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-intentfs/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

课堂练习
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 退成报错里的一句劝告,浓度高下立见。