ИИ-агенты и prompt engineering Агенты и ReAct: цикл рассуждение-действие, инструменты, планирование, память
0%

Агенты и ReAct: цикл рассуждение-действие, инструменты, планирование, память

Агенты и ReAct: цикл рассуждение-действие, инструменты, планирование, память

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

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

Когда агент нужен, а когда это дорогой способ выстрелить себе в ногу

Есть спектр от «одного вызова» до «полной автономии», и большая часть продакшн-систем живёт не на его правом конце.

  • Один вызов — классификация, извлечение, суммаризация. Никакого цикла.
  • Workflow (цепочка) — вы жёстко прописали последовательность шагов в коде, модель заполняет отдельные слоты. Роутер, «сначала retrieve, потом ответь», «сначала переведи, потом проверь». Путь задан вами.
  • Агент — путь выбирает модель. Вы даёте инструменты и цель, но не знаете заранее, сколько будет шагов и какие.

Anthropic формулирует это правило прямо: «ищите самое простое решение и добавляйте сложность только когда она доказанно улучшает результат» (Building Effective Agents). Агентность — это не уровень качества, а способ обращения с неопределённостью. Если вы можете нарисовать граф шагов заранее — рисуйте, workflow дешевле, быстрее и отлаживается.

Четыре вопроса перед тем, как писать цикл:

  1. Сложность. Задачу реально нельзя специфицировать заранее? («Преврати этот дизайн-док в PR» — да. «Достань заголовок из PDF» — нет.)
  2. Ценность. Результат оправдывает 10–50-кратный рост стоимости и латентности относительно одного вызова?
  3. Выполнимость. Модель вообще умеет такое? Проверьте на 20 примерах вручную, прежде чем строить обвязку.
  4. Цена ошибки. Ошибку можно поймать и откатить — тестами, ревью, транзакцией? Если нет, агент должен спрашивать человека.

ReAct: почему рассуждать и действовать надо вместе

Оригинальная работа — Yao et al., ReAct: Synergizing Reasoning and Acting in Language Models, arXiv:2210.03629 (ICLR 2023). Её ключевой вклад не в формате Thought/Action/Observation, а в наблюдении, что два чистых режима ломаются симметрично:

  • Только рассуждение (CoT). Модель строит красивую цепочку, но факты берёт из весов. Ошибка на третьем шаге дальше только усиливается: цепочка когерентна и неверна. Это классическая галлюцинация, «дрейф фактов».
  • Только действие (act-only). Модель дёргает инструменты, но между вызовами ничего не осмысляет. Она не умеет решить «поиск ничего не дал, надо переформулировать» и застревает в повторении одного и того же вызова.

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

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

Обратите внимание на две дуги, которые новички забывают: ОшибкаИнструмента --> Рассуждение (ошибка — это тоже наблюдение) и Рассуждение --> Останов (цикл обязан уметь сдаться).

Кто с кем разговаривает

Оркестратор — это ваш код. Он владеет циклом, а не модель.

Три вещи, которые видны только на этой диаграмме:

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

Голый ReAct: как это выглядело изначально

Исторический формат — модель генерирует текст, вы его парсите:

Thought: нужно узнать статус заказа, у меня есть только email клиента
Action: find_customer
Action Input: {"email": "a@b.com"}
Observation: {"customer_id": "c_912", "tier": "pro"}
Thought: теперь можно получить заказы
Action: list_orders
Action Input: {"customer_id": "c_912", "limit": 5}
Observation: [{"id": "o_5511", "status": "shipped", ...}]
Thought: у меня есть ответ
Final Answer: заказ o_5511 отправлен 14 июля.

Минимальная реализация — чтобы было видно, что магии нет:

import re

ACTION_RE = re.compile(r"^Action:\s*(\w+)\s*$", re.M)
INPUT_RE = re.compile(r"^Action Input:\s*(\{.*\})\s*$", re.M)

