Skip to content

第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、调用方仍收到原错误。