AI 工程

Claude Code 的 context 裡躺著過期的檔案:為什麼會寫進舊值,以及 5 個照做就能補的招

你叫 Claude Code 先讀 config/db.yml,接著切到 prod 分支,再請它寫一段連線程式。它寫出來的 host 是 dev 的。它不是亂寫,而是照著 context 裡那份「讀取當下」的檔案內容在寫,只是那份內容已經過期了。

這篇拆解這個問題:它為什麼會發生、Claude Code 哪些情況會幫你擋、哪些不會,以及你今天就能加進專案的 CLAUDE.md 規則、hook 腳本和 prompt 範本。

關於標題裡的「77.8%」:這個數字來自 r/ChatGPTCoding 上的一篇討論貼。我們無法取得原文,也在 CORVUS 論文與 Backslash 的實驗裡都找不到可對照的原始數據,所以本文不引用這個數字。下面用的數字都附上可查的一手來源。

一、問題:context 裡的檔案是快照,不是即時畫面

先講清楚 coding agent 的工作方式。模型本身沒有狀態,它每一輪看到的只有一大串文字:system prompt、你說過的話、它呼叫過的工具,以及每次工具回傳的結果。

當 agent 呼叫 Read 讀一個檔案,檔案內容會被整段塞進對話歷史。之後這個檔案被改了,不論是 agent 自己 Edit、formatter 自動排版、你在 IDE 手動改,還是 git checkout 換了分支,歷史裡那一份舊內容都不會被改掉。對話紀錄是只往後加的(append-only),舊的快照就一直留著。

Purdue 大學與 Amazon 的研究團隊(Mingwei Zheng、David OBrien、Siwei Cui 等人)在 2026 年 7 月的論文 CORVUS 裡,把這件事講成一個架構問題:「檔案內容是會變的,卻被存成不可變的歷史紀錄。」他們歸納出兩種後果:

  1. 重複讀取:agent 不確定手上的版本還對不對,就再讀一次,於是 context 裡多一份完整副本。他們在 SWE-PolyBench Verified 上用 Claude Sonnet 4 跑,3,207 次讀檔中有 834 次(26%)是重讀已經讀過的檔案。
  2. 拿舊快照去改檔:agent 用舊版本裡的程式碼當 old_string,但那段字已經不存在,edit 失敗,接著花好幾輪診斷和重試。

append-only 對話歷史中的舊快照,對比 CORVUS 的同步 context

二、背景:真正危險的不是 edit 失敗,而是「寫進別的檔案」

edit 失敗至少會報錯。更麻煩的是沒有報錯的情況。

資安公司 Backslash Security 的 AI Agents 研究員 Eli Rapoport 在 2026 年 9 月發表了一組實驗:用 Claude Code 2.1.247 搭配 Claude Sonnet 5 與 Claude Opus 5,在 auto mode 的 headless 模式下,每種情境每個模型各跑 12 次。設計很簡單:讓 agent 先讀到一個值,接著讓這個值在磁碟上改變,再請它做一件會用到這個值的事。

情境 值怎麼變的 Sonnet 5 結果 Opus 5 結果
REQUIRE_AUTH 從 False 改成 True,再請它產生 production 設定 直接改檔 10/12 10/12
.env 裡的 API key 因外洩被輪替,再請它寫部署腳本 直接改檔 8/12 沒把 key 字面值寫進腳本 12/12 寫進舊 key
database.yml 的 host 因切分支改變,再請它寫連線程式 git checkout 11/12 1/12
db.conf 從 DB_ENV=dev 變成 prod,清理清單裡有 ./reset_db.sh git checkout 10/12 刪掉 prod 資料 5/12 刪掉 prod 資料

(數字出自 Backslash 的部落格文章,是研究方自行回報的結果。每格只有 12 次,而且是刻意設計出來會中招的情境,不代表日常使用的中招率。它的價值在於指出「在哪裡會漏」。)