def parse_step(text: str) -> tuple[str, dict] | None:
    """Достаём (имя инструмента, аргументы) из свободного текста. Хрупко."""
    action = ACTION_RE.search(text)
    args = INPUT_RE.search(text)
    if not action or not args:
        return None            # модель забыла формат — шаг потерян
    return action.group(1), json.loads(args.group(1))  # и тут может рвануть

Почему так больше не пишут: вы отдали контракт на откуп естественному языку. Модель напишет Action Input: {"email": "a@b.com"} с висячей запятой, или вставит блок ```json, или скажет Action: find_customer (по email). Каждая такая осечка — потерянный шаг и потраченные деньги. Замеры на BFCL показывают, что разрыв между текстовым парсингом и нативным function calling по доле корректных вызовов измеряется десятками процентных пунктов (Berkeley Function-Calling Leaderboard).

Знать этот формат всё равно полезно: он до сих пор нужен для локальных моделей без нативного tool use (см. локальные модели) и для few-shot-примеров, где вы показываете модели образец хорошей траектории.

Современный цикл: нативный tool calling

Сегодня «Thought» — это блок thinking, а «Action» — типизированный блок tool_use с провалидированными аргументами. Цикл сводится к «крутить, пока stop_reason == "tool_use"».

import json
import anthropic

client = anthropic.Anthropic()
MODEL = "claude-opus-4-8"

TOOLS = [
    {
        "name": "list_orders",
        "description": (
            "Вернуть заказы клиента, самые свежие первыми. "
            "Используй, когда нужен статус, дата или сумма заказа. "
            "Не используй для поиска клиента по email — для этого есть find_customer."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "customer_id": {"type": "string", "description": "ID вида c_912"},
                "limit": {"type": "integer", "minimum": 1, "maximum": 20, "default": 5},
            },
            "required": ["customer_id"],
            "additionalProperties": False,
        },
        "strict": True,          # аргументы гарантированно валидны по схеме
    },
]

SYSTEM = """Ты агент поддержки. Работай так:
- Прежде чем менять что-либо, собери факты инструментами. Не выдумывай ID и суммы.
- Если инструмент вернул ошибку, прочитай текст ошибки и исправь вызов, а не повторяй его.
- Если данных не хватает и инструменты не помогают — скажи об этом прямо, не догадывайся.
- Возврат средств оформляй только после подтверждения оператора."""


class StepLimitExceeded(RuntimeError):
    pass


def run_agent(task: str, max_steps: int = 12, token_budget: int = 200_000) -> str:
    messages = [{"role": "user", "content": task}]
    spent = 0

    for step in range(max_steps):
        resp = client.messages.create(
            model=MODEL,
            max_tokens=8000,
            system=[{"type": "text", "text": SYSTEM,
                     "cache_control": {"type": "ephemeral"}}],  # кэшируем префикс
            thinking={"type": "adaptive"},   # думает столько, сколько нужно шагу
            output_config={"effort": "high"},
            tools=TOOLS,
            messages=messages,
        )
        spent += resp.usage.input_tokens + resp.usage.output_tokens
        if spent > token_budget:
            raise StepLimitExceeded(f"бюджет токенов исчерпан на шаге {step}")

        # ВАЖНО: кладём весь content целиком — thinking, text и tool_use блоки.
        # Выдёргивание только текста ломает подпись thinking-блоков.
        messages.append({"role": "assistant", "content": resp.content})

        if resp.stop_reason != "tool_use":
            return "".join(b.text for b in resp.content if b.type == "text")

        results = []
        for block in resp.content:
            if block.type != "tool_use":
                continue
            try:
                payload = dispatch(block.name, block.input)
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": truncate(json.dumps(payload, ensure_ascii=False)),
                })
            except Exception as exc:                      # ошибка — тоже наблюдение
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": f"{type(exc).__name__}: {exc}",
                    "is_error": True,
                })
        # все результаты — ОДНИМ сообщением, иначе отучим модель от параллелизма
        messages.append({"role": "user", "content": results})

    raise StepLimitExceeded(f"не уложились в {max_steps} шагов")

Тот же цикл на TypeScript — форма один в один:

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

export async function runAgent(task: string, maxSteps = 12): Promise<string> {
  const messages: Anthropic.MessageParam[] = [{ role: "user", content: task }];

  for (let step = 0; step < maxSteps; step++) {
    const resp = await client.messages.create({
      model: "claude-opus-4-8",
      max_tokens: 8000,
      system: [{ type: "text", text: SYSTEM, cache_control: { type: "ephemeral" } }],
      thinking: { type: "adaptive" },
      tools: TOOLS,
      messages,
    });

    messages.push({ role: "assistant", content: resp.content });

    if (resp.stop_reason !== "tool_use") {
      return resp.content
        .filter((b): b is Anthropic.TextBlock => b.type === "text")
        .map((b) => b.text)
        .join("");
    }

    const calls = resp.content.filter(
      (b): b is Anthropic.ToolUseBlock => b.type === "tool_use",
    );
    // параллельно — модель специально просит несколько вызовов сразу
    const results = await Promise.all(
      calls.map(async (call): Promise<Anthropic.ToolResultBlockParam> => {
        try {
          const out = await dispatch(call.name, call.input);
          return { type: "tool_result", tool_use_id: call.id, content: truncate(out) };
        } catch (e) {
          return {
            type: "tool_result",
            tool_use_id: call.id,
            content: `${(e as Error).name}: ${(e as Error).message}`,
            is_error: true,
          };
        }
      }),
    );
    messages.push({ role: "user", content: results });
  }
  throw new Error(`не уложились в ${maxSteps} шагов`);
}

Сложность. Пусть $N$ — число шагов, $P$ — размер постоянного префикса (system + схемы инструментов), $T$ — средний прирост контекста за шаг (мысль + вызов + наблюдение). Тогда суммарный расход входных токенов за траекторию:

$$C_{\text{in}} = \sum_{i=1}^{N}\bigl(P + i \cdot T\bigr) = N \cdot P + T \cdot \frac{N(N+1)}{2} = O(N^2)$$

По времени — $O(N)$ последовательных сетевых раундов; по памяти оркестратора — $O(N \cdot T)$. Квадратичность по деньгам — главный факт про агентов, и он определяет почти все инженерные решения ниже.

Проектирование инструментов: agent-computer interface

Термин ACI (agent-computer interface) ввели авторы SWE-agent (arXiv:2405.15793): они показали, что при той же модели переработка интерфейса инструментов поднимает решаемость SWE-bench в разы. Инструменты — это не тонкая обёртка над вашим API, это UI для модели. К ним применимы те же принципы, что к UX для человека, только пользователь читает по 200 строк за раз и не умеет прокручивать назад.

Практика Плохо Хорошо Почему
Гранулярность execute_sql(query) на всю БД find_customer, list_orders, issue_refund Узкие инструменты проверяемы, логируются и гейтятся по отдельности
Описание «Выполняет поиск» «Ищет по базе знаний. Используй, когда ответа нет в диалоге. Не используй для поиска по коду — для этого grep_repo» Модель выбирает инструмент по описанию; напишите, когда звать и когда не звать
Схема {"args": {"type": "string"}} Типизированные поля, enum, required, additionalProperties: false, strict: true Валидная схема снимает целый класс ретраев
Формат вывода сырой дамп ORM на 40 КБ плоский JSON с 6 полями, обрезанный по лимиту Каждый байт вывода вы оплатите ещё $N-i$ раз
Ошибки {"ok": false} NotFound: клиента c_9x не существует. Найди ID через find_customer Ошибка — обучающий сигнал; она должна говорить, что делать дальше
Побочные эффекты delete_records(filter) delete_records(ids, dry_run) + подтверждение Необратимое действие должно быть явным и гейтящимся

Ещё несколько вещей, которые узнаёшь только на практике:

Число инструментов имеет предел. Каждое описание — это токены в постоянном префиксе, и, что важнее, каждый новый инструмент — это лишний кандидат при выборе. Практически: до ~10–15 инструментов модель выбирает уверенно; после 30–40 растёт доля не тех вызовов. Если инструментов реально много — не грузите все схемы сразу, используйте отложенную загрузку через поиск по инструментам (defer_loading + tool search) или разнесите домены по субагентам, о чём в мультиагентных системах.

Не давайте одно и то же двумя способами. Если есть и bash, и read_file, модель будет выбирать между ними при каждом чтении и иногда выбирать неудачно. bash даёт широту; выделенный инструмент даёт вашему оркестратору типизированные аргументы, которые можно перехватить, отрисовать в UI, распараллелить или заблокировать. Правило: начинайте с bash, повышайте действие до отдельного инструмента, когда его нужно гейтить, рендерить, аудировать или параллелить.

Обрезайте наблюдения на входе, а не на выходе.

MAX_OBS_CHARS = 4_000

def truncate(text: str, limit: int = MAX_OBS_CHARS) -> str:
    """Оставляем голову и хвост: начало задаёт структуру, конец обычно содержит итог."""
    if len(text) <= limit:
        return text
    head, tail = limit * 2 // 3, limit // 3
    cut = len(text) - head - tail
    return (
        f"{text[:head]}\n"
        f"... [обрезано {cut} символов; сузьте запрос или запросите страницу] ...\n"
        f"{text[-tail:]}"
    )

Подсказка в середине обрезки важна: без неё модель считает, что видела всё, и делает выводы по половине данных.

Планирование: четыре топологии

ReAct переплетает мысль и действие. Это гибко, но каждый шаг тянет за собой весь контекст. Есть альтернативы с другим профилем стоимости.

Подход Вызовов модели Контекст на шаге Реакция на неожиданность Когда брать
ReAct $N$ (по одному на шаг) растёт линейно немедленная путь неизвестен, среда шумная
Plan-and-Execute (arXiv:2305.04091) 1 $ + N$, но исполнение можно отдать модели подешевле план фиксирован, шаги короткие только при перепланировании много однотипных шагов, план стабилен
ReWOO (arXiv:2305.18323) 2–3 почти не растёт отсутствует шаги независимы, среда предсказуема
Reflexion (arXiv:2303.11366) $k \cdot N$ сброс между попытками между попытками есть внешний проверяющий сигнал

Про ReWOO стоит сказать отдельно: он экономит десятки процентов токенов, потому что исполнитель не видит промежуточных наблюдений — план строится сразу со ссылками вида #E1, которые потом подставляются. Расплата прямая: если шаг 2 вернул не то, что предполагал план, никто этого не заметит. Берите ReWOO там, где неожиданности означают «упало», а не «надо думать иначе».

Про Reflexion — самая частая ошибка: сделать «рефлексию» без внешнего сигнала. Модель, которую просят перепроверить себя без новой информации, чаще портит верный ответ, чем чинит неверный — это измерено (Huang et al., arXiv:2310.01798). Reflexion работает, когда вердикт приходит извне: упавший тест, ненулевой exit code, отказ валидатора, несходящийся баланс. Рефлексия без верификатора — это самообман за ваши деньги.

Память: четыре уровня, а не один

«У агента есть память» почти всегда означает «мы дописываем всё в контекст». Это самый дорогой и самый хрупкий из четырёх вариантов.

Четыре уровня памяти агента

Уровень 1, рабочая память (контекст). Быстро, но конечно и дорого. Её главный враг — не размер окна, а деградация внимания: факты в середине длинного контекста извлекаются заметно хуже, чем в начале и конце (Liu et al., «Lost in the Middle», arXiv:2307.03172). То есть большое окно ≠ большая рабочая память. У длинных траекторий есть отдельная патология, которую практики зовут context rot: контекст забит устаревшими наблюдениями и неудачными попытками, и модель продолжает на них опираться.

Лечится это тремя способами:

  • Очистка — выкинуть старые tool_result (структура диалога остаётся, содержимое пропадает);
  • Компакция — свернуть предысторию в summary и продолжить с него;
  • Вынос наружу — на уровни 2–4.

Anthropic описывает это как «инженерию контекста»: задача не «набить окно», а найти минимальный набор высокосигнальных токенов (Effective context engineering for AI agents).

Уровень 2, скрэтчпад. Дайте агенту файл. Буквально: notes.md, plan.md, findings.md с инструментами чтения и записи. Это переживает компакцию, читается человеком при разборе инцидента и стоит ноль токенов, пока агент сам не решит перечитать. Для длинных задач это самый недооценённый приём: агент, который в начале пишет план в файл и по ходу его вычёркивает, не теряет цель на 30-м шаге.

Уровень 3, эпизодическая. Чем кончались прошлые прогоны. Даёт две вещи: few-shot из траекторий, которые действительно сработали в вашей среде, и «мы это уже пробовали, не сработало». Идея восходит к memory stream в Generative Agents (arXiv:2304.03442), где к недавности добавляют релевантность и важность при отборе воспоминаний.

Уровень 4, семантическая. Факты о домене — это ваш RAG из пятой и шестой статей, только вызываемый агентом по своей инициативе, а не подмешиваемый заранее.

Ключевой принцип управления памятью: уровни 2–4 существуют, чтобы уровень 1 оставался маленьким. Идея разделения «быстрая память / внешнее хранилище с явными операциями подкачки» подробно разобрана в MemGPT (arXiv:2310.08560) — аналогия с виртуальной памятью ОС здесь буквальная, и если вам близка тема, загляните в операционные системы.

Экономика цикла: где на самом деле уходят деньги

Рост контекста агента по шагам

Посчитаем на конкретных числах. Модель — claude-opus-4-8 (5 $ за 1M входных, 25 $ за 1M выходных). Префикс $P$ = 3 000 токенов (system + 8 схем инструментов). Прирост за шаг $T$ = 2 500 токенов (короткая мысль, вызов, наблюдение на 2 000). Выход — 400 токенов на шаг.

Шагов $N$ Входные токены $N P + T\frac{N(N+1)}{2}$ Без кэша С кэшем префикса С кэшем + обрезка $T$ до 800
5 52 500 0.31 $ 0.28 $ 0.13 $
10 167 500 0.94 $ 0.88 $ 0.35 $
20 585 000 3.03 $ 2.90 $ 1.02 $
40 2 170 000 11.25 $ 11.00 $ 3.63 $

Что здесь видно:

  • Кэширование префикса почти не помогает на длинных траекториях. Оно даёт постоянный выигрыш $N \cdot P$, а растущий член квадратичен. Кэш префикса критичен для коротких агентов и для роутеров, а не для 40-шаговых.
  • Обрезка наблюдений — самый мощный рычаг. Урезание $T$ втрое режет счёт втрое, потому что $T$ стоит при квадратичном члене. Один жирный SELECT * на десятом шаге вы оплатите тридцать раз.
  • Компакция превращает квадрат в кусочно-линейное. Свернув историю на 15-м шаге до 4 000 токенов, вы обнуляете накопленное и стартуете заново — счёт для 40 шагов падает примерно вдвое.

Отдельно про кэш: он живёт по префиксному совпадению, поэтому любое изменение в начале промпта убивает кэш целиком. Дата в system-промпте, несортированный json.dumps схем инструментов, случайный request_id в начале — всё это тихо превращает кэш в ноль. Проверять надо не логикой, а полем usage.cache_read_input_tokens: если оно нулевое между одинаковыми запросами, у вас есть невидимый инвалидатор.

Ещё один приём — роутинг по шагам. Планирование и финальный синтез отдайте сильной модели, рутинное исполнение по готовому плану — claude-sonnet-5 или claude-haiku-4-5. Осторожно: смена модели тоже сбрасывает кэш, поэтому роутинг оправдан на границе фаз, а не на каждом шаге. Подробнее — в статье про продакшн и стоимость.

Контроль цикла: как не дать агенту сойти с ума

Цикл без предохранителей — это финансовая и репутационная бомба. Минимальный набор:

from dataclasses import dataclass, field
from hashlib import sha256
import time

@dataclass
class Governor:
    max_steps: int = 20
    max_tokens: int = 200_000
    max_usd: float = 2.0
    wall_clock_s: float = 300.0
    max_repeats: int = 2          # сколько раз терпим идентичный вызов

    started: float = field(default_factory=time.monotonic)
    steps: int = 0
    tokens: int = 0
    usd: float = 0.0
    seen: dict[str, int] = field(default_factory=dict)

    def check(self) -> None:
        if self.steps >= self.max_steps:
            raise Halt("исчерпан лимит шагов")
        if self.tokens >= self.max_tokens:
            raise Halt("исчерпан лимит токенов")
        if self.usd >= self.max_usd:
            raise Halt(f"исчерпан бюджет ${self.max_usd}")
        if time.monotonic() - self.started > self.wall_clock_s:
            raise Halt("превышено время выполнения")

    def observe_call(self, name: str, args: dict) -> str | None:
        """Детект зацикливания: тот же инструмент с теми же аргументами."""
        key = sha256(f"{name}:{json.dumps(args, sort_keys=True)}".encode()).hexdigest()
        self.seen[key] = self.seen.get(key, 0) + 1
        if self.seen[key] > self.max_repeats:
            # возвращаем это как tool_result — модель должна увидеть, что застряла
            return (
                f"LoopDetected: ты вызываешь {name} с этими же аргументами "
                f"{self.seen[key]} раз подряд и получаешь тот же результат. "
                f"Смени подход или сообщи, что задача не решается доступными средствами."
            )
        return None

Три отдельных замечания:

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

Человек в цикле — это отдельный инструмент, а не флаг. Оформите подтверждение как request_approval(action, reason): модель обязана его вызвать перед необратимым действием, оркестратор блокирует цикл и возвращает вердикт как tool_result. Так решение видно в трассе, а политика описана в одном месте.

Идемпотентность важнее ретраев. Агент может вызвать issue_refund дважды — из-за ретрая на таймауте, из-за зацикливания, из-за перезапуска после падения. Ключ идемпотентности на стороне инструмента (f"{run_id}:{tool_use_id}") закрывает весь класс проблем; надеяться, что модель «не будет повторяться», — не инженерная позиция. Про смежные риски — подмену инструкций через содержимое наблюдений — читайте в безопасности и prompt injection.

Типичные ошибки

  • Инструмент возвращает сырой ответ API. 40 КБ JSON с вложенными объектами. Вы оплатите его на каждом последующем шаге и вдобавок утопите полезный сигнал.
  • Ошибки скрыты. try/except: return None. Модель видит пустоту, придумывает объяснение и уходит не туда. Отдавайте текст ошибки с подсказкой, что делать.
  • tool_result разбиты по нескольким сообщениям. Модель перестаёт запрашивать параллельные вызовы, латентность растёт линейно там, где могла быть константной.
  • Нет лимита по деньгам, только по шагам. 20 шагов с гигантскими наблюдениями стоят дороже, чем 60 маленьких. Считайте доллары, а не итерации.
  • Тесты гоняют модель. Юнит-тесты цикла должны использовать фейковый клиент, отдающий заранее записанные ответы: детерминированно, бесплатно, ловит именно ошибки оркестрации. Реальную модель оставьте для eval-набора.
  • Нет пошаговой трассы. «Агент дал неверный ответ» без трассы неразличимо с «инструмент вернул стухшие данные», «наблюдение обрезалось не там» и «модель выбрала не тот инструмент».
  • Смешаны детерминированное и агентное. Если после шага A всегда идёт шаг B, зашейте это в код. Заставлять модель каждый раз «догадываться» — платить за то, что вы и так знали.
  • Слишком мягкие лимиты в дев-среде. max_steps=100 в песочнице обязательно однажды уедет в прод.

Продакшн: наблюдаемость и оценка

Трассировка. Один запуск = одна трасса, шаг = span. Минимум атрибутов на span: имя инструмента, размер аргументов, размер результата, is_error, латентность, input_tokens/output_tokens/cache_read_input_tokens, stop_reason. Для схемы атрибутов есть стандарт — OpenTelemetry GenAI semantic conventions; использовать его дешевле, чем изобретать свои имена полей, потому что дашборды и SDK уже под него заточены. Готовые бэкенды — LangSmith, Langfuse, Phoenix; графовый рантайм с чекпойнтами и человеком в цикле — LangGraph.

Метрики, за которыми стоит следить в проде:

Метрика Что ловит
Доля задач, решённых без вмешательства главная бизнес-метрика; всё остальное — диагностика
Медиана и p95 числа шагов рост p95 = деградация: агент начал блуждать
Доля вызовов с is_error по инструментам плохая схема или плохое описание конкретного инструмента
Доля траекторий, упершихся в лимит либо задачи усложнились, либо цикл зациклился
Стоимость на решённую задачу а не на запрос — иначе провалы выглядят «дёшево»
Cache hit rate падение до нуля = кто-то добавил дату в system-промпт

Бенчмарки, по которым сравнивают агентов (подробно — в оценке и бенчмарках):

  • SWE-bench и SWE-bench Verified — правки реальных багов в open-source-репозиториях; вердикт даёт тестовый набор, а не судья-модель.
  • τ-bench (arXiv:2406.12045) и его развитие τ²-bench (arXiv:2506.07982) — агент против симулированного пользователя в домене с правилами и API. Здесь особенно нагляден разрыв между pass@1 и pass@k: агент решает задачу с первой попытки заметно чаще, чем стабильно с восьмой, — это прямая мера ненадёжности.
  • GAIA (arXiv:2311.12983) — вопросы, требующие многошагового поиска и работы с файлами.
  • WebArena (arXiv:2307.13854) — задачи в реалистичных веб-окружениях.
  • BFCL — узко про качество вызова функций: правильный ли инструмент, правильные ли аргументы, умеет ли модель воздержаться от вызова.

Смотрите не только на среднее качество, но и на дисперсию по повторным прогонам. Агент со средним 60% и разбросом 15 п.п. в проде хуже, чем агент со средним 55% и разбросом 3 п.п.: второй предсказуем, а с первым вы не сможете дать пользователю никакого обещания.

Мини-итог

  • Агент — это цикл «модель выбирает действие → среда возвращает наблюдение → контекст растёт». Всё остальное надстройка. Если пути известны заранее — пишите workflow, он дешевле и отлаживается.
  • ReAct работает потому, что наблюдения якорят рассуждение, а рассуждение направляет действия. Убери одно — получишь либо галлюцинирующую цепочку, либо слепое дёрганье API.
  • Текстовый Thought/Action/Observation — исторический формат; сегодня это нативный tool calling со строгими схемами, и парсинг перестаёт быть источником отказов.
  • Инструменты — это UI для модели. Узкая гранулярность, описания в стиле «когда звать и когда не звать», обрезанные наблюдения, ошибки с подсказкой. Переработка ACI даёт больше, чем смена модели.
  • Стоимость траектории растёт как $O(N^2)$ по токенам. Главный рычаг — размер наблюдения, потом компакция, потом кэш префикса.
  • Память бывает четырёх уровней, и три из них существуют, чтобы первый оставался маленьким. Файл-скрэтчпад — самый недооценённый приём для длинных задач.
  • Цикл обязан иметь лимиты по шагам, токенам, деньгам и времени, детект зацикливания, идемпотентные инструменты и пошаговую трассу. Без этого он не готов к проду ни в каком виде.
  • Рефлексия помогает только при внешнем сигнале верности. Самопроверка без новой информации чаще ломает верные ответы, чем чинит неверные.

Источники

Что дальше

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

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

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов