AI 工程

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,699261,498——而模型的 window 是 262,144——整場 run 的 compaction 嘗試次數是 0。這兩次 run 的最後一輪 output 分別只剩 445646 個 token。

不是模型變笨,是它連把話講完的空間都沒有了。

這篇拆兩件事:compaction 觸發機制為什麼會「看不見」自己快爆了、新的修法實際怎麼算;以及同一個版本裡另一個更該立刻升級的理由——在 Windows 上,只要 repo 裡有一個叫 rg.exe 的檔案,就能在你按下任何核准鍵之前被執行。兩段都會給你可以直接照做的設定與驗證方式。

二、背景:Cline 怎麼決定「該壓縮了」

先把機制講清楚,不然後面的修法會看不懂。

Cline 的 compaction 觸發是一條很單純的判斷式,住在 sdk/packages/core/src/extensions/context/compaction.ts。每輪送請求前,它會做三件事:

  1. 估算這次請求有多大:把 systemPromptmessagestools 整包 JSON.stringify,用字元數除以一個常數換算成 token。
  2. 算出可用預算:從模型資訊拿 maxInputTokens(沒有的話用 contextWindow × 0.9,再沒有就退回預設 128_000)。
  3. 比大小:估算值 ≥ 預算 × 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」,於是任務就這樣不明不白地收掉了。從使用者角度看,它就只是「突然變笨」。

Cline v4.1.19 compaction 觸發機制:從字元估算改用 provider 回報的實際 token 數

三、運作原理:為什麼不是「直接拿實際值去比」

最直覺的修法是:既然 provider 每次回應都會在 usage 事件裡回報 inputTokens,那就拿這個數字去比門檻就好了。

Bee 沒有這樣做,理由值得一講,因為這是整個 patch 最有意思的設計決策。

3-1 先把實際值接進來

第一段改動在 sdk/packages/agents/src/agent-runtime.tsAgentRuntime 的內部 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 次 skipcompaction-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_executedtask.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,就手動切 basicbasic 是確定性截斷,難看但一定會發生;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.xsrc/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 完打開就結束了。

Windows 上 spawn 裸程式名的解析順序:修正前會先命中 workspace 裡被植入的 rg.exe

修法沒有去寫一個 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

有三個設計細節值得學:

  1. 必須從 main thread 呼叫。 worker thread 的 process.env 是一份 copy,原生程式碼看不到。但反過來,main thread 寫進去之後,整個 process 共用的環境區塊都改了,連 file-indexer worker thread 的 spawn 也一起被保護——一次呼叫覆蓋所有現有與未來的 spawn 點。
  2. 這個變數刻意留在子行程環境裡。 cmd.exe、libuv-based 的子行程、Go、Bun 1.4 都認這個變數。所以當模型跑 cmd /c npm test、而 repo 裡有一個植入的 npm.cmd 時,一樣受保護。
  3. 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 一併外送這三條,也都值得一看)。

七、來源

整理:DataAgent · Coding Agent 實戰教學

發表迴響

%d 位部落客按了讚: