Перейти к содержимому
23/30Глава 23 из 30

Собираем agent harness: цикл и пять выходов из него

15-строчный цикл, который работает сразу, а затем семь намеренных поломок — начиная с runaway в 77 раз дороже лимитированного.

На этой странице

Начнём с честного, потому что больше этого никто не скажет: «harness» — это жаргон, а не стандарт. Нет спецификации, нет комитета, нет эталонного определения. Четыре работы, на которые ссылается эта глава, — ReAct,1 CoALA,2 SWE-bench и vLLM — ни разу не используют это слово в своих аннотациях. Самая скачиваемая реализация этой штуки, пакет Vercel ai с 89,4 миллиона скачиваний в месяц, тоже его не использует: строка harness встречается ноль раз в 397 КБ деклараций типов, поставляемых версией 7.0.93.3 Единственное место, где слово действительно несёт смысловую нагрузку, означает совсем другое. SWE-bench пять раз говорит «harness» в README, всегда как evaluation harness — контейнеризированный каркас, который применяет патч и запускает тесты, — а его Python-модуль буквально называется swebench.harness.run_evaluation.4

Так что два разных предмета делят одно имя. Evaluation harness удерживает agent на месте и оценивает его. agent harness — это программа, которая запускает agent: вызывает модель, выполняет то, что модель запрашивает, решает, когда остановиться, и хранит состояние между шагами. Эта глава строит второй вариант: меньше чем за двести строк TypeScript и вообще без framework.

Сам цикл занимает пятнадцать строк и работает с первой попытки. Всё после этого — способы из него выйти.

Показать детали

Что этой главе нужно из предыдущих.

  • Глава 14 — для клиента: дедлайны, триаж статусов, отмена, ключи идемпотентности и техника mock provider, снова используемая здесь.
  • Глава 16 — для арифметики: input tokens растут с квадратом разговора, а тарифы ниже — те, что были прочитаны там 6 сентября 2026 года.
  • Глава 18 — для каталога инструментов: schema, которую видит модель, endpoint, которого она никогда не видит, и правило, что ошибки — это context, а не исключения.
  • Глава 22 — для цикла, который наследует эта глава, и для двух опубликованных определений «agent», не согласных друг с другом.

Здесь нет tensors. Это второй узел зависимостей курса: главы 24, 25, 29 и 30 работают на файле ниже, а 26–28 строятся на том, до чего он может дотянуться.

Provider, который можно сценаризировать

Ссылка на раздел: Provider, который можно сценаризировать

Главу 14 нельзя было писать против настоящего provider, потому что нельзя попросить его выдать 429 в выбранный момент. У этой главы та же проблема в другой форме: нельзя попросить настоящую модель уйти в runaway или воспроизводимо запросить один и тот же инструмент два раза подряд по требованию.

Поэтому первая программа — scripted provider: endpoint в форме chat completions API, чей ответ является функцией индекса хода и того, что инструменты уже вернули. Он считает tokens настоящим byte-pair encoder, так что деньги ниже — арифметика, а не декорация.

mock-provider.mjsJS
const SCRIPTS = {
  // A well-behaved task: list, read, answer.
  plan: (t) =>
    t === 0 ? asks(call("c1", "list_files", {}))
    : t === 1 ? asks(call("c2", "read_file", { path: "errors.log" }))
    : text("errors.log mentions a timeout: worker 7 timed out after 30000 ms."),

  // Never declares itself done.
  runaway: (t) => asks(call(`c${t}`, "list_files", {})),           

  // Guesses a file name, then corrects itself IF it was told what happened.
  recover: (t, all) =>
    t === 0 ? asks(call("c1", "read_file", { path: "timeout.log" }))
    : /Call list_files/.test(all)                                  
      ? (t === 1 ? asks(call("c2", "list_files", {}))
        : t === 2 ? asks(call("c3", "read_file", { path: "errors.log" }))
        : text("errors.log mentions a timeout."))
      : text("I could not read the file, so I do not know."),
};

const turn = messages.filter((m) => m.role === "assistant").length;             
const toolText = messages.filter((m) => m.role === "tool").map((m) => m.content).join("\n");
const message = SCRIPTS[scenario](turn, toolText);

