ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

context-mode:为AI编程助手构建可切换的代码上下文模式

context-mode:为AI编程助手构建可切换的代码上下文模式

那段时间我几乎每天都在跟同一个问题搏斗:代码库越来越大,AI 编程助手却越来越"健忘"。明明改一个 Service 会影响整个模块的依赖注入,模型却自信地给出一个删掉 provider 的建议;明明仓库里有 800 多个文件,我却只能把整个目录拖进对话框,结果 token 烧掉几万,回答还停留在最无关紧要的 utils 工具函数上。我开始意识到,问题根本不是模型不够聪明,而是我从来没有认真回答过一个问题:每一次任务,到底该让 AI 看哪些上下文?

这个问题的答案,就是我做context-mode的起点。它不是一个花哨的框架,也不是什么新算法,而是一套让我把"给 AI 看什么代码"从手工复制粘贴变成可声明、可切换、可复用模式的小工具加工作流。这篇文章我会把完整的设计思路、核心实现、真实配置以及我踩过的坑都写出来,适合正在用 AI 辅助开发、并且被大仓库上下文问题折磨的工程师参考。

1. 让我决心写 context-mode 的两个真实翻车现场

先讲两个我亲身经历的翻车场景,它们直接决定了这个工具的形态。

1.1 翻车现场:模型不知道"改这里会影响注册表"

当时我在维护一个 NestJS 项目,任务是把某个 Service 里缓存逻辑抽出去。因为涉及依赖注入,我特意把那个 Service 文件完整贴给了 AI 助手,想着这肯定够了。结果模型给出的重构方案里,直接把@Injectable()装饰器下面的构造函数参数顺序改了,还建议删掉一个看起来"没用"的 provider。

方案一执行,整个模块在应用启动阶段就报错,提示无法解析依赖。原因很简单:真正决定依赖关系的是同目录下的*.module.ts文件,那段注册 Providers 的代码,还有另一个被注入到构造函数里的接口定义。我贴给模型的上下文里根本没有这些信息,模型只能靠猜,而它猜得还挺有逻辑——只是逻辑建立在缺失的信息上。

这个案例让我明白一件事:代码的上下文不是"文件",而是"依赖关系图"。单独看一个文件,信息就是不完整的;只有把它的调用者、被调用者、依赖声明、类型定义放在一起,才算一份可用上下文。

1.2 翻车现场:token 预算全被"别人家代码"吃掉

另一个更常见的场景是写新功能。为了保险,我把整个src/目录拖进了 AI 对话框,想着"反正模型上下文窗口大,全给它总没错"。

结果是灾难性的。那次任务一共生成了 68,000 多个 token 的内容,模型花了大量篇幅分析仓库里跟本次需求毫无关系的文件——历史遗留模块、过时的工具函数、甚至还有某个已经被废弃的数据迁移脚本。真正涉及的表结构变更、API 路由注册、前端页面调用的那三个文件,反而因为排在大量无关内容之后,被模型"平均用力"地对待,给出的代码直接偏离了项目现有的分层约定。

那时候我发现,AI 的注意力是有限的,上下文窗口再大也架不住噪音。给它塞得越多,它反而越抓不住重点。

1.3 结论:我们要的不是"更多上下文",而是"被选择的上下文"

两次翻车之后我做了个总结:大多数开发者在用 AI 辅助编程时,都在"给太少"和"给太多"之间反复摇摆。给太少,模型缺上下文开始胡编;给太多,模型被噪音淹没,关键信息反而被稀释。

我们需要的是一个中间层——它不是把上下文当作一个固定文件包,而是当作可以按需切换的模式:不同任务(修 bug、做 review、加新功能)需要看不同范围、不同粒度的代码。这就是context-mode的核心思想:上下文不是数量问题,是结构、范围和选择问题。

2. context-mode 的设计:三个组成模块加一次模式切换

想清楚要解决什么问题之后,工具的整体结构很快就定下来了。它由三部分组成:模式定义文件、上下文骨架生成器、内容组装流水线。整个使用过程就是一次"模式切换"。

