跳到主要内容

改代码引擎:从「模型的改动」到「你 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:1686)在解析前先做一轮归一化:

-- 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:681)用五级逐步放宽的匹配兜底:

原文件每个起始位置,尝试匹配 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:658)做一次「滑动窗口逐行比较」,只是比较函数不同。看真实结构:

-- 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):

  1. 插入新行:insert_diff_blocks_new_lines(replace_in_file.lua:554)把每块 new_lines 写进 buffer 对应位置。
  2. 画删除虚行:highlight_diff_blocks(replace_in_file.lua:564)用 extmark 的 virt_lines 把「被删的旧行」以高亮虚行形式显示在新行下方(TO_BE_DELETED_WITHOUT_STRIKETHROUGH),新行用 INCOMING 高亮——绿增红删的既视感,但旧行是虚行不占真实行。
  3. 注册逐块审阅键: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)。
  4. 弹确认框: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:197720):一次 AI 改动被合并成一步可撤销——你 u 一下就能整体撤销,而不是撤十几次。

5. 流式 diff:边收边铺

replace_in_filesupport_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:33102 的注释):

有些模型按字母序流式生成函数参数。若参数叫 diff,会让 pathdiff 之后才到——可流式 diff 必须先知道改哪个文件(path)才能往那个 buffer 铺。改名 the_diffpath 字母序在前,于是先拿到 path,再边收 diff 边铺。

流式过程还有节流:同一个工具 2 秒内 diff 没变化、或行数没变,就跳过这次刷新(replace_in_file.lua:121-134),避免疯狂重绘。

6. 可选:minimize_diff(只高亮真正变的行)

模型给的 SEARCH/REPLACE 块里常含一堆「为了定位而带的上下文行」,它们没变。若开 behaviour.minimize_diff(默认开,config.lua:844),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 模型(morph provider,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.luaM.funcinsert_diff_blocks_new_lineshighlight_diff_blocks
流式预览lua/avante/llm_tools/replace_in_file.luahighlight_streaming_diff_blocksget_unstable_diff_blocks
五级模糊匹配lua/avante/utils/init.luaM.fuzzy_matchM.try_find_match
脏块头修复lua/avante/utils/init.luaM.fix_diff
unified diff → SEARCH/REPLACElua/avante/utils/diff2search_replace.luadiff2search_replace
str_replace 包装lua/avante/llm_tools/str_replace.luaM.func
Morph fast-applylua/avante/llm_tools/edit_file.luaM.func
minimize_diff 拆 hunklua/avante/llm_tools/replace_in_file.luarough_diff_blocks_to_diff_blocks