如果你也在做一个依赖大模型辅助编程的工具,一定绕不开一个问题:到底该给模型塞多少上下文?我负责的那个内部插件,最初只做了一个开关,叫context-mode,后来这个开关一路长成了整个产品的核心架构。它听起来不起眼,却直接决定了补全准不准、延迟高不高、token 花得快不快。
context-mode本质上是一套上下文管理模式。它做的不是“猜你要什么”,而是让你显式指定:当前这次请求,模型能够看到哪一层代码信息。是只看当前文件,还是连带 git diff,还是干脆把整个仓库的关键索引都打包进去。模式不同,请求成本不同,返回结果的质量也完全不同。这篇文章把我在设计、实现和踩坑过程中的完整记录写出来,给同样在做 AI 编程工具、或者想在编辑器里精准控制大模型上下文的朋友一个可复用的参考。
1. 项目背景:context-mode 要解决的核心矛盾
1.1 为什么“无脑塞上下文”行不通
最早我的第一版实现简单粗暴:拿到用户当前打开的文件,再拿几个最近打开的文件,全部拼在一起丢给模型。结果实测下来问题很多。代码超过一定行数后,模型开始忽略关键信息,特别是函数定义和调用关系容易被长文件稀释。token 成本也控制不住,一次请求经常吃掉五千到八千 token,但真正有价值的可能只有中间几百行。
后来我意识到,问题不在模型,而在上下文的结构。模型并不会自动从一堆代码里挑出“当前光标所在函数的调用链”这种关键内容,它只会平均分配注意力。于是context-mode的设计初衷就变成了:把“让模型自己找重点”变成“我先把重点挑出来”。这看起来是产品逻辑的调整,实际上是整个系统架构的分水岭。
1.2 模式切换背后的成本和收益
给 context 分模式,本质上是做一次“信息预算”的分配。每种模式对应不同的 token 预算、数据源组合和缓存策略。比如我定义的几个模式里,file模式只关注当前文件,适合快速补全;selection模式只关注选中区域,适合重构和解释;repo模式会扫描仓库索引,适合“修改 A 文件时同步调整 B 文件”这类跨文件任务。
必须承认,模式越多,用户理解成本越高。所以设计时我一直在做减法。只保留四个核心模式,每个模式有明确的应用场景,避免功能重叠。context-mode不是越智能越好,而是越可预期越好。用户要知道当前模式会让模型看到什么,才不会出现“我以为它看到了,但它没看到”的挫败感。
2. 核心细节:模式定义、数据源与参数设计
2.1 上下文数据源分类
实现context-mode之前,我先把项目里所有可能成为上下文的数据源列了一遍,一共七类:当前文件全文、光标附近的代码块、函数/类定义、项目依赖关系、git 工作区变更、终端输出、选中文本。这七类的信息密度和获取成本完全不一样。当前文件全文最容易拿,但噪声大;函数定义需要做轻量 AST 解析;git diff 则要调版本控制接口。
每个模式都是这七类的组合。比如diff模式,我选择只取 git diff 涉及的文件,而不是把所有工作区文件都塞进去。这样既保证模型能理解改动的上下文,又不会因为无关文件导致回答跑偏。数据源的组合顺序也很重要,我把“与光标位置的距离”作为默认排序权重,距离越近优先级越高,这个策略后来被证明非常有效。
2.2 用 JSON 描述模式,而不是写死在代码里
写了两个版本之后我发现,把模式硬编码在switch里完全是个坑。每加一种上下文来源就要改代码,不合算。后来我改成用 JSON 描述每个模式的行为,核心是一个ContextModeConfig的结构。下面是我在实际项目里用过的精简示例:
{ "modes": { "file": { "label": "当前文件", "sources": ["activeFile", "cursorBlock"], "maxTokens": 2048, "cache": "lineLevel", "fallback": "selection" }, "diff": { "label": "本次变更", "sources": ["gitDiff", "changedFiles"], "maxTokens": 4096, "cache": "fileLevel", "fallback": "file" }, "repo": { "label": "仓库检索", "sources": ["symbolIndex", "dependencyGraph", "relatedFiles"], "maxTokens": 8192, "cache": "sessionLevel", "fallback": "diff" } } }maxTokens不只是上限,还影响后续的重排序策略。cache字段告诉系统当前模式的数据可以缓存到哪一层。fallback是当主数据源拿不到内容时的降级方向。这套 JSON 方案上线后,扩展新模式变成写一段配置,而不是改一轮代码,团队里非后端同学也能参与调整。
2.3 三个关键参数:预算、优先级、缓存
第一个参数是 token 预算。我统计过一个典型请求的 token 花费:系统提示词固定约 400 token,用户指令约 200 token,代码上下文是唯一可变量。context-mode做的就是把上下文压到预算内。如果预算太小,模型会缺上下文;预算太大,延迟和成本都会失控。经验值是:补全任务给 2048,解释任务给 4096,跨文件修改给 8192,这是比较稳的起点。
第二个参数是优先级。上下文数据不是简单的串联,每一条源都带一个权重。光标所在函数权重最高,文档注释次之,历史对话最低。我实现了一个rerank_context函数,在组装 prompt 之前按权重排序,把核心信息放在头部。实测这个步骤对补全准确率提升非常明显,比单纯调 prompt 效果好得多。
第三个参数是缓存策略。相同模式、相同文件、光标的行号变化不大时,复用上次解析出来的符号索引,能省掉一大半获取时间。比如file模式下,我缓存了当前文件的 AST 符号表,只有当文件被修改或光标跳到另一个函数时,才重新解析。这样一次补全的延迟从平均 800ms 降到了 300ms 左右。
3. 实操落地:从模式枚举到完整上下文管线
3.1 用 Python 快速验证最小实现
设计完配置之后,我先用 Python 写了一个不依赖 IDE 的最小验证版本,用来验证“模式切换”本身是否成立。核心模块只有三个:模式枚举、上下文收集器、prompt 组装器。枚举定义很直接:
from enum import Enum class ContextMode(Enum): FILE = "file" SELECTION = "selection" DIFF = "diff" REPO = "repo"上下文收集器接收一个ContextRequest对象,里面包含模式、当前文件路径、光标位置、选中文本等基本信息。收集器的工作是读配置、调对应的数据源、把结果合并成ContextBundle。当时我担心的不是功能,而是每次请求都要做文件 IO、AST 解析和 git 操作,会不会太慢。验证结果比预期好,除了 git diff 第一次调用稍慢外,其他都能控制在 50ms 以内。
3.2 组装 prompt 时的格式设计
prompt 组装决定了模型能不能理解上下文的边界。我用的是分段标记加上显式的“当前任务”字段。在系统提示词里告诉模型:你会收到多段标记过的代码,请优先参考[active_function]和[cursor_block]。然后把代码按数据源类型分块,用 XML 标签包起来,避免模型把不同来源的代码混在一起。
def build_prompt(request: ContextRequest, bundle: ContextBundle) -> str: parts = [ "You are helping with a coding task.", f"Mode: {request.mode.value}", "Use the following context blocks.", "", ] for block in bundle.ranked_blocks: parts.append(f"<context source=\"{block.source}\">") parts.append(block.content) parts.append("</context>") parts.append(f"<task>{request.instruction}</task>") return "\n".join(parts)这个设计有个隐蔽的好处:当上下文被截断时,模型仍然能从标签知道“这里缺失了一块”,不会把相邻的代码错误地当作完整信息。后来我处理超长文件时,就是靠这个标记结构做截断,优先保留active_function块,删掉中间的无关行,并明确告诉模型“中间内容已省略”。
3.3 接入编辑器时的真实取舍
验证完最小实现后,我把它接进了一个 VS Code 扩展原型。接入过程比预想复杂的是:编辑器本身有非常多的上下文事件,比如光标移动、文件切换、选中变化,如果每个事件都触发一次完整上下文收集,性能和体验都扛不住。这里必须做节流和事件合并。
我的方案是:光标静止 150ms 后触发一次“轻量模式”的上下文更新,比如只更新光标块;只有显式调用“解释这段代码”或“改这个函数”时,才触发完整模式的数据收集。模式切换通过状态栏下拉框完成,用户选完立即生效,下一次请求自然使用新模式。这个交互设计让context-mode从一个后端概念变成了用户能感知的开关,调试的时候特别直观——一眼就知道模型当前是在看文件还是在看 diff。
4. 常见问题与实测避坑记录
4.1 模式切换后上下文过期
有个高频报错:用户从diff模式切回file模式,但模型仍然在回答 git 相关的内容。排查后发现是缓存没清。我用的缓存策略是按模式缓存,切换模式时应该丢弃旧模式的上下文,结果代码里只做了按文件名缓存,导致新模式下读取了旧数据。修复方法是在ContextModeManager.set_mode()里主动清空当前会话缓存。教训是:模式本身就是缓存 key 的一部分,任何上下文缓存都必须带模式维度。
4.2 token 爆炸:低模式高配额
另一个问题来自用户手动调大maxTokens,选了file模式却给了repo模式的预算。结果当前文件被完整塞进去,模型开始过度关注文件末尾的重复代码,补全质量反而下降。后来我在配置里加了硬约束:每个模式有内部上限,用户不能通过参数把自己抬到另一个模式的预算。比如file模式的maxTokens硬上限是 4096,超过就截断,而不是涨预算。这个约束简单粗暴,但省掉了大量调参纠结。
4.3 实用排查速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 补全内容与当前函数无关 | 上下文里没有激活的函数定义 | 检查cursorBlock是否覆盖到光标所在函数 |
| 模型反复引用已删除代码 | gitDiff数据源过期 | 确认工作区 diff 是否在每次请求前重新获取 |
| 切模式后仍用旧上下文 | 缓存未按模式隔离 | 清除模式切换时的会话缓存 |
| 尺寸很大的仓库但不触发 repo 模式 | 用户停留在 file 模式 | 检查 UI 上是否明确显示当前模式 |
| token 未超限但延迟很高 | 上下文收集器做了重复 AST 解析 | 开启文件级缓存,避免重复解析 |
4.4 避坑:不要把模式做成一堆互斥的魔法开关
我在迭代中最容易犯的错误是,为了让某个模式更聪明,不断往里面加“自动判断”。比如“当检测到用户打开三个文件时,自动把 repo 模式改成 file 模式”。这种逻辑会让结果变得不可预期。context-mode的价值在于可控,不是自动化。即使是所谓“智能上下文选择”,也应该只提供建议,不改变当前用户选择的模式。用户是最终决策者,这个原则帮我避免了很多玄学 bug。
另外,模式名称的命名也要克制。用file、selection、diff、repo这种一眼能懂的词,别用smart、balanced这类模糊词。因为模式会直接出现在提示词里,如果模型看到smart这个词,可能会产生不必要的解读,反而干扰任务理解。保持朴素的命名,是对生产环境的尊重。
我在实际维护这套系统的过程中最大的感受是,context-mode与其说是一个功能,不如说是一种产品习惯:永远让用户清楚“边界”在哪里。模型能看什么,不能看什么,应该由人决定,而不是让代码猜。后续我还想在里面加入“会话记忆模式”,把多轮对话中的关键决策也纳入上下文管理,但依然会沿用同样的原则——先明确模式,再执行请求。这个方向目前跑得很顺,也算是我对context-mode的一点小小延伸。