现代代码补全的技术路线:从 LSP 到大模型补全

这一篇单独聊补全:IDE 里“按点一下点号出来一堆建议”这件事,底层到底走过哪些演进阶段,以及现在大模型补全是怎么和 LSP 一起工作的。

在 LSP 出现之前,很多编辑器的补全更多是“高级版的字符串匹配”:

  • 扫一遍当前文件/项目里的标识符,做一个简单的索引;
  • 当前光标前的前缀和这些标识符做匹配;
  • 按字典序/出现频率排序,列出候选。

再加上一些:

  • 固定模板(比如 foriftry/catch 的代码片段);
  • 关键词补全(语言关键字、常见内置类型)。

这种补全的特点是:

  • 完全不知道“当前位置语法上该出现什么”;
  • 不理解类型,也不理解上下文;
  • 但实现简单,对所有语言都能凑合用。

有了 LSP 之后,补全的底座基本变成了:

  • 编辑器只负责把“光标位置 + 文件内容”等信息发给 Language Server;
  • Language Server 基于语法树、类型系统、项目索引给出 CompletionItem 列表;
  • 编辑器根据这些 CompletionItem 渲染 UI、插入代码。

这类补全的能力明显上了一个台阶:

  • 知道当前位置是“类型位置”“表达式位置”“参数位置”等;
  • 知道有哪些符号在当前作用域可见;
  • 知道类型信息,可以只给出类型匹配的候选;
  • 可以带上文档、参数信息、重载签名等。

它的上限也很清楚:

  • 大多数建议是“已有符号”的组合,很少生成新的结构;
  • 跨文件时更多依赖静态索引,对“隐式约定”“业务语义”毫无感觉;
  • 对于动态语言(类型信息弱)时显得有点吃力。

大模型进来后,补全这件事多了一种完全不同的风格:

  • 输入不再只是“光标前的一点点上下文”,而是整段代码片段甚至多个文件;
  • 输出不再只是一个标识符,而是一整行甚至一整段逻辑;
  • 模型会模仿当前项目的风格:变量命名、错误处理习惯、日志格式等。

一个典型的大模型补全请求会包含:

  • 当前文件中光标附近的代码(前后若干行);
  • 相关文件的一些片段:接口定义、类型定义、调用方代码;
  • 明确的任务指示:比如“只补全当前行后面的部分”“从光标位置开始往后写几行代码”;
  • 语言/框架提示:当前是 TypeScript/Go/Java,使用了哪些框架。

模型的输出通常会经过一层 IDE 侧的处理:

  • 对齐缩进和风格;
  • 避免重复补全已经存在的代码;
  • 如果它产生了多行/多分支逻辑,按策略截断到合适的长度。

在实际 IDE 里,很少只用单一来源的补全。一个比较自然的分工是:

  • LSP 提供语法/类型级别的精准补全

    • 成员列表、方法签名、重写/实现接口等;
    • 保证“不会编译不过”,是一个安全下限。
  • 大模型提供上下文/风格感知的“整段建议”

    • 一句 SQL、一个完整的 if/for 逻辑、错误处理和日志等;
    • 根据已有代码推断你“打算怎么写”。

很多实现会把两路结果一起拉回来,在 IDE 里做一层排序和过滤:

  • 当前只是简短成员访问时(比如 obj.),优先用 LSP 的成员列表;
  • 当前在写复杂表达式或控制流时,优先展示大模型的整段补全;
  • 有些环境还会把“LSP 的候选”作为提示拼进大模型的 prompt,帮助模型做更精准的生成。

大模型的补全质量很大程度取决于“喂给它的上下文”:

  • 当前文件里:

    • 最近定义的变量、函数、类;
    • 光标附近的注释、Todo、类型;
    • 同一个函数/组件的前后代码。
  • 项目级:

    • 当前文件所在模块/包里的接口定义;
    • 被当前文件频繁调用的其他模块;
    • 最近打开/编辑过的文件。

如果没有精心筛选,很容易出现两种极端:

  • 上下文太少 → 模型补的东西风马牛不相及;
  • 上下文太多 → prompt 被挤爆,成本和延迟飙升。

所以一个成熟的补全系统,往往在模型调用之前有一层上下文选择/压缩逻辑

  • 基于路径和依赖关系选相关文件;
  • 只抽取函数签名/类型定义,而不是把整文件塞进去;
  • 对注释和文档做适度摘要。

Post-process 与“最后一道防线”

大模型给出的补全结果,在真正插入编辑器前通常还要过一层“体检”:

  • 语法快速检查:括号/引号是否闭合,缩进和当前文件保持一致;
  • 简单的 LSP 校验:
    • 插入之后是否马上产生明显语法错误;
    • 类型是否完全不可接受(例如把 number 赋给 string 很明显不对)。

在一些高要求场景里,还会:

  • 用小型的静态分析或单元测试对子补全做快速验证;
  • 如果模型结果明显不合理,就降级回传统 LSP 补全。

这样可以在“保持模型带来的生产力”的同时,尽可能降低“瞎补”“补坏代码”的概率。

从开发者视角看,现在的补全体验之所以比早年 IDE 强这么多,本质是:

  • 语法/类型这条链路(LSP)已经非常成熟,能保证下限;
  • 大模型在上面加了一层“语义 + 风格”的能力,把日常重复劳动大量自动化;
  • IDE 把这两者编排在一起,做了不少“脏活累活”:上下文筛选、结果排序、风格对齐、错误兜底。

了解这些细节之后,再看各种“智能补全”相关的配置和行为,就会更容易判断:

  • 哪里是“模型本身的问题”;
  • 哪里是“上下文没选好”;
  • 哪些场景应该更相信 LSP,哪些可以多依赖大模型来“帮忙多想一步”。