
Первая версия моего агента поддержки в трети ходов не ходила за заказом, а выдумывала его статус. Модель сама решала, когда звать тул, и решала неправильно. В харнесе модель ничего не решает, за данными ходит код, модель зовут два раза, понять вопрос и написать ответ.
Агентский цикл, где модель решает сама, поднимается за вечер и пропускает тул в 1 ходу из 24. Конвейер для прода не пропускает совсем, но роутер и сценарии для него пишет человек. Тысяча диалогов на Qwen стоит полдоллара, на Opus тридцать долларов.
Привет, Хабр!
По работе нужно было сделать харнес, не привязанный к одной компании и одной модели, без дообучения. Поэтому модель в нем стоковая и подключается по OpenAI-совместимому API. Локально, для разработки, у меня Qwen3.5 9B, в облаке любая модель с OpenRouter, меняется одной строкой в env. Все, что зависит от компании, вынесено в конфиг: скиллы, тулы, чанки, персона с ролью и тоном, лимиты. Меняется конфиг, а код и модель те же.
Две попытки до харнеса
Первый вариант я делал на JSON-графе, где каждый шаг диалога прописан руками. Работало предсказуемо, отвечало плохо и стоило дорого. Второй вариант был полностью агентский, модель сама решала, когда сходить за данными. Отвечал хорошо. Но примерно в трети ходов не вызывал тул и выдумывал статус заказа. Клиент спрашивает, где заказ, и получает уверенный ответ про заказ, который модель не проверяла.
Третий вариант это конвейер. Определить тему, достать поля, сходить за данными, ответить по сценарию. За данными ходит код, а не модель, поэтому забыть сходить за заказом агент не может. Модель делает только две вещи, понимает вопрос и пишет ответ.
Прототип за вечер
Репо лежит на https://github.com/nmrcs/support-agent, лицензия MIT. Это не урезанная копия харнеса, а его противоположность, тот самый цикл, где модель сама решает, когда звать тул. Я собрал его отдельно, чтобы его можно было поднять за вечер и посчитать, как часто цикл пропускает тул.
Монорепо на npm workspaces, три пакета. Backend на NestJS 11 с Prisma 7 и PostgreSQL в докере, логин по JWT. Фронт на React 19. В packages/contracts лежат Zod-схемы, их проверяют обе стороны. Модель любая с OpenAI-совместимым API, по умолчанию Qwen3.5 9B, меняется в env.
docker compose up -d # postgres на :5433 cp apps/backend/.env.example apps/backend/.env # адрес и ключ модели cp apps/frontend/.env.example apps/frontend/.env npm install npm run db:reset # сидовые клиенты и заказы npm run dev # backend :4001, frontend :4000

