工具输出契约:值与展示分离
同一个结果,模型看的和人看的可以不一样
本页解决的问题
先给结论「工具输出契约:值与展示分离」要解决的关键问题是什么?
同一个结果,模型看的和人看的可以不一样
让这个结论先证明自己值得留下。 把这一页当成决策工具,而不是需要背下来的定义。把概念连到一个真实任务、一个可观察结果,以及一个能改变你判断的失败上。
写下一个问题:试完这个方法后,你能用什么证据回答它?
结论听起来很完整,却没有检查最关键的假设。
等待 execute() 返回…
schema 校验
packages/core/tools/src/index.ts 第 211 至 219 行的输出契约与 presentation.ts 的 card 联合类型。玩的时候对比左右两栏:同一个 value,两份完全不同的呈现。先回答标题里的问题:工具结果到底是字符串还是结构化值?在 DSH 里两个都是,但地位不同。工具的 execute 只返回一个规范 JSON 值(canonical value),这个值必须通过工具自己声明的 output.schema 校验。字符串是后来才有的:注册表拿着校验过的值调用 render(args, value),投影出模型看到的内容块。
所以链路是:execute 产出值,schema 把关,render 投影模型内容,可选的 presentationMeta 投影一份可回放的 UI 数据,presentResult 再把它变成一张卡片。render 和 presentResult 都是纯函数,不做 I/O,因为它们在实时流式输出和会话日志回放两条路径上都要跑,跑出来必须一样。
UI 那边拿到的东西叫渲染意图(render intent):一个带 card 标签的联合类型,值域是 generic、terminal、diff、read、search、web 六种卡片。客户端只需要对 card 做 switch,不需要认识任何工具名。换一个搜索后端 provider,工具实现整个换掉,只要它还产出 search 卡,UI 一行不用改。这就是 UI 契约与工具实现解耦的意思。
value 只活在执行期持久化的 tool/result 事件只存 content、error 和 meta,规范值从不落盘。回放可以重现每一张卡片和每一段模型文本,却重建不了中间值(docs/subsystems/tools.zh.md「结果仅承载产出」一节)。
投影坏了不等于崩了值没过 schema、render 抛异常、presentationMeta 产出非 JSON,全部转成 JSON 安全的 isError 结果。模型看到一条错误文本,流水线照常走完,出处在 index.ts 第 1793 行起的 createSuccessResult。
截断必须亮牌search 卡强制携带 truncated 和 total 两个字段,UI 永远不会把砍过的结果当完整结果画出来(presentation.ts 第 223 至 231 行)。read 卡同理带 offset 和 totalLines,能画出「显示 N 行,共 M 行」。
输出契约的全部字段就九行。schema 是强制的,render 是强制的,presentationMeta 可选。注意两个投影器的注释都强调 Pure:这是回放确定性的地基。
/** Tool-owned canonical output contract used after the body returns a JSON value. */
export interface ToolOutputDefinition {
/** Raw supported JSON Schema enforced against every successful canonical value. */
readonly schema: JsonSchemaNode
/** Pure projection from validated arguments and value to Native/model content. */
render(args: unknown, value: JsonValue): ContentBlock[]
/** Pure replayable presentation projection, computed only for top-level calls. */
presentationMeta?(args: unknown, value: JsonValue): JsonValue
}
packages/core/tools/src/index.ts,核对日期 2026-08-13。代码块保留源码原文。值与展示分离还解释了 post-execute 插件的一条怪规矩:accept 的时候,换 content 和换 value 只能二选一。这条规矩不是靠文档约定,是写死在类型定义里的。PostToolDecision 的 accept 有两个分支:一个分支允许带 content,同时把 value 字段的类型标成 never;另一个分支反过来,允许带 value,把 content 标成 never。TypeScript 里 never 类型没有任何合法取值,谁想在一个决定里同时塞两个字段,编译器直接报错。第三个分支是 block,把纠正性反馈变成错误结果。
出处:packages/core/tools/src/index.ts 第 593 至 600 行的 PostToolDecision 类型定义,核对日期 2026-08-13。
为什么不许同时换?因为两边语义不一样。换 content 是展示层的动作:值保持原样,只改模型看到的文本。换 value 是数据层的动作:注册表会拿新值重新过一遍 schema,再重新算 content 和 meta,保证三份投影出自同一个源头。允许同时换,就可能出现文本说 A、值是 B 的分裂结果。文档还补了一句要害提醒:内容替换是展示策略,想对程序隐藏值的插件必须换值或者 block,光改文本瞒不住 Code Mode 里拿值的程序(docs/subsystems/tools.zh.md「后置策略」一节)。
两个兜底问题也有了答案。render 抛异常,注册表把它转成 JSON 安全的 isError,模型看到错误文本。第三方工具没写 presentCall / presentResult,客户端回退到 generic 卡:标题就是工具名,原始参数当输入展示(index.ts 第 79 至 83 行的注释写明了这条回退)。都不崩,都有着落。
Claude Code 的渲染直接长在工具接口上。Tool 接口里有 renderToolResultMessage() 负责 UI 渲染、mapToolResultToToolResultBlockParam() 负责格式转换(书稿 study/chapters/02-tool-system.md 第 96 至 98 行的接口分类图),工具文件本身是 .tsx,渲染逻辑是工具自带的 React 组件。这条路线的好处是工具作者掌控每个像素,代价是换一个客户端(比如从终端换到编辑器插件)就要重写渲染层,回放也需要重新执行渲染代码。DSH 把这层翻译成了数据:工具只声明渲染意图,六种卡片词汇是 host 和 client 之间的中立协议,谁来渲染都行。
结果超限的处理也能对上:CC 用 maxResultSizeChars,超了就落盘、给模型留预览加路径(study/chapters/02-tool-system.md 第 463 至 496 行);DSH 的对应机制是 spill 策略,在 Compaction 双路径 一课讲过。两家都想清楚了同一件事:工具结果的体量必须有人管,不能放任它撑爆上下文。
Grok Build 用 Rust 枚举给工具输出做类型化:比如 search_replace 的输出是 SearchReplaceOutput 枚举,InvalidInput、NoMatchesFound 这些失败形态在编译期就定死了(crates/codegen/xai-grok-tools/src/implementations/grok_build/search_replace/mod.rs)。输入侧同样讲究,面向模型的 canonical input 做成稳定投影,站内 Canonical input 是稳定投影 有完整拆解。至于输出的 UI 呈现与模型文本是否像 DSH 这样走统一的卡片词汇,已核对的 Grok 材料里未见等价机制,这条结论基于已公开证据保留。
给一个 SQL 查询工具设计输出契约
你要接入一个第三方 sql_query 工具,查询返回 1200 行但只保留前 50 行。请写出:value 的 schema 大致长什么样(提示:rows、total、truncated 三个字段少不了);render 给模型的文本要不要包含全部 50 行;presentResult 选六种卡片里的哪一种,截断信息放哪。最后一问:安全插件想对模型隐藏其中的手机号列,在 post-execute 里该换 content 还是换 value?想想 Code Mode 里程序拿到的是什么。
「交互演示 · 双视角展台」的能力藏在每次交接里
「先回答标题里的问题:工具结果到底是字符串还是结构化值?在 DSH 里两个都是,但地位不同。工具的 execute 只返回一个规范 JSON 值(canonical value),这个值必须通过工具自己声明的 output.schema 校验。字符串是后来才有的:注册表拿着校验过的值调用 render(args, value) ,投影出模型看到的内容块」说明,Agent 的表现不只由模型决定。模型、上下文、工具、状态、权限和人之间的每次交接,都会改变任务能否继续以及出了问题能否恢复。
先写清状态,再增加能力
从「所以链路是:execute 产出值,schema 把关,render 投影模型内容,可选的 presentationMeta 投影一份可回放的 UI 数据, presentResult 再把它变成一张卡片。render 和 presentResult 都是纯函数,不做 I/O,因为它们在实时流式输出和会话日志回放两条路径上都要跑,跑出来必须一样」出发,可以把流程拆成起始状态、下一步动作、工具返回、状态更新和停止条件。这样调试时找的是第一处丢失信息或权限的位置,而不是笼统地说“模型变笨了”。
成功路径不能代表系统可靠
用「你要接入一个第三方 sql_query 工具,查询返回 1200 行但只保留前 50 行。请写出:value 的 schema 大致长什么样(提示:rows、total、truncated 三个字段少不了);render 给模型的文本要不要包含全部 50 行;presentResult 选六种卡片里的哪一种,截断信息放哪。最后一问:安全插件想对模型隐藏其中的…」重放一次成功和一次失败,记录每一轮真正传入的上下文、工具结果和负责人;只要第二个人能复述这条链,系统才有可维护性。
从「交互演示 · 双视角展台」走到「机制拆解 · 一个值,三份投影」
「交互演示 · 双视角展台」先把问题落在「read 读文件 grep 搜索 没写 presentation render 抛异常 播放 单步 重置 规范值 value 等待 execute() 返回… schema 校验 模型视角 render(args, value) 进入上下文、按 token 计费的那份文本 UI 视角 presentResult(args, result) 客户端拿到的渲染意图,一张带 card 标签的卡片 会话日志落盘: conte…」上;到了「机制拆解 · 一个值,三份投影」,讨论继续推进到「先回答标题里的问题:工具结果到底是字符串还是结构化值?在 DSH 里两个都是,但地位不同。工具的 execute 只返回一个规范 JSON 值(canonical value),这个值必须通过工具自己声明的 output.schema 校验。字符串是后来才有的:注册表拿着校验过的值调用 render(args, value) ,投影出模型看到的内容块」。两段连起来,重点就不只是记住一个结论,而是看清它成立所依赖的条件。
把这条判断带到下一个场景
分析 Agent 时,沿着状态、动作、工具结果和下一步的顺序走一遍;每次交接都要能说明信息从哪里来、由谁确认、失败时停在哪里。
- 「交互演示 · 双视角展台」:read 读文件 grep 搜索 没写 presentation render 抛异常 播放 单步 重置 规范值 value 等待 execute() 返回… schema 校验 模型视角 render(args, value) 进入上下文、按 token 计费的那份文本 UI 视角 presentResult(args, result) 客户端拿到的渲染意图,一张带 card 标签的卡片 会话日志落盘: conte…
- 「机制拆解 · 一个值,三份投影」:先回答标题里的问题:工具结果到底是字符串还是结构化值?在 DSH 里两个都是,但地位不同。工具的 execute 只返回一个规范 JSON 值(canonical value),这个值必须通过工具自己声明的 output.schema 校验。字符串是后来才有的:注册表拿着校验过的值调用 render(args, value) ,投影出模型看到的内容块
- 「最后的要点」:值与展示分离还解释了 post-execute 插件的一条怪规矩:accept 的时候,换 content 和换 value 只能二选一。这条规矩不是靠文档约定,是写死在类型定义里的。 PostToolDecision 的 accept 有两个分支:一个分支允许带 content,同时把 value 字段的类型标成 never ;另一个分支反过来,允许带 value,把 content 标成 never 。TypeS…
最后的「最后的要点」把讨论落到「值与展示分离还解释了 post-execute 插件的一条怪规矩:accept 的时候,换 content 和换 value 只能二选一。这条规矩不是靠文档约定,是写死在类型定义里的。 PostToolDecision 的 accept 有两个分支:一个分支允许带 content,同时把 value 字段的类型标成 never ;另一个分支反过来,允许带 value,把 content 标成 never 。TypeS…」。回看这条线索时,最值得保留的是:当输入、规模或风险改变,哪些判断需要重新做一遍。
我把这篇文章里的一个判断改写成了今天可以验证的小实验。比记住结论更有用的是,知道下一步要观察什么。
读完以后我先回头找它成立的条件,而不是直接把方法搬进项目。这个顺序让后面的取舍清楚很多。
如果把这个判断放到真实工作里,最先需要补的约束是什么?我想知道从阅读到第一次实践之间,哪一步最值得先做。
还没有这篇文章的讨论。