主线:Runner 的 agent 循环
这一章讲整个 SDK 的「心跳」:
run(agent, input)内部那个 while 循环怎么把一次对话从输入跑到finalOutput。读懂它,后面所有特性(工具、交接、人审)都只是往这条线上挂分支。
1. 它要解决的小问题
模型一次只会「说一段话」,但它说的话可能是「我要调 getWeather('Tokyo')」。这时候你得:解析它要调哪个工具、真的去调、把返回值拼成一条消息、再调一次模型让它看到结果继续。可能要来回好几拍才有最终答案。
这个来回,就是 agent loop。 Runner 把它做成一个稳定的循环,你不用手写。
2. 思路/直觉
循环的每一拍(turn)干三件事,对应三个核心函数:
- 准备这一拍 —— 拼历史、跑输入护栏、组装 system prompt + 工具列表,调模型。
- 分类模型说了啥 ——
processModelResponse:把模型输出拆成「消息 / 函数调用 / 交接 / 计算机动作 / MCP 审批…」。 - 执行 + 决定下一步 ——
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:911state._currentStep = state._currentStep ?? { type: 'next_step_run_again' }—— 新 run 第一拍就是run_again。 - 准备这一拍:
run.ts:958prepareTurn({...})—— 它在这里启动输入护栏(可并行)、emitagent_start、拼出turnInput。 - 调模型:
run.ts:1008getResponseWithRetry(preparedCall.model, {...})—— 带重试的模型调用。 - 分类:
run.ts:1059processModelResponseAsync(...)。 - 执行 + 定下一步:
run.ts:1073resolveTurnAfterModelResponse(...),结果写回state._currentStep(run.ts:1086applyTurnResult)。 - 读标签分发:
run.ts:1101的switch (currentStep.type)——next_step_final_output跑输出护栏后return new RunResult(run.ts:1102-1120);next_step_handoff换 agent 并把步骤重置为run_again(run.ts:1121-1133);next_step_interruption直接return(run.ts:1134)。
5.2 模型输出怎么被分类
processModelResponse(runner/modelOutputs.ts:654)遍历 modelResponse.output 的每一项,按 output.type 归类,产出一个 ProcessedResponse:里面有 functions(要跑的函数工具)、handoffs、computerActions、shellActions、mcpApprovalRequests 等数组,外加一个关键方法 hasToolsOrApprovalsToRun()(runner/modelOutputs.ts:926)——它告诉决议函数「这一拍模型到底有没有动作要执行」。
一个值得记住的细节:函数调用和「交接」长得一样(都是 function_call),靠名字区分。resolveFunctionOrHandoff(runner/modelOutputs.ts:131)先查 handoffMap 再查 functionMap,都没有就报 not_found。
5.3 决定下一步
resolveTurnAfterModelResponse(runner/turnResolution.ts:880)是「执行 + 决议」的合体:
- 并行跑函数工具和计算机动作(
turnResolution.ts:909Promise.all([executeFunctionToolCalls, executeComputerActions]))——注释点明二者互不依赖;而 shell / apply_patch 因为都改 sandbox 文件系统,必须按模型给的顺序串行(turnResolution.ts:927、orderShellAndApplyPatchActions)。 - 有交接就交接:
turnResolution.ts:995—— 返回executeHandoffCalls(...),其结果是next_step_handoff。 - 工具产出了最终答案 / 触发了中断:
maybeCompleteTurnFromToolResults(turnResolution.ts:1176)—— 处理toolUseBehavior(如stop_on_first_tool)和审批中断。 - 模型这一拍有动作但没结束 →
next_step_run_again(turnResolution.ts:1027,条件是processedResponse.hasToolsOrApprovalsToRun())。 - 纯文本、没动作 → 取最后一条 assistant 消息当
potentialFinalOutput;若 agent 是text输出就直接 final(turnResolution.ts:1112),若是结构化输出就先用 schema parser 校验,校验失败抛ModelBehaviorError(turnResolution.ts:1125-1141)。
6. 关键细节/坑
-
「同一拍里有工具调用,就不能把同拍的 assistant 文本当最终答案」:见
turnResolution.ts:1024-1027的注释。否则模型「边说话边调工具」时会过早结束。这是个容易踩的语义坑,SDK 显式挡住了。 -
只有第一个 agent 的输入护栏会跑:
run.ts:533docstring 明说 “only the first agent's input guardrails are run”。交接后的新 agent 不重跑输入护栏。 -
结构化输出的校验在循环里:如果你给 agent 设了 Zod
outputType,模型返回的文本会被parser校验(turnResolution.ts:1127),失败会抛错而不是静默返回脏数据。 -
流式是另一条几乎平行的循环:
Runner.#runStreamLoop(run.ts:1188起)逻辑与非流式高度对称,但多了「逐事件 emit」「中途取消时的 abort 对账」reconcileStreamAbortIfNeeded(run.ts:1412)。两条循环共用prepareTurn/resolveTurnAfterModelResponse/applyTurnResult,所以行为一致。
7. 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 顶层 run | run.ts | run、Runner.run |
| 非流式循环 | run.ts | Runner.#runIndividualNonStream |
| 流 式循环 | run.ts | Runner.#runStreamLoop |
| 下一步标签 | runner/steps.ts | nextStepSchema、SingleStepResult |
| 输出分类 | runner/modelOutputs.ts | processModelResponse、resolveFunctionOrHandoff |
| 一拍决议 | runner/turnResolution.ts | resolveTurnAfterModelResponse、maybeCompleteTurnFromToolResults |
| 应用下一步 | runner/runLoop.ts | applyTurnResult、resumeInterruptedTurn |