工具调用生命周期 本节摘要:前三节讲了工具的契约、执行形态、库存。这一节把它们串起来,走完一次完整的工具调用生命周期——从模型生成 toolcall,到框架查找工具、构造上下文、走鉴权、执行、流式回显、标准化结果、回填历史。这是工具系统「在运行中如何运转」的全景。本节会分阶段拆解这个过程,指出每个阶段的关键动作与设计考量,特别是鉴权在哪个环节介入(为第 6 章铺垫)。理解这一节,你就把前几节的内容激活成了动态的过程。 一、生命周期的整体阶段 一次工具调用,从模型决定调用到结果回填,大致经历这些阶段: 下面逐阶段拆解。 二、阶段一:模型生成 toolcall 工具调用的起点,是模型在生成响应时决定「我需要调用某个工具」。
本节摘要:前三节讲了工具的契约、执行形态、库存。这一节把它们串起来,走完一次完整的工具调用生命周期——从模型生成 tool_call,到框架查找工具、构造上下文、走鉴权、执行、流式回显、标准化结果、回填历史。这是工具系统「在运行中如何运转」的全景。本节会分阶段拆解这个过程,指出每个阶段的关键动作与设计考量,特别是鉴权在哪个环节介入(为第 6 章铺垫)。理解这一节,你就把前几节的内容激活成了动态的过程。
一次工具调用,从模型决定调用到结果回填,大致经历这些阶段:
① 模型生成 tool_call ↓ ② 框架查找工具 ↓ ③ 构造调用上下文 ↓ ④ 鉴权(权限管线介入) ↓ ⑤ 执行(ToolStream) ↓ ⑥ 流式回显(Progress 转发 UI) ↓ ⑦ 结果标准化(Terminal → ToolResult) ↓ ⑧ 回填历史(作为 tool_result 消息) ↓ ⑨ 进入下一轮思考
下面逐阶段拆解。
工具调用的起点,是模型在生成响应时决定「我需要调用某个工具」。
回顾第 4 章,模型生成工具调用是流式的——它边生成边吐出 ToolCallDelta,framework 的桥累积这些 delta。流结束时(Completed 事件),完整的工具调用就组装好了:
模型生成的响应里包含: text: "我先读一下这个文件" tool_calls: [ { id: "call_abc123", name: "GrokBuild:read_file", arguments: {"path": "src/main.rs"} } ]
模型生成的 tool_call 有几个字段:
注意:一次响应可能包含多个 tool_call。比如模型可能同时决定「读 A 文件」和「读 B 文件」,生成两个调用。框架会逐个处理它们。
拿到 tool_call 后,框架第一步是按 name 在工具注册表里查找:
for call in resp.tool_calls: tool = tool_bridge.find(&call.name) if tool is None: # 工具不存在(罕见,通常是模型幻觉了不存在的工具名) 生成一条 tool_result:"工具 {name} 不存在" 塞回历史 continue # 找到了,继续后续流程 ...
查找的依据是工具的身份字符串(GrokBuild:read_file 等)。注册表是一个哈希表,查找是 O(1) 的。
工具不存在的情况
正常情况下,模型只调用注册表里有的工具(因为它看到的工具列表来自注册表)。但偶尔模型可能「幻觉」出一个不存在的工具名,框架要优雅处理——不崩溃,而是生成一条「工具不存在」的 tool_result 塞回历史,让模型在下一轮自我纠正。
这种「容错」很重要——Agent 系统要对模型的不可靠输出有韧性,不能因为一个奇怪的 tool_call 就崩。
找到工具后,框架构造一个 ToolCallContext(在 xai-tool-runtime/src/context.rs),它是工具执行时能拿到的「环境」:
ToolCallContext { call_id: "call_abc123", # 本次调用的 id extensions: TypedExtensions { # 类型化的扩展容器,装着各种上下文 Cwd: 当前工作目录, BehaviorVersion: 行为版本, Cancellation: 取消令牌, SessionContext: 会话上下文(权限模式、能力模式等), WorkspaceBindMetadata: 工作区绑定元数据 { yolo_mode: 是否跳过权限, capability_mode: 能力模式(read-only/read-write/execute/all), tools: 工具白名单, ... }, ... } }
TypedExtensions 是什么
TypedExtensions 是一个「按类型存取的扩展容器」——你可以往里塞任何类型的值,按类型取出。它类似一个类型安全的「上下文包」,让工具能按需取用各种环境信息,而不必每个工具都接受一长串参数。
工具在 execute 时,从 ctx.extensions 里取自己需要的:
impl Tool for ReadFileTool { fn execute(&self, ctx, args) -> ToolStream<...> { let cwd = ctx.extensions.get::<Cwd>() # 取当前目录 let cancel = ctx.extensions.get::<Cancellation>() # 取取消令牌 ... } }
这种「按需取用」的设计,让简单的工具(只读个文件)不必关心复杂的上下文(如 MCP 状态、记忆句柄),只取自己要的。
WorkspaceBindMetadata 的关键性
注意 WorkspaceBindMetadata——它装着 yolo_mode、capability_mode、tools 白名单等会话级的执行约束。这些约束是第 6 章权限管线的关键输入:
这些约束随 ToolCallContext 传播到工具执行,工具与权限管线据此决定行为。
这是工具调用生命周期里最关键的安全环节。在第 03 节的伪代码里,execute_dyn 内部会走到一个 ToolDispatch::call_streaming,它做的第一件事就是鉴权。
鉴权的完整管线在第 6 章详谈,这里先建立印象。一次工具调用,在真正执行前,要穿过几道闸门:
准备执行 → 鉴权管线: [闸门 1] PreToolUse Hooks 用户自定义的钩子,可拒绝 ↓ 通过 [闸门 2] 权限规则匹配 deny 命中 → 拒绝 allow 命中 → 直接放行 ask 命中 → 进询问 ↓ [闸门 3] 记住的授权(会话内) 本次会话用户曾允许 → 复用 ↓ [闸门 4] 内建自动放行 只读工具 / 只读命令白名单 → 放行 ↓ [闸门 5] 提示策略(由 mode 决定) default → 弹询问 acceptEdits → 编辑类直接放行 dontAsk → 不问(但仍受规则约束) bypassPermissions → YOLO 全放行 plan → 计划模式,不实际执行
任意一个闸门拒绝,工具不执行,生成「被拒绝」的 tool_result 塞回历史(模型看到后通常会换个思路或询问用户)。
鉴权结果的三种走向:
关键概念:鉴权是工具调用的「安全阀」。模型不能想干什么就干什么——它的每一次动作,在真正执行前都要穿过这道管线。这种「先鉴权后执行」的纪律,是 Agent 安全性的根本保障。第 6 章会详谈这道管线的每一道闸门。
鉴权通过后,工具真正执行。执行返回一条 ToolStream(第 02 节讲过的不变量):
let stream = tool.execute_dyn(ctx, args) while let Some(item) = stream.next().await { match item { Progress(p) => { ... } # 阶段六:流式回显 Terminal(r) => { ... } # 阶段七:结果标准化 } }
执行的具体内容取决于工具:bash 派生子进程、read_file 打开文件、grep 遍历目录,等等。每个工具自己实现 execute,框架不关心细节。
执行的几个共性:
执行过程中吐出的 Progress,框架实时转发给 UI:
Progress(p) => { # 转发 ACP 通知给 pager 发 ACP 通知:"工具 {tool_name} 进度: {p}" # UI 据此更新显示 # 例如 bash 工具的 stdout 块,显示在「工具输出」区域 }
这就是用户在 TUI 里看到的「工具边做边报」效果——bash 命令的输出实时滚动、搜索结果逐条显示、构建日志持续打印。
回显与结果的分离(回顾第 02 节):
Progress 转发给 UI,但不塞回历史。只有最终的 Terminal 才塞回历史。这意味着:
这种分离是 ToolStream 不变量的直接收益。
流的 Terminal 出现,执行结束。Terminal 的内容是工具的强类型 Output,需要标准化成统一的 ToolResult:
Terminal(result) => { # result 是工具的 Output 类型 # 调用 ToolOutput trait 的方法标准化 let tool_result = result.to_tool_result() # tool_result 通常是文本(或结构化文本) # 例如 ReadFile 的 FileContent 标准化成: # "文件 src/main.rs 的内容:\n<文件内容>" }
标准化(第 01 节提到的 ToolOutput trait)让不同工具的各异 Output,变成统一的 ToolResult 文本。这个文本是模型在下一轮能看懂的形式。
为什么需要标准化
模型的「阅读对象」是文本(或结构化文本)。它看不懂 Rust 的 FileContent { path, content, lines } 结构体,但能看懂「文件 src/main.rs 的内容:\n...」。标准化把强类型 Output 翻译成模型友好的文本。
标准化还有一个作用:控制给模型的信息量。例如 bash 工具的输出可能非常长(几千行日志),标准化时可以截断或摘要,避免历史爆炸。
标准化的 ToolResult,作为新的 tool_result 消息塞回会话历史:
# 构造一条 tool_result 消息 let msg = Message::tool_result( tool_call_id: "call_abc123", # 关联到这次调用 content: tool_result, # 标准化的文本 ) # 追加到 chat_state chat_state.append(msg)
注意 tool_call_id——它把这条 tool_result 关联到模型之前生成的 tool_call。这样模型在下一轮看到历史时,能正确地把「我问的工具调用」与「工具给的结果」对应起来。
历史里的工具调用结构
经过一次工具调用,历史里多了两条消息:
... 之前的对话 ... [assistant 消息] text: "我先读一下这个文件" tool_calls: [{id: "call_abc123", name: "GrokBuild:read_file", arguments: {...}}] [tool_result 消息(角色 tool)] tool_call_id: "call_abc123" content: "文件 src/main.rs 的内容:\n..." ... 后续对话 ...
这种「assistant 发起 tool_call + tool 回 tool_result」的对偶,是 ReAct 模式在消息层面的体现。模型在下一轮看到这对消息,就知道「我之前要读这个文件,这是它的内容」。
工具结果回填后,process_conversation_turn 的循环 continue,进入下一轮:
本轮: 准备工具、组装请求(历史现在包含了工具结果) 下沉 sampler → 模型看到工具结果,继续推理 模型可能: - 基于结果继续工作(再调工具) - 给出最终回复(无工具调用,循环结束)
这就回到了第 3 章的循环主干。工具调用的生命周期,完整地嵌入在循环里——它是循环「思考-行动」中「行动」这一半的全部内容。
一次响应可能含多个 tool_call(模型同时决定调多个工具)。框架逐个处理:
for call in resp.tool_calls: ① ~ ⑨ 完整跑一遍这个工具的生命周期 # 所有工具都处理完,把所有 tool_result 塞回历史 # 进入下一轮
串行还是并行
具体是串行还是并行处理多个工具调用,取决于框架实现与工具性质。一些观察:
实际处理策略以源码为准,本节建立「多个调用都要完整走生命周期」的印象即可。
如果工具执行失败(命令返回非零、文件不存在等),Terminal 里会带失败信息,标准化后作为 tool_result 塞回历史:
[bash 工具执行 cargo build 失败] Terminal 包含:退出码 1,stderr 是编译错误 标准化:"命令失败(退出码 1):\n<编译错误输出>" 塞回历史 下一轮模型看到:"命令失败了,错误是...,我来修复"
关键:工具失败通常不中断循环(第 3 章讲过)。失败结果作为信息塞回历史,模型据此调整。这是 Agent 「从错误中学习」的体现——跑测试失败不是结束,而是给模型的反馈。
唯一例外是鉴权被用户明确拒绝,这时工具不执行,生成「被拒绝」结果,模型通常不会再坚持(而是换思路或询问用户)。
把整个生命周期画成流程图:
这张图建议结合第 3 章的循环骨架反复对照,直到你能在脑海里完整「演练」一次工具调用。
下一节,我们看工具如何拿到执行所需的共享资源——SharedResources 与 Params 模式。