Две строки несут весь дизайн. Индекс хода выводится из разговора, а не хранится в переменной, поэтому provider не имеет состояния, и запуск можно убить, а затем возобновить против него. А recover читает результаты инструментов перед решением: scripted model, которая читает собственную стенограмму, — минимум, нужный для измерения того, дал ли ей harness хоть что-то стоящее прочтения.

Каталог — из главы 18: четыре инструмента в трёх файлах: list_files, read_file, delete_file — помечен needsApproval — и scan_archive, намеренно медленный.

Вот вся идея целиком, до любых деталей, которые делают её живучей.

loop.tsTS
while (true) {
  const reply = await callModel(base, messages, tools, signal);
  messages.push(reply.message);

  const calls = reply.message.tool_calls ?? [];
  if (!calls.length) return reply.message.content;          

  for (const c of calls) {
    const tool = byName.get(c.function.name);
    const result = await tool.run(JSON.parse(c.function.arguments));
    messages.push({ role: "tool", tool_call_id: c.id, name: c.function.name, content: result });
  }
}

Направьте его на scripted provider, и он делает ровно то, на что похож:

TEXT
plan, cap 20    turns=3  tools=2  in=815  out=70  cost=$0.002470  ms=89  status=completed
   answer: "errors.log mentions a timeout: worker 7 timed out after 30000 ms."
   per-turn prompt tokens: 204, 269, 342

Три хода, два выполнения инструментов, четверть цента США. Обратите внимание на последнюю строку: 204, 269, 342. Каждый ход заново отправляет всё, что было до него, — это квадратичный счёт из главы 16 пришёл туда, где никто ничего не печатал. Остальная часть этой главы — о том, что происходит, когда эта строка не перестаёт расти.

Поломка первая: задача, которая никогда не заканчивается

Ссылка на раздел: Поломка первая: задача, которая никогда не заканчивается

Направьте тот же цикл на сценарий runaway — модель, которая каждый ход просит инструмент и никогда не выдаёт прозу, — и помеченный return никогда не сработает. Другого выхода нет. Программа работает, пока не умрёт процесс или кредитная карта.

Исправление — одна строка, это первый контроль, который рекомендует литература,5 и рано или поздно его пишут все. Почти никто не измеряет, сколько он стоит:

лимит ходоввызовы моделиinput tokensстоимость
883,431$0.009070
202016,259$0.038038
505088,649$0.191098
100100337,299$0.702198

Читайте две последние строки вместе. Удвоение лимита с 50 до 100 не удвоило стоимость; оно умножило её на 3,7. Input tokens выросли с 88,649 до 337,299, то есть в 3,8 раза, потому что ход nn несёт с собой каждый предыдущий ход, а сумма равна Θ(n2)\Theta(n^2). Лимит ходов — не линейная ручка. Это ручка на квадратном корне вашего худшего случая, поэтому поднять её с 20 до 100 «на всякий случай» — решение, цену которого стоит узнать заранее.

Поломка вторая: лимит ходов не является лимитом денег

Ссылка на раздел: Поломка вторая: лимит ходов не является лимитом денег

Проблема с лимитом ходов в том, что ход не имеет фиксированной цены. Двадцать ходов по короткой стенограмме выше стоили $0.038. Двадцать ходов с каталогом из 200 инструментов, набором retrieved documents и сорока сообщениями истории стоят в сотни раз больше, и лимит об этом не знает. Оператор хочет ограничить счёт.

Поэтому цикл считает деньги, используя computeCost из главы 16 против тарифов, прочитанных там: $2.00 за миллион input tokens и $12.00 за миллион output для модели, тарифицируемой во всём курсе:

harness.tsTS
const PRICE_IN = 2.0 / 1e6, PRICE_OUT = 12.0 / 1e6;
export const cost = (u: Usage) => u.prompt_tokens * PRICE_IN + u.completion_tokens * PRICE_OUT;

// at the top of every iteration, before asking the model anything:
if (state.turns >= opts.limits.maxTurns) return stop("max_turns_exceeded", { type: "max_turns" });
if (state.costUsd >= opts.limits.maxBudgetUsd) return stop("budget_exceeded", { type: "max_budget" }); 

// ...and once the reply is back, before anything else happens with it:
state.costUsd += cost(reply.usage);

Тот же runaway script, вообще без лимита ходов, три бюджета:

бюджетдостигнуто ходовфактически потрачено
$0.019$0.010780
$0.0524$0.051790
$0.2052$0.205398

Стоит назвать две вещи. Во-первых, бюджет каждый раз покупает разное число ходов, и в этом смысл: он ограничивает то, что важно оператору, а счётчику ходов позволяет упасть туда, куда его ставит стенограмма. Во-вторых, каждая строка перерасходует. Бюджет был $0.010, а потрачено $0.010780, потому что проверка выполняется перед ходом, а цена хода неизвестна, пока он не закончится. Нельзя ограничить расходы абсолютно точно; можно ограничить их с точностью до стоимости одного хода. Скажите это в интерфейсе, а не притворяйтесь, и ставьте проверку перед вызовом, чтобы перерасход был один ход, а не два.

Пять способов выйти из цикла, а не один

Ссылка на раздел: Пять способов выйти из цикла, а не один

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

как он заканчиваетсякто решилчто должен сделать caller
модель перестала проситьмодельпрочитать ответ
лимит ходоввы, заранееподнять лимит или принять частичный результат
бюджет исчерпанвы, заранееодобрить больше денег или принять частичный результат
ошибка, которую нельзя retryprovider или инструментисправить deployment; решает триаж из главы 14
вмешался человекчеловекдождаться вердикта, затем возобновить

Схлопывание этого в один boolean — самая частая проектная ошибка в этом файле, и она дорого обходится конкретным образом: три из пяти вариантов возобновляемы, а два нет. agent, упёршийся в лимит ходов, имеет валидную стенограмму, настоящий частичный результат и следующий шаг; agent, получивший 401, не имеет ничего из этого. Поэтому harness записывает причину как данные:

harness.tsTS
export type RunStatus =
  | "running" | "completed" | "failed"
  | "max_turns_exceeded" | "budget_exceeded" | "interrupted";

export type Interruption =
  | { type: "approval"; callId: string; toolName: string; args: unknown }
  | { type: "max_turns" } | { type: "max_budget" }
  | { type: "cancelled"; reason: string };

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

Один сбой, три политики. Scripted model угадывает файл, которого не существует; инструмент бросает no such file: timeout.log. Call list_files to see what exists.

что harness делает с ошибкойходызапуски инструментовстоимостьчто получил пользователь
выбрасывает её из цикла11$0.000756stack trace
возвращает Error: the tool failed.21$0.001462«Я не смог прочитать файл, поэтому не знаю.»
возвращает то, что произошло на самом деле43$0.003550«errors.log упоминает timeout.»

Третья строка стоит в 4,7 раза больше первой и единственная отвечает на вопрос. А вторая строка интересна потому, что именно так поступает большинство codebases: ошибка поймана, цикл выжил, модели сказали, что что-то сломалось, но не что именно, и она вежливо сдалась. Разница между строками два и три — не error handling. Это предложение, написанное для читателя.

Поэтому harness трактует брошенный инструмент как данные и делает формулировку политикой:

harness.tsTS
} catch (err: any) {
  if (signal.aborted) return stop("interrupted", { type: "cancelled", reason: String(signal.reason) });
  if (opts.toolErrorsAreFatal) { state.error = err.message; return stop("failed"); }
  result = (opts.toolErrorText ?? ((e: Error) => `Error: ${e.message}`))(err);   
}

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

TEXT
read a file the process may not open   turns=12  toolruns=11  in=7,079  cost=$0.018622
                                      status=max_turns_exceeded   answer=""

Одиннадцать идентичных выполнений вызова, который не может преуспеть, 5,2 раза стоимости запуска, восстановившегося после исправимой ошибки, и ничего в конце. Ошибки — это context; постоянная ошибка — это context, который отравляет остальную часть запуска. Различие — это триаж статусов из главы 14, поднятый на слой выше: ошибка, с которой модель может что-то сделать, возвращается в стенограмму, а ошибка, с которой не может, должна остановить запуск с причиной. Сегодня между вами и вторым случаем стоит лимит ходов, а это пол, не исправление.

Поломка четвёртая: тот же вызов дважды

Ссылка на раздел: Поломка четвёртая: тот же вызов дважды

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

Измерено против baseline той же задачи без повтора:

