Klepa AI
Установить
Часть 3 · Работа по плану · Урок 6

Генератор или агент: что выгоднее генерировать по правилам

Часть кода пишется по правилам: DTO, миграции, клиенты API, типы. Агент сделает это каждый раз немного по-разному и за токены. Генератор сделает одинаково, бесплатно и мгновенно. Разберём, где граница.

~45 минут 3 интерактива 3 песочницы с проверками Примеры: JS · TS · PHP · Python

После урока вы сможете

  • объяснить, что такое детерминированный генератор и почему это важно;
  • написать простой генератор «спецификация плюс шаблон равно код»;
  • решать, что отдать генератору, что агенту, а что делать вместе;
  • защитить сгенерированный код от ручных правок и проверить генерацию в CI.
1 Аналогия

Формочка для печенья и лепка руками

Представьте

Сто одинаковых печений проще выдавить формочкой, чем лепить руками. Руками лепят торт на заказ: он один, и он особенный.

Формочка (генератор)

Руками (агент)

Партия 1: слева все одинаковые, справа каждая своя.
Формочка= шаблон генератора
Тесто= спецификация: JSON, OpenAPI, схема
Печенье= сгенерированный код
Кондитер с тортом на заказ= агент на нестандартной логике
«Все печенья одинаковые?»= проверка в CI: генерация не даёт диффа

Главная мысль: то, что делается по правилам, делайте правилами. Агента оставьте там, где нужны понимание и решения.

2 Объяснение

От простого к сложному

Уровень 1 · Суть

Шаблон плюс данные равно код

Детерминированный (предсказуемый) значит: тот же вход даёт тот же выход, байт в байт. Модель из урока 1 так не умеет: выбор токенов случайный. Обычная функция умеет.

Генератор кода — это функция. На вход — описание (спецификация). Внутри — шаблон. На выход — текст программы.

const entity = { name: "Order", fields: [{ name: "id", type: "number" }, { name: "email", type: "string" }] };

const ts = `export interface ${entity.name} {
${entity.fields.map(f => `  ${f.name}: ${f.type};`).join("\n")}
}`;
// export interface Order {
//   id: number;
//   email: string;
// }
Что важноЗапустите эту функцию сто раз — получите сто одинаковых файлов. Попросите агента сто раз — получите сто немного разных.
Уровень 2 · Детали

Где живут генераторы и где граница с агентом

Вы почти наверняка уже пользовались генераторами:

  • OpenAPI (описание API) превращается в клиентов, DTO и заготовки контроллеров;
  • JSON Schema превращается в типы TypeScript, Python, PHP;
  • описание сущности превращается в Entity, миграцию, репозиторий (в Symfony это make:entity и doctrine:migrations:diff);
  • protobuf превращается в клиентов и серверы gRPC.

Генератору отдавайте: CRUD, DTO, контракты API, маппинги, типы, миграции. Всё, что повторяется и следует правилу.

Агенту оставляйте: нестандартную бизнес-логику, рефакторинг, разбор багов, склейку систем. Всё, где нужно понять смысл.

Гибрид работает лучше всего. Агент пишет или правит спецификацию (это короткий понятный файл). Генератор делает из неё код. Агент дописывает бизнес-логику в оставленные для неё места.

Мини-генератор SQL на четырёх языках — шаблонные строки

Один и тот же генератор: JSON с описанием сущности на входе, CREATE TABLE на выходе.

import { readFileSync, writeFileSync } from "node:fs";

const SQL = { int: "INTEGER", string: "VARCHAR", text: "TEXT", bool: "BOOLEAN", decimal: "NUMERIC(12, 2)", datetime: "TIMESTAMP" };
const snake = s => s.replace(/[A-Z]/g, c => "_" + c.toLowerCase());

function column(f) {
  let type = SQL[f.type];
  if (f.type === "string") type += `(${f.length ?? 255})`;
  const parts = [snake(f.name), type];
  if (f.primary) parts.push("PRIMARY KEY");
  else if (!f.nullable) parts.push("NOT NULL");
  if (f.unique) parts.push("UNIQUE");
  return "  " + parts.join(" ");
}

const e = JSON.parse(readFileSync("entities/order.json", "utf8"));
const sql = `-- GENERATED from entities/order.json. Do not edit.\nCREATE TABLE ${e.table} (\n${e.fields.map(column).join(",\n")}\n);\n`;
writeFileSync(`migrations/${e.table}.sql`, sql);
import { readFileSync, writeFileSync } from "node:fs";

type FieldType = "int" | "string" | "text" | "bool" | "decimal" | "datetime";
interface Field { name: string; type: FieldType; length?: number; nullable?: boolean; unique?: boolean; primary?: boolean }
interface Entity { name: string; table: string; fields: Field[] }

const SQL: Record<FieldType, string> = { int: "INTEGER", string: "VARCHAR", text: "TEXT", bool: "BOOLEAN", decimal: "NUMERIC(12, 2)", datetime: "TIMESTAMP" };
const snake = (s: string): string => s.replace(/[A-Z]/g, c => "_" + c.toLowerCase());

