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 標籤不認工具名,換實現不動介面。持久化只存投影不存值:回放能復現所有展示,值本身隨執行結束消失。