1. context-mode 到底在解决什么问题
如果你最近在调 LLM 应用,一定对 context-mode 这个词不陌生。它看起来只是一个开关:在零上下文、当前文件、项目级上下文之间切换。但真正把这个功能做扎实,涉及的问题远比想象的多。我花了两周时间在一个终端 AI 助手里落地了 context-mode,过程中踩了不少坑,也总结出一套可以复用的设计思路,今天完整写出来。
先说结论:context-mode 的本质不是“给 AI 更多资料”,而是“在正确的时间,给 AI 恰好正确的资料”。没有上下文,模型只能泛泛而谈;上下文塞得太多,模型会被无关文件带偏,回答质量反而下降,Token 成本和响应延迟也上去了。Context-mode 就是通过一套显式的模式切换机制,把“上下文数量”的控制权从开发者手里,交还给使用者。
这个功能非常适合下面几类人来参考:正在做 AI 编程助手、聊天机器人、代码评审工具的人;需要在项目内快速定位问题、又不愿意每次都手动把文件内容粘进 prompt 的开发者;以及想优化 API 调用成本、减少无效 Token 消耗的团队。我后面讲到的方案不绑定任何特定框架,你用 Python、TypeScript 或者纯命令行脚本都能迁移过去。
1.1 从一次糟糕的对话说起
我做这个功能之前,先遇到了一个很典型的场景。有个同事在终端里问 AI:“帮我看看 user service 里为什么登录接口偶尔超时?”当时助手绑定的是整个项目上下文,一口气把仓库里的 30 多个文件都塞进 prompt。结果模型回答的时候,重点放在了数据库连接池的配置上,反而把真正有问题的 timeout 参数和 Redis 调用链给忽略了。最尴尬的是,那次请求花了 8 万多 Token,响应时间接近 30 秒。
反过来,如果完全不提供上下文,让 AI 直接回答同样的问题,它会给你一段非常正确的废话:“建议检查网络连接、数据库负载、代码逻辑”,一句能用的话都没有。这两种体验都非常糟糕。
而 context-mode 要解决的,就是这个二选一的困境。我后来把交互改成了三个档位,用户可以先问一个“零上下文”的问题,比如“解释一下 JWT 鉴权流程”;等确认问题范围后,再切换到“当前文件模式”,让 AI 只看正在编辑的代码;如果需要改动涉及多个文件,就再升级到“项目级模式”,让 AI 自己从文件列表里挑相关文件。整个过程从“赌运气”变成了“可控”。
1.2 设计目标与使用场景
我在设计 context-mode 时,给自己定了四个目标:
- 响应质量要稳定,不能因为切换模式后回答反而更差。
- Token 消耗要透明,用户能清楚知道每个模式大概花多少。
- 模式切换要快,最好一次按键就能完成。
- 用户始终保有最终控制权,自动推荐只能参考,不能替用户做决定。
四个目标按优先级排序,质量第一,控制权第二,成本第三,速度第四。原因很简单,一次垃圾回答浪费的时间和情绪成本,往往比多花几毛钱 Token 更昂贵。
适用场景我经验里主要就三类。第一类是单文件维度的代码解释和修复,比如“这个函数为什么返回空数组”“帮我加一个参数校验”,当前文件模式就够了,塞整个项目反而干扰。第二类是跨文件重构,比如“把支付逻辑从订单服务里抽出来”,这种必须用项目级模式,让 AI 理解依赖关系。第三类是纯知识问答,不需要读任何项目文件,用零上下文模式最省。后续我们加的自定义模式,是把这三类的边界继续细化,这个后面专门讲。
1.3 context-mode 不是 context window
有一件事必须澄清:context-mode 和模型支持的上下文窗口长度是完全无关的两个概念。上下文窗口是模型硬件层面的能力上限,比如 128K、200K;context-mode 是我们产品层面的设计,是“主动决定往窗口里放什么数据的策略”。
这就像一个大厨房,锅的大小(上下文窗口)决定了最多能煮多少食材,而 context-mode 是“菜谱和采购清单”,决定今天这顿饭到底该买什么、切什么、先下锅什么。把整扇猪肉都丢进锅里,不如按照菜谱切好那半斤五花肉。很多人误以为“既然窗口有 200K,我就把整个仓库都丢进去”,实测下来的效果通常都很差,原因不是窗口不够用,而是相关性问题——模型面对超长上下文时,注意力会被无关信息稀释,关键代码反而找不到了。
所以 context-mode 的底层逻辑很简单:上下文长度不是目的,相关性才是。
2. 核心设计:上下文的分层、预算与切换策略
2.1 上下文资源的三层结构
在实现之前,我把一次对话涉及的上下文拆成了三层:第一层是系统层,包括角色设定、输出格式要求、安全规则;第二层是会话层,包括用户之前的问题、AI 之前的回答、当前正在编辑的文件;第三层是检索层,包括从项目里搜索出来的相关代码、文档、配置文件。context-mode 主要控制的是第二层和第三层的组合方式。
系统层内容必须永远保留,不随模式变化。这一点很多初学者容易忽略,我见过有人把系统提示词当成“可被覆盖的普通文本”,结果切到零上下文模式后,模型连基本的输出格式都忘了。正确做法是:系统层恒定,会话层按模式裁剪,检索层按模式增强。
我用一张表来总结三个基础模式的资源配置方式:
| 模式 | 系统层 | 会话层历史 | 当前文件 | 检索层文件 | 推荐预算 |
|---|---|---|---|---|---|
| zero | 固定 | 保留最近 2 轮 | 不含 | 不含 | 2K Token |
| file | 固定 | 保留最近 4 轮 | 含 | 不含 | 8K Token |
| project | 固定 | 保留最近 4 轮 | 含 | 根据问题检索 | 32K Token |
这里推荐预算不是写死的硬限制,是防止误操作的软上限。在 file 模式下,如果你选中的文件本身有 3 万行,当然可以超过 8K,但那说明你可能选错文件了,系统会提示一次。预算的作用是让用户对“成本诡异上升”这件事有感知。
2.2 三个基础模式的行为定义
零上下文模式 zero mode,是所有模式的兜底。它不读取任何项目文件,只把用户当前的问题、历史对话摘要、以及系统固定的角色信息交给模型。适合做通用问答、格式化代码、写正则表达式这类不依赖项目背景的任务。这个模式最大的价值是便宜,响应快,而且不会因为加载了不相关代码而产生“幻觉式引用”。
当前文件模式 file mode,是日常编辑中使用频率最高的模式。它读取编辑器当前聚焦的文件内容,同时会优先截取“和光标位置最近的代码块”,而不是把整个文件原样塞进去。比如你在一个 1000 行的文件里修改第 800 行的函数,那 context-mode 会把第 700 到 900 行作为核心上下文,剩余的 700 行只保留一个精简的“符号清单”,列出类名、函数名、全局变量名。这样做是因为大模型对局部代码的修改,最关心的是“我改的这个函数周边有什么”。
项目级模式 project mode,是成本最高、效果也最容易翻车的模式。它不是把整个项目目录递归读一遍然后全塞进去,而是先构建索引,再通过关键词、文件依赖关系和最近 git 变更来筛选一个“候选文件集合”。我实测下来,候选集合控制在 5 到 10 个文件是最优区间,超过 15 个文件后,回答质量的提升曲线会迅速变平,Token 消耗却接近线性上涨。
2.3 模式自动推荐与手动覆盖
很多人以为 context-mode 就是让用户手动切,其实更成熟的交互是“半自动”。每次用户提问时,系统先根据问题的词频判断:如果问题里出现了当前打开文件的函数名或变量名,自动推荐切换到 file 模式;如果问题里出现两个以上文件的类名,自动推荐切换到 project 模式;如果问题完全不带项目特征,就维持 zero 模式。
但推荐只以状态栏提示的形式出现,绝不自动帮用户切。自动切换的问题在于,它打断了心智模型,用户不知道现在 AI 到底在用什么上下文,回答错了也不知道该怪谁。我们后来把推荐逻辑做成一个可选开关,默认打开推荐、不自动切换,用户按一次快捷键Ctrl+Shift+M就能接受推荐,再按一次可以手动换到别的模式。用了一周后我发现,用户对自动推荐的接受率大概在 70%,剩下 30% 的情况用户会手动选择,这 30% 恰恰是最容易出错的高价值场景。
3. 从零实现一个可用的 context-mode
3.1 先定义一个清晰的上下文数据模型
动手写代码之前,我先定义了一个数据模型,把所有上下文相关的信息都装进一个对象里。这一步看起来多余,但后面做缓存、做调试、做日志的时候会非常省心。我用 Python 的类型标注来描述核心结构:
from dataclasses import dataclass, field from enum import Enum from pathlib import Path class ContextMode(str, Enum): ZERO = "zero" FILE = "file" PROJECT = "project" CUSTOM = "custom" @dataclass class FileSnippet: path: Path content: str start_line: int end_line: int token_estimate: int = 0 @dataclass class ContextUnit: role: str # "system" / "history" / "current_file" / "retrieved" content: str mode: ContextMode source_path: Path | None = None priority: int = 0 # 数字越大越优先保留 @dataclass class ContextBundle: mode: ContextMode units: list[ContextUnit] = field(default_factory=list) def total_tokens(self) -> int: return sum(u.token_estimate for u in self.units)这段代码的核心是ContextUnit,它把每一块上下文都打上了来源标记和优先级。为什么要加priority字段?因为最后做 Token 截断时,我们不是按“谁先谁后”截,而是按“谁的优先级低先丢谁”。系统提示词优先级最高,当前文件里的核心代码块优先级次之,历史对话再次之,检索出来的辅助文件优先级最低。这个设计让我避开了很多截断后格式崩坏的问题。
实际项目中你还可以加上embedding字段用来做向量检索,加上mtime字段用来做文件变更检测,但是最初版本不必一次性设计完,够用就好,先跑通核心链路再迭代。
3.2 实现当前文件模式的收集器
收集器是整个 context-mode 最繁重的一块,因为它要处理真实文件。我实现 file 模式时专门写了一个FileContextCollector,它做三件事:定位当前文件、定位光标附近的代码块、生成符号表。
import ast def extract_active_block(source: str, cursor_line: int) -> str: """根据光标行号,提取所在的函数或类定义块。""" tree = ast.parse(source) for node in ast.walk(tree): if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): block_start, block_end = node.lineno, node.end_lineno if block_start <= cursor_line <= block_end: lines = source.splitlines() return "\n".join(lines[block_start - 1: block_end]) return source这个函数的原理很简单:先把源码解析成 AST 语法树,然后遍历所有函数、异步函数和类定义,判断光标行号落在哪个代码块的范围内,把那个块整体提取出来。之所以用 AST 而不是按行号附近取 200 行,是因为函数嵌套场景下,按行数硬取会把外层函数和内层函数混在一起,语义会被破坏。我在一次真实测试里遇到过,光标在一个嵌套函数内部,按固定行截取把外层函数的 30 行注释都带进来了,模型回答受干扰特别严重。AST 方案出来后,这个问题就消失了。
符号表生成则是把所有类名、函数名、全局变量名和它们所在行号整理成一个紧凑列表。比如:
[Symbols] UserService:1024 UserRepository:888 validate_token:756这段符号表单独占一块,放在完整代码块之后,让模型知道“这个项目里还有这些符号,但你没看到全文”。模型在回答跨函数调用时,会依据符号表去猜测关系,比啥都没有强很多。
3.3 项目级模式的检索与筛选
project 模式的收集器,我建议先做“关键词 + 依赖图”的两阶段筛选,不要一上来就上向量数据库。很多项目规模其实没有大到需要 embedding 的程度,用关键词过滤加上文件内容里的 import / require 关系分析,就能选出 80% 的相关文件。
第一阶段是关键词召回。把用户当前问题分词后,提取里面的标识符,比如类名UserService、函数名create_order、配置项timeout。然后在整个项目的文件列表里做匹配,文件名命中的权重乘 3,文件内容命中的权重乘 1,按得分倒序取前 20 个候选文件。
第二阶段是依赖图扩展。对第一阶段的候选文件做 import 分析,找出它们依赖了哪些文件、又被哪些文件依赖。为什么必须做这一步?因为很多 bug 的根源不在报错的那个文件里,而在它调用的下游模块。关键词只搜报错文件时,经常漏掉真正的问题源。依赖图扩展能把这个缺口补上。
def expand_candidates(candidates: list[Path], import_map: dict[str, set[str]]) -> list[Path]: result = set(candidates) for path in candidates: for dep in import_map.get(path.name, set()): if len(result) >= 12: # 候选文件数量上限 break result.add(path) return sorted(result, key=lambda p: str(p))注意这里的候选文件数量上限,我填的是 12。这个值不是拍脑袋定的,后面测试环节我会专门说数据,12 到 15 个是一个性价比拐点。候选文件的完整内容都会进入 prompt,但会按“是否与关键词直接相关”分成核心区和辅助区,核心区内容完整保留,辅助区只保留关键函数和类的摘要。
3.4 模式切换的交互设计
模式怎么切,直接影响到用户会不会用。我一开始把模式切换放在配置文件里,结果同事都懒得去改,功能基本没人用。后来改成命令行输入框前的斜杠命令:输入/context file切到当前文件模式,输入/context zero切到零上下文模式,输入/context project切到项目级模式。这样最直接,因为用户本来就要敲命令提问。
同时我在终端界面底部加了状态栏,显示当前模式和预估 Token:
[context-mode: file mode] [used: 7.2K/8K tokens]这个状态栏异常重要,它让用户对“AI 是否在看正确的文件”有一个直观判断。很多用户反馈说,看见 Token 快爆了,才意识到自己上次项目级上下文一直没切回来。加了一行状态栏之后,这类错误少了 80%。
还有一个小技巧:模式切换不会自动清空历史记录,但我会把上一次模式生成的检索结果标记为stale过期状态,在下一次回答前自动丢弃。如果不做这一步,旧模式加载的文件会残留在上下文里,变成上下文污染,这个问题我在后面的排查章节会专门展开。
4. 实测数据:不同模式的成本与质量对比
理论说再多,不如跑一次真实测试。我用一个大约 80 个 Python 文件、包含 API 层、服务层、数据访问层和测试文件的中型项目做了对照实验。测试工具是一个基准脚本,对 5 个典型问题分别用零上下文/单文件/项目级三种模式提问,并统计每次的 Token 消耗、响应时间、以及人工评分。
测试问题设计如下:
- “解释 JWT 鉴权流程” —— 通用知识,预期 zero 最优。
- “user service 里登录接口为什么超时” —— 跨文件排查,预期 project 最优。
- “给 payment.py 的
validate_sign函数补一个边界判断” —— 单文件修改,预期 file 最优。 - “列出这个项目里所有用到 Redis 的地方” —— 跨文件搜索,预期 project 最优。
- “这段代码的 PEP8 问题有哪些” —— 单文件风格检查,预期 file 最优。
评分规则是 1 到 5 分,人工判断“回答是否准确、是否可执行”。结果如下:
| 问题编号 | zero 模式得分 | file 模式得分 | project 模式得分 | 最优模式 |
|---|---|---|---|---|
| 1 | 4.5 | 4.0 | 3.5 | zero |
| 2 | 1.5 | 2.5 | 4.5 | project |
| 3 | 2.0 | 4.5 | 4.0 | file |
| 4 | 1.0 | 2.0 | 4.5 | project |
| 5 | 2.5 | 4.5 | 3.5 | file |
最值得注意的数据是问题 1。零上下文模式得分 4.5,比 project 模式的 3.5 高了一大截。很多人以为项目上下文是“万金油”,加了总比不加好,可实测发现,当问题本身是通用知识时,项目文件纯粹是噪音,模型甚至会因为项目里某个不规范的写法而改变标准答案。这说明 context-mode 里“留白”本身也是一种主动设计,而不是“没找到上下文”的失败状态。
Token 消耗方面,三次 project 模式平均消耗 29.6K Token,file 模式平均 7.8K,zero 模式平均 1.9K。单次成本差别不算特别大,但如果一个开发者每天触发 100 次调用,project 模式一个月比 file 模式多出大约 200 万 Token,按常见模型定价换算,不是一笔可以忽略的钱。所以我在默认配置里,把 project 模式设成了“手动确认后才能进入”,避免系统自动把用户拖进高成本通道。
关于候选文件数量的经验值,我也做了一组对照实验。候选文件从 3 个增加到 6 个时,问题 2 的评分从 3.0 跳到 4.5,提升非常明显;从 6 个增加到 12 个,评分只从 4.5 升到了 4.7,几乎可以忽略;超过 15 个之后,评分反而因为噪音增加掉回了 4.2,同时 Token 消耗增加近一倍。这就是我把 12 作为候选文件数量上限的原因。与其堆文件,不如提升文件内容筛选的质量。
5. 常见问题与排查技巧实录
5.1 上下文污染:模式切换后残留旧文件
这是我在实际使用中遇到最多的问题。现象是:用户切到 file 模式后,问一个当前文件的问题,AI 回答时却提到了完全不相干的旧文件。排查下来发现,是因为上一次 project 模式加载的检索文件,还留在会话历史里没有清掉,它们被模型当作了隐式上下文。
解决方案是在切换模式时,给所有不属于当前模式的上下文单元打上stale过期标记,并在下一次构造 prompt 前过滤掉。更彻底的做法是,切换模式时同时生成一条变更日志,写入会话元数据:
{ "event": "mode_changed", "from": "project", "to": "file", "discarded_units": ["retrieved:db_config.py", "retrieved:cache_service.py"], "timestamp": "2025-01-15T10:23:00Z" }这样一旦回答出问题,可以拿着日志回看“当时模型到底看到了什么”。这个习惯帮我迅速定位了很多诡异回答。没有日志时排查上下文污染是很痛苦的,因为问题往往要等到两轮之后才暴露出来。
5.2 Token 溢出:核心内容被意外截掉
最开始我做 Token 截断时,贪图省事,直接按照列表顺序从头截到尾。结果发现一个典型问题:系统层提示词和历史对话占用了太多空间,当前文件的代码块被甩到列表末尾,一超出预算就被裁掉,最后模型完全没有看到用户要改的代码,回答质量断崖式下跌。
修正后的策略是按优先级排序再截断。优先级顺序是:系统提示词最高,当前文件核心代码块第二,检索文件核心区第三,符号表第四,历史对话第五。截断时从低优先级开始丢弃,直到总 Token 数低于预算。几个文件之间,我还会优先保留当前文件,再保留最近修改的文件。这套策略跑了一个月,再没出现“模型看不到代码”的尴尬。
另外,我建议在 prompt 尾部加一段可见的 Token 使用提示,比如“当前上下文已使用 7.9K Token,接近 8K 上限,与问题无关的文件内容可能被省略”。这不算浪费 Token,反而能避免模型在信息缺失时“脑补”,实测能减少 30% 左右的幻觉式回答。
5.3 模式切换后效果不一致:历史依赖不可信
有用户反馈,同一个问题,先切 project 再切 file,和直接 file 提问,得到的回答不一致,甚至更差。原因在于,切到 file 模式时,我保留了最近 4 轮历史对话,而这几轮对话里可能包含了 project 模式下的推理过程,比如 AI已经分析了 5 个文件、给出了一个阶段性结论。新的 file 模式提问会继承这个推理过程,于是被旧结论带偏。
这个问题的最优解不是清空历史,而是对历史对话做摘要压缩。我写了一个condense_history函数,把最近 4 轮聊天压缩成 200 字以内的摘要:“用户询问了登录超时问题,AI 分析了 user_service.py 和 redis_config.py,暂未定位到根因”。下一轮提问时,模型只带上这个摘要,而不是完整的旧分析过程。这样既保留了对上下文的衔接,又不会让旧模式的结论过度污染新模式。
5.4 文件读取性能:大项目下卡顿严重
project 模式刚上线时,用户反馈第一次切换要等 5 秒以上,体验非常差。瓶颈出在读取文件内容:候选 12 个文件,平均每个 500 行,加上 AST 解析和符号提取,耗时确实不短。
我的做法是引入两层缓存。第一层是文件哈希缓存,记录每个文件的修改时间、文件大小、以及内容哈希。文件没变就直接复用之前提取的代码块和符号表,不重新解析。第二层是检索结果缓存,同一个问题关键词在 5 分钟内不重复检索,TTL 设为 5 分钟是考虑到开发者通常会在一个任务上停留较长时间。
做完缓存后,project 模式首次切换耗时从 5.1 秒降到了 1.8 秒,第二次命中缓存时降到 0.4 秒。虽然还有优化空间,但已经不影响正常使用了。缓存带来的副作用是,文件改动后可能读不到最新内容,所以我用了文件修改时间和大小做双校验,只要有一个变化就把缓存标记为失效,问题不大。
6. 后续扩展:从手工模式到智能模式
6.1 把静态检索升级为 RAG 向量检索
context-mode 做扎实之后,我尝试给 project 模式接入了向量检索,把项目文件按函数粒度切片后 embedding,用户提问时先做向量召回,再和关键词召回合并,最终用重排序模型挑选候选文件。效果提升主要集中在一类问题上:“项目里有没有类似 XXX 的实现”,这类语义匹配是关键词召回做不到的。但代价是索引构建和向量存储的额外基础设施,小项目没必要上,100 个文件以内关键词方案完全够用。
6.2 把“模式”升级为“任务模板”
比多模态更实用的扩展方向,是任务模板。比如把“写测试”定义成一个 custom 模式,它固定包含测试框架配置、被测文件、相关 fixtures、以及一条“只输出测试代码”的系统指令;把“Code Review”定义成另一个 custom 模式,它固定包含 git diff、当前分支变更文件列表、以及评审规则。这些模板本质上就是 context-mode 四个档位之外的第五档,但因为它能绑定具体任务,用户切换时的心理负担更小。
任务模板看起来是小事,实际价值非常大。它让 context-mode 从“上下文数量控制工具”进化成了“团队最佳实践的封装器”。新同事入职后不需要知道项目里有哪几个核心文件、测试怎么写,直接选一个“写测试”模板就能得到不错的结果。我自己团队用下来,这是投入产出比最高的一个扩展。
6.3 建立回归测试集,防止越改越差
最后我想说一个外面文章很少提的点:context-mode 这类功能,必须配一套回归测试。因为 AI 模型经常升级,同一个 prompt 今天能用,下个月可能就变了;项目代码重构后,原来能检索出的文件路径可能已经失效。我建了一个非常轻量的测试集,包含大约 20 个问题,每个问题标注了预期模式和预期得分下限,每周自动跑一遍,发现评分低于阈值就告警。
这套测试救了我不止一次。有一次模型厂商改格式输出偏好,导致 file 模式下代码块被误判为 Markdown 表格,所有回答全都走样。如果没有回归测试,这种问题要等到用户吐槽才会暴露,有了自动化测试,在发布前就拦住了。
context-mode 做到这一步,已经不再是简单的开关切换,而是变成了一个可观测、可评测、可持续迭代的上下文管理系统。我个人感触最深的一点是:做这类功能,永远不要神话“上下文越多越好”,也不要迷信“向量检索天下无敌”,真正重要的是给用户足够的控制感和反馈信息。如果用户能随时看清 AI 当前在看什么、预算是多少、是什么模式,那么即使某个模式选错了,他们也能快速自我纠正。这个设计哲学,我后来在做其他 AI 功能时也一直沿用。