06 · 文件编辑与 Patch:失败时留下什么
选择 patch 或最小替换协议,处理陈旧文件、部分失败和 reviewer 可见性。
本章任务
要回答的问题
文件已变化或 Patch 只成功一半时,怎样保证编辑可检测、可回滚、可审查?
读完你能
- 为陈旧文件、模糊匹配和部分失败定义不变量
- 比较 Patch DSL、最小替换和整文件重写的代价
- 设计让 Agent 和 Reviewer 都能解释的失败反馈
- 适合现在读
- 正在实现 Edit、Patch、SEARCH/REPLACE 或代码修改工具的工程师
- 先修知识
- 理解文件版本、Diff 与基本并发冲突
- 实践产物
- 一组 Patch 协议契约测试与回滚用例
- 证据边界
- 协议能减少歧义,不能消除模型选错位置或弱测试隐藏的逻辑错误
一次编辑失败时,什么必须保持不变
Section titled “一次编辑失败时,什么必须保持不变”场景:Agent 读取 config.ts 后准备替换一个函数,用户在同一时间修改了文件;旧片段在文件里还出现两次,Patch 命中了错误位置并只应用了一半。再次重试会把已经成功的部分重复修改。
通过标准:编辑带文件版本或内容哈希前置条件;匹配不唯一时拒绝猜测;一组修改要么原子提交,要么留下可回滚的部分失败记录;最终 Diff 能让 Reviewer 看见实际改变,而不是只相信工具返回“成功”。
先定义编辑的失败边界
Section titled “先定义编辑的失败边界”四家在 4 件事(表达、校验、落盘、反馈)上的落地:
| 维度 | Codex | Claude Code | OpenClaw | Hermes |
|---|---|---|---|---|
| 表达 DSL | V4A 内嵌 patch DSL(5 类 marker:Begin、End、Update、Add、Delete、Move) | str_replace:old_string 加 new_string 加 replace_all 三参数 | 通用 fs.read、fs.write、fs.edit 工具集 | V4A 内嵌 patch DSL(Python `tools/patch_parser.py` 重新实现) |
| 原子性 | 解析或上下文匹配失败时整段 reject;写盘不构成跨文件事务 | 单条 edit,多条改动等于多次工具调用 | 单次写一个文件,多文件多次调用 | V4A parser 可整段拒绝;写盘失败仍需恢复 |
| 校验时机 | parser 阶段(`apply-patch/src/parser.rs`)加文件状态再校验 | `FILE_UNEXPECTEDLY_MODIFIED_ERROR`:写前比对 mtime | `tool-fs-policy.workspaceOnly` 中间件 | V4A parser 校验;写盘阶段另行处理失败 |
| 失败恢复 | parser error 直接回模型,模型自己重写 patch | 权限拒绝走 deny tool_result,模型读到再改 | hook 拦截,标准 tool_result error | parse error 加退回模型重写 |
| 附加副作用 | rollout/* 写盘加 execpolicy 审加 sandbox 隔离 | LSP diagnostics 失效加 fileHistory 追踪加 VS Code SDK 通知 | tool 事件流加 session lane | memory commit 加 trajectory event |
只比较会改变原子性或审阅的实现
Section titled “只比较会改变原子性或审阅的实现”Codex · 设计专用 patch DSL V4A,模型 inline 输出整段 patch
Section titled “Codex · 设计专用 patch DSL V4A,模型 inline 输出整段 patch”Codex 在文件编辑这件事上的出发点是:模型擅长生成 unified diff(GitHub、GitLab 上的 PR diff 就是这种格式,模型训练时见过几百万个),但 git 标准的 unified diff 有几个跟 agent 场景不太搭的特性。它需要精确的行号加行数(context 前 5 行、后 5 行),模型生成时容易算错。它没法在一个 diff 里表达「移动文件」这种语义(要拆成 Delete 加 Add)。
它假设 reviewer 跟 patch 作者在同一个 git 版本(要 fuzz match 处理偏移)。所以 Codex 决定自己造一套专门为 agent 优化的 patch DSL,叫 V4A(V 是 version,A 可能是 apply 或 agent),保留 unified diff 的可读性但去掉那些 agent 容易写错的地方,同时加上文件级语义(添加、删除、移动)。
V4A 不走 function call JSON 参数,而是让模型在 assistant message 里直接 inline 输出整段 patch(用特殊的 marker 标记 patch 开始结束),Codex 的 message parser 看到 marker 就截取整段交给 apply-patch crate 处理。
这种「让模型把 patch 写在对话里而不是参数里」的设计,把容量边界从 function-call 参数移到模型输出。两者的上限都取决于 provider 和模型,不能用固定 token 数概括。Rust 端的 apply-patch crate 用 Lark 语法(一种类似 EBNF 的 parser generator 语言)定义 V4A 的完整 grammar:
Codex codex/codex-rs/apply-patch/src/parser.rs:1-22 V4A 格式的 Lark 语法定义
//! The official Lark grammar for the apply-patch format is://!//! start: begin_patch hunk+ end_patch//! begin_patch: "*** Begin Patch" LF//! end_patch: "*** End Patch" LF?//!//! hunk: add_hunk | delete_hunk | update_hunk//! add_hunk: "*** Add File: " filename LF add_line+//! delete_hunk: "*** Delete File: " filename LF//! update_hunk: "*** Update File: " filename LF change_move? change?//! filename: /(.+)///! add_line: "+" /(.+)/ LF -> line//!//! change_move: "*** Move to: " filename LF//! change: (change_context | change_line)+ eof_line?//! change_context: ("@@" | "@@ " /(.+)/) LF//! change_line: ("+" | "-" | " ") /(.+)/ LF//! eof_line: "*** End of File" LF读这段 grammar 可以看出 V4A 的几个关键设计。整段 patch 用 *** Begin Patch 和 *** End Patch 包裹,这样 parser 可以从 assistant message 的任意位置截取整段(即使模型在 patch 前后写了解释文字也不影响解析)。
hunk 分三类:add_hunk(创建新文件,每行加号开头)、delete_hunk(删除整个文件,只需要一行 *** Delete File:)、update_hunk(修改现有文件,可选 change_move 表示重命名加一个 change block 包含改动内容)。
最关键的是 change block 的格式跟 unified diff 几乎一样(+ 加号开头表示新增、- 减号开头表示删除、空格开头表示 context),但去掉了行号和行数(让 parser 自己根据 context 行去定位,不要模型算行号)。change_context 用 @@ 函数名 @@ 这种锚点提示帮助 parser 在文件里定位(如果 context 行太短可能误匹配多处)。
eof_line 是 *** End of File 标志,让 parser 知道改动延伸到文件末尾。
V4A 的第一个取舍是把 patch 放进 assistant 输出,而不是 function arguments。两条路径都有 provider 与模型上限,但边界不同;是否能容纳更大的 diff,要按当前 API 限制和实际输出测。
第二是多文件一起提交:一段 patch 可以同时包含 Update、Add、Delete、Move 任意组合。解析阶段如果任何一个 hunk 失败,整段会被拒绝且不写盘;真正写盘时仍可能遇到磁盘、权限或锁冲突,不能把它说成跨文件事务。第三是 patch 文本本身就是可读的 diff:rollout 落盘时直接存原 patch 文本,replay 时一字不改重放,审计 reviewer 可以像看 PR diff 一样审 agent 的所有改动。
代价当然是模型必须学会这套 DSL。Codex 在 system prompt 里专门教 V4A 格式(给几个例子让模型 in-context learning),但即使如此 gpt-4.1 偶尔还是会写错格式(少打个空格、少打个 @@ 锚点),所以 Codex 还在 parser 里加了 ParseMode::Lenient 容错模式(gpt-4.1 之外的模型用严格模式),常见的格式错误(多余空格、缺失锚点)会被 parser 主动修复。
Claude Code · 改动拆到最小单元 str_replace
Section titled “Claude Code · 改动拆到最小单元 str_replace”Claude Code 把编辑拆到更小的 tool call,适合 IDE 逐步展示与审阅。这里用“一次改 20 个文件”作为合成场景,不是产品上限;真正差异是集中 patch 与逐点 edit 的审阅节奏。FileEditTool 每次定位一个字符串,参数很少:
Claude Code claude-code/src/tools/FileEditTool/types.ts:1-30 FileEdit 三参数:old_string / new_string / replace_all
inputSchema: z.object({ file_path: z.string(), old_string: z.string().describe('The text to replace'), new_string: z .string() .describe( 'The text to replace it with (must be different from old_string)', ), replace_all: z.boolean().default(false).describe( 'Replace all occurrences of old_string (default false)', ),})这种「最少三个参数」的设计有几个细致的工程考虑。old_string 必须在文件中唯一匹配:如果文件中出现多次相同字符串而 replace_all 是 false,工具会拒绝执行让模型补充更多 context 让 old_string 唯一。这迫使模型在 Edit 前先 Read 文件、看清楚上下文(多个相同字符串通常意味着模型对文件结构理解不够)。
如果用户想批量替换(比如重命名一个变量名 across the whole file),可以传 replace_all=true 一次替换所有出现。new_string 必须跟 old_string 不同(用 zod schema 在 describe 里明确说),否则操作没有意义。
写盘前还有一个隐藏的关键校验:比对文件的 mtime(modification time),如果发现读取文件后 mtime 变了说明其他进程(用户在 IDE 里手动编辑、git pull、其他 agent)刚改过这个文件,FileEditTool 会拒绝写入并抛出 FILE_UNEXPECTEDLY_MODIFIED_ERROR,迫使模型重新 Read 文件再 Edit。这个机制防止「模型基于过期内容做改动覆盖别人刚写的东西」的灾难性 race condition。
每次 FileEdit 调用还要穿过 Claude Code 整套副作用网络:LSP diagnostics 失效(clearDeliveredDiagnosticsForFile() 通知 LSP 重新分析这个文件的语法、类型、lint)。文件历史追踪(fileHistoryTrackEdit() 把每次改动写入 session 的历史记录里,用户可以用 /diff 命令查看所有 agent 改过什么)。
VS Code SDK 通知(notifyVscodeFileUpdated() 让 VS Code 编辑器立刻刷新打开的 tab,用户看到最新内容)。权限校验(checkWritePermissionForTool() 走整套 permission mode 体系,acceptEdits、plan、bypassPermissions、default 各模式下行为不同)。
这种每次 edit 都触发完整副作用网络让 IDE 体验很顺滑:agent 改了文件,VS Code 立刻刷新、LSP 立刻重新分析、错误提示立刻更新。代价是每次 edit 都有这些 overhead。
代价当然是 token 烧得快:一次只能改一处,模型要做大改动得连发好几个 Edit 调用,每次 Edit 的工具调用上下文都重复传一遍(file_path、old_string、new_string)。Claude Code 2.1.88 这个版本里没看到 MultiEdit 工具,历史版本有过的批量 edit 工具被合并掉了。团队应该是判断 MultiEdit 容易让模型一次塞太多改动用户审不过来、reviewer 体验变差,宁愿要 Edit 多调几次。
OpenClaw · 不为 coding 造 DSL,通用 fs 工具加 workspaceOnly 策略
Section titled “OpenClaw · 不为 coding 造 DSL,通用 fs 工具加 workspaceOnly 策略”OpenClaw 在文件编辑这件事上的判断是:它本身是 agent 控制面(不是 coding 工具),coding 只是众多 workload 之一(用户可能用 OpenClaw 写 Slack bot、做客服 agent、跑数据分析 agent,这些场景根本不需要编辑文件)。为单一场景造专门的 patch DSL 是错的,应该让 fs 操作走通用工具栈,约束全靠 policy 中间件。
具体实现是在 tool-catalog.ts 的 fs 类目里挂 fs.read、fs.write、fs.list 这些常规读写工具(接口跟 Node.js fs 模块完全一致,模型熟悉),不定义任何专门的编辑协议(没有 V4A、没有 str_replace)。约束全靠 tool-fs-policy.ts 的一个布尔字段:
OpenClaw openclaw/src/agents/tool-fs-policy.ts:1-32 tool-fs-policy 只有一个开关 workspaceOnly
export type ToolFsPolicy = { workspaceOnly: boolean;};
export function createToolFsPolicy(params: { workspaceOnly?: boolean }): ToolFsPolicy { return { workspaceOnly: params.workspaceOnly === true, };}
export function resolveEffectiveToolFsWorkspaceOnly(params: { cfg?: OpenClawConfig; agentId?: string;}): boolean { return resolveToolFsConfig(params).workspaceOnly === true;}workspaceOnly: true 时,任何越出 session 工作区目录的路径都会被拒。这个策略由 plugin pipeline 在 before_tool_call 钩子里拦截执行(详见工具系统):具体逻辑是把 args.path 跟 session.workspaceDir 做 resolve 后比对,如果不是 workspaceDir 的子路径就直接拒绝调用、把错误塞回 tool_result 让模型自己理解。
这个一个布尔字段的设计极简但实用,企业部署时管理员只要把 workspaceOnly 设为 true 就锁死了 agent 的文件操作范围,不需要写复杂的策略文件。
设计取舍上有几个值得讨论的点。OpenClaw 不绑定 coding 场景所以不为编辑造 DSL 是合理的,但代价是:没有原子多文件提交语义(模型要改多个文件得发多次 fs.write 调用,如果第 3 次失败前 2 次已经写盘了,需要 plugin 用户自己写补偿逻辑兜底)。没有 str_replace 那种「写前比对 mtime」的 race condition 防护(fs.write 永远直接覆盖,模型如果基于过期内容写文件可能覆盖别人刚改的内容)。
没有 LSP 联动、文件历史、IDE 通知这些副作用网络(OpenClaw 不假设 agent 跑在 IDE 里,所以也不内建这些)。OpenClaw 用户如果要做 coding agent,要么自己写 V4A 风格的 patch 工具挂到 fs 类目下,要么用 OpenClaw 的 plugin 机制叠加 lint、format、atomic write 等 hook 来补足这些能力。
Hermes · 复用 Codex V4A 做跨生态兼容,Python 端重写一遍
Section titled “Hermes · 复用 Codex V4A 做跨生态兼容,Python 端重写一遍”Hermes 没有重新设计 patch DSL,而是复用了 V4A 的格式。这样做的可确认收益是解析器和示例可以复用;不能仅凭公开样例断言某个模型在训练阶段见过它。
Hermes 在 tools/patch_parser.py 里用 Python 重新实现一遍 V4A 解析(Hermes 是 Python 生态,不能直接调用 Codex 的 Rust crate),并在 docstring 里坦白写明这是个跨生态兼容的决定:
Hermes hermes-agent/tools/patch_parser.py:1-29 patch_parser.py 明确说复用 V4A 跨生态格式
"""V4A Patch Format Parser
Parses the V4A patch format used by codex, cline, and other coding agents.
V4A Format: *** Begin Patch *** Update File: path/to/file.py @@ optional context hint @@ context line (space prefix) -removed line (minus prefix) +added line (plus prefix) *** Add File: path/to/new.py +new file content +line 2 *** Delete File: path/to/old.py *** Move File: old/path.py -> new/path.py *** End Patch"""这段 docstring 直接列出 V4A 格式的所有 marker(Begin Patch、End Patch、Update File、Add File、Delete File、Move File 等等),跟 Codex 完全一致。Hermes 选 V4A 而不是自己造一套有两个具体理由。
第一是格式已有公开实现和样例,Hermes 可以直接复用 parser 与提示示例;具体模型能否稳定输出,仍要按模型评测。第二是跨工具可移植:兼容 V4A 的工具可以共享 patch 形状,减少转换代码。
跟 Codex 的实现有一个关键差异:Hermes 的 patch tool 还是一个普通的 function call(patch 字符串作为参数 args 传入),不是 inline DSL。模型把整段 patch 当作字符串塞进 tool 参数。
这种选择简化了 Python 端协议处理,但 patch 大小会受 function-call 参数和模型输出共同约束。具体上限随 provider、模型和 SDK 变化,应由集成测试确认。
跟 OpenClaw 也有一个关键差异:Hermes 接收一整段 V4A patch,而 OpenClaw 的通用 fs 工具通常按文件调用。V4A parser 可以在解析或上下文匹配失败时整段拒绝;一旦进入多文件写盘,权限、锁和磁盘错误仍可能留下部分结果,除非宿主提供隔离 worktree、临时副本或补偿机制。
先保护原子性和可审阅性
Section titled “先保护原子性和可审阅性”四个实现能提炼出四个检查点,但每家覆盖程度不同。
第一点,编辑要携带当前文件证据:Codex 和 Hermes 的 V4A update hunk 用 context line 定位,Claude Code 用唯一 old_string 和 mtime 检查。OpenClaw 的 workspaceOnly 只约束路径,源码没有强制“本轮先 Read”。因此“写前读”应由编辑协议或宿主显式保证,不能从四个样本推成共同默认。
其次,区分内容新鲜度和路径策略:context line 与 mtime 可以发现部分 stale write;workspaceOnly 只检查写入边界,不能证明文件自读取后未变化。需要防并发覆盖时,宿主还要加入版本、哈希或锁。
第三点,解析拒绝可以是原子的,写盘未必:V4A 能在解析或上下文匹配失败时拒绝所有 hunk,不产生写入。进入写盘后,权限、锁或磁盘空间仍可能让跨文件操作只完成一部分。单文件 Edit 缩小了影响范围,但没有提供跨文件事务。
最后,给 reviewer 一个稳定的变更视图:Codex 的 patch 本身可读,Claude Code 的 fileHistory 提供 diff。其他工具若只返回 success/fail,宿主应在编辑后生成 diff 或等价的 before/after 记录;不要假设所有工具结果天然带 diff。
把失败恢复成本放在选择旁边
Section titled “把失败恢复成本放在选择旁边”「每轮改动应该多大、多分散」这个问题的取舍本质上是一致性对比可审性的权衡。从场景反推选型,四种取舍各对应一类典型部署。
大重构需要在一个请求里表达多文件改动:可以评估 Codex 的 V4A inline DSL。parser 能在语法或 context 校验失败时拒绝整段,reviewer 也能看到一份集中 patch;一旦进入磁盘应用,它不是跨文件事务,写入失败可能留下部分 delta。文件数量只是示例,路由应由改动结构、冲突风险和实测 payload 决定。
agent 是 IDE 插件、用户希望看每一步改动(IDE 实时刷新文件、reviewer 跟着 agent 一步一步审):Claude Code 的 str_replace 更贴近这个交互。每个 edit 记录一个小 diff,LSP 和 fileHistory 可以分别接住后续更新。代价是大改动会重复传 file_path 与 context;是否值得拆分,要用目标模型和编辑器的实际轨迹评估。
agent 是通用控制面、coding 只是众多用例之一:OpenClaw 的「不为 coding 造 DSL」是正确克制。给所有用户硬塞 V4A 抽象层是浪费(写 Slack bot 的用户不需要 patch DSL)。但 coding 场景下要自己用 plugin 补齐 atomic、mtime check、副作用网络。
想复用已有 patch 形状:Hermes 的方案值得看。它复用 V4A parser,把整段 patch 放进 function-call 参数以简化协议处理。兼容程度和容量边界都要逐个工具验证;V4A 不是正式标准。
编辑策略取决于失败后能否回滚
Section titled “编辑策略取决于失败后能否回滚”| 编辑约束 | 借鉴路线 | 代价或边界 |
|---|---|---|
| 一次请求需要表达多个文件,并在写前整段校验 | Codex V4A patch grammar | 写盘仍不是跨文件事务 |
| 最重要的是小 diff 和并发修改检测 | Claude Code str_replace 加 mtime 检查 | 大型重构会消耗更多轮次 |
| 通用 Agent 只需要 workspace 内的文件操作 | OpenClaw workspaceOnly policy | 没有多文件原子保证 |
| 需要兼容已有 V4A 工具链 | Hermes patch parser | function-call 参数限制超大 patch |
从一个可拒绝的最小编辑器开始
Section titled “从一个可拒绝的最小编辑器开始”自己写文件编辑工具时,先保证定位无歧义、写入可回滚、结果可审查,再考虑多文件 patch 和 IDE 副作用。
复刻方案
最小可行
- 从 str_replace 起步只接受三个参数(old_string、new_string、file_path)。这是较小的起点,不用先解析 DSL;先把单文件单点编辑和失败报告跑通,再按场景增加复杂语义
- 强制 old_string 在文件中唯一匹配(参考 Claude Code)。出现多个匹配就报错让模型加更多上下文。这个约束让模型必须先 Read 拿到精确上下文才能 Edit,避免「随手改」错位
- 写前校验文件 mtime 防 race(参考 Claude Code 的 FILE_UNEXPECTEDLY_MODIFIED_ERROR)。用户在 IDE 里同时改了文件、另一个 agent 在改、git 切了分支等情况都会触发。mtime 不一致就拒绝写让模型重新 Read
- 编辑完返回 diff(不只是 success、fail),让模型和用户都能 verify。模型能从 diff 确认改对了,用户能看到 diff 决定是否回滚。diff 是可审计的核心
进阶
- 当改动需要集中表达多个关联 hunk 时,可评估 V4A(参考 Codex、Hermes)。解析与 context 校验可以整段拒绝;写盘阶段要另设隔离 worktree、补偿或失败报告,不能宣传为跨文件原子提交
- patch 解析用 Lark 语法或正则三段(Begin、hunks、End),失败整段 reject。Lark 比正则更可读、更易扩展。任何一行 marker 错位都让整个 patch 拒绝(不要尝试部分应用,会留下不一致状态)
- 加 fs policy 中间件(参考 OpenClaw 的 workspaceOnly)。限制路径不能出 workspace(防止模型一时糊涂改了 ~/.bashrc 或 /etc/...)。这是文件系统层的安全底线
- 改完触发 LSP 重分析、文件历史落盘、编辑器通知(参考 Claude Code 的副作用网)。编辑成功不是终点,要让 IDE 看到变化、让 git 历史记录到、让其他 agent 看到通知。副作用网做好了 IDE 体验才丝滑
一开始别做
- 别让模型直接发 bash 的 sed、awk 改文件。没有 diff 反馈(用户看不到改了什么),错了找不到(无法回滚到改前状态),且 sed 语法模型经常写错(容易 -i 跳过个别 case)。用专门的 Edit 工具
- 别把行号当唯一定位协议。文件变化或上下文裁剪后,行号可能失效;用 old_string、上下文锚点或内容 hash 复核目标位置
- 别在没测容量和截断行为前,把大 patch 塞进 function-call 参数。上限随 provider 与模型变化;无论 inline 还是参数传输,都要让不完整 patch 在写盘前解析失败
- 别忘了 mtime、hash 校验。两个 agent 同时改一个文件、用户在 IDE 里改了又被 agent 覆盖,都会出现「改没了、互相覆盖」的事故。mtime 是最便宜的防御
两条编辑路线怎样分叉
Section titled “两条编辑路线怎样分叉”把这两条路线放一起看就知道:V4A 让模型一次说完所有改动,Lark parser 当门神。str_replace 让模型每次只改最小单元,唯一匹配加 mtime 当门神。两边都不让模型盲改,但路径完全不同。
核对原子性与冲突处理
Section titled “核对原子性与冲突处理”本章带走什么与下一步实验
Section titled “本章带走什么与下一步实验”安全编辑依赖前置条件、唯一定位、原子性和可审计结果。Patch DSL 的价值不是语法漂亮,而是让冲突、部分失败和回滚成为显式状态。
下一步实验:用同一编辑任务触发四种失败:文件已变化、匹配两处、第二个 hunk 失败、写入后进程退出。验证原文件是否可恢复、成功部分是否重复、最终 Diff 是否完整,以及错误反馈能否让模型选择重新读取而不是盲重试。
附录:练习与复盘
Section titled “附录:练习与复盘”按需展开练习和十道复盘题
- 实现一个 str_replace 工具(简单):参数
file_path、old_string、new_string。强制old_string在文件里唯一匹配,否则报错。返回 diff。 - V4A parser(中等):用你熟悉的语言实现 V4A patch 的最小子集(只支持
*** Update File加 add、delete、context 行)。验证:你的 parser 能正确处理apply-patch/tests/suite/scenarios.rs里至少一条 case。 - mtime 校验(中等):在 1 的基础上加 mtime 校验。模拟两个进程同时改一个文件,验证第二次能拿到
FILE_UNEXPECTEDLY_MODIFIED_ERROR。 - 跨系统兼容(高难):把你写的 V4A parser 拿去解析 Codex 的 test patch(
apply-patch/tests/)和 Hermes 的 patch_parser 测试输入。哪些 case 行为不一致?
Q1 · 概念:V4A 这种「内嵌 DSL」和普通 function call 形式的工具,本质区别是什么?
V4A 是 Codex 设计的 patch 描述语言,模型把整段 patch 输出在 assistant 文本里(不是 tool_use 参数),harness 用 Lark 文法解析。本质区别有三层:
1. 协议位置不同:Function call 的参数走 tool_use.input 字段,被 JSON 包装。V4A 走 assistant.content 文本,跟模型的自然语言输出共存。前者经过 Anthropic、OpenAI 的协议序列化层,后者跳过。
2. 容量边界不同:tool_use.input 和 assistant output 都有上限,且随 provider、模型和 SDK 变化。V4A 只是把 patch 放到另一条输出通道,不等于可以任意长。
3. 错误恢复路径不同:Function call 出错要在协议层处理(错误参数、schema mismatch)。V4A 出错就是文本格式错误,可以让模型用「我再发一次」自然语言回滚,无需重启 tool_use。
为何两家选择不同?V4A 更适合把多个 hunk 放进一次可审阅的 patch;str_replace 更适合逐点确认。源码没有给出“每轮 20 个文件”或“最多 100 行”的统一产品边界。
实操推论:单点、可唯一定位的改动优先用 str_replace;跨文件或需要一次审阅完整 diff 时再考虑 V4A。不要用固定 token 数替代结构判断。
源码定位:codex/codex-rs/apply-patch/src/parser.rs:1-22 是 Lark 文法,hermes-agent/tools/patch_parser.py:1-29 是 Python 复刻。
追问:「V4A 是不是事实标准?」它在这些实现里重复出现,但没有统一标准组织或兼容性规范。更准确的说法是“被多种 coding agent 采用的 patch 形状”。
Q2 · 架构:Claude Code 的 str_replace 强制 old_string 唯一匹配,为什么?不能让用户改 default 关掉吗?
强制唯一匹配是为了杜绝歧义改动。如果 old_string 在文件里出现 3 次,模型说「把这个改成那个」,harness 无法知道是哪 3 次都改还是只改第 1 次,更不知道是该改第 2 次。让模型先用 replace_all=true 改全部或者补足够上下文做唯一匹配,是把判断权强制压到模型身上。
为何不能默认关掉?因为「猜模型意图」会把歧义藏进底层工具。当前源码能确认的是唯一匹配约束;它没有附带“改前/改后错误率”样本,因此这里不宣称具体降幅。
Codex 的 V4A 是另一种解法:每次 update_hunk 强制带 3 行 context(@@ context @@),通过 context 唯一性而不是字符串唯一性来定位。seek_sequence.rs 实现锚点搜索算法。
如果你自己实现 str_replace,建议:
- 默认强制唯一:低层 API 不要 silently 改第一个匹配。
- 提供
replace_all开关:让模型明确表达「我就是要改所有」的意图。 - 错误信息要包含 line 范围:「文件第 12、47、89 行都有 old_string,请补充上下文区分」,模型读了就能用 Read 拿更多 context。
源码定位:claude-code/src/tools/FileEditTool/FileEditTool.ts:1-130、codex/codex-rs/apply-patch/src/seek_sequence.rs。
追问:「为何不直接让模型给行号?」行号在 multi-turn 里漂移严重:模型读完文件输出 tool_use,期间文件可能被另一个进程改、被前一个 edit 改。基于行号的协议从设计上就是脆弱的。
Q3 · 工程:FILE_UNEXPECTEDLY_MODIFIED_ERROR 怎么实现?为何不用 fcntl 文件锁?
实现思路:每次 Read 工具记录 mtime(modification timestamp),每次 Edit 工具写入前 stat 文件,如果当前 mtime 跟记录的不一致就报错。代码大致这样:
// pseudo-codeconst { mtime: readMtime } = await stat(file_path);trackFileRead(file_path, readMtime);
// later in Edit toolconst { mtime: currentMtime } = await stat(file_path);if (currentMtime !== trackedMtimeFor(file_path)) { throw new Error('FILE_UNEXPECTEDLY_MODIFIED_ERROR');}// proceed to write为何不用 fcntl 文件锁?三个原因:
1. 文件锁锁不住所有写者:如果别的进程不取锁直接写(vim、VS Code),锁形同虚设。mtime 校验是被动观察,所有写法都触发。
2. 协作模型不一样:agent 不是数据库,它的「冲突」是「我之前读到的内容已经过时」,而不是「我要独占写权限」。mtime 校验对应「optimistic concurrency control」语义,更合适。
3. 平台兼容:Windows、macOS、Linux 的 fcntl 行为不一致。mtime 是 POSIX 加 Windows 都支持的最小公约数。
实操注意:
- mtime 的有效分辨率和运行时取值会随文件系统、平台与 API 而异。短间隔连续写入不能只依赖 mtime;需要时应配合内容 hash 或其他版本标识。
- 跨进程共享 trackedMtime 时(多 worker),mtime 表要放共享存储。Claude Code 是单进程,所以放内存。
源码定位:claude-code/src/tools/FileEditTool/utils.ts 里有 findActualString 加 mtime 校验细节。
追问:「git hash 校验呢?」可以替代 mtime。但 git hash 计算比 mtime 慢(要 SHA-256),且小文件改动可能 hash 不变(注释改动?不,注释改了 hash 也变)。Claude Code 选 mtime 是为了速度。
Q4 · 架构:V4A 同时 Update、Add、Delete 时,原子性边界在哪里?
V4A 只有第一阶段具备 all-or-nothing 拒绝语义;这不是数据库式两阶段提交:
Phase 1 · Parse 加 Validate:整段 patch 先全文解析,所有 hunk 在内存里构造成结构化对象。任何一个 hunk 解析失败,整段拒绝,不写盘任何东西。
Phase 2 · Apply:所有 hunk 解析成功后按顺序写盘。中途遇到磁盘、权限或锁错误时,ApplyPatchFailure 会携带失败前已经提交的 delta;写失败时 delta.exact = false。调用方必须把它当作可能部分完成,而不是假设 crate 已回滚。
为何不做更严格的「真原子」(all-or-nothing)?因为 POSIX 文件系统层面就没有跨文件原子写:你只能对单个文件做 atomic rename,没法对一组文件做 atomic commit。要做的话需要:
- 写到临时目录、临时文件名。
- 全部成功后,逐个 rename 到目标位置。
- 中间失败就清理临时目录。
这套机制 Codex、Hermes 都没完整实现,因为:
- 复杂度高:临时目录管理、rename 的边界 case、跨文件系统 rename 失败。
- 真实需求低:phase 1 会先拦住语法和上下文错误;磁盘满、权限错等 phase 2 失败仍需让用户介入。本章没有失败样本,不能给 phase 1 编一个过滤比例。
- 隔离环境可整体丢弃:在 disposable worktree 或临时副本中运行时,失败后可以废弃整个副本;不要在含用户改动的工作树里用宽泛 reset 代替事务。
实操建议:先做 phase 1 的全量解析与校验,再设计 phase 2 的写盘失败恢复。需要跨文件原子性时,在隔离 worktree、临时副本或支持事务的文件层执行;不要用会覆盖用户未提交改动的宽泛 git reset 兜底。
源码定位:codex/codex-rs/apply-patch/src/lib.rs 里 apply_patch_to_disk 函数。
追问:「git 自带的 patch apply 行为是不是更好?」git apply 的错误会指出失败的 hunk,这是可观察到的格式差异。它是否比 V4A 的输出更容易让人或 agent 定位问题,需要在目标编辑工作流中验证;本文不据此给出通用易用性排名。
Q5 · 概念:什么叫「diff 是反馈格式」?为何每次 edit 完都要给模型回 diff?
「diff 是反馈格式」意思是:edit 工具的 result 不应该是 “ok” 或 boolean,而是改动前后的 diff 文本。例如:
--- before+++ after@@ -10,3 +10,3 @@- const name = "foo"+ const name = "bar"为何每次 edit 完都要回 diff?三个理由:
1. 让模型验证自己改了什么:模型发 tool_use 时心里想着「改 line 12」,但实际 old_string 匹配可能落在 line 47。回 diff 让模型马上看到「啊,改对了」或「啊,改错了,要回滚」。
2. 让用户、reviewer 审查:Diff 能显示具体增删,比一句「edit success」更适合复核。Claude Code 把 diff 通过 /diff 命令暴露给用户,CI 场景下 diff 也可以进入 PR 描述。
3. 让下游工具(LSP、linter)有触发点:Diff 触发 LSP diagnostics 重新计算、linter 重新检查、test runner 重跑相关文件。如果只回 ok,下游不知道哪里变了。
工程实现:
- diff 应该是 unified diff 格式(git diff 风格),所有程序员都看得懂。
- diff 返回上限应可配置;截断时同时给出完整 diff 的可访问位置和按文件摘要,不能只留下 summary。
- 对于多文件 patch(V4A),diff 按文件分组,每个文件独立 diff 块。
Codex 的 V4A 还做了一件聪明的事:rollout 文件里存 patch 原文。replay 时直接复用 patch 字符串,不用重新 diff。
源码定位:claude-code/src/tools/FileEditTool/ 里有 diff 生成逻辑(utils.ts)。
追问:「diff 用什么算法生成?」通常 myers diff(O(ND)),现代用 patience diff。Node 上 diff 包是标准选择。Codex 用 Rust 的 similar crate。
Q6 · 实操:你的 agent 要支持「修复一个 bug,可能涉及 5 个文件」。设计 edit 工具的 schema 和工作流。
Schema 设计(推荐 V4A 兼容 + str_replace fallback):
// Option A: V4A 大 patchinterface ApplyPatchInput { patch: string; // *** Begin Patch ... *** End Patch}
// Option B: str_replace 单点interface FileEditInput { file_path: string; old_string: string; // 必须唯一 new_string: string; replace_all?: boolean;}让模型按结构选:单点、可唯一定位的改动用 FileEdit;跨文件或需要一次审阅完整 patch 时用 ApplyPatch。行数只能作为观测值,不应成为硬路由规则。
工作流:
- 模型先 Read 所有相关文件:System prompt 强制「修 bug 前必须先 Read 所有涉及文件」。
- 模型 think 一段(用 thinking block 或 markdown),描述 root cause 和改动计划。这一步进 trajectory,方便后续 review。
- 模型发 ApplyPatch 或多个 FileEdit:如果是 5 个文件的协调修改,建议 ApplyPatch 一把过。如果是 1-2 个文件,FileEdit 也行。
- agent 跑 tests、lint:如果失败,trajectory 里附错误信息,让模型决定回滚还是继续改。
- 生成 PR description:基于 trajectory 里的 think 段和 diff,自动写一段「修了什么、为何」。
关键决策:
- 强制先 Read:跳过 Read 会让上下文不完整,容易把错误扩大;这里没有本站错误率样本。Claude Code 在 prompt 里硬性要求,Codex 在 V4A grammar 里要 context 锚点变相强制。
- 加 mtime 防 race:5 个文件改完中间有用户手动改某个文件,要立刻报错。
- 恢复机制:在隔离 worktree、临时副本或明确快照中运行协调修改。测试失败时保留 diff 和错误,再由宿主决定补偿或丢弃隔离环境;不要用会混入用户改动的全局 stash 当事务。
避坑:
- 大 FileEdit 是否触碰参数上限要按 provider 和模型实测;超过稳定范围就拆分。
- 多文件修改若需要一致性,应在隔离 worktree 或快照里完成;一个 ApplyPatch 也不自动提供写盘事务。
- 不要在没跑 tests 前就觉得修好了(lazy verifier 是兜底)。
源码定位:Codex 的 goals.rs 把「改完跑 tests」串到 verifier 里。Claude Code 在 query.ts 里用 stopHooks 做类似的事。
追问:「跨语言项目(如 backend Python 加 frontend TS)怎么办?」每个 sub-project 跑各自的 tests,failure 信号合并回 trajectory。Codex 的 run_tests 工具会自动识别项目语言。
Q7 · 架构:OpenClaw 为何不做编辑 DSL?这种「不为 coding 优化」的选择有什么后果?
OpenClaw 是控制面工具,它的 use case 不是「一个 coding agent」而是「一个 agent 平台,用户可以装很多 skill」。skill 里可能有 coding workload,也可能是数据分析、客服、爬虫。为单一场景(coding 改文件)造 DSL 不符合通用性目标。
具体做法:把 fs.read、fs.write、fs.edit 当普通工具放在 tool-catalog.ts 的 fs 类目下,用 tool-fs-policy.ts 的一个 boolean(workspaceOnly)做边界。没有 V4A,没有 str_replace 协议,没有 mtime 校验。
后果:
- 多文件原子性靠不住:OpenClaw 没有 patch 协议,多文件改动等于多个 fs.write 调用,中间失败状态不一致。
- edit 体验差:模型要自己读再写,每次写就是整段文件覆盖(除非工具支持 diff-style edit,但默认没有)。
- 审计粒度粗:trajectory 里能看到「写了 file X」,但看不到「改了哪几行」。
- 二开方可以加:
tool-policy-pipeline允许在before_tool_call、after_tool_call钩子里加自定义校验,理论上可以塞 V4A 解析进去。
为何能接受这些后果?因为 OpenClaw 的核心 user 是「装 skill 的 agent 用户」,coding skill 只是其中一种。@coding-skill 这种插件可以自带 V4A 协议、自带 str_replace 工具,不用 OpenClaw 内核来管。
类比:
- VSCode 不内置 git,让 git extension 来做。VSCode 是控制面,git 是 skill。
- OpenClaw 不内置 V4A,让 coding-skill 来做。同样的设计哲学。
实操推论:如果你做的是「agent 平台」,不要为单一场景内核里造 DSL,做成 skill 或 plugin。如果你做的是「coding agent」,内核就该深入到 V4A、str_replace 这一层。
源码定位:openclaw/src/agents/tool-fs-policy.ts:1-32(整个文件就是一个 boolean),openclaw/src/agents/tool-catalog.ts 里 fs 类目。
追问:「LangChain 怎么处理 file edit?」LangChain 没有内置 V4A,提供 file-toolkit 让用户自己 hook。LangChain 跟 OpenClaw 一样是控制面思路。
Q8 · 工程:Hermes 复用 V4A 但「当 function call 参数塞进去」,相对 Codex 的 inline 模式有什么 trade-off?
Hermes 的做法:模型在 tool_use 的 input.patch 字段塞一整段 V4A 字符串,harness 收到后调 tools/patch_parser.py 解析。Codex 的做法:模型在 assistant message 的 text content 里 inline 输出 V4A,harness 扫文本找 *** Begin Patch。
Trade-off:
| 维度 | Hermes(function arg) | Codex(inline) |
|---|---|---|
| 协议复杂度 | 低,标准 function call | 高,要 parse assistant text |
| 大小限制 | 受 function arg 与 output 上限约束 | 受 output 上限约束 |
| 模型学习成本 | 略低(function call 模型熟悉) | 略高(要学一个非标 DSL) |
| 失败恢复 | function call 错误处理标准化 | 需要自己定义 parse failed 回包 |
| 协议可移植性 | 跟所有 function-calling 模型兼容 | 依赖 Anthropic、OpenAI 容许 inline text |
为何 Hermes 选 function arg?两个考量:
- 跨模型兼容:Hermes 要同时支持 OpenAI、Anthropic、Gemini,所有模型都支持 function calling,但 inline DSL 在 Gemini 上更难(Gemini 的 thinking 模式跟 text content 容易混)。
- 简化协议处理:Python 代码里
result["patch"]一行拿到 patch 字符串,比扫描 assistant message 文本简单。
为何 Codex 选 inline?反过来:
- Codex 主要跑 GPT,撞 function arg 上限是日常。
- rollout 文件里存 assistant message 原文:inline 路径下 patch 直接进 rollout,replay 时不用额外拼装。
- Codex 不需要跨模型:绑死 OpenAI,不用考虑兼容性。
实操建议:
- 多模型 agent → Hermes 模式(function arg)。
- 单模型加大 patch 场景 → Codex 模式(inline)。
- 不确定 → 起步 function arg,撞上限再换 inline。
源码定位:hermes-agent/tools/patch_parser.py:1-29(明说复用 V4A),hermes-agent/tools/file_tools.py(怎么调 parser)。
追问:「Hermes 撞 function arg 上限了怎么办?」模型自己把 patch 拆成多段 tool_use 发,每段一个 file。这丢了原子性,但是个 fallback。
Q9 · 概念:「文件编辑的副作用网」具体指什么?为何 Claude Code 要做这么完整?
「副作用网」指文件编辑之后,要触发的所有外部系统更新。Claude Code 编辑一个文件后触发四件事:
- LSP diagnostics 失效(
clearDeliveredDiagnosticsForFile):让 LSP server 重新分析这个文件,下次模型读 diagnostics 拿到最新错误列表。 - fileHistory 追踪(
fileHistoryTrackEdit):在内部历史表里记一条「时刻 T,文件 X,diff Y」。/diff命令可以查看会话内所有改动。 - VS Code SDK 通知(
notifyVscodeFileUpdated):如果 Claude Code 跑在 VS Code 扩展里,告诉编辑器刷新文件(避免显示旧内容)。 - transition reason 写入:loop 退出时如果有过 edit,transition 里会标记
had_edits: true,让监控区分「只读会话」和「有改动会话」。
为何要做这么完整?因为 agent 不是孤岛。一个 edit 不止是文件改了,它还影响:
- 下一个 turn 的 context:LSP 没更新,模型下次问 diagnostics 拿到陈旧错误。
- 用户的视觉感知:VS Code 没收到通知,用户在编辑器里看到的还是旧内容,跟 agent 状态不一致。
- 会话级别的回溯:用户问「你刚才改了什么」,没有 fileHistory 就没法答。
- CI、监控:transition.reason 没标记 had_edits,监控看不出会话性质。
Codex、OpenClaw、Hermes 做的相对少:
- Codex:rollout 写盘加 execpolicy 审计,等于 1.5 件事。
- OpenClaw:tool 事件流加 session lane,等于 1 件事。
- Hermes:memory commit 加 trajectory event,等于 1.5 件事。
Claude Code 做得多是因为它定位 IDE-native agent,跟编辑器深度集成,必须把 IDE 状态保持一致。Codex 定位 CLI、CI agent,没有编辑器要同步,只关心 rollout 落盘。
实操建议:
- 起步只做「LSP 失效」加「fileHistory」(2 件)。
- 上 IDE 集成再做 VS Code 通知。
- 上 production 监控再做 transition reason 标记。
源码定位:claude-code/src/tools/FileEditTool/FileEditTool.ts:1-130(看 edit 完成后做的所有事)。
追问:「LSP server 自己应该能感知文件变化吧?」能,通常靠 file watcher。延迟依平台、负载和 watcher 配置而变;若 IDE 需要即时一致性,应测量主动通知与 watcher 兜底在目标环境中的行为。
Q10 · 开放:让你设计一套分层的编辑工具协议,会怎么做?
我会做一个分层协议,吸收四家精华:
Layer 1 · 单点 edit(必选)
interface SimpleEdit { file_path: string; old_string: string; // 必须唯一匹配 new_string: string; replace_all?: boolean;}参考 Claude Code 的 str_replace,强制 unique 加 mtime 校验。它适合单点、可唯一定位的改动;是否使用不由 token 数单独决定。
Layer 2 · 大 patch(按需)
interface BulkPatch { patch: string; // V4A 格式 validate_only?: boolean; // dry run 模式}参考 V4A 协议。Lark grammar parser,phase 1 validate + phase 2 apply。撞 function arg 上限时模型 fallback 到 SimpleEdit。
Layer 3 · Policy(必选)
interface FsPolicy { workspace_root: string; // 边界 forbidden_paths: string[]; // 黑名单 allowed_extensions?: string[]; // 白名单 require_mtime_check: boolean; // 默认 true}参考 OpenClaw 的 workspaceOnly + 黑白名单。每次 edit 前过 policy。
Layer 4 · 副作用网(需要 IDE 或审计时)
interface EditSideEffects { notify_lsp: boolean; track_history: boolean; notify_editor: boolean; // VS Code / Cursor / etc. emit_event: boolean; // 给监控 / 审计}参考 Claude Code 的副作用网,但每件都可关闭(小 agent 不一定都需要)。
Layer 5 · Transition(需要恢复与观测时)
每次 edit 完,trajectory 里附:
interface EditOutcome { changed_files: string[]; diff: string; // unified diff bytes_changed: number; mtime_check_passed: boolean; side_effects_fired: string[]; error?: { code: string; message: string };}监控直接读 EditOutcome 做聚合。
API 示例:
const editor = createFileEditor({ policy: { workspace_root: '/app', forbidden_paths: ['.env'] }, side_effects: { notify_lsp: true, track_history: true },});
await editor.simpleEdit({ file_path: 'src/foo.ts', old_string: '...', new_string: '...' });// orawait editor.bulkPatch({ patch: '*** Begin Patch ...' });这份组合的边界:它把 V4A、mtime、policy 和编辑副作用放进同一接口,但没有复现四家完整运行时,也没有基准证明它更轻、更深或更易维护。是否保留 rollout、默认开启哪些副作用,要由恢复与审计需求决定。
工程量取决于目标语言、parser 覆盖、并发写入语义、编辑历史和测试矩阵。本文没有实现记录,不给固定周数。
源码定位:综合参考本章四套系统的实现。 追问:「这个协议能跨语言吗?」JSON Schema 输入输出和文本 patch 允许不同语言实现同一协议,但每种语言仍要实现 parser,并用同一组成功、歧义和部分失败样本做一致性测试。