如果你的 AI 编程助手时不时答非所问,把三个月前的旧需求当成当前任务来“发挥”,那大概率不是模型不行,而是被塞进窗口里的上下文出了岔子。这个问题的核心,就是 context-mode——上下文模式的选择与治理。我花了几周时间做了一个专门处理这件事的小项目,核心就一句话:让每一次请求都知道该带什么、不该带什么。这篇东西把完整的设计思路、实现细节和踩过的坑都整理出来,对正在折腾 AI 辅助开发、或者在大模型应用里做上下文管理的人,应该能少走不少弯路。
1. 一次答非所问引发的项目:context-mode 到底管什么
1.1 表象是模型问题,根子是上下文失焦
先讲一个真实场景。我在改一个登录页的空指针报错,让 AI 助手帮忙定位。它给的答复措辞很专业,但内容完全跑偏——它把一周前我做活动页时讨论过的营销弹窗逻辑翻了出来,还附赠了一段促销倒计时代码。我当时的第一反应是“模型不够聪明”,但把完整 prompt 拉出来一看,发现问题出在我这边:我传给模型的上下文里,同时包含了登录页代码、活动页代码、旧对话历史、还有一堆项目配置文件,模型只是在那一堆 token 里做了它认为最合理的关联。
大模型本身没有“当前任务”这个概念,它的世界就是 prompt 里那串 token。给它什么,它就基于什么回答。上下文里 70% 是无关内容时,它答非所问才是正常发挥。这件事让我意识到,与其纠结换哪个模型,不如先把输入治理好。
1.2 context-mode 的三个职责
我做的这个项目,名字就叫 context-mode,它本质上是一个上下文治理模块,专门负责三件事:
- 决定带什么:从打开的文件、选中的代码、项目结构、历史对话里,筛选出与当前任务真正相关的内容。
- 决定带多少:在模型窗口有限的前提下,把 token 预算分配到最值得的地方。
- 决定以什么顺序带:核心内容往前放,次要内容往后放,防止中间位置被无关信息占据。
用一个生活化类比解释:你请人帮忙装修厨房,不可能把整个杂物间的东西都搬到对方面前,只会挑出眼下要用的扳手、瓷砖和图纸。context-mode 干的就是“挑东西”这件事,只是它挑的对象是代码和文档。
1.3 项目定位与适用范围
我把它做成一个可以独立接入的中间件模块,既能挂在 CLI 工具里,也能被 IDE 插件调用,还能嵌到服务端的请求链路中。适用对象主要有三类:
- 自己搭 AI 编码助手、被“乱塞上下文”问题困扰的开发者。
- 做 Chat 类应用,需要在多轮会话里管理历史上下文的人。
- 做文档问答、代码仓库问答,需要做检索增强但又不想让 token 成本失控的团队。
后端接口设计得很薄:输入是当前会话状态加上用户指令,输出是一份已经排好序、去重、预算可控的 prompt 组装结果。如果你只想快速了解思路,不写代码也行,后面章节里的配置和规则可以直接借鉴。
2. 三种模式的设计逻辑与适用边界
context-mode 的核心不是某一个算法,而是把“怎么选上下文”这件事拆成了三种可切换的模式:manual、auto、agent。它们对应三种完全不同的使用心态:用户全权指定、系统自动筛选、模型自主决策。
2.1 manual:把选择权完全交还用户
manual 模式下,系统不做任何推测。用户显式地通过 @file 或 #selection 指定要携带的内容,我只做组装、去重和预算控制。
这个模式的适用场景非常明确:用户已经知道问题出在哪个文件、哪个函数里,只需要 AI 帮忙精读和修改。比如一个空指针异常,报错栈指向UserService.java的getUserById,你直接@UserService.java就行,没必要把整个 service 层都拖进来。
manual 的风险在于,它要求用户对自己的代码库结构足够熟悉。用得不好,会出现两种情况:一是漏带关键文件,AI 只盯着你给的那一小段代码,看不到调用方,给出的方案驴唇不对马嘴;二是带错文件,把不相关的模块塞进去,平白增加 token 消耗。
2.2 auto:按任务意图自动筛选相关上下文
auto 是我日常用的最多的一种模式,它的工作逻辑是:以用户当前的操作信号(打开的文件、选中的代码、最近的 git diff、输入的 prompt 文本)为输入,做一次多路召回,把可能相关的文件和代码片段捞出来,再按相关性排序和预算取舍。
这里的核心是“相关性”怎么定义。代码场景里,相关性不等于语义相似。userInfo和getUserInfo在 embedding 空间里距离可能很近,但如果当前任务是修登录跳转逻辑,真正相关的是调用链上的AuthController,而不是那个长得像的userInfo工具类。所以我在召回路里同时使用了 BM25 关键词命中和 embedding 语义相似度,两条路的结果做融合排序,避免单一信号跑偏。
auto 适合日常绝大部分开发场景:改 bug、写单测、查日志、调样式。用户不需要精确指定,系统给一个“足够好用”的上下文,模型就能给出不错的答复。
2.3 agent:全量视角,把决策交给模型
agent 模式是另一种极端:我把项目结构树、检索到的 top-k 片段、最近的 git 历史、对话摘要全部组装好,塞进窗口,让模型自己决定关注哪些内容。
这种模式适合跨模块的重构、新需求设计、性能瓶颈分析这类任务。因为这类任务本身就不存在“某一个文件是正确答案”的情况,用户往往自己也说不清需要哪些上下文,不如把决策权交给模型。
代价也很明显:token 消耗是量级上涨,响应延迟显著增加。如果每一条日常消息都走 agent 模式,成本会非常难看,而且模型在大量上下文里反而更容易“挑花眼”,回答可能泛泛而谈。
2.4 三种模式该怎么选
我把三种模式的差异做成了一张对照表,接入时可以直接参考:
| 模式 | 输入范围 | 典型首字延迟 | token 消耗量级 | 适用任务 | 主要风险 |
|---|---|---|---|---|---|
| manual | 用户显式指定的文件/选区 | 低 | 低(约1k) | 精修单文件 bug、定点代码审查 | 用户漏带信息,模型信息不足 |
| auto | 当前操作信号 + 多路召回 | 中 | 中(约5k-8k) | 日常编码、调试、单测 | 召回不准,关键文件被遗漏 |
| agent | 项目结构 + 全量检索 + 历史 | 高 | 高(约20k+) | 跨模块重构、新需求设计 | token 成本高,注意力被稀释 |
一句话总结:能用 manual 说清楚的就别让 auto 猜,日常杂活用 auto,真正要“上帝视角”的任务才开 agent。
3. 核心实现:token 预算、相关性排序与边界处理
设计好模式之后,真正让系统稳定跑起来的是几个底层实现细节。这块内容偏工程,但我会尽量讲清楚每个设计背后的理由。
3.1 token 预算怎么算
我见过很多人用len(text)来估算文本长度,这在做中文内容时误差极大。一个中文字符可能对应 1~2 个 token,而一段代码里的回车、缩进、长变量名消耗的 token 数量跟字符数完全不成比例。所以第一步就是按模型对应的 tokenizer 做预估。
以常见的 4k 窗口为例,我的分配策略是这样的:
MAX_CONTEXT_WINDOW = 4096 def build_prompt(system_text, history, retrieved_chunks, user_input): # 给模型生成预留的 token output_reserve = 512 # 系统提示词固定占位 system_cost = estimate_tokens(system_text) # 用户输入必带 input_cost = estimate_tokens(user_input) remaining = MAX_CONTEXT_WINDOW - output_reserve - system_cost - input_cost # 历史对话和检索内容竞争剩余预算 history_budget = int(remaining * 0.3) retrieval_budget = remaining - history_budget history_trimmed = trim_by_token(history, history_budget) chunks_trimmed = trim_ranked_chunks(retrieved_chunks, retrieval_budget) return assemble(system_text, history_trimmed, chunks_trimmed, user_input)这段逻辑的核心是给“历史对话”和“检索内容”显式划分预算。经验值:历史对话占比 30%,检索内容占比 70%。原因很简单,检索内容是针对当前任务的直接证据,历史对话只是提供背景,优先级理应更低。
3.2 相关性排序:双路召回与分数融合
auto 模式下的排序公式,我用的是 BM25 分数和 embedding 余弦相似度的加权融合:
def fused_score(query, doc, bm25_scores, embedding_model): bm25 = bm25_scores.get(doc.id, 0) emb = cosine_similarity(embedding_model.encode(query), doc.embedding) # BM25 的命中要更“硬”,权重给高一点 score = 0.6 * normalize(bm25) + 0.4 * emb # 精确修复合集的文件加分 if doc.path in test_related_files(query): score += 0.15 return score为什么 BM25 权重比 embedding 高?我踩过坑:embedding 相似度经常被“高保真复制粘贴”骗到。一个文件如果到处引用某个公共变量名,比如config.get("xxx"),那么任何包含config的查询都可能把它召回来,但实际上这个文件跟当前任务毫无关系。BM25 对关键词命中更严格,能过滤掉这些“字数像但内容不像”的干扰项。
3.3 三种模式共用的边界处理
不管在哪个模式下,有几个边界问题是共通的:
去重。同一个文件可能既被用户 @ 了,又在自动召回路中命中。如果不去重,同一份内容会在 prompt 里出现两次,token 翻倍,模型还容易混淆。我的做法是对每个内容块计算 hash,组装 prompt 时先过一遍指纹集合。
定位信息。把代码块直接拼进 prompt 而不告诉模型它来自哪个文件,等于让模型盲人摸象。每个代码片段前面我都会加上文件路径和函数名,例如:
### File: src/auth/LoginController.java ### Function: handleLogin(HttpServletRequest req)这样模型至少能结合路径语义推断代码的使用场景,回答时会更有针对性。
保序截断。当检索内容超过预算时,不能简单地从中间切一刀。代码块之间的顺序暗示着依赖关系,我采用按排序分数从低到高丢弃的方式,优先保证排序靠前的核心块完整保留。
4. 实测:三种模式在真实任务上的延迟与消耗对比
光说设计逻辑不够,我把自己项目里的一个中型代码库作为测试对象,跑了三种模式各 50 次请求,任务类型覆盖了单文件 bug 修复、跨模块功能开发、以及代码走查,下面直接放结果。
测试环境是一个约 8 万行代码的 Java 服务端项目,模型接的是通用大模型 API,记录指标包括首字延迟、总 token 消耗和一次答复是否被用户采纳(我人工标注)。
| 模式 | 平均首字延迟 | 平均总 token | 单次任务平均对话轮数 | 一次采纳率 |
|---|---|---|---|---|
| manual | 2.1s | 1.3k | 2.4 | 82% |
| auto | 3.4s | 6.8k | 3.1 | 78% |
| agent | 6.5s | 22.4k | 4.2 | 74% |
从数据里可以读出几个结论。
第一,manual 的采纳率最高,这一点不意外。用户自己指定上下文,模型拿到的信息最干净,回答的自然最准确。它的问题在于“用户得知道要带哪些信息”,这对新手不友好。
第二,auto 和 manual 的采纳率差距只有 4 个百分点,但 token 消耗差了 5 倍。这意味着只要召回做得好,auto 能以很小的可用性代价把用户从“手动整理上下文”这个负担里解放出来。日常开发里我用 auto 最多,就是这个原因。
第三,agent 模式在简单任务上不仅慢,还出现了一个有趣的现象:模型会过度依赖项目结构里的某个看起来“高大上”的模块,给出一些过度设计的方案。比如修一个空指针异常,它居然建议引入缓存框架。上下文太多的时候,模型容易“迷失在信息里”。
这个测试结果让我坚定了原则:模式切换一定要有感知,不能一键 auto 用到底,需要的时候得手动降级到 manual,该上 agent 的任务也不要犹豫。
5. 实际接入时最容易踩的四个坑
这一章是我最想写的内容。设计文档里不会告诉你这些细节,但它们直接决定系统能不能在实际项目里跑起来。
5.1 坑一:同一份文件被注入两次,token 翻倍,效果反而变差
接入后的第三周,我偶然看到一次请求的 token 统计高得离谱,一份 3000 行的OrderServiceImpl.java在 prompt 里出现了两次。原因是用户用@OrderServiceImpl.java手动指定了它,同时自动召回路也把它命中了。
排查链路是这样的:先看 token 统计,发现 6.8k 的消耗里有一份文件占了 3k;再把完整 prompt 打印出来,人肉比对发现文件内容重复;最后定位到是 manual 指定和 auto 召回没有做去重联动。
修复方案很直接:组装 prompt 前全局维护一份内容指纹集合,任何内容块进集合前先算 hash,重复的直接丢弃。这个改动让我整体 token 消耗下降了近 20%。
5.2 坑二:窗口溢出被静默截断,丢的偏偏是最有用的中间片段
有一次用户反馈,auto 模式下回答质量突然断崖式下跌。我一开始以为是模型抽风,重试了好几次都一样。后来排查发现,当检索内容特别多、超出窗口预算时,我直接用了.slice(0, max_tokens)这种从头截断的写法,于是 prompt 变成了:系统提示 + 用户输入 + 最早注入的文件 + 被硬生生砍断的中间内容。
最要命的是,检索结果里相关性最高的几个片段全在中间位置,被拦腰截断。模型拿到的核心证据是残缺的,回答自然稀碎。
正确做法是永远从低优先级的一侧开始裁剪。按照排序分数从低到高逐个淘汰,保住头部核心块,宁可让少的片段保持完整,也不要用一半的高分内容。
5.3 坑三:长会话切换模式,旧模式的历史污染新模式
这个坑藏得很深。有一次用户开了 agent 模式分析了十分钟代码,然后切到 manual 模式准备修一个小 bug,只 @ 了一个文件。按道理这是一次非常干净的上下文,但模型给出的回答仍然在讨论 agent 模式下那个“全局设计方案”的术语,完全没有聚焦到 bug 上。
原因在于我对“模式切换”只重置了检索结果,却没有重置历史对话缓冲区。agent 模式产出的那一大段分析文本还躺在历史里,模型被它带偏了。
修复方式有两种:一是切换模式时清空历史,只保留系统提示和用户当前输入;二是把历史压缩成一段摘要再传入,让模型知道“此前讨论过大方向,现在转向具体修复”,但不保留完整原文。我最终选了第二种,用户体验更顺。
5.4 坑四:召回来的“相关文件”其实毫不相关
auto 模式上线一段时间后,我收到反馈说检索结果老是带上一批奇怪的文件。打开命中列表一看,全是包含config、Util、Constants这类公共符号的文件。原因之前提过:embedding 相似度太容易被高频符号刷分。
最终方案是双路召回加权重调整。BM25 命中得分权重提到 0.6,embedding 降到 0.4,又加了一个惩罚项:包含大量公共工具方法的文件(工具类、常量类)在打分时降低权重。这个改动上线后,检索结果的精准度明显回升,模型废话也变少了。
6. 模式判定的小技巧:什么时候切到什么模式
context-mode 如果只靠用户手动切换,使用门槛还是偏高。我加了一层轻量的自动判定模块,基于规则和少量关键词信号,就能在大多数场景下帮用户选对模式。
6.1 从用户输入里读信号
规则并不复杂,用的是一些典型信号词:
| 用户输入特征 | 判定模式 | 理由 |
|---|---|---|
| 包含“报错”、“异常”、“修复”、“改 bug”、“打印日志” | manual | 用户目标明确,需要精准定位 |
| 包含“重构”、“梳理”、“设计”、“整体方案”、“依赖关系” | agent | 需要全局视角,单文件不够 |
| 包含“写单测”、“测试用例”、“样式”、“文档” | auto | 中等相关性,自动召回效率最高 |
| 默认且无法判定 | auto | 保守策略,性价比最高 |
这套规则我用 Python 实现只有几十行,但已经能覆盖大部分情况。关键不是规则本身多聪明,而是它能避免用户频繁手动切换,减少使用摩擦。
6.2 用数据微调判定阈值
规则定了之后,不要直接拍板上线。我在项目里加了埋点,记录了每次请求的模式、用户是否手动切换了模式,以及最终答案是“直接采纳”还是“被修改”。每周看一次这些数据,就能知道规则是否误判。
比如我发现“改样式”这个信号被规则划到了 auto,但用户经常在 auto 结果里手动补充 @ 文件,说明这个信号的上下文范围还是不够精准,后来我把样式类任务也归入了 manual,反馈数据好了很多。
6.3 一个可以直接抄走的配置样例
如果你也想在自己项目里快速接入 context-mode 的思路,可以先用下面这份 YAML 当起点:
context_mode: default_mode: auto modes: manual: enabled: true include_plain_path: true auto: enabled: true retrieval: bm25_weight: 0.6 embedding_weight: 0.4 top_k: 8 budget: retrieval_ratio: 0.7 history_ratio: 0.3 agent: enabled: true include_project_tree: true include_git_diff: true max_retrieval_top_k: 15 dedup: enabled: true mode_switch: reset_history: false condense_history: true这份配置跑了两周后,平均 token 消耗下降了三成,用户重复提问的次数也少了。不要觉得这些数字很小,在模型按量计费的时代,每一个 token 都值得省。
我在实际接入中的体会是:上下文治理永远比模型选型更值得先花时间。同一个模型,在乱七八糟的上下文下可能只发挥三成功力,但给它一份精挑细选的信息,表现立刻上一个大台阶。context-mode 这个项目做下来,最让我意外的收获不是 token 省了多少,而是我把“AI 助手为什么不听话”这个问题,从“模型不行”变成了“我给它塞了什么东西”——后一个才是真正能控制的部分。