上下文模式这个概念,我是被一次极其烦躁的会话逼出来的。当时我在终端里用 AI 工具排查线上网关超时问题,同一个会话里我至少手动打了三遍“这是 xx 项目的网关服务,用的 Go 1.21,当前分支是 fix/gateway-timeout,我怀疑是熔断器参数问题”,结果 AI 还是会给出无关紧要的建议。问题显然不在模型本身,而在于我每次都要花时间把背景重新念一遍——而这些背景信息明明就摆在我的终端里:当前目录、git 分支、最近提交、刚才改过的文件,全是现成的。为什么不把这些信号做成一种显式可检测、可注入、可切换的“模式”呢?于是就有了这个小项目:context-mode。它不是一个复杂的框架,而是一套把开发环境的隐性上下文转化为显式模式描述的工具集合,适合那些频繁切换项目目录、重度依赖命令行 AI 工具、维护大型仓库、或者想在 CI 日志里少翻半天原因的开发者。
1. 先聊清楚:context-mode 到底在解决什么矛盾
1.1 隐式上下文的成本,比你想象的高得多
做开发的时候,“上下文”这个词听起来很虚,但它决定了一切。你看一个函数能不能秒懂,取决于你对这个模块的背景了解;你问 AI 一个问题能不能得到靠谱回答,取决于你给它的背景是否完整;你在构建日志里能不能快速定位错误,取决于日志本身带了哪些环境信息。问题在于,开发环境里的上下文天然是隐式的、碎片化的、散落在各个地方的。
我的工作节奏是每周至少在 5 个不同仓库之间来回切换。每个仓库有不同语言栈、不同分支策略、不同业务领域。以前每次切到新仓库,我要么花 10 秒钟看一眼目录结构,要么翻 git log,要么直接问同事“这服务是干嘛的”。换到 AI 工具上更痛苦:同一个终端会话,昨天还在写 Python 脚本,今天切到 Go 微服务,如果不把背景交代清楚,AI 给出的代码风格、依赖建议、错误排查方向全都会跑偏。
这就像一个餐厅服务员不断在换桌服务,每张桌子的菜不同、忌口不同、要求不同,如果全靠记忆,迟早会端错菜。你要么在每个环节重复背诵“顾客信息”,要么让这些信息变成一种一望即知的可识别状态。显式上下文模式,解决的就是后者。
1.2 从“人肉携带上下文”到“机器检测上下文”的两个转变
要让上下文变成模式,最关键的是两个转变。
第一个转变,是把上下文从“人肉携带”变成“机器检测”。我理想中的状态是:当我cd进一个项目目录,工具能自动知道我在哪个项目;当我切了 git 分支,工具能自动知道我当前任务方向;当我刚才改了三个文件并跑了一轮测试,工具能推断出我正在写测试还是调 bug。这些信息不需要我输入一个字,全部来自环境信号。
第二个转变,是把上下文从“固定值”变成“随输入变化的模式”。很多团队会维护一个 CONTEXT.md 或者 README 来描述项目背景,这是静态的,不会跟着你的操作实时变化。但真实开发是流动的:早上你在看订单模块,下午可能在修支付回调。context-mode 把上下文当成一种“模式”,模式由四层信号实时推导,每层信号有自己的采集器,采集结果经过降噪和合并,最终输出一行紧凑的上下文描述。
1.3 这个项目适合谁、不适合谁
先说不适合的,别让我白安利。如果你常年只在一个仓库里写代码,用的又是图形化 IDE 的完整内置功能,上下文问题对你来说没那么尖锐;如果你的 AI 工具使用频率很低,一周开不了几次,那手工写两行背景也花不了多少时间。这些场景下,context-mode 带来的收益覆盖不了你理解这套规则的成本。
适合的人呢,大概是这几类:
- 依赖终端 AI 工具(如各种 CLI 大模型客户端)解决问题的人;
- 经常在多个项目、多个分支间快速切换的开发者;
- 维护 monorepo 或大型仓库、目录层级很深的人;
- 需要在 CI 日志里反复排查“哪个分支、哪个任务、哪次提交触发了这个构建”的人。
我自己属于全部四类,所以我花在 context-mode 上的功夫,最后都以“不用再手工交代背景”的形式还了回来。
2. 四层上下文源:目录、Git、文件标记、时序历史如何嗅探
2.1 目录结构嗅探:从 $PWD 反向定位项目根
目录是最基础的上下文信号。人的工作位置在哪,基本决定了你在忙哪个项目。我的采集逻辑很简单:从当前目录往上逐级找项目根标记,找到后停止,并计算当前目录相对项目根的相对深度。
context_root() { local dir="$PWD" local depth=0 while [ "$dir" != "/" ]; do for marker in go.mod package.json Cargo.toml pyproject.toml .git; do if [ -e "$dir/$marker" ]; then echo "$dir:$depth" return 0 fi done dir=$(dirname "$dir") depth=$((depth + 1)) done echo ":/" }注意一个细节:我没有把.git作为唯一的标准。因为很多 monorepo 场景下,.git只存在于仓库最外层,而go.mod或package.json会分散在各个子项目中——你希望上下文能精确到“我在这个仓库的某个子服务里”,而不是笼统地停在仓库根。反过来,某些工作区.git可能只是一个文件(指向真正的 gitdir),而配套的Cargo.toml反而更能标识项目边界。所以我的实际实现里,go.mod、package.json这类语言级标记的优先级高于.git。
这个函数每次都在 prompt 显示前跑一遍的话,目录层级深的时候会有可感知的延迟。我的做法是:在 zsh 的precmd钩子里把结果缓存到临时文件,只有当$PWD变化时才重新执行。
2.2 Git 状态:分支、提交、未提交改动,最快的一层信号
目录告诉我们“你在哪”,Git 告诉我们“你在干什么方向”。这层信号的采集成本低、信息浓度高,是我最依赖的一层。
context_git() { local branch branch=$(git branch --show-current 2>/dev/null) || return 1 local recent_log recent_log=$(git log -2 --format="%s" 2>/dev/null | head -n 2 | tr '\n' '|') local dirty_count dirty_count=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ') echo "branch=$branch;recent=$recent_log;dirty=$dirty_count" }这三个字段各有用途:
- 当前分支名,是所有信号里最直白的任务指示。比如
feat/cart-discount基本等于告诉我“正在做购物车折扣功能”。 - 最近两条提交的 subject,价值在于它能在分支名不够精确时做补充。比如分支名是
fix/gateway-timeout,但最近两条提交写的是“调整熔断阈值”“增加超时指标日志”,任务方向一下子就清晰了。 - 未提交改动数量,这是一个轻量信号,用来判断上下文是“干净”还是“正在处理中”。如果 dirty 数量很大,说明这个模式的临时性很强,上下文可能随时变化。
在实际采集时有个坑:在 monorepo 里切换子目录后,git log依然是整个仓库的历史,而不是当前子项目的。所以我会把第一层捕获到的项目根相对路径和 git 信息交叉使用——如果当前目录不在项目根上,就再取一下最近改动文件的路径前缀,用这个前缀去过滤提交信息。
2.3 文件标记:让上下文可以被“书写”而不是只被“读出”
目录和 Git 都是被动读到的信号,但有些上下文只存在于人的脑子里——比如“这个目录我打算用来做实验”“当前任务卡在某个问题上”。这类信息不该靠猜,最好的方式是约定一个显式的书写位置。
我在 context-mode 里支持三类文件标记,按优先级从高到低:
- 项目根下的
CONTEXT.md:首行作为人工声明的项目级上下文。比如“这是一个订单拆分服务,上游是购物车 API,下游是库存系统”。这种人工声明属于强信号,比任何自动推断都可靠。 .context-mode/task文件:这是我会随手更新的细粒度任务描述。比如一行“正在排查 Redis 连接池耗尽”,AI 工具拿到的上下文里就会带上这句话。Makefile中最近被调用的.PHONY目标:通过 shell history 匹配到make test-api、make lint这类命令时,把目标名提取出来作为任务标签。
为什么文件标记很重要?因为自动推断有天花板。目录层级再精确,也只能说你“在订单模块”;Git 分支再清晰,也只能说你“在做折扣功能”。但“这次改动要保证历史订单兼容”这种深一层意图,只有人自己写得出来。所以 context-mode 不是纯自动方案,它留了人工书写上下文的口子,让模式描述可以更准确。
2.4 时序历史:根据最近操作判断当前任务类型
最后一层信号来自“你刚才做了什么”。这里我用了两个轻量数据源:shell history 的最近 20 条命令,以及最近 30 分钟内被修改过的文件。
思路其实不复杂。我定义了一个任务关键词表,命中就累加对应任务类型的分数:
task_score() { local scores=(0 0 0 0) # test, debug, refactor, feature history_20=$(fc -ln -20 2>/dev/null) while IFS= read -r cmd; do case "$cmd" in *pytest*|*go\ test*|*jest*) scores[0]=$((scores[0] + 2));; *gdb*|*dlv*|*strace*|*--debug*) scores[1]=$((scores[1] + 1));; *mv\ *|*rename*|*sed\ -i*) scores[2]=$((scores[2] + 1));; *feature*|*feat/*) scores[3]=$((scores[3] + 1));; esac done <<< "$history_20" # 最近修改文件扩展名再叠加一次 if find . -name "*.test.js" -mmin -30 2>/dev/null | grep -q .; then scores[0]=$((scores[0] + 1)) fi }这块的定位是“辅助推断”,不是权威判定。它输出的 task 类型(run-tests / debug / refactor / feature / unknown)会和 Git 分支、文件标记一起进入合并器,最后由合并器决定谁做主信号。实际上我最常遇到的情况是:分支名说这在做功能,但 history 显示我一上午都在跑go test——合并器会把这些信息组合成“feature 开发中,当前处于测试验证阶段”,这个结果比我只看任何单一信号都要靠谱。
3. 合并与降噪:如何拼出一行真正有用的上下文
3.1 降噪原则:信息不是越多越好,识别率才是
四层信号全量铺开看,原始数据是相当吓人的:
- 目录层级可能很深:
/Users/me/work/org/repo/packages/svc-order/internal/handler - 分支名可能是内部代号:
fix/DEV-4342-timeout-retry - 最近两条提交可能写了一堆细节:
refactor(cart): extract pricing service和test(cart): increase coverage for discount - 未提交文件可能有一长串
如果把这些全部塞进 AI 提示词里,表面上信息丰富,实际上会把模型真正需要的指令性信息稀释掉。我实测过:同一道排查问题,我给 AI 塞了 15 行环境信息,它给出的建议反而泛化了;切成 2 行精简上下文之后,回答的针对性强得多。
所以合并器第一原则是:每多一个字段,都要问自己“这条信息会不会改变 AI 的决策方向”。不会改变决策方向的,一律去掉。比如绝对路径中除了项目名之外的所有父级目录,几乎从来不影响 AI 的回答质量,去掉;dirty 文件数量只保留“有/无”的二元状态,去掉具体数字。
我的实际输出长这样:
[context] project=cart-svc branch=feat/cart-discount task=run-tests scope=src/cart.go modified=yes [/context]这是给 AI 的 prompt 版本。还有个更简单的人读版本,用于状态栏显示:
cart-svc | feat/cart-discount | run-tests | src/cart.go3.2 权重规则与冲突消解
四层信号的优先级不是相等的,我的合并器遵循以下顺序:
- 人工文件标记(CONTEXT.md / .context-mode/task),因为它代表明确的人类意图;
- 时序任务推断(当前正在测试、正在调试),因为它代表最近 30 分钟内的真实行为;
- Git 分支与最近提交,它代表宏观方向;
- 目录位置,它只代表工作范围。
冲突消解的难点在分支名和实际行为不一致的时候。举个真实例子:我的分支叫feat/cart-discount,看起来是在做购物车折扣;但最近 30 分钟我改的全是redis.go、cache.go这类缓存文件,history 里是一串go test ./cache/...。这时候如果死板地取分支名作为 task,AI 会以为我在开发折扣功能;而实际我的临时任务可能是“先把缓存层测试修绿”。合并器的逻辑是:当时序信号(文件扩展名 + 命令关键词)和分支信号不一致时,时序任务推断胜出,分支名降级为参考信息。
另外我还维护了一份忽略清单,把一些噪音分支自动排除。比如dependabot/*、merge-*、release/*这类分支基本不携带有效任务信息,遇到它们直接把分支字段置空,避免误导。
3.3 输出格式与配置示例
context-mode 的配置用的是 TOML,因为这类工具用 TOML 维护起来比 JSON 舒服。核心配置如下:
[project] markers = ["go.mod", "package.json", "Cargo.toml", "pyproject.toml", ".git"] max_depth = 5 [task] enable = true window_minutes = 30 keywords = { test = ["pytest", "go test", "jest"], debug = ["dlv", "gdb", "strace"] } [sanitize] enable = false patterns = ["fix/[A-Z]+-[0-9]+", "DEV-[0-9]+"] [output] format = "prompt" # prompt | human | json separator = "[/context]"输出三种产物,分别给不同的消费端:
--format=human:给人看,显示在终端 UI 或编辑器状态栏;--format=prompt:给 AI 工具,固定用[context]标签包裹,方便模型识别这是背景信息区;--format=json:给脚本和 CI 系统,解析方便,可以按字段做后续判断。
我日常用得最多的是prompt格式,但它同时也是最需要谨慎的:上下文注入进提示词后,如果哪天脚本拼接出错,把用户输入和上下文混在一起,容易产生提示词注入的隐患。所以我坚持用分隔标签并且把上下文放到用户输入之前,确保行为可预期。
4. 三种真实接入方式:终端 AI、编辑器状态栏、CI 构建脚本
4.1 接入终端 AI:把 context 注入 prompt 前缀
这是最核心的消费场景。我的用法是:定义一个环境变量AI_CONTEXT,在 zsh 的precmd钩子里保持最新,然后让所有命令行 AI 客户端读取它。
_precmd_update_context() { AI_CONTEXT="$(context-mode --format=prompt)" } precmd_functions+=(_precmd_update_context) alias ai='llm run -p "$AI_CONTEXT\n\nUser: $1\n"'关键点在于注入位置:context 必须放在用户指令之前,并且要有明确的起始和结束标记。如果放在用户指令之后,它会被当成后续追加的需求,AI 可能会为了迎合上下文而去改写用户本来的命令,导致行为变差。我最初就把上下文拼在指令末尾,结果 AI 经常把“忽略上面的指令”之类的话也接进去,效果一言难尽。改成前缀 + 分隔标签后,稳定了很多。
如果你用的是支持 system prompt 工具的客户端,更优雅的做法是把 context 放 system prompt 里,而不是每次拼进用户消息。文件标记层面的CONTEXT.md内容尤其适合放 system prompt,因为它的变动频率低,语义更接近静态约束。
4.2 接入编辑器状态栏:让编写代码的人随时看到当前模式
终端里方便,但我大部分时间还是在编辑器里写代码。编辑器接入方式有两种,一种是在状态栏显示,一种是在 AI 插件里注入。
状态栏显示的做法,是让 context-mode 以 watcher 模式运行,在项目目录变化、git 分支变化、最近修改文件变化时,自动重写一个.context-mode临时文件(内容就是 human 格式的那行文字)。Neovim 的 lualine 组件只需要读这个文件就能在右下角显示当前模式;修改时触发自动重写,不需要轮询。
-- lualine 组件 { function() local f = io.open(".context-mode", "r") if not f then return "" end local content = f:read("*l") f:close() return content or "" end, color = { fg = "#c0caf5" } }为什么不直接在编辑器里跑 shell?因为编辑器的自动命令触发频率太高,动不动就刷新一次 statusline,每次刷新都去跑git log、find这类命令,会导致明显的进程开销。用后台 watcher 加文件读取的模式,编辑器永远只做一次廉价 IO,重活都交给守护进程。
另一个 AI 插件接入场景更实用:我用的编辑器 AI 补全插件支持附加额外指令内容,我把它指向.context-mode文件的 prompt 版本,这样每次触发补全时,模型都能感知到“我在哪个服务、当前改的是哪类文件”。实测下来显著改善了跨文件补全时的语义准确性。
4.3 接入 CI:在构建时重新生成上下文,避免开发机脏状态
CI 场景是我后来才补的。开发机上生成的 context 不能直接搬到 CI 里用,因为开发机上可能有未提交的改动、脏文件、甚至过期的 task 文件。CI 上必须重新生成,用 CI 环境自己的信号。
我在 GitLab CI 里的用法是:
./context-mode --ci \ --branch "$CI_COMMIT_BRANCH" \ --job "$CI_JOB_NAME" \ --commit "$CI_COMMIT_SHA" >> build.log输出到构建日志里大概是:
[context] job=build-service|branch=feat/cart-discount|commit=abc123|task=compile[/context]它的价值在于,当构建失败时,日志里的[context]行能立刻告诉你看日志的人:这是哪个分支、哪个 job、哪次提交触发的构建。尤其是多个 MR 同时构建、日志混在一起的时候,这行信息能省去大量“这个报错到底是不是我这次改动引起的”的核对时间。
需要注意,CI 环境默认没有 TTY,脚本里如果有任何交互式检测或颜色输出,一定要先禁用,否则构建日志会吞掉输出或者报错。我在--ci模式下把所有带颜色和交互逻辑的代码路径全部短路掉,这是从一开始就该做的设计。
5. 踩坑实录:上下文“过期”、目录误判、规则冲突
5.1 上下文过期:昨晚生成的上下文,今天早会回来还在用
第一个让我抓狂的问题,是上下文过期。context-mode 的 watcher 会在信号变化时更新临时文件,但有个盲区:如果信号没变化,文件就不会刷新。我经常遇到的情况是:昨晚下班前还在feat/cart-discount分支上改代码,生成了一份“正在开发折扣功能”的上下文;今天早上来开完会,git pull拉了新代码,但 watcher 只感知到了目录和分支变化,没感知到我心态上的变化——我今天的实际任务可能是“先看一下昨天的 MR 评论,处理 review comment”。
等我在终端里问 AI“这段代码为什么这么写”时,它依然带着昨天的任务前缀,答偏了。这种错误很难察觉,因为上下文本身看起来是合理的,只是和此刻的真实意图脱节。
解决办法是我在合并器里加了两道保险。第一道是时间戳校验:每条信号采集时都带采集时间,合并时如果某层信号超过 12 小时未更新,它的权重自动下降。第二道是 git HEAD 校验:每次输出 context 前比对当前 HEAD 和缓存里的 HEAD,不一致就强制重写所有依赖 git 信号的缓存。这两道加起来,至少保证“代码都拉到新版本了,任务描述还停在昨天”这种低级错误不再出现。
5.2 目录误判:在家目录跑个命令,它把整个仓库当上下文
第二个坑出现在目录嗅探上。有几天我发现 statusline 上始终显示着一个不认识的仓库名,排查了很久,发现是~/.config下的某个目录里恰好有个package.json(某个工具的配置文件)。只要我cd进那个目录,context-mode 就把它当作项目根,生成一份毫无意义的上下文。
这个问题的根因是标记匹配太宽松。修复用了两个手段。第一个是主标记必须是“真实项目特征”,语言级标记要有配套特征文件才生效——光有package.json不够,还得有src/目录或者node_modules/目录;光有go.mod不够,还得有.go文件。第二个是深度限制:max_depth设为 5,往上层级超过 5 还没找到强标记,就直接放弃,不输出项目字段。
这次修复让我意识到:自动检测类工具,与其费劲提高召回率,不如先把精确率提上去。宁可少检测出几个项目,也不能让错误上下文天天挂在状态栏上误导自己。我现在的项目根匹配逻辑就是这个原则。
5.3 任务判断过于激进:history 里的测试命令被我当成了“正在写测试”
时序历史这层信号,刚开始跑的时候最不稳定。第一次上线版本,我发现 task 字段频繁误判成run-tests。原因是 history 里只要出现过go test,我就会把它累加进测试任务分数。但我实际的情况是:我在写业务代码,中途为了验证跑了三次测试,结果 task 被判定成“正在写测试”,AI 拿到这个上下文后,回答的重心全偏向了测试代码。
这个问题的本质是:命令关键词和任务意图之间不是等价关系。跑测试不等于写测试,可能是调试、可能是验证重构、可能是例行检查。
我后来加了一个关键的校准条件:命令关键词命中之外,还必须同时满足“最近 30 分钟内对应类型的文件有修改”。比如历史里有pytest,同时我刚才确实改了一个test_*.py文件,才判定为run-tests。如果只有命令没有文件改动,task 回落为unknown。这个规则看起来简单,但我实测下来误判率大幅下降。现在 task 字段的值我只有 70% 左右的置信度,所以我刻意在 prompt 输出里用task=run-tests?这样的弱断言表示不确定,AI 会对它保持保留态度,不会盲从。
6. context-mode 的边界:什么时候我建议你别用
6.1 一句话上下文撑不住的场景
我在使用中发现,context-mode 最擅长的是“定位型上下文”——告诉 AI 你在哪个服务的哪个文件、正在做什么类型的操作。但它描述不了“目标型上下文”——你对这次改动的预期结果、你要实现的业务逻辑、你受到的非技术约束。
举几个场景:
- 写长文档和技术方案时,目录、分支、任务类型这些信号意义不大;
- 做架构设计时,你需要的是整个系统的设计背景,不是一个目录名;
- 跨多仓库重构时,单独一个项目里的上下文反而会误导判断。
这些场景我仍然选择手动写说明,而不是强行依赖 context-mode。它的价值定位是“减少重复交代背景的摩擦”,而不是“替你思考目标”。如果你发现自己每次都要先写一大段项目说明才能开始用 AI,那说明这个工具适合你;如果你要写的是“为什么这么设计”这类深层问题,工具帮不上太多,老老实实写 design doc 吧。
6.2 团队协作中的脱敏问题
这是越用越在意的一点。分支名和本地路径真的很能暴露信息:内部系统代号、产品或项目名、团队组织方式,全都出现在 context 里。在我本地没问题,但一旦接入 CI 输出或者和他人共享 AI 会话,这些信息就可能被带出去。
我建议团队使用 context-mode 时把[sanitize]配置打开。我用正则做两层脱敏:一层匹配fix/DEV-1234这类内部编号格式,直接替换成fix/<ticket>;一层匹配指定的目录名黑名单。脱敏开关我默认是关闭的,因为个人使用不需要;但凡要进团队协作或 CI 流水线,一定要先跑一遍context-mode --check看看输出里有没有不该出现的词。
6.3 我现在的使用心得
跑了几周之后,context-mode 已经变成我终端环境里最不显眼但最离不开的一部分。它不像那些花哨的提示工具会跳出来刷存在感,它的工作方式是安静地待在状态栏和 AI 提示词前缀里,让我少说很多废话。
最后分享一个使用上的小技巧:context-mode 最适合和“会话式 AI 客户端”配合使用,因为这类工具的价值在于多轮对话维持一致性。如果你用的是一次性问答式的 AI 工具,上下文注入的作用会被削弱一大半。我建议把它接入到你日常最先打开的终端或者编辑器的 AI 入口里,这样每次会话开始时,它就已经把该交代的背景放好了,你只需要专注提问本身。