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 退成報錯裏的一句勸告,濃度高下立見。