Назад к разделу Technology

У нашего агента 102 инструмента. Вся инженерия в том, чего он не видит

Все рефлексы в дизайне агентов сейчас работают на сложение: больше инструментов, больше инструкций, больше памяти. Norman отдаёт 102 инструмента и 19 плейбуков, и почти каждое архитектурное решение было вычитанием. Вот что мы отнимаем у модели и почему агент делает свою работу лучше именно потому, что никогда не знает, на каком он шаге.

Категория
Общее
Обновлено
Автор
Stan Kharlap

Подключи сегодня клиент к MCP-серверу Norman, и он выдаст тебе 102 инструмента в четырнадцати модулях. Создать счёт, категоризировать транзакцию, провести основателя через регистрацию GmbH, предзаполнить Fragebogen zur steuerlichen Erfassung, вытащить отчёт по НДС, оплатить счёт. На бумаге это число и есть питч: смотри, сколько агент умеет.

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

Скетчи ниже упрощены до формы явления, а не скопированы из нашего исходника, но формы настоящие.

Количество инструментов это не возможности. Это бюджет, который тратится дважды

Инструмент стоит тебе в двух валютах, и очевидна только одна.

Очевидная это токены. Определение каждого инструмента уезжает в каждом запросе, так что сотня инструментов это фиксированный налог на каждый ход каждого диалога, независимо от того, регистрирует пользователь компанию или спрашивает, когда у него дедлайн по НДС. Менее очевидная это внимание. Где-то после пятидесяти инструментов модели измеримо хуже выбирают нужный и хуже следуют инструкциям, которые к нему прилагались. Ты замечаешь это не как ошибку. Ты замечаешь это как агента, который начинает делать примерно то, что нужно.

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

register(core_tools)                       # счета, транзакции, налоги

if company.is_incorporated:
    register(chart_of_accounts_tools)      # фрилансеру бессмысленны

if user.role == "tax_advisor":
    register(multi_client_review_tools)    # владельцу бизнеса бессмысленны

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

Неэффектная прокладка труб, и самый дешёвый выигрыш в надёжности во всей системе: быстрее всего отучить модель звать не тот инструмент можно, не отправив ей этот инструмент.

Плейбуки живут в индексе, а не в промпте

Помимо инструментов мы держим 19 плейбуков как Markdown-файлы в репозитории: месячная сверка, напоминания по просрочкам, поиск недостающих чеков, регистрация компании, подготовка к DATEV. Настоящее процедурное знание, такое, которое писал предметный эксперт.

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

Поэтому мы отдаём оглавление, а главу агент запрашивает сам:

# В системном промпте: только имена и триггеры, несколько сотен токенов.
for workflow in workflows:
    prompt += f"- {workflow.name}: {workflow.trigger}\n"

@tool
def get_workflow_details(name: str) -> str:
    """Пошаговое тело ОДНОГО воркфлоу. Звать перед выполнением."""
    return workflows[name].body

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

Тот же инстинкт определяет порядок самого промпта. Наш провайдер модели, как сейчас и большинство, кеширует достаточно длинный общий префикс между запросами и считает его со скидкой, так что раскладка промпта перестала быть вопросом авторского стиля и стала вопросом денег:

prompt  = STATIC_PREFIX            # идентичен для всех, остаётся в кеше
prompt += workflow_index()         # идентичен для всех
prompt += company_facts(company)   # про конкретную компанию, значит всегда в конце

Поставь название компании одного пользователя наверх, и ты инвалидировал общий префикс для всех остальных пользователей платформы.

Правило, на котором всё держится: никогда не отслеживай свой прогресс сам

Вот это я защищал бы жёстче всего.

Три наших потока по-настоящему длинные: регистрация GmbH или UG (18 инструментов), Fragebogen zur steuerlichen Erfassung для фрилансера (12 инструментов) и Gewerbeanmeldung (10 инструментов). Каждый это сбор данных по пяти разделам через несколько сессий, где основатель исчезает посреди потока, возвращается завтра, передумывает насчёт правовой формы, добавляет третьего участника и ждёт, что агент точно знает, как сейчас дела.

Соблазнительный дизайн отдаёт это модели. Дай ей память, пусть суммирует прогресс, поверь, что она запомнит: капитал готов, а раздел нотариуса нет. Мы делаем наоборот, и плейбук говорит это одной строкой:

Бэкенд это источник истины. Навигируй по sections.missing; никогда не отслеживай прогресс сам.

Любой ответ инструмента, по какому бы поводу его ни позвали, приносит с собой всё состояние потока:

{
  "status": "data_collection",
  "sections": {
    "company":      { "complete": true,  "missing": [] },
    "shareholders": { "complete": false, "missing": ["managingDirector"] },
    "capital":      { "complete": false, "missing": ["nominalSumMismatch"] }
  }
}

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

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

