说实话,我算不上一个喜欢折腾效率工具的人。手机上的番茄钟、桌面上的窗口管理器、笔记软件里的各种插件,我基本都是坚持不到三天就换回最笨的原始方法。真正让我意识到“上下文”是个问题的,是今年接二连三在不同项目之间救火的那些日子:上午还在 A 项目里看一个 Python 服务的内存泄漏,中午被拉去 B 项目确认前端打包问题,下午又要在 C 项目把上次和客户对过的方案整理成文档。每切到 B 项目,我都得先回答自己一堆问题:这个仓库我上次用的是哪个分支?排查到哪一层了?环境变量指向哪个环境?哪些文件改了一半还不能提交?这些问题,搜索引擎救不了,聊天记录也救不了。也就是那段时间,我给自己写了一个叫context-mode的小工具,专门把这种“切换工作上下文”的过程固化下来。这篇文章就是它从想法、到设计、再到实测踩坑的完整记录。如果你也常年在多个仓库、多种身份、多个议题之间切来切去,这篇应该值得你花十分钟读完。
1. 为什么我最终决定自己造一个 context-mode
1.1 一次典型的多线操作现场
假设你同时有三个项目压在身上。我先把这三个项目会用到什么信息列清楚,因为这是后续所有设计的源头:
| 项目 | 每次切换时最想找回的信息 | 没有工具时,找回这些信息大概要多久 |
|---|---|---|
| A 服务端性能分析 | 当前分支、上次的 git diff、采样脚本位置、上次生成的火焰图命令 | 10-15 分钟 |
| B 前端构建排查 | 未提交的半成品文件、node 版本、临时补丁内容 | 10 分钟 |
| C 方案文档 | 上次和客户对齐的版本、关键素材链接、遗留问题清单 | 15-20 分钟 |
久吗?听起来好像只是读一下文件、翻一下 git log。但真正的问题是,这些信息分布在不同的工具里:git 状态要看命令行,环境配置要看 .env,未完成的想法散落在编辑器标签和笔记里,还有一部分藏在某个聊天软件的历史会话中。每找回一类信息,都要从“我记得好像……”开始重开一次搜索。次数多了之后,切换一次项目的成本根本不是 10 分钟,而是 20 分钟起步,因为中间还会顺带处理两个新冒出来的问题,然后陷入新的上下文。
1.2 现成工具各管一段,却没人管“理解”
我对现成方案做过一次认真盘点,结论是不好不坏,但都覆盖不到最重要的点。
- tmux:能保存窗口布局和运行中的进程,但重启机器后经常丢,而且它不关心我这个项目的背景故事。
- direnv:按目录自动加载环境变量,很省心,但它只解决环境变量这一项,不解决“我上次想到哪一步了”这种认知层面的东西。
- 项目管理器 / 工作区快照类插件:能把打开的窗口和文件列表恢复,但恢复的是界面,不是记忆;文档里的待办、跟客户对齐过的结论、排查到一半的思路,这些它一概不知。
- AI 聊天记录:对话历史会被丢掉,而且同一个项目里不同对话之间的上下文是隔离的,每次都要重新讲一遍背景。
换句话说,我需要的是一个介于“环境管理”和“笔记系统”之间的东西:它既知道当前仓库、分支、环境变量这类客观状态,也能承载一份主动记录的、可以复现的“思考上下文”。这就是context-mode的出发点:不追求全自动,而追求在我主动保存时,能把我认为重要的东西一次性收进一个文件里;在我切换回来时,能迅速把这份文件恢复成一个真的可用的开发环境,而不是只能看看的 memo。
1.3 我只做三件事
- 采集:从当前终端、git 仓库、目录结构里提取一段基础状态,并允许我补充自由文本。
- 保存:按名字存成一份独立快照,随时可以查看、修改、删除。
- 恢复:把快照中的目录、分支、环境变量、待办提示恢复到当前 shell,并打印一份可读的摘要。
因为目标就这三个,后面所有实现都没有扩大得很离谱。很多人做这种工具到最后会做成“第二个笔记软件”,我刻意把范围收紧了:不管代码搜索、不管日历提醒、不管自动同步。范围一旦扩大,这个工具就会变成另一个需要维护的负担,那和我的初衷就完全相反了。
2. 上下文快照里到底应该装什么
2.1 一份快照的字段设计
我最终把一份 context 快照定义成四个部分:环境数据、记忆数据、引用数据、可选负载。在文件层面,我把它拆成四个文件,存在~/.context-mode/contexts/<名字>/下:
context.yaml:结构化数据,机器能读,主要存目录、分支、环境变量白名单、最近命令等。notes.md:自己写的自由文本,人可以读,也是生成 AI 上下文卡时最核心的部分。env.sh:保存的一小段 export 语句,恢复时由 shell 函数 source 或 eval 进当前 shell。files.txt:手动维护的“关键文件路径”清单,用来快速定位而不是把所有文件都复制进来。
context.yaml 一个示例:
name: service-perf root: /home/tom/work/service-a git: branch: feature/fix-memory-leak worktree: /home/tom/work/service-a-worktree env_files: - .env.local allow_env: - SERVICE_ENV - LOG_LEVEL - NODE_OPTIONS last_used: 2025-06-20T10:24:00这里每一行都不是随便写的。root 是恢复现场的基础;git.worktree 用于在切到较重的分支时避免污染主目录;env_files 是可选的手动加载项;allow_env 是白名单,只有白名单里的变量在恢复时会被 export,这个设计后面救了我好几次。
2.2 为什么我不能只依赖自动收集
自动收集看起来很美:保存时直接把env、git status、history全拍进去,恢复时原样还原。我一开始确实试过,但很快发现三个问题。
第一,噪音。env命令输出通常有几十行,大部分是LS_COLORS、XDG_*这类当前 shell 的场景变量。恢复这种变量没有任何意义,还会在切换时把上一个项目的 PATH 混乱带到下一个项目。第二,历史记录过长。把最近 500 条命令存下来,恢复时根本没人看,AI 读取时也会被无关信息稀释。第三,自动收集会给人一种“系统在帮我记”的错觉,于是自己不再动手写任何笔记。可真实工作里最值钱的恰恰是手写的那几行:比如“这个泄漏疑似和连接池有关,下一步改配置验证一下”。这种思路,任何自动化都捕捉不到。
所以我的原则是:系统自动收集的是确定性状态,比如仓库路径、分支、环境变量白名单、目录列表;而不确定性内容,比如结论、疑点、下一步计划,一律由人主动写进 notes.md。工具不帮你思考,只帮你不丢你思考过的结果。
2.3 notes.md 的书写范式
为了让 notes.md 和后续 AI 导出都好用,我给自己定了一套轻量模板:
# 目标 (这个 context 最终要解决什么问题) # 当前进度 (已经做到哪一步,有哪些验证过的事实) # 怀疑点 / 阻塞点 (排查方向卡在哪里) # 下一步 (回到这个任务时第一件要做的事) # 相关链接 - ...这套模板没有新概念,唯一要求是每条尽量一句话说清。不要写长篇大论,写长了就不会再维护,这是我在笔记软件里反复吃亏后的教训。一个合格 notes.md 的大小,控制在 50 行以内、1KB 上下比较合适。超过这个规模,你就要开始怀疑自己是不是只记笔记,没有真正在推进任务了。
2.4 为什么用“快照”而不是“实时同步”
也许你会问:为什么不把 context 做成后台 service,监听目录和 git 变化,实时更新?我做过对比之后选择不做,核心原因是代价:实时监听就要常驻进程,意味着端口占用、开机启动、配置文件、崩溃恢复一堆工程问题全部冒出来。而开发任务的上下文变化其实是低频的,一天真正值得记录的节点可能只有三五个。快照机制配合手动save,既能卡在这些节点上,又避免了一切监听带来的复杂度。我的经验是:切走之前五秒钟随手 saved,比任何智能采集都可靠。
3. 实现细节:命令、存储与加载
3.1 为什么选 Python 而不是 Shell
工具主体我用 Python 写,核心逻辑只依赖一个 PyYAML 用来处理 context.yaml,其它全是标准库。选 Python 的理由很实际:这类工具的核心逻辑是字符串处理、文件操作、git 命令封装,Python 写起来比 shell 更不容易出错,尤其在做配置数据整理的时候,一套dict操作远比awk的边角情况直观。为什么不直接用 Go 或 Rust?因为一开始的需求就不稳定,我需要一种“改两行就能立刻跑”的语言。等到接口稳定下来,如果想做成发行版二进制,再用 Go 或者 Rust 重写一遍也不迟,反正核心逻辑只有两三百行。如果你更熟悉 Go,用 Go 写也是一样的思路,下面的代码都是概念示例,语言不是重点。
3.2 完整命令集
我最初只设计了两个命令,后来补到七个。看一眼最终的命令结构:
| 命令 | 作用 |
|---|---|
context-mode init <name> | 初始化一个空白 context,生成模板文件 |
context-mode save <name> | 把当前目录、分支、白名单环境变量收集进 context |
context-mode list | 列出所有 context,显示名称、root、最近使用时间 |
context-mode use <name> | 恢复现场,真正的切换入口 |
context-mode edit <name> | 打开 notes.md 和 context.yaml 进行编辑 |
context-mode export <name> --ai | 把 context 转成给 AI 的上下文卡 |
context-mode remove <name> | 删除一个 context |
这里的关键点是:save不会冲突。这个命令只是把当前 shell 里的信息覆盖到已有 context 上,它不要求你从零开始。所以实际使用流程是init一次、save无数次,越到后面这个 context 越贴近真实状态。
3.3 保存逻辑的代码骨架
保存部分的 Python 代码大概长这样:
#!/usr/bin/env python3 import datetime import os import pathlib import subprocess import yaml ROOT = pathlib.Path.home() / ".context-mode" def git_info(): repo = subprocess.run(["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True) branch = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True, text=True) if repo.returncode != 0: return None return { "repo": repo.stdout.strip(), "branch": branch.stdout.strip(), } def save(name: str, env_allowlist: list[str]): ctx_dir = ROOT / "contexts" / name ctx_dir.mkdir(parents=True, exist_ok=True) data = { "name": name, "root": os.getcwd(), "git": git_info(), "updated": datetime.datetime.now().isoformat(timespec="seconds"), } with open(ctx_dir / "context.yaml", "w", encoding="utf-8") as f: yaml.safe_dump(data, f) env_lines = [] for var in env_allowlist: val = os.environ.get(var) if val is not None: env_lines.append(f"export {var}='{val}'") (ctx_dir / "env.sh").write_text("\n".join(env_lines), encoding="utf-8")这里我没把完整实现贴出来,因为三百行贴出来也没人看。但最重要的思路就是:save 只负责把“此刻的 shell 状态”写进固定目录,不负责分析、不负责清理。环境变量只保存白名单里明确列出的项,这个白名单来自 context.yaml 的allow_env,我在设计上一节已经提到过,它后面承担了几乎所有安全相关的防护。
3.4 load 为什么必须是个 shell 函数
use命令不能做成普通可执行程序,这是整个工具最容易忽略也最关键的实现细节。因为一个子进程无论怎么输出都改变不了父 shell 的环境变量。你运行./context-mode use xxx,程序可以在终端打印一堆信息,但它无法让“当前这个 bash/zsh 窗口”真的把目录切过去。要让环境变量在当前 shell 生效,必须使用 source 或 eval 这种“由当前 shell 自己执行脚本”的方式。
所以我在.bashrc/.zshrc里放的是:
context-use() { local name="$1" eval "$(python3 ~/bin/context-mode.py export-env "$name")" cd "$(python3 ~/bin/context-mode.py get-root "$name")" || return python3 ~/bin/context-mode.py print-summary "$name" }export-env输出的是合法的 shell export 语句。之后在 shell 里执行context-use service-perf就能一次性完成环境变量恢复和目录切换。这个功能用函数封装之后,还能顺手加一层确认:如果当前目录有未提交改动,会先提示一句再切换,避免我手滑丢现场。
3.5 fzf 速选与别名
切换命令敲全称还是有点累,我配了一个短别名和 fzf 的联动:
alias cu='context-use $(context-mode list | fzf --height 40% --header "select context" | cut -d" " -f1)'这样在终端输入cu会直接弹出一个可搜索的列表,选中即切换。fzf 的好处是它不需要我记住 context 的完整名字,我只记得 project 名的一部分就能模糊匹配。如果你不用 fzf,也可以简化为alias cu='context-use',然后靠补全,效果差一点但完全可用。
4. 把 context-mode 接到日常工具链上
4.1 一条命令生成 AI 上下文卡
这是我觉得整个工具最值钱的部分。以前跟 AI 助手聊一个项目,开场总要花三五轮解释背景:这是什么项目、目录结构怎样、当前分支有没有没提交的改动、上次排查到哪了。现在我用context-mode export service-perf --ai,它会直接在终端打印一张包含上述所有信息的卡片:
context 名称: service-perf root: /home/tom/work/service-a branch: feature/fix-memory-leak 未提交改动: 3 files changed, +120 -14 环境: SERVICE_ENV=staging, LOG_LEVEL=debug 关键文件: - src/http/handler.py - src/db/pool.py notes: - 目标: 定位内存泄漏 - 当前进度: 已排除连接泄漏,疑点集中在异步任务队列 - 下一步: 用 tracemalloc 跑 10 分钟对比两次采样我只需要复制这一段粘贴到对话窗口里,第一句话甚至不用写,AI 就能立刻理解问题背景。以前那种“打开聊天窗口却要从零开始描述项目”的挫败感基本消失了。
4.2 导出时不直接把整份文件喂进去
导出 AI 上下文卡有一个反直觉的原则:不要为了省事把 notes.md 或 files.txt 里对应文件内容全部粘进去。原因很简单,你的核心诉求是让 AI 知道“站在哪、在看什么”,而不是替你把所有代码重新读一遍。如果你把几百行代码直接丢进上下文,prompt 会变得巨长,AI 反而抓不住重点,甚至开始复述代码而不是回答问题。所以我的导出逻辑里做了两件事:一是对files.txt中列出的文件,只生成文件路径和每个文件的前几行摘要;二是 notes.md 按原样输出,因为那是人写的浓缩判断,信息密度最高。这种做法和“给新同事一份 README 而不是一本代码大全”是同一个道理。
4.3 与 git worktree 的配合
跨分支并行开发的时候,主分支上往往是没法直接工作的,因为一批未提交的改动可能横跨多个文件。我一开始的做法是 stash,后来发现 stash 也是一个会丢失上下文的地方,恢复时经常想不起来 stash 里的是哪一坨。现在我把 worktree 路径写进 context.yaml 的git.worktree字段,context-use时如果检测到该 worktree 存在就直接 cd 进去,如果不存在就提示是否创建。这样一来,每个 context 都对应一个独立的登机口,主工作区的改动不会互相干扰,context 本身也不用手动清理。
4.4 和 tmux session 的联动思路
我没有把 tmux 管理做成强制功能,但提供一个可选联动:在 context.yaml 里如果有tmux: session-name,那么context-use会检测同名 tmux session 是否存在,存在则切换过去,不存在则创建一个新 session 并在其中的 shell 里执行同一套 load。这个联动最大的好处是,环境变量、目录、打开的窗口布局和“认知上下文”一起恢复,而不是只恢复一个空壳。实现上就是在 shell 函数里多包一层简单的 tmux 判断,不复杂。唯一要注意的是,不要在 tmux 内部触发递归创建自己,否则会陷入循环。
4.5 与 direnv 的边界
不少人以为 context-mode 和 direnv 是重复的。我的用法是让它们分工:direnv 负责“当我在某个目录里时,自动加载这个目录需要的环境”这种确定性逻辑;context-mode 负责“当我在某个任务上下文里时,恢复我对这个任务的记忆和偏好”,它是手动触发的。两者不冲突。甚至可以把 env.sh 里要保存的变量做成和 direnv 一致的命名,这样我在.envrc里写同一套变量名时不会有二义性。底线还是那句:任一层都不要把密钥和 token 直接写进明文文件。
5. 实测两周后踩过的坑
5.1 第一个坑:快照比现实老得更快
我保存好一个 context 后省了三天,三天后切回来发现分支已经不是那个分支了,notes.md 里记的待办也早就在别的地方被推进了。这是快照类工具的宿命:保存的和现实一定会有偏差。我的解法是,在print-summary里不仅输出 context 里存的状态,还实时跑一次git status --short和最近三条 commit 信息,让输出永远反映“context 视角 + 现实最小修正”。另外每周最好固定做一次context-mode refresh式的更新,把 notes 里过时的结论清理一遍,否则 notes 很快会变成坟场。
5.2 第二个坑:eval 是把双刃剑
用 eval 加载环境变量很方便,但也意味着任何能写进 env.sh 的内容都会在你的 shell 里被当成命令执行。如果某个 context 曾经保存了一个变量,值里带分号或者其它特殊字符,eval 之后就是一场事故。我的防线有三条:
- 第一,白名单变量默认全部用
export VAR='value'单引号包裹,值里的单引号做一次转义。 - 第二,env.sh 的内容生成后,加载前由 shell 函数做一次校验,只允许
export [A-Z_]+=开头的行存在,其它行一律拒绝。 - 第三,也是最重要的,不要顺手把 API token、数据库密码这类敏感信息存进 context;要用
${REF_HOME}/.secret这样的引用方式,或者用系统密钥管理器读取。
表格可能比列表更清晰,但这里三条都属于安全纪律,顺序本身就是优先级。第三点是我坚持得最狠的一条,因为一旦在某个 context 里存过一次真实 token,它就会出现在你的备份、导出、diff 输出里,清理成本远高于最开始多写两行引用。
5.3 第三个坑:Windows 上的 source 与 eval 差异
我在一台 Windows 机器上试跑过,Git Bash 里source可用,但纯 cmd 或 PowerShell 环境下这套 shell 函数是不成立的。如果你的开发环境以 Windows 为主,建议把加载部分做成“为当前 shell 输出一段文本,再由各自 shell 的 wrapper 执行”的架构;PowerShell 里对应的 wrapper 是Invoke-Expression。换句话说:核心工具只管输出标准文本,跟 shell 绑定的事永远只发生在用户自己写的 wrapper 里,不在工具内部硬编码 bash 语法。这是我踩完坑后重构的结论。
5.4 第四个坑:上下文膨胀
notes.md 写起来毫无成本之后,很容易养成随手写的习惯,但时间一长 notes 就会失控,从 30 行涨到 300 行,从 1KB 涨到 30KB。更麻烦的是导出 AI 卡片时这些内容会全部算进 token 成本。我后来在 export 子命令里加了一个--depth参数:--depth short只导出最新 10 行 notes,--depth full才导出全部。在日常工作中我九成用的是 short,因为 AI 真正需要的只是“当前进度、怀疑点、下一步”这三板斧,而不是完整工作日志。
5.5 第五个坑:多个 context 之间的 diff
一开始是没打算做 diff 功能的。但有一次我同时维护三个 context,切了几轮之后,逐渐分不清哪个 context 里记录的环境变量和我正在用的环境一致。于是加了一个context-mode diff A B命令,逐字段对比 root、git.branch、env 白名单和 notes 里的前三行。这个命令的排查价值很高,它能直接告诉你“我在 A 上下文里设的是 staging,但当前终端里跑的是 production”,避免在错误环境里白忙一下午。如果你的 context 不超过两个,可以不开这个功能,一旦超过两个,diff 就是刚需。
写了这么多,最后想分享一个我在实际使用中的体会:工具不要追求一步到位。我第一版做的 context-mode 只有 save 和 use 两个命令,后面所有功能都是真在使用过程中撞出来的。比如 fzf 联动是因为记不住名字,diff 是因为同时维护三个 context 会搞混,--depth是因为 AI token 成本爬得太快。如果你也想照着做一个类似的工具,我的建议是从最轻量的一两个命令开始,先坚持用它记两周的 notes,然后你会非常清晰地知道下一步该加什么。而一旦这个习惯养成,我现在的日常就是每天早上先cu切到今天要做的任务,再也不用花二十分钟去回忆“上次到底改到哪了”。对我来说,这就是 context-mode 最值得保留的价值。