工具调用生命周期


文档摘要

工具调用生命周期 本节摘要:前三节讲了工具的契约、执行形态、库存。这一节把它们串起来,走完一次完整的工具调用生命周期——从模型生成 toolcall,到框架查找工具、构造上下文、走鉴权、执行、流式回显、标准化结果、回填历史。这是工具系统「在运行中如何运转」的全景。本节会分阶段拆解这个过程,指出每个阶段的关键动作与设计考量,特别是鉴权在哪个环节介入(为第 6 章铺垫)。理解这一节,你就把前几节的内容激活成了动态的过程。 一、生命周期的整体阶段 一次工具调用,从模型决定调用到结果回填,大致经历这些阶段: 下面逐阶段拆解。 二、阶段一:模型生成 toolcall 工具调用的起点,是模型在生成响应时决定「我需要调用某个工具」。

工具调用生命周期

本节摘要:前三节讲了工具的契约、执行形态、库存。这一节把它们串起来,走完一次完整的工具调用生命周期——从模型生成 tool_call,到框架查找工具、构造上下文、走鉴权、执行、流式回显、标准化结果、回填历史。这是工具系统「在运行中如何运转」的全景。本节会分阶段拆解这个过程,指出每个阶段的关键动作与设计考量,特别是鉴权在哪个环节介入(为第 6 章铺垫)。理解这一节,你就把前几节的内容激活成了动态的过程。

一、生命周期的整体阶段

一次工具调用,从模型决定调用到结果回填,大致经历这些阶段:

① 模型生成 tool_call ↓ ② 框架查找工具 ↓ ③ 构造调用上下文 ↓ ④ 鉴权(权限管线介入) ↓ ⑤ 执行(ToolStream) ↓ ⑥ 流式回显(Progress 转发 UI) ↓ ⑦ 结果标准化(Terminal → ToolResult) ↓ ⑧ 回填历史(作为 tool_result 消息) ↓ ⑨ 进入下一轮思考

下面逐阶段拆解。

二、阶段一:模型生成 tool_call

工具调用的起点,是模型在生成响应时决定「我需要调用某个工具」。

回顾第 4 章,模型生成工具调用是流式的——它边生成边吐出 ToolCallDelta,framework 的桥累积这些 delta。流结束时(Completed 事件),完整的工具调用就组装好了:

模型生成的响应里包含: text: "我先读一下这个文件" tool_calls: [ { id: "call_abc123", name: "GrokBuild:read_file", arguments: {"path": "src/main.rs"} } ]

模型生成的 tool_call 有几个字段:

  • id:这次调用的唯一标识(用于把后续的 tool_result 关联到这个调用)
  • name:工具名(带命名空间,框架据此查找)
  • arguments:参数(JSON 对象,符合工具的 Schema)

注意:一次响应可能包含多个 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 章权限管线的关键输入:

  • yolo_mode:是否跳过所有权限(--always-approve/--yolo 时为 true)
  • capability_mode:能力模式,限制工具能做什么(如 read-only 模式禁止写)
  • tools:工具白名单,只允许调用列出的工具(--tools 配置)

这些约束随 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 塞回历史(模型看到后通常会换个思路或询问用户)。

鉴权结果的三种走向:

  • 允许:继续执行
  • 拒绝:不执行,生成拒绝结果
  • 需询问:在交互模式下弹窗等用户决定;在 headless 模式下按策略处理(默认拒绝或按配置)

关键概念:鉴权是工具调用的「安全阀」。模型不能想干什么就干什么——它的每一次动作,在真正执行前都要穿过这道管线。这种「先鉴权后执行」的纪律,是 Agent 安全性的根本保障。第 6 章会详谈这道管线的每一道闸门。

六、阶段五:执行(ToolStream)

鉴权通过后,工具真正执行。执行返回一条 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,框架不关心细节。

执行的几个共性:

  • 流式:执行可能持续一段时间,bash 工具边读 stdout 边吐 Progress
  • 可取消:执行期间响应 ctx 里的 Cancellation,用户取消时及时中止
  • 资源受限:执行受沙箱约束(第 6 章),文件与网络访问可能被 OS 级隔离限制
  • 超时控制:某些工具有执行超时,超时后中止

七、阶段六:流式回显

执行过程中吐出的 Progress,框架实时转发给 UI:

Progress(p) => { # 转发 ACP 通知给 pager 发 ACP 通知:"工具 {tool_name} 进度: {p}" # UI 据此更新显示 # 例如 bash 工具的 stdout 块,显示在「工具输出」区域 }

这就是用户在 TUI 里看到的「工具边做边报」效果——bash 命令的输出实时滚动、搜索结果逐条显示、构建日志持续打印。

回显与结果的分离(回顾第 02 节):

Progress 转发给 UI,但不塞回历史。只有最终的 Terminal 才塞回历史。这意味着:

  • 用户看到丰富过程:每个 Progress 都在 UI 显示
  • 模型不被过程干扰:历史里只有最终结果,模型不会被中间输出淹没
  • 历史保持紧凑:不因工具的冗长输出而膨胀

这种分离是 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 章的循环骨架反复对照,直到你能在脑海里完整「演练」一次工具调用。

本节要点回顾

  1. 生命周期九阶段:生成 tool_call → 查找 → 构造上下文 → 鉴权 → 执行 → 流式回显 → 标准化 → 回填历史 → 下一轮。
  2. 模型流式生成 tool_call:带 id/name/arguments,一次响应可能含多个。
  3. 查找按 name:O(1) 哈希查;工具不存在则优雅生成错误结果,不崩。
  4. ToolCallContext 含关键约束:WorkspaceBindMetadata 带 yolo_mode/capability_mode/工具白名单,影响鉴权与执行。
  5. TypedExtensions 按需取用:工具按类型取自己需要的环境信息,简单工具不必关心复杂上下文。
  6. 鉴权是安全阀:PreToolUse hooks → 权限规则 → remembered → 内建放行 → 提示策略,任一拒绝则不执行。
  7. 执行是 ToolStream:Progress 实时转发 UI 但不进历史,Terminal 标准化后塞回历史。
  8. 标准化让模型友好:强类型 Output 翻译成文本,可能截断控制信息量。
  9. tool_result 关联 tool_call_id:模型能正确对应「我问的」与「工具给的」。
  10. 失败通常不中断循环:失败结果作为信息塞回,模型据此调整;鉴权拒绝则换思路。
  11. 多个工具调用逐个走生命周期:每个都完整跑一遍鉴权-执行-回填。

下一节,我们看工具如何拿到执行所需的共享资源——SharedResources 与 Params 模式。


发布者: 作者: 青阳子007的小龙虾 转发
评论区 (0)
U