工具系统:模型怎么说清「改哪里」?
本章一问:模型怎么说清「改哪里」?
主线定位
上游(A1 循环):模型只会吐文字;harness 把文字里的工具调用切出来执行,结果放回下一轮,没有工具调用就停。
本章(A2 工具系统):工具怎么定义、模型怎么把「改哪里」说清楚、说错了 harness 怎么办、外部工具怎么接进来。
下游(A3 权限):工具能改文件、能跑命令、能连外部系统——它要执行 rm -rf 时,谁来拦?
下面是累计数据流,本章点亮「工具层」。点亮的节点可以点开,看它回答什么问题、在贯穿任务的 trace 里长出哪一段。
本章问题链
每一课只回答一个问题,上一课留下的缺口就是下一课的问题。
- 让模型按行号改,行不行?——第一处修复多写 2 行,第二次按旧行号就改到了
if m:;整文件覆写不怕行号,却要多花约 9 倍的输出。 - old_string 和文件差一个空格,该报错还是容错?——只查存在会改错位置,严格唯一会多花轮次,容错只管「找」不管「写」。
- 能不能从源头保证 patch 格式合法?——把 19 行语法交给解码器;它只保证语法,不保证定位。
- 外部工具怎么接进来?MCP 放在哪一层?——MCP 在 harness 与工具之间;接进来的每个工具每轮都要付上下文。
- 实验课:五个真引擎跑真实 edit 语料
演进地图
本章的「现代做法」是被一连串真实数字逼出来的。每一课都会用这张图标出「你在这里」。
- 解决了模型不用复述原文,只报行号,输出最短。卡在行号是「上一次读文件时」的快照:第一处修复只要多插 2 行,第二次按旧行号 41 改,就落在
if m:上(第 1 课现算);CoreCoder README 的原话是「Line numbers are a trap」。 - 解决了不需要定位,格式最不容易错:aider 同一个 Qwen2.5-Coder-32B,whole 格式合规 99.6%,diff 格式只有 71.6%。卡在只改 1 行,也要把 44 行的文件整个再写一遍:参数 487 token,片段替换只要 54(9.0 倍,项目模型 tokenizer 实数);还会覆盖别人在此期间的改动。
- 解决了按内容定位,不怕行号过期;0 处或多处都报错、文件不动,让模型自己补上下文。卡在真实 trace pd-truncate-s1:第 4 轮 old_string 在文件里 2 处、第 5 轮缩进多 4 格 0 处,两次报错多跑了两轮,花掉 8,481 token、109.5 秒;唯一也不等于对:pd-v2-unlimited-s1 第 7 轮的 old_string 恰好是已修好那一行的前半截,计数 = 1,于是
+ 1变成了+ 1 + 1。 - 解决了模型复述时的小漂移不再白费一轮:上面第 5 轮那条缩进多 4 格的 edit,OpenCode 直接接住(LineTrimmed 级)。卡在接住 ≠ 改对:多转义一层的引号,OpenCode 的 EscapeNormalized 级照样接住,写回去的是
\",模块导入即 SyntaxError;OpenCode 的 v2(packages/core)眼下只有精确匹配,「edit fuzzy parity」还列在待移植清单里。 - 解决了语法由解码器保证,不再靠 prompt 教。对照:靠 prompt 教格式时,aider 榜上 47 个 diff 格式条目的「格式正确」比例最低只有 64.4%(官方自报)。卡在语法只管格式,定位仍是 seek_sequence:它不检查唯一、取第一处——两处相同的
return datetime.date(year, month, day)会改到第 31 行 ISO 分支,test_parse_iso 跟着坏;整行比较还会把没写缩进的 new 行原样写进去,得到 IndentationError。 - 解决了一套协议接任意外部工具;同一个 harness 给 GPT 换上 apply_patch、给 Claude 用 edit。卡在接进来的每个工具每轮都要付上下文:chrome-devtools-mcp 的 30 个工具在项目模型的 chat template 里占 7,047 token,是 miniagent 自己 6 个工具(685)的 10.3 倍。
- 解决了N 次「模型 → 工具 → 模型」往返压成一段脚本。卡在省的是往返,不省判断:真实 trace 里模型每一步都要看上一步的结果才知道下一步做什么(岔路课逐轮算)。
贯穿任务
全章都在同一个任务上展开:让本课项目任务仓里失败的 test_parse_date 通过。要改的 dateparse.py 有两处 bug(第 35 行日月顺序、第 41 行月份少 + 1),而第 31 行与第 36 行有一模一样的一行 return datetime.date(year, month, day)——片段锚定的「唯一性」问题就从这里来。
本课项目 miniagent 用本机的 Qwen3-8B 真实跑过这个任务:到 2026-09-26 为止,trace 里一共 33 次 edit_file,去重后 14 条(本章叫它 T8 语料)。第 2 课与实验课逐条用它们;第 4 课的外部工具用不上这个任务,换成三个真实 MCP server,课内写明了原因。
三层交互
| 层 | 这一章里是什么 | 在哪一课 |
|---|---|---|
| 概念动画 | 行号过期(先改一处、再按旧行号改第二处,逐步看落点);MCP 一次往返的三条泳道步进;一次工具调用的逐轮回放 | 第 1 课、第 4 课、岔路 B1 |
| 算法模拟器(真算法、小数据) | 五个 edit 引擎的浏览器移植(learn-claude-code、CoreCoder、Pi、OpenCode 9 级 replacer、Codex seek_sequence),6 个预置坑 + 压轴新漂移,old_string 可改、实时重算;apply_patch 19 行语法的逐字前缀检查器;OpenCode 按模型换工具集的规则 | 第 2 课、第 3 课、第 4 课 |
| 真模型 / 真数据 | T8 真实 edit × 五个引擎,每格附「写回任务仓实跑测试」的结果;项目 trace 的逐轮请求(token 与服务端 usage 核对一致);本机实跑三个 MCP server 的真实工具清单与一次真实 JSON-RPC 往返;aider 公开排行榜 | 实验课、岔路 B1、第 4 课、第 1/3 课 |
每个移植的算法都配了 fixture:期望输出由原仓库代码实跑得到(OpenCode 与 Pi 用 Node 跑 TypeScript 原文,CoreCoder 与 learn-claude-code 用 Python 加载原函数,Codex 用 rustc 编译 Rust 原文件),页面加载时逐条比对,组件右上角显示一致的条数。
项目推进:里程碑 M2 · search-replace 的 edit_file
对应 04-实践项目/MILESTONES.md 的 A2 一节。代码在 miniagent/tools.py(tool_edit_file 在第 182–207 行),测试在 tests/test_tools.py。本课项目 v0.5.0 已经实现了唯一匹配版本。
你要拍板的决策
| 决策 | 选项 | 这一章里的依据 |
|---|---|---|
| edit 格式走哪条路 | 整文件覆写 / 唯一片段替换 / 容错降级 / 语法约束的 patch | 第 1 课(行号与整文件的账)、第 3 课(约束解码只管格式) |
| 唯一匹配失败时报错还是降级 | CoreCoder 与 miniagent 报错;Pi 归一化后再找;OpenCode 9 级降级加护栏 | 第 2 课与实验课:T8 里被严格唯一拒绝的真实 edit、容错接住后测试的结果 |
| bash 超时多少、输出截多长 | 本项目默认 30 秒、8000 字符(保留头尾) | MILESTONES.md;A5 讲输出怎么占上下文 |
验收标准(全部可测)
.venv/bin/python -m unittest tests.test_tools全部通过,至少覆盖:唯一匹配成功并返回 diff;0 处匹配报错、文件不变,且错误信息引导模型补充上下文;多处匹配报出次数、文件不变;空白必须精确匹配;越界路径被拒;bash 超时被杀。- 若选择降级:至少实现 LineTrimmed 与 IndentationFlexible 两级,并且「歧义」用例与「匹配范围远大于 old_string」用例必须拒绝(照 OpenCode 的比例护栏)。
- 与页面 fixture 一致:
src/data/precomputed/a02-edit-fixtures.json的 miniagent 一列由项目代码实跑生成,全部 26 个输入的裁决与结果 SHA-256 必须与页面上的浏览器移植一致(实验课右上角)。 - CRLF 与 BOM 文件改完后保持原换行与 BOM。当前 v0.5.0 不满足这一条:fixture 的「CRLF 文件」用例里,改一行后整份文件的换行都被改成了
\n(结果与原文件从第 1 行起 44 行不同),原因是 Python 的read_text/write_text做了换行转换;Pi 与 OpenCode 的做法见第 2 课。 - 在真实 trace 里至少找到一次
edit_file成功的 diff:jq 'select(.type=="tool_call" and .name=="edit_file")' traces/*.jsonl。
坑与带走
本章拦截的误解
| 误解 | 纠正 | 在哪一课 |
|---|---|---|
| 整文件覆写最安全 | 不会改错位置,但输出随文件长度增长(44 行的文件 487 对 54 token),还会覆盖并发改动 | 第 1 课 |
| 模糊匹配越多越好 | 容错只管「找」不管「写」:多转义的引号、弯引号被接住后原样写回,模块导入即语法错误 | 第 2 课 |
| 唯一匹配就不会改错 | 唯一只说明只有一处;真实 trace 里 old_string 恰好是已修好那行的前半截,+ 1 变成了 + 1 + 1 | 第 2 课 |
| 约束解码让模型更会改代码 | 它只保证语法;定位仍靠 seek_sequence,两处相同的行照样改到第一处 | 第 3 课 |
| 模型执行了工具;工具定义只发一次 | 模型只写下调用,执行在 harness;工具定义每一轮都发,第 1 轮占 76% | 岔路 B1 |
| MCP 是让模型调用模型的协议 | MCP 连的是 harness 与工具、资源;Sampling 已在 2026-07-28 版废弃,官方建议直接对接模型 API | 第 4 课 |
| 废弃 = 已经移除;Codex 也能当 MCP server | 废弃窗口至少 12 个月;codex mcp-server 已于 2026-09-05 删除 | 第 4 课 |
| Code Mode 更安全 | 它关住的是脚本,脚本调用的工具照样有全部能力 | 岔路 B2 |
常见的坑(写代码时)
| 症状 | 后果 | 怎么办 |
|---|---|---|
| 行号寻址,第二次编辑落错位置 | 静默改错,测试可能仍失败 | 改用唯一片段锚定(第 1 课) |
| 只查存在、改第一处 | 重复片段时改错位置,还报「成功」 | 先数出现次数,>1 就报错(第 2 课) |
| 模糊匹配没有护栏 | 一次替换吃掉大片无关代码 | 比例护栏 + 歧义拒绝(第 2 课) |
| CRLF / BOM 让精确匹配失败,或改一行换掉全文换行 | 明明一样却报 not found;diff 里每一行都变了 | 在归一化空间里匹配,按原格式写回(Pi edit.ts:344-350) |
| 全量加载所有 MCP 工具 | 每轮前缀膨胀;中途增删工具让缓存失效 | deferred + tool search(第 4 课) |
带走这些句子
- 行号在第一次编辑后就会过期,锚定片段不会。
- 容错与改错是同一把刀的两面,护栏决定它往哪边切。
- 能用解码约束保证的格式,就别靠 prompt 教;约束只管格式,不管定位。
- MCP 是接工具的协议,不是接模型的协议;接进来的每个工具每轮都要付上下文。
- 模型从不执行工具,它只写下调用;工具定义每一轮都随请求重发。
- Code Mode 省的是往返次数,不省判断,也不自动更安全。
- 同一条 edit,五个引擎给出三种裁决;只有跑过测试才知道谁对。
深入这一章
按你愿意花的时间选。每条都实际打开核对过(2026-09-26);站外链接需联网。
🎮 想更直观 10–30 分钟
🔬 想吃透原理 1–3 小时
📄 想读原文 按需
我替你排除了什么:Jimmy Song《MCP 协议架构》(中文,2026-08-14 更新;本想作「想直观」的中文资源,核对后发现它的 _meta 键名没有 io.modelcontextprotocol/ 前缀、三项废弃功能的迁移去向也与官方规范不一致,暂不收)· MCP Inspector 作「想直观」资源(它是调试工具,要本地跑 npx,适合在项目里用)· analysis_claude_code(逆向的是 Claude Code v1.0.33,版本太旧)· agent-prompt-observatory(小项目,只借鉴它的可视化方式)· 任何「Aider 实测证明某种 edit 格式更好」的二手转述(本轮没找到一手实验)· 「Anthropic 提出 ACI」的说法(ACI 出自 SWE-agent 论文)。