M6 内核层 · L17

工具执行与流式事件:一次 tool call 的完整生命周期

45 min · agent-loop.ts:443-1300 + agent-stream-response.ts:286-357

🎒 预备知识:完成上一课「L16」(零基础入口:第 0 课)

本课唯一结论:工具不是黑盒函数调用——从模型输出 tool call 到结果回填, 中间经过验证 → 准入 → 串/并行调度 → 执行 → 结果构造 → 回填六站; 而且在流式模式下,工具在流还没结束时就开始执行了。每一站都是可插拔的钩子位。

一、六站旅程(对照 L7 的协议层)

① 模型流式输出 toolcall_end 事件
② validateToolArguments 参数验证 + beforeToolCall 准入(block:true → 合成失败结果)
③ 串行 or 并行?config.toolExecution 与批次内 hasSequentialToolCall 决定(:498-510)
④ execute(toolCallId, args, signal) —— 进程内函数;MCP 工具也包装成统一 execute 形态(:1151-1163)
⑤ 异常 → isError:true 的结果消息(不是抛给用户!:1114-1131 catch 转化)
⑥ role:"toolResult" 消息 push 回 context.messages(:327 / 流式路径 :185-191)

L7 讲的是"协议上该怎么往返",本课看"harness 在每站插了什么"。重点看两段真实代码。

二、流中提前执行(最反直觉的设计,:321-347 原文节选)

if (event.type === "toolcall_end" && event.toolCall.async && ...) {
  const prefix = prepareAssistantMessage(...slice(committedContentCount,
                                                    event.contentIndex + 1)...);
  // Await transcript persistence before admitting side effects. The model
  // may keep sampling, but every executed call has a durable owner.
  await commitFragment(prefix);        // ① 先把"前缀消息"持久化进 transcript
  committedContentCount = event.contentIndex + 1;
  enqueueTools(prefix);                // ② 然后才把工具放进执行队列
}

逐行读:流还在生成时,一个完整的 toolcall_end 到了,就立刻—— ① 把"到目前为止的 assistant 消息片段"先落盘(注释原文:先持久化再放行副作用—— 模型还在继续采样,但每个被执行的调用都有了"确定的归属");② 把工具入队执行。 收益:工具耗时与模型生成重叠,总时延显著下降; 约束:副作用必须有先行留痕——出问题时有据可查。 这是"性能优化不破坏审计性"的教科书案例。

三、结果构造与 isError(:1478-1500 原文节选)

const message = ... {
  role: "toolResult",
  toolCallId: finalized.toolCall.id,     // 凭 id 对应——和 L7 协议一致
  toolName: finalized.toolCall.name,
  content: finalized.result.content ?? [],
  isError: finalized.isError,            // ← 铁律①在类型系统里的位置
  timestamp: Date.now(),
};

注意 isError 是消息的一等字段,不是异常堆栈——失败被结构化了, 模型下一轮能精确读到"哪个调用(toolCallId)的什么错误"。再看 :1114-1131 与 :1173-1191 的另一半: 执行抛出的真异常会被 catch 并转成同样的 isError 结果。两路失败汇成一个协议。

四、钩子链:harness 的扩展点(你自己加功能的接口)

钩子时机典型用途
beforeToolCall执行前审批/付费拦截(block:true → 合成失败结果给模型)
afterToolCall / afterToolOutcome执行后改写结果、打 taint 标记、脱敏
resolveDeferredTool找不到工具时tool-search 延迟加载目录(A2 的按需加载落点)
prepareNextTurn轮与轮之间换模型/换思维档(L16 :373)

好 harness 的标志:加功能不改内核。OpenClaw 的审批系统与 taint 体系全部挂在 afterToolCall/afterToolOutcome 上(agent-session-base.ts),agent-loop.ts 一行不用动。 你给自己的 agent 加"付费工具前弹确认",就该插 beforeToolCall。

五、常见踩坑

坑 1:把流中执行当成"边生成边执行全部"。只有 async: true 的工具才提前入队; 同步工具等流结束统一批次执行。设计自己的工具时要标 async。

坑 2:钩子里做重活。beforeToolCall 里查数据库做审批——批次被串行卡住。 钩子要轻,重活放工具本体。

坑 3:MCP 工具当子进程调用。它们同样被包装成进程内 execute 函数统一调度—— 分发的抽象层只有一个,别在业务里再分叉。

六、检索练习

七、出口检验

画出一次失败 tool call(脚本 crash)的完整数据流:从模型决定调用, 到模型在下一轮看到什么——标注 isError 在哪一站被设置、流中执行路径和批次路径分别在哪回填。 再答:如果要加"付费工具必须先过人工审批",插哪个钩子、返回什么? 发到对话里我批改。

一手源:agent-loop.ts:443 起 executeToolCalls + :1114-1131 异常转结果段。