跳到主要内容

模糊改文件:7 级降级匹配

这一章是全库工程含量最高的一块。要解决的小问题:LLM 说「把这段旧代码换成那段新代码」,但它给的「旧代码」几乎从不和文件里一字不差(缩进差了、空格多了、引号变了)。怎么把它精确、可靠地落到真实文件上?

1. 为什么难:模型记的和磁盘上的对不齐

你让 LLM 改一个函数,它返回:

{ "oldText": "def foo():\n return 1", "newText": "def foo():\n return 2" }

但文件里那行可能是 4 空格缩进、行尾有个被 formatter 加的逗号、或者引号是单引号。如果你用「精确字符串替换」,一个字符不对就整个失败。直接放弃太脆;但乱模糊匹配又会改错地方(把第 3 个 return 1 当成第 1 个)。

CodeCompanion 的答案:一条从严到宽的策略链,带置信度,命中即停,歧义就降级。

2. 思路:7 级策略,逐级放宽

核心算法在 strategies.luaM.find_best_match(strategies.lua:729)。它按固定顺序试 7 种策略(get_all_strategies,strategies.lua:535):

顺序策略置信度容忍什么
1exact_match1.0啥都不容忍,逐行精确(快路径)
2substring_exact_match1.0仅 replaceAll 且无换行:全文子串替换(改 token/关键字)
3whitespace_normalized0.95空格/缩进差异
4punctuation_normalized0.93标点差异(formatter 加的逗号等)
5position_markers1.0^/$/<<START>>/<<END>> 文件首尾插入
6trimmed_lines0.8逐行去缩进 + 相似度打分
7block_anchor0.6最后手段:用首行+末行当锚点,中间模糊

怎么读这张表:从上到下越来越宽容。 严格的先试,命中就停;只有当前级找不到「干净的赢家」才往下走。

3. 降级决策:命中、歧义、保底

find_best_match 的主循环(strategies.lua:740)对每个策略:

跑策略 → 过滤掉低于该级置信阈值的匹配


select_best_match 判断:
· 只有 1 个匹配 → 成功,返回
· replaceAll → 全返回
· 多个但有明显赢家 → 用最高分那个
· 多个且分数太接近(歧义)→ should_try_next,记下来当保底,降到下一级


所有级都试完仍歧义 → use_ambiguous_fallback 取保底里分最高的

「歧义」的判定很关键(select_best_match,定义在 strategies.lua:801):排序后,如果最高分和第二名的差 < AMBIGUITY_THRESHOLD(该比较在函数内的 strategies.lua:827),就认为「分不清谁是谁」,不在这一级硬选,而是降级——更严格的下一类信号也许能区分开。这就是注释说的「prevents false positives while ensuring edits eventually succeed」(strategies.lua:1)。

这段把「降级 + 保底」的骨架演出来(# 示意,非源码):

local fallback
for i, strategy in ipairs(strategies) do
local good = filter_by_confidence(strategy.run(content, old_text), strategy.min_confidence)
if #good > 0 then
local pick = select_best_match(good)
if pick.ambiguous then
fallback = fallback or good -- 记住第一个出现歧义的级,留作保底
if i == #strategies then return best_of(fallback) end
-- 否则继续降级
elseif pick.success then
return pick -- 干净命中,收工
end
end
end
return fallback and best_of(fallback) -- 全程歧义,用保底

4. 两个有代表性的策略

trimmed_lines(第 6 级,strategies.lua:177):带相似度的逐行打分。 它先 remove_common_indentation 去掉公共缩进,再逐行比:完全相等加满分,激进归一化空格后相等加高分,字符串相似度过中/低阈值加对应分(strategies.lua:228)。最后置信度是「总分 / 行数」。注意它满是性能护栏:文件过大截断、搜索文本过大直接放弃、迭代次数超限提前停(strategies.lua:190:217)——因为这是 O(文件行数 × 搜索行数) 的扫描。

block_anchor(第 7 级,strategies.lua:613):用首尾行当锚。 最后的兜底:从搜索块里挑出「有意义的首行」和「有意义的末行」(get_meaningful_anchor 跳过纯标点/过短的行,strategies.lua:436),在文件里找首行,按行距推算末行位置,核对末行是否对上;中间的行只做模糊相似度打分(strategies.lua:679)。哪怕中间全乱,只要首尾锚得住,也能定位到这个块。

5. 应用替换:行级 vs 字节级

定位到匹配后,apply_replacement(strategies.lua:851)分两种回填:

  • 行级(多数策略):按行号替换。多个匹配时从后往前改(start_line > b.start_line 倒序),避免前面的改动让后面的行号失效。
  • 字节级(substring_exact_match):按字节位置 start_pos/end_pos 替换,同样从后往前(strategies.lua:864)。

这个「倒序应用」是处理多点替换不串位的经典手法。

6. 改完不是直接写盘:diff 确认

匹配成功后,init.luaexecute_edit(insert_edit_into_file/init.lua:147)不直接写文件,而是走 diff.review(insert_edit_into_file/init.lua:164):把 from_lines(原内容)和 to_lines(改后)交给 diff UI,apply 回调里才真正 source.write。是否需要确认由 require_confirmation_after 控制(config.lua:259 默认 true)。

文件和 buffer 走不同的 source(make_file_source vs make_buffer_source,init.lua:73/:111):改的是已打开的 buffer 就走 nvim_buf_set_lines,改磁盘文件就走 io 写入——但匹配和 diff 逻辑共用。

还有一层鲁棒性:LLM 给的 edits JSON 经常半残,json_repair.fix_edits(init.lua:196)先尝试修复再解析。

7. 巧妙之处(可借鉴)

  • 置信度 + 歧义降级,而非「精确或失败」二元论——这是让编码 agent 的 edit 真正能落地的工程核心。
  • 每一级都有性能护栏(文件/搜索大小上限、迭代上限、单策略超时告警,strategies.lua:492),保证在大文件上不会冻住编辑器。
  • 保底而非放弃:即使全程歧义,也取最佳模糊匹配并标 fallback_used,把「成功率」拉满,再用 diff 确认兜住正确性。

8. 边界与局限

  • 文件上限 2MB、搜索文本上限 50KB(constants.LIMITS,见 init.lua:84);超限直接报错。
  • block_anchor 置信度只有 0.6,锚点选得不好可能定位错块——所以它是最后才用,且仍要过 diff 确认。
  • 多个高度相似的代码块(如重复样板),即便降级也可能选错;这时 diff 确认是最后防线。

9. 横向对比

各家编码 agent 都要解决「edit application」这道题。CodeCompanion 的取法是纯规则的多级降级匹配(无需额外模型),好处是确定、快、可离线;另一种主流取法是用一个「apply 模型」专门做合并。规则法的代价是策略链要手工调参(7 个阈值),好处是透明可调、零额外 API 成本。

10. 代码地图

主题文件符号
策略链主控…/insert_edit_into_file/strategies.luaM.find_best_matchM.select_best_match
精确/归一化策略…/insert_edit_into_file/strategies.luaM.exact_matchM.whitespace_normalizedM.trimmed_lines
锚点兜底…/insert_edit_into_file/strategies.luaM.block_anchorget_meaningful_anchor
回填替换…/insert_edit_into_file/strategies.luaM.apply_replacementapply_line_replacement
工具编排/diff…/insert_edit_into_file/init.luaexecute_editmake_file_sourcemake_buffer_source
处理多条 edit…/insert_edit_into_file/process.luaM.process_edits
JSON 修复…/insert_edit_into_file/json_repair.luafix_edits