檔案編輯的工程學:先讀後寫
read / edit / write 三件套,沒讀過的檔案不許改。核心原始碼:packages/fs/fs-observation-policy/src/index.ts。
packages/fs/fs-observation-policy/src/index.ts 的 writeIntent / editIntent 與 docs/subsystems/filesystem.zh.md 的錯誤分類。玩的時候盯住帳本:判定從來不看檔案本身,只看這個會話親眼見過什麼。Agent 改檔案的三大翻車現場:改錯位置、覆蓋沒讀過的檔案、拿著過期內容做編輯。DSH 的對策是三件套加一本帳。三件套是面向模型的 read、edit、write 工具(docs/tool-catalog.zh.md):read 視窗化讀取帶行號的文字,edit 做字面量替換,write 整檔案建立或覆蓋。帳是 fs-observation-policy 外掛肚子裡的一個弱引用對映:以會話為鍵,記下每個檔案目標的觀測狀態。
帳本只有三種狀態。未見:帳本裡壓根沒這個檔案的條目。present@vN:讀到過,而且讀到的是版本 vN,版本號是檔案系統後端簽發的不透明新鮮度憑證。absent:確認過這個路徑不存在,比如 read 撲了個空。每次 read、write、edit 成功後,工具會發一個 fs/observed 事件,外掛同步記帳。
判定發生在動手之前。工具要寫或要改時,會分發 fs/write-intent 或 fs/edit-intent 事件,這是單槽瀑布:第一個返回決定的監聽器獨佔決策權,按部署約定就是這個策略外掛。它對著帳本給出守衛條件,真正的檢查由後端在一個原子臨界區裡完成:先驗版本再匹配再替換,中途誰也插不進來。
write 永遠有路走沒讀過就 write,守衛是 createIfAbsent:檔案不存在就建立,存在就拒絕(FS_NOT_OBSERVED)。讀過再 write,守衛是 replaceIfVersion:版本對上才替換。新建檔案不用先讀,覆蓋別人的檔案不行。
edit 一步都不讓沒讀過直接 FS_NOT_OBSERVED,帳本記著 absent 就 FS_NOT_FOUND,讀過則帶版本守衛上路。版本檢查排在字面量匹配之前,所以拿過期內容編輯報的是 FS_STALE_VERSION,不會退化成一個誤導性的匹配失敗。
錯誤帶結構化身份所有失敗都帶穩定的 FsError code,工具註冊表在錯誤結果上保留 { name, code }。重試邏輯和 UI 按 code 分支就行,不用解析錯誤文案(docs/subsystems/filesystem.zh.md「錯誤分類體系」)。
整個策略外掛不到 140 行,核心就是兩個查帳函式。write 的判定是一個三行的選擇:查帳發現讀到過(present),就返回帶版本號的 replaceIfVersion 守衛,版本對上才許替換;帳本裡沒條目或者確認過不存在,就返回 createIfAbsent,檔案不存在才許建立。函式頭上的註釋把這張決策表用兩個箭頭寫完了。這就是「write 永遠有路走」的實現:新建檔案不用先讀,覆蓋別人的檔案不行。
出處:packages/fs/fs-observation-policy/src/index.ts 第 61 至 71 行的 writeIntent,核對日期 2026-08-13。
edit 的判定更嚴,沒讀過連守衛都拿不到,直接拋錯。這個函式值得整段看,兩個 throw 就是本章標題的全部內容:
editIntent(target: FsTarget, actor: object | undefined): { version: FsVersion } {
const owner = this.owner(actor)
const prior = owner ? this.get(owner, target.targetKey) : undefined
if (!owner || prior === undefined) {
throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED')
}
if (prior.kind === 'absent') {
throw new FsError(`cannot edit "${target.displayPath}": not found`, 'FS_NOT_FOUND')
}
return { version: prior.version }
}
packages/fs/fs-observation-policy/src/index.ts,核對日期 2026-08-13。程式碼塊保留原始碼原文。返回的 { version: prior.version } 就是那張新鮮度憑證。後端 editText 拿到它,先對當前版本,不符報 FS_STALE_VERSION;對上了才做字面量匹配,old_string 必須恰好命中一次,命中多處報 FS_AMBIGUOUS_EDIT,一處都沒有報 FS_EDIT_NOT_FOUND,除非模型顯式傳了 replace_all。匹配、行尾處理、陳舊檢查、原子替換全在一個臨界區內完成(docs/subsystems/filesystem.zh.md 第 151 行)。
還有三個值得記的細節。其一,這套防線是可拔的:卸掉外掛,write 和 edit 退回無條件的裸提供方行為,工具 schema 一個字不變,因為工具只分發事件、從不直接調策略。其二,read 的授權只看新鮮度,不分整讀還是視窗讀:只要檔案沒變,讀 10 行也能授權後續整個檔案的 edit。其三,read_image 是條件註冊的典型:部署沒有 ctx.attachments 能力就根本不註冊,註冊了但當前路由的模型不吃圖,執行時也拒絕(docs/tool-catalog.zh.md 第 718 行)。順帶一提演進史:edit 結果裡那張帶上下文的 diff 卡,最早是靠結果時刻重算 hunk 實現的,方案記錄在已歸檔的 Agent Note .agents/notes/archived/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md,後來後端直接返回 before/after 全文,工具算 hunk 存進 meta,回放免重算,這條通道在 工具輸出契約 一課剛講過。
Claude Code 也強制先讀後寫,規則直接寫進了工具說明書。FileEditTool 的 prompt 原文:
「You must use your ${FILE_READ_TOOL_NAME} tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.」
「This tool will error」說明 CC 的執行時確有強制檢查,不只是提示詞客氣一下;old_string 不唯一會失敗、要麼加上下文要麼 replace_all 這條也和 DSH 完全同構。差別在防線的掛載位置:CC 的先讀檢查長在 FileEditTool 自己身上,DSH 把它抽成獨立外掛,read、edit、write、str_replace_editor 四個工具共享同一本帳,工具本體一行權限程式碼都沒有。至於檔案被外部改動後 CC 如何檢測過期讀取,已核對的書稿材料未展示實現細節,這條基於已公開證據保留。
Grok Build 的 search_replace 工具留下了同一場鬥爭的痕跡。配置裡有個 skip_read_before_edit 欄位,註釋標著已廢棄的執行時空操作,只在配置期把關 Read 工具依賴,說明先讀後寫曾是硬開關、後來鬆了綁。對過期讀取的處理更能看出取向差異:
「When true, append a hint that the user may have changed the file to NoMatchesFound error messages. This nudges the model to re-read instead of blindly retrying with the same stale content.」
翻譯一下:檔案被人改了導致匹配失敗時,Grok 在報錯文案裡加一句提示,勸模型重新讀一遍再試。這是提示語濃度的防線,靠模型自覺。DSH 是版本憑證濃度:版本對不上就 FS_STALE_VERSION,物理上不給寫。Grok 也有自己的強項,編碼坑那關它準備了 unicode_normalized_fallback,智慧引號、長橫線這類肉眼難辨的字元匹配失敗時可以做歸一化重試(同檔案第 103 至 110 行),DSH 的 edit 目前只按行尾規範化後精確匹配。hunk 級的變更追蹤 Grok 單獨抽了 xai-hunk-tracker crate 來做,Grok 工具系統的全景可以看站內 實現族、註冊表與動態 MCP。Codex 把力氣花在了 diff 格式本身,讓模型好寫、機器好驗,站內 Codex apply-patch 一課拆了它的語法與校驗。
推演一次三連擊
會話剛開始,模型依次做三件事:write 一個不存在的 draft.md、edit 這個 draft.md、然後你在編輯器裡手動改了 draft.md 一個字,模型又發起第二次 edit。請寫出三次呼叫各自的守衛(createIfAbsent / replaceIfVersion / 版本守衛)和結局,標出帳本在每一步之後的狀態。特別想一想第二步:write 成功會 emit fs/observed 嗎?如果不記這筆帳,第二步的 edit 會發生什麼?(提示:writeIntent 的決策表裡,present 走 replaceIfVersion,而 edit 沒讀過直接 FS_NOT_OBSERVED。)