跳到主要内容

主线:Runner 的 agent 循环

这一章讲整个 SDK 的「心跳」:run(agent, input) 内部那个 while 循环怎么把一次对话从输入跑到 finalOutput。读懂它,后面所有特性(工具、交接、人审)都只是往这条线上挂分支。

1. 它要解决的小问题

模型一次只会「说一段话」,但它说的话可能是「我要调 getWeather('Tokyo')」。这时候你得:解析它要调哪个工具、真的去调、把返回值拼成一条消息、再调一次模型让它看到结果继续。可能要来回好几拍才有最终答案。

这个来回,就是 agent loop。 Runner 把它做成一个稳定的循环,你不用手写。

2. 思路/直觉

循环的每一拍(turn)干三件事,对应三个核心函数:

  1. 准备这一拍 —— 拼历史、跑输入护栏、组装 system prompt + 工具列表,调模型。
  2. 分类模型说了啥 —— processModelResponse:把模型输出拆成「消息 / 函数调用 / 交接 / 计算机动作 / MCP 审批…」。
  3. 执行 + 决定下一步 —— resolveTurnAfterModelResponse:跑那些动作,然后贴一个标签 next_step_* 告诉循环:结束 / 再来一拍 / 换 agent / 暂停等人审。

循环顶部读这个标签,决定是 return 还是 continue

3. 图示:一拍里的状态流转

run.ts 顶部的 docstring 把规则写得很白(run.ts:520-533 Runner.run):调 agent → 有最终输出就停 → 有交接就换 agent 再循环 → 否则跑工具再循环。落到代码就是四个「下一步」标签(runner/steps.ts:6 nextStepSchema):

┌─────────────────────────────┐
│ while(true) 循环顶部 │
│ 读 state._currentStep │
└──────────────┬──────────────┘
┌───────────────────────────┼───────────────────────────┐
▼ ▼ ▼
next_step_run_again next_step_handoff next_step_interruption
(调模型这一整拍) 换当前 agent, 有待审批的工具:
│ 再 continue return RunResult ⏸
▼ │ (带 interruptions)
resolveTurnAfterModelResponse └──► continue


next_step_final_output ──► 跑输出护栏 ──► return RunResult ✅

四个标签的定义就是一个 zod discriminated union——简单到可以一眼看完:

// runner/steps.ts:6 nextStepSchema(真实源码,节选)
export const nextStepSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('next_step_handoff'), newAgent: z.any() }),
z.object({ type: z.literal('next_step_final_output'), output: z.string() }),
z.object({ type: z.literal('next_step_run_again') }),
z.object({ type: z.literal('next_step_interruption'), data: z.record(z.string(), z.any()) }),
]);

这四个标签就是整个引擎的控制流词汇表——后面每一章都在讲「什么情况贴哪个标签」。

4. 原理演示:把循环写成 30 行

下面这段把 Runner 的核心循环抽象成可读的伪实现,帮你建立直觉。重点看四个 next_step_* 分支怎么驱动循环:

// 示意,非源码 —— agent loop 的精神
async function runLoop(agent, input, maxTurns) {
const state = { currentAgent: agent, items: [], step: { type: 'run_again' }, turn: 0 };

while (true) {
if (state.step.type === 'interruption') {
return result(state); // 暂停等人审,把状态交还给调用方
}

if (state.step.type === 'run_again') {
if (++state.turn > maxTurns) throw new MaxTurnsExceededError();
const turnInput = buildInput(input, state.items); // 历史 + 上一拍结果
const response = await model.getResponse(turnInput); // ① 调模型
const processed = processModelResponse(response, agent); // ② 分类输出
state.step = await resolveTurn(processed, state); // ③ 执行 + 定下一步
}

switch (state.step.type) {
case 'final_output': return result(state); // ✅ 结束
case 'handoff': // 换 agent
state.currentAgent = state.step.newAgent;
state.step = { type: 'run_again' };
break;
// run_again / interruption 已在上面处理
}
}
}

真实的 Runner 比这多了护栏、会话持久化、sandbox、追踪、流式——但骨架就是这个。

5. 真实实现

5.1 循环主体

非流式循环在 run.ts:909 起的 while (true)(位于 Runner.#runIndividualNonStream)。逐段对应上面的演示:

  • 拿/建状态run.ts:834 —— 如果输入是一个 RunState(恢复场景)就直接用它,否则 new RunState(context, input, startingAgent, maxTurns)
  • 默认下一步run.ts:911 state._currentStep = state._currentStep ?? { type: 'next_step_run_again' } —— 新 run 第一拍就是 run_again
  • 准备这一拍run.ts:958 prepareTurn({...}) —— 它在这里启动输入护栏(可并行)、emit agent_start、拼出 turnInput
  • 调模型run.ts:1008 getResponseWithRetry(preparedCall.model, {...}) —— 带重试的模型调用。
  • 分类run.ts:1059 processModelResponseAsync(...)
  • 执行 + 定下一步run.ts:1073 resolveTurnAfterModelResponse(...),结果写回 state._currentSteprun.ts:1086 applyTurnResult)。
  • 读标签分发run.ts:1101switch (currentStep.type)——next_step_final_output 跑输出护栏后 return new RunResultrun.ts:1102-1120);next_step_handoff 换 agent 并把步骤重置为 run_againrun.ts:1121-1133);next_step_interruption 直接 returnrun.ts:1134)。

5.2 模型输出怎么被分类

processModelResponserunner/modelOutputs.ts:654)遍历 modelResponse.output 的每一项,按 output.type 归类,产出一个 ProcessedResponse:里面有 functions(要跑的函数工具)、handoffscomputerActionsshellActionsmcpApprovalRequests 等数组,外加一个关键方法 hasToolsOrApprovalsToRun()runner/modelOutputs.ts:926)——它告诉决议函数「这一拍模型到底有没有动作要执行」。

一个值得记住的细节:函数调用和「交接」长得一样(都是 function_call),靠名字区分。resolveFunctionOrHandoffrunner/modelOutputs.ts:131)先查 handoffMap 再查 functionMap,都没有就报 not_found

5.3 决定下一步

resolveTurnAfterModelResponserunner/turnResolution.ts:880)是「执行 + 决议」的合体:

  1. 并行跑函数工具和计算机动作turnResolution.ts:909 Promise.all([executeFunctionToolCalls, executeComputerActions]))——注释点明二者互不依赖;而 shell / apply_patch 因为都改 sandbox 文件系统,必须按模型给的顺序串行(turnResolution.ts:927orderShellAndApplyPatchActions)。
  2. 有交接就交接turnResolution.ts:995 —— 返回 executeHandoffCalls(...),其结果是 next_step_handoff
  3. 工具产出了最终答案 / 触发了中断maybeCompleteTurnFromToolResultsturnResolution.ts:1176)—— 处理 toolUseBehavior(如 stop_on_first_tool)和审批中断。
  4. 模型这一拍有动作但没结束next_step_run_againturnResolution.ts:1027,条件是 processedResponse.hasToolsOrApprovalsToRun())。
  5. 纯文本、没动作 → 取最后一条 assistant 消息当 potentialFinalOutput;若 agent 是 text 输出就直接 final(turnResolution.ts:1112),若是结构化输出就先用 schema parser 校验,校验失败抛 ModelBehaviorErrorturnResolution.ts:1125-1141)。

6. 关键细节/坑

  • 「同一拍里有工具调用,就不能把同拍的 assistant 文本当最终答案」:见 turnResolution.ts:1024-1027 的注释。否则模型「边说话边调工具」时会过早结束。这是个容易踩的语义坑,SDK 显式挡住了。

  • 只有第一个 agent 的输入护栏会跑run.ts:533 docstring 明说 “only the first agent's input guardrails are run”。交接后的新 agent 不重跑输入护栏。

  • 结构化输出的校验在循环里:如果你给 agent 设了 Zod outputType,模型返回的文本会被 parser 校验(turnResolution.ts:1127),失败会抛错而不是静默返回脏数据。

  • 流式是另一条几乎平行的循环Runner.#runStreamLooprun.ts:1188 起)逻辑与非流式高度对称,但多了「逐事件 emit」「中途取消时的 abort 对账」reconcileStreamAbortIfNeededrun.ts:1412)。两条循环共用 prepareTurn / resolveTurnAfterModelResponse / applyTurnResult,所以行为一致。

7. 代码地图

主题文件符号
顶层 runrun.tsrunRunner.run
非流式循环run.tsRunner.#runIndividualNonStream
流式循环run.tsRunner.#runStreamLoop
下一步标签runner/steps.tsnextStepSchemaSingleStepResult
输出分类runner/modelOutputs.tsprocessModelResponseresolveFunctionOrHandoff
一拍决议runner/turnResolution.tsresolveTurnAfterModelResponsemaybeCompleteTurnFromToolResults
应用下一步runner/runLoop.tsapplyTurnResultresumeInterruptedTurn