DeepSeek Harness · 上下文工程

Spill:工具輸出太大怎麼辦

超限輸出落盤存檔,給模型留一張取回憑證。核心源碼:packages/spill/spill-policy/src/index.ts

課程目標讀完你能説清三件事:一條 2MB 的 grep 結果進來,DSH 為什麼既不塞進上下文也不砍掉,選擇全文落盤、上下文裏只留首尾預覽加一張取回憑證;post-execute 五步決策每一步在判什麼、為什麼 read 工具被跳過;以及存儲寫失敗時結果為什麼照樣算成功。
互動演示 · 大輸出處理流水綫
模型上下文(模型看得到的部分)
存檔櫃 spillStore(會話專屬文件)
POST-EXECUTE 五步決策(spill-policy/src/index.ts)
1 · next() 委託先讓下游把結果結算好 · L194
2 · 純文本檢查混入非文本塊就整個不碰 · L200-201
3 · 字節閾值UTF-8 大小超過 maxInlineBytes 才動手 · L202-203
4 · saveText 落盤全文原樣寫進會話存檔 · L155
5 · 替換成預覽 + 憑證首尾預覽加取回提示進上下文 · L173-175
對照 · 硬截斷的做法
砍到上限,只留開頭被砍掉的部分不再存在
貼一個 truncated 標記告訴模型截過了,僅此而已
點「播放」運行情景 A,或滾動到此處自動播放。
演示為教學化模擬:卡片、字節數與文件路徑均為課程化抽象,決策邏輯對應 packages/spill/spill-policy/src/index.ts 第 190 至 209 行。演示中 maxInlineBytes 設為 50 KB,與 Agent Note 示例部署一致。
這道題的三個候選答案

一條 grep 命中了幾萬行,或者 web_fetch 抓回一整頁文檔,結果 2MB。這條結果接下來去哪,只有三個選項。

選項一,整個塞進上下文。下一次模型請求直接被它佔滿,錢包和上下文窗口一起遭殃。選項二,砍掉超出的部分。省是省了,可萬一模型後面要找的正是被砍掉的那行報錯,任務就卡死了。選項三是 DSH 的做法:全文落盤存檔,上下文裏只留首尾預覽,外加一句提示,告訴模型全文存在哪個路徑、用 read 或 grep 就能撈。丟出去的信息隨時找得回來,這就是 Spill(溢寫)。

截斷丟信息,溢寫找得回。

幹這件事的插件叫 dsh-spill-policy。它掛在工具執行流水綫的 tools/post-execute 事件上,等一條工具結果徹底定稿後才出手。整個決策就五步,Agent Note 2026-07-08 寫得很清楚:委託、純文本檢查、字節閾值、saveText、替換。演示裏那張清單就是這五步的原樣搬運。

核心視覺 · 教學化結構圖
工具結果定稿 tools/post-execute · next() 之後 純文本 且 > maxInlineBytes? read 工具直接跳過,防死循環 否 · 原樣放行 模型上下文 模型看得到的部分 saveText() 全文落盤 ctx.spillStore · 失敗則保留內聯 存檔文件(0600 排他寫) session-<hash>/<random>-grep.txt 首尾預覽 + 取回憑證 locator + retrievalHint 模型 · read / grep 按憑證隨時撈回全文
教學化結構圖:節點與連綫用於解釋源碼關係,內容經過課程化整理。
三個最容易混淆的點
只處理純文本

結果裏混進任何一個非文本塊(比如一張截圖),flattenPlainText 返回 undefined,整條結果原樣保留。策略只認識最終格式化文本,不懂工具內部結構,所以寧可不碰。出處:index.ts 第 80 至 87 行。

read 被跳過

模型面向的那一臂明確跳過 read 工具,防止 read 的輸出被 spill 成文件、模型再 read、再 spill 的死循環。日誌那一臂不跳,因為日誌副本進不了模型上下文,循環不成立。出處:第 195 至 197 行與第 219 至 222 行註釋。

憑證不是路徑

locator 是不透明句柄:本地後端給的是文件路徑,遠程後端可以給 URI 或鍵。消費方不解析它,按後端附帶的 retrievalHint 渲染取回話術,不假定 read 永遠是正確的取回方式。出處:docs/subsystems/spill.zh.md 第 70 行。

五步決策的守門邏輯

策略的入口是一串放行判斷,四條不碰的理由挨個查:下游監聽器沒接受這條結果、別的插件已經替換過值、這是嵌套子調用或 read 工具、內容混了非文本塊,任何一條命中就原樣放行。都沒命中,再量字節,沒超過 maxInlineBytes 也放行。全過了才走 spill。

這裏有個容易忽略的順序:判斷的第一步是 await next(),先委託。讓下游監聽器(比如某個替換了內容的 hook)把結果徹底結算好,spill 再對定稿動手。所以哪怕別的插件換過內容,換上來的內容照樣被 spill 管住。

出處:packages/spill/spill-policy/src/index.ts 第 194 至 209 行,核對日期 2026-08-13。跳過 read 的原因寫在源碼註釋裏:避免 read 的輸出被 spill 成文件、模型再 read、再 spill 的循環。

關鍵證據 · 存儲失敗不改判

大綱裏那個邊界條件:spill 存儲寫失敗,呢次工具調用算成功定失敗?答案在 catch 分支裏,算成功,一個字都不藏。

