我在调一个开源代码提示插件的时候,第一次正儿八经地研究 context-mode 这个词。插件本身功能简单,但工程一大,它给模型塞的上下文要么太少导致答非所问,要么一股脑全塞进去,token 预算直接被干爆。后来我把这套上下文收集、过滤、打包的逻辑单独抽出来,做成了一个可切换的 context-mode,配合不同任务场景使用,效果立刻就不一样了。
这篇文章就把我做的这个 context-mode 完整拆开讲一遍:为什么需要它、核心设计怎么想、具体怎么实现、遇到哪些坑、现在怎么扩展。不管你是做 AI 辅助开发工具,还是想在命令行里快速给大模型喂项目上下文,这套思路都能直接抄。
1. 为什么需要 context-mode:先弄清上下文到底管什么
1.1 上下文不是“越多越好”
很多人有个直觉:上下文窗口越大,AI 回答问题就越准。实际用下来真不一定。我见过一个项目把整个代码库的 Readme、所有配置文件、十几个源码文件全部拼进提示词里,结果模型生成的建议把无关模块当成了核心逻辑,反而把一个本来很简单的问题搞复杂了。
这里的关键在于:上下文是给模型提供“当前场景下最相关的信息”,不是开卷考试。你的目标应该是让模型在有限的 token 预算内,看到它真正需要的内容。上下文多了,注意力被稀释;上下文少了,它就开始自由发挥,编造不存在的函数和变量。
context-mode 解决的就是这个问题。它不是一个具体功能,而是一种工作模式:自动判断当前场景,从整个项目里挑出最相关的内容,压缩成一条结构化的上下文注入到提示词里。看起来是“少给了信息”,实际是“给对了信息”。
1.2 三种典型场景的上下文需求差异
我做这个工具之前,先列了三个最常见的场景,发现它们对上下文的要求差异非常大。
场景一:新代码生成。比如“帮我在现有项目里新增一个用户列表页面”。这种场景需要的是:路由怎么配的、现有页面风格是什么、接口层怎么写的、组件库是什么。你需要给它看同目录下的文件、路由配置文件、接口封装文件,而不是整个后端代码。
场景二:代码审查。比如“review 一下我这次改动的 diff”。这种场景需要的是:改动涉及的文件内容、这些文件之间有没有调用关系、改动是否影响现有测试。你给它看的上下文越聚焦越好,一个文件的旧版本、新版本、调用它的文件,就够了。
场景三:问题排查。比如“这个报错是怎么回事”。这种场景需要的是:报错堆栈、相关文件源码、最近改动的代码。如果能把报错位置附近的代码带上,命中率会高很多。
这三个场景如果都用同一个“把项目全塞进去”的策略,体验必然很差。context-mode 的核心思路就是围绕场景来切换上下文的采集范围和打包策略。我在工具里定义了三种模式:轻量模式(只带当前文件和直接依赖)、平衡模式(带当前目录和关联模块)、完整模式(带全项目关键文件)。后面会详细讲这三种模式怎么配置。
2. context-mode 的核心设计拆解
2.1 四段式架构:收集、过滤、排序、打包
我一开始直接写了一个函数:遍历项目目录,把所有文本文件读进来,拼接成一个大字符串。结果很快就发现不可行——中型项目就有几千个文件,光读取和处理就要几十秒,而且输出根本塞不进上下文窗口。
后来我把整个流程拆成了四个阶段:收集、过滤、排序、打包。每个阶段各干各的事,互不干扰,也好调试。
收集阶段负责找出所有候选文件。这个阶段的关键是目录白名单和黑名单,以及扫描性能。过滤阶段负责剔除无关文件。比如测试文件、构建产物、锁文件、二进制文件,还有超过一定大小的大文件。排序阶段负责给每个候选文件打分,按照与当前任务的相关程度从高到低排列。打包阶段负责把选中的文件装进一条结构化的提示词,同时做 token 预算控制,超了就截断。
这四个阶段里最容易出问题的其实是收集阶段。很多工具在扫描目录时不注意隐藏目录和符号链接,很容易把node_modules或者.git扫进来,轻则垃圾信息一大堆,重则直接把内存干爆。我在实现里对所有目录做了剪枝处理,遇到黑名单目录直接不进入子目录递归。
2.2 关键参数:token 预算、白名单与优先级权重
做 context-mode 之前,我建议你先搞清楚三个参数:模型上下文限制、安全比例、固定开销。
上下文限制是模型允许的最大 token 数,比如 128K 或者 200K。但你不能真的用到 128K,因为还要留出生成回答的空间。安全比例我一般取 0.7,也就是最多用 70% 的窗口存输入。固定开销包括系统提示词、任务指令、格式标记,这些差别不大,但必须预留出来。
所以我算上下文预算的公式很简单:
budget = int(model_limit * safety_ratio) - fixed_overhead比如一个 128K 的模型,安全比例 0.7,固定开销 1200 token,那可用预算就是 88400 token 左右。再把这些预算按比例分给文件:每个文件一个基础配额,再按优先级权重做加权调整。
优先级权重我按经验定义了一套默认值,你可以直接参考:
| 因素 | 权重 |
|---|---|
| 当前激活文件 | +100 |
| 与当前文件同目录 | +30 |
| 与当前文件同类型 | +5 |
| lib / utils 目录 | +10 |
| index / main / config 文件 | +8 |
| 测试文件 | -6 |
| 最近修改的文件 | +15 |
| 被当前文件 import 的文件 | +20 |
注意这套权重是经验值,不是精确规则。实际使用中你会发现不同项目的规律不一样:有的项目核心逻辑都在services目录,有的项目公共组件在components目录。我建议你把权重做成可配置的,别写死在代码里。
2.3 模式切换与组合策略
三种模式不是凭空定的,每个模式的差异主要体现在采集范围上:
| 模式 | 采集范围 | 适用场景 |
|---|---|---|
| 轻量模式 | 当前文件 + 直接 import 的文件 | 修 bug、小改动 |
| 平衡模式 | 当前目录 + 相关子目录 + 最近改动 | 新功能开发、代码审查 |
| 完整模式 | 全项目关键文件 + 文档 | 架构设计、全局重构 |
很容易踩的坑是核心文件会被识别为与某模式无关而被过滤掉。比如在做一个跨模块功能时,关键的状态管理文件在store目录,不在当前目录下,轻量模式根本不会带到上下文里。解决方法是给关键文件加“固定钉”:你可以通过配置文件显式指定必须包含的文件列表,这些文件无论权重多少都会被带上。
我在实现中做了这样的处理:用户可以在.contextmode.json里写pin_files字段,把关键文件路径写进去。这样无论什么模式,都会优先保证这些文件进入上下文。固定钉之外,再按权重排序填充剩余预算。
3. 实操:从零实现一个轻量 context-mode
下面我直接给你看核心实现。我用的 Python,逻辑上手简单,也能方便地扩展成命令行工具。整个代码分为四段,对应前面的四段式架构。
3.1 文件收集与过滤规则
收集阶段我用os.walk遍历目录,但遍历时直接剪枝黑名单目录,避免不必要的性能损耗。
import os from pathlib import Path PROJECT_ROOT = Path(".") BLACKLIST_DIRS = { "node_modules", "dist", "build", ".git", "__pycache__", ".venv", "venv", "coverage", ".next", "target", } WHITELIST_EXTS = { ".py", ".js", ".ts", ".tsx", ".jsx", ".vue", ".go", ".rs", ".java", ".md", ".json", ".yaml", ".yml", ".toml", } def collect_files(): files = [] for dirpath, dirnames, filenames in os.walk(PROJECT_ROOT): dirnames[:] = [ d for d in dirnames if d not in BLACKLIST_DIRS and not d.startswith(".") ] for name in filenames: ext = Path(name).suffix if ext in WHITELIST_EXTS and not name.endswith(".min.js"): full_path = Path(dirpath) / name if is_binary(full_path): continue files.append(full_path) return files这里有两个容易被忽略的细节。第一是dirnames[:] = ...这种写法,它是 Python 遍历目录时动态剪枝的标准做法,如果不加这段,黑名单目录的子目录也会被递归进去,性能会很差。第二是.env、.gitignore这类隐藏文件应该被排除,但Dockerfile、.github/workflows这类文件在某些模式下是有用的,所以我把隐藏目录排除,但单独处理点开头的文件,而不是一刀切。
二进制文件判断我单独写了一个函数,很实用:
def is_binary(path: Path): try: with open(path, "rb") as f: chunk = f.read(1024) return b"\x00" in chunk except OSError: return True原理很简单:文本文件通常不会包含\x00空字节,一旦出现基本就是二进制文件。这个判断比靠扩展名靠谱,因为有些文件扩展名是.txt,实际内容是二进制。
3.2 优先级评分与排序
过滤之后就是给文件打分。打分的基础是两个维度:与当前文件的关联度、与项目核心结构的亲密度。
def score_file(path: Path, active_file: Path | None) -> int: score = 0 rel = path.relative_to(PROJECT_ROOT) parts = list(rel.parts) if active_file and path.resolve() == active_file.resolve(): score += 100 if active_file and rel.parent == active_file.parent: score += 30 if active_file and path.suffix == active_file.suffix: score += 5 if path.stat().st_mtime > max_time - 7 * 24 * 3600: score += 15 if is_imported_by_active_file(path, active_file): score += 20 for p in parts: if p in {"lib", "utils", "shared", "common"}: score += 10 elif p in {"store", "models", "api"}: score += 6 if rel.stem in {"index", "main", "config", "constants"}: score += 8 if "test" in rel.stem or "spec" in rel.stem: score -= 6 return score注意这个实现里is_imported_by_active_file需要额外解析 import 语句,比较麻烦。我的简化做法是用正则匹配 import 路径:
import re def is_imported_by_active_file(path: Path, active_file: Path | None) -> bool: if not active_file: return False try: content = active_file.read_text(encoding="utf-8", errors="ignore") except OSError: return False stem = path.stem return bool(re.search(rf"['\"]{re.escape(stem)}['\"]", content))这个实现虽然粗糙,但已经够用。它只判断“文件名有没有出现在 import 语句的字符串里”,对于路径别名、动态导入会有漏判,但对于大多数项目,这个层面的关联已经能大幅提升上下文命中率。如果你想更精确,可以再解析package.json或go.mod,但那就是另一个复杂度级别了。
排序就简单了,按分数从高到低排,取前 N 个文件。
3.3 token 估算与预算控制
排序完了不代表能直接用,因为你还不知道这些文件加起来占多少 token。我写了一个快速估算函数,不用调 API 就能算:
def estimate_tokens(text: str) -> int: ascii_chars = sum(1 for c in text if ord(c) < 128) non_ascii_chars = len(text) - ascii_chars return int(ascii_chars / 4 + non_ascii_chars * 1.5)这个估算的依据是主流 tokenizer 的编码方式:英文大概 4 个字符一个 token,中文大概 1.5 个字符一个 token。它和真实值会有偏差,比如代码里的换行符和缩进也比较吃 token,但在做预算控制的时候,这个误差完全可接受。你要做的是留出余量,不要把预算卡到 100%。
预算控制的核心逻辑是这样:
def build_context(files, active_file=None): model_limit = 128_000 safety_ratio = 0.7 fixed_overhead = 1_200 budget = int(model_limit * safety_ratio) - fixed_overhead scored = sorted( [{"path": f, "score": score_file(f, active_file), "content": f.read_text( encoding="utf-8", errors="ignore")} for f in files], key=lambda x: x["score"], reverse=True, ) context_parts = [] used = 0 for item in scored: tokens = estimate_tokens(item["content"]) if used + tokens > budget * 0.8: continue context_parts.append(item) used += tokens return context_parts, used注意这里我没有用满 100% 的 budget,而是留了 20% 的余量。原因是文件拼接后还有格式标记,输出侧也需要 token,而且 token 估算本身就存在误差。我建议你也留出这个余量,别把最后几个文件硬塞进去,宁缺毋滥。
3.4 增量上下文与缓存机制
每次全量扫描项目肯定慢,所以我还加了缓存。做法是记录每个文件的mtime和大小,构建一个文件指纹。下次运行只读取发生变化的部分:
import json CACHE_PATH = Path(".contextmode.cache") def load_cache(): if CACHE_PATH.exists(): return json.loads(CACHE_PATH.read_text()) return {} def save_cache(data): CACHE_PATH.write_text(json.dumps(data)) def get_changed_files(files, cache): changed = [] for path in files: stat = path.stat() key = str(path) meta = cache.get(key) if meta is None or meta["mtime"] != stat.st_mtime or meta["size"] != stat.st_size: changed.append(path) return changed这个缓存的细节不多,但很有效。一个几万文件的项目,第一次构建可能需要几秒,后续增量构建基本毫秒级。对 CLI 工具来说,这个体验差异非常明显。
另一个增量思路是结合 git。如果当前目录是 git 仓库,最近改动的文件天然应该优先:
import subprocess def get_git_changed_files(): try: result = subprocess.run( ["git", "diff", "--name-only", "HEAD"], capture_output=True, text=True, timeout=5, ) return result.stdout.splitlines() except (subprocess.SubprocessError, FileNotFoundError): return []这个函数能识别出最近改动但还没提交的文件。配合前面的打分函数,你可以给 git 改动文件额外加上更高的权重。因为它们往往和你当前要处理的任务直接相关。
3.5 输出格式与模型适配
最后一步是输出。我建议用一种结构化的格式而不是纯文本拼接。结构化的好处是模型能更容易区分“这是文件路径”“这是文件内容”“这是任务指令”,注意力会更集中。
我用的格式如下:
<context-file path="src/api/client.ts" token="286" score="45"> export async function request() { ... } </context-file>然后用分隔符包起来:
<context-all> <context-file ...>...</context-file> </context-all> <task> 在 src/pages/index.tsx 中新增一个列表页面 </task>这种格式对 Claude 和 GPT 系模型都很友好。我自己对比过:同样的内容,结构化格式比纯文本拼接的命中率高不少,尤其是在文件多的情况下,模型能更快定位到它需要的文件。
4. 常见问题与排查技巧实录
4.1 token 溢出怎么办
最典型的报错就是提示词超过模型上下文限制。很多人的第一反应是调大窗口或者换模型,但更合理的做法是优化筛选逻辑。我踩过几次坑之后总结了一套排查顺序。
第一步看是不是固定开销漏算了。比如系统提示词写得很长,或者任务指令模板特别啰嗦,这些都在消耗预算。我自己的模板从 2000 多字压缩到 1200 字,预算立刻宽裕很多。第二步看是不是文件分块不完整。很多代码文件超过几百行,整体读取很浪费,可以把大文件按函数或者类拆分,只带相关部分。但这种拆分需要 AST 解析,复杂度高,对大多数场景没必要。
我常用的降载手段是调整 safety_ratio,从 0.7 降到 0.6,虽然会少带一些文件,但稳定性好很多。再配合只取文件前缀部分(比如每个文件只带前 200 行),基本上不会再有溢出的问题。
4.2 上下文内容跑偏
有时候模型生成的答案明显不对,不是代码问题,而是它在一堆上下文里选错了重点。这通常意味着排序权重没调好,不该排在前面的文件排在了前面。
我自己遇到过一次:做一个接口联调的功能,工具把utils/format.ts排到了最前面,因为它是 lib 目录下的公共文件,权重给高了。但模型真正需要的接口定义在api/types.ts里。后来我把“被当前文件 import 的文件”权重从 20 提到 30,同时把公共目录的权重从 10 降到 5,问题就解决了。
所以调试权重时不要光看分数,要实际看输出的上下文顺序。如果你发现某个文件明明很关键却排在后面,直接在配置文件里pin_files把它钉住,比反复调权重更高效。
4.3 二进制与编码污染
上下文里混入二进制文件是非常隐蔽的问题。这类文件不会导致崩溃,但会让模型输出一堆乱码或者“无法解析”。刚才说的\x00判断法基本能挡住绝大多数二进制文件,但还有一些边缘情况:
- 图片的 base64 编码字符串在 JSON 文件里
- 编码不正确的 CSV 文件
- 超大单行文件
我处理这类问题的兜底方案是读取时统一用errors="ignore",同时限制单文件最大长度(比如超过 50KB 的文件直接跳过)。这个策略会让你丢失一些超大文件的细节,但能保证整个提示词的干净。
4.4 收集性能问题
有用户反馈说模式切换要等很久。检查后发现他没有加缓存,每次切换模式都全量扫描整个仓库。加了前面 3.4 节的增量缓存之后,扫描时间从 3 秒降到 200 毫秒。
另一个性能坑是黑名单目录没生效。比如项目里有一个tmp目录,里面动辄几千个小文件,如果没把它加进黑名单,扫描就会卡住。我的建议是黑名单列表尽量写全,宁可多导几个目录也不要图省事。因为收集阶段是 IO 密集操作,多排除一个目录就是实打实的性能收益。
5. 后续扩展方向与我的建议
现在这个 context-mode 还在持续迭代,我个人最想加的功能有两个。
第一个是语义化过滤。目前排序全靠规则打分,但“相关”不是一个完全可以用规则描述的概念。比如“帮我找权限相关的逻辑”,规则很难判断哪个文件最相关,但嵌入模型可以。在收集阶段之后加一层向量检索,用任务描述去匹配最相关的几个文件,再把它们插到权重前列。这个思路我现在已经在测试了,整体效果提升很明显,就是构建向量索引需要额外的存储和算力成本。
第二个是工程级上下文管理。现在只是单个项目内的上下文,如果涉及跨仓库、微服务场景,还需要考虑服务间调用的追踪。比如当前服务调用了另一个服务的接口,那相关接口定义和 mock 数据也应该被带上。这块我在架构上把它抽象成“上下文来源”,每种来源是一个插件,目前已经接入了 git 来源、本地文件来源、接口文档来源,后面还想接数据库 schema。
最后分享一个我实际工作里的建议:把 context-mode 做成一个独立的 CLI 工具,然后用别名调用,会比把它绑死在编辑器插件里好用得多。现在我常用的命令是:
ctx -m balance -f "新增一个批量导出功能" -o context.txt这个命令把收集、过滤、排序、打包一次性做完,输出一个context.txt,我可以直接把它贴到任何模型的对话窗口里。模式和文件都可以指定,灵活性比编辑器内嵌好太多。
如果你的工作流里也经常要跟大模型打交道,强烈建议把“上下文管理”当成一个正经的环节来设计,别再用“把全部代码复制进去”这种粗糙方式。context-mode 看起来是个小概念,但做完整之后,对整个开发效率的提升是肉眼可见的。