DeepSeek Harness · 工具系统 工具输出契约:值与展示分离 同一个结果,模型看的和人看的可以不一样。核心源码: packages/core/tools/src/index.ts 与 presentation.ts 。 一句话速览 同一个结果,模型看的和人看的可以不一样
课程目标 读完你能说清三件事:工具的返回值在 DSH 里是带 schema 的结构化 JSON 值,模型看到的文本和 UI 画出的卡片都是从这个值投影出来的;UI 靠 card 标签联合类型渲染结果,全程不需要认识工具名;持久化只存投影不存值,所以回放能复现每一张卡片,却永远重建不了中间值。
交互演示 · 双视角展台
read 读文件 grep 搜索 没写 presentation render 抛异常
播放 单步 重置
规范值 value 等待 execute() 返回… schema 校验
模型视角 render(args, value) 进入上下文、按 token 计费的那份文本
UI 视角 presentResult(args, result) 客户端拿到的渲染意图,一张带 card 标签的卡片
会话日志落盘: content meta value
选择场景后点「播放」,或滚动到此处自动播放 read 场景。 演示为教学化模拟:值、文本与卡片均为课程化举例,投影关系对应 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 行」。
核心视觉 · 投影关系图
execute() 返回 规范值 value(JSON)
output.schema 逐次强制校验
render(args, value)
content 内容块 模型看的,进上下文
presentationMeta(args, value)
meta 展示数据 可回放,随日志持久化
presentResult(args, result)
card 渲染意图 UI 看的,switch(card)
会话日志 content + meta value 不落盘 执行结束即丢弃
教学化结构图:三条投影对应 index.ts 第 211 至 219 行的 ToolOutputDefinition 与第 84 至 92 行的两个 present 回调。
关键证据 · 契约与二选一 输出契约的全部字段就九行。schema 是强制的,render 是强制的,presentationMeta 可选。注意两个投影器的注释都强调 Pure:这是回放确定性的地基。
packages/core/tools/src/index.ts 第 211 至 219 行
/** 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
}
源码快照说明: 依据本地仓库 deepseek-harness-master,核对文件 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 材料里未见等价机制,这条结论基于已公开证据保留。
课堂练习
01 给一个 SQL 查询工具设计输出契约 你要接入一个第三方 sql_query 工具,查询返回 1200 行但只保留前 50 行。请写出:value 的 schema 大致长什么样(提示:rows、total、truncated 三个字段少不了);render 给模型的文本要不要包含全部 50 行;presentResult 选六种卡片里的哪一种,截断信息放哪。最后一问:安全插件想对模型隐藏其中的手机号列,在 post-execute 里该换 content 还是换 value?想想 Code Mode 里程序拿到的是什么。
Takeaway: 工具产出一个带 schema 的值,模型文本和 UI 卡片都是它的纯函数投影,改哪份投影就走哪个通道,二选一不许混。UI 只认 card 标签不认工具名,换实现不动界面。持久化只存投影不存值:回放能复现所有展示,值本身随执行结束消失。