packages/spill/spill-policy/src/index.ts第 153 至 161 行節選
    let ref: SpillRef
    try {
      ref = await spillStore.saveText(save)
    } catch (error: unknown) {
      // Best-effort: a storage failure (permissions, ENOSPC, backend down) must
      // never fail the call or hide the content — keep the original inline.
      ctx.logger.warn(`spill-policy: saveText failed for ${toolName}: ${String(error)}; keeping the inline content`)
      return undefined
    }
源碼快照説明:依據本地倉庫 deepseek-harness-master,核對文件 packages/spill/spill-policy/src/index.ts,核對日期 2026-08-13。程式碼塊保留源碼原文。

磁盤滿了、權限不對、後端沒掛載,都只換來一條 warn 日誌,然後原始結果原樣內聯進上下文。設計文檔裏的原話是「spill 失敗絕不會把成功的工具調用變為 isError 結果,也不會隱藏內聯結果」(Agent Note 2026-07-08 第 91 行)。邏輯很樸素:spill 是省錢的優化,優化失敗最多讓上下文胖一點,絕不能把一次成功的調用弄成失敗,更不能弄丟信息。

還有一個反方向的坑也被堵了:配置校驗放在插件加載時,不放在每次調用時。一個負數或小數的 maxInlineBytes 會直接讓部署啓動失敗(第 114 至 119 行),因為壞配置該炸的是部署,輪不到某次工具調用背鍋。

憑證長什麼樣

替換後的模型可見文本是三段式:保留的頭部預覽、省略説明加憑證、保留的尾部預覽。憑證那一行由 spillNotice 拼出(第 104 至 108 行),Agent Note 給的示例是「(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.)」。措辭刻意通用,因為策略只知道最終文本,不瞭解工具內部資源。

一個細節能看出這套程式碼的較真程度:憑證本身的字節數會先從 maxInlineBytes 預算裏扣掉,再算預覽能留多少(第 171 至 172 行)。不然預覽花滿預算、憑證再往後一貼,替換文本反而可能比上限還大。要是憑證一行就超過整個上限,策略乾脆放棄 spill、保留內聯,絕不違反自己宣稱的上限(第 183 至 185 行)。

存檔文件本身也講究。本地後端把文件寫到 <root>/session-<hash>/<random>-<safeName>,根目錄私有(0700),寫入用 open(path, 'wx', 0o600) 排他且僅所有者可讀,預先植入的符號連結沒法重定向寫入(docs/subsystems/spill.zh.md 第 85 行)。同族設計還有附件系統:引用進日誌、字節放外部 store,思路一樣,正文只留輕量引用(docs/subsystems/attachment.zh.md)。

橫向對比 · 三家怎麼處理大輸出

Claude Code · 上限 + 落盤

官方網誌的原話是「For Claude Code, we restrict tool responses to 25,000 tokens by default」,源碼對應 Tool.ts 的 maxResultSizeChars(書稿 study/chapters/02-tool-system.md 第 664 至 666 行)。書稿第 670 行提到工具結果超限落盤後會附帶説明路徑,方向與 DSH 一致。網誌還補了一條原則:截斷時要告訴 Agent 為什麼截了、怎麼拿到完整內容。

Grok Build · bash 專屬落盤

工具輸出預設上限 20,000 字節(DEFAULT_TOOL_OUTPUT_CHARS,crates/codegen/xai-grok-tools/src/lib.rs 第 11 行),超限截斷並隨結果返回 truncated 標誌。bash 工具是特例:全量輸出先寫進會話目錄的 terminal log 文件(bash/mod.rs 第 379 至 381 行),截斷的部分可以從文件裏找回。只是這份落盤是 bash 專屬的,別的工具輸出被截就是被截了。

對比的焦點在通用性。三家都承認大輸出不能全餵給模型,差別是丟掉的部分還能不能找回來、這個能力覆蓋多少工具。Grok 只給 bash 落盤;Claude Code 與 DSH 做成了通用機制。DSH 的版本切得最碎:預覽機制歸 output-retention 庫,存儲歸 spillStore seam(一個方法的抽象服務),策略插件只決定什麼時候 spill、怎麼拼憑證。三個包各管一段,換一個遠程存儲後端不用動策略一行程式碼。Agent Note 的替代方案一節還點名了參照對象:做通用預設行為,就是衝着「類似 Claude Code 通用工具結果持久化」去的(第 187 行)。

課堂練習
01

手推三條輸出的命運

部署配置 maxInlineBytes: 50000。三條工具結果先後進來:一條 60,000 字節的 web_fetch 純文本;一條 200,000 字節、但混了一個 image 塊的瀏覽器截圖結果;一條 80,000 字節的純文本,但落盤時磁盤滿了。對照第 194 至 203 行的守門邏輯和第 153 至 161 行的 catch 分支,分別寫出每條結果最終進入模型上下文的形態,以及存檔櫃裏各多了幾個文件。

Takeaway:Spill 把大輸出從塞進去還是丟掉的二選一裏解放出來:全文落盤、預覽加憑證進上下文,模型用現成的 read/grep 隨時撈回。策略只處理純文本定稿、跳過 read 防死循環,存儲失敗保留內聯、不改判 isError。截斷丟信息,溢寫找得回,這是兩種上下文觀。