ходызапуски инструментовстоимость
задача, без повтора21$0.001396
та же задача, один вызов повторён32$0.002446
повтор, с кэшем результата на read-only инструментах31$0.002446

Дублированный вызов стоил дополнительные $0.001050, рост на 75 %, и вот что удивляет людей: кэширование результата не вернуло ничего. Deduplication сэкономила выполнение инструмента, но не ход, потому что к тому моменту, когда ваш код замечает повтор, модели уже заплатили за просьбу. Экономия реальна, когда инструмент медленный, rate-limited или тарифицируется за вызов, — и равна нулю в той статье расходов, которая выросла.

Есть версия хуже. Примените тот же кэш к инструменту, который пишет, и второй вызов тихо не произойдёт:

TEXT
naive cache on every tool        3 turns, 1 tool run,  files deleted: ["access.log"]
cache only on read-only tools    3 turns, 2 tool runs, files deleted: ["access.log","access.log"]

Что из этого правильно? Ни то ни другое, и это известно. Protocol говорит, что это два вызова: они несут два разных значения tool_call_id. Аргументы говорят, что, возможно, это один. harness, который решает сравнением строк аргументов, однажды проглотит второй из двух идентичных, намеренных списаний — а глава 14 уже назвала единственный механизм, который честно это разрешает: idempotency key, сгенерированный на логическую операцию слоем, который знает, что это за операция. Пока инструмент не несёт такой ключ, защищаемая по умолчанию позиция — read-only gate выше: кэшировать чтения, выполнять записи и позволить собственной идемпотентности записи обработать остальное.

harness.tsTS
if (opts.dedupe && (tool.readOnly || opts.dedupeAll) && seen.has(signature)) {   
  state.messages.push({ role: "tool", tool_call_id: c.id, name: c.function.name, content: seen.get(signature)! });
  continue;
}

Сценарий destructive перечисляет файлы, а затем просит удалить один, который задача никогда не упоминала. Ничто в цикле до сих пор его не остановит.

Инструмент, помеченный needsApproval, не падает и не продолжается. Он останавливает запуск и возвращает управление, со всем, что нужно человеку для решения:

harness.tsTS
if (tool.needsApproval && !state.approved.includes(c.id)) {
  trace(state.runId, "approval_required", { toolName: tool.name, args: c.function.arguments, callId: c.id });
  return stop("interrupted", { type: "approval", callId: c.id, toolName: tool.name, args: JSON.parse(c.function.arguments) });
}
TEXT
stopped at turn 2: interrupted / approval -> delete_file({"path":"access.log"})
files deleted so far: []
approve -> total turns=3  deleted=["access.log"]  "Deleted access.log to free space."
reject  -> total turns=3  deleted=[]              "I did not delete anything: you declined the deletion."

Это весь механизм, и причина, по которой это return, а не callback, — следующий раздел: между остановкой и вердиктом процесса может уже не существовать.

Но сначала измерение, которого никто не ожидает. Отклонение — не отсутствие результата: в стенограмме есть слот с ключом tool_call_id, и в него должно что-то попасть. Запустите одно и то же отклонение дважды, меняя только то, что именно туда записано:

TEXT
rejected with a reason   deleted=[]  the agent then told the user:
                                     "I did not delete anything: you declined the deletion."
rejected with nothing    deleted=[]  the agent then told the user:
                                     "Deleted access.log to free space."

В обоих запусках ничего не было удалено, а во втором пользователю сказали, что было. Permission system сработала идеально; отчёт — ложь. Это тот же механизм, что и в таблице ошибок инструментов, только в месте куда важнее: человек сказал «нет», действие корректно заблокировано, а summary agent противоречит реальности, потому что отказ не был записан там, где модель читает. Правило отсюда короткое: что бы ваш код ни решил о вызове инструмента, запишите это решение в стенограмму словами. Глава 30 возвращается к этому со стороны безопасности, где это разница между audit trail и вымыслом.

Approval занимает минуты или часы. Deploy занимает секунды. Если запуск живёт в локальной переменной внутри HTTP-запроса, каждый рестарт — потерянный запуск, а каждый approval — гонка.

Поэтому запуск — не closure. Это обычный serialisable object: сообщения, счётчик ходов, стоимость, статус, interruption, список approved call ids — а цикл является pure function над ним. Одно это ограничение делает persistence заботой в одну строку:

harness.tsTS
export const save = (s: RunState, dir: string) => writeFileSync(`${dir}/${s.runId}.json`, JSON.stringify(s));
export const load = (dir: string, runId: string) => JSON.parse(readFileSync(`${dir}/${runId}.json`, "utf8"));

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

Исправление — заставить цикл начинать с вопроса стенограмме: что ещё outstanding?

harness.tsTS
export function pending(state: RunState): ToolCall[] {
  const answered = new Set(state.messages.filter((m) => m.role === "tool").map((m) => m.tool_call_id));
  const last = state.messages.at(-1);
  if (last?.role !== "assistant") return [];
  return (last.tool_calls ?? []).filter((c) => !answered.has(c.id));    
}

Каждая итерация сначала опустошает pending и спрашивает модель только когда outstanding больше ничего нет. Resume становится тем же code path, что и обычный ход, как и approval: одобренный вызов — это просто pending call, которому теперь разрешено выполниться. Убейте процесс в середине задачи и перезапустите его:

TEXT
process died after turn 2. tool runs so far: list_files, read_file:errors.log
restored from disk: turns=2  cost=$0.001570  messages=6  status=running
resumed and finished: turns=3  cost=$0.002470  status=completed
tool runs across BOTH processes: list_files, read_file:errors.log

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

scan_archive занимает здесь три секунды и заменяет инструмент, который в продакшене занимает три минуты. Пока он работает, не хватает двух вещей: пользователь не понимает, что что-то происходит, а кнопка Stop ничего не делает.

У обеих проблем одно исправление, и это AbortSignal из главы 14, протянутый на уровень глубже. Сигнал не только для fetch — он передаётся внутрь инструмента, и хорошо написанный инструмент его уважает:

harness.tsTS
result = await tool.run(JSON.parse(c.function.arguments), {
  signal,                                                                     
  progress: (label) => { trace(state.runId, "tool_progress", { toolName: tool.name, label }); opts.onProgress?.(label); },
});
TEXT
progress: scanned 200 of 1200 files  (t+506 ms)
progress: scanned 400 of 1200 files  (t+1007 ms)
no cancellation:            stopped after 3,015 ms, status=completed
user presses Stop at 1.2 s: stopped after 1,202 ms, status=interrupted, reason="user pressed Stop"

Две миллисекунды от клика до остановки, потому что sleep внутри инструмента слушает тот же сигнал, что и fetch. Протяните его только в fetch, и та же кнопка Stop будет ждать три секунды — длину работы инструмента, — а запуск «отменится» после того, как отменяемая работа уже закончилась. Cancellation, не проложенная до самого низа, — это spinner, который говорит правильное слово.

harness emits одну строку на событие, а словарь достаточно мал, чтобы его запомнить: turn, tool_start, tool_progress, tool_result, approval_required, run_stopped.

TEXT
{"runId":"n1","type":"turn","turn":1,"prompt_tokens":204,"completion_tokens":23,"total_tokens":227,"costUsd":0.000684,"finish":"tool_calls"}
{"runId":"n1","type":"tool_start","toolName":"list_files","args":"{}","callId":"c1"}
{"runId":"n1","type":"tool_result","toolName":"list_files","ms":1,"ok":true}
{"runId":"n1","type":"turn","turn":2,"prompt_tokens":269,"completion_tokens":29,"total_tokens":298,"costUsd":0.00157,"finish":"tool_calls"}
{"runId":"n1","type":"approval_required","toolName":"delete_file","args":"{\"path\":\"access.log\"}","callId":"c2"}
{"runId":"n1","type":"run_stopped","status":"interrupted","reason":"approval","turns":2,"costUsd":0.00157}

Три свойства делают это trace, а не logging. Каждая строка несёт run id, так что запуск, растянутый на три процесса и два дня, — это один query. Каждая строка turn несёт собственные счётчики tokens и текущую стоимость, так что на вопрос «почему этот запуск стоил сорок долларов» можно ответить постфактум, а не только теоретически воспроизвести. А run_stopped несёт причину — поле, которое превращает support ticket в ответ на одну строку: agent, остановившийся на бюджете, и agent, который crashed, снаружи выглядят одинаково, но требуют противоположных реакций.

Глава 13 измеряла time to first token на вашем железе. Глава 14 измеряла его через socket. agent умножает его, и множитель — число, которое никто не выбирал:

