1. 为什么写context-mode:一个补全工具在大型项目里的翻车现场
如果你做过AI辅助编程方向的工具,一定会遇到这种场景:工具搭起来了,模型也接上了,跑demo的时候表现惊艳,一丢进真实项目里立刻就变得"又傻又瞎"——明明上一行才定义了一个变量,下一页代码就开始胡编一个不存在的函数名;明明报错信息就在终端里,模型却像完全没看到一样自顾自建议一段风马牛不相及的代码。
我最初做的是一个小型代码补全插件,思路很简单:把当前文件内容丢给模型,然后让模型预测接下来的代码。编辑器里写写小脚本、几十行的配置文件时,效果非常理想。但一旦面对几千行、跨越十来个文件的核心模块,模型就像短时记忆被塞爆的人一样,顾得了开头就顾不上结尾。问题不在模型本身,而在输入——我没有给模型提供它真正需要的"上下文"。
这个"上下文"不是单纯的字符拼接,而是理解当前代码在项目里的位置、依赖关系、调用链、相关符号和近期改动的能力。简单地把整个文件塞进去,是一种粗暴的下策;只传当前光标附近几行,又等于让模型盲猜。后来我在这个项目上重写了上下文处理模块,就是标题里说的context-mode,专门负责"在合适的时机,把合适的代码信息,以合适的格式喂给模型"。
写这篇文章,就是想把我在这个模块上踩过的坑、验证过的做法、以及最终沉淀下来的一整套方案讲清楚。如果你也在做AI编程工具、代码问答、或者任何需要理解代码仓库的自动化工具,这篇内容应该能省下你不少摸索时间。
2. 四个核心模块的拆解:采集、裁剪、排序、注入
2.1 采集层:从编辑器事件到结构化代码切片
context-mode要做的第一件事,是搞清楚"当前用户在做什么"。这个听起来简单,实际做起来有很多细节——用户可能正在修改某个函数,可能刚打开一个新文件,可能把光标停在一处尚未实现的TODO上,也可能只是滚动浏览代码。不同的行为,决定了工具需要注入哪些上下文。
我的做法是把采集层拆成三个部分:事件源、工作区快照和符号索引。
事件源负责监听编辑器的各类操作。这里有一个容易被忽视的原则:不要每次按键都触发全量采集,而是用带防抖的监听器,把用户停顿下来之后的稳定状态作为一次有效快照。比如用户连续输入代码时,输入事件会高频触发,如果每个事件都做一次全量文件解析,CPU占用会直接拉满。业界普遍的做法是延迟300到500毫秒,等用户不再输入后再执行采集。
工作区快照是采集层的核心数据结构。它不仅保存当前文件,还要记录当前工程里哪些文件被修改过、当前文件里有哪些符号与别的文件里的引用相关。比如用户改了utils/format.ts里的一个函数签名,那么所有引用了这个函数的地方都应该被标记为"受影响区域",这些区域的代码需要进入后续的上下文裁剪管线。
符号索引则是通过语言服务器协议(LSP)或者轻量级语法解析器来生成的。它负责回答一类关键问题:当前文件里的UserService从哪里导入?它的定义在哪一行?当前函数调用了哪些外部方法?这些信息比单纯的文件文本重要得多,因为模型看到import { UserService } from './services/user'只能知道一个名字,但看到UserService类的核心方法定义、构造参数和典型用法后,才能真正理解这个项目里UserService的语义约定。
你可以把采集层想象成一个图书馆管理员:用户只是问了句"某本书在哪",管理员不仅要找到那本书,还要顺手把书架上与它相关的参考书目、最近被借阅的记录、以及书中被密集标注的几页一起取来。这正是context-mode要的原始素材。
2.2 裁剪层:先按语言语法剪枝,再用阅读价值打分
采集回来的原始材料如果全部塞进Prompt,token预算很快会被打穿。以GPT-4级别的模型为例,上下文窗口即便有128K,扣掉系统提示和用户指令之后,留给代码的余量也就是几十K token。而一个中大型项目的核心模块,光是一个文件就可能达到几千行,更不用说关联文件的聚合了。所以裁剪层是context-mode里最花心思的部分。
我第一版做的是行级截断——按距离开光标的行数来保留代码,比如只保留前后50行。实测效果非常不理想,因为代码不是线性文本,一段被截断的函数往往缺失关键的分支逻辑和变量声明,模型看到一半函数,经常补出完全错误的另一半。
后来我改成了语法感知裁剪。具体分为两步:
第一步,用树的稳定解析方式生成抽象语法树(AST),把文件拆成结构化单元。函数定义、类声明、导入语句、条件表达式块、循环体,都会成为独立的节点。每个节点有自己的起止行号和父级关系。这样裁剪时不至于从某个函数中间一刀切断。
第二步,给每个节点打一个"相关性分数",按分数构成优先级队列。评分规则我做过多次调整,最终稳定下来的权重配置如下表:
| 评分因素 | 权重 | 说明 |
|---|---|---|
| 距离光标的行数偏移 | 0.35 | 越近的代码,与当前编辑意图越相关 |
| 是否在调用链上 | 0.25 | 当前函数直接调用的函数,优先级提升 |
| 是否被当前符号引用 | 0.2 | 当前操作涉及类、函数、变量,其声明处优先级提升 |
| 近期是否被修改 | 0.15 | 刚被改过的代码块,往往正处于调试/迭代期 |
| 是否包含TODO/FIXME | 0.05 | 只有前面因素都相当时才起作用 |
这里的调用链信息从哪里来?就是采集层生成的符号索引。比如用户当前光标在一个请求拦截器函数里,这个函数里调用了tokenStorage.get(),那么tokenStorage对象定义以及get方法的实现,即便距离光标几百行,也会被提到高优先级。
裁剪完成后,context-mode得到了一个有序的候选代码块列表。但到这里还没完——还需要一层更细粒度的处理:删除注释(如果注释对理解代码没有关键作用)、折叠空白字符、把完整的类型定义转换成简写形式。所有压缩手段加起来,通常能让代码体积缩减到原始的40%到60%,同时保留语义完整度。
2.3 排序与组装:如何让当前编辑位置排在第一名
裁剪之后,剩下的代码块可能还有几十个,但Prompt空间依然有限。排序问题就成了决定上下文质量的关键。我最开始没有认真设计排序策略,只是按文件名分组、再按行号排列,结果模型经常误解代码间的主次关系——它会把一个不相关的工具函数当作核心逻辑来分析。
正确的排序策略应该回答一个核心问题:在当前这个编辑时刻,模型最需要先看什么?
我的答案是三层递进:第一层是当前文件里围绕光标的代码片段,它是整个Prompt的主干;第二层是当前函数的完整签名、调用方代码、被调用的函数实现,它们构成立即相关的业务链路;第三层才是项目的全局约定,比如编码风格、依赖声明、配置文件的格式示例。
为了体现这种层级关系,context-mode在组装Prompt时使用了三种结构性标签:主代码块、辅助上下文和全局参考。每块代码在注入前都会被打上类似[CURRENT_SCOPE_BEGIN]这样的伪标记,让模型能明确区分"这段代码是我的主战场,其余材料仅供参考"。
组装顺序上,还有一个容易被忽略的细节:**当前编辑位置必须放在所有上下文的中间,而不是开头或结尾。**模型对序列两端的内容关注度更高,中间位置容易被稀释。把光标附近的代码放在Prompt中段,让前后两侧的辅助上下文自然形成"铺垫—核心—延伸"的节奏,实测下来比把主代码放在开头的理解准确率更高,尤其是涉及长函数和跨文件引用的时候。
2.4 注入层:用三种伪标签把结构化数据编码进Prompt
有了排序好的代码块,还剩最后一公里:怎么把它转换成模型友好的Prompt文本。
很多人会直接把代码块用markdown代码围栏包起来,丢给模型。这在简单场景下可用,但在context-mode里不行——因为我们需要传递的不只是代码文本,还有代码之间的结构关系、当前光标位置、用户修改的历史等信息。如果这些信息不加编码地混在代码里,模型很容易把它们当作代码本身的注释来处理。
我用的是伪标签编码方案。每一段被选中的代码块,注入Prompt之前会经过一个模板化包装:
<context-block type="definition" file="src/service/user.ts" symbol="UserService" lines="12-45" weight="0.82"> ...代码内容... </context-block>标签里的type字段标明这个块的类型:definition表示这是一个定义、caller表示这是调用方、dependency表示依赖项、recent-change表示这是一段近期修改的代码。file和lines帮助模型建立代码的空间位置感,weight虽然不直接作为模型输入参数,但会被模板用来决定裁剪边界,也就是在token溢出时优先丢弃低权重的块。
还有一类重要的信息是光标锚点。我会在Prompt中注入一个特殊标记<cursor />,放在当前编辑位置对应的代码块中间。这样模型能明确知道"用户正在此处继续输入",而不是认为代码已经写完。实测这个标记对补全质量的影响非常显著——没有它,模型经常把光标后的代码当作已存在内容,从而生成重复代码或者忽略待续写的语义。
组装后的Prompt形态大致是这样:先放一段简短的指令(告诉模型当前任务是什么,比如"分析这段代码存在的问题"或"续写该函数实现"),再按排序注入上下文块,最后放置当前编辑区域的代码和光标锚点。这套结构沿用至今,在多个项目里被验证稳定。
3. 工程实现里的关键权衡:token预算、窗口策略和同步细节
3.1 token预算怎么分:留多少余地给模型的输出
context-mode在运行时需要动态估算即将消耗的token数。这里我遇到过不少尴尬情况:设计时给上下文预留了30K token,结果模型回答刚开了个头就被截断,因为输出token也占用同一窗口。
最终采用的分账户策略是这样的:假设模型总上下文窗口为64K,系统提示和指令固定占4K,模型输出预留10K到12K,那么剩给上下文的额度就是48K左右。如果裁剪后的候选集估算超过48K,就按权重从低到高丢弃,直到满足预算。同时,估算token不能简单用"字符数除以4"来计算,中英文混排和代码符号会带来很大偏差。我后来直接用预置分词器跑一遍获取精确token数,虽然多花几十毫秒,但稳定性好很多。
这里还有一个经验的细节:代码类内容的token密度普遍比自然语言高。一段1000字符的代码,实际token可能在300到400之间,而英文若大约250到300。不同语言又有差异,Python相对紧凑,Java因为类型声明多而更耗token。这类差异会在长上下文场景下被放大,建议按语言设定不同的估算系数,而不是统一处理。
3.2 窗口策略:一次调用吃下全部上下文,还是拆成多轮
在context-mode的目标场景里,代码库是持续变化的。最直观的做法是每次请求都重新采集、裁剪和组装全部上下文,这在文件较小、请求频率低的场景下没有问题。但如果你把工具接到编辑器的实时补全上,每次键入都全量重建上下文,性能就会成为灾难——解析一个5000行的文件并跑完整裁剪管线,耗时可能到几百毫秒,补全的体验就毁了。
我采用了两级窗口策略:
第一级是常驻窗口,维护当前文件的关键结构化信息,包括AST、符号索引和最近修改的代码块。文件保存或大段编辑时,才会触发增量更新。
第二级是请求窗口,只在用户明确触发补全或提问时,根据常驻窗口里的快照执行裁剪、排序和组装。请求窗口的构建可以复用上一步的排序结果进行缓存,避免重复计算。
在这之上还加了一层上下文延续机制:如果同一个会话里用户连续发起多次请求,context-mode会保留上一轮的关键上下文ID,并对更新的代码块做差异对比,把差异部分合并进新一轮的上下文集。这样模型能看到"用户上一轮问过X,现在改到了Y"的连续变化,而不至于每轮都从零开始理解。
3.3 同步细节:编辑事件与索引更新之间的时序处理
这是整个模块里最容易出bug的部分。编辑器的文件内容、磁盘上的文件快照、以及context-mode内部维护的符号索引,三者之间天然存在时序差。你可能刚更新完索引,用户又在文件里插入了一段新代码;也可能索引还没构建完,请求已经进来了。
我踩过最深的坑是缓存过期问题:一度用了带TTL的缓存来避免重复解析,结果TTL设得太长(10秒),用户连续修改文件后,context-mode喂给模型的仍然是不久前但已失效的代码,导致模型的建议基于过时逻辑展开,产生了大量无效输出。
后来我改成版本号驱动的同步机制:每次文件变更事件触发时,递增该文件的版本号,并异步重建相关索引。任何请求到达时,先检查当前文件版本号与索引版本号是否一致,不一致则强制刷新后再组装上下文。虽然极端情况下会有一个短暂的重建等待,但至少保证了喂给模型的数据一定是新的。
另一个细节是同时编辑多个文件的情况。用户在修改函数A时,可能同时改到B文件里调用A的代码。context-mode需要感知到跨文件的关联变化,并把这些变化合并进请求上下文。我的做法是维护一张"跨文件引用依赖图",每当某个文件的符号变更时,把依赖它的文件标记为"受影响",请求组装时主动包含受影响文件的相关片段。这个机制单独写起来不复杂,但和版本号同步机制叠加时,时序逻辑会变得很绕,建议画清楚状态机再动手编码。
4. 实测效果与踩坑记录:从"答非所问"到"记住上一行"
4.1 三组对照实验:全文件、线性截断和context-mode
为了验证context-mode的真实收益,我在一个模拟项目X上做了三组对照实验。这个项目是一个中等规模的后端服务代码库,约两万行代码,包含路由、服务层、数据访问层和大量跨模块引用。
第一组是基线组,直接把当前文件全文塞进模型,不做任何裁剪。第二组是线性截断组,取光标前后各50行。第三组是context-mode完整管线。三个组用同一批任务来评估,任务包括:解释某个函数的副作用、定位某个bug的可能根因、以及续写一个缺失的分支逻辑。
结果差异非常明显:
| 任务类型 | 全文件组 | 线性截断组 | context-mode |
|---|---|---|---|
| 函数作用解释(准确性) | 61% | 54% | 89% |
| Bug根因定位(命中率) | 47% | 39% | 76% |
| 分支续写(语义匹配度) | 55% | 48% | 83% |
| 平均响应延迟(毫秒) | 1830 | 420 | 780 |
全文件组表现平庸的原因不是模型能力不够,而是大量无关文件内容占据了注意力资源——它被淹没在无关模板代码、配置声明和页面结构里。线性截断组延迟最低,但准确性最差,因为局部代码脱离上下文后,信息严重不足。context-mode在准确性和延迟之间取得的折中是最理想的。
4.2 三个印象最深的坑:注释污染、符号误判和缓存旧数据
第一个坑是注释污染。代码里的注释对模型理解逻辑有时是正面帮助,但很多项目里的注释是过时的,甚至与代码本意完全相反。context-mode早期会把所有注释原样传入,结果模型被过时注释误导,给出的建议反而偏离了实际代码逻辑。后来我在裁剪层的压缩环节加入了注释分析:只有描述函数用途、参数含义、业务约束的注释会被保留,明显与代码状态不符的注释会被过滤掉。这个过滤本身又是一层逻辑,需要谨慎处理,不能全部砍掉。
第二个坑是符号误判。符号索引在解析动态语言时会遇到很多"看起来像定义但其实不是"的情况。比如Python里一个类属性被动态赋值,LSP返回的符号信息可能指向一个赋值语句而非真正的类型声明。如果context-mode把这类符号的上下文当作核心定义注入,模型理解就会跑偏。我的处理是建立一层"符号可信度"评分,对于那些通过赋值、回调、装饰器间接产生的符号,降低其在排序阶段获得的权重优先级,避免它们抢占核心上下文名额。
第三个坑就是前面提到的缓存旧数据。这个问题的典型表现是:用户改完一个函数后立刻发起提问,context-mode却仍然基于旧的AST来组装上下文,模型给出的建议包含已经被删除的参数名。因为机器无法自动知道"用户已经改了文件",除非你有明确的版本号对比机制。这个体验型bug非常伤产品口碑,我从那次之后就把"版本号一致性校验"列为这个模块的强制要求。
4.3 印象最深的一次修复:跨文件联动导致的重复代码
有一个需求是修复一个历史遗留的接口参数错误。用户在api/client.ts里把某个函数的参数结构改了,但他真正调用这个函数是在service/order.ts里的一个深层方法中。context-mode最开始只注意到了当前文件api/client.ts的变化,把旧参数结构对应的调用点当作辅助上下文注入,结果模型建议在service/order.ts里补一段新逻辑,这段逻辑与现有代码高度重复。
根因是跨文件引用依赖图在改动发生时没有被正确地标记为受影响,因此另一文件的相关片段虽然进了候选集,但权重被排得很低,模型没有足够重视。修复方式并不复杂——在依赖图里增加对"调用点对齐"的处理:当某个函数的签名变更时,所有调用点代码块的权重自动加一层加权修正,即便它们距离很远。这个修正让模型能看出"函数签名变了,调用点还没跟上",从而给出迁移建议而不是重复生成。这类跨文件场景,恰恰是context-mode相比简单文件拼接最大的价值所在。
5. 还能往哪走:context-mode的后续演进
5.1 从规则打分升级到语义索引
当前的排序策略主要依赖距离、调用链和修改时间这些规则信号,它们可靠但上限有限。比如一个工具函数虽然离光标很远、也不在调用链上,但如果它的实现方式与当前代码的模式高度一致,其实对模型理解很有价值——这需要语义层面的相关性判断。
一个自然的演进方向是引入代码向量索引。把代码块通过嵌入模型转换成向量,在请求时用当前编辑位置的向量做近邻检索,找出语义上相似或互补的代码块,再与规则排序的结果做加权融合。这样既能保留规则信号的稳定性,又能补上语义层面的灵活性。
不过要提醒的是,代码嵌入本身有成本,中型以上仓库的全量索引构建需要时间和存储开销。在工程落地上,建议把语义检索作为第二层召回机制,只有当规则排序的结果命中置信度不足时再触发,而不是每次请求都全量检索。
5.2 与长对话记忆的融合
context-mode目前的设计是面向单次请求的,每次组装上下文都基于当前状态独立生成。但实际使用中,用户经常会连续发起多个问题:"这个函数为什么会报错?""那如果改成异步呢?""那对性能会有什么影响?"——这类连续对话中,前几轮的结论对当前问题有强指导意义。
一种做法是把前几轮的关键代码块和模型回答摘要作为额外的上下文块注入,保持整个会话的连贯性。另一种做法是维护一个"会话关注点列表",把用户连续提问中反复出现的符号、文件、代码模式提取出来,在后续轮次中主动提高它们的权重。两者可以并行实施。
5.3 针对大仓库的分布式预计算
当代码仓库规模达到几十万甚至上百万行时,context-mode的常驻窗口策略需要做进一步改造——单进程内构建全量索引不再可行。一个可行的架构是把采集、索引计算放到独立的后台服务中,每次请求只做"查询已建好的索引 + 局部上下文组装",让延迟从百毫秒级进一步降至几十毫秒级。这个方向本质上是在做的,就是把这个上下文模块从编辑器内的轻量实现,演化成仓库级的基础设施能力。
就我个人而言,这个项目的下一版核心迭代点就是语义索引和会话记忆的融合。因为从实际使用反馈来看,当context-mode准确理解了一个大型代码库的结构之后,用户下一个自然需求就是"让它记住我们刚刚讨论过的那些结论和取舍",而不是每次提问都从头解释一遍来龙去脉。这两种能力的组合,才是真正接近"熟悉这个项目的结对程序员"的状态。