DeepSeek Harness · 工具系统

工具输出契约:值与展示分离

同一个结果,模型看的和人看的可以不一样。核心源码:packages/core/tools/src/index.tspresentation.ts

课程目标读完你能说清三件事:工具的返回值在 DSH 里是带 schema 的结构化 JSON 值,模型看到的文本和 UI 画出的卡片都是从这个值投影出来的;UI 靠 card 标签联合类型渲染结果,全程不需要认识工具名;持久化只存投影不存值,所以回放能复现每一张卡片,却永远重建不了中间值。
交互演示 · 双视角展台
规范值 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 卡强制携带 truncatedtotal 两个字段,UI 永远不会把砍过的结果当完整结果画出来(presentation.ts 第 223 至 231 行)。read 卡同理带 offsettotalLines,能画出「显示 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 标签不认工具名,换实现不动界面。持久化只存投影不存值:回放能复现所有展示,值本身随执行结束消失。