TrunN(tmodel+ttools)T_{\text{run}} \approx N \cdot \left( t_{\text{model}} + t_{\text{tools}} \right)

Та же трёхходовая задача, меняется только latency provider:

latency provider на ходwall clock, 3 хода
0 ms15 ms
200 ms615 ms
800 ms2,413 ms

Сам harness добавляет пятнадцать миллисекунд к трёхходовому запуску. Всё остальное — NN, умноженное на число, которое вы не контролируете, — заданное внутри serving scheduler, который batching ваш запрос с запросами незнакомцев6, — а NN выбирает модель. Поэтому streaming из главы 14 важнее здесь, чем в chat, и помогает меньше: вы можете stream последний ход, а четыре хода до него — тишина, если harness не emits progress. Это также весь аргумент в пользу события tool_progress выше: в agent честная единица обратной связи — не token, а step.

Тот же harness, настоящая модель за портом

Ссылка на раздел: Тот же harness, настоящая модель за портом

Всё выше работало против scripted provider, что доказывает harness и ничего не доказывает о моделях. Поэтому меняем одну строку — seam из главы 14, LLM_BASE_URL — и направляем идентичный код на локальную Qwen2.5-0.5B-Instruct с теми же четырьмя инструментами. Шесть задач по тем же трём файлам:

TEXT
turns=2 tools=1 wall= 15,260ms  Which file mentions a timeout?      -> "The file timeout.txt does not exist..."
turns=2 tools=1 wall= 13,037ms  How many files are in the directory? -> "There are three files..."
turns=2 tools=1 wall= 10,121ms  Read notes.txt and tell me what it says. -> "Remember to rotate your logs."
turns=2 tools=2 wall= 21,290ms  List the files and then read each one.
turns=2 tools=1 wall= 10,698ms  Which file is the largest?          -> "The largest file is access.log."
turns=2 tools=1 wall= 12,490ms  Is there a file about rotating logs?
TOTAL turns=12  toolruns=7  wall=82,896ms  mean turn=6,908ms

Три вывода, и третий — причина существования этого раздела.

Каждая отдельная задача завершилась ровно за два хода. Лимит ходов не сработал, бюджет не сработал, а единственным выходом цикла стала проза модели. Модель на полмиллиарда параметров не итерирует; она отвечает на втором дыхании, независимо от того, есть ли у неё то, что нужно. Число ходов — свойство модели, а не вашего цикла.

Средний ход занял 6,908 миллисекунд, так что таблица latency выше — не игрушка: при таком размере гипотетический восьмиходовый запуск — почти минута wall clock без ничего на экране.

И ответы неверны. Самый большой файл — errors.log; модель перечислила файлы, не прочитала их и всё равно назвала один. В первой задаче она угадала имя файла, ей сказали, что его не существует, и она сделала вывод. harness во всех шести запусках сработал безупречно. harness делает agent управляемым, а не правильным — глава 29 о том, как выяснить разницу, а глава 30 — о том, сколько это стоит, когда никто её не выяснил.

Subagents, названы здесь и тарифицируются позже

Ссылка на раздел: Subagents, названы здесь и тарифицируются позже

Один инструмент в каталоге может иметь за собой другой запуск. Интерфейс — из главы 18: schema и endpoint, — и целый agent помещается за ним, потому что интерфейс узкий:

subagent.tsTS
const research: Tool = {
  name: "research",
  description: "Investigate one question and return a short summary.",
  parameters: { type: "object", properties: { question: { type: "string" } }, required: ["question"] },
  readOnly: true,
  async run(args, ctx) {
    const child = newRun(RESEARCH_SYSTEM, args.question);        // its own transcript
    const out = await run(child, researchTools, { base, limits: { maxTurns: 6, maxBudgetUsd: 0.05 }, signal: ctx.signal });
    return out.output ?? "no result";
  },
};

Три вещи уже правильны в этих десяти строках, и все три являются следствием решений выше: у child есть собственное window, поэтому стенограмма parent получает summary, а не всё, что child прочитал; у него есть собственные limits, поэтому runaway child не может потратить бюджет parent; и он наследует signal, поэтому один Stop отменяет всё дерево. Почему чистое window — смысл, а не побочный эффект, объясняет глава 24; пять orchestration patterns — prompt chaining, routing, parallelisation, orchestrator-workers, evaluator-optimiser — и handoff — глава 25.