def missing_in_capital(record) -> list[str]:
    missing = [f for f in REQUIRED_FIELDS if not getattr(record, f)]

    # межполевой инвариант: части должны складываться в объявленное целое
    if sum(p.nominal for p in record.parts) != record.declared_total:
        missing.append("nominalSumMismatch")

    return missing

Основатель, снизивший уставный капитал через час после того, как доли уже разделили, получает этот маркер на следующем же вызове, и агент спрашивает про это, потому что оно прямо в ответе, который он только что получил. Никакая диалоговая память не поймала бы это надёжно.

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

Мы держим это даже там, где двух источников истины действительно два. Дорожная карта регистрации отслеживает семь шагов после нотариуса, и каждый может быть закрыт либо самим основателем, либо продвинут нашей ops-командой. Оба варианта легитимны, ни один не имеет права перезаписать другой:

# Готово, если основатель отметил сам ИЛИ ops уже прошли этот майлстоун.
# Самоотметки никогда не пишут в операционный статус: так письма о
# майлстоунах уходят вовремя, а отмена остаётся честным переключателем.
done = bool(step.marked_at) or ops_status_reached(record, step.milestone)

Примирять это в промпте было бы безнадёжно. Как вычисляемое чтение по двум независимым колонкам это около шести строк.

Сравнение того, что существует на сервере, и того, что модель держит за один ход. На стороне сервера: 102 MCP-инструмента в четырнадцати модулях, 19 плейбуков воркфлоу общим объёмом около десяти тысяч токенов, и состояние потока в базе данных в виде sections и дорожной карты. В середине три вычитания: регистрировать инструменты по роли пользователя и типу компании, что экономит примерно пять-восемь тысяч токенов на запрос, отдавать индекс объёмом в несколько сотен токенов и подгружать тела плейбуков по требованию, и возвращать состояние в каждом ответе инструмента, чтобы модель ничего не хранила. Итоговый рабочий набор содержит только инструменты этого пользователя, индекс воркфлоу вместо их тел, статический кешируемый префикс сначала и факты о компании в конце, и явным образом никакого прогресса, никакого счётчика шагов и никакой памяти о потоке. Внизу цикл: модель зовёт инструмент, сервер считает недостающие разделы из базы данных, ответ несёт новое состояние, и модель читает его и задаёт следующий вопрос. Обязывающий шаг отсутствует намеренно: submit-инструмента нет, человек нажимает Submit в приложении.
Возможности остаются на сервере. То, что доходит до модели, собирается под каждый ход, и прогресса среди этого нет.

Самый важный инструмент это тот, который мы не выпустили

В потоке постановки юрлица на налоговый учёт девять инструментов для сбора и валидации данных, а потом он заканчивается. Не потому что у нас кончилось время:

get_registration, get_choices, create_registration,
update_company, update_details, update_people,
update_financials, update_vat_and_bank,
get_submission_link          # возвращает URL + readyToSubmit

# Намеренно отсутствует: submit()

Анкета уходит в налоговую через ELSTER, и этот последний, обязывающий шаг происходит только в приложении Norman, где пользователь видит отрендеренный предпросмотр каждого ответа и сам нажимает Submit. Финальная возможность агента состоит в том, чтобы подать пользователю дверь.

Это та же линия, которую мы провели в Autofiling, нашем агенте подачи НДС: всё, что подаёт в налоговую, двигает деньги или отправляет письмо, проходит через человеческое подтверждение, и это подтверждение не промпт, через который модель может себя уговорить. Это отсутствие инструмента. Нет такой формулировки, джейлбрейка или запутанного многоходового состояния, которое позволило бы агенту подать обязывающий налоговый документ, потому что этого глагола нет в его словаре. Возможность, которую ты никогда не выдавал, не нуждается в ограждении.

Я лучше объясню пользователю, почему агент дал ему ссылку, чем основателю, почему он отправил в налоговую неправильную правовую форму.

Как это выглядит в продакшене

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

Эта инверсия не провал чат-агента. Это архитектура, работающая как задумано. В диалоге живёт неоднозначность, поэтому ему достаются максимальные возможности и самые жёсткие ограничения; высокочастотные пути узкие по построению, и им почти никаких не нужно. Быть AI-first не значит гнать всё через чат-бокс. Это значит быть точным в том, какие решения действительно требуют модели, и строить скучную механику, которая делает эти решения безопасными.

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

Norman берет операционную финансовую работу на себя

От invoicing до bookkeeping: Norman организует повторяющиеся финансовые процессы так, чтобы вы успевали к дедлайнам с меньшим объемом ручной работы.