Spill:工具輸出太大怎麼辦
超限輸出落盤存檔,給模型留一張取回憑證。核心源碼:packages/spill/spill-policy/src/index.ts。
read 工具被跳過;以及存儲寫失敗時結果為什麼照樣算成功。
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、替換。演示裏那張清單就是這五步的原樣搬運。
只處理純文本結果裏混進任何一個非文本塊(比如一張截圖),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 分支裏,算成功,一個字都不藏。
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
}
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 行)。
手推三條輸出的命運
部署配置 maxInlineBytes: 50000。三條工具結果先後進來:一條 60,000 字節的 web_fetch 純文本;一條 200,000 字節、但混了一個 image 塊的瀏覽器截圖結果;一條 80,000 字節的純文本,但落盤時磁盤滿了。對照第 194 至 203 行的守門邏輯和第 153 至 161 行的 catch 分支,分別寫出每條結果最終進入模型上下文的形態,以及存檔櫃裏各多了幾個文件。