行内补全:实现思路与关键细节(FIM + Diff + 多层过滤)

想把 “行内补全” 做得好用,核心不是 “能生成”,而是生成后如何稳定落到光标处:不重复、不乱插、不无限循环、单行 / 多行行为符合预期。


先看结论

  • 使用标准 FIM(Fill-In-the-Middle) 流程:prefix + suffix -> middle
  • 内置模型:Pro/Qwen/Qwen2.5-Coder-7B-Instruct(通过统一的 "FIM" 模型名对外提供)
  • 非流式请求拿到完整输出,但内部用 “类流式管道” 逐层过滤(字符级→行级→后处理)
  • 单行补全额外做 Diff:自动判断 “插入” 还是 “替换到行尾”,避免括号 / 后缀重复
  • 多行补全不做 Diff:直接替换到行尾(更符合用户预期,也省成本)


1) 整体架构(从 VSCode 到最终插入)

流程上分 6 段:预过滤 → 分类 → 模板 → 调用与过滤 → 后处理 → Diff(单行)

InlineCompletionProvider
  -> 预过滤(跳过不该补全的场景)
  -> 构建 prefix/suffix(HelperVars)
  -> 单/多行分类
  -> FIM 模板渲染
  -> 调用 FIM API(非流式拿完整结果)
  -> 过滤器管道处理(字符级/行级/后处理)
  -> 单行:Diff 决定 range;多行:替换到行尾
  -> 返回 InlineCompletionItem


2) FIM 请求格式与参数

FIM 模板(Qwen 标准)

<|fim_prefix|>{prefix}<|fim_suffix|>{suffix}<|fim_middle|>

关键约定

  • API 模型名统一用:"FIM"
  • 请求方式:非流式(一次性拿完整结果)
  • max_tokens:单行 64、多行 128(策略性限制 “胡乱续写” 概率)


3) 预过滤:哪些场景直接不补全

目的:便宜地跳过 “补全只会添乱” 的场景,减少无意义请求。

常见跳过项:

  • 配置文件 / 特殊文件模式
  • 未命名空文件
  • 多光标(multi-cursor)
  • 其它明确不应触发 inline completion 的场景


4) 单行 vs 多行:分类策略(决定用户体验)

分类逻辑按优先级(越靠前越强):

  1. Intellisense 选中项:强制单行(避免和补全列表打架)
  2. 单行注释检测:强制单行(注释里多行补全通常噪音大)
  3. 语言特定规则:按语言做微调
  4. 默认:允许多行


5) 过滤器系统:三层拦截,解决 “能生成但不好用”

即使是非流式输出,也可以把 “完整文本” 当成流来处理:逐步截断、跳过、清洗。

LLM 完整输出
 -> 字符级过滤(stop token / suffix 重复 / 首字符换行)
 -> 行级过滤(行数限制 / 重复行 / 相似行 / 空注释 / 双换行)
 -> 后处理(空白、重复上一行、极端重复、去 markdown backticks)
 -> 最终补全

5.1 字符级过滤(更早、更快止损)

典型能力:

  • Stop Token 检测:遇到终止标记立即截断
  • 后缀重复检测:避免生成 “补全 + suffix + 继续乱写”
  • 首字符换行过滤:不让补全以空行开头(体感很关键)

5.2 行级过滤(防循环、防抄下一行)

典型能力:

  • 行数上限:例如超过 50 行直接停止
  • 重复行检测:同一行反复出现(模型陷入循环)就停
  • 相似行检测:生成内容与 “光标下一行” 相似度过高就停(防重复已有代码)
  • 空注释过滤:只输出 //# 这种空注释行直接丢弃
  • 双换行过滤:控制空行数量,避免 “稀碎的大段空白”

5.3 后处理(最终把关)

典型规则:

  • 空白补全直接拒绝
  • 重复上一行(基于 Levenshtein 相似度)直接拒绝
  • 极端重复模式(例如 6 行以上重复)拒绝
  • 去掉模型偶发输出的 Markdown 代码块标记(```)


6) 单行 Diff:解决 “补全重复后缀 / 括号” 的关键

问题本质

FIM 模型对单行位置常见三种输出:

  1. 纯插入:只生成新内容
  2. 重复后缀:把光标后的文本也生成了一遍
  3. 部分重叠:生成内容与后缀部分重叠

如果不处理,最常见的坏体验就是:多一个括号、多一段重复 token

解决方式(单行专用)

diffWords() 比较:

  • currentText:光标后到行尾的已有内容
  • completion:模型给的最后一行补全

然后根据 diff 模式判断:

  • 该不该只 “插入”
  • 还是要 “替换光标到行尾”(即设置 range

Range 语义(VSCode 行为)

  • range = undefined:在光标处插入
  • range = { start: cursor, end: lineEnd }:先删除光标到行尾,再插入(用于清理重复后缀)


7) 多行为什么不做 Diff,而是直接替换到行尾?

多行补全的期望更像 “接管后续内容”,而不是 “精确对齐每个 token”。

选择 “替换到行尾” 的原因:

  • 多行 Diff 成本高,收益不稳定
  • 多行重复后缀概率相对低(且过滤器已拦截一部分)
  • 用户更希望多行补全能 “把后面那段写完”,而不是半插半改


附:代码组织(给想看源码的人)

关键文件(按职责):

  • VvCompletionProvider.ts:主入口,串起 6 阶段流程
  • vvCompletionStreamer.ts:API 调用管理(非流式)
  • vvAutocompleteTemplate.ts:FIM 模板
  • prefiltering.ts:预过滤
  • multiline.ts:单 / 多行分类
  • filters.ts:后处理
  • processSingleLineCompletion.ts:单行 Diff
  • streamFilters/:字符级与行级过滤管道

📌 转载信息
原作者:
allen_zhang
转载时间:
2026/1/1 15:46:53