function column(f: Field): string {
  const type = f.type === "string" ? `VARCHAR(${f.length ?? 255})` : SQL[f.type];
  const parts = [snake(f.name), type];
  if (f.primary) parts.push("PRIMARY KEY");
  else if (!f.nullable) parts.push("NOT NULL");
  if (f.unique) parts.push("UNIQUE");
  return "  " + parts.join(" ");
}

const e: Entity = JSON.parse(readFileSync("entities/order.json", "utf8"));
writeFileSync(`migrations/${e.table}.sql`, `CREATE TABLE ${e.table} (\n${e.fields.map(column).join(",\n")}\n);\n`);
<?php
// bin/generate-sql.php entities/order.json
$sqlTypes = ['int' => 'INTEGER', 'text' => 'TEXT', 'bool' => 'BOOLEAN', 'decimal' => 'NUMERIC(12, 2)', 'datetime' => 'TIMESTAMP'];
$snake = fn (string $s): string => strtolower(preg_replace('/[A-Z]/', '_$0', $s));

$e = json_decode(file_get_contents($argv[1]), true, flags: JSON_THROW_ON_ERROR);
$columns = [];
foreach ($e['fields'] as $f) {
    $type = $f['type'] === 'string' ? sprintf('VARCHAR(%d)', $f['length'] ?? 255) : $sqlTypes[$f['type']];
    $parts = [$snake($f['name']), $type];
    if ($f['primary'] ?? false) {
        $parts[] = 'PRIMARY KEY';
    } elseif (!($f['nullable'] ?? false)) {
        $parts[] = 'NOT NULL';
    }
    if ($f['unique'] ?? false) {
        $parts[] = 'UNIQUE';
    }
    $columns[] = '  ' . implode(' ', $parts);
}
file_put_contents("migrations/{$e['table']}.sql", "CREATE TABLE {$e['table']} (\n" . implode(",\n", $columns) . "\n);\n");
import json
import re
import sys
from pathlib import Path

SQL = {"int": "INTEGER", "text": "TEXT", "bool": "BOOLEAN", "decimal": "NUMERIC(12, 2)", "datetime": "TIMESTAMP"}

def snake(s: str) -> str:
    return re.sub(r"[A-Z]", lambda m: "_" + m.group().lower(), s)

def column(f: dict) -> str:
    t = f"VARCHAR({f.get('length', 255)})" if f["type"] == "string" else SQL[f["type"]]
    parts = [snake(f["name"]), t]
    if f.get("primary"):
        parts.append("PRIMARY KEY")
    elif not f.get("nullable"):
        parts.append("NOT NULL")
    if f.get("unique"):
        parts.append("UNIQUE")
    return "  " + " ".join(parts)

e = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
body = ",\n".join(column(f) for f in e["fields"])
Path(f"migrations/{e['table']}.sql").write_text(f"CREATE TABLE {e['table']} (\n{body}\n);\n", encoding="utf-8")
Готовый генератор по OpenAPI — пример команды

Писать свой генератор нужно не всегда. Для OpenAPI есть готовый openapi-generator с шаблонами под десятки языков. Ниже пример: фрагмент спецификации и команда генерации клиента.

openapi: 3.0.3
info: { title: Shopik API, version: 1.0.0 }
paths:
  /orders/{id}:
    get:
      operationId: getOrder
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: Заказ
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Order" }
components:
  schemas:
    Order:
      type: object
      required: [id, email, totalKopecks]
      properties:
        id: { type: integer }
        email: { type: string, format: email }
        totalKopecks: { type: integer }
        comment: { type: string, nullable: true }
# пример: TypeScript-клиент для фронтенда
npx @openapitools/openapi-generator-cli generate -i openapi.yaml -g typescript-fetch -o src/api

# пример: PHP-клиент для другого сервиса
npx @openapitools/openapi-generator-cli generate -i openapi.yaml -g php -o generated/shopik-client
Уровень 3 · Усложнение

Защита сгенерированного кода и экономика

Сгенерированный код не правят руками. Следующая генерация перезапишет правку, и она тихо пропадёт. Правят спецификацию или шаблон. Как это закрепить:

  • в начале файла метка GENERATED и откуда он получен;
  • сгенерированные файлы лежат в отдельной папке (например, generated/);
  • в правилах агента: «файлы с меткой GENERATED не правь, правь спецификацию и запусти генератор»;
  • хук (урок 4) запрещает агенту запись в такие файлы;
  • CI запускает генерацию и проверяет, что дифф пустой: make generate && git diff --exit-code.

Где детерминированность ломается. Дата генерации в шапке файла, случайные id, порядок ключей словаря, разные версии генератора у разных разработчиков. Каждое из этого даёт дифф без изменений спецификации, и CI-проверка становится бесполезной. Версию генератора фиксируйте, дату не пишите, поля сортируйте стабильно.

