← max_tokens

Не каждый запрос заслуживает агента

Я долго гонял каждое сообщение пользователя через полный агентный цикл: планирование, tool-calling, большой контекст и топовую модель. Так проще: один путь, одна архитектура, меньше решений в коде.

Проблема проявилась не там, где я ожидал. Счёт и латентность росли не только на сложных задачах, а на самых обычных сообщениях: «спасибо», «можешь короче», «а что ты умеешь», «привет». Запускать многошагового агента на такой запрос — всё равно что вызывать автокран, чтобы поднять ложку.

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

Дорогой путь должен включаться только там, где он действительно нужен.

Наивная архитектура: один путь для всего

Самый простой вариант выглядит так:

request → agent_loop → response

Один маршрут. Всегда топовая модель. Всегда tool-calling. Всегда большой системный промпт и растущий контекст.

Но реальный трафик устроен иначе. В нём лежит всё подряд:

  • приветствия;
  • благодарности;
  • smalltalk;
  • короткие уточнения вроде «да» или «сделай короче»;
  • FAQ-вопросы;
  • оффтоп;
  • запросы, которым нужен ровно один известный инструмент.

Для всего этого полный агентный цикл часто избыточен.

Цена наивного пути на простом запросе складывается из трёх вещей. Сначала токены: системный промпт агента, описания инструментов и история диалога — на «спасибо» это сотни или тысячи токенов впустую. Дальше латентность: агентный цикл это несколько ходов модели и round-trip’ы к инструментам, и даже на простой задаче пользователь ждёт как на сложной. И качество: чем больше лёгких мусорных запросов проходит через дорогую модель, тем сильнее загрязняется её контекст, и на действительно трудных задачах это начинает мешать.

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

Не «у меня агент на топовой модели», а «у меня есть несколько дорог разной стоимости, и я решаю, по какой пустить конкретный запрос».

Воронка стоимости

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

Воронка стоимости: весь трафик идёт в дешёвый классификатор, тот маршрутизирует запрос в шаблон, прямой вызов инструмента или полный агентный цикл

Минимально полезных маршрутов три:

  1. Trivial — приветствие, благодарность, smalltalk, простая формулировка. Можно ответить шаблоном или маленькой моделью без инструментов.
  2. Single tool — запрос требует одного понятного действия. Инструмент можно вызвать напрямую, без планирования агентом.
  3. Agent — задача неоднозначная, многошаговая или требует нескольких инструментов. Здесь нужен полный цикл.

Начать можно даже с двух маршрутов: cheap и agent. Главное — перестать отправлять весь трафик в самый дорогой путь.

Почему это работает

Распределение запросов почти всегда тяжёлохвостое. Большая часть трафика оказывается простой, меньшая часть — действительно сложной.

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

Разница в стоимости между маршрутами обычно составляет порядок или два. Классификатор — это один короткий вызов маленькой модели с маленьким ответом: enum и confidence. Агентный проход — это несколько вызовов модели, растущий контекст и tool round-trip’ы.

Кэш тоже помогает, но его не надо описывать как «почти бесплатно». Системный промпт классификатора стабилен, поэтому большая часть input-токенов может покрываться prompt cache. Но это не ноль: вы всё равно платите за output, за запись кэша и теряете скидку после истечения TTL.

Короткий классификатор с кэшированным префиксом — действительно дешёвый. Просто не бесплатный.

Среднюю стоимость можно прикинуть так:

avg = P(trivial)·c_cheap + P(single_tool)·c_tool + P(agent)·c_agent

c_agent доминирует, потому что он на порядки больше остальных. Если хотя бы половину трафика вывести из agent в cheap, средняя стоимость падает не на проценты, а в разы.

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

Сам классификатор

Классификатор не должен отвечать пользователю. Его задача — принять маршрутное решение.

Полезно классифицировать не только «просто / сложно», а несколько признаков:

  • категория интента;
  • нужен ли инструмент;
  • какой инструмент нужен;
  • уровень сложности;
  • язык;
  • safety-флаг;
  • нужно ли вообще отвечать.

Часто это не один label, а небольшая схема.

Классификатор можно сделать маленькой моделью, embedding-классификатором или гибридом правил и модели. В любом случае temperature = 0: здесь нужен детерминизм, а не красота текста.

Ответ лучше сразу заставить быть структурированным. Парсить прозу хрупко, а схему легко тестировать.

from enum import Enum
from pydantic import BaseModel

class Route(str, Enum):
    TRIVIAL = "trivial"          # приветствие, спасибо, smalltalk
    SINGLE_TOOL = "single_tool"  # одно известное действие без планирования
    AGENT = "agent"              # неоднозначная или многошаговая задача

