Преамбула

Есть у меня один пет-проект, NutriLog, демонстрация интеграции веб-приложения и кастомного GPT-чата. Частью этого проекта является двуязычный (en, ru) SSR-сайтик на десяток страниц на базе шаблонизатора Mustache - чисто для SEO. Две недели назад я вернулся из отпуска и подумал, а не автоматизировать ли мне переводы страничек с одного языка на другой? Переводы я делал вручную, через ChatGPT Web UI - модель показывала себя прекрасно. Переводила только контент и правильно внедряла его в код шаблона. Плюс я решил убить и второго "зайца" — сделать проект с использованием лишь одной своей библиотеки "@teqfw/di". Вытащить наружу и показать в явном виде всё то "волшебство", что спрятано "под капотом" в моём персональном фреймворке TeqFW (не волнуйтесь, этот пост не о нём :). Таким образом, в перспективе нарисовалась разработка простой мультиязычной файловой CMS с автоматизацией переводов через LLM с основного языка на поддерживаемые.

Посоветовался с "Игорь Ивановичем" (ИИ) и пришёл к выводу, что в качестве шаблонизатора лучше использовать Nunjucks, а в качестве веб-серверов — express или fastify. После чего, не спеша, в довольно спокойном темпе, при помощи LLM за две недели создал и развернул демонстрационный сайт на движке, который я, не особо фантазируя, назвал TeqCMS. В этом посте я делюсь своими выводами, сделанными по результатам этого двухнедельного спринта.

Разделение труда: идея и реализация

Я не считаю, что Модель (LLM) способна к творчеству. У неё нет собственных целей или намерений. Она начинает действовать только в ответ на внешнее побуждение. При этом Модель — не просто инструмент. В отличие от отвертки или компилятора, она непредсказуема, вариативна и способна предлагать альтернативные решения.

Я воспринимаю Модель одновременно и как инструмент, и как партнёра. В нашем взаимодействии роли разделены: я формулирую цели и направления, Модель помогает исследовать возможные пути. Она предлагает варианты, уточняет, показывает, чего я не заметил. Финальные решения — всегда за мной.

Но суть глубже: я не просто использую Модель, чтобы получить результат. Работая с Моделью, я постепенно переформатирую своё мышление. Уточняю термины. Пересматриваю структуру. Переосмысляю границы архитектуры. Это не только разработка приложения — это и разработка разработчика. Самопрограммирование при помощи ИИ.

Текст как язык общения с LLM

Само название LLM (Large Language Model) говорит о том, что основой взаимодействия с Моделью является язык. Чтобы Модель меня поняла, я должен выразить свои намерения, наблюдения и сомнения в текстовой форме. Даже если я передаю изображение или звук, Модель сначала интерпретирует их как текст: она распознаёт, описывает и структурирует восприятие в языковом представлении.

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

В GPT Plus я пользуюсь функцией проектов — это механизм, который позволяет связать диалоги общей инструкцией (системным промптом) и набором файлов. Системная инструкция задаёт стиль и намерение, а файлы проекта формируют расширенный контекст, доступный модели в каждом запросе. Это позволяет вести осмысленную и непрерывную разработку — не от запроса к запросу, а от замысла к реализации.

Проект в GPT объединяет различные диалоги
Проект в GPT объединяет различные диалоги

Перед кодом — диалог

Вот сравнительная таблица максимального контекста для популярных моделей — она была составлена GPT-чатом по моей просьбе:

Модель

Макс. контекст (токенов)

Макс. вход (токенов)

Макс. выход (токенов)

GPT-4o (OpenAI)

128 000

~126 000

~4 000

Claude 3 Opus

200 000

~195 000

~4 000

Gemini 1.5 Pro

1 000 000+

~980 000+

~10 000

Claude 3 Sonnet

200 000

~195 000

~4 000

Обратите внимание на вот это: входной контекст у моделей на порядок больше выходного.
Даже если мы можем подать в модель 100 000 токенов кода, она сможет сгенерировать лишь 4 000 — и это создаёт архитектурные ограничения на этапе проектирования.

Прежде чем поручать Модели генерацию кода, необходимо согласовать с ней не только цели и бизнес-задачи, но и структурное разбиение проекта — так, чтобы каждый логический блок укладывался в объём, удобный для обработки. Практически это означает: не более 100K токенов на один пакет.