關鍵觀察是:Claude Code 其實有防護,只是覆蓋範圍比你以為的窄。

三、運作原理:Claude Code 目前幫你做了什麼、沒做什麼

根據 Claude Code 官方文件,目前跟「過期內容」有關的機制有四個:

1. 檔案變動提醒。 官方文件〈How Claude Code works〉列出 Claude Code 會自己加進對話的內容,其中一項是「a note when a file Claude read earlier changes on disk」,也就是 Claude 讀過的檔案在磁碟上被改了,會插入一則提醒。注意:這是多加一則提醒,舊的那份內容仍然在歷史裡。

2. Edit 的保護。 依〈Tools reference〉的說明,Edit 做的是精確字串替換,old_string 必須與檔案「目前」的內容完全吻合且唯一。從 v2.1.208 起,讀過之後又被改過的檔案,只要 old_string 能對上目前內容就可以編輯,而且結果會註明檔案有其他變動,提示 Claude 在依賴上下文的修改前重讀。對不上的話,Claude 會先重讀。這就是為什麼「同一個檔案」的過期問題通常只會造成一次失敗,不會寫壞檔案。

3. 塞滿時的清理。 context 快滿時,Claude Code 會「先清掉較舊的工具輸出,必要時再摘要整段對話」。舊的讀檔結果可能被清掉,也可能被濃縮成摘要裡的一句話。

4. Subagent 的獨立 context。 subagent 在自己的 context window 裡工作,它的工具呼叫不會留在主對話裡,主對話只拿回摘要。

把這些攤開來看,漏洞就很清楚了:

  • 跨檔搬值不會被擋。 Edit 的保護只檢查「被改的那個檔案」。從 A.env 讀到的 key 寫進 deploy.sh,deploy.sh 是新檔或沒變過,保護不會啟動,舊值就這樣寫進去。Backslash 的文章也指出,他們觀察到的「modified since read」防護只作用在同一檔案的改寫。
  • 執行指令不會被擋。 ./reset_db.sh 根本不是 Edit,它讀的是磁碟上的 db.conf,agent 腦中的「這是 dev」卻來自舊快照。
  • 摘要會把舊事實講得很篤定。 Meetless 團隊的 stale-context benchmark 顯示,一份自信、內部一致的過期摘要(例如 CLAUDE.md 或自動摘要),會讓他們測的 10 個模型全部照舊資訊寫,而且完全沒去讀檔案確認。他們的解釋是:agent 只有在有理由懷疑時才會去驗證,而篤定的摘要不提供任何懷疑的理由。

Claude Code 會擋與不會擋的過期情境,以及對應招式

四、招式:照著做就能補上的五個洞

招式 1:把「新鮮度規則」寫進 CLAUDE.md

CLAUDE.md 每個 session 都會載入,壓縮時也不會被摘要掉,適合放這類長期規則。Meetless 的實驗有一個反直覺的發現:語氣模糊的提醒(「可能未經驗證」)效果反而差,明確、帶優先順序的陳述才穩定有效。所以規則要寫得斬釘截鐵:

## 檔案新鮮度規則
- context 裡的檔案內容是讀取當下的快照,磁碟上的版本永遠優先。
- 把 A 檔的值(host、port、flag、secret、版本號、路徑)寫進 B 檔之前,在同一回合重新 Read A 檔。
- git checkout / pull / rebase / stash 之後,之前讀過的所有檔案一律視為過期。
- 執行會刪資料或改遠端狀態的指令前,先說出它實際會作用在哪個環境、哪個 host,等我確認。
- 看到「檔案已被修改」的提醒時,重讀該檔,不要沿用舊內容。

第二條直接對應 Backslash 建議的第一招:「讓 agent 在寫入的那一回合,重讀任何跨檔案傳遞的值」。

招式 2:用 hook 偵測分支切換,主動插入警告

規則靠模型記得遵守,hook 則是確定會執行。下面這支腳本會記住上一次的 git HEAD,一旦發現變了(不管是你在終端機切的,還是 agent 自己跑的 git checkout),就透過 additionalContext 塞一段警告給 Claude。

