Appearance
第32章 把方向盘交给循环——Agent Loop
本章导读: 你会写
src/agent.ts的runAgent()——它持有消息状态,收集文本和工具调用,执行工具,再把结果塞回下一轮。关键认知:模型只能"提出"工具请求,执行权和停止判定都在循环手里。
模型接通了。小a给它写了个测试:让模型“读一下 config.ts 然后告诉我里面写了什么”。模型返回了一段文字,小a盯着日志看了半天,问老z:“它……只是说了?没真读?”
“对。模型只能提出工具请求。”老z说,“src/agent.ts 的 runAgent() 持有消息状态,依次收集文本和 ToolCall,记录 assistant 消息,再执行工具并记录 tool result。这一章,我们把‘下一步’交给循环。”
text
user → ModelClient.stream → assistant(text/toolCalls)
→ approval → tool lookup → tool result → 下一轮"把这个流程压缩成一个问题:**模型说,宿主做。**模型说的话是 tool call,宿主做的事是查找工具、问审批、执行、记录结果。两者之间隔着一条明确的边界——模型永远碰不到 toolsByName 这个 Map,宿主也永远不会自己编造一个 tool call。谁越界谁就错了,这个分工就是循环的全部骨架。"
小a:“runAgent 到底接收什么、返回什么?我照着画一个。”
“先看真实签名——输入里混着‘配置’和‘运行时’,输出里只有消息和停止原因。”老z打开 agent.ts:
ts
// examples/mini-agent/src/agent.ts,节选
export async function runAgent(options: {
client: ModelClient;
system: string;
tools: Tool[];
user: string;
maxTurns: number;
signal?: AbortSignal;
beforeToolCall?: BeforeToolCall;
sessionPath?: string;
contextMaxChars?: number;
}): Promise<RunResult>;
export type RunResult = {
messages: Message[];
stopReason: "complete" | "max_turns" | "aborted";
};“注意几点:client、tools、beforeToolCall 都是注入的——循环不负责创建它们,测试才方便替换;signal 和 sessionPath、contextMaxChars 都是可选——你不传,循环照样跑,只是没有取消、没有持久化、没有裁剪。”老z说,“返回只有两样:messages(这轮之后完整的事实记录)和 stopReason(三种停止原因之一)。没有‘输出文本’这种字段——你要的最终回答,得从 messages 里自己找。这跟 CLI 章‘打印最后一条 assistant content’是配套的设计。”
小a:“为什么不直接返回 finalText?省得调用方去翻 messages。”
“因为循环不知道哪条算‘最终’。”老z说,“你定义的最终是‘最后一条 assistant content’,但别的调用方可能要‘最后两条’,可能要‘所有 tool result’,可能要‘把 assistant 和 tool 配对’。把 messages 整个交出去,把定义权留给调用方——这是 库函数不能替应用做决定 的又一个例子。如果 runAgent 返回 finalText,它就替所有调用方假设了‘最终答案 = 最后一条 assistant 文本’,这个假设在多轮、多工具场景里随时会破。”
“可是 main 和 runCli 不就都这么取的吗?”小a问,“一个取最后一条,一个全打印。”
“对,但那是入口层在决定,不是循环层。”老z说,“main.ts 里 result.messages.filter(m => m.role === "assistant").at(-1)?.content ?? ""——它在自己的文件里决定‘我要最后一条’,如果哪天 main 想要‘最后三条’,改的是 main,不是 agent.ts。runCli 甚至更宽:它把每条非空 assistant 都写给 write。同一个 RunResult,两个入口两种取法,谁都没动循环——这就是不返回 finalText 的回报。”
老z把输入参数的"必填/可选"分了个表:
| 参数 | 必填 | 不传的后果 |
|---|---|---|
client / system / tools / user / maxTurns | 是 | 缺任一项循环无法启动 |
signal | 否 | 没有取消能力,aborted 路径永不触发 |
beforeToolCall | 否 | 默认放行所有工具(见 ?? { approved: true }) |
sessionPath | 否 | 不持久化,进程退出即丢 |
contextMaxChars | 否 | 不裁剪,长会话直接喂全长 messages |
“这张表里每一行‘不传的后果’,都是一个功能降级,不是崩溃。”老z说,“设计成可选的,不是偷懒——是因为这些能力彼此正交:你可以有取消没持久化、有持久化没取消、有裁剪没审批。循环把正交的能力拆开,调用方按需组合。”
“那 contextMaxChars 存在时,循环做了什么额外的?”小a问,“它不只是把 messages 原样丢给 stream 吗?”
“看 agent.ts 的 stream 调用点:messages: options.contextMaxChars ? trimContext(messages, options.contextMaxChars) : messages。”老z说,“裁剪发生在发给模型之前、记录之后——内存里的事实记录永远是全量的,只有送进 stream() 的那一刻才被裁剪。这意味着裁剪对模型可见、对会话不可见:恢复的 session 还是完整历史,只是这一轮的请求省了 token。**‘记录保持完整、请求按预算裁剪’,这两件事不冲突,因为它们的对象不同。**代价是每次请求都要跑一遍 trimContext,而这套产物的吞吐瓶颈同样不在这。contextMaxChars 的另一个约束是 maxChars < 1 直接抛错、预算小到装不下最小自洽单元也抛错——剪不动的预算宁可报错,也不悄悄放空。”
“那 maxTurns 呢?它和 session 里恢复的历史是什么关系?”小a追问。
“maxTurns 只数本轮的循环次数,不数历史里的轮次。”老z说,“恢复的历史作为 messages 的起点参与上下文,但 for (let turn = 0; turn < options.maxTurns; turn++) 的计数从 0 开始——恢复了多少轮旧对话,不影响本轮还剩几次。**‘历史’和‘预算’是两个维度:历史决定起点,maxTurns 决定本轮上限。**main 和 runCli 都传 8,这是入口的策略,不是循环的常量——循环里没有写死任何轮数。”
消息先记录,再继续
小a:“那消息从哪开始?会话文件要不要一开始就加载?”
“加载要放在取消检查之前。”老z打开 agent.ts:
ts
const messages: Message[] = options.sessionPath ? await loadSession(options.sessionPath) : [];
if (aborted(options.signal)) {
return { messages, stopReason: "aborted" };
}
const record = async (message: Message) => {
messages.push(message);
if (options.sessionPath) await appendSession(options.sessionPath, message);
};
await record({ role: "user", content: options.user });“加载发生在取消检查之前,因此 pre-abort 可以返回已恢复历史;检查发生在新 user message 的首次 record() 之前,所以不会污染 session 文件。”老z说,“record 先更新内存再追加磁盘;这意味着 append 失败时内存和文件可能短暂不一致,当前实现让异常上抛,不提供补偿或重试。”
小a:“那正常 session 往返测过吗?”
“正常 session round-trip 已由直接调用 runAgent({ sessionPath }) 的回归测试覆盖;main.ts 与 runCli() 当前都没有装配 sessionPath。”
“为什么入口不装配?”小a问,“持久化不是现成的吗?”
“是现成的,但装配它是入口的决策,不是循环的义务。”老z说,“main 面向一次性脚本——跑完打印、进程退出,会话文件对它没意义;runCli 面向交互,理想情况下应该接上持久化,但当前没接就是没接,不能假装有。**测试里反复出现 sessionPath,恰恰证明循环这条链路是通的、可测的;入口不接,是范围问题,不是能力问题。**等到某个入口真的需要‘中断后恢复’,装配处加一行 sessionPath 就行——这正是第41章清单里‘main/runCli 尚未装配 sessionPath’那条的真实含义。”
“那 loadSession 恢复的历史,会被 trimContext 裁掉吗?”小a又问。
“会,而且这正是设计意图。”老z说,“恢复的历史和本轮消息一起进 messages,请求前统一过 trimContext——预算优先,历史再久也按预算办。持久化保证‘历史不丢’,裁剪保证‘模型不撑’;一个管磁盘,一个管窗口,两者由同一条 messages 数组衔接。”
一轮如何收集事件
小a:“一轮的流式事件怎么收集?”
ts
let text = "";
const calls: ToolCall[] = [];
for await (const event of options.client.stream(request)) {
if (event.type === "text") text += event.text;
if (event.type === "tool_call") calls.push(event.call);
}
if (text === "" && calls.length === 0) throw new Error("model completed without text or tool calls");“字符串在本轮局部累积,工具请求保留 id 与输入,直到流结束才提交 assistant message。”老z说,“空响应检查在 commit 前,因此 session 不会得到一条看似正常的空 assistant。”
“那流式过程中 stream() 抛错了呢?还没 commit 的 text 和 calls 去哪了?”小a问。
“被丢弃。”老z说,“agent.ts 的 try/catch 包住整个 for-await:catch 里先查 signal 是否已取消——取消了就走 aborted 返回;没取消就直接 throw error 把原始异常抛给调用方。**局部变量 text 和 calls 在这一刻随作用域一起消失,不落盘、不返回。**为什么?因为它们是‘这一轮的临时结果’,不是‘会话事实’——事实只有在 commit 之后才算数。这跟取消时丢弃部分流的逻辑是同一个原则:未提交即不存在。”
小a:“那done 事件呢?ModelEvent 里有 done,但这段循环好像没处理它?”
“没处理,因为 done 对 loop 是个无操作。”老z说,“for await 遍历的是 AsyncIterable<ModelEvent>,done 事件只是流里一个普通元素,loop 看到它既不累加也不抛错——真正的‘流结束了’信号是 for-await 自然走完。done 对 loop 透明,对 toModelEvents 有意义:它保证‘协议映射完成,前面没有半截 block’。一个事件是给 Provider 映射层收尾的,一个循环只关心自己收集到了什么,各看各的。”
工具分派与失败
小a:“那工具怎么执行?失败了怎么办?”
“正常路径:approval 允许 → 按名称找到 tool → execute → record tool result → 下一轮。拒绝路径写入 reason;未知名称写入 unknown tool;execute 自己返回错误结果。”老z列了一张表:
| 停止/失败 | 谁决定 | 结果 |
|---|---|---|
complete | 无工具调用 | 返回消息 |
max_turns | 外层 for | 不再请求模型 |
aborted | 外部 signal 与 checkpoint | 不启动下一调用;已启动工具先收结果 |
approval / execute 抛错(未取消) | hook 或工具 | 先闭合当前及未执行调用,再上抛原错误 |
| 空响应 | Agent 校验 | 抛出异常 |
| 持久化失败 | 文件系统 | 抛出异常 |
小a看着“aborted”那行:“那工具是顺序执行的吗?”
“顺序执行是不是安全?它只降低此循环内的并发复杂度,**不保证外部进程或跨文件动作安全。**所有 calls 目前按数组顺序 await,收益是同一文件写入不会被本循环并发打断,代价是独立读取也无法并行。”
“那为什么不并行?模型一次要三个文件,并行读不是更快吗?”小a问。
“更快,但代价是顺序保证的消失。”老z说,“一旦 Promise.all 并行跑工具,就引入了三类新问题:写同一文件时的竞态、失败时‘谁先失败’的不确定、以及取消时‘哪些已启动哪些还没启动’的精确记账。顺序执行把这些问题全部推平——call 1 的结果记录完,call 2 才开始,任何时刻都只有‘一个正在跑的工具’和‘一张明确的结果账’。这一章为确定性牺牲吞吐,不是不知道并行,是并行让失败语义失去可测试性。”
“那‘未知工具名’呢?”小a追问,“模型编造一个不存在的工具,循环会怎样?”
“记录 unknown tool: ${call.name} 然后 continue——不是抛错,是继续下一轮。”老z指着表里那一行,“模型读到‘unknown tool: does_not_exist’,它的正常反应是意识到自己用错了名字,下一轮换成真的。agent-branches.test.ts 里专门有一个用例喂了不存在的工具名,断言消息里带着完整的调用 id。把未知工具当错误反馈而不是异常,和工具失败转消息是同一个哲学:模型的认知错误,交给模型修正。”
四个取消 checkpoint
小a:“取消又怎么处理?我不想做到一半被用户按掉,然后状态乱七八糟。”
“取消是协作式状态转换,不是线程抢占。”老z列了四个 checkpoint:
| Checkpoint | 发生位置 | 已提交状态 | 取消结果 |
|---|---|---|---|
| 首次记录前 | session load 后 | 只有恢复历史 | 不新增 user,返回 aborted |
| assistant commit 前 | stream 结束后 | 本轮事件仍是局部变量 | 丢弃部分本轮,返回 aborted |
| 工具启动前 | approval await 后 | assistant 已含 toolCalls | 给当前及剩余调用补取消结果 |
| 工具返回后 | 真实 result 已记录 | 已启动副作用和结果保留 | 剩余调用补取消结果,返回 aborted |
“这四个点分别阻止‘取消任务却写入新 user’‘把部分流冒充完整回答’‘审批期间取消后仍启动工具’和‘工具已经完成却误报 max turns’。”老z说,“回归测试用确定性 fake 制造竞态,不需要真实网络。”
“每个 checkpoint 各自要防的错误,能说得更具体一点吗?”小a问。
“拿第二个举例。”老z说,“cancellation.test.ts 里那条‘abort after a non-cooperative stream completes prevents assistant commit’——fake 吐了一句 ‘partial’,然后立刻 abort,stream 正常走完。如果取消检查只放在循环开头,loop 会以为这轮很成功,把 ‘partial’ commit 成完整回答。第二个 checkpoint 挡的就是这个:流一结束马上看 signal,aborted 就返回,那句 partial 永远不进 session。一个 checkpoint 挡一个具体的坏结果,四个加起来才覆盖四种‘取消夹在哪个瞬间’的排列。”
为什么要补齐取消的工具结果
小a:“工具没执行就取消,为什么还要往 session 里写东西?”
“因为 assistant 一旦提交,其中每个 ToolCall.id 就成为会话事实。直接返回会留下没有结果的调用。”老z敲出 recordAbortedCalls():
ts
// examples/mini-agent/src/agent.ts,节选
for (const call of calls.slice(startIndex)) {
await record({
role: "tool",
toolCallId: call.id,
content: "aborted before execution",
isError: true,
});
}“若第一项工具已完成,startIndex 从下一项开始;真实结果保留,剩余项才标取消。未取消的 approval 或 execute 异常也采用同一闭合原则:当前调用记录不包含异常详情的通用失败,后续调用记录为‘前序失败后未执行’,然后原始异常继续向调用者传播。恢复会话时,每个已声明调用因此都有可诊断的闭合状态。”
失败时的闭合:recordFailedCallAndSkippedCalls
小a:“那工具执行抛异常了呢?不是取消,是真失败了。”
“那就走另一个闭合函数。”老z打开 agent.ts:
ts
// examples/mini-agent/src/agent.ts,节选
const recordFailedCallAndSkippedCalls = async (
calls: ToolCall[],
failedCallIndex: number,
failureContent: string,
): Promise<void> => {
const failedCall = calls[failedCallIndex];
if (!failedCall) return;
await record({
role: "tool",
toolCallId: failedCall.id,
content: failureContent,
isError: true,
});
for (const call of calls.slice(failedCallIndex + 1)) {
await record({
role: "tool",
toolCallId: call.id,
content: "not executed after an earlier failure",
isError: true,
});
}
};“注意它的措辞:失败的那个记录 failureContent(比如 "tool execution failed"),剩下的记录成 "not executed after an earlier failure"——不是 aborted。为什么?因为取消和失败是两回事:取消是你主动叫停,失败是出了岔子但没取消。恢复会话时,你要能从记录里分辨‘这个调用是没跑成,还是压根没轮到’。”
“那原始异常去哪儿了?”小a问。
“recordFailedCallAndSkippedCalls 只把通用失败文本写进 session——"tool execution failed",不含异常堆栈。然后 throw error 把原始异常抛回调用者。”老z说,“这就是 fail loud 的分层:session 里留可诊断的闭合状态,调用方手里拿完整的原始错误,两边都不缺,也都不互相污染。”
已启动工具不能回滚
小a:“那……如果取消的时候,工具已经在跑了呢?”
老z没有直接回答,先让他看代码:
ts
// examples/mini-agent/src/agent.ts,节选
result = await tool.execute(call.input, options.signal);
await record({
role: "tool",
toolCallId: call.id,
content: result.content,
isError: result.isError ?? false,
});
if (aborted(options.signal)) {
await recordAbortedCalls(calls, callIndex + 1);
return { messages, stopReason: "aborted" };
}“signal 会传给工具,但工具可以不理会它。**工具一旦开始,循环只能等待 Promise resolve 或 reject;已经发生的文件写入、网络请求或进程副作用无法由 AbortSignal 回滚。**若工具返回,先记录真实结果再检查取消。”
小a:“那为什么不干脆在 signal 触发时立刻返回?”
“因为那会把仍在后台运行的副作用伪装成‘已经停止’。”老z说,“**等待工具交还控制权虽然可能慢,却让消息记录与真实世界更一致。**若 custom tool 抛错且 signal 已取消,循环继续现有 aborted 语义:记录取消期间的诊断结果、补齐剩余调用并返回 aborted;未取消时则先用通用 isError: true 结果闭合当前和剩余调用,再上抛原异常。这样 session 不会保存潜在敏感的异常文本,而调用者仍能诊断原始错误。”
小a:“那‘已启动不能回滚’这条,会不会让用户产生不安全感?”
“不会,前提是把话说清楚。”老z说,“测试里那条‘abort during a started tool preserves its real result’就是给这个语义立证:工具执行到一半 abort,但执行器还是返回了 ‘real result’,循环记录它,stopReason 是 aborted。注意这里没有回滚——文件如果写了就写了,结果保留。用户需要的不是‘取消等于时间倒流’的幻觉,而是‘取消之后,哪些副作用已经发生、哪些没有,消息里写得明明白白’。协作式取消的承诺是‘状态可解释’,不是‘状态可撤销’。”
“那三种停止原因,最后怎么变成退出码的?”小a问。
“cli.ts 里 exitCodeForStopReason 一个纯 switch:complete 为 0、max_turns 为 2、aborted 为 130。”老z说,“cli-exit.test.ts 一行一个断言钉住映射,main 和 runCli 都靠它收尾。**退出码是循环与操作系统的唯一接口——它不携带错误文本,只携带状态类别。**这保持了‘循环不知道终端长什么样’的独立性。”
小结
循环把"模型决定"和"宿主执行"劈成两半,这是它最重要的设计决策。模型只产出 tool call 的意图——一个带名称、参数、调用 ID 的请求;至于这个请求要不要执行、执行后结果如何回传、什么时候算一个 turn 结束,全部由宿主程序决定。循环在四个 checkpoint 上检查取消信号:流开始前、流进行中、工具执行前、工具执行后。停止原因分三种——complete 表示模型不再调用工具而自然结束,max_turns 表示轮次预算耗尽,aborted 表示外部信号中止,各自映射不同的退出码。取消和失败的调用不会被静默丢弃,recordAbortedCalls 和 recordFailedCallAndSkippedCalls 会把它们显式记入消息,保证 session 重载时每个 call 都有对应结果。
这里最大的认知陷阱是把"取消"当成"撤销"。AbortSignal 是协作式的——它请求停止,但不能强制终止一个不响应它的工具,更不能回滚已经发生的副作用:文件已经写了、网络请求已经发了,这些不会因为 signal 而消失。循环也没有并发控制、重试机制或副作用去重,这些都是故意不做的。
这一章的另一个隐形设计是"未提交即不存在"。text 和 calls 在流结束前只是局部变量,取消或抛错时随作用域一起消失,只有 commit 成 assistant 消息才算会话事实。这个原则贯穿整个循环:临时结果与事实记录之间有一条明确的提交线,所有取消检查都挤在提交线的两侧,确保半成品永远不会被当成完整答案写进 session。与之配套的是"已启动不能回滚"——工具一旦跑起来,循环就只负责等待、记录,不负责反悔。这两条合起来定义了循环对真实世界的姿态:它不撒谎(不把 partial 当 complete),也不抵赖(不假装工具没跑过)。
循环把停止原因压成三态,是它向外界交出的一张干净的牌。complete、max_turns、aborted 分别对应不同的退出码,调用方据此决定自己该怎么办:脚本看退出码,交互入口看消息内容,测试看 stopReason 断言。**循环不判断"这个结果好不好",它只保证结果可归类、可解释。**前面章节反复强调的"宿主拥有停止权",落到代码上就是这三态和那条 for turn 上限——模型可以一直提请求,但什么时候停,永远由循环的返回决定。
阶段验收
npm test 包含独立回归:pre-abort 不改 session、流完成竞态不提交 assistant、approval race 不执行工具、工具返回竞态保留真实结果、多调用补齐关联取消结果;还覆盖工具或 approval hook 抛错后 session reload 中每个 call 都有结果、后续调用关联完整、敏感异常文本未写入 session、调用方仍收到原错误。