2.1 模式定义文件:用一段 DSL 描述"这次任务要看什么"

我平时跟团队协作,最怕的就是每个人用 AI 的方式都不样,你贴三个文件,他贴整个目录。所以context-mode的第一步,是把"这次任务看什么"固化成配置文件。

我设计了一个 YAML 格式的模式文件,放在项目根目录的.context/modes/下:

# .context/modes/bugfix.yaml name: bugfix description: 针对某个 bug 的定位与修复,聚焦调用链和依赖关系 rules: include: - "src/**/*.ts" exclude: - "**/*.spec.ts" - "**/*.test.ts" - "src/migrations/**" - "src/vendor/**" focus: # 核心:必须包含的文件,即使 include 规则没命中也会强制加入 required: - "src/app.module.ts" - "src/**/*.module.ts" # 骨架:需要生成符号级摘要的文件目录 depth: 2 transform: maxFileTokens: 4000 keepImportLines: true includeCallers: true includeCallees: true instructions: | 你是一名资深后端工程师。下面是某次 bug 修复任务的上下文包。 请先阅读代码骨架,理解模块间的依赖关系,再针对焦点文件给出修复方案。 注意:只修改被明确标记为 FOCUS 的文件,不要顺手重构其他模块。

字段设计上,我刻意区分了三类规则:include是"哪些文件可能相关",focus是"哪些文件必须精读",transform是"在组装上下文时如何裁剪加工"。这三者分开,是因为它们在流水线里处于不同阶段,混在一起写会导致配置极难排查。用配置文件而不是写死在代码里,是因为模式是需要团队沉淀的资产——新人来了看一眼配置就知道项目里常见的任务长什么样,而不是各自为战。

2.2 上下文骨架生成器:先让模型"知道项目长什么样"

很多人会直接把代码全文塞给 AI,但我不这么做。context-mode在真正组装上下文之前,会先生成一份"骨架":用 tree-sitter 解析代码,提取出每个文件里的类、函数、接口、关键类型定义,以及它们之间的调用关系。

骨架长这样:

## 模块: billing 文件: src/billing/billing.service.ts (215 行) 类 BillingService 方法 createPaymentIntent(data: CreatePaymentIntentDto): Promise<PaymentResult> 调用: userRepo.findByExternalId, billingConfigRepo.getActiveConfig 方法 handleWebhookEvent(rawBody: string): Promise<void> 被调用: PaymentsController.postWebhook 依赖导入: UserRepository from '../user/user.repository' BillingConfigRepository from './billing-config.repository'

为什么不直接把代码都贴上去?因为骨架给的是"地图",模型看地图就能建立对项目结构的认知,比盲目阅读全文高效得多。真正需要模型精读的,只有focus标记的少数文件。这符合一个很朴素的认知:人看新项目,也不会一上来读全部源码,而是先看目录结构、类图、关键调用链。

2.3 内容组装流水线:过滤、裁剪、补充、打包

所有配置最终都要过一遍流水线。我把链路分成四个步骤,每一步都有对应的可观测输出:

  1. 文件树收集:基于git ls-files获取仓库内受版本控制的文件列表,天然过滤掉node_modules、dist等未跟踪目录。
  2. 规则过滤:依次应用include、exclude规则,生成候选文件列表。
  3. 代码块裁剪:对候选文件做函数级裁剪——只提取符号对应的 AST 节点,而不是整个文件;同时保留 import 头、类型定义等必要上下文。
  4. 组装与统计:把"骨架 + 焦点文件代码 + 自定义指令"打包成一个 Markdown 文件,输出到指定位置,并打印 token 预估和文件命中统计。

流水线的设计原则是"每一步只做一件事"。最初我想把过滤和裁剪放在一起做,省一次遍历,后来发现当某个文件意外被裁空时,根本分不清是规则写错了还是 AST 解析失败了。改成四步之后,配合--debug参数,每一层都能输出中间结果,排查问题的时间从小时级降到了分钟级。

2.4 模式切换的"默认值"设计:安全兜底

为了避免有人图省事不写配置直接裸奔,我设计了一套安全默认值。当没有指定任何模式时,context-mode仍然会工作,但行为极其保守:

  • 只包含src/下受版本控制的源码文件
  • 默认排除测试文件、迁移脚本、生成代码、锁文件
  • 所有内容包括在内时打上只读标记
  • 明确要求模型"只读,不修改任何文件"

这套默认值后来帮了大忙。有一次我在 CI 环境里忘了指定模式,工具没有报错,而是以安全默认值启动,避免了把整个仓库的测试文件打包给模型的灾难。默认值必须安全,而不是必须功能强大——这是我在这个项目里学到的重要教训。

3. 从零搭一个可用的 context-mode:关键技术选型与实现

纸上谈兵结束,下面说具体实现。这部分是给想复刻或者改造的人看的。

3.1 语言选择:为什么先用 TypeScript 而不是 Go 或 Rust

我把原型对比过 Rust 和 TypeScript。Rust 的性能确实好,一个几十万行代码的仓库,全量解析几十秒内能跑完;但我的首要需求不是性能,而是生态——我需要在 VSCode 里做插件,需要跟现有 Node.js 工具链无缝集成,还需要快速迭代配置格式。TypeScript 只需要一个 npm 包就能随处运行,还能直接复用fast-glob、picomatch、yaml这些成熟库。

而且context-mode的瓶颈根本不在解析速度上,而在"规则设计"和"上下文组装策略"上——这些都是逻辑问题,TypeScript 完全够用。最后我选了一个折中:核心用 TypeScript,但符号提取的部分调用 tree-sitter 的 WASM 版本,性能关键路径再单独优化。

3.2 代码结构解析:tree-sitter 还是正则硬啃?

这是最核心的技术决策。我对比过三种方案:

方案优点缺点我的取舍
tree-sitterAST 语义精确,能拿准函数/类边界需要配置语言包,WASM 加载有成本最终采用
ctags / universal-ctags速度快,多语言通用只有符号定位,没有结构边界信息用于兜底提取
正则硬匹配零依赖,最简单大括号嵌套、注释字符串里全踩坑只用来提取"TODO/关键行"

最终选了 tree-sitter,但它带来的复杂度比我想象中高。最麻烦的是每个语言的 AST 结构都不一样:TypeScript 里函数声明是function_declaration,Python 里是function_definition,JavaScript 里则有可能是arrow_function嵌在变量声明里。这意味着我写了一套查询配置:

const languageQueries: Record<string, string> = { typescript: ` (function_declaration name: (identifier) @name) @function (class_declaration name: (type_identifier) @name) @class (method_definition name: (property_identifier) @name) @method `, python: ` (function_definition name: (identifier) @name) @function (class_definition name: (identifier) @name) @class `, }

然后用 tree-sitter 的查询 API 拿到所有符号的起始位置和结束位置。这让我能在字节级别精确地切割函数体,而不是靠大括号配对这种碰运气的方式。

3.3 函数级裁剪的实现细节:裁剪边界与上下文前缀

拿到符号位置后,真正的难点来了:如何在不破坏可读性的前提下裁剪函数代码。

一开始我简单粗暴地把函数体截取出来,结果模型看到的就是一堆孤零零的函数,缺了 import、缺了类型别名、缺了外部依赖的 key。这种上下文模型根本没法用。后来我调整成"保留上下文前缀"策略:

  • 默认保留文件开头的所有import语句,因为类型依赖集中在这里;
  • 保留被裁剪函数直接引用的类型声明(通过 AST 查询函数的identifier引用,再反查同文件里的类型定义);
  • 函数体内部不做二次删除,完整保留。

按这个策略,一个 400 行的文件,裁剪后通常只剩 80~120 行:30 行 import,10 行类型定义,加上 1~2 个完整函数。实测下来,模型对这些裁剪过的代码的理解准确度,明显高于完整文件——因为噪音更少,关键路径更突出。