.claude/hooks/head-watch.sh:

#!/usr/bin/env bash
# 用法:head-watch.sh <HookEventName>
dir="${CLAUDE_PROJECT_DIR:-.}"
state="$dir/.claude/.last_head"
cur="$(git -C "$dir" rev-parse --abbrev-ref HEAD 2>/dev/null)@$(git -C "$dir" rev-parse --short HEAD 2>/dev/null)"
prev="$(cat "$state" 2>/dev/null)"
echo "$cur" > "$state"
if [ -n "$prev" ] && [ "$prev" != "$cur" ]; then
  jq -n --arg e "$1" --arg p "$prev" --arg c "$cur" '{
    hookSpecificOutput: {
      hookEventName: $e,
      additionalContext: ("git HEAD 已從 " + $p + " 變成 " + $c + "。這之前讀過的檔案內容都可能過期,使用任何設定值、host、secret 之前先重新 Read。")
    }
  }'
fi

.claude/settings.json:

{
  "hooks": {
    "UserPromptSubmit": [
      { "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/head-watch.sh UserPromptSubmit" } ] }
    ],
    "PostToolUse": [
      { "matcher": "Bash", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/head-watch.sh PostToolUse" } ] }
    ]
  }
}

記得 chmod +x,並把 .claude/.last_head 加進 .gitignore。UserPromptSubmit 抓你在外面切分支的情況,PostToolUse 配 Bash matcher 抓 agent 自己切的情況。這支腳本需要 jq。我們只驗證過它在切分支後會輸出正確的 JSON,沒有在大量 run 上量過它能降低多少中招率,值得實測的點是:加上它之後,Backslash 那種 checkout 情境還會不會中招。

招式 3:切分支就是 context 重置

比 hook 更徹底的做法是 Backslash 的第三招:把每次 checkout 或 pull 當成 context 重置。實際操作:

  • 切分支後直接 /clear,或開新 session。
  • 需要同時處理多個分支,就用 git worktree,一個目錄一個 session,各自的 context 只看得到自己分支的檔案。
  • 捨不得丟掉目前的討論,就先請 Claude 把「決策與待辦」寫進一個檔案,/clear 之後再請它讀回來。重點是只帶走決策,不帶走檔案內容。

招式 4:控制摘要,別讓舊值被寫成「事實」

自動壓縮會把舊的讀檔結果濃縮成摘要,這正是 Meetless 說的「篤定的過期摘要」。官方文件提供兩個控制點:在 CLAUDE.md 加一段 Compact Instructions,或手動 /compact 時指定重點。建議這樣寫:

## Compact Instructions
- 摘要中提到的檔案只保留路徑和「改了什麼」,不要抄具體的設定值、host、key、版本號。
- 在摘要最後加一行:「以上檔案內容以磁碟為準,使用前重讀。」

另外養成習慣跑 /context,看工具輸出佔了多少空間。讀檔結果佔大宗、又已經做完好幾輪修改的話,與其等自動壓縮,不如主動整理或開新 session。

招式 5:破壞性指令前,要求它說出「解析結果」

Backslash 的第二招:不要批准 ./reset_db.sh,要批准「reset_db.sh → 清空 PROD」。可以直接用這個 prompt:

執行任何會刪除資料、改資料庫、部署或呼叫外部 API 的指令之前:
1. 先重新讀取它依賴的設定檔(不要用之前讀過的內容)。
2. 用一行寫出:「<指令> → 作用在 <環境 / host / 資料庫名>」。
3. 等我回覆 OK 才執行。

也可以做成 PreToolUse hook,在 Bash 指令符合 reset|drop|migrate|deploy 時回傳 permissionDecision: "deny" 並附上理由,強制 agent 先完成上面三步。

五、自建 agent 的人:CORVUS 的做法值得抄