class Triage(BaseModel):
    route: Route
    tool: str | None = None
    confidence: float            # 0..1

async def classify(message: str, recent: list[str]) -> Triage:
    return await small_model.structured(
        system=CLASSIFIER_PROMPT,
        input={
            "message": message,
            "recent": recent[-2:],
        },
        schema=Triage,
        temperature=0,
    )

Дальше начинается самое важное: ошибки классификатора стоят по-разному.

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

Асимметрия ошибок: сложный запрос в дешёвом пути даёт плохой ответ и ломает доверие; простой запрос в агенте — лишь переплата

Поэтому при низкой уверенности нужно эскалировать вверх — к способности, а не вниз, к дешевизне.

triage = await classify(msg, recent)

# При сомнении выбираем способность, а не экономию.
if triage.confidence < 0.6:
    return await run_agent(msg)

match triage.route:
    case Route.TRIVIAL:
        return template_reply(msg)

    case Route.SINGLE_TOOL if triage.tool is not None:
        return await call_tool(triage.tool, msg)

    case Route.AGENT:
        return await run_agent(msg)

    case _:
        # single_tool без названного инструмента — та же логика: эскалируем вверх
        return await run_agent(msg)

Порог 0.6 здесь просто пример. Его нужно калибровать на своих данных. Сырое confidence модели почти всегда переоценено, а нормальная граница видна только на golden set.

Одна оговорка: single_tool — решение о стоимости, а не о безопасности. Один вызов инструмента всё ещё может удалить, оплатить, отправить — дёшево смаршрутизировать не значит дёшево ошибиться. Роутинг выше намеренно минимален; перед прямым call_tool поставь гейт по сигналу безопасности (один из сигналов классификатора из списка выше) — без присмотра только когда явно безопасно, иначе подтверждение или эскалация в агента. Пропустить агента не значит пропустить защиту.

Внутри диалога маршрут зависит от контекста. «Да» после сложного вопроса — не тривиальный запрос. Но классификатору не нужна вся история. Обычно хватает последних одной-двух реплик. Если давать ему весь диалог, он начнёт стоить как система, которую должен удешевлять.

И ещё одна важная вещь: классификатор дрейфует. По мере роста числа интентов промпт пухнет, а точность начинает плыть. Лечится это тремя практиками:

  • версионировать prompt;
  • держать golden set и confusion matrix;
  • не раздувать таксономию.

Три-четыре маршрута обычно надёжнее, чем пятнадцать красивых интентов.

Классификатор — это код, а не просто конфиг. К нему нужно относиться как к коду: тестировать, версионировать и регулярно смотреть, где он ошибается.

Что мерить

Без метрик непонятно, работает ли воронка вообще.

Минимальный набор:

  • распределение маршрутов: trivial / single_tool / agent;
  • стоимость запроса по каждому маршруту;
  • средняя стоимость запроса;
  • доля misroute-ошибок;
  • отдельно — доля случаев, где сложный запрос уехал в дешёвый путь;
  • доля эскалаций из-за low confidence;
  • p50/p95 латентности по маршрутам;
  • A/B: «всё в агент» против «воронки».

Последний пункт важнее всего. Он показывает, не испортили ли вы качество ради экономии.

Когда это не нужно

Паттерн не бесплатный. Есть случаи, где классификатор только мешает.

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

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

Есть несколько способов это смягчить:

  • держать классификатор очень быстрым;
  • блокироваться на нём только ради дешёвого пути;
  • при необходимости стартовать агента спекулятивно параллельно и отменять лишнее после вердикта.

Классификатор также становится единой точкой отказа. Если он лёг, весь вход не должен лечь вместе с ним. Правило такое же, как при низкой уверенности: при любой ошибке классификатора уходим в агента.

Способность важнее экономии.

Вывод

Самая дешёвая оптимизация в LLM-приложении — не звать дорогую модель там, где она не нужна.

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

Тир модели — это маршрутное решение.

Ошибайтесь в сторону способности. Мерьте воронку. Версионируйте классификатор как код.

<|endoftext|> · 2 713 tok · finish_reason: stop

// top_k · nearest neighbors

  1. [0] 0.735 Turn count как продуктовая метрика
  2. [1] 0.734 Промпт — это 5% работы: контекст-инжиниринг LLM-агента в проде
  3. [2] 0.640 Агент прошёл eval. В прод я его всё равно не пущу

cosine of embeddings · scale 0–1 absolute · computed at build

integrity: sha256 e2c4a9b1…

tokens · o200k_base