ToolStream 不变量 本节摘要:上一节讲工具的「身份与执行签名」,这一节看执行的「形态」。在 Grok Build 里,工具执行不是返回单个值那么简单——它返回的是一条流:零或多个 Progress(进度/中间块),最后有且仅有一个 Terminal(最终结果)。这个「Progress then Terminal」的形态,是工具系统的一个核心不变量(invariant),所有工具的执行都遵循它。本节会讲清这个不变量是什么、为什么需要它、它在不同工具里如何体现,以及它带来的统一价值。 一、为什么工具执行是「流」 最朴素的工具执行模型是「调用→返回值」:给参数,得结果。这对简单的工具(如读一个小文件)够用。
本节摘要:上一节讲工具的「身份与执行签名」,这一节看执行的「形态」。在 Grok Build 里,工具执行不是返回单个值那么简单——它返回的是一条流:零或多个 Progress(进度/中间块),最后有且仅有一个 Terminal(最终结果)。这个「Progress* then Terminal」的形态,是工具系统的一个核心不变量(invariant),所有工具的执行都遵循它。本节会讲清这个不变量是什么、为什么需要它、它在不同工具里如何体现,以及它带来的统一价值。
最朴素的工具执行模型是「调用→返回值」:给参数,得结果。这对简单的工具(如读一个小文件)够用。但很多工具的执行是「长且产出中间结果」的:
如果用「调用→返回值」模型,这些工具要么:
ToolStream 的解法是:把执行定义成一条流,工具可以边执行边吐出中间结果(Progress),最后吐出最终结果(Terminal)。这样:
ToolStream 的不变量(在 xai-tool-runtime 里):
ToolStream 是一条流,其元素是: enum StreamItem<T> { Progress(ProgressItem), # 进度/中间块,可有零或多个 Terminal(T), # 最终结果,有且仅有一个,且是流的最后一个 } 不变量: 1. 流里可以有零或多个 Progress 2. 流里有且仅有一个 Terminal 3. Terminal 是流的最后一个元素(之后流结束)
用图表示:
工具执行流: [Progress] [Progress] [Progress] ... [Terminal] └─中间进度─┘ └─中间进度─┘ └─中间进度─┘ └─最终─┘ │ ▼ 流结束
或者简单的工具(没有中间进度):
[Terminal] │ ▼ 流结束
无论哪种,Terminal 一定是最后一个,这是不变量的核心。
Progress(进度)
代表「执行过程中的一个中间产物」,可能是:
Progress 的特点:
Terminal(最终结果)
代表「执行的最终产物」,是工具的「返回值」。它的类型是工具的 Output(经过 ToolOutput 标准化后变成 ToolResult)。Terminal 的特点:
关键概念:Progress 与 Terminal 的区分,本质是「过程」与「结果」的区分。Progress 让用户实时看到过程,Terminal 让模型拿到最终结果。这两者服务不同的受众(用户看过程,模型看结果),用同一条流统一表达。
看几个典型工具如何体现这个不变量:
ReadFile(读文件)
执行:打开文件 → 读取内容 → 返回 流: [Terminal(FileContent)] └─没有中间进度,直接给最终结果─┘
ReadFile 通常很快,不需要吐 Progress,直接 Terminal。
bash(执行命令)
执行:派生子进程 → 边读 stdout 边转发 → 等待退出 → 返回退出码与输出 流: [Progress(stdout 块 1)] [Progress(stdout 块 2)] ... [Terminal(完整结果)] └─逐块转发,实时显示─────────────────┘ └─最终聚合结果─┘
bash 是 ToolStream 的典型受益者。命令的 stdout 不是一次性产生的,而是持续输出。bash 工具按块(如 16KB)读 stdout,每读一块吐一个 Progress,框架转给 UI 实时显示。命令结束时,吐 Terminal,包含退出码与完整输出。
Grep(搜索)
执行:遍历文件 → 找到匹配 → 累积 → 返回所有匹配 流(可能): [Progress(找到 10 个)] [Progress(累计 50 个)] ... [Terminal(所有结果)]
Grep 在大目录里可能跑很久,边找边吐 Progress(让用户看到「已经找到 N 个」),最后 Terminal 给完整结果。
apply_patch(应用补丁)
执行:解析补丁 → 逐文件应用 → 验证 → 返回 流: [Progress(已应用文件 1)] [Progress(已应用文件 2)] ... [Terminal(汇总)]
补丁可能涉及多个文件,每个文件应用后吐一个 Progress,最后汇总 Terminal。
不变量的价值在于「让框架能用统一代码处理所有工具」。框架的处理逻辑大致是:
async fn execute_tool_via_dyn(tool: ArcTool, ctx, args) -> ToolResult { let stream = tool.execute_dyn(ctx, args) let mut final_result = None while let Some(item) = stream.next().await { match item { Progress(p) => { # 转发给 UI 实时显示(让用户看到进度) 发 ACP 通知:"工具进度: {p}" # 不更新 final_result } Terminal(result) => { # 这是最终结果,记下来 final_result = Some(result) # 流在此之后结束(不变量保证) break } } } # final_result 一定有值(不变量保证 Terminal 必然出现) final_result.unwrap() }
不变量的好处在这里清晰可见:
如果没有这个不变量(比如某些工具吐多个 Terminal、某些不吐 Terminal),框架的处理代码会变成一堆特殊情况的处理,复杂且易错。
回顾第 3 章 process_conversation_turn 的 execute_tools 步骤:
for call in resp.tool_calls: tool = tool_bridge.find(call.name) result_stream = tool.execute_dyn(ctx, call.input) for item in result_stream: match item { Progress(p) => 转发 ACP 通知给 UI Terminal(r) => 把 r 标准化后塞回历史 }
这个处理正是基于 ToolStream 不变量。循环知道:
不变量让循环的代码简洁——它信任工具会遵守不变量,只需处理「正常」情况。如果有工具违反不变量(理论上不应该发生),那是工具实现的 bug,应该在工具内部修复。
ToolStream 不变量与 sampler 层的流式输出(第 4 章)形成一个对称:
sampler 层:模型生成是流式的(token 增量) → SamplingEvent: ChannelToken, ToolCallDelta, ... → UI 实时显示「模型边想边说」 工具层:工具执行是流式的(进度增量) → ToolStream: Progress, Terminal → UI 实时显示「工具边做边报」
这两层都用了「流」的抽象,都让用户看到「实时进展」而非「苦等结果」。这种对称不是巧合——它们解决的是同一类问题:让长过程的中间状态可见,改善用户体验。
理解了这种对称,你对「流式」在 Grok Build 里的 pervasive(无处不在)就有更深的体会——从模型生成到工具执行,流是统一的交互范式。
把 ToolStream 不变量看下来,有几个设计点值得琢磨:
设计点一:过程与结果分离
Progress(过程)与 Terminal(结果)服务不同受众:过程给用户看,结果给模型用。这种分离让两者各得其所——用户看到丰富过程,模型不被过程干扰(只看最终结果)。
设计点二:不变量即契约
「Progress* then Terminal」是一个契约,工具作者必须遵守。契约的存在让框架代码简洁(只处理正常情况),也让工具行为可预测。这是「约定优于配置」的体现。
设计点三:简单工具不被强迫复杂化
不变量允许「零 Progress + 一个 Terminal」的简单形态。简单工具(如 ReadFile)不必为了凑数而吐 Progress,直接 Terminal 就行。不变量对简单工具是「无负担」的。
设计点四:长跑工具得到原生支持
不变量原生支持长跑工具的「边做边报」。bash、监控、构建这类工具天然适合流式,bash 工具不必自己造轮子,直接吐 Progress 就获得实时回显能力。
公平起见,也看看 ToolStream 不变量可能带来的问题与边界:
问题一:不变量的强制性
不变量要求工具必然吐且只吐一个 Terminal。如果工具因为 bug 不吐 Terminal,框架的 final_result.unwrap() 会 panic(或更优雅地报错)。这要求工具作者谨慎遵守不变量。
问题二:Progress 的粒度选择
Progress 吐得太细(每个字节一个)会有性能开销;吐得太粗(全部完成才一个)失去实时性。粒度的选择由工具作者把握,通常基于「用户关心的最小进展单位」。
问题三:取消的语义
如果在工具执行流跑到一半时用户取消,会发生什么?框架会中止流(不再 await),工具的 task 被清理。未吐 Terminal 的执行不会有最终结果,但循环已经因为取消而退出,不会等 Terminal。
这些边界并不削弱不变量的价值,只是说明它需要与取消、错误处理等机制配合(第 6 章会讲权限与沙箱如何介入工具执行)。
下一节,我们看清 Grok Build 内置的「工具库存」——50+ 工具是如何编译期组装进注册表的,以及 apply_patch 等移植工具的来历。