Где frameworks, и почему этот курс не использовал ни один

Ссылка на раздел: Где frameworks, и почему этот курс не использовал ни один

Ничто выше не следует читать как аргумент против библиотек. Измерено 7 сентября 2026 года, за месяц, закончившийся 29 августа:7

packageскачивания за этот месяцчто он даёт
ai (Vercel AI SDK)89,385,860ToolLoopAgent, stopWhen, approval инструментов, step hooks
@anthropic-ai/claude-agent-sdk41,558,352Claude Code harness как библиотека: цикл, sessions, hooks, permissions, subagents8
@langchain/langgraph12,812,815цикл как явный state graph
langchain11,359,058chains, agents, integrations
@openai/agents6,093,155agents, handoffs, guardrails
@mastra/core5,914,502agents, workflows, memory

Причина, по которой этот курс пишет цикл вручную, а не учит одному из них, заявлена прямо, а не подразумевается, и её можно измерить. За двенадцать месяцев до 7 сентября 2026 года ai опубликовал 945 версий и перешёл с major 5 на major 7, а его класс agent всё ещё экспортируется как Experimental_Agent; langchain опубликовал 132 версии за то же окно; @openai/agents опубликовал 83 и всё ещё на 0.x через пятнадцать месяцев после первого релиза.7 Глава, написанная под любой из этих API, устаревает за сезон, а эта публикуется на тридцати трёх языках, так что каждое переиздание стоит всей переведённой версии. То, что лежит под ними всеми, не движется: цикл, stopping rule, каталог, executor, немного state.

И reference implementation согласна с этой главой в главном. В ai версии 7.0.93 выход из цикла — не число, а stopWhen, список predicates, из которых счётчик steps — лишь один:3

ai-sdk.tsTS
type StopCondition<TOOLS extends ToolSet> = (options: { steps: Array<StepResult<TOOLS>> }) => PromiseLike<boolean> | boolean;
declare function isStepCount(stepCount: number): StopCondition<any, any>;   // exported as stepCountIs

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

Теперь у вас есть harness: цикл, каталог, executor, пять выходов, persisted run, signal, доходящий до инструментов, и trace с run id в каждой строке. Главы 24, 25, 29 и 30 строятся на этом файле, а 26–28 — на том, до чего он может дотянуться.

Осталась одна проблема, и измерения выше всё время на неё указывали. Посмотрите ещё раз на таблицу runaway: 3,431 input tokens на восьми ходах, 337,299 на сотне. Посмотрите на рабочий запуск: 204, 269, 342. Каждый ход заново отправляет всю стенограмму, поэтому context agent заполняется собственной историей — а модель хуже использует дальний конец длинного window, чем ближний, вот почему хороший agent на пятом ходу становится растерянным на сороковом.

Лимит ходов это не исправляет. Он просто не даёт вам платить за просмотр происходящего. Исправляет это решение на каждом отдельном ходе: какие tokens заслуживают window, что compact, что вынести в заметку, которую agent сможет fetch, что отдать subagent с чистым window, и какие определения инструментов стоят своего постоянного налога. Глава 24 измеряет, куда window реально уходит, — и сюрприз в том, что не в разговор.


