实现篇第3章 把方向盘交给循环——Agent Loop

本章导读: 你会写
src/agent.ts的runAgent():它持有消息状态,收集文本和工具调用,执行工具,再把结果塞回下一轮。关键认知:模型只能“提出”工具请求,执行权和停止判定都在循环手里。
模型接通了。小A给它写了个测试:让模型“读一下 config.ts 然后告诉我里面写了什么”。模型返回了一段文字,小A盯着日志看了半天,问老Z:“它……只是说了?没真读?”
“对。模型只能提出工具请求。”老Z说,“src/agent.ts 的 runAgent() 持有消息状态,依次收集文本和 ToolCall,记录 assistant 消息,再执行工具并记录 tool result。这一章,我们把‘下一步’交给循环。”
user → ModelClient.stream → assistant(text/toolCalls)
→ approval → tool lookup → tool result → 下一轮“把这个流程压成一句话:模型说,宿主做。模型说的话是 tool call,宿主做的事是查找工具、问审批、执行、记录结果。两者之间隔着一条明确的边界:模型永远碰不到 toolsByName 这个 Map,宿主也永远不会自己编造一个 tool call。”
小A:“runAgent 到底接收什么、返回什么?我照着画一个。”
老Z把 agent.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,两个入口两种取法,谁都没动循环。”
老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 的开头:
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 就行,实现篇第12章清单里‘main/runCli 尚未装配 sessionPath’那条,说的就是这个。”
“那 loadSession 恢复的历史,会被 trimContext 裁掉吗?”小A又问。
“会,而且是故意的。”老Z说,“恢复的历史和本轮消息一起进 messages,请求前统一过 trimContext,预算优先,历史再久也按预算办。持久化管磁盘,保证历史不丢;裁剪管窗口,保证模型不撑,两边由同一条 messages 数组衔接。”
一轮如何收集事件
小A凑过来:“事件具体怎么收?”
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() 抛错了呢?”小A问,“还没 commit 的 text 和 calls 去哪了?”
“被丢弃。”老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”那行:“工具是顺序执行的吗?”
“是顺序的。但这只降低此循环内的并发复杂度,不保证外部进程或跨文件动作的安全。”老Z说,“所有 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。这跟工具失败转成消息是一个思路:模型的认知错误,交回给模型自己修正。”
四类关键取消窗口
小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():
// 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:
// 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说,“session 里留可诊断的闭合状态,调用方手里拿完整的原始错误,两边都不缺,也互不污染。”
已启动工具不能回滚
小A想了想:“如果取消的时候,工具已经在跑了呢?”
老Z没有直接回答,先让他看代码:
// 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、调用方仍收到原错误。
