ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

context-mode:AI编程助手的上下文管理实战

context-mode:AI编程助手的上下文管理实战 我在调一个开源代码提示插件的时候第一次正儿八经地研究 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与当前文件同类型5lib / utils 目录10index / 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(encodingutf-8, errorsignore) 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_fileNone): 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( encodingutf-8, errorsignore)} for f in files], keylambda x: x[score], reverseTrue, ) 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_outputTrue, textTrue, timeout5, ) return result.stdout.splitlines() except (subprocess.SubprocessError, FileNotFoundError): return []这个函数能识别出最近改动但还没提交的文件。配合前面的打分函数你可以给 git 改动文件额外加上更高的权重。因为它们往往和你当前要处理的任务直接相关。3.5 输出格式与模型适配最后一步是输出。我建议用一种结构化的格式而不是纯文本拼接。结构化的好处是模型能更容易区分“这是文件路径”“这是文件内容”“这是任务指令”注意力会更集中。我用的格式如下context-file pathsrc/api/client.ts token286 score45 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 文件超大单行文件我处理这类问题的兜底方案是读取时统一用errorsignore同时限制单文件最大长度比如超过 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 看起来是个小概念但做完整之后对整个开发效率的提升是肉眼可见的。
返回列表