1. context-mode 是什么:先说清楚它解决什么问题
1.1 从一次“失忆”的会话说起
我在做终端小工具的时候遇到过这么一个问题:用户在第一轮输入了“当前项目在 /home/dev/webapp”,第二轮直接问“这个项目的依赖有哪些”,工具这边却完全不知道“这个项目”指的是哪个,只能老老实实让用户重新说一遍全路径。那一刻我意识到,很多看起来不够智能的软件,缺的不是算法,而是记忆,缺的正是 context-mode(上下文模式)这种把“刚刚发生过什么”保存下来、并影响下一步行为的能力。
context-mode 不是一个新造出来的词,它其实散落在很多产品里:代码编辑器的自动补全要看光标前文,客服系统要记住用户上次的工单编号,语音助手要维持一段对话的指代关系。但真正值得展开聊的,是把它当成一种独立的设计模式来对待,而不是顺手塞进去的临时状态。这篇文章我会从使用场景、工作原理、模块设计、实战代码到踩坑排查,把 context-mode 完整拆开讲一遍,适合正在做聊天机器人、终端交互工具、IDE 插件或者任何需要“记忆”的开发者参考。
1.2 上下文模式的三个关键词
要理解 context-mode,我习惯抓住三个关键词:上下文、作用域、恢复。上下文是当前任务相关的信息集合,作用域规定了这些信息在什么时候有效,恢复则是在需要的时候能把之前的状态重新拉起来。这三个词共同回答了一个问题:系统在做出某个决策时,应该看到哪些历史信息?
举一个生活化的类比:你去银行办业务,柜员会先看你拿的号,再翻你的账户信息,最后根据你前面说过“要买东西”这件事推荐办信用卡。整个过程里,号是短期上下文,账户是长期上下文,办卡是当前任务。如果柜员每次接待你都像第一次见面一样重头问一遍,效率就没了;如果她记着你十年来的每笔消费记录,又会信息过载。context-mode 的核心,就是在这两者之间做取舍。
2. 为什么值得自己做一套 context-mode
2.1 别急着接大模型,先管理好上下文
现在很多人一谈到上下文,第一反应是“当然是大模型的 prompt 窗口”。这个思路没错,但容易忽视一个事实:在真正把一段上下文塞给模型之前,你得先有一套机制来决定“这段上下文是从哪来的”“能活多久”“谁可以修改它”。否则会出现两个极端:要么上下文装了太多无关信息,模型被噪声带偏;要么上下文太短,模型每次都像失忆一样。
我见过一个团队做客服问答,最初他们把整个数据库表结构、用户操作日志、当前聊天内容全部拼进 prompt,结果模型回答得又慢又乱。后面他们反思下来,核心问题就是没有设计 context-mode:缺少对上下文的分类、分级和过期策略。单独做一个 context 管理模块,比依赖模型“自己从海量信息里挑重点”要稳定得多,也更容易测试和回滚。
2.2 和 session、cache、state 的区别
很多人会问:这不是 session 吗?不是 cache 吗?不是全局 state 吗?它们确实有关系,但出发点和形态不太一样。
| 概念 | 侧重点 | 典型问题 |
|---|---|---|
| session | 用户会话存活期内的状态 | 过期时间、会话标识 |
| cache | 加速重复读取的数据 | 命中率、失效策略 |
| state | 应用运行时的即时数据 | 状态流转、一致性 |
| context-mode | 当前决策所需的相关历史信息 | 范围选取、信息压缩、恢复策略 |
cache 关注的是“快速拿到以前算过的结果”,session 关注的是“这个用户登录后保持连接”,state 关注的是“这一刻应用处于什么状态”,而 context-mode 关注的是“做当前这件事需要知道什么”。一个完整的 context-mode 可能会用到 session 的持久化能力,也会用到 cache 的读取效率,还会依赖 state 来标记当前阶段,但它本身是一层更接近业务语义的组织方式。
2.3 适用与不适用的场景
context-mode 用得好,能明显提升交互流畅度。我个人总结下来,比较适合以下几类场景:多轮对话系统、命令行交互工具、代码补全和代码生成、表单分步骤填写、游戏对话系统、运维巡检中根据前一步结果执行下一步操作。
但也不是所有地方都需要它。如果你的任务每次都是独立的,比如一个单纯的计算器、一个一次性文件格式转换器,引入 context 反而增加了复杂度。另外,如果上下文数据涉及权限边界,比如普通用户不应该访问上一个用户留下的内部信息,那么设计时就必须额外做隔离校验,这种情况下宁可不要“方便”的功能。
3. 设计一个可用的 context-mode:核心步骤
3.1 先给上下文定“边界”
动手写代码之前,第一步不是定义数据结构,而是给上下文画边界。我吃过亏,一开始想着把所有东西都塞进一个 dict,变量名叫ctx,结果代码里到处都能读写,最后根本不知道某个字段是被哪个模块改掉的。后来我学会了区分三种边界:
- 交互边界:这次会话范围内应该记住什么,比如用户刚输入的关键词、当前选中的文件路径。
- 任务边界:完成当前任务才需要的临时信息,比如正在解析的配置文件内容、已经生成了一半的结果。
- 生效边界:哪些模块可以读取哪些字段,比如权限校验模块只能读用户身份相关字段,不能动业务数据。
这样划分的好处是职责清楚。交互边界对应一个会话的开始和结束,任务边界对应一个流程的开始和结束,生效边界则用白名单解决,没有白名单的字段原则上不允许读取。在实际项目里,我会先用一个表格把字段、来源、生命周期、可读方列一遍,再动手写类。
3.2 保留什么、丢弃什么:权重判断
context-mode 的精髓在于“取舍”。信息不是越多越好,而是要跟当前目标相关。我给信息保留设计了一个简单的权重公式:
保留权重 = 相关度 * 新鲜度 / 获取成本
相关度越高越值得留,越新鲜的信息越值得留,获取成本越低越值得留。举个例子,一个终端工具里,用户最近选择的文件路径,相关度很高、很新鲜、获取成本低,权重就高;用户三天前设置的主题颜色,虽然相关度低,但获取成本也低,保留一下无妨;用户上次输入的一长串调试日志,已经过去很久,而且体积很大,就会被提前清理。
具体实现时,我会给每个上下文条目加created_at、access_at、priority、ttl几个字段,然后设一个总容量上限。当容量满的时候,优先淘汰优先级最低、最久没被访问的条目。这个思路其实跟 LRU 缓存很像,但多了一个业务相关的 priority。
3.3 一个最小可跑的上下文管理器(Python 示例)
下面是一个我实际用过的简化版实现,核心就是:能写入、能读取、能压缩、能淘汰。没有用任何第三方库,方便你直接跑起来看效果。
import time import heapq class ContextEntry: def __init__(self, key, value, priority=1, ttl=300, source="user"): self.key = key self.value = value self.priority = priority self.ttl = ttl self.source = source self.created_at = time.time() self.access_at = time.time() def is_expired(self, now=None): now = now or time.time() return now - self.created_at > self.ttl class ContextManager: def __init__(self, max_entries=20): self._entries = {} self.max_entries = max_entries def set(self, key, value, priority=1, ttl=300, source="system"): if key not in self._entries and len(self._entries) >= self.max_entries: self._evict() entry = ContextEntry(key, value, priority, ttl, source) self._entries[key] = entry return entry def get(self, key, now=None): entry = self._entries.get(key) if not entry: return None if entry.is_expired(now): self.delete(key) return None entry.access_at = time.time() return entry.value def delete(self, key): self._entries.pop(key, None) def _evict(self): # 优先淘汰过期条目 for key in list(self._entries.keys()): if self._entries[key].is_expired(): self.delete(key) if len(self._entries) < self.max_entries: return # 否则按 优先级/最后访问时间 排序 oldest = min(self._entries.keys(), key=lambda k: ( self._entries[k].priority, self._entries[k].access_at )) self.delete(oldest) def compact(self): # 压缩一下体积,实际场景里可以替换为摘要、脱敏、截断 for key, entry in list(self._entries.items()): if isinstance(entry.value, str) and len(entry.value) > 100: entry.value = entry.value[:100] + "..." self._evict()这段代码里有一个容易被忽略的细节:get方法会更新access_at。这么做是为了让“最近用过”的信息变得重要,不至于因为提前写了很久就被优先淘汰。如果你把淘汰策略写得太死,会出现一种尴尬情况:用户刚提到过的上下文反而被当成旧数据清掉了。
3.4 持久化:把记忆写到磁盘
如果上下文只能存活在内存里,进程一重启就全部归零,很多事情就没法做。我通常把 context-mode 分成两层:内存层负责快速读写,持久层负责跨重启恢复。
持久化的实现方法很多。规模小的时候,直接存成 JSON 文件按项目维度分目录即可;规模大了再用 SQLite 或者 Redis。关键不是选什么存储,而是确定“何时持久化”。如果每次写入都落盘,性能会很差;如果全部攒着最后写,进程崩溃就会丢很多数据。我习惯的做法是:重要字段(比如任务状态、用户当前选中项)每次变更都持久化,细节字段(比如中间计算日志)只要定期同步一次就够了。
序列化时还要考虑跨语言场景。我建议不要直接序列化 Python 对象,而是先把 context 转成纯字典,然后再落到文件。这样将来换成 Go、Java 或者其他语言,数据格式依然是通用的,不会因为语言特性绑死自己。
4. 实战案例:给命令行工具加上 context-mode
4.1 需求拆解与交互设计
为了不空谈理论,我拿一个实际做过的终端小工具举例。这个工具叫“dev-helper”,作用是帮开发者查询项目信息和执行常见命令。最初它的交互是这样的:用户每敲一条指令,工具都通过参数把完整信息带上,例如“查询项目 /home/dev/webapp 的依赖”,非常啰嗦。
接入 context-mode 之后,我希望支持这样的对话:
你:查看项目 /home/dev/webapp 助手:已切换项目,正在加载配置。 你:它的依赖有哪些? 助手:读取到项目创建于 2023-05-01,主要依赖有 Flask、Redis SDK, 正在生成依赖列表... 你:用 pip 导出一份 requirements.txt 助手:已基于当前项目目录执行导出。这里面有两个关键动作:第一次输入时把“当前项目路径”写入 context;第二三次输入虽然没有重复提到项目路径,但助手能从上下文里取到。交互层面还要设计好“什么时候需要用户确认”:当上下文里的信息足够可信时,直接使用;当存在多个候选项目时,主动让用户选一个,而不是闷头猜。
4.2 核心代码结构与实现
我实现的时候没有把 context-mode 硬写在业务逻辑里,而是做成一个独立模块,业务逻辑只通过get_current_project()这类方法去读取上下文。这样做的好处是,将来如果要给工具增加图形界面或者网页界面,底层 context 逻辑可以原封不动地复用。
关键步骤大概是这几步:
- 解析用户原始输入中的实体信息,例如识别出路径、文件名、命令类型。
- 把识别出来的实体写入 ContextManager,带高优先级和较长的 ttl。
- 执行业务命令前,先向 ContextManager 查询当前项目路径、当前分支等字段,自动补全参数。
- 命令执行后,把执行结果里的关键信息(例如“构建成功”)也写入上下文,给下一轮决策做参考。
from context_manager import ContextManager ctx = ContextManager(max_entries=50) def handle_command(user_input: str): if "查看项目" in user_input: project_path = extract_path(user_input) ctx.set("current_project", project_path, priority=2, ttl=1800) return f"已切换到 {project_path}" if "依赖" in user_input: project = ctx.get("current_project") if not project: return "还没有指定项目,请先告诉我要查看哪个项目" deps = read_dependencies(project) summary = summarize_deps(deps) ctx.set("last_deps_summary", summary, priority=1, ttl=600) return summary if "导出" in user_input: project = ctx.get("current_project") if not project: return "请先设置项目路径" run_pip_export(project) return "导出完成"这段代码看起来简单,但里面藏着两个重要设计。第一,current_project的优先级是 2,而last_deps_summary的优先级是 1,一旦容量不够,先被淘汰的是低优先级的摘要,而不是项目路径。第二,ttl 不是随便定的,项目路径在 30 分钟内有意义,摘要只需要 10 分钟,可以把 ttl 作为业务策略的一个维度来调。
4.3 尝试过的失败方案与修正
这个工具的第一个版本,我把上下文直接写成了类里的全局变量,业务模块之间直接互相读字段。结果项目越写越大,模块之间互相读写,日志一多根本查不出来是谁改的。后来我改成只有ContextManager内部能改数据,外部全部通过set/get方法访问,状态才变得可追踪。
我还试过用装饰器自动记录上下文。一开始设想得很好:每个业务函数执行完后自动把返回值存进 context,调用前自动把参数补全。真写起来才发现,自动处理逻辑对参数类型和返回值的判断过于武断,经常把用户不想被记住的东西也存进去。比如用户临时输错了一个路径,也被当成重要上下文存下来,导致后续命令一直跑到错误路径上。后面我干脆改成显式写入,只在真正有把握的位置调用ctx.set(),反而更可靠。
5. 常见问题与排查实录
5.1 上下文污染:上次的结果带到了这次
最典型的故障是“串味”。用户先选择了项目 A,又来问项目 B 的信息,但因为上下文里还残留着项目 A 的路径,系统把 B 的问题拿 A 的路径去查,结果答非所问。这个问题几乎每个做 context 的人都会遇到。
我的排查思路分三步:先看是哪条上下文字段导致结果错误;再看这条字段是什么时候被写入的;最后判断它是否应该在当前任务边界内生效。如果是用户主动切换了项目,那么写入新项目路径的同时,应该把旧项目相关的临时字段清掉。这相当于给上下文加了一个“重置”操作。
def switch_project(project_path): ctx.delete("current_project") ctx.delete("last_deps_summary") ctx.delete("last_build_result") ctx.set("current_project", project_path, priority=2, ttl=1800)这个 reset 操作很重要。我发现很多新手只记得 set,不记得 delete,结果旧上下文一直留在里面,慢慢变成一个隐性 bug。你可以在set同一个 key 的时候自动清理同组其他字段,也可以像上面这样显式清理。
5.2 信息过载与截断策略
上下文存太多了,尤其是聊天场景里把每一轮用户输入和系统输出都存下来,很快容量就会爆。最简单的办法是只保留最近 N 条消息。但这样会丢掉早期的重要信息,比如用户第一句说的“我的项目在 /home/dev/webapp”,到第 20 轮可能已经被挤出窗口了。
更合理的做法是定期对上下文做摘要压缩。比如每 5 轮对话后,把前面的内容用规则提取出“项目路径”“当前目标”“待办事项”这几个关键字段,保存成一份浓缩版上下文,再把原始会话记录降级或者清理。这样既保留了核心记忆,又不会无限膨胀。
5.3 并发场景下的上下文隔离
如果你的服务同时服务多个用户,那 context 绝对不能只用一个全局字典。每个用户要有独立的 context 实例。这个隔离很简单,但如果每个用户内部有多个并发任务,还要注意上下文中的竞争问题。
我踩过的坑是这样的:同一个用户触发了两个异步任务,两个任务同时读取当前项目路径,然后一个任务把它覆盖为 B 项目,另一个任务拿 A 项目的答案,结果互相污染。解决方式有两种:一种是在任务开始时把需要的上下文字段复制出来,后续不依赖共享对象;另一种是给每次任务生成一个独立的 context 子作用域,任务结束后合并回主上下文。对于终端工具这类轻量级应用,第一种更简单有效。
5.4 调试上下文的小技巧
context-mode 有一个天然难点:它不像普通变量那样容易观察,状态是“隐形”的。为了调试方便,我习惯给 ContextManager 增加快照能力,可以在任意时刻导出当前所有上下文内容。
def snapshot(self): return { key: { "value": entry.value, "priority": entry.priority, "ttl": entry.ttl, "created_at": entry.created_at, "access_at": entry.access_at, "source": entry.source, } for key, entry in self._entries.items() }遇到问题的时候,先打一张快照,看看当前上下文里到底有什么,再决定是删除、修改还是补写。这个习惯帮我节省了大量排查时间。你还可以在命令行工具里加一个隐藏命令,比如debug context,直接打印快照,方便现场定位。
6. 接入 context-mode 后的实测与进一步扩展
6.1 效果对比与体验变化
我把这套 context-mode 接入 dev-helper 之后,做了一个简单对比。使用前,用户平均每条指令要打 3 到 4 个参数,比如“查询项目 /home/dev/webapp 的 git 状态”;使用后,第一轮告诉工具项目路径,后续直接说“git 状态”“构建”“跑测试”就行。指令长度缩短了一半以上,而且用户不再需要频繁复制路径,体验明显更顺畅。
在稳定性方面,显式写入 + 删除 + 快照这套机制让问题更容易定位。以前如果出现串味,只能靠猜哪里的全局变量出了问题;现在只要打一张 context 快照,一眼就能看到当前记住了什么,哪里不对就改哪里。这种可观察性,我认为比任何花哨的智能算法都重要。
6.2 后续可以继续扩展的方向
如果让我继续完善,我会做三件事。第一,把摘要压缩做得更智能,不只用规则提取,还可以接入轻量的文本摘要模型,让压缩后的上下文更接近真实意图。第二,给 ContextManager 增加订阅通知机制,上下文字段变化时,自动触发相关模块刷新,比如项目路径变了,自动重新加载配置。第三,做一个细粒度的权限控制,区分哪些字段是用户可编辑的,哪些是系统内部计算的,避免用户输入被误当成系统可信状态。
最后一个我在实际使用中的体会是:context-mode 看似是个技术组件,实际上是产品设计问题。你越清楚用户在当前任务里真正需要记住什么,越能把代码写得既简单又可靠。不要一上来就追求“全自动记忆”,先把手动的、显式的上下文流转做稳,再去考虑智能化,这条路会顺很多。