Агенты и ReAct: цикл рассуждение-действие, инструменты, планирование, память
В продвинутых техниках промптинга модель думала внутри своей головы: цепочка мыслей, голосование, дерево. Всё это работает с одним фиксированным входом. Агент — это следующий шаг: модель получает право менять свой собственный вход, дёргая внешний мир и складывая ответы обратно в контекст.
Формально агент — это цикл: модель выбирает действие, среда возвращает наблюдение, наблюдение дописывается в контекст, модель выбирает следующее действие. Всё остальное — планирование, память, мультиагентность — надстройки над этими четырьмя строчками.
Когда агент нужен, а когда это дорогой способ выстрелить себе в ногу
Есть спектр от «одного вызова» до «полной автономии», и большая часть продакшн-систем живёт не на его правом конце.
- Один вызов — классификация, извлечение, суммаризация. Никакого цикла.
- Workflow (цепочка) — вы жёстко прописали последовательность шагов в коде, модель заполняет отдельные слоты. Роутер, «сначала retrieve, потом ответь», «сначала переведи, потом проверь». Путь задан вами.
- Агент — путь выбирает модель. Вы даёте инструменты и цель, но не знаете заранее, сколько будет шагов и какие.
Anthropic формулирует это правило прямо: «ищите самое простое решение и добавляйте сложность только когда она доказанно улучшает результат» (Building Effective Agents). Агентность — это не уровень качества, а способ обращения с неопределённостью. Если вы можете нарисовать граф шагов заранее — рисуйте, workflow дешевле, быстрее и отлаживается.
Четыре вопроса перед тем, как писать цикл:
- Сложность. Задачу реально нельзя специфицировать заранее? («Преврати этот дизайн-док в PR» — да. «Достань заголовок из PDF» — нет.)
- Ценность. Результат оправдывает 10–50-кратный рост стоимости и латентности относительно одного вызова?
- Выполнимость. Модель вообще умеет такое? Проверьте на 20 примерах вручную, прежде чем строить обвязку.
- Цена ошибки. Ошибку можно поймать и откатить — тестами, ревью, транзакцией? Если нет, агент должен спрашивать человека.
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)$ по токенам. Главный рычаг — размер наблюдения, потом компакция, потом кэш префикса.
- Память бывает четырёх уровней, и три из них существуют, чтобы первый оставался маленьким. Файл-скрэтчпад — самый недооценённый приём для длинных задач.
- Цикл обязан иметь лимиты по шагам, токенам, деньгам и времени, детект зацикливания, идемпотентные инструменты и пошаговую трассу. Без этого он не готов к проду ни в каком виде.
- Рефлексия помогает только при внешнем сигнале верности. Самопроверка без новой информации чаще ломает верные ответы, чем чинит неверные.
Источники
- Yao et al. ReAct: Synergizing Reasoning and Acting in Language Models — arXiv:2210.03629
- Shinn et al. Reflexion: Language Agents with Verbal Reinforcement Learning — arXiv:2303.11366
- Xu et al. ReWOO: Decoupling Reasoning from Observations — arXiv:2305.18323
- Wang et al. Plan-and-Solve Prompting — arXiv:2305.04091
- Yang et al. SWE-agent: Agent-Computer Interfaces Enable Automated Software Engineering — arXiv:2405.15793
- Schick et al. Toolformer — arXiv:2302.04761
- Packer et al. MemGPT: Towards LLMs as Operating Systems — arXiv:2310.08560
- Park et al. Generative Agents — arXiv:2304.03442
- Liu et al. Lost in the Middle — arXiv:2307.03172
- Huang et al. Large Language Models Cannot Self-Correct Reasoning Yet — arXiv:2310.01798
- Yao et al. τ-bench — arXiv:2406.12045; τ²-bench — arXiv:2506.07982
- Jimenez et al. SWE-bench — arXiv:2310.06770
- Mialon et al. GAIA — arXiv:2311.12983
- Zhou et al. WebArena — arXiv:2307.13854
- Liu et al. AgentBench — arXiv:2308.03688
- Anthropic. Building Effective Agents — инженерный блог
- Anthropic. Effective Context Engineering for AI Agents — инженерный блог
- Anthropic. Tool use — документация
- Berkeley Function-Calling Leaderboard — gorilla.cs.berkeley.edu
- OpenTelemetry. GenAI semantic conventions — спецификация
- LangGraph — документация
Что дальше
Один агент упирается в потолок довольно быстро: слишком много инструментов, слишком длинный контекст, некому проверить его же работу. Естественное продолжение — разложить задачу по нескольким специализированным агентам с ролями, оркестратором и отдельным критиком. Дальше — Мультиагентные системы: роли, оркестрация, критики, верификация.