context-mode:基于调用链构建高质量上下文,解决AI编程上下文不足问题
如果你也搞过 AI 编程助手八成遇到过这个场景问一个跨文件的函数调用链助手答得含含糊糊你为了让它“看见”相关代码只能手动把文件一段段贴进上下文。我做了一个叫 context-mode 的小工具专门解决这种“该给的上下文没给齐”的问题。它不是把整个仓库塞给模型而是从你当前关心的函数出发自动把调用链、关键定义和依赖关系按预算组装好组织成适合喂给大模型的文本块。这篇文章会讲清楚 context-mode 是什么、能做什么、为什么我选择这样设计以及我在落地过程中踩过的坑和调出来的经验参数。适合两类人看一是想在终端里提升 AI 辅助编码效率的开发者二是正在做上下文管理工程化、想给 LLM 应用接“记忆”的同学。两种视角下这篇文章都能给你一套可以直接抄作业的思路。1. 为什么我要做 context-modeAI 辅助开发里最尴尬的空白1.1 上下文窗口不是越大越好几乎每次聊到 AI 辅助编程都会有人提“上下文窗口已经很大了直接把整个文件都塞进去不就行了”。这句话我只同意一半。窗口大只代表模型能接受的输入上限高不代表它真的能把所有内容都同等重视地利用起来。Transformer 的自注意力机制天然对长文本存在注意力稀释信息埋在一堆无关文件里时模型很容易关键时刻抓错重点。我实测过一个很简单的场景把一个 1000 行的文件原样贴进去问一个局部 bug和把相关的三四个函数引用链整理成 500 行再问前者经常给出泛泛而谈的答案后者反而能直接指到问题行。这说明上下文质量远比上下文数量重要。context-mode 做的核心事情就是控制质量只把和当前问题真正相关的代码结构抽取出来而不是做整段搬运。1.2 现有方案的差距检索是一回事上下文是另一回事不是没有现成方案。代码库检索工具已经很成熟RAG 能把相似代码块捞出来IDE 的 AI 插件也会主动带上当前打开的文件。但这些方案都有一个共同的问题它们给模型的“上下文”不是围绕问题的因果链来组织的。拿 RAG 来说向量召回本质上是在做语义相似度匹配它擅长回答“和这段代码功能类似的东西在哪儿”但它不擅长回答“这个入口函数执行时到底涉及到哪些文件的哪些函数”。因为调用链这种东西不是靠文本相似度能算出来的。IDE 插件则更尴尬它们能拿到当前文件的符号信息但对跨文件的依赖关系往往只做一层查找再深就断掉了。更深层的空白在于没有人在“上下文供给”这个环节里做取舍。到底该带函数体带调用方带被调方还是带全局定义这些决策以前都是模型自己在死记硬背开发者只能手动传参。context-mode 相当于把“人类读代码时的直觉”固化成了规则让上下文供给有了结构和优先级。1.3 我给 context-mode 定的三个目标做这个工具之前我先给自己框定了三个必须达到的目标后面所有代码和设计都围这三个点转第一从任意文件、任意函数出发我一条命令就能拿到当前问题的结构化上下文不需要自己判断要贴哪些文件。第二上下文要跟着真实调用关系走不能只是相似文本的堆叠得能顺着 import、函数调用、类型引用一层层展开。第三输出要干净能直接粘进 ChatGPT、Claude、本地模型对话窗口也可以作为自动化接口的输入。这三个目标听起来不复杂真正做起来踩了不少坑核心难点主要在“上下文怎么算出来”和“扩到什么程度为止”这两件事上。2. context-mode 的核心设计把“上下文”当成一等公民2.1 Context Selector先选逻辑单元再选文件大多数人处理上下文的方式是“选文件”比如我打开 handler.ts就把 handler.ts 全贴进去。context-mode 换了一种思路我把它叫做“先选逻辑单元”。什么是逻辑单元函数、类、接口、常量定义、类型声明这些都属于逻辑单元。工具先在语法树里找到当前光标所在函数再以这个函数为圆心向外扩展。这个区别很重要同样是 200 行代码“一个完整函数加上它的直接依赖”和“两个文件各取前 100 行”虽然文本量差不多但信息密度差出好几个量级。做这个选择器的过程让我意识到编辑器里所谓的“mode”其实是关于“当前操作的最小有效范围”的约定。文本模式下最小范围是一个字符行模式下是一行context-mode 里最小范围就变成“一个完整的逻辑单元及其必要依赖”。2.2 三种上下文模式slice、chain、globalcontext-mode 这个名字里既然带有“mode”说明它不该只提供一种粒度。我最后定的是三档模式分别对应三种不同强度的上下文需求。模式范围典型场景输出量级slice当前函数/类自身局部 bug 排查、问一个函数的作用几十到几百行chain从当前逻辑单元沿调用链扩散跨文件问题、重构影响分析几百到一两千行global在 token 预算内覆盖所有相关符号架构级问题、找到根因但不确定位置预算内尽量满slice 模式最简单就是把光标所在的函数体连同函数签名、所在文件路径、import 一起取出来适合当前文件里的快速问答。chain 模式是 context-mode 的主力模式。它会从当前函数出发构建调用图和依赖图一层层往外扩展。扩展过程中不是盲目地把所有文件都拉进来而是按照依赖权重排序优先放直接调用、同文件内互调、有 import 关系的符号然后才考虑间接引用的类型定义。global 模式则更像一个“在预算内做全局寻路”的模式。它适合这种问题“为什么这个接口的返回结果老是和预期不一致但我不知道根因在哪一层。”这时候它会把入口函数、所有相关调用链、用到的类型和常量尽量都塞进预算让模型自己从头到尾捋。2.3 为什么不用全仓库索引加向量召回可能有同学会问为什么不直接对全仓库做向量索引然后让模型检索我在 1.2 里已经说过了检索和上下文的差异再展开一层。向量召回适合回答“哪里有相似代码”不适合回答“这条执行路径依赖了哪些代码”。举一个例子handler 里调用了 service.getUserservice 里再调用 repository.findUserById最后用了 utils.encrypt。如果向量检索你搜索“加密逻辑有问题”返回的可能是 utils.encrypt 这段代码本身但不会告诉你它到底是在哪条业务路径下被触发的。context-mode 不一样它从 handler 入口出发把 getUser、findUserById、encrypt 这一整条链全部收进来问题和答案之间的因果就通了。所以我最终没有给 context-mode 加向量检索而是用了语法树加图遍历。这不是说 RAG 没用而是它解决的问题和 context-mode 解决的问题不在同一个层面。3. 实现拆分这些概念如何落到代码里3.1 用 tree-sitter 建立符号索引第一步要做的是让工具认得代码结构。我用的是 tree-sitter它对主流语言都有成熟的语法解析器能给出准确的语法树还能做增量解析。在初始化时context-mode 会对指定目录下的代码文件做一次扫描提取四类信息函数、类、接口、方法定义的位置和起止行号函数之间的调用关系通过匹配函数名和调用点变量和常量定义尤其是跨文件引用的导出符号import / require / include 语句的文件依赖关系这一步跑完之后整个代码库在 context-mode 内部就变成一个关系图节点是逻辑单元边是调用和引用关系。为了方便调试我会把索引结果导出成一份 JSON文件路径、行号范围、符号名、依赖列表都清清楚楚。工具启动时只加载索引文件并不需要每次请求都重新扫一遍全仓库这是能保持秒级响应的关键。3.2 调用图扩散算法和剪枝策略拿到关系图之后接下来的核心算法就是扩散。我第一版直接把所有可达节点全部贪婪地收进来结果 token 直接爆掉。后来重新设计成带预算的受控扩散思路和看板上的任务安排一样先看最紧急的依赖再看可暂时放一放的依赖预算不够就先砍掉。伪代码大概是这个样子def expand(start_symbol, graph, mode, token_budget): queue deque([(start_symbol, 0)]) visited set() collected [] used_tokens 0 while queue and used_tokens token_budget: symbol, depth queue.popleft() if symbol in visited or depth max_depth[mode]: continue visited.add(symbol) snippet render_symbol(symbol) if used_tokens snippet.tokens token_budget: continue collected.append(snippet) used_tokens snippet.tokens neighbors graph.neighbors(symbol, modemode) for n in sorted(neighbors, keyweight_score, reverseTrue): if n not in visited: queue.append((n, depth 1))这个算法的取舍逻辑很有意思weight_score不是简单的调用次数而是综合了三个维度——是否同文件、是否有直接 import、以及是否属于类型定义。同文件的函数权重最高因为它们的耦合往往比跨文件更强直接 import 的文件次之类型定义虽然暂时不产生执行路径但少了它模型容易理解错误所以也会给一个基础权重。真正的剪枝发生在两种节点上。一是离入口过远的节点比如深度超过 3 的间接工具函数除非 token 预算特别宽裕否则先不加。二是体积大但信息密度低的节点像整份配置文件、超长的常量表我会压缩成摘要而不是全文。3.3 Token 预算控制宁可截断不可溢出LLM 调用最怕的就是输入超限真等 API 报错再回退就太晚了。所以 context-mode 在组装上下文前会先估算 token。估算不靠数空格而是用语言相关的经验值CJK 大约每字符 0.6 到 1 token英文代码大约每 3 到 4 字符 1 token注释按普通文本计。我内部给了一个token_budget参数默认值是 6000。这个数字不是随便拍的它是我反复试过的一个平衡点对大多数代码问答场景6000 token 足够覆盖一条完整调用链同时又不会把模型输出空间压得太小。在最终发给模型的 prompt 里我会把这 6000 token 再拆成两块90% 放代码上下文10% 放任务指令和预留输出。如果扩散过程中发现预算触顶处理顺序很关键先删除距离远但工具函数不重要的叶子节点再对超长文件做截断最后才考虑对中间层函数做摘要。这个顺序保证了“核心链路完整”至于背景信息少一点影响不大但主干不能断。3.4 命令行接入方式和输出格式我不打算把它做成一个需要打开特定 IDE 才能用的工具命令行的自由度更高。实际使用时的命令大概是这样的context-mode --mode chain \ --file src/event_handlers/on_click.ts \ --line 42 \ --question 为什么这里拿到的 userId 会是 undefined工具会先解析指定文件在 42 行的函数然后按 chain 模式扩散最后生成一份结构化的上下文文本输出到 stdout。输出的格式我用的是普通 Markdown里面按文件路径分段每段先写清楚文件相对路径和符号名再贴代码片段。这样不管我是把它粘到对话窗口还是交给下游脚本继续处理都很方便。我还会顺手生成一行“上下文统计”涉及几个文件、几个函数、估算 token 数。这行信息对调试非常有用能直观看到究竟什么东西被放进去了。4. 实测效果跨二十个文件的 bug 定位一次说清4.1 对比数据开启和关闭 context-mode 的差距口说无凭我整理了自己在一个中型 TypeScript 项目里的实测数据。项目一共四十多个源文件涉及事件处理、服务层、数据访问层、公共工具库。我挑了 10 个真实的修复任务每个任务先直接问模型再用 context-mode 开启 chain 模式问一遍记录结果。指标直接提问context-mode 开启一次答对的次数2 / 107 / 10平均交互轮数7.22.1单次任务平均 token 消耗128009400定位到根因文件/函数的准确率稀疏经常指向错误模块稳定指向 1-2 个候选文件最夸张的一个任务本来要跨 20 个文件排查开启 context-mode 之后一轮就锁定了根因。这里我想多说一句token 消耗降低不是最关键的好处关键是“给模型的语义噪声”变少了模型不是在 20 个文件里大海捞针而是顺着调用链线性阅读。4.2 还原一个实战场景从入口到根因的完整链路我拿一个真实修过的 bug 做例子。现象是用户点击“导出报表”按钮后前端只收到空数据后端日志完全没有报错。直接问模型“为什么导出报表返回空”模型通常会猜可能是数据库查询结果为空可能是接口参数没传对这些答案听着合理但都没踩到真正的坑。context-mode 开启后我从onClickExport这个入口函数出发自动抓到了下面这条链路onClickExport.ts调用了reportService.generatereport.service.ts先调用checkPermission(userId)通过后才执行generateReportDatapermission.util.tscheckPermission里用了accountStatus这个常量constants.tsaccountStatus被一个环境变量初始化但环境变量在测试环境没配真正的根因是constants.ts里初始化状态时把未定义的环境变量处理成了“禁用”而checkPermission依赖这个状态直接中断了后续逻辑。如果没有链式上下文模型很容易停留在猜测数据库查询问题的层面。有了 context-mode模型能看到“权限状态判断”这层逻辑根因就藏在这条看起来不太起眼的支线上。4.3 没预料到的连锁反应上下文少了幻觉也少了这是我做这个工具前没想到的收益。注入的上下文太多样、太松散时模型反而容易把不相关的内容也当作线索强行编出一个解释。context-mode 把上下文集中到一条真实存在的执行路径以后幻觉明显减少。原因也不难理解模型在推理时会更依赖“已提供代码”而不是“自己的知识加猜测”。当你把入口、中间层、底层依赖都按顺序摆好它更像是在读一段有逻辑的代码而不是在回答一个开放题。至少在我自己的使用体验里回答的“可信感”明显提升了。5. 踩坑记录与调优经验5.1 坑一递归引用把 token 烧光了第一版 chain 模式刚跑起来我拿项目里的一个服务类做测试结果一次请求直接生成了 18000 token远远超出预期。查了半天发现两个类相互引用A 里调用 BB 里调用 A扩散算法疯狂把这两个文件来回加入预算鞭长莫及。解决办法就是 3.2 里的visited集合加上深度上限。其中visited解决的是环形依赖问题深度上限解决的是“合法但没必要”的深层链路问题。我最后把 chain 模式的默认深度上限设成了 3global 模式放到 5再往上走收益就很低了。这个坑让我明白一件事上下文工具不是越全越好它要像人读代码一样有耐心但也得知道何时该停。无限递归是一个工具最容易犯的错也是设计上必须提前防住的一环。5.2 坑二静态索引赶不上动态改动代码永远是动的。我一开始是全量扫描建立索引然后就直接用索引做扩散结果经常给出过期内容某个函数已经被删了调用链里还留着它的影子某个变量签名改了旧定义还在。我后来改成两次校验第一次在启动时加载索引第二次在实际扩散前对将要读取的每个文件检查文件修改时间如果比索引时间新就对那个文件重新解析。这样做成本低但基本能保证当前会话里用到的代码是新的。更进一步的优化是把 git diff 也纳入进来。如果当前工作区有未提交改动context-mode 会优先把这些 diff 点标记成高优先级的上下文节点因为大部分问答都是针对刚写的代码来的。这个改动帮我解决了很多“模型照着旧代码说话”的郁闷场景。5.3 坑三多语言和大仓库的调参手感不同语言在语法树和调用关系上的表现差异很大。TypeScript 和 Python 这类现代语言函数和类型的边界很清晰扩散过程很顺。C 的宏定义、模板、前置声明会干扰索引我有时候不得不把一些文件手动加进 ignore 列表否则预处理器生成的隐式调用会让图变得混乱。面对大仓库我加了两条铁律第一条默认忽略node_modules、vendor、dist、build这类生成目录这不难理解但很多人会漏掉。第二条支持只针对“当前改动涉及的模块”做局部索引而不是永远全仓库扫描。我做了个--since参数配合 git 历史使用只索引最近修改的文件和它们依赖的上游模块。这两条加起来让 context-mode 在几万文件的仓库里也能在几秒内完成一次上下文组装而不是把启动时间消耗在全量解析上。5.4 我最后保留的三个默认参数调整了大概两三周之后我固定下来三个默认参数现在作为项目的出厂配置token_budget 6000既够用又不至于让模型输出被挤压。max_depth 3对绝大多数代码任务来说三层依赖已经能解释清楚问题。priority_weight 3同文件内函数调用的权重系数调成 3 以后能明显减少“上下文里有远端无关代码”的干扰。这三个参数不是绝对的如果你主要用的是超大模型或本地小模型预算和深度都要跟着改。但我想强调的是任何上下文工具都应该先把默认值定到“够用且不越界”的位置再让用户自己调。6. 下一步把 context-mode 从“粘贴工具”变成“内嵌记忆”目前的 context-mode 是一个被动触发的命令行工具我手动发起问题它给出一段上下文我再粘给模型。坦白说这已经帮我解决了大部分痛点但我知道还不够。我希望它下一步能变成一个真正的“内嵌记忆层”当我发起一次问答时它能自动把过去几个问题的上下文也纳入考虑避免开发者反复问同一个问题。我在试验的另一个方向是做持久化上下文卡片。context-mode 在每次生成完上下文后把核心调用链和建议的修复思路保存成一张卡片下次再遇到类似问题时直接从卡片里检索不需要重新做图遍历。这个思路有点类似把 context-mode 变成一个轻量级的项目知识库但它不是靠向量相似度而是靠“曾经的代码问题”和“当前问题”的符号重叠来判断相关性。就个人使用体验来说context-mode 已经成了我日常编码中离不开的一个环节。它最让我舒服的一点是我不需要记住代码库里每一处细节只需要知道要从哪个入口开始看剩下的依赖关系由工具帮我补齐。如果你也经常被“上下文不够用”这件事困扰我建议你从最小版本做起先让你的程序能读懂调用关系再一步步给它加预算、加剪枝。这个东西做到后面真正值钱的部分不是代码而是你对“什么才叫有用上下文”的理解。