Coding Agent 究竟怎样写文件和改文件?
给 ChatOllama 增加 write_file 和 edit_file 以后,我很快发现,“让 Agent 写一个文件”远不是调用一次 writeFile 那么简单。两个工具一旦进入真实的 Agent Loop,路径逃逸、符号链接、并发覆盖、旧内容误写、半截文件和批量编辑的一致性就会同时出现。
为了给这两个工具确定边界,我重新查看了 Codex、Pi Agent 和 DeepSeek Harness 的当前公开源码。它们分别从 patch 语言、可控的字符串替换和文件系统 contract 出发,给了我三种不同的参考。这篇会记录我从这些实现中看到的取舍,以及它们怎样影响了 ChatOllama 的设计。期望对大家有所帮助。
写与改的差异
write 和 edit 给模型的参数不同,但在文件系统一侧,它们最终都要完成同一件事:把目标文件从版本 A 变成版本 B。
write 通常由模型提供完整的新内容,原内容即使存在也不参与变换。edit 则要先读 A,根据某种编辑描述计算 B,再写回去。差异集中在中间这一步:字符串替换工具说“找到这段旧文本,换成新文本”,patch 语言描述“在这些上下文行之间,删除和增加这些行”。
1 | write(path, content) |
这段伪代码省略了最难的部分:
- resolve 要回答模型能写哪里
- apply 要回答编辑究竟匹配哪一处
- publish 要回答失败时旧文件会不会被破坏
并发还会插进每个空隙:两个工具可能同时编辑同一文件,另一个进程也可能在 Agent 读完以后修改文件。
这也是我查看三个参考实现时使用的五个观察角度:编辑语言、匹配策略、落盘语义、并发控制和安全模型。先把结果放在一起,后面再逐项拆开。
| 角色 | 实现 | 模型面对的编辑抽象 | 默认匹配 | 单次落盘 | 并发与旧版本 | 路径边界 |
|---|---|---|---|---|---|---|
| ChatOllama 的设计结果 | ChatOllama | 单文件字符串替换,可批量 | 精确、唯一、互不重叠 | 同目录临时文件 + fsync + rename | 规范化路径队列 + 可选内容版本 | 只接受 workspace 相对路径,拒绝最终符号链接 |
| 参考实现 | Codex | 可跨文件的 patch 语言 | 上下文变更块(hunk)定位 | 通过 ExecutorFileSystem 逐项变更 | patch 本身没有数据库式全局事务 | 先计算所有受影响路径,再接入 sandbox 与 approval |
| 参考实现 | Pi Agent | 单文件字符串替换,可批量 | 精确优先,失败后受控归一化 | 默认直接 writeFile | 按真实目标路径排队,无内容版本条件 | 接受相对或绝对路径,不自行做 workspace confinement |
| 参考实现 | DeepSeek Harness | FileSystem contract 上的 literal edit | 精确,默认唯一,可 replaceAll | provider 保证原子写 | 按目标身份加锁 + 可选 version guard | sandbox provider 在写入前重新 canonicalize |
这里的“原子”必须先限定范围。同目录先创建临时文件再 rename,或者后端承诺的 atomic mutation,通常保证的是单个目标文件不会暴露半写状态。它不等于一份涉及三个文件的 patch 是一个事务:如果前两个文件已经成功,第三个失败,系统是否会自动把前两个回滚,这是另一个话题。
Codex 让模型提交一份 patch
首先参考是 Codex。这里关注的文件编辑工具是 apply_patch,而不是一对独立的字符串 write/edit。它的 patch 语法可以在一次调用中 Add、Update、Delete 和 Move 文件,一个 Update 又可以包含多个 hunk。
这里的 hunk 是 patch 中一段连续的变更块。它通常同时包含修改位置附近的上下文行、要删除的旧行和要加入的新行。例如下面这一个 hunk,会利用函数声明与右花括号定位中间的返回语句:
1 | @@ |
这定义了一种更灵活的模型表达修改的方式。字符串替换要求模型复制一段必须唯一的旧文本;patch hunk 则同时携带旧行、新行和上下文,还可以用函数或类定义一类的 change_context 缩小查找范围。Codex 会从上一个 hunk 之后继续查找,不能定位期望行就拒绝该更新。
上下文 hunk 对多处代码变更更自然,也能把多个文件的意图放在同一份 diff 中。代价是工具必须拥有解析语言、定位上下文、处理文件尾和换行风格的完整实现。字符串替换的错误通常是“找不到”或“不唯一”;patch 的错误还可能来自语法、hunk 顺序、上下文漂移和移动目标。
Codex 的另一个特点是,patch 在真正执行前已经成为一组明确的文件变化。Runtime 会收集每个源路径以及 Move 的目标路径,再根据当前文件系统策略计算还缺少哪些写权限;这些路径随后进入 sandbox 和 approval 流程。
这比“执行时碰到哪个路径再说”更适合多文件操作,因为用户审批的是整份 patch 可能影响的范围。
底层执行并不绑定本机 std::fs,而是通过 ExecutorFileSystem 读取、写入、建目录和删除。公开 Runtime 会把已经验证并完成安全评估的 patch 交给相应 environment 的文件系统,并携带文件系统 sandbox context。
这让本地环境和远程 executor 可以共享 patch 语义,但具体隔离能力仍由公开代码中的 sandbox、approval policy 与所选 environment 决定。这里没有必要推断 Codex 产品未公开的部署细节。
需要特别注意,整份 patch 不是数据库式全局原子事务。公开实现会按变化逐项提交,并记录已经 committed 的增量;失败类型也会携带失败前确定已经完成的变更。
仓库甚至有“前面成功、后面失败后保留已完成变更”的测试场景。于是 Codex 的“一个工具调用改多个文件”不应被误读为跨文件事务。
Pi Agent 优先提高编辑成功率
Pi Agent 更接近 ChatOllama 这次开发的工具形态,它同样给模型提供单文件 write 和 edit。它明确接受相对或绝对路径,相对路径按 cwd 解析;默认 write 会递归创建父目录,然后直接调用 Node.js writeFile。
这里没有 ChatOllama 那种内建 workspace confinement,也没有临时文件 + rename 的本地落盘流程。直接 writeFile 更简单,但如果进程在覆盖过程中异常退出,不应假设旧文件或新文件必然完整保留。另一方面,Pi 把 mkdir、writeFile、readFile 和 access 包成可插拔 operations,调用方可以把写入转给 SSH 或其他后端。
WriteOperations 与 EditOperations
因此默认本地语义较轻,非常易于扩展。
Pi 的 edit 支持同一文件的一批 edits[],同样把各项匹配在原始内容上并拒绝重叠。它会先去掉 BOM 参与匹配,统一换行为 LF,修改后再恢复 BOM 和原来的主要换行风格。
更与众不同的是匹配策略。Pi 先尝试精确查找;失败后才对文件和 oldText 做受控归一化,包括 NFKC、移除行尾空白、统一弯引号、Unicode dash 和特殊空格。归一化后仍要求目标唯一。
如果一批编辑中任何一项需要模糊匹配,算法会在归一化空间计算替换,再只重写实际受影响的行,尽量保留未修改行的原始内容。
这是一种有意控制范围的 fuzzy match,不是计算编辑距离以后挑一个“最像”的位置。它能处理模型常见的弯引号、破折号、空格和换行差异,提高一次工具调用成功的概率;风险是两个原本字节不同的片段归一化后可能相同,所以实现仍坚持唯一性检查。ChatOllama 选择不做这一步,是把可预测失败放在成功率之前:匹配不上就让 Agent 重新读取和重试,不让工具替模型推断。
Pi 也对同一文件的 mutation 操作排队。已存在文件使用 realpath 作为 key,因此两个别名指向同一真实目标时会进入同一队列;不存在的文件退回解析后的绝对路径。
这能避免 Pi 进程内两个工具的读写交错,但它没有 expectedVersion 一类内容条件,无法判断一次调用是否基于更早的一次观察。排队保证执行顺序,版本条件保证写入依据没有过期,两者不能互换。
DeepSeek Harness 用 FileSystem 统一文件操作
DeepSeek Harness 的出发点又不同。dsh-fs 定义抽象 FileSystem,后负责稳定 target identity、路径解析、文本解码、containment 和原子 mutation;writeText 与 editText 都属于接口,而不是本地工具代码的实现。
这层抽象把路径字符串和文件身份分开。模型提供 displayPath,provider 的 resolve 产生稳定的 targetKey;本地实现会让不同路径别名尽量映射到同一个真实目标。锁也按 targetKey 建立,因此检查、literal match 和原子写入共享一个临界区。
editText 做的是 literal edit,不提供 Pi 那种归一化模糊匹配。默认要求 oldString 精确出现一次,也可以显式设置 replaceAll。模型侧工具先取得可选的观察版本,再把版本条件与编辑请求一起交给 provider;provider 在锁内先检查 version,再读取、匹配并原子发布。
本地 provider 写入时还保留原文件 mode。与 ChatOllama 把 0600 用在临时文件上不同,DSH 把最终文件的权限继承和原子发布作为后端语义;这使同一套上层工具能够换成本地、远程或其他执行环境,同时要求每个 provider 都实现必要的接口。
文件能写到哪里,同样由后端决定。直接使用 fs-local 时,系统不会限制写入范围;换成 fs-sandbox 后,workspace-write 模式只允许写入指定的工作区和临时目录。
真正写入之前,fs-sandbox 会重新解析一次路径,取得目标此刻指向的真实位置,再检查它是否仍在允许写入的目录内。检查通过后,后续写入也使用这次新解析出的目标,而不是之前保存的旧路径。
SandboxedFileSystem.checkedTarget
如果上级目录的符号链接已经被另一个进程替换,这次复查就能发现目标位置发生了变化。
不过,这次复查只是缩短了检查与写入之间的竞态窗口,不能完全消除 TOCTOU。它负责约束文件工具的写入位置;如果要运行不可信代码,仍然需要 shell sandbox 提供操作系统层面的隔离。
三种编辑语言的取舍
看完三份参考实现以后,我先比较它们的编辑语言,因为这直接决定工具愿意替模型推断多少,也影响 ChatOllama 应该把边界画在哪里。
精确字符串替换最容易审计。命中位置由字节或字符串相等决定,唯一性和重叠也容易解释。它对模型复制空白、引号和换行的准确度要求较高,因此失败率会上升。这成为 ChatOllama 的基础选择:接受明确失败,把重新读取交给 Agent Loop。
Pi 的受控归一化允许工具统一一组已知、可解释的表示差异。它没有在整个文件中寻找语义相似段落,也不在多个候选中擅自挑选,所以风险仍受到唯一匹配约束。成功率更高的同时,工具实现也必须处理归一化后的 offset 与原始内容之间的映射。
Codex 的上下文 hunk 更适合结构化代码变更。模型可以用周围代码说明位置,一次表达多个文件、多个片段和移动操作。它的复杂度不在 fuzzy,而在 patch 语言本身:parser、上下文定位、换行保存、权限集合和部分提交都成为核心机制。
DSH 声明了另一种扩展方向:模型侧仍可保持简单 literal edit,但把稳定身份、版本条件、原子写和 sandbox policy 交给 FileSystem。它扩展的不是编辑语言,而是执行后端。

