数据截至 (上游 commit 6fb43a175495)
模糊改文件: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.lua 的 M.find_best_match(strategies.lua:729)。它按固定顺序试 7 种策略(get_all_strategies,strategies.lua:535):
| 顺序 | 策略 | 置信度 | 容忍什么 |
|---|---|---|---|
| 1 | exact_match | 1.0 | 啥都不容忍,逐行精确(快路径) |
| 2 | substring_exact_match | 1.0 | 仅 replaceAll 且无换行:全文子串替换(改 token/关键字) |
| 3 | whitespace_normalized | 0.95 | 空格/缩进差异 |
| 4 | punctuation_normalized | 0.93 | 标点差异(formatter 加的逗号等) |
| 5 | position_markers | 1.0 | ^/$/<<START>>/<<END>> 文件首尾插入 |
| 6 | trimmed_lines | 0.8 | 逐行去缩进 + 相似度打分 |
| 7 | block_anchor | 0.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.lua 的 execute_edit(insert_edit_into_file/init.lua:164)不直接写文件,而是走 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 给的
editsJSON 经常半残,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.lua | M.find_best_match、M.select_best_match |
| 精确/归一化策略 | …/insert_edit_into_file/strategies.lua | M.exact_match、M.whitespace_normalized、M.trimmed_lines |
| 锚点兜底 | …/insert_edit_into_file/strategies.lua | M.block_anchor、get_meaningful_anchor |
| 回填替换 | …/insert_edit_into_file/strategies.lua | M.apply_replacement、apply_line_replacement |
| 工具编排/diff | …/insert_edit_into_file/init.lua | execute_edit、make_file_source、make_buffer_source |
| 处理多条 edit | …/insert_edit_into_file/process.lua | M.process_edits |
| JSON 修复 | …/insert_edit_into_file/json_repair.lua | fix_edits |