
做仓库级代码补全的时候我第一个遇到的坎不是模型选型而是“上下文到底该给模型看什么”。你坐在编辑器前光标停在一个调用某个工具函数的位置这个函数的定义在项目另一个目录里。如果只把当前文件丢给模型它大概率会猜一个不存在的函数名如果把整个仓库都喂进去先不说成本光是那些无关的 import、注释、常量定义就足够把模型的注意力带偏。这就是“选择性检索增强”要做的事不是盲目扩大上下文而是沿着代码的符号依赖关系精准地把当前补全位置真正需要的那几个定义片段找出来、拼进去。这篇文章是我在“仓库级代码补全”方向上的一轮完整落地记录。内容围绕三块展开仓库级补全到底难在哪、为什么选择“选择性检索”而不是更流行的语义向量检索、以及我自己从零搭这套系统的详细步骤和踩坑实录。适合正在做AI编程助手、IDE插件、或者想把大模型接到真实代码库上的开发者看尤其建议那些已经被“上下文物料不够用”折磨过几轮的朋友重点读。1. 仓库级代码补全“难在哪”1.1 行内补全到跨文件补全的断层大多数代码补全工具做的还是“行内补全”根据光标所在行的左侧内容预测接下来要写什么。模型只需要看当前文件、当前函数、最多往前翻几十行就能有不错的发挥。但只要项目一复杂这种模式立刻失灵。举个例子项目的工具目录里定义了一个上传头像的函数函数名是uploadAvatar它接收一个File对象、内部调用了压缩逻辑。业务代码在另一个文件里写await upload这几个字母你希望补全系统能直接给出uploadAvatar(userId, file)。可当前文件里既没有 import 这个函数也没有任何关于“上传”语义的线索。行内补全模型看到的就是“await upload”它没有理由猜到仓库里还有一个叫uploadAvatar的函数。这种情况不是个别现象。我在一个有几十个源文件的模拟项目里统计过跨文件函数调用的数量大约占到全部函数调用的四成。如果补全系统对这部分调用毫无感知那用户的体验就是“补全经常对不上”只能被迫自己敲完整个函数名工具的价值直接砍半。1.2 直接塞上下文为什么走不通第一个想到的方案永远是“把上下文扩大”。有人想用长上下文窗口硬扛把项目中所有相关文件全部拼到一个提示词里有人想用 RAG 的思路把仓库切片、向量化检索 Top-K 片段再喂给模型。这两种路我都试过效果都不能让人满意。全量拼接的问题很直接令牌token数量爆炸。一个中等规模的仓库光源代码就可能几十万 token加上按目录拼接产生的重复 import、注释提示词很容易超出模型的处理上限。就算模型支持超长输入生成延迟也是按输入长度线性上涨的做交互式补全根本等不起。更隐蔽的问题在质量层面。模型看了一大堆无关代码之后会开始“过度联想”。我在测试里遇到过一个典型的翻车案例当前文件里写的是删除数据库记录的逻辑因为全量拼接的上下文里包含了一个文件读写工具模块模型补全结果直接给了一段文件删除的代码和数据库操作毫无关系。这些噪声不是无害的它们会实打实地把模型的生成结果拽向错误方向。所以仓库级补全的核心矛盾从来不是“上下文不够长”而是“怎么从浩如烟海的仓库代码里选出当前补全位置真正依赖的那一小部分”。这其实就是检索增强要解决的问题。2. 检索增强方案的选择过程2.1 先试过的两种检索路子我最早尝试的是基于语义向量的检索。把仓库里每个文件切块用嵌入模型算成向量等用户写代码的时候拿当前编辑位置的代码上下文去查相似块把最相似的几块拼进提示词。听着很合理用起来却处处别扭。嵌入模型对自然语言文本比较友好对代码里的符号名、参数列表、函数体反而不够敏感。我碰到过一种很尴尬的情况某个模块的注释里写着“删除图片文件”这个模块被检索模型判为高相似度被拉进了一个正在处理用户头像上传任务的上下文。结果模型在补全时优先考虑了这个和图片删除相关的片段生成了完全相反的意图。注释和字符串对语义检索的干扰比我想象中大得多。另外一条路是从调用链入手。写插件的时候记录用户的行为日志看看用户平时点了哪些文件、哪些函数把这个关系当成检索依据。这套逻辑在运行时或许能够跑通但补全发生在写代码的瞬间用户还没运行过程序根本不存在真实的调用链。静态场景拿不到动态数据不是技术难搞是数据源就不成立。2.2 为什么选了“选择性检索”试过两条路之后我回过味来代码补全需要的是“符号级相关”不是“文档级相似”。模型想知道的是uploadAvatar这个符号的定义而不仅仅是“某个和上传相关的文件”。这种符号级的相关性用语法树解析来拿最可靠。所谓“选择性检索”简单说就是以当前编辑位置的代码结构为入口找出当前代码中“引用了但没有定义”的符号再沿着这些符号去仓库里的其他文件里精确抓取对应的定义片段。选择的依据是语法依赖不是语义相似度。这个思路更像人写代码时的真实行为。你碰到一个不认识的函数第一反应是点进定义看签名你看到一个类先看看构造函数接收哪些参数。选择性检索就是把这个动作自动化了而且每一步选出来的内容都能解释清楚“为什么选它”排查问题非常方便。相比之下向量检索选出来的结果有时候人自己都说不清为什么相关落起地来很心虚。在工程实现上选择性检索也更轻。不需要部署向量数据库、不需要维护嵌入模型只需要一个能解析语法的正则工具链。成本可控、结果可预期。这就是我最终选它当主线的根本原因。3. 实操过程与核心环节实现3.1 整体流程先梳一遍把整套系统拆开其实只有五步解析当前编辑文件拿到光标附近的语法树在语法树里找出当前代码引用的外部符号函数、类、变量拿这些符号去仓库的符号索引表里查到它们定义所在的位置把定义位置的代码片段抓出来按依赖关系排序、裁减组装成上下文附在当前代码内容之前交给补全模型。这五步里用到的所有技术本质上都是静态分析。不跑程序、不依赖运行时日志、不需要后端服务常驻。后面几步其实都围绕一个核心工程点仓库符号索引。3.2 给仓库建符号索引索引是选择性检索的地基。没有索引每次补全都要全仓库扫一遍文件延迟高到你根本不想用。我为模拟项目建的索引结构不复杂用了一张表记录四类信息符号名、符号类型、定义所在文件、定义所在行号。另加一个字段记符号的“签名摘要”比如函数名、参数列表、返回值类型。对补全来说这些信息足以支撑后续决策。索引的构建依赖静态语法分析。我用的是一个通用的源码 AST 解析器这类工具很多机制大同小异它能把源码解析成一棵语法树然后从树里把FunctionDeclaration、ClassDeclaration、ImportDeclaration这些节点全部遍历出来。一个几万行的项目在本地机器上跑完一轮全量索引耗时控制在几十秒级别。后续编辑增量更新时只重新解析被改动的文件即可。索引表建好之后要解决的问题就是“当前文件引用了哪些外部符号”。这一步仍然靠语法树但这一次是实时解析当前编辑文件。3.3 从光标位置出发找缺失符号我把光标附近的代码段提取出来做一次轻量解析。解析结果里重点看三类节点函数调用表达式CallExpression、类实例化NewExpression、import 语句ImportDeclaration。对每个函数调用表达式取被调用方的名字当作候选符号。然后检查两个条件这个符号在当前文件里有没有定义包括 import 导入过的如果在当前文件里找不到对应的定义或导入就认定它是“缺失符号”放入待查列表。举一组伪代码逻辑其实很短def collect_missing_symbols(file_ast, current_symbols): missing [] for node in walk(file_ast): if node.is_call_expression(): callee node.callee_name() if callee not in current_symbols: missing.append(callee) if node.is_new_expression(): cls node.class_name() if cls not in current_symbols: missing.append(cls) return list(set(missing))这一步看起来简单实际调试中大量时间花在“判断符号是否在当前文件已定义”这件事上。同一个文件里可能有两个同名函数或者某个函数被局部变量遮蔽了真实逻辑比这段伪代码复杂得多。但对于第一版实现先跑通主路径最重要。3.4 依赖图遍历与上下文裁剪拿到缺失符号列表后去符号索引表里查每个符号的定义位置。查到的定义代码就是候选的检索结果。纯按缺失符号找一遍结果通常还是不够用。我举个例子当前代码调用了createUser这个函数定义在userService文件里它内部又调用了hashPassword而hashPassword定义在security.ts里。模型看到createUser的完整定义时会发现里面调了个hashPassword还是外部符号。为了让模型真正理解createUser在干什么有必要把hashPassword的定义也一并带上。这就成了一个依赖图遍历问题。从当前文件的缺失符号出发把每个符号的定义解析出来再检查定义内部是否还有进一步的外部符号有的话继续往外扩。我用广度优先遍历限制最大深度为 2 层每层最多取 5 个符号避免上下文膨胀失控。遍历完之后对抓到的每个定义片段还要做一次裁剪。函数定义可能很长好几屏都放不下但提示词空间有限。我的做法是把定义片段的函数签名、参数注释、前几行核心逻辑保留下来其余函数体截断。如果截断后语义不完整再放宽一点范围。这一版裁剪逻辑直接决定了最终提示词的信息密度。3.5 组装提示词最后一步是组装。我采用的提示词结构分三段仓库检索到的外部定义片段、当前文件的开头部分包含 import 列表、光标位置左侧的实际代码内容。外部定义片段在提示词里排最前面顺序按依赖优先级排列和当前补全位置直接相关的符号定义排最前间接依赖的排后面。每个片段前面都要加一个文件路径标记格式约定成# file: src/utils/security.ts。这样模型能清楚知道每段代码来自哪个文件降低跨文件符号的混淆概率。同一份提示词再附上几个“仅需补全此处的代码”这类简短指令其他交给模型自由发挥。这里有一个排序细节值得注意定义片段里函数的真实定义顺序往往被打乱了我按照“直接依赖优先、间接依赖靠后”重排了一遍比原始文件顺序效果要好得多。模型在生成时能先看到最相关的定义注意力分配更聚焦。4. 常见问题与排查技巧实录4.1 符号表不准怎么办静态解析天然拿不到运行时信息最典型的就是动态类型语言。例如某种动态脚本语言里很多类是在运行时用元编程方式生成的静态分析完全看不到。再比如装饰器一个函数被装饰器包裹后AST 上看到的符号名可能是装饰器返回的包装函数定义体却找不对。我的兜底策略分两层。第一层符号表里记录“可能定义”的多个候选位置查不到精确匹配时按命名空间模糊匹配选最近命名空间里的同名定义片段。第二层在上下文里加一个提示词说明“以下片段可能是该符号的定义但未完全确定仅供参考”。让模型在不确定时降低对这段代码的信任权重实测下来能减少胡编。4.2 上下文还是膨胀依赖图遍历控制深度之后还有个隐藏问题单个符号的定义函数本身可能极大。某些工具函数几百行还带着大段注释一个片段就够把提示词撑爆。对策是我在上面提过的“签名化压缩”对于检索到的定义片段默认只保留函数签名、顶部注释和前 20 行函数体如果这 20 行内还有未闭合的结构比如括号没配对再往后多取几行直到结构闭合。测试表明这种压缩后的片段在补全效果上基本不损失信息但提示词体积能降到三分之一。4.3 同名符号的歧义仓库里不同目录下很容易出现同名函数比如parse这个名字在三个模块里各有定义。补全时如果检索到了多个同名符号模型会晕头转向。解决方式依赖 import 分析。当前文件的 import 语句里如果已经导入了parse直接从导入来源精确定位到对应模块如果没有 import再检查光标附近的代码里有没有出现过某个模块的前缀引用。两个条件都不满足时把所有同名候选都放进片段里并分别标注完整文件路径让模型自己判断。绝大多数场景下前两种方式能直接命中。4.4 编辑器集成的延迟控制用户写代码的时候补全过程必须快。如果每敲一个字符就触发一轮全链路检索解析文件、查索引、遍历依赖图、裁剪片段、调模型延迟很容易到一秒钟以上这个体验根本没法用。我做了三件事来压延迟。第一给输入加了 200 毫秒的防抖debounce用户连续输入时不重复触发第二符号索引查询改用异步线程不阻塞编辑器主线程第三结果缓存按光标位置分片同一文件内移动时不重新检索只有跨越函数边界或打开新文件时才刷新缓存。这三步做完补全响应基本稳定在几百毫秒级别。4.5 问题速查表问题根因解决建议检索不到符号静态解析拿不到运行时定义模糊匹配候选位置增加“不确定”提示上下文过大函数定义太长、依赖展开过广签名化压缩、限制依赖深度和符号数同名符号冲突多个模块定义同名函数按 import 来源精确匹配、标注文件路径响应延迟高输入触发频繁、链路串行防抖、异步查询、按光标位置缓存补全内容偏离无关片段混入上下文检查缺失符号收集逻辑优先验证依赖关系这张表是后面调优时候的基础。每解决一个问题就把对应的排查步骤记录下来越到后面系统越稳定。5. 效果评估与几条关键经验5.1 评估方法我搭了一套简单的评估流程准备一批仓库级补全测试用例每个用例包含“编辑前的代码文件 光标位置 期望补全结果”。系统跑完一轮后对比模型生成的补全结果和期望结果计算行级匹配率。同时记录从触发到返回的端到端延迟。额外引入人工打分评价补全结果是否“看起来合理”因为在真实场景里合理的补全可能和期望结果不完全一致但依然有用。测试集选了一个有几十个源文件的中型模拟项目覆盖常见语言、工具目录、业务模块三种位置。最终行级匹配率对比基线只喂当前文件有明显提升人工打分稳定在“大部分合理”以上。数据不夸张说仓库级补全的体验比最初版本高了一个档次。5.2 几条关键经验经验一先做减法再做加法。我给提示词里加外部上下文的同时也在压缩当前文件里那些和补全无关的内容。比如当前函数上方一大段稀疏的注释、历史遗留的死代码该清就清。把当前文件的多余上下文清掉比多加几个外部片段带来的提升还明显。模型注意力是有限的喂进去多少垃圾就损失多少精度。经验二检索质量大于检索数量。早期版本我追求多检索把直接依赖、间接依赖、兄弟模块全部塞进上下文结果补全质量反而不升。后来收紧到“每个符号最多保留一个最优定义片段”效果立刻变好。符号定得准比符号多更有用。经验三不同语言差异很大。静态类型语言比如 Java、TypeScript的效果提升最明显因为符号定义和引用都非常清晰动态语言比如 Python、Ruby提升幅度相对小主要受制于静态解析的不确定性。如果项目里动态语言居多建议在设计检索策略时多留几个兜底路径。5.3 一点体会和近期计划这个方案从想法到跑通我花了不少时间最大的体会是做检索增强不能只想着“把相关的东西全部堆给模型”而是要替模型做一次“什么是当前最该看的内容”的判断。选择性检索这个名字里的“选择性”比“检索”两个字更关键。近期我在试一个有趣的改进方向把检索到的定义片段做一层轻量“签名化摘要”之后再合并而不是原样拼接。比如把函数定义、参数说明压缩成几行紧凑的结构化描述再附上原始代码片段作参照。初步测试里模型对这种结构更敏感生成准确率又有小幅上涨。等这个实验跑完我再整理一份新的记录出来。