数据截至 (上游 commit 1a36a37b1a96)
改代码引擎:从「模型的改动」到「你 buffer 里的 diff」
这是 avante 工程含量最高、最值得借鉴的一章。难点从来不是「让模型说要改什么」,而是「把模型那段不精确的旧/新文本,可靠地落到你真实文件的正确位置上,并让你能逐块审阅」。
1. 改代码的「协议」:SEARCH/REPLACE 块
avante 不让模型直接给「整文件新内容」或「行号补丁」,而是给搜索/替换块——指明「把这段旧文本换成这段新文本」。格式(来自 replace_in_file.lua:36-58 的工具描述):
------- SEARCH
[要查找的原文,需尽量精确匹配]
=======
[替换成的新内容]
+++++++ REPLACE
为什么用「匹配旧文本」而不是「行号」?因为模型不知道精确行号,而且文件可能已被改动。匹配旧文本对「文件被自动格式化、行号漂移」更鲁棒。工具描述里甚至明确要求:SEARCH 内容要从最新文件内容匹配,别引用上次的 diff(replace_in_file.lua:61-62)。
上层不同工具最后都汇成这个协议:
str_replace(str_replace.lua:58)把old_str/new_str拼成一个 SEARCH/REPLACE 块,转交replace_in_file。- 模型若给的是 unified diff(
@@ -a,b +c @@),diff2search_replace(utils/diff2search_replace.lua:11)先把它转成 SEARCH/REPLACE 块。
2. 先修脏数据:fix_diff
模型(尤其 gpt-4o 这类)经常把块头写错——漏掉 SEARCH 行、用 <<<<<<< 而非 -------。Utils.fix_diff(utils/init.lua:1688)在解析前先做一轮归一化:
-- utils/init.lua:1686 起(节选):把各种写歪的块头掰回标准格式
diff = diff2search_replace(diff) -- unified diff → SEARCH/REPLACE
diff = diff:gsub("<<<<<<<%s*SEARCH", "------- SEARCH") -- git 冲突风格 → avante 风格
diff = diff:gsub(">>>>>>>%s*REPLACE", "+++++++ REPLACE")
diff = diff:gsub("-------%s*REPLACE", "+++++++ REPLACE")
直觉:这是「容错的第 0 关」——先把模型笔误的分隔符掰正,后面才好解析。
3. 核心绝活:fuzzy_match 五级降级
解析出 old_lines 后,要在真实文件里定位它在哪几行。问题是:模型给的 old_lines 几乎从不和真实文件一字不差——可能差缩进、差尾随空格、差转义。Utils.fuzzy_match(utils/init.lua:682)用五级逐步放宽的匹配兜底:
原文件每个起始位置,尝试匹配 old_lines:
① 精确相等 line_a == line_b
命中即停 ──────────────────────────┐
② 去尾随空白后相等 trim(a)==trim(b) │ 任一级命中
命中即停 ──────────────────────────┤ 立即返回
③ 去全部空白后相等 trim_space ... │ start_line, end_line
命中即停 ──────────────────────────┤
④ 去转义后相等 a == trim_escapes(b) │
命中即停 ──────────────────────────┤
⑤ 去转义+去全部空白后相等 │
─────────────────────────────────────┘
每一级都调 try_find_match(utils/init.lua:659)做一次「滑动窗口逐行比较」,只是比较函数不同。看真实结构:
-- utils/init.lua:681 起(节选):从严到宽,命中就返回
function M.fuzzy_match(original_lines, target_lines)
-- ① 精确匹配
local s, e = M.try_find_match(original_lines, target_lines,
function(a, b) return a == b end)
if s and e then return s, e end
-- ② 去尾空白(space/tab)
s, e = M.try_find_match(original_lines, target_lines,
function(a, b) return M.trim(a, {suffix=" \t"}) == M.trim(b, {suffix=" \t"}) end)
if s and e then return s, e end
-- ③ 去全部空白 ④ 去转义 ⑤ 去转义+空白 ...(同款套路,逐步放宽)
end
这是整个项目最该带走的一招:不要相信模型给的旧文本是精确的,用一个「从严到宽的匹配阶梯」去容错定位。命中后,若发现原文件该行的缩进和模型给的缩进不一致,还会把整块统一加上原文件的缩进(replace_in_file.lua:214-218),让替换不破坏缩进。
4. 把 diff 铺到真实 buffer(非流式)
定位到行号后,replace_in_file 不是直接 write 文件,而是把改动当三方合并铺在你正在看的 buffer 上(replace_in_file.lua:689-694):
- 插入新行:
insert_diff_blocks_new_lines(replace_in_file.lua:554)把每块new_lines写进 buffer 对应位置。 - 画删除虚行:
highlight_diff_blocks(replace_in_file.lua:564)用 extmark 的virt_lines把「被删的旧行」以高亮虚行形式显示在新行下方(TO_BE_DELETED_WITHOUT_STRIKETHROUGH),新行用INCOMING高亮——绿增红删的既视感,但旧行是虚行不占真实行。 - 注册逐块审阅键:
register_keybinding_events(replace_in_file.lua:464)绑定diff.ours(保留原文)、diff.theirs(采用新文)、diff.prev/diff.next(跳块);光标移到某块时显示提示[<co>: OURS, <ct>: THEIRS, ...](replace_in_file.lua:413)。 - 弹确认框:
Helpers.confirm("Are you sure you want to apply this modification?", ...)(replace_in_file.lua:722)。接受后才noautocmd write!落盘(replace_in_file.lua:736);拒绝则把 buffer 恢复成original_lines(replace_in_file.lua:725)。
配合 undojoin(replace_in_file.lua:197、720):一次 AI 改动被合并成一步可撤销——你 u 一下就能整体撤销,而不是撤十几次。
5. 流式 diff:边收边铺
replace_in_file 的 support_streaming = true(replace_in_file.lua:19)。模型还在吐 SEARCH/REPLACE 块的过程中,avante 就开始把已成形的部分铺到 buffer 上预览(highlight_streaming_diff_blocks,replace_in_file.lua:646),光标跟到最新块(replace_in_file.lua:703-707)。
这里有那个著名的反直觉决定——参数为什么叫 the_diff 而不是 diff(replace_in_file.lua:33、102 的注释):
有些模型按字母序流式生成函数参数。若参数叫
diff,会让path在diff之后才到——可流式 diff 必须先知道改哪个文件(path)才能往那个 buffer 铺。改名the_diff让path字母序在前,于是先拿到 path,再边收 diff 边铺。
流式过程还有节流:同一个工具 2 秒内 diff 没变化、或行数没变,就跳过这次刷新(replace_in_file.lua:121-134),避免疯狂重绘。
6. 可选:minimize_diff(只高亮真正变的行)
模型给的 SEARCH/REPLACE 块里常含一堆「为了定位而带的上下文行」,它们没变。若开 behaviour.minimize_diff(默认开,config.lua:838),rough_diff_blocks_to_diff_blocks(replace_in_file.lua:234)会对每块再跑一次 vim.diff(histogram 算法,replace_in_file.lua:256),只把真正变化的 hunk 拆成 diff block 高亮;否则整块当一个 diff(replace_in_file.lua:262)。效果:你看到的红绿只在真改的行,不会把一大段没动的代码也标红。
7. 另一条路:Morph fast-apply(edit_file)
当配 behaviour.enable_fastapply 时,启用的是 edit_file 工具(edit_file.lua:10),走一条很不同的路线:
- 模型不给精确 SEARCH/REPLACE,而是给一段粗略的 edit,用
// ... existing code ...注释代表「这里有没改的代码」(工具描述edit_file.lua:14-15)。 - avante 把「原文件 + 这段粗略 edit + 一句 instructions」发给一个专门的 Morph 模型(
morphprovider,edit_file.lua:62),请求体是<instructions>...<code>原文件<update>粗略edit(edit_file.lua:88-102)。 - Morph 这个「更便宜的小模型」负责把粗略 edit 补全成完整新文件,avante 拿到后再走
str_replace(用old_str=整个原文件、new_str=补全结果)铺成 diff(edit_file.lua:218-224)。
大模型: 给粗略 edit(省 token,只写改的地方)
│ // ... existing code ... + 改动
▼
Morph 小模型: 原文件 + 粗略 edit → 完整新文件
│
▼
str_replace → replace_in_file: 铺 diff,你审阅
设计动机:让贵的大模型只写「改了什么」(省 token、更快),把「精确合并进原文件」这件机械活外包给便宜的 fast-apply 模型。这是 Cursor 同款思路。
8. 巧妙之处合集
- 匹配旧文本 + 五级模糊降级(
fuzzy_match):对模型不精确输出的核心容错,是最值得抄的一招。 - diff 即三方合并铺在真实 buffer:用 extmark 虚行表达删除,改动落在真 buffer +
undojoin一步可撤销——编辑器内嵌形态才能做到的体验。 the_diff参数命名 hack:为流式预览迁就模型的字母序流式输出。- fast-apply 分工:大模型写意图、小模型做精确合并。
9. 边界与坑
- 五级模糊匹配仍可能失败(找不到旧文本就回
Failed to find the old string,replace_in_file.lua:212),此时这一块编辑失败,结果作为 error 喂回模型重试。 - fast-apply 依赖 Morph provider 与其 API key(
edit_file.lua:62-64),没配就报错。 - 流式预览节流是「2 秒 / 行数」启发式(
replace_in_file.lua:124),不是精确去抖。
10. 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| SEARCH/REPLACE 解析 + 铺 diff + 确认落盘 | lua/avante/llm_tools/replace_in_file.lua | M.func、insert_diff_blocks_new_lines、highlight_diff_blocks |
| 流式预览 | lua/avante/llm_tools/replace_in_file.lua | highlight_streaming_diff_blocks、get_unstable_diff_blocks |
| 五级模糊匹配 | lua/avante/utils/init.lua | M.fuzzy_match、M.try_find_match |
| 脏块头修复 | lua/avante/utils/init.lua | M.fix_diff |
| unified diff → SEARCH/REPLACE | lua/avante/utils/diff2search_replace.lua | diff2search_replace |
| str_replace 包装 | lua/avante/llm_tools/str_replace.lua | M.func |
| Morph fast-apply | lua/avante/llm_tools/edit_file.lua | M.func |
| minimize_diff 拆 hunk | lua/avante/llm_tools/replace_in_file.lua | rough_diff_blocks_to_diff_blocks |