ToolStream 不变量


文档摘要

ToolStream 不变量 本节摘要:上一节讲工具的「身份与执行签名」,这一节看执行的「形态」。在 Grok Build 里,工具执行不是返回单个值那么简单——它返回的是一条流:零或多个 Progress(进度/中间块),最后有且仅有一个 Terminal(最终结果)。这个「Progress then Terminal」的形态,是工具系统的一个核心不变量(invariant),所有工具的执行都遵循它。本节会讲清这个不变量是什么、为什么需要它、它在不同工具里如何体现,以及它带来的统一价值。 一、为什么工具执行是「流」 最朴素的工具执行模型是「调用→返回值」:给参数,得结果。这对简单的工具(如读一个小文件)够用。

ToolStream 不变量

本节摘要:上一节讲工具的「身份与执行签名」,这一节看执行的「形态」。在 Grok Build 里,工具执行不是返回单个值那么简单——它返回的是一条:零或多个 Progress(进度/中间块),最后有且仅有一个 Terminal(最终结果)。这个「Progress* then Terminal」的形态,是工具系统的一个核心不变量(invariant),所有工具的执行都遵循它。本节会讲清这个不变量是什么、为什么需要它、它在不同工具里如何体现,以及它带来的统一价值。

一、为什么工具执行是「流」

最朴素的工具执行模型是「调用→返回值」:给参数,得结果。这对简单的工具(如读一个小文件)够用。但很多工具的执行是「长且产出中间结果」的:

  • bash 工具:跑一个长命令,stdout 逐块输出,可能持续几十秒
  • 构建工具:跑 cargo build,编译日志持续打印
  • 搜索工具:在大目录里 grep,匹配结果可能逐条找到
  • 监控工具:持续观察一个长跑进程,每行输出都是新进展

如果用「调用→返回值」模型,这些工具要么:

  • 等全部完成才返回:用户看不到中间进展,体验差(等了几十秒才看到一坨输出)
  • 自己搞一套进度回调:每个工具各搞各的,无法统一处理

ToolStream 的解法是:把执行定义成一条,工具可以边执行边吐出中间结果(Progress),最后吐出最终结果(Terminal)。这样:

  • 中间进展实时可见:每吐出一个 Progress,框架可以立刻转给 UI 显示
  • 统一处理:所有工具的执行都是同一种流形态,框架用同一套代码处理
  • 最终结果明确:流的最后一定是 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 与 Terminal 的语义

Progress(进度)

代表「执行过程中的一个中间产物」,可能是:

  • bash 的 stdout 一个数据块(如 16KB 的输出)
  • 搜索工具找到的一个匹配
  • 构建日志的一行
  • 监控工具的一行输出

Progress 的特点:

  • 可有可无:简单工具可能不产生任何 Progress
  • 可有多个:长跑工具可能持续吐 Progress
  • 不是最终结果:Progress 是「过程」,框架可以转发给 UI 但不当作工具的「返回值」

Terminal(最终结果)

代表「执行的最终产物」,是工具的「返回值」。它的类型是工具的 Output(经过 ToolOutput 标准化后变成 ToolResult)。Terminal 的特点:

  • 有且仅有一个:每个执行流必然以一个 Terminal 结束
  • 是最后一个:Terminal 之后流立即结束
  • 是塞回历史的:Terminal 的内容(标准化后)作为 tool_result 消息塞回会话历史,给模型看

关键概念: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() }

不变量的好处在这里清晰可见:

  • 统一的循环结构:无论工具简单还是复杂,框架都用同一个 while 循环处理流
  • 进度处理统一:所有工具的 Progress 都走「转发 UI」的同一套逻辑
  • 结果获取确定:框架确信流结束时有且仅有一个 Terminal,可以放心 unwrap
  • 流结束语义清晰:Terminal 出现即流结束,框架知道何时停止等待

如果没有这个不变量(比如某些工具吐多个 Terminal、某些不吐 Terminal),框架的处理代码会变成一堆特殊情况的处理,复杂且易错。

六、不变量与第 3 章循环的关系

回顾第 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 不变量。循环知道:

  • Progress 会持续到来,实时转发就行
  • Terminal 出现就结束,把它塞回历史
  • 不必担心「工具吐了多个 Terminal」「工具没吐 Terminal」等异常

不变量让循环的代码简洁——它信任工具会遵守不变量,只需处理「正常」情况。如果有工具违反不变量(理论上不应该发生),那是工具实现的 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 章会讲权限与沙箱如何介入工具执行)。

本节要点回顾

  1. 工具执行是流而非单值:支持长跑工具的「边做边报」,改善用户体验。
  2. 不变量「Progress then Terminal」*:零或多个 Progress,有且仅有一个 Terminal,Terminal 是最后一个。
  3. Progress 是过程:中间产物(bash 输出块、搜索匹配等),转发给 UI 实时显示,不当返回值。
  4. Terminal 是结果:最终产物,塞回历史给模型看,流在此结束。
  5. 不同工具的不同体现:ReadFile 零 Progress 直接 Terminal;bash 多 Progress 边输出边转发。
  6. 框架利用不变量统一处理:同一个 while 循环处理所有工具,Progress 转发、Terminal 收集,确定性结束。
  7. 与第 3 章循环的关系:execute_tools 的处理基于不变量,代码简洁信任工具守约。
  8. 与 sampler 流式的对称:模型生成流式 + 工具执行流式,流是统一交互范式。
  9. 设计点:过程与结果分离、不变量即契约、简单工具无负担、长跑工具原生支持。

下一节,我们看清 Grok Build 内置的「工具库存」——50+ 工具是如何编译期组装进注册表的,以及 apply_patch 等移植工具的来历。


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