Каждое число в этой главе получено из двух серверов, описанных выше, на Node 22 через loopback interface: scripted provider, считающий tokens с encoding o200k_base, и Qwen/Qwen2.5-0.5B-Instruct за endpoint той же формы, greedy decoding, на CPU. Стоимости вычислены по измеренным token counts на тарифах, которые глава 16 прочитала 6 сентября 2026 года: $2.00 за миллион input tokens и $12.00 за миллион output; ни один request в этой главе не ушёл в paid endpoint. Ответы локальной модели — ответы маленькой модели; читайте их как evidence о цикле, который в обоих случаях идентичен, а не как benchmark того, что делают current models.

  1. Yao, S., Zhao, J., Yu, D., Du, N., Shafran, I., Narasimhan, K. and Cao, Y. ReAct: Synergizing Reasoning and Acting in Language Models. arXiv:2210.03629 (2022). Чередование reasoning traces и actions, которое реализует цикл, и источник наблюдения, что действие позволяет модели «handle exceptions», — ровно это измеряет таблица ошибок инструментов выше.

  2. Sumers, T. R., Yao, S., Narasimhan, K. and Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023). Формальная трактовка того, что цикл выше делает неформально: модульные компоненты памяти, структурированное пространство действий, охватывающее внутреннюю память и внешние среды, и «обобщённый процесс принятия решений для выбора действий». Читайте ради словаря, которого не хватает отраслевому термину, — особенно разделения working, episodic, semantic и procedural memory, чья практическая тень — таблица трёх хранилищ в главе 24.

  3. ai (Vercel AI SDK) версии 7.0.93, опубликован 4 сентября 2026 года; декларации типов прочитаны из cdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.ts 7 сентября 2026 года. Файл размером 397 КБ содержит ноль вхождений строки harness. Класс agent — declare class ToolLoopAgent, экспортируется и как ToolLoopAgent, и как Experimental_Agent; declare function isStepCount(stepCount: number) — экспортируемый как stepCountIs — дословно процитирован выше; type StopCondition показан без второго type parameter (RUNTIME_CONTEXT extends Context = Context), что является единственным пропуском во фрагменте, как и форма stopWhen?: Arrayable<StopCondition<...>> на generateText и streamText. Тот же файл объявляет toolApproval, ToolApprovalStatus, prepareStep и repairToolCall, то есть reference implementation независимо пришла к approval gates, per-step preparation и error repair. 2

  4. Jimenez, C. E., Yang, J., Wettig, A., Yao, S., Pei, K., Press, O. and Narasimhan, K. SWE-bench: Can Language Models Resolve Real-World GitHub Issues? arXiv:2310.06770 (2023). Аннотация называет артефакт «evaluation framework» из 2,294 задач и никогда не использует слово «harness»; собственный README проекта (github.com/SWE-bench/SWE-bench, прочитан 7 сентября 2026 года) использует его пять раз, всегда как «evaluation harness», а entry point — python -m swebench.harness.run_evaluation. Это другой смысл слова: каркас, который удерживает agent на месте и оценивает его, а не цикл, который его запускает.

  5. Anthropic, Building effective agents, 19 December 2024, anthropic.com/engineering/building-effective-agents, прочитано 7 сентября 2026 года. Augmented model как строительный блок, agent как LLM, «using tools based on environmental feedback in a loop», и рекомендация stopping conditions, «such as a maximum number of iterations», для сохранения контроля. Глава 22 цитирует это определение полностью.

  6. Kwon, W., Li, Z., Zhuang, S., Sheng, Y., Zheng, L., Yu, C. H., Gonzalez, J. E., Zhang, H. and Stoica, I. Efficient Memory Management for Large Language Model Serving with PagedAttention. arXiv:2309.06180 (2023). Другой цикл — serving scheduler, который batching ваш запрос с запросами незнакомцев и управляет KV cache из главы 13. О его существовании стоит знать именно потому, что он не ваш: latency, которую умножает ваш harness, задаётся внутри него, и никакая работа над вашим циклом её не сдвинет.

  7. Счётчики скачиваний npm registry, api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, явное окно вместо rolling last-month, и истории релизов из registry.npmjs.org/<package>; оба запрошены 7 сентября 2026 года. Счётчики релизов — число версий, опубликованных за двенадцать месяцев до этой даты, включая canary builds: ai 945 (последняя 7.0.93 от 2026-09-04, major versions 5, 6 и 7 все появляются внутри окна), langchain 132 (последняя 1.5.10 от 2026-08-20), @openai/agents 83 (последняя 0.17.0 от 2026-08-19, впервые опубликован 2025-06-03). 2

  8. Claude Agent SDK (@anthropic-ai/claude-agent-sdk) — это Claude Code harness, упакованный как библиотека: agent loop, built-in file and shell tools, context management, sessions, hooks, permissions и subagents — документирован на code.claude.com/docs/en/agent-sdk. Это ближайшее к опубликованному описанию каждого механизма, который эта глава строит вручную, и его стоит читать рядом с собственной реализацией ради частей, которые он называет, а эта глава лишь обозначает.

Готовы доверить выбор модели LIA?

Создавайте со всеми ИИ-моделями в одном месте — начните бесплатно уже сегодня.