3.4 token 预估与多模型适配

token 估算这件事,比我想象中复杂。不同的模型 tokenizer 差异巨大:Claude 系列、GPT 系列、本地跑的 Llama 系,对同一段代码的分词结果完全不同。

一开始我图省事,直接用"字符数 / 3"估算,结果发现对中文注释严重低估——一个中文字往往只算 1 个 token 而实际可能是 1.5~2 个;对纯代码又高估,很多符号可以合并成一个 token。后来我改成按内容分类加权:代码行按字符数除以 4,注释和字符串行除以 2,纯中文文本再单独打折。精度上升了不少,但最后还是接入了正确的 tokenizer——用 tiktoken 处理 OpenAI 兼容模型、用@dqbd/tiktoken和 Claude 的官方 tokenizer 包处理各自生态,精度才算真正达标。

多模型适配还涉及输出格式。同一个上下文包,发给 Claude Code 时习惯用file_path加代码块的格式,发给 Cursor 或 GitHub Copilot 时又偏爱纯 Markdown 拼接。我在配置文件里加了一个target字段,指定不同的渲染模板。

3.5 CLI 与编辑器集成的最小实现

工具必须足够顺手才有人用,所以我做了一个极简 CLI:

# 构建 bugfix 模式上下文包,输出到 .context/output/bugfix.md context-mode build --mode bugfix --output .context/output/bugfix.md # 构建后直接复制到剪贴板 context-mode build --mode review --clipboard # 打印当前模式的文件命中统计和 token 预估 context-mode build --mode feature --stats # CI 里校验模式定义是否合法 context-mode validate

VSCode 集成也做了,但不复杂,就是绑定一个快捷键:选中当前文件名,一键执行对应模式,把生成的上下文包作为注释块插到聊天输入框里。核心逻辑还是 CLI,编辑器只是个入口。我个人更推荐把这个命令接到 Claude Code 的/context命令或者 Cursor 的自定义指令里,毕竟现在的 AI 编程工具都已经原生支持引用文件了。

4. 三种真实模式配置与输出样例

光讲设计不落地等于白写。这一节是我日常工作中最常用的三种模式,直接照着配置就能用。

4.1 bugfix 模式:聚焦调用链

修 bug 时,模型最需要的信息是三样:报错位置的代码、该函数被谁调用、该函数调用了谁。我的 bugfix 模式用两个开关实现:

transform: includeCallers: true includeCallees: true

includeCallers会让流水线在裁剪时,额外引入当前文件的调用方签名;includeCallees则引入被调用方。配合 call graph 信息,模型能准确判断"这个改动会不会引发连锁错误"。

实际输出的上下文包结构如下:

## 任务说明 修复支付回调重复入账的问题。焦点文件为 src/billing/billing.service.ts 中的 handleWebhookEvent。 ## 项目骨架(摘要) ...(全部 547 个符号,仅保留名称与调用关系) ## 焦点代码 文件: src/billing/billing.service.ts (裁剪后: 147 行 / 原始 263 行) ## 调用方上下文(签名级) - PaymentsController.postWebhook -> billingService.handleWebhookEvent - PaymentEventsSubscriber.onPaymentSucceeded -> billingService.handleWebhookEvent ## 被调用方上下文(签名级) - userRepo.findByExternalId: (externalId: string) => Promise<User> - billingConfigRepo.getActiveConfig: () => Promise<BillingConfig>

同样一个任务,全库打包要 67,000 token,用 bugfix 模式只需要 3,800 token,而且首轮回答的准确性明显提升。有一次还因为它带了调用方签名,模型主动提醒我"这个函数同时被事件订阅器调用,需要考虑事件重放的幂等性"——这个细节我手动整理上下文时从来没注意过。

4.2 code-review 模式:关注变更与影响面

代码评审最怕的就是审阅者不了解这段代码在项目里的位置。review 模式不基于固定文件,而是基于git diff:

name: review rules: include: - "git-diff" exclude: - "**/*.lock" - "*.lock" focus: # 变更文件自动成为焦点 fromDiff: true # 同时引入变更文件的直接调用方 includeCallers: true transform: maxFileTokens: 3000

流水线执行时先跑git diff --name-only,把变更文件作为焦点,再通过 call graph 找到它们的影响面。输出会额外生成一段变化概览:

## 变更影响面 - src/user/user.service.ts (变更) 影响: src/auth/auth.service.ts, src/billing/billing.service.ts - src/user/user.repository.ts (新增) 影响: src/user/user.service.ts

这个模式在团队里推了之后,review 效率提升比我想象中明显。大家以前是"我只看diff",现在能带着上下文看diff,很多设计层面的问题在 review 阶段就暴露了。

4.3 feature 模式:跨模块新增功能

写新功能时最麻烦的是上下文分散在多个模块。比如实现一个"用户积分功能",可能要同时看user模块、billing模块、database schema、api route。feature 模式的做法是指定一个目录集合,并强制包含 schema 和路由文件:

name: feature rules: include: - "src/**/*.ts" - "prisma/schema.prisma" - "src/**/*.route.ts" - "src/**/*.controller.ts" focus: required: - "src/app.module.ts" - "prisma/schema.prisma" transform: maxFileTokens: 5000 keepImportLines: true includeCallees: true

这个模式通常跨时长,一次对话可能要几轮,所以我会额外输出一个"已有标记清单",把已决策的信息(比如表名、字段类型)以摘要形式放在上下文包最前面,方便模型在后续对话中反复确认,不会因为对话轮次多了就遗忘。

4.4 模式的组合与复用:base 模式

模式多了之后,我发现每个配置里都有相同的字段,比如"排除测试文件""排除生成文件""保留 import 前缀"这些全局约定。于是加了extends支持:

name: feature extends: base # 只写 feature 特有的差异

base模式里统一放语言约定、项目说明、通用排除规则,子模式只写差异。这样团队加新模式时,只需要关心"这次任务有什么不同",而不是复制粘贴一大堆公共配置。我强烈建议用这套方法——否则配置文件会成为新的维护负担,而工具本身也变成了一种"上下文灾难"。

5. 我踩过的坑:上下文工具自己也需要"上下文"

工具写出来是一回事,真正用起来是另一回事。这部分是我被现实教育后的记录,每一个坑都实打实让输出质量降级过。

5.1 坑一:剪裁过度导致"局部代码孤立"

第一次用函数级裁剪时,我特别激进,只保留目标函数体。结果模型总是问我:"这个函数返回的类型PaymentResult是什么?""userRepo是从哪来的?"——我明明已经保留了 import 和类型定义,但还是漏了关键信息。

排查后发现,问题出在"间接依赖"上。PaymentResult这个类型可能不在 import 列表里,因为它是在某个d.ts文件里通过 namespace 挂到全局的;userRepo可能在构造函数里被注入,但构造函数本身没有被裁剪进去。

解决办法是增加一个"引用完整性检查":裁剪完代码后,扫描所有 identifier,检查在可视范围内是否有对应定义;没有定义的就从全库符号表里尝试匹配,把缺失的定义追加到上下文包末尾。这个检查逻辑很笨,但效果立竿见影。裁剪上下文是一道减法题,但减完之后必须证明剩下的部分仍然自洽。

5.2 坑二:token 统计在中文注释上差 3 倍

这个问题已提过,但它值得再展开一次。我的项目有大量中文注释,第一次用"字节数/3"估算时,预估值只有 1,800 token,实际上传到模型后显示 5,200 token。也就是说,估算和实际差了将近 3 倍。

后来的修正方案是按行分类统计:纯代码行按字节/4 估算,注释行按字节/1.5 估算,中文字符再乘 1.2 系数。这个粗糙的启发式方法准确率能到 85% 左右。等到接入真实 tokenizer 之后才算彻底解决。我看很多类似工具都忽略了这个问题,但这直接影响用户会不会超额——特别是用免费额度的时候,超了真的会中断任务。

5.3 坑三:glob 规则叠加导致模式"悄悄失效"

这是让我排查最久的一个问题。某个模式配置了exclude: ["src/legacy/**"],而项目的.gitignore里恰好也有legacy目录。文件树收集阶段是基于git ls-files的,所以 legacy 目录下的文件本来就不会出现在候选列表里;但exclude规则又会再过滤一次。

看起来没问题,直到有一天我加了--debug才发现:一个被标记为focus.required的文件,因为同时命中了.gitignore和模式里的 exclude,被第二道过滤静默剔除了。整个模式看似成功,其实核心文件根本没进上下文。

解决方案是在--debug输出里增加三块信息:每个规则的命中次数、最终文件列表、以及为什么某些 required 文件没有被包含。排查这类问题,最忌讳的是静默失败——宁可多打日志也不让工具"悄悄做决定"。

5.4 坑四:大型仓库的解析性能

最开始在公司的核心仓库(1,200 多个 TypeScript 文件)上跑context-mode build,tree-sitter 全量解析需要约 2 分钟。这个时间对命令行工具来说太长了。

我做了三个优化:

  1. 按需解析:先根据规则过滤出候选文件,只对候选文件做 AST 解析。这个改动直接把 2 分钟降到 30 秒左右。
  2. 增量缓存:在.context-cache/目录缓存文件哈希与解析结果,只有文件变更时才重新解析。二次运行通常在 5 秒内完成。
  3. 并行解析:用worker_threads按文件数分片并行,8 核机器上能再快 3~4 倍。

优化之后,日常使用的体验基本无感。我还是很建议做缓存的,因为上下文工具本身也是每天要跑多次的工具,如果每次都要等几十秒,人很快就会放弃用它。

5.5 坑五:团队协作中模式文件漂移

工具引入团队后,配置文件本身也成了需要维护的代码。我们遇到过:某个目录重构后,配置文件里的路径全失效了,但没有任何报错;某个模式被加上了过强的 exclude,导致新代码永远进不了上下文;还有一次有人把mode: bugfix写成了mode: bug-fix,字段校验没拦住,模式直接空转。

解决办法有三层:第一层是 JSON Schema 校验,保证字段名、枚举值合法性;第二层是启用了context-mode validate作为 CI 检查项,拉取最新文件树,对比模式里所有 include/exclude 规则的命中情况,命中数为 0 就告警;第三层是人肉约束——模式文件也要走 code review,如果修改模式定义,提交信息里必须附上之前和之后的 token 对比。这三层下来,配置文件漂移的问题基本得到控制。

6. 这个工具给我的日常带来了什么变化

工具稳定运行了几个月,我记录了它给工作流带来的真实改变。

任务类型平均 token 使用首轮回答可采纳率人工返工次数(每周)
手写上下文45,000+约 40%8~10
使用 context-mode4,500约 75%2~3

数字只是表面,我更看重的是思考方式的变化。以前我花大量时间"整理上下文",本质是在手动维护一个隐形的知识图谱;现在我把这个图谱本身变成了可声明的配置,每次新建模式,都是一次对项目架构的重新梳理。

这款工具并不是万能的。它没有解决"需求本身不清楚"的问题——如果任务目标模糊,上下文再准也没用;它也没办法帮你判断一个方案是否设计得优雅,那是模型和人的事。它做的最重要的一件事,就是把"该给 AI 看什么"从一个每次都要重新思考的问题,变成了一个沉淀好的、按一下就能用的工程资产。

如果你也在大仓库里用 AI 辅助开发,我的建议很直接:不要急着优化 prompt,先把你项目的上下文分层设计好。定义三五个模式——修 bug 的、做 review 的、加功能的——比你背一百个提示词技巧都管用。项目代码的组织方式,才是 AI 最好的提示词。context-mode只是把我这个想法变成了一个可以每天用的命令行工具,而真正让工作发生变化的,是开始认真对待"上下文"这件事本身。

返回列表