Для ориентира — текущие размеры пакетов в моём проекте:

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

Результатом становятся один или несколько Markdown-документов, которые затем используются как часть контекста при генерации кода — через Web-интерфейс или API. Эти документы — не вспомогательные, а архитектурно-значимые артефакты, обеспечивающие воспроизводимость, масштабируемость и когнитивную согласованность проекта. Именно эти документы становятся кандидатами в README.md для npm-пакетов.

Разработка в изоляции

В какой-то момент становится понятно, что архитектурный каркас проекта достаточно стабилен — и можно переходить к реализации. Мы начинаем наполнять структуру кодом, при этом сохраняя возможность работы с фрагментами системы независимо от целого.

Внедрение зависимостей

Именно на этом этапе становится критически важным использовать технику позднего связывания и внедрения зависимостей (DI). Каждый файл, генерируемый Моделью, должен укладываться в 4K токенов, что означает: проект фактически представляет собой граф es6-модулей с явными связями.

В своих проектах я использую библиотеку @teqfw/di, в которой зависимости между модулями указываются через параметры конструктора. Пример:

export default class Fl32_Tmpl_Back_Service_Load {
    /**
     * @param {Fl32_Tmpl_Back_Logger} logger - Logger for exceptions
     * @param {Fl32_Tmpl_Back_Act_File_Find} actFind - Action to find files
     * @param {Fl32_Tmpl_Back_Act_File_Load} actLoad - Action to load files
     */
    constructor(
        {
            Fl32_Tmpl_Back_Logger$: logger,
            Fl32_Tmpl_Back_Act_File_Find$: actFind,
            Fl32_Tmpl_Back_Act_File_Load$: actLoad,
        }
    ) {}
}

Такой подход позволяет Модели точно понимать, в каком окружении работает код, и какие зависимости необходимо учитывать. Пока их количество не превышает 20–25, весь набор можно подать модели в одном входном контексте.

Интерфейсы как контракты

Когда разрабатываются модули нижнего уровня, реализации многих зависимостей ещё не существует — но их интерфейсы уже можно описать. Это позволяет продолжать генерацию кода, даже не имея полного представления о деталях.

Пример: CMS-пакет использует объект data для рендеринга шаблона, но сам шаблон, набор переменных и шаблонизатор ( Nunjucks, Mustache и т. д.) могут подключаться позже. Тем не менее, мы определяем интерфейс Fl32_Cms_Back_Api_Adapter:

/**
 * @interface
 */
export default class Fl32_Cms_Back_Api_Adapter {
    /**
     * @param {object} args - Parameters object.
     * @param {import('node:http').IncomingMessage | import('node:http2').Http2ServerRequest} args.req - The HTTP(S) request object.
     * @returns {Promise<Fl32_Cms_Back_Api_Adapter.RenderData>} Rendering context for the template engine.
     * @throws {Error} If the method is not implemented by the application.
     */
    async getRenderData({req}) {
        throw new Error('Method not implemented');
    }
}

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

Юнит-тестирование и моки

Поскольку работа идёт на уровне изолированных модулей, необходимо проверять каждую часть без зависимости от остального проекта. DI делает это возможным — окружение можно замещать мок-объектами, включая нативные Node.js-модули.

Пример: зависимость от node:fs в боевом коде:

export default class Fl32_Tmpl_Back_Act_File_Find {
    /**
     * @param {typeof import('node:fs')} fs
     */
    constructor(
        {
            'node:fs': fs,
        }
    ) {
      const {existsSync} = fs;
      ...
      if (plain.startsWith(root) && existsSync(plain)) {...}
    }
}

А вот как выглядит мок в юнит-тесте:

const checkedPaths = [
    '/abs/app/root/tmpl/web/en-US/welcome.html',
];
container.register('node:fs', {
    existsSync: (p) => checkedPaths.includes(p),
});

Таким образом, Модель может создавать и тестировать код автономно, файл за файлом — без знания всех реализаций, без интеграции в полноценное приложение. Это критически важно в начале разработки, когда большая часть системы ещё отсутствует.

Юнит-тесты здесь играют особую роль: они становятся интерфейсом обратной связи между Человеком и Моделью. Они позволяют Человеку убедиться, что Модель правильно интерпретировала задачу, а сгенерированный код выполняет нужные действия. По мере роста системы важность юнит-тестов снижается, уступая место интеграционным — но на ранних этапах они незаменимы.

Код как носитель смысла

При генерации исходников особое внимание стоит уделить их документированию. Я использую JSDoc-аннотации не только для того, чтобы IDE лучше ориентировались в структуре проекта и помогали человеку понимать код, но прежде всего — для того, чтобы зафиксировать когнитивный контекст, в котором этот код был сгенерирован Моделью.

Такой подход делает возможным рефакторинг: Модель (или другой разработчик) может, сопоставив документацию с новым контекстом проекта, определить, насколько текущая реализация соответствует изменившимся условиям. JSDoc становится якорем архитектурных ожиданий.

Пример — интерфейс CMS-адаптера:

/**
 * Application adapter interface for the CMS plugin.
 *
 * This adapter connects the plugin to the application-specific logic.
 * It allows the application to analyze the incoming HTTP request and
 * return the data and rendering options required to process the page
 * using the selected template engine.
 *
 * The plugin interacts with this interface only, without knowledge of the implementation.
 *
 * @interface
 */
export default class Fl32_Cms_Back_Api_Adapter {
    /* eslint-disable no-unused-vars */
    /**
     * Analyze the incoming request and provide data and rendering options for the template engine.
     *
     * This method is called on every HTTP request handled by the CMS plugin.
     * The application must extract context-specific information (e.g., locale, route data, user agent)
     * and prepare a structured result that will be passed to the template renderer.
     *
     * @param {object} args - Parameters object.
     * @param {import('node:http').IncomingMessage | import('node:http2').Http2ServerRequest} args.req - The HTTP(S) request object.
     * @returns {Promise<Fl32_Cms_Back_Api_Adapter.RenderData>} Rendering context for the template engine.
     * @throws {Error} If the method is not implemented by the application.
     */
    async getRenderData({req}) {
        throw new Error('Method not implemented');
    }
}

/**
 * @typedef {object} Fl32_Cms_Back_Api_Adapter.RenderData
 * @property {object} data - Variables used in the template (e.g., page metadata, content blocks, user info).
 * @property {object} options - Template engine options (e.g., layout, partials, flags).
 * @property {Fl32_Tmpl_Back_Dto_Target.Dto} target - Render target metadata including template path, type, and localization context.
 */

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

Документированный код становится не только средством исполнения, но и носителем замысла — мостом между прошлым и будущим состоянием проекта.

Заключение

На основании собственного опыта могу с уверенностью сказать: применение LLM в разработке обосновано и действительно эффективно. За две недели, используя ChatGPT Plus и DeepSeek API, я создал мультиязычную файловую CMS с автоматизацией переводов. Этот проект работает прямо сейчас — https://cms.teqfw.com, а его код открыт: https://github.com/flancer32/teq-cms-demo

LLM-first — это не про игры с промптами. Это про структуру, архитектуру и согласование позиций между Человеком и Моделью. Я уверен, что смогу управлять рефакторингом своей CMS — и что Модель будет в этом процессе на моей стороне, а не против меня.

Может ли вайбкодинг привести к такому же результату?
Если да — покажите мне этот результат.

Talk is cheap. Show me the code.
— Linus Torvalds

Комментарии (3)


  1. supercat1337
    01.06.2025 13:05

    Спасибо за статью. Интересно! А что если скармливать ИИ не сам js-код, а d.ts файлы? Мне кажется, что количество токенов можно уменьшить в разы. d.ts файлы они и код описывают, и комментарии содержат. Например, DeepSeek выдавал вполне сносные выводы по скормленным d.ts файлам.


    1. flancer Автор
      01.06.2025 13:05

      Я не использую TypeScript в разработке, я заменяю его JSDoc-аннотациями. Да, вы правы, что количество токенов можно уменьшить, если использовать описание интерфейса вместо его реализации. При достаточно большом объёме проекта это может быть отличной опцией - отделять описания интерфейсов. Можно даже использовать TS-нотацию в них.


      1. supercat1337
        01.06.2025 13:05

        Так я тоже пишу на JS с комментариями на jsdoc. Только в дополнение с помощью typescript я генерирую d.ts.