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 分支,分別寫出每條結果最終進入模型上下文的形態,以及存檔櫃裡各多了幾個檔案。