Экономика40 DTO по спецификации партнёра. Агент: десятки тысяч токенов, полчаса ревью, и при обновлении спецификации всё заново, с новыми мелкими отличиями. Генератор: день на настройку один раз, потом секунды и ноль ревью шаблонного кода. Чем чаще повторяется задача, тем выгоднее генератор.
Проверка генерации в CI — Makefile и GitHub Actions
# Makefile
generate:
	node bin/generate-sql.mjs entities/*.json
	npx @openapitools/openapi-generator-cli generate -i openapi.yaml -g typescript-fetch -o src/api

check-generated: generate
	git diff --exit-code -- migrations/ src/api/ || (echo "Сгенерированный код устарел: запустите make generate" && exit 1)
# .github/workflows/ci.yml (фрагмент)
- name: Сгенерированный код совпадает со спецификацией
  run: make check-generated
3 Интерактив

Генератор в деле

Живой генератор: одна спецификация, четыре языка

интерактив 1 из 3

Меняйте JSON слева. Код справа пересобирается сразу. Типы: int, string, text, bool, decimal, datetime. Флаги: primary, nullable, unique, length. Попробуйте добавить поле, сломать JSON или указать неизвестный тип.

    
              

    Генератор, агент или вместе?

    интерактив 2 из 3

    Для каждой задачи выберите исполнителя. После выбора появится объяснение.

    Калькулятор: оцените свою задачу — четыре ползунка

    Два прогона: агент и генератор

    интерактив 3 из 3

    Одна и та же спецификация, два запуска подряд. Смотрим, совпадёт ли результат байт в байт.

    Прогон 1

    Прогон 2

    Шаг 0
    4 Практика

    Песочницы с автопроверкой

    Здесь вы сами пишете генераторы. Тесты сравнивают результат со строкой байт в байт, как это делает CI.

    Песочница 1. Генератор TypeScript-интерфейса

    JS

    Напишите generateTsInterface(entity). Типы: int → number, decimal → string (деньги не храним во float), string и text → string, bool → boolean, datetime → string. Если nullable — добавить | null. Отступ два пробела, в конце перевод строки. Сущность-пример уже лежит в ORDER.

    Песочница 2. Генератор CREATE TABLE

    JS

    Напишите generateSqlTable(entity) и помощник toSnake(name). Правила:

    • имя колонки — snake_case: totalKopecks → total_kopecks;
    • типы: int → INTEGER, string → VARCHAR(length) (по умолчанию 255), text → TEXT, bool → BOOLEAN, decimal → NUMERIC(12, 2), datetime → TIMESTAMP;
    • primary → PRIMARY KEY (без NOT NULL); иначе не nullable → NOT NULL; unique → UNIQUE в конце;
    • колонки через ,\n, отступ два пробела, в конце ); и перевод строки.

    Песочница 3. Проверка детерминированности

    JS

    Это мини-версия CI-проверки. Напишите checkDeterminism(generator, input, runs = 3): запустить генератор runs раз и сравнить каждый результат с первым. Всё совпало — { ok: true }. Иначе — { ok: false, line: N }, где N — номер первой отличающейся строки (с единицы) в первом несовпавшем прогоне.

    Проверьте себя

    Что значит «детерминированный генератор»?

    Поэтому результат можно проверять в CI сравнением: если дифф не пустой, значит спецификация и код разошлись.

    Какую задачу выгоднее отдать генератору, а не агенту?

    Повторяемо, по правилу, обновляется регулярно. Остальные две задачи требуют понимания: это работа агента.

    Агент поправил руками файл с меткой GENERATED. Что произойдёт?

    Поэтому в правилах агента пишут «не правь GENERATED», а хук может запретить такую запись.

    Почему не стоит писать дату генерации в шапку файла?

    Вы сами поймали это в песочнице 3. То же с случайными id и нестабильным порядком полей.

    Задание на своём проекте — 45 минут
    1. Найдите в проекте код, который повторяется по шаблону хотя бы 5 раз: DTO, маппинги, типы ответов API, однотипные контроллеры.
    2. Опишите один такой случай как JSON или YAML: только данные, без логики.
    3. Напишите генератор на своём языке по образцу из объяснения. Проверьте, что он воспроизводит существующий файл байт в байт или с понятными отличиями.
    4. Добавьте в шапку метку GENERATED, а в правила агента строку «такие файлы не правь, правь спецификацию».
    5. Добавьте в CI шаг «генерация и git diff --exit-code».
    5 Чек-лист

    Отметьте, что усвоили

    • Могу объяснить, что такое детерминированность и почему модель ею не обладает.
    • Понимаю формулу «спецификация плюс шаблон равно код».
    • Знаю готовые генераторы: OpenAPI, JSON Schema, make:entity, миграции.
    • Различаю задачи для генератора, для агента и для гибрида.
    • Знаю, как защитить сгенерированный код: метка, папка, правило агента, хук, CI.
    • Знаю, что ломает детерминированность: даты, случайные id, порядок полей, версии.
    • Решил песочницы: TypeScript-интерфейс, CREATE TABLE и проверку детерминированности.
    Урок пройденЧасть работы теперь делает генератор без единого токена. Дальше — как ловить ошибки агента автоматически, ещё до ревью.