Cline v4.1.19 拆解:compaction 為什麼該壓的時候沒壓,以及一個 repo 就能劫持 agent 的 Windows 漏洞
本文大綱
一、長任務跑到後段,agent 突然只剩 400 個 token 可以講話
你大概遇過這個場景:叫 coding agent 做一個橫跨十幾個檔案的 refactor,前二十輪很順,到後段它開始「變笨」——回覆愈來愈短、工具呼叫愈來愈草率,最後草草收尾。多數人第一反應是「模型不行」,但更常見的原因是:context window 已經塞滿,而自動壓縮(compaction)沒有在該觸發的時候觸發。
Cline 在 v4.1.19(2026-09-17 發布)修掉的正是這件事。Cline 工程師 Bee(GitHub abeatrix)在 PR #14195 裡貼了實測數字:在 Terminal-Bench 2.1 與一組內部 24 題 SWE 類任務上(模型為 nemotron-3.5-lightning / nemotron-3-ultra),有兩次 run 的 provider 回報的實際 input token 分別衝到 261,699 與 261,498——而模型的 window 是 262,144——整場 run 的 compaction 嘗試次數是 0。這兩次 run 的最後一輪 output 分別只剩 445 和 646 個 token。
不是模型變笨,是它連把話講完的空間都沒有了。
這篇拆兩件事:compaction 觸發機制為什麼會「看不見」自己快爆了、新的修法實際怎麼算;以及同一個版本裡另一個更該立刻升級的理由——在 Windows 上,只要 repo 裡有一個叫 rg.exe 的檔案,就能在你按下任何核准鍵之前被執行。兩段都會給你可以直接照做的設定與驗證方式。
二、背景:Cline 怎麼決定「該壓縮了」
先把機制講清楚,不然後面的修法會看不懂。
Cline 的 compaction 觸發是一條很單純的判斷式,住在 sdk/packages/core/src/extensions/context/compaction.ts。每輪送請求前,它會做三件事:
- 估算這次請求有多大:把
systemPrompt、messages、tools整包JSON.stringify,用字元數除以一個常數換算成 token。 - 算出可用預算:從模型資訊拿
maxInputTokens(沒有的話用contextWindow × 0.9,再沒有就退回預設128_000)。 - 比大小:估算值 ≥ 預算 ×
COMPACTION_TRIGGER_RATIO(0.9)就壓縮。
問題出在第 1 步那個常數。在 sdk/packages/shared/src/llms/tokens.ts 裡:
export const CHARS_PER_TOKEN = 3;
export function estimateTokens(chars: number): number {
return Math.max(1, Math.ceil(chars / CHARS_PER_TOKEN));
}
註解寫得很誠實:用 3 而不是慣例的 4,是為了「略為高估」,讓門檻在被 provider 退件之前就先燒起來。對一般的 JS/TS 原始碼,這個保守假設是成立的——甚至會過度高估。
但有一類內容不吃這套:反組譯輸出、base64 圖片 dump、minified bundle、密集的 log。這些東西的 tokenizer 密度遠高於 3 字元換 1 token。於是整條判斷式就翻轉了:實際 token 已經逼近 window 上限,而估算值還悠哉地停在門檻以下,compaction 一次都不會啟動。
接下來發生的事更陰險。請求還是送得出去,provider 也不會硬性拒絕,只是 input 吃光了預算,留給 output 的空間被壓到剩幾百個 token。agent loop 看到這種極短回覆,會判定成「撞到 output limit」,於是任務就這樣不明不白地收掉了。從使用者角度看,它就只是「突然變笨」。

三、運作原理:為什麼不是「直接拿實際值去比」
最直覺的修法是:既然 provider 每次回應都會在 usage 事件裡回報 inputTokens,那就拿這個數字去比門檻就好了。
Bee 沒有這樣做,理由值得一講,因為這是整個 patch 最有意思的設計決策。
3-1 先把實際值接進來
第一段改動在 sdk/packages/agents/src/agent-runtime.ts。AgentRuntime 的內部 state 多了一個欄位,在 usage 事件抵達時記下來:
case "usage": {
if (
typeof event.usage.inputTokens === "number" &&
event.usage.inputTokens > 0
) {
this.state.lastRequestInputTokens = event.usage.inputTokens;
}
await this.updateUsage(event.usage);
break;
}
然後在組下一輪的 prepare-turn context 時,以 previousRequestInputTokens 傳下去。
這裡有個容易被忽略的細節,而且它差點讓整個修正變成空包彈:SessionRuntime.createRuntimePrepareTurn() 是逐欄位重建 prepare-turn context 的。沒有在那裡補一行轉發,這個欄位會在每一個正式的 core session 裡被默默丟掉。PR 為此加了一個專門的回歸測試(session-runtime-orchestrator.test.ts)。這是所有多層 pipeline 架構的通病:只要中間任何一層是 explicit field mapping,新增的欄位就有機會人間蒸發,而且不會報錯。
3-2 縮預算,而不是抬門檻
拿到實際值之後,compaction.ts 的算法是這樣:
const actualPreviousInputTokens =
typeof context.previousRequestInputTokens === "number" &&
context.previousRequestInputTokens > 0
? context.previousRequestInputTokens
: 0;
const underestimateFactor =
actualPreviousInputTokens > 0 && requestInputTokens > 0
? Math.min(
MAX_INPUT_UNDERESTIMATE_FACTOR,
Math.max(1, actualPreviousInputTokens / requestInputTokens),
)
: 1;
const maxInputTokens = rawMaxInputTokens / underestimateFactor;
拆開看:
actual / estimate就是「估算器低估了幾倍」。前一輪 provider 說用了 20 萬 token,而我們的字元估算只算出 10 萬,那 factor 就是 2。Math.max(1, ...)保證它只會收緊、不會放寬。估算器高估的時候(JS/TS repo 的常態)factor 固定為 1,行為完全不變。Math.min(MAX_INPUT_UNDERESTIMATE_FACTOR, ...)把上限鎖在 4。原始碼註解解釋得很清楚:密集內容現實中大約密 2–3 倍,設 4 是防止某個病態的小估算值把預算直接壓垮、變成每輪都在壓縮。- 最後
rawMaxInputTokens / factor——縮的是整個預算,不是只抬門檻。
為什麼一定要縮預算?因為 compaction 不只需要決定「要不要壓」,還要決定「壓完留多少」。requestTriggerTokens(預算 × 0.9)和 retention target 都是從同一個 maxInputTokens 推導出來的。如果只把觸發門檻調嚴,那壓縮確實會啟動,但它的保留目標仍然是照著那個被高估的預算算的——壓完還是會溢位,然後下一輪再壓一次。縮預算讓 trigger、target、以及 projection 裡每一則訊息的成本估算,全部維持在同一套單位裡。
程式碼裡那行註解值得抄進你自己的 codebase:
Scaling the budget rather than just the trigger keeps every downstream number — trigger, target and the projection's message costs — in the same estimate units while still corresponding to the provider's real limit.
3-3 估算值仍然是地板
比大小的那一行沒有改:
const shouldCompact = requestInputTokens >= requestTriggerTokens;
requestInputTokens 依然是字元估算。這是刻意的——因為第一輪請求還沒有任何 usage 可以參考,previousRequestInputTokens 是 undefined,factor 是 1。如果整套邏輯改成只信實際值,一個開場就超大的 context(例如貼進一整份 minified bundle)反而會完全不設防。
保留估算當地板、用實際值當校正係數,是這個 patch 在工程上最漂亮的一手。
3-4 順手調大 summarizer 的 output budget
同一個 PR 把 DEFAULT_SUMMARY_MAX_OUTPUT_TOKENS 從 4096 拉到 8192。原始碼註解說明了理由:預設就會 reasoning 的模型,可能把一個很緊的 output budget 全花在思考上,最後一個字的摘要都沒吐出來,於是 compaction 直接被判定為 skip。
這是一個很典型的、只有在 reasoning model 普及之後才會出現的失效模式:你的 output budget 現在要同時養活「思考」和「產出」兩件事。如果你自己的系統裡有任何「叫 LLM 產一段結構化輸出」的節點,而且 max_tokens 是幾年前拍板的,現在值得回去看一眼。
四、數據與限制:這個 PR 誠實到有點罕見
我通常不會在技術長文裡誇 PR 描述寫得好,但 #14195 值得特別提,因為它做了一件很多人不會做的事——作者在 PR 描述裡公開推翻了自己稍早的版本。
原本的描述宣稱 validation run 證明了新的 trigger 有正確武裝,peak 每請求用量 180K–202K,對上 235,930 的門檻。Bee 後來自己更正:那是錯的,而且錯的原因正是上面 3-1 講的 bridge bug——previousRequestInputTokens 根本沒傳到 pipeline,actual 一直是 0,那次 run 完全是靠字元估算武裝的。原文寫得很直白:
The honest reading of those numbers is the opposite of what I wrote — on those JS/TS repos the estimator over-counts, and the actual-usage path contributed nothing.
所以把數字的適用範圍講清楚:
已證實的部分:bug 是真的。261,699 / 262,144、零次 compaction、最後一輪 445 個 output token,這是可驗證的失效現場。
未經端到端實測的部分:修正後的行為目前由單元測試覆蓋(compaction.test.ts 79/79,含四個新測試;session-runtime-orchestrator.test.ts 69/69),不是由一次大規模 benchmark run 證明的。PR 有做 mutation check——把預算縮放拔掉會讓 trigger 測試失敗、把 bridge 轉發拔掉會讓 orchestrator 測試失敗——這比單純「測試通過」有說服力,但它終究是單元層級的證據。
還沒修好的部分
PR 自己點名了一個 沒有解決 的問題:4096 → 8192 的調整並沒有消滅 skip。在 validation run 裡,13 次 compaction 嘗試產生了 1 次成功、11 次 skip(compaction-skipped,也就是 agentic summarizer 回傳空摘要,數字依 PR 原文引述)。
而 compaction 一旦武裝,它會每一輪都重試、每一輪都 skip——在一個本來就已經很吃緊的 run 上,這等於每次迭代都白燒一次 summarizer 呼叫。
Bee 提出的後續方向是:當 agentic 回傳空值時,fallback 到 deterministic 的 basic 策略,因為「skip 掉的 compaction 嚴格來說比機械式截斷更糟」。這個 follow-up 在我查證時(2026-09-18)還沒有對應的 PR 合併。
所以請這樣理解這個版本:它修好的是 arming(該不該壓),不是 completion(壓不壓得成)。
另一個已知的邊界:provider 不回報 usage 就沒有用
整套機制的前提是 event.usage.inputTokens > 0。如果你的 provider 沒有回報 input token 數(或回報 0),factor 永遠是 1,行為就退回 v4.1.19 之前。
這不是假設性的風險。Cline repo 的 issue #13989(2026-09-09 開啟,查證時仍為 OPEN)就記錄了一個相關情境:CLI 打原生 Ollama API 時,task.compaction_executed 與 task.compaction_skipped 兩個事件都從未發出,run 把 num_ctx 填滿、log 裡已經警告估算 prompt 超過 context window,下一輪照樣不壓縮就送出去。那張 issue 描述的是更上游的觸發問題、環境是較舊的 CLI 3.0.61,不能直接當成 v4.1.19 的測試結果,但它足以說明一件事:在本地模型或小眾 provider 上,不要預設 context 管理是安全的。
五、實戰:你現在該做的三件事
5-1 確認並設定你的 compaction 策略
Cline 的全域設定檔在:
~/.cline/data/settings/global-settings.json
(路徑可被 CLINE_DIR / CLINE_DATA_DIR / CLINE_GLOBAL_SETTINGS_PATH 三個環境變數覆寫,解析邏輯在 sdk/packages/shared/src/storage/paths.ts。)
相關的兩個欄位:
{
"compactionStrategy": "agentic",
"compactionEnabled": true
}
compactionStrategy 只吃 "basic" 或 "agentic",schema 的 .catch("agentic") 會把任何非法值默默吃掉並退回 agentic——所以打錯字不會報錯,只會讓你以為設定生效了。要關掉是設 compactionEnabled: false,而不是把 strategy 設成某個奇怪的值;程式碼刻意在關閉時保留原本的 strategy,之後重新開啟會還原。
CLI 端有對應的旗標,優先權高於設定檔:
cline --compaction agentic # LLM 摘要(預設)
cline --compaction basic # 機械式截斷
cline --compaction off # 關閉
怎麼選? 在上面第四節那個「11 次 skip」的證據下,我的建議是:如果你的工作負載是密集內容(大量 log、binary dump、超長 tool output),而且你發現 agentic 一直 skip,就手動切 basic。basic 是確定性截斷,難看但一定會發生;agentic 品質好但可能什麼都不做。在 fallback 機制合併之前,這個選擇得你自己做。
5-2 打開 debug log,親眼看 factor 有沒有動
這是這個版本最有價值的一招。compaction.ts 會輸出一筆叫 Context compaction diagnostics 的 debug log,而且 PR 特地把三個新欄位加了進去。
CLINE_LOG_LEVEL=debug cline
# 預設 log 路徑:<CLINE_DATA_DIR>/logs/cline.log
# 可用 CLINE_LOG_PATH 覆寫
然後撈出來看:
grep "Context compaction diagnostics" ~/.cline/data/logs/cline.log | tail -5
你要盯的欄位:
| 欄位 | 意義 | 怎麼判讀 |
|---|---|---|
rawMaxInputTokens |
未縮放的原始預算 | 應該等於模型的 maxInputTokens |
actualPreviousInputTokens |
provider 回報的實際值 | 一直是 0 = 新機制對你完全沒作用 |
underestimateFactor |
低估倍率 | 1 = 沒啟動;>1 = 正在收緊;碰到 4 = 撞到上限 |
maxInputTokens |
縮放後預算 | rawMaxInputTokens / underestimateFactor |
requestTriggerTokens |
實際門檻 | 縮放後預算 × 0.9 |
actualPreviousInputTokens 恆為 0 是最重要的警訊——代表你的 provider 沒有回報 input token,你等於還在跑舊行為。這一條檢查花你 30 秒,但它直接決定這次升級對你有沒有意義。
5-3 Windows 使用者:這個安全修正比 compaction 更急
同一個版本裡,Cline 創辦人 Saoud Rizwan 合併了 PR #14171,處理一個 binary planting 漏洞。這個要單獨講。
在 Windows 上,child_process.spawn("rg", ...) 解析裸程式名時,會先看子行程的工作目錄,再走 PATH。libuv 把這個步驟 gate 在 NeedCurrentDirectoryForExePathW 上,而這個函式只在「spawn 的行程有定義 NoDefaultCurrentDirectoryInExePath」時才回傳 false(PR 描述表示已對 libuv 1.49.2、1.51.0 與 v1.x 的 src/win/process.c 逐一查證)。
而 Cline core 會用使用者的 workspace 當 cwd 去 spawn:rg(檔案索引器與搜尋)、git(simple-git,用於 workspace metadata 與 checkpoint)、powershell / pwsh(shell executor)、taskkill.exe、hook launcher、以及 MCP server。
所以攻擊面長這樣:一個 repo 裡放一個 rg.exe,在 workspace 被索引的那一刻就會以你的權限執行——在任何核准流程之前。 你不需要跑它、不需要按同意、甚至不需要開任何檔案。git clone 完打開就結束了。

