DeepSeek Harness · 工具系統

檔案編輯的工程學:先讀後寫

read / edit / write 三件套,沒讀過的檔案不許改。核心原始碼:packages/fs/fs-observation-policy/src/index.ts。

課程目標讀完你能說清三件事:DSH 怎麼用一本觀測帳本記住「這個會話讀過哪些檔案、讀到的是哪個版本」;edit 為什麼在沒讀過時被 FS_NOT_OBSERVED 攔下、在檔案被外部改動後被 FS_STALE_VERSION 攔下;以及這套防線為什麼做成可拔外掛,而 Claude Code 和 Grok 在同一個問題上給出了提示詞和提示語兩種不同濃度的答案。
互動演示 · 先讀後寫闖關
磁碟上的檔案 notes.md外部改動版本 v7
版本號是後端簽發的新鮮度憑證,檔案一變它就變
觀測帳本
observation-policy 外掛的記錄:這個會話見過誰、見到的是哪個版本
未見 = 帳本裡沒條目;present@vN = 讀到過版本 vN;absent = 確認過不存在
edit 意圖判定
工具分發 fs/edit-intent,外掛對著帳本作決定
等待工具呼叫…
待判定
選擇情景後點「播放」,或滾動到此處自動播放情景 A。
演示為教學化模擬:檔案內容與版本號是課程化舉例,判定邏輯對應 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「錯誤分類體系」)。

核心視覺 · 判定流
read 成功 emit fs/observed present@vN 觀測帳本 WeakMap: 會話 → 目標 → 狀態 edit 呼叫到達 分發 fs/edit-intent 單槽瀑布 editIntent(target) 查帳本,不查磁碟 未見 FS_NOT_OBSERVED absent FS_NOT_FOUND present@vN 帶版本守衛放行 replaceIfVersion(vN) 後端臨界區 驗版本 → 匹配 → 原子替換 不符→STALE
教學化結構圖:判定分支對應 fs-observation-policy 的 editIntent,臨界區語義出自 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 就是本章標題的全部內容:

packages/fs/fs-observation-policy/src/index.ts第 78 至 88 行
  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 }
  }
原始碼快照說明:依據本地倉庫 deepseek-harness-master,核對檔案 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 原文:

claude-code-sourcemap-main/study/chapters/14-all-prompts.md · 第 1124 行(引 restored-src/src/tools/FileEditTool/prompt.ts 第 14 至 28 行)
「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 工具依賴,說明先讀後寫曾是硬開關、後來鬆了綁。對過期讀取的處理更能看出取向差異:

grok-build-main/crates/codegen/xai-grok-tools/src/implementations/grok_build/search_replace/mod.rs · 第 111 至 113 行(include_user_edit_hint 欄位註釋)
「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 一課拆了它的語法與校驗。

課堂練習
01

推演一次三連擊

會話剛開始,模型依次做三件事:write 一個不存在的 draft.md、edit 這個 draft.md、然後你在編輯器裡手動改了 draft.md 一個字,模型又發起第二次 edit。請寫出三次呼叫各自的守衛(createIfAbsent / replaceIfVersion / 版本守衛)和結局,標出帳本在每一步之後的狀態。特別想一想第二步:write 成功會 emit fs/observed 嗎?如果不記這筆帳,第二步的 edit 會發生什麼?(提示:writeIntent 的決策表裡,present 走 replaceIfVersion,而 edit 沒讀過直接 FS_NOT_OBSERVED。)

Takeaway:先讀後寫在 DSH 裡是一本觀測帳本加一張版本憑證:沒讀過的檔案 edit 直接 FS_NOT_OBSERVED,讀過但被外部改動的檔案 FS_STALE_VERSION,判定只看帳本不看運氣。防線做成可拔外掛,工具零權限程式碼。同一條規則,CC 寫進工具自身的執行時檢查,Grok 退成報錯裡的一句勸告,濃度高下立見。