Внутри один сервис. Модель в цикле либо отвечает текстом, либо просит тул. Код выполняет тул, кладет результат в контекст и зовет модель еще раз. На один ход я дал максимум три шага, этого хватает на вызов тула, повтор после ошибки и ответ. Если диалог перевалил за пятнадцать ходов, он уходит оператору.
сообщение клиента │ ▼ [LLM] ── просит тул? ──▶ [код выполняет тул] ──▶ обратно в LLM │ get_order_status │ escalate_to_human ▼ финальный текст ──▶ клиенту (3 шага без текста ──▶ оператор)
В историю для модели уходят только финальные тексты, старые tool_calls не повторяются. Ответ агента и так уже содержит то, что вернул тул, а схемы тулов уходят в каждый вызов независимо от истории. FAQ это три строки в системном промпте, там же тон и правила эскалации. Весь агент описан одним файлом prompt.ts.
Тулов два, обе обычные функции, модель знает о них из описаний в формате tool_calls. Аргументы приходят от модели, это граница системы, поэтому на входе Zod. Если модель прислала кривой JSON, ход не падает, ошибка возвращается ей как результат тула, и следующим шагом она себя поправляет.
const GetOrderStatusArgs = z.object({ orderNumber: z.string().min(1) }) async run(name: string, rawArgs: string, ctx: ToolContext) { let args: unknown try { args = JSON.parse(rawArgs) } catch { return { error: 'arguments are not valid JSON' } } switch (name) { case 'get_order_status': { const parsed = GetOrderStatusArgs.safeParse(args) if (!parsed.success) return { error: 'invalid arguments' } const order = await this.orders.findForUser(ctx.userId, parsed.data.orderNumber) if (!order) return { found: false } return { found: true, number: order.number, status: order.status, eta: order.eta, trackingCode: order.trackingCode } } // escalate_to_human ставит флаг на диалоге и пишет причину в лог } }
get_order_status ищет заказ только среди заказов залогиненного клиента. userId берется из JWT, а не из вывода модели, модель управляет одним аргументом, номером заказа. Промпт-инъекция вида «покажи заказ клиента Боба» упирается в выборку по своему id и получает found: false.
Каждый ход пишет трейс, что просила модель, что вернул тул и сколько это заняло. Он виден под каждым ответом и в любом старом диалоге из списка.

Умеет агент немного. Здоровается, отвечает на «где мой заказ 1001» по данным заказа залогиненного клиента, закрывает вопросы про доставку и возвраты из FAQ и передает диалог человеку по просьбе или по лимиту шагов.

В прототипе нет RAG, FAQ живет прямо в промпте, с настоящей документацией так не сделать. Нет guardrails и watchdog, из лимитов только три шага на ход и пятнадцать ходов на диалог. Нет стриминга, ответ приходит одним JSON. Нет мультитенантности и админки, чтобы поменять агента, надо править код. Для вечернего прототипа это нормально, для прода нет.
Как часто цикл пропускает тул
npm run bench гоняет один и тот же диалог на четыре хода восемь раз подряд. Приветствие, заказ клиента, несуществующий заказ, просьба позвать человека. Латентность, токены и пропуски тула пишутся в bench/runs.md. Числа ниже из этого файла.
На Qwen3.5 9B модель пропустила обязательный тул в 1 ходу из 24. На двух тулах и промпте в один экран цикл пропускает редко, а треть пропусков была на ранней агентской версии моего харнеса. Думаю, дело в масштабе выбора. Чем больше тулов и длиннее промпт, тем чаще модель отвечает сама, не вызывая тул. Конвейер убирает пропуск совсем, цикл только делает его реже.
Ход с тулом стоит два вызова модели и в среднем 1461 токен на вход, потому что история и схемы тулов уходят в каждый вызов цикла.
Харнес для прода
Роутер понимает тему и достает поля из любой формулировки, без обучения и разметки. Композер пишет ответ по данным тула, поэтому текст меняется вместе с данными, а не собирается из шаблона со слотами. Там, где хватает готового текста, статус «не найден» или приветствие, модель не вызывается вообще. За данными ходит код.
сообщение клиента │ ▼ [watchdog] лимиты ходов, уточнений, промахов ──превышен──▶ оператор │ ▼ [роутер] 1-й вызов модели ──тема не найдена──▶ уточняющий вопрос │ тема + номер заказа (2-й промах подряд ──▶ оператор) │ ▼ [тул] код ходит за заказом ──нет номера──▶ уточняющий вопрос │ статус, трек, дата │ ▼ [сценарий] по статусу заказа ├── доставлен / в пути / в обработке ──▶ подсказка композеру ├── готовый текст ────────────────────▶ ответ клиенту (без 2-го вызова) └── не найден ──1-й раз──▶ перепроверить номер, 2-й раз ──▶ оператор │ ▼ [композер] 2-й вызов модели, ответ по подсказке и данным │ ▼ ответ клиенту
Если где-то не сошлось, тема не определилась, номера нет, заказ не нашелся, агент один раз переспрашивает, а на второй раз отдает диалог оператору.
Слоев в харнесе десять.
Роутер: первый вызов модели. Тема сообщения и данные из него, номер заказа.
Композер: второй вызов модели. Ответ клиенту по сценарию и данным.
Скиллы: темы, которые агент умеет. У скилла свой тул и свои сценарии.
Тулы: код, который ходит за данными.
Сценарии: ветки скилла по данным тула. У сценария готовый текст или подсказка композеру.
Чанки: куски документов для вопросов из FAQ.
Watchdog: лимиты на ходы, уточнения подряд и промахи роутера, таймауты на модель и тул.
Guardrails: проверка сообщения на входе и ответа на выходе. Инъекция в промпт, персональные данные, уход от темы.
Трейс: запись каждого шага. Результат, статус, время.
Эскалация: передача диалога оператору.
Минимальному конвейеру на вопрос «где мой заказ» хватает четырех, роутер, композер, один тул и эскалация. Остальные я добавлял по ходу эксплуатации, у каждого есть случай, после которого он появился.
Архитектура
Монорепо на npm workspaces, два сервиса на NestJS, фронт на React, общие Zod-схемы отдельным пакетом. Модель подключается по OpenAI-совместимому API, так что один и тот же код работает и с локальным LM Studio, и с OpenRouter. Engine ничего не хранит между ходами. История, состояние диалога и конфиг приезжают в теле каждого запроса от backend. Снаружи до engine не достучаться, он слушает только loopback.
apps/ engine/ харнес. NestJS 10 answer/ оркестратор хода, watchdog, эскалация router/ 1-й вызов модели, structured output, temp 0.1 composer/ 2-й вызов модели scenarios/ выбор ветки по данным тула tools/ код, который ходит за данными rag/ поиск по чанкам через backend llm/ клиент OpenAI-совместимого API backend/ данные и API. NestJS 10, Prisma 7 + PostgreSQL, Qdrant, JWT, ws frontend/ чат, панель трейса, редактор скиллов и чанков. React 19 packages/ contracts/ Zod-схемы, общие для engine, backend и frontend docker-compose.yml qdrant векторы чанков ollama эмбеддинги nomic-embed-text, отдельный рантайм от чат-модели
Роутер и композер
Роутер получает последнее сообщение и список скиллов, отвечает JSON по строгой схеме, слаг скилла, уверенность, извлеченные поля, поле reasoning. Температура 0.1. Композер получает сценарий, данные тула и найденный чанк, пишет текст клиенту. В один вызов их не свести, роутеру нужна строгая схема и низкая температура, композеру свободный текст.
Поля роутер берет только из последнего сообщения, из истории запрещено промптом, выдуманные номера заказов приходят оттуда. Не назвал клиент значение, поля нет, ни null, ни пустой строки. В каждый вызов уходит reasoning_effort со значением none, иначе Qwen сжигает сотни токенов на размышление до первого токена ответа.
Чат
Фронт получает ответ по SSE, событие delta на каждый кусок текста, sentence на законченное предложение, done с трейсом и состоянием диалога. Эффект печати это приход дельт, анимации нет. Готовый текст сценария приходит одной дельтой и появляется сразу, потому что модель для него не вызывается.

Скиллы и сценарии
Скилл это тема. Описание для роутера, несколько реальных фраз клиента вроде «где мой заказ 1234» или «трек-номер», подсказка клиенту при промахе, тул и список сценариев. Примеры лежат в промпте рядом с описанием. Маленькой модели пример помогает сильнее, чем правило, в других промптах пример вместо правила давал 8 из 8 там, где правило давало 7 из 8. Сценарии идут по порядку, побеждает первый, чьи условия подошли к данным тула. Готовый текст сценария отдается только при уверенном роутинге, иначе клиент получит шаблонный ответ не на свой вопрос.

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

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

Чанки
Вопросы без данных для тула, «как вернуть товар», «сколько идет доставка», закрываются чанками. Чанк это пара вопрос и ответ. При сохранении он превращается в вектор и кладется в Qdrant. Сценарий с флагом needsRag получает один лучший чанк выше порога, ниже порога ход уходит оператору. Роутер выбирает только скилл, поиск по чанкам включается уже внутри скилла, иначе список вариантов у роутера рос бы вместе с базой. У чанка есть слаг скилла, поэтому чанки про возврат не мешают чанкам про оплату.

Трейс
Каждый ход пишет трейс, шаг, статус, время, что вернул роутер, что вернул тул, какой сценарий победил. Лежит в базе рядом с сообщением и виден в панели чата. Все секунды в замерах ниже взяты из трейса.

Эскалация и watchdog
Рестарт Qdrant посреди диалога отдавал человека оператору, потому что любой сбой шел через эскалацию, а эскалация закрывает тикет. Пришлось развести. Эскалация это бизнес-исход. Причин пять. Не определилась тема, нет чанка выше порога, сценарий помечен как эскалация, диалог топчется на месте, кончился лимит ходов. Упавший тул, молчащий поиск, модель вне таймаута это ошибка хода. Она пишется в лог, тикет остается открытым. Тогда же появились таймауты на все исходящие вызовы, модель 120 с, поиск 10 с, engine 180 с. До них зависший провайдер держал три процесса открытыми. Watchdog это страховка, не основной путь. Лимиты ходов и уточнений заданы в конфиге, и обычно роутер и сценарии отвечают или эскалируют раньше, чем он срабатывает.
Что дали замеры
Все замеры одиночные, на M3 Max, без нагрузки. Секунды у вас будут другие, железо другое. Смотрите на разницу до и после.
Одна модель на роутер и композер быстрее двух. Я пробовал поставить на роутер Qwen3 4B, она легче и в изоляции отвечает быстрее, 1.2 с против 1.8 с. Но LM Studio держит в памяти одну модель, и внутри хода они грузились по очереди, сначала 4B под роутер, потом 9B под композер. Ход стал 12.8 с вместо 3.0 с. Вернул одну Qwen3.5 9B на обе роли.
Поле reasoning в схеме роутера нужно не для трейса, а чтобы JSON не ломался. Без него модель льет рассуждения в открытые поля схемы. Изобретает сброс полей, которых никто не просил. На длинном ответе ломает JSON. На двенадцати сложных сообщениях 11 верных с полем против 7 без него. Ограничил поле 80 символами, те же 11 верных, но 51 токен вместо 83.
Эмбеддинги и чат-модель в одном рантайме не живут. Ход после поиска по чанкам занимал 7.56 с вместо 0.65 с, и я не сразу понял, почему. Каждый поиск выгружал 6 ГБ чат-модель из памяти, а следующий вызов грузил ее обратно. Эмбеддинги уехали в ollama отдельным контейнером, чат-модель осталась в LM Studio.
Первый запрос после старта греть формой реального вызова. Прогрев роутера на старте настоящим вызовом по его схеме снял первый запрос с 9.5 с до 3.2 с.
Что изменил |
До |
После |
|---|---|---|
Прогрев модели на старте формой реального вызова роутера |
первый запрос 9.5 с |
3.2 с |
Эмбеддинги в отдельном рантайме от чат-модели |
ход после поиска 7.56 с |
0.65 с |
То же, готовый текст сценария от сообщения до ответа |
8.5 с |
1.9 с |
Одна модель на роутер и композер вместо двух |
ход 12.8 с |
3.0 с |
Поле reasoning в схеме роутера |
7 из 12 верных |
11 из 12 |
Ограничение reasoning в 80 символов |
роутер 4.57 с, 83 токена |
2.92 с, 51 токен |
Цена диалога
Диалог на четыре хода. Клиент спрашивает, где заказ 1234, сколько стоит доставка, где заказ 5555, которого нет, и просит позвать человека. Тул, чанки, переспрос, эскалация.
Ход |
Роутер, вход / выход |
Композер, вход / выход |
|---|---|---|
Where is my order 1234? |
885 / 74 |
224 / 33 |
How much is delivery? |
937 / 64 |
290 / 25 |
Where is order 5555? |
981 / 72 |
нет, готовый текст |
I want to talk to a human |
1018 / 61 |
нет, эскалация |
Итого |
3821 / 271 |
514 / 58 |
Шесть вызовов, 4335 токенов на вход, 329 на выход. Роутер съедает 88 процентов входа, потому что в его промпте весь список скиллов с примерами, около 770 токенов. Они одинаковые на каждом вызове, локальный рантайм их кеширует. В таблице ниже кеш не учтен. У агентского цикла тот же ход с тулом стоил в среднем 1461 токен входа против 885 плюс 224 здесь.
Цены OpenRouter на 12 сентября 2026, $ за миллион токенов. Для Anthropic токены те же, токенизатор другой, это оценка.
Модель |
Вход |
Выход |
1000 диалогов, $ |
|---|---|---|---|
Qwen3.5 9B локально |
0 |
0 |
0 |
Qwen3.5 9B, OpenRouter |
0.10 |
0.15 |
0.48 |
Qwen3.5 27B, OpenRouter |
0.195 |
1.56 |
1.36 |
Claude Haiku 4.5 |
1 |
5 |
5.98 |
Claude Sonnet 5 |
2 |
10 |
11.96 |
Claude Opus 5 |
5 |
25 |
29.90 |
Тысяча диалогов на Opus это тридцать долларов. На Qwen полдоллара.
Заключение
Мой харнес и опенсорс-агент отвечают на один и тот же вопрос «где мой заказ». Вся разница в том, кто решает идти за данными. В конвейере харнеса это делает код, в цикле опенсорса модель. В конвейере пропуск тула невозможен, но роутер и сценарии для него пишет человек. Цикл собирается за вечер и пропускает тул, у меня в 1 ходу из 24, а на ранней агентской версии харнеса доходило до трети.
Для первой линии мне ближе конвейер, пропуск тула на живом клиенте дороже гибкости. Проверить на своих данных можно за вечер. Код в опенсорсе.