修法沒有去寫一個 per-spawn 的路徑解析器(這正是被取代的 #14149 的做法,約 200 行、還只保護到 shell executor),而是直接用 Microsoft 文件化的開關。@cline/shared 新增了一個 34 行的 helper:
export const NO_DEFAULT_CURRENT_DIRECTORY_IN_EXE_PATH_ENV =
"NoDefaultCurrentDirectoryInExePath";
export function disableCurrentDirectoryExecutableSearch(
options: {
env?: Record<string, string | undefined>;
platform?: NodeJS.Platform;
} = {},
): void {
const { env = process.env, platform = process.platform } = options;
if (platform !== "win32") return;
env[NO_DEFAULT_CURRENT_DIRECTORY_IN_EXE_PATH_ENV] = "1";
}
它在四個入口被呼叫一次——CLI (apps/cli/src/index.ts)、desktop sidecar、VS Code 擴充的 activate()、以及 JetBrains 用的 cline-core.ts。
有三個設計細節值得學:
- 必須從 main thread 呼叫。 worker thread 的
process.env是一份 copy,原生程式碼看不到。但反過來,main thread 寫進去之後,整個 process 共用的環境區塊都改了,連 file-indexer worker thread 的 spawn 也一起被保護——一次呼叫覆蓋所有現有與未來的 spawn 點。 - 這個變數刻意留在子行程環境裡。 cmd.exe、libuv-based 的子行程、Go、Bun 1.4 都認這個變數。所以當模型跑
cmd /c npm test、而 repo 裡有一個植入的npm.cmd時,一樣受保護。 - runtime 的差異有被盤過。 CLI 與 desktop sidecar 跑在 Bun 1.3.13 上,它的 spawn 只走 PATH,本來就沒這個問題;但 Bun 1.4 加入了同樣的 cwd 搜尋、且 gate 在同一個變數上(
src/which/lib.rs),所以還是先防禦性地加上去。
唯一的行為改變:在 Cline 啟動的 Command Prompt shell 裡,要執行當前目錄的程式現在需要加 .\——而這正是 Windows 預設的 PowerShell 一直以來的要求。VS Code 內建終端機是由 pty host 而非 extension host spawn 的,不受影響。
值得實測的點是:在 Cline 的 run_commands 終端機裡跑一次
echo $env:NoDefaultCurrentDirectoryInExePath
有值就代表開關確實傳到了子行程。(PR 的自動化測試是在 windows-latest CI 上,在暫存目錄植入一個零位元組的 cmd.exe 再 spawn cmd /c echo ok:變數未設時會選中植入檔並以 EFTYPE 失敗,呼叫 helper 之後才跑到真正的 System32\cmd.exe。)
六、對工程團隊的意義
把這兩個修正抽象一層,有三件事可以直接帶回你自己的 agent 系統:
一、不要拿估算值當硬性安全邊界。 只要下游有一個「真值」可以拿(provider 的 usage 回報、tokenizer 的實際輸出),就該用它去校正估算,而不是讓估算獨自決定生死。Cline 的解法是個好模板:估算當地板保底、真值當收緊係數、係數設上限防病態值。
二、多層 pipeline 的欄位轉發要有回歸測試。 previousRequestInputTokens 差一點就整個失效,而且失效時完全不報錯——它只是靜靜地變成 undefined,讓 factor 永遠是 1。任何逐欄位重建 context 的 orchestrator 層,都該有一個「這個欄位確實有傳過去」的斷言測試。這種 bug 在 code review 裡幾乎抓不到。
三、盤點你的 agent 有哪些 cwd-relative 的執行。 Cline 這個漏洞的本質不是 Windows 的鍋,是「agent 以使用者權限、在不受信任的目錄下 spawn 裸程式名」這個模式本身很危險。如果你的系統會在 clone 下來的 repo 裡跑 rg / git / npm / hook script,去確認你的 runtime 的路徑解析順序到底是什麼。這不是 Windows 限定的思考題,只是 Windows 剛好給了最直接的利用路徑。
該不該升級? Windows 使用者是「立刻」——這是一個不需要使用者互動的任意程式碼執行。其他平台的話,compaction 修正對你有沒有用,用 5-2 那段 log 檢查 30 秒就知道了;如果 actualPreviousInputTokens 一直是 0,那這版對你的價值主要在其他那二十幾條修正上(其中 checkpoint 重新雜湊的效能修正、apply_patch 靜默覆寫既有檔案、以及 Langfuse tracing 之前會把第三方 provider 的 prompt 一併外送這三條,也都值得一看)。
七、來源
- Release:cline/cline v4.1.19(2026-09-17 發布)
- PR #14195 —
core: trigger compaction on the provider's actual input-token count,作者 Bee(abeatrix),2026-09-17 合併:https://github.com/cline/cline/pull/14195 - PR #14171 —
fix: stop Windows resolving bare program names through the workspace cwd,作者 Saoud Rizwan(saoudrizwan),2026-09-17 合併,取代 #14149:https://github.com/cline/cline/pull/14171 - 原始碼:
sdk/packages/core/src/extensions/context/compaction.ts、compaction-shared.ts、sdk/packages/shared/src/llms/tokens.ts、sdk/packages/shared/src/runtime/windows-exe-path.ts、sdk/packages/shared/src/storage/paths.ts - CHANGELOG:https://github.com/cline/cline/blob/main/CHANGELOG.md
- 相關 issue:cline/cline#13989(CLI + 原生 Ollama 自動壓縮未觸發,查證時為 OPEN)
整理:DataAgent · Coding Agent 實戰教學