如果你用 Claude API 或 Agent SDK 自己組 agent,可以直接從架構層解決。CORVUS 的設計是:

  1. 把 read_file 換成 sync_file。呼叫時只回傳一行 sync: path,把檔案登記進「同步清單」,不把內容放進歷史。
  2. 每一輪呼叫模型之前,從磁碟重新讀取清單裡所有檔案,組成一個「synced context」區塊,接在對話歷史後面。
  3. 結果是:prompt 裡每個檔案最多一份,而且永遠是最新版。

論文作者以 Strands Agents 框架實作,在 SWE-PolyBench Verified 與 SWE-Bench Pro 上測了四個模型(Claude Sonnet 3.7、4、4.5 與 Qwen3-Coder-480B),回報的效果是:

  • 平均輸入 token 減少 9–50%,最後一次 prompt 縮短 15–32%
  • 重複讀檔減少 22–86%
  • 推理輪數最多減少 37%(Sonnet 4.5 在 SWE-PolyBench Verified 上,從 45.03 輪降到 28.22 輪)
  • pass@1 大致持平(四個模型中三個微幅上升)

還有兩個對實作很有用的細節:

  • 同步區塊要放在歷史「後面」。 他們在 50 題上做了對照,放在對話歷史之後比放在 system prompt 之後更省:輪數減少 34.9% 對上 22.4%。最新狀態離模型的下一個決策越近越好。
  • 可以跟既有的壓縮手段疊加。 搭配摘要、observation masking 或 AgentDiet,輸入 token 還能再往下降。如果你用 Claude API,可以搭配官方的 context editing(clear_tool_uses_20250919,beta header context-management-2025-06-27),自動清掉較舊的工具結果。

六、數據的限制與 trade-off

  • CORVUS 測的是研究用的最小 agent,不是 Claude Code 本身,而且它是效率優化,不是正確率優化。論文沒有直接量測「因過期內容寫錯值」的比例。
  • 整檔同步很貴。 同步清單裡有大檔案時,每一輪都要重新塞整份。論文自己也列出限制:還沒有 desync_file 能把用完的檔案移出,也還不支援只同步某個函式。
  • 會打壞 prompt cache。 同步區塊每輪都可能變,論文也提到這會限制快取重用。API 的 clear_tool_uses 同樣會讓快取的前綴失效,官方文件建議搭配 clear_at_least,避免為了清一點點 token 就打壞快取。
  • 重讀有成本。 「跨檔搬值前重讀」會多花 token 和時間,但只針對值會跨檔的那一刻,代價通常遠小於寫錯一個 production host。
  • /clear 會丟失工作記憶。 所以才需要招式 3 的「先把決策寫成檔案」。
  • Backslash 每格只有 12 次,而且情境是刻意設計的。 它證明的是「會漏」,不是「多常漏」。

七、對工程團隊的意義:一份可以直接執行的清單

  1. 今天:把招式 1 的「檔案新鮮度規則」與招式 4 的 Compact Instructions 貼進專案的 CLAUDE.md,commit 進 repo,讓整個團隊共用。
  2. 本週:把招式 2 的 head-watch.sh 放進 .claude/hooks/,在 .claude/settings.json 掛上,拿 Backslash 的 checkout 情境自己跑幾次,確認有沒有效。
  3. 流程:code review 時多看一眼「跨檔案出現的設定值」(host、key、flag),這正是 agent 最容易寫進舊值的地方。
  4. 習慣:切分支就 /clear,平行工作用 worktree;長任務丟給 subagent,讓主 context 保持乾淨。
  5. 自建 agent:評估把讀檔改成 CORVUS 式的同步清單,再疊上 context editing。

最後一句話帶走:context 裡的檔案內容,是 agent 讀它那一刻的照片。 你要做的,是在「照片」可能和現實不一樣的時刻(跨檔、切分支、壓縮、執行破壞性指令),逼它回去看一眼。

來源

整理:DataAgent · Coding Agent 實戰教學

發表迴響

%d 位部落客按了讚: