1. context-mode 是什么,为什么我离不开它
最近在重构自己的终端工作流,绕不开一个词:context-mode。如果你经常用 AI 辅助写代码,一定会遇到这种尴尬场景——明明你在自己的项目里问了一个很具体的问题,AI 却给了一个通用到像搜索引擎摘要一样的答案,因为你没告诉它你当前在哪个目录、用的什么语言、刚改到哪个文件、跑在什么分支上。context-mode 解决的就是这个问题:让 AI 工具自动感知你的开发上下文,把环境信息、项目状态、最近操作一并打包,注入到每一次提问里。
说白了,context-mode 就是一套“上下文采集与注入机制”。它监听你的工作区状态,采集 git 分支、变更文件、依赖信息、运行环境等元数据,然后按固定模板组装成一段结构化描述,在 AI 对话或命令调用时自动附加上去。这个模式在 Warp、Cursor、Continue 这类现代开发工具里已经以不同形式存在,但如果自己动手做一遍,你会对“上下文”三个字有完全不一样的理解。
这篇文章我把整套实现思路、代码方案、踩坑记录都整理出来了。适合正在做 AI 辅助开发集成、或者单纯想让自己的 CLI 工具更聪明的开发者参考,也适合那些觉得“AI 不懂我的项目”的人——问题往往不在模型,而在你没给它足够的 context。
2. 整体设计:把“环境感知”拆成三层
2.1 核心思路:上下文不是一股脑塞给 AI
第一次做 context-mode 的时候,我踩了个典型误区:恨不得把整个项目都塞进 prompt。真实情况是,AI 的上下文窗口虽然越来越大,但信息密度才是关键。你要的不是把 README、代码全文、目录树都打包进去,而是用一套结构化的、有选择性的描述,让模型在最短时间内建立起对你当前工作状态的准确认知。
我的设计思路是把 context 拆成三个层级:
- 全局上下文:系统环境信息,比如 OS 类型、Node 版本、包管理器、Shell 类型。这部分几乎不变,只需要采集一次,可以缓存。
- 工作区上下文:当前项目的信息,包括项目目录名、git 分支、变更文件列表、最近一条 commit 信息、依赖管理器。这部分在切换分支、增删文件时变化,建议在每次提问前动态采集。
- 任务上下文:用户当前要做什么。这通常来自用户输入的自然语言,再加上最近的操作记录(比如刚打开的文件、最近执行的命令),帮助 AI 理解“现在正在做什么”。
三段信息拼起来,才是完整的 context。少了任何一层,AI 的理解都会偏差。比如你只告诉它分支名,它不知道仓库里有没有未提交的破坏性改动;你只告诉它改了哪些文件,它不知道这个项目用的是 pnpm 还是 npm,给出的安装命令可能就水土不服。
2.2 为什么选 CLI 脚本而不是 IDE 插件
实现 context-mode 有两条路:做成 IDE 插件,或者做成独立的 CLI 工具。我选了后者,原因很实际。
IDE 插件虽然能拿到更丰富的编辑器状态,但绑定具体平台,VSCode 的插件没法直接在终端里用,而且插件 API 频繁变动,维护成本高。CLI 脚本的优势在于通用性——它不关心你用的是 VSCode、Neovim 还是 JetBrains,只要能在 shell 里执行,就能把上下文采集出来,通过管道传给任意 AI 工具。而且 CLI 可以方便地集成进 git hook、shell prompt、CI 流程,扩展性比插件强得多。
另一个考量是调试方便。CLI 脚本的输出是明文的,你可以直接跑一遍看它到底采集了什么、格式对不对。插件里的上下文往往是隐式的,出了问题你根本不知道 AI 到底收到了什么信息,排查起来很痛苦。context-mode 的核心价值就是“可观测”,这一步我必须保留。
2.3 技术选型:Node.js 做胶水层
具体实现我用了 Node.js,原因倒不是它性能多好,而是生态合适。Node 的子进程模块对 shell 命令的调取非常顺手,JSON 处理也自然,而且前端和 Node 开发者都熟悉 JavaScript,后续想集成到编辑器插件里,语言栈可以复用。
如果你更习惯 Python,用 subprocess 也能达到同样效果,核心逻辑不变,只是语法差异。这里有一个原则:采集层尽量少做重逻辑,把 shell 命令的执行和输出解析作为唯一职责。上层如何组装、如何注入,和采集层解耦,这样未来换语言重写,或者加新的上下文来源,都不影响整体架构。
3. 核心细节解析与实操要点
3.1 上下文采集:每一条命令都有选择理由
先看最核心的采集模块。它要做的事情是执行一组 shell 命令,并把结果整理成可供后续解析的结构化数据。下面是我的实现片段:
const { execSync } = require('child_process'); function run(cmd, fallback = '') { try { return execSync(cmd, { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return fallback; } } function collectWorkspaceContext() { return { cwd: process.cwd(), projectName: process.cwd().split('/').pop(), gitBranch: run('git branch --show-current', 'unknown'), gitStatus: run('git status --porcelain', '').slice(0, 500), lastCommit: run('git log -1 --oneline --no-decorate', 'no commits yet'), nodeVersion: run('node -v', 'not available'), packageManager: detectPackageManager(), changedFiles: parseChangedFiles(run('git status --porcelain', '')), }; }我逐个解释为什么选这些字段。
git branch --show-current比git branch再 grep * 的方式高效得多,它只输出当前分支名,不会有彩色标记和多余字符,解析零成本。git status --porcelain是给程序解析用的状态格式,每一行都是XY path的固定结构,第一列是暂存区状态,第二列是工作区状态,??表示未跟踪的新文件,M表示已修改。这个格式非常稳定,不会因为 git 版本更新而改变。
detectPackageManager的实现也值得说一下:依次检查pnpm-lock.yaml、yarn.lock、package-lock.json的存在性,再加上bun.lockb。真实的项目里,lock 文件比 package.json 里的packageManager字段更可靠,因为有人会手动改字段,但 lock 文件一般是工具生成的,不会骗人。
function detectPackageManager() { const fs = require('fs'); if (fs.existsSync('pnpm-lock.yaml')) return 'pnpm'; if (fs.existsSync('yarn.lock')) return 'yarn'; if (fs.existsSync('package-lock.json')) return 'npm'; if (fs.existsSync('bun.lockb')) return 'bun'; return 'unknown'; }3.2 组装策略:控制 Token 预算与信息密度
采集到的原始信息不能直接拼到 prompt 里,必须要经过一个组装层。这里关键是信息密度的权衡:Token 太多,AI 的理解不一定更好,反而会稀释真正重要的信号。
一个实用的策略是分层截断。全局上下文控制在 200 个 Token 以内,工作区上下文控制在 800 个 Token 以内,任务上下文不设限。对于git status --porcelain的输出,如果变更文件超过 20 个,就只保留前 20 个,后面注明“+N more”。对于超长的路径名,按目录聚合,比如把src/components/Button.tsx和src/components/Modal.tsx合并成src/components/下的 2 个文件变更。这样既有细节,又不至于刷屏。
组装顺序也有讲究。我把最敏感的“当前状态”放在最前面,比如分支名、变更数量、最近的 commit。因为这些信息是 AI 回答问题时最依赖的锚点——你改了哪些文件直接决定了 AI 应该在哪里找问题。系统环境信息放在中间,作为辅助参考。任务本身的描述放在最后,紧接用户输入。
实际组装出来的格式是这样的:
[context-start] project: my-app (pnpm) branch: feature/user-login changed: src/api/client.ts (M), src/components/LoginForm.tsx (M) last-commit: 2e1f3d0 feat: add login form skeleton node: v20.11.0 os: darwin-arm64 [context-end] 用户问题:...这个明文格式的好处是肉眼可读,任何一步出问题都能快速定位。不需要 JSON,因为 prompt 是给模型看的,不是给程序看的,可读的标记组合反而更容易让模型捕捉到边界。
3.3 注入方式:环境变量、管道、还是 API 封装
采集完上下文,接下来要解决“怎么送进 AI”的问题。我试过三种方式,各有适用场景。
第一种是环境变量注入。把组装好的 context 字符串放到CONTEXT_MODE_DATA环境变量里,AI 工具启动时读取。这种方式适合那种自己封装了模型调用的场景,代码里通过process.env.CONTEXT_MODE_DATA就能拿到。缺点是有长度限制(具体取决于系统,一般够用),而且环境变量在子进程里可见,做多租户隔离时注意别泄露。
第二种是管道前插。直接把 context 拼在用户输入前面,通过标准输入传给命令行 AI 工具。比如我经常用这种组合:
context-mode collect | xargs -I{} sh -c 'echo "{}" && cat -' | some-ai-cli这种方式灵活,但问题是每个 AI 工具的交互协议不同,xargs拼接容易出转义问题。如果只是自己用,可行;如果要分发给团队,我建议走第三种。
第三种是把 context-mode 封装成一个本地 HTTP 服务,AI 调用方通过 API 请求获取上下文。这种方式最解耦,但对你自己的基础设施要求更高。如果是在本地开发环境下使用,其实用不到这种重量级方案。
4. 实操过程与核心环节实现
4.1 环境准备
动手之前,先把环境准备好。我用的是 macOS + Node.js 20,但下面的代码在 Linux 和 Windows WSL 下同样适用。需要确认三件事:
- Node.js 16 以上版本可用(
node -v检查) - git 已初始化且当前在仓库内
- 本地有可执行权限的目录,比如
~/bin或/usr/local/bin
这里有个经验教训:Node 版本太老会导致execSync的timeout和stdio选项表现不一致。老版本不会报错,但会忽略某些参数,导致命令挂死的时候,脚本没有兜底机制。所以如果脚本莫名卡住,先检查 Node 版本。
4.2 完整脚本:模块拆分与主流程
把脚本拆成三个文件,职责清晰:collector.js负责采集,assembler.js负责组装,main.js负责编排。
先看collector.js的完整实现:
// collector.js const { execSync } = require('child_process'); const fs = require('fs'); function run(cmd, fallback = '') { try { return execSync(cmd, { encoding: 'utf8', timeout: 3000, killSignal: 'SIGKILL', }).trim(); } catch { return fallback; } } function detectPackageManager(cwd) { const targets = [ ['pnpm', 'pnpm-lock.yaml'], ['yarn', 'yarn.lock'], ['npm', 'package-lock.json'], ['bun', 'bun.lockb'], ]; for (const [name, file] of targets) { if (fs.existsSync(`${cwd}/${file}`)) return name; } return 'unknown'; } function collect() { const cwd = process.cwd(); return { cwd, projectName: cwd.split(/[\\/]/).pop(), gitBranch: run('git branch --show-current', 'not-a-repo'), gitStatus: run('git status --porcelain', ''), lastCommit: run('git log -1 --oneline --no-decorate', ''), packageManager: detectPackageManager(cwd), }; } module.exports = { collect };这里有几个细节值得注意。execSync的killSignal我设成了SIGKILL,而不是默认的SIGTERM。因为有些 shell 命令对SIGTERM不敏感,比如一个卡住的git log,SIGTERM可能等不到进程退出。SIGKILL虽然粗暴,但保证调用方不会被拖死。
cwd.split(/[\\/]/).pop()兼容 Windows 路径。很多脚本用split('/'),在 Windows 上拿到的项目名是空字符串,这个小坑我踩过。
再看assembler.js:
// assembler.js function assemble(raw) { const changedFiles = raw.gitStatus .split('\n') .filter(Boolean) .slice(0, 20); const lines = [ '[context-start]', `project: ${raw.projectName}`, `package-manager: ${raw.packageManager}`, `branch: ${raw.gitBranch}`, `changed: ${changedFiles.length > 0 ? changedFiles.join(' | ') : 'none'}`, raw.lastCommit ? `last-commit: ${raw.lastCommit}` : '', `node: ${raw.nodeVersion || 'unknown'}`, `cwd: ${raw.cwd}`, '[context-end]', ]; return lines.filter(Boolean).join('\n'); } module.exports = { assemble };主流程main.js负责调用这两个模块,并根据参数决定输出格式:
// main.js #!/usr/bin/env node const { collect } = require('./collector'); const { assemble } = require('./assembler'); const args = process.argv.slice(2); if (args.includes('--json')) { process.stdout.write(JSON.stringify(collect(), null, 2)); } else { process.stdout.write(assemble(collect())); }--json参数是我后来加的。用纯文本组装格式给 AI 看,用 JSON 格式给程序看。团队协同场景里,有人需要把 context 作为参数传给自己的封装函数,JSON 就派上用场了。这个双输出设计花不了几分钟,但让工具适用面广了很多。
4.3 分支与仓库边界处理
context-mode 最容易被忽略的边界,是不在 git 仓库里的情况。你平时可能不觉得,但node -e随便跑一下脚本,一旦目录不在仓库里,git status就会报错。我在run()函数里加了兜底:命令执行失败的时候返回一个默认值,这样采集函数永远能返回完整对象,而不会中断。
更精细的处理方法是判断git rev-parse --is-inside-work-tree,但这会多跑一个子进程,性能上有损耗。权衡之下,我选择用返回值的gitBranch === 'not-a-repo'来识别非仓库状态。这个识别逻辑虽然不够优雅,但胜在采集稳定。
仓库边界还有一个陷阱:在.git目录内部执行脚本。比如你在.git/hooks/下跑,git branch --show-current可能拿到空值。不过大部分用户不会在这种场景下用 context-mode,我做了默认值兜底就够了。
4.4 性能优化:子进程开销压到 100ms 以内
有人会问,每次提问都要执行好几条 shell 命令,累不累?实测下来,一次完整的采集大概 20-60ms,其中git status --porcelain是最耗时的,在大仓库上可能到 100ms 以上。这个量级在交互场景里完全无感,但如果要做成事件驱动的自动补全,就得考虑缓存了。
我的优化方案是分级缓存。全局环境信息(OS、Node 版本)一次会话只采集一次,后面直接复用。工作区状态每次采集,但设置 500ms 的防抖——如果你连续打三个问题,只采集一次。这样既保证了信息新鲜度,又不会浪费性能。
对于超大仓库的git status,还可以把命令换成git status --porcelain --untracked-files=no,忽略未跟踪文件输出,性能会提升很多。代价是 AI 看不到新文件,但这在大多数情况下其实无关紧要——新文件本来就没有内容可以分析。
5. 常见问题与排查技巧实录
5.1 上下文太长,Token 消耗失去控制
这是最容易翻车的问题。不设上限地采集状态,一个几千文件的大仓库,git status --porcelain输出可能上万字符。我第一次测试的时候,一次 prompt 就烧掉了将近 4000 Token,而且大部分都在重复输出文件名。
解决办法是三管齐下。第一,gitStatus输出截断到 500 字符;第二,变更文件数量超过 20 个时只保留前 20 个;第三,对文件名按目录聚合,把粒度从“文件”提升到“目录”。比如你只改了src/utils/date.ts,context 里体现src/utils/足以让 AI 定位到相关领域,不一定要精确到文件。
还有一个容易被忽略的点:不要把完整命令历史塞进 context。我试过把history的最后 20 条放进去,看似有用,实际上噪声极大。用户执行的命令大多数和当前问题无关,AI 反而会被误导。真要加命令历史,至少要按目录过滤,只保留当前项目路径下的命令。
5.2 敏感信息被带进了 Prompt
这个坑比较隐蔽。我当时把process.env的键值对全部采集进去,想着 AI 能更懂环境配置。结果 debug 的时候发现,有人的环境变量里有云服务的 access key。虽然只是本地工具,但一旦你走 HTTP API 方式把 context 发给远程模型,密钥就等于裸奔了。
安全策略如下:默认完全忽略环境变量采集,除非显式指定CONTEXT_MODE_INCLUDE_ENV白名单列表。所有输出前过一道内容过滤,把形似密钥的正则模式(AKIA开头、sk-开头、长 Base64 字符串)替换成[REDACTED]。这层过滤放在组装层之前,确保任何来源的上下文数据都不会绕过。
5.3 分支切换后上下文过期
典型的时序问题:用户先问了问题 A,AI 给出答案之后,用户切了分支、改了代码,紧接着又问问题 B。如果 context 是启动时采集一次、之后全程复用的,问题 B 拿到的就是过期状态。
解决方案是给每段 context 打上时间戳和指纹。指纹用git status --porcelain输出的哈希值,只要工作区变了,哈希就变。注入前比对指纹,不一致就触发重新采集。这个逻辑不复杂,但能避免大量“AI 为什么瞎了”的困惑场景。
5.4 Shell 命令执行的安全与挂死处理
采集层通过execSync执行 shell 命令,本质上是把外部输入交给了 shell。虽然这里的命令是硬编码的,但如果你后续扩展成允许用户自定义采集命令,就必须做命令白名单校验,否则就是妥妥的命令注入。
挂死处理也一样重要。execSync的timeout: 3000保证了单个命令不会超过 3 秒。但要注意,Shell 命令的执行时间实在难预估,git status在巨型仓库上可能真的会跑几秒。所以超时时间你可以按环境调节,但逻辑上不要用“无限等待”作为兜底——那会把整个工具变成僵尸进程。
我在实际使用中还遇到过execSync报EAGAIN的情况,这是系统的进程资源临时耗尽。用try-catch包裹后返回默认值即可。这类系统级错误不值得处理,但要保证它不炸掉主流程。
6. 进阶玩法:让 context-mode 真正融入工作流
6.1 对接 AI 聊天工具与本地模型
把 context-mode 接入你常用的 AI 工具,是我做这个项目之后觉得最值的一件事。以 OpenAI 兼容 API 为例,你可以在对话的 system prompt 前拼接 context,也可以在每次用户消息前自动插入。我倾向于后者,因为 system prompt 的更新机制在有些封装里比较隐晦,直接插到用户消息里最透明。
如果你用的是本地模型(比如通过 Ollama 跑 Qwen 或 Llama),context-mode 的价值反而更大。本地模型的上下文窗口通常比云端模型小,想让它理解大仓库的结构,就必须把信息压缩到极致。我的组装模板正好就是为这种场景设计的——每条信息都短,但信息量不缩水。
对于支持 MCP(Model Context Protocol)的工具,context-mode 还可以做一个 MCP server,暴露get_context这个工具给模型调用。这样一来,模型可以在需要的时候主动拉取上下文,而不是每次都无脑带上。这种方式更进一步,但核心的采集与组装逻辑依然是同一套。
6.2 多项目与团队协作里的配置覆盖
不同项目的 context 需求差异很大。一个前端项目,AI 需要知道用的 UI 框架是 React 还是 Vue;一个数据仓库项目,AI 需要知道 SQL 方言是 Spark 还是 ClickHouse。我在 context-mode 里加了一个配置覆盖机制:项目根目录下的.contextmoderc文件可以追加自定义采集项。
{ "extend": { "framework": "react", "sqlFlavor": "clickhouse", "customCommands": ["cat .env.example 2>/dev/null || true"] } }这样每个项目的 owner 自己维护专属上下文,核心工具不变,但输出千人千面。团队协作时,建议把这份配置文件纳入版本管理。新成员 clone 项目后跑一次,自动就能获得和你一样的信息维度,不用靠嘴问“我们这个项目用什么技术栈”。
6.3 从“能用”到“好用”的几个小技巧
最后分享几个让 context-mode 真正好用的小技巧。
首先,给输出加上可读性标识。我在 context 块外面加了[context-start]和[context-end]标记,可以让 AI 明确辨识出这是环境信息而不是用户指令,避免它把 context 当成需求去执行。实测下来,模型对边界清晰的上下文理解准确率高了不少。
其次,考虑把 context 缓存按目录隔离。不同项目的 context 差异大,缓存在全局容易串味。我在采集函数里加了CWD维度作为缓存 key,切目录自动失效。
最后,不要只把它用在提问场景。我写了一个简单的 git commit message 生成器,提交代码之前先跑 context-mode,把分支名和变更列表作为输入,让模型按 conventional commit 格式生成提交信息。这样每次 commit message 都带着项目背景,写出来的信息比光靠git diff生成的要自然得多。
7. 写在最后的一些体会
做完 context-mode 这个项目,我个人最大的收获不是脚本本身,而是对“上下文”的理解变深了。以前总觉得 AI 不够懂我,做了这个工具才意识到,问题出在我自己没把场景表达清楚。你给模型的信息是什么质量,它给你的答案就是什么质量。context-mode 本质上是在人和模型之间架了一座桥,把环境里本来就存在、但很难用自然语言表达的信息,转换成模型能直接读懂的信号。
如果这个项目给了你什么启发,我建议不用照搬脚本,而是理清楚自己的使用场景:你每次向 AI 提问前,有多少信息是重复描述的?这些信息能不能自动化采集?答案就是属于你自己的 context-mode。能在不打断思路的情况下,让 AI 自动知道你在做什么,这种感觉试过就不会回去了。
最后一个小建议:工具做得再顺手,也要保持对输出的审视。context 是环境的投影,而环境是复杂的——有些信息模型确实需要,有些信息纯属噪声。多跑几次,看看哪些字段真正帮到了你,哪些只是让 prompt 变长了,这种迭代打磨的过程,比工具本身更有价值。