你有没有遇到过这种情况:上午刚让AI帮你梳理完A项目的接口方案,下午切到B项目,希望AI还记得A项目里那个技术选型的限制条件,结果它一脸茫然,甚至给出跟之前完全相反的建议。我一度以为这是模型能力问题,后来才明白,真正的坑在于——上下文没有管理。这就是我为什么写了 context-mode 这个小工具。
context-mode 是一个轻量级的命令行工具,专门用于管理不同任务、不同项目之间的AI对话上下文。它把每个项目的背景资料、当前进展、关键决策和对话摘要保存成独立的上下文文件,按需切换、随时恢复,彻底解决AI“换个话题就失忆”的毛病。如果你经常用AI辅助写代码、写方案、做数据分析,又在多个项目之间来回切换,那这个工具和它背后的管理思路会非常值得参考。
我最早只是想给自己写个脚本,后来发现同事和朋友也有同样的痛点,于是把它打磨成了一个小项目。这篇文章会把设计思路、核心实现、完整实操和踩坑记录都写清楚,代码也不长,你可以直接照着搭建一套自己的上下文管理模式。
1. 为什么需要 context-mode:上下文管理的核心痛点
1.1 上下文分裂:AI为什么总是“跑偏”
先还原一个真实场景。那天我在做两个事情:一个是给旧系统的支付模块补一个对账接口,另一个是在新项目里设计用户权限的数据模型。两个项目同时在IDE里开着,AI辅助工具也挂在同一个会话流上。我让AI帮我写支付对账SQL,它写完我觉得不够健壮,让它“按之前说过的错误处理思路改一下”。结果它引用了权限模型里的角色继承关系,问我要不要按这个来设计异常链路。我当时就明白了——它把两个上下文混在一起了。
这种“上下文分裂”是大模型辅助开发中最常见、也最隐蔽的问题。模型本身没有时间线,也不清楚你当前正在处理哪个项目。它只能从当前对话窗口里能看到的所有文本中推测你的意图。如果你把两个项目的讨论都塞在同一个对话流里,它自然会把A项目的决策当成B项目的背景,或者把B项目的约束当成A项目的需求。结果就是逻辑混乱、代码风格漂移、无用建议一堆,最后你还得花时间纠正它。
1.2 现有方案的不足:会话列表、复制粘贴都不够用
很多人会用多会话窗口来区分不同项目,这确实比混在一起好,但远远不够。第一,模型应用的会话列表一般只能按时间排序,你很难快速回到三天前那个项目的某个关键决策点。第二,单个会话本身有长度限制,聊得久了,早期的重要背景会被挤出去,模型就开始“短线失忆”。第三,切换项目时,你需要关闭当前会话、打开另一个会话、重新讲述一遍项目背景,这个重复劳动本质上就是在消耗时间和漏失细节。
复制粘贴更不靠谱。我试过把一段项目背景文档复制到新会话开头,效果呢?比没有强一点,但前提是你还记得要把哪些内容贴进去。如果连续切换三次,你很可能忘了某个重要约束,而模型又不会主动提醒你“你上次说过不要用某某方案”。人类本来就是靠外部记录来管理注意力的,指望大脑在多个项目间精准记忆是不现实的。
1.3 context-mode的设计目标:隔离、持久、快速恢复
基于这些痛点,我给context-mode定了三个目标:
- 隔离:每个项目、每个任务有独立的上下文空间,互不干扰。
- 持久:上下文不仅存在当前对话里,而是存到本地文件,长期保存,随时翻看。
- 快速恢复:一条命令切换项目,相关背景自动载入,几秒钟内进入状态。
这就像同时开多个项目会。开会的时候,你总得先翻一下上一次会议纪要,才能接上话题。但如果你每次开会前手上都有当前项目的纪要,而且只需要一秒钟就能拿到,那会议效率就会高很多。context-mode 就是干这个的——给AI聊天的每个项目都建一份“会议纪要”,切换时自动摆到它面前。
2. context-mode 的整体设计与核心机制
2.1 上下文文件结构:用Markdown加头信息,而不是塞进数据库
我一开始想得很简单,把所有上下文塞进一个SQLite里。后来发现,既然要给人看、给AI用,最好的格式反而是文本文件。一个上下文对应一个Markdown文件,文件名就是上下文ID,文件开头有一段YAML格式的元数据,后面是结构化正文。
每个上下文目录位于~/.context-mode/contexts/下,目录命名采用“项目名-任务名-短ID”的方式。上下文文件context.md的结构大概长这样:
--- name: pay-api-refactor project: backbone created: 2025-03-10 updated: 2025-03-14 tags: [支付, 对账, SQL] status: active --- # 当前目标 为支付模块补充交易对账接口,保证订单流水和第三方支付流水每日对账一致。 # 关键决策 - 对账口径以本系统订单流水为准,第三方流水仅作为核对源。 - 失败订单不参与对账,单独生成异常清单。 - 使用批次号 + 支付流水号作为唯一匹配键。 # 技术约束 - 数据库为 MySQL 8.0,禁止使用外键约束。 - 对账任务每天凌晨跑,需要幂等,重复执行不会重复生成差异记录。 - 接口必须兼容现有返回结构,不能破坏旧客户端。 # 当前进展 - 已写完对账SQL的核心查询,待补充异常清单插入逻辑。 - 已完成接口参数校验,未开始联调。 # 对话摘要 用户:希望按批次号对齐当天流水,并输出差异表格。 助手:设计了 base 查询和 diff 查询,建议用 union all 合并。 用户:要求去除手续费分摊逻辑,简化成订单金额对比。 助手:已调整,下一步封装为存储过程。 # 待办 - [ ] 处理重复对账的幂等键 - [ ] 增加超时重试机制这个结构有个好处:AI能看到明确的目标、决策、约束、进展和近期对话摘要。这些内容比溅射在对话窗口里的内容更精炼、更结构化,模型理解起来非常快。而且Markdown是纯文本,任何人可以直接打开看,也可以用脚本解析。YAML头信息则方便做筛选、排序和状态标记。
2.2 核心命令设计:像git分支一样管理上下文
命令行是这个工具的主体。我设计了一套非常精简的命令,全部以cm开头,和“context-mode”的缩写对应:
cm init:在当前项目目录初始化一个上下文配置文件,生成.cm/config.yml。cm new <name>:新建一个上下文并切换到它。cm save:把当前的上下文状态保存到文件。cm switch <name>:切换到指定上下文。cm list:列出所有上下文,显示名称、项目、更新时间。cm cat:输出当前上下文文件内容,方便喂给AI或复制到其他地方。cm clear:清空当前上下文中的对话摘要,保留目标和约束。
这套命令的语义非常直观。你不需要去理解内部存储结构,只需要把它想象成一个“上下文分支管理工具”。就像git checkout切换分支一样,cm switch切换的是你当前的工作话题和AI的记忆。
2.3 与AI辅助工具的集成思路:把上下文变成标准输入
工具本身不直接绑定任何AI客户端。它只负责管理上下文文件,并提供一个“导出”机制。你想要在某个AI工具中使用当前上下文,只需要把cm cat的输出拼到你的提示词里,或者让AI工具读取这个文件。比如,我的使用方式是在shell里定义一个别名:
alias ai-chat='ai chat --system-file ~/.context-mode/current.md'每次执行cm switch时,工具会更新~/.context-mode/current.md这个软链接,让它指向当前上下文的文件。这样AI在启动对话时,自动就能读取当前项目的目标、约束和聊天摘要,相当于给它喂了一份“项目简报”。
这个设计的关键在于:上下文管理不应该和具体的AI模型或客户端耦合。因为模型更新很快、客户端五花八门,而上下文格式是慢变量,用标准文件格式隔绝变化,工具才能稳定用下去。
2.4 关键技术选型:为什么是Python加Click
这个工具我用Python写,命令解析用Click,没有引入复杂依赖。选择原因是:
- Python直接可用,跨平台方便,用户不需要额外装Node或Go。
- Click可以快速定义子命令和参数,使用体验和git接近。
- 文件操作用Pathlib,读取写入都很干净。
我不推荐为了一个几十行的CLI工具引入重型框架,或者去用ORM、消息队列什么的。工具的核心逻辑其实很简单,复杂度主要在于上下文格式设计和使用习惯,而不是技术栈。
3. 实操过程:从零搭建并接入你的工作流
3.1 初始化项目并创建第一个上下文
拿到源码后,先安装依赖并执行安装:
pip install click pyyaml python setup.py install # 或者把 cm.py 直接放到 PATH然后进入你的某个项目目录,初始化上下文配置:
cd ~/work/backbone cm init这条命令会在当前目录生成一个.cm/config.yml:
project: backbone owner: zhang default_context: pay-api-refactorconfig.yml里记录了项目名和默认上下文。以后你在这个目录下执行cm switch时,如果不带参数,就会自动切换到default_context。这样做的好处是:每个项目的缺省上下文都不同,你不需要每次手动指定。
接着创建第一个上下文:
cm new pay-api-refactor工具会在~/.context-mode/contexts/下创建pay-api-refactor目录,写入一个模板文件。然后自动把当前上下文软链接指向这个文件。你可以打开~/.context-mode/contexts/pay-api-refactor/context.md开始编辑内容,填入项目目标、约束等。
3.2 保存上下文快照:记录关键信息,而不是流水账
真正开始用的时候,你不需要一次性把整个项目细节都写进去。我的习惯是,每天工作时先更新三个部分:当前目标、关键决策、待办事项。对话摘要则可以交给AI生成,每天下班前跑一次:
cm save --summary "今天完成了对账SQL查询开发,确定了批量匹配键,待处理幂等"如果你希望更自动,可以把摘要保存做成一个分支流程:让AI根据今天的对话生成摘要,然后你复制到cm save的输入里。实测下来这比手动回想快很多,而且AI生成的摘要往往更贴对话内容的重点。
保存完之后,context.md会更新updated字段,并把对话摘要部分替换成新内容。为了避免历史记录被完全覆盖,我把旧的摘要追加到archive.md文件中,这样你随时可以往回查。这个设计在实际使用中非常有用,因为你可能过两周需要回顾之前某个决策是怎么做出的。
3.3 切换上下文:一行命令换项目
当你准备切到另一个项目时,执行:
cm switch user-permission-model此时~/.context-mode/current.md会指向新项目上下文。为了验证是否成功,可以执行:
cm list输出类似:
name project updated pay-api-refactor backbone 2025-03-14 10:32 user-permission-model portal 2025-03-15 09:12然后用cm cat输出当前上下文:
cm cat | head -60你会看到当前上下文的目标和约束已经是新项目的了。这时候再启动AI聊天,就不需要你手动复制粘贴,AI直接通过system file获取这些内容。
这里有一个关键实现细节:cm switch默认是一个普通子命令,但它要改变的是父shell中的环境变量或软链接。软链接问题不大,直接更新即可;如果你想进一步在shell中设置CM_CONTEXT环境变量,就得让cm switch输出一段可被eval执行的shell代码,常用做法是:
eval "$(cm switch user-permission-model)"cm switch检测到当前shell为/bin/bash或/bin/zsh时,输出:
export CM_PROJECT=portal export CM_CONTEXT=user-permission-model这样父shell环境也更新了。这个小细节是很多新手写CLI工具时会踩的坑:子进程只能改变自己进程的环境变量,没法影响父进程。所以必须用eval输出环境变量赋值语句。
3.4 用别名和shell函数实现极速切换
每次都要敲cm switch xxx还是有点烦。我在.zshrc里加了一个函数:
function cm() { if [[ $1 == "switch" && -n $2 ]]; then eval "$(command cm switch $2)" if [[ -f ~/.context-mode/current.md ]]; then export CM_FROM="$(basename $(dirname $(readlink ~/.context-mode/current.md)))" echo "switched to $CM_FROM" fi else command cm "$@" fi }这样执行cm switch user-permission-model后,不只更新软链接,还自动把环境变量设置好了。习惯之后,切换几乎成为肌肉记忆。我还给最常用的三个项目做了单字母别名:
alias cpa='cm switch pay-api-refactor' alias cpu='cm switch user-permission-model'彻底告别长命令,打开终端直接输入别名就能开始工作。
3.5 结合git分支实现项目维度隔离
进一步使用时,我发现一个问题:同一个项目下可能有不同功能分支,比如版本V1和V2。如果上下文只有一个,分支之间的信息会交叉污染。解决办法是:让上下文名称包含分支信息,或者用一个符号链接自动指向当前git分支对应的上下文。
我在cm switch里增加了一个--auto选项:
cm switch --auto它会检测当前目录的git分支名,比如feature/priv-role,然后自动切换到名为priv-role的上下文,如果不存在就自动创建。这样切换git分支的同时,AI上下文也跟着切,完美对齐。
这个操作背后的逻辑是:上下文本质上和“当前工作主题”强相关,而代码分支通常就代表了工作主题。当代码分支切换时,工作主题必然变了,AI的上下文也必须跟着变。把这两者绑定在一起,能省下很多手动管理成本。
4. 常见问题与排查技巧实录
4.1 上下文文件越来越大,导致AI响应变慢
用了一段时间后,context.md越写越长,尤其对话摘要一直累积。AI读取的上下文一多,响应速度明显下降,而且会开始“过度关注”那些琐碎的旧摘要。
我的解决方式是引入大小上限。在cm save时,如果文件超过8KB,就把最老的对话摘要移到archive.md,只保留最近3轮。这样当前上下文始终保持精简,历史信息不丢,但不会干扰AI。
你可能会问,为什么不保留所有历史?因为模型对近因效应很敏感,前面塞一万字的旧日志,反而会把最新的目标淹没。上下文管理的本质是“懂得舍弃”,不是把所有东西都堆在一起。
4.2 多设备同步冲突
我在公司和家里都会使用同一套上下文,早期用坚果云同步整个~/.context-mode目录,但偶尔会出现两边都修改同一个文件的情况,markdown内容直接乱掉。后来我把上下文目录本身做成一个git仓库,在cm save后自动git add和git commit:
cd ~/.context-mode git add -A git commit -m "update context: $CM_CONTEXT"如果冲突,git会标出来。因为上下文文件是文本格式,冲突合并很直观,用编辑器手动解决一下就行。这个方案比各种云同步稳定很多,而且所有历史版本都有记录,误删也能恢复。
4.3 敏感信息泄露风险
上下文里经常会有API密钥、数据库密码、内部系统地址。如果你把上下文目录提交到公开仓库,或者传给AI服务商时,这就是很大风险。我的习惯是:
- 用环境变量存密钥,上下文里只写变量名,比如
DB_PASSWORD=${DB_PASSWORD}。 - 在
.gitignore里忽略secrets.md,单独特权访问。 - 配置一个检查脚本,在
cm save前扫描上下文文件,如果发现类似password=或api_key=的行,自动警告。
这个检查逻辑很简单,但能在关键时刻拉你一把。有一次我差点把生产环境的AK写进摘要里,工具弹了个警告,我当场删掉了。自用工具也一样要重视安全,别因为“只有我自己用”就放松警惕。
4.4 忘记保存上下文怎么办
最尴尬的是,当你正切换上下文时,突然意识到上个上下文的重要进展还没保存。这个情况太常见了。我后来加了一个机制:在shell的PROMPT_COMMAND里检测当前目录的.cm/config.yml被修改过,但上下文没有及时保存,就自动打印提醒:
if [[ -f .cm/config.yml && -z $CM_UP_TO_DATE ]]; then echo "cm changes not saved, run cm save" fi当然,更保险的做法是定期自动保存。我写了一个后台脚本,每隔10分钟把当前的context.md的修改时间戳写入版本文件。检测到当前上下文在半小时内被编辑过且未cm save,就自动保存并commit。虽然有点“过度工程”,但确实解决了健忘问题。
4.5 与其他AI工具的兼容性
different AI工具读上下文的方式都不一样。有些支持--system-file,有些不支持;有些人喜欢JSON格式的上下文。为了兼容,我把命令设计成多种输出格式:
cm cat --format md:直接输出Markdown正文。cm cat --format text:去掉YAML头,只输出给AI看的提示文本。cm cat --format json:输出包含元数据和正文的JSON对象,方便程序处理。
如果你用的AI客户端接收JSON,就可以直接用cm cat --format json作为输入。如果客户端只支持纯文本,就选text。关键是不要在某个单一客户端里绑死,把格式转换放在输出层。
5. 核心代码参考:一个可用的最小实现
受篇幅限制,我提炼一个最精简版本。它实现了new、switch、save、cat四个核心命令,够日常使用。完整工程还包含init、list、clear、自动提交和校验逻辑,大家可以根据下面的框架继续扩展。
#!/usr/bin/env python3 """context-mode minimal implementation.""" import click import yaml import os import shutil from pathlib import Path from datetime import datetime CONTEXT_ROOT = Path.home() / ".context-mode" CONTEXTS_DIR = CONTEXT_ROOT / "contexts" CURRENT_LINK = CONTEXT_ROOT / "current.md" ARCHIVE_DIR = CONTEXT_ROOT / "archives" def ensure_dirs(): CONTEXTS_DIR.mkdir(parents=True, exist_ok=True) ARCHIVE_DIR.mkdir(parents=True, exist_ok=True) def get_context_path(name: str) -> Path: return CONTEXTS_DIR / name / "context.md" def make_context(name: str): ctx_dir = CONTEXTS_DIR / name ctx_dir.mkdir(parents=True, exist_ok=True) ctx_file = ctx_dir / "context.md" if not ctx_file.exists(): now = datetime.now().isoformat(timespec="minutes") template = f"""--- name: {name} created: {now} updated: {now} --- # 当前目标 # 关键决策 # 技术约束 # 当前进展 # 对话摘要 """ ctx_file.write_text(template, encoding="utf-8") return ctx_file def switch_context(name: str): ctx_path = make_context(name) if CURRENT_LINK.is_symlink() or CURRENT_LINK.exists(): CURRENT_LINK.unlink() os.symlink(ctx_path, CURRENT_LINK) return ctx_path @click.group() def cli(): """context-mode: manage AI contexts.""" ensure_dirs() @click.command() @click.argument("name") def new(name: str): """Create a new context and switch to it.""" path = switch_context(name) click.echo(f"created and switched to {name} ({path})") click.echo(f"export CM_CONTEXT={name}") @click.command() @click.argument("name") def switch(name: str): """Switch to an existing context (create if missing).""" path = switch_context(name) click.echo(f"switched to {name}") click.echo(f"export CM_CONTEXT={name}") @click.command() @click.option("--summary", default=None, help="New summary text.") @click.option("--max-size", default=8192, help="Max context size.") def save(summary: str, max_size: int): """Save current context.""" if not CURRENT_LINK.exists(): click.echo("no current context", err=True) raise SystemExit(1) current = CURRENT_LINK.resolve() data = current.read_text(encoding="utf-8") if summary: new_data = replace_summary(data, summary) current.write_text(new_data, encoding="utf-8") size = current.stat().st_size if size > max_size: archive_and_trim(current, max_size) click.echo(f"context trimmed to {max_size} bytes") click.echo("context saved") @click.command() @click.option("--format", "fmt", default="md", type=click.Choice(["md", "text", "json"])) def cat(fmt: str): """Print current context.""" if not CURRENT_LINK.exists(): click.echo("no current context", err=True) raise SystemExit(1) data = CURRENT_LINK.read_text(encoding="utf-8") if fmt == "md": click.echo(data) elif fmt == "text": lines = data.splitlines() start = False for line in lines: if line.startswith("---"): if start: continue else: start = True continue if start: continue click.echo(line) elif fmt == "json": parts = data.split("---", 2) meta = yaml.safe_load(parts[1]) if len(parts) >= 3 else {} body = parts[2].strip() if len(parts) >= 3 else data click.echo(Path(json_dumps({"name": meta.get("name"), "content": body})))这里有两个要点值得说明。
replace_summary函数建议用正则定位# 对话摘要部分并替换,简单实现即可,核心是避免手动拼接出错。
archive_and_trim是把最旧的对话摘要移到archive.md。我在裁剪时会把原文件的内容备份到ARCHIVE_DIR/<context_name>-<date>.md,然后删除对话摘要下所有的列表项,只保留最近4行。这样既保留了可追溯性,又不让当前上下文膨胀。
如果只想快速使用,你甚至可以把上面这段代码保存为cm.py,放到PATH里,加上chmod +x,然后配合eval "$(cm switch xxx)"就能跑起来。完整的复杂逻辑(自动提交、敏感词检测、git分支绑定)刚才也都有思路,可以作为进阶练习自己加。
6. 再聊聊我对上下文管理的体会
我最初写 context-mode 只是为了让AI少犯蠢,但用了一年多之后,我发现它真正改变的是我自己的思维方式。以前我经常在项目切换时靠大脑硬记,陷入“好像有个限制条件但我忘了”的状态。现在每次保存上下文,我都会下意识把当前目标、关键决策、技术约束想一遍,这本身就是对工作非常有效的梳理。
给大家一个小建议:不要一上来就追求完美的上下文模板。先随便用一段文字记录“这个项目在干嘛、下一步做什么”,然后配合AI的对话摘要慢慢沉淀。等到用顺手了,再逐步补充像“技术约束”、“已废弃方案”这些结构。工具是死的,使用习惯才是核心。如果你也在被多项目切换困扰,不妨先试一下这套机制,告别每次开新对话都要重新教一遍AI的原始生活。
最后再说一个细节:这类工具一定要让你的“切换成本”趋于零。如果切换一个上下文要敲五条命令,那你早晚会放弃。用别名、用shell函数、用git分支绑定,怎么方便怎么来。把复杂留给工具,把简单留给自己,这才是个人生产力工具的终极目标。