1. 先搞清楚:context-mode 到底在解决什么问题
第一次让我认真琢磨 context-mode 这四个字,是在一个挺头疼的现场。当时我用某个 AI 编程助手改一个老项目,前一天已经跟它聊了二十多轮,把模块结构、命名规则、异常处理约定都交代过一遍。第二天打开新会话,问它"把刚才说的 default handler 补充完整",它直接回答:你在说什么 handler?我那一瞬间的感受,和所有用过"失忆版聊天机器人"的人一样:这功能好是好,但怎么就不能记住点东西呢。
这时候你需要的不是一个"更聪明的模型",而是一个更聪明的上下文模式。context-mode 翻译过来就是"上下文模式",它本质上是一个功能开关,决定系统在处理当前这次请求时,到底携带哪些历史信息、周边信息,以及怎么携带、携带多少。它不负责替你思考,它负责决定"思考的时候应该把哪些材料摊在桌面上"。
为什么这个东西最近忽然成了热词?因为大量产品都开始遇到同一个尴尬:模型越强,用户越懒——大家都默认它能记住上下文,可它一旦真的"什么都记",又会带来误判、发散、昂贵、隐私泄漏一堆新问题。于是 context-mode 就成了一个绕不开的设计话题。本文我会按自己的实际经验,先讲清楚它解决什么,再讲设计取舍,然后给出一套可以直接抄走的最小实现代码,最后聊聊我踩过的坑和验证方法。
1.1 上下文模式的本质:在"记忆广度"和"注意力纯度"之间找平衡
把模型看成一位刚入职的员工。这位员工能力很强,但有两种极端:一种做法是你每次都只给它当前这一张工单,它当然不会瞎忙活,但也永远学不会你的项目惯例;另一种做法是把你过去三年所有聊天记录、代码、邮件全部堆给它,它反而会被无关信息干扰。上下文模式就是在中间设计出一个"记忆管理机制":什么时候该忘、什么时候该记、什么时候该翻旧账。
在技术实现上,影响上下文模式的变量主要有这几个:
- 历史消息的长度:到底往前翻多少轮对话?
- 内容来源:对话记录、代码片段、本地文件、数据库查询结果,哪些要进提示词?
- 召回策略:按时间顺序取,还是按和当前问题的相关度取?
- 压缩方式:超过窗口长度之后,是直接截断,还是先做摘要?
这四个变量组合起来,基本就能决定一个上下文系统是聪明还是烦人。我在实际项目里,最常用一句话来设计:最少够用。只把和当前任务直接相关的上下文放进来,而不是把整个历史都搬进提示词。后面我会给出一套可以抄走的实现框架,现在先看几种典型场景,方便你快速对号入座。
1.2 三种典型场景,快速对号入座
场景一:客服或文档助手。用户在同一个会话里连续提问,前一句说"我的订单号是 20240515",下一句问"这个订单几天能到",如果助手没有上下文,它根本不知道"这个"指的是什么。这里 context-mode 要解决的是代词和省略句的指代问题,通常保留最近三到五轮对话就够了。
场景二:代码结对场景。AI 需要同时知道当前打开的文件、最近改动的 diff、以及会话里讨论过的设计约定。这不是简单地把文件全文塞进去,而是要做取舍:文件很大时,只抽取和当前光标位置相关的函数、类、引用关系。context-mode 在这里更像一个"项目经理",决定把哪几个文件片段放进本轮对话。
场景三:多租户后台系统。每个用户、每个项目都有独立的数据边界。如果上下文模式做得不严谨,A 项目里讨论的变量名、业务规则,可能会在下一次请求里悄悄混进 B 项目。这种串号比"回答得不好"严重得多,是设计红线。
你可以对照自己的业务判断一下:如果产品里已经有"历史记录"、"会话"、"粘贴上下文"这类设计,那 context-mode 基本就是帮这些功能建立规则的那个大脑。它不抢模型的活,它只是告诉模型:这次睁眼看哪几页文件。
2. 上下文模式设计的四个关键取舍
很多团队第一次做 context-mode 的时候,思路特别朴素:把 chat history 全部拼起来,再塞进 system prompt。这样做上线第一天可能没问题,跑一周后问题会越来越多。我整理了这个阶段最值得花时间的四个设计点。
2.1 收集范围:不是全都要,而是按需取
收集上下文的第一步,是搞清楚"有哪些候选材料"。常见来源包括:
| 来源 | 典型内容 | 要不要默认进 context | 备注 |
|---|---|---|---|
| 当前会话历史 | 用户和 AI 的历史问答 | 要 | 需要做轮次截断 |
| 打开的文件/页面 | 正在编辑的代码、文档 | 按需 | 文件太长时必须分段 |
| 检索结果 | 向量数据库召回的知识片段 | 要 | 加相关性阈值 |
| 用户画像 | 偏好、权限、身份信息 | 谨慎 | 权限信息尽量不在提示词里明文出现 |
| 全局配置 | 语言、模板、业务规则 | 可以 | 优先级最高,但保持精简 |
我的经验是,给每个来源设一个优先级,而不是平均用力。比如当前会话里刚讨论过的结论,优先级应该高于一个月前的零散笔记;用户正在看的这个文件,优先级应该高于检索到的泛泛相关文章。实现上就是一个带优先级字段的 Collector,先把材料收集齐,再统一排序和裁剪。
这里有个很容易犯的错:以为"上下文 = 对话记录"。实际上对话记录只是其中一部分,真正影响回答质量的是"当前任务相关的那一小撮材料"。我做项目时,会把"对话记录"和"任务材料"分开存,因为它们的过期速度和压缩方式完全不同。对话记录可以按时间窗口滑动,任务材料更多按主题去重。
2.2 上下文窗口的保鲜策略
收集完材料之后,下一个问题:这些材料哪些还新鲜?哪些已经过期?
我常用的保鲜策略有三种:
- 滑动窗口:只保留最近 N 轮对话,比如 10 轮。优点是实现简单、成本可控;缺点是第 11 轮之前的关键约定可能被丢掉。
- 时间衰减:给每条上下文打个时间戳,超过一定时间后优先级指数下降。适合"用户上次提到的东西"这类场景,但不适合"项目技术架构"这种长期稳定信息。
- 语义摘要:每隔一段时间,把旧对话压缩成一段摘要,比如"用户希望用 polars 替代 pandas,已完成数据读取部分的改造"。摘要类上下文不会占太多 token,又能保留长期约定。
三种策略可以组合使用,不一定非要单选。我线上那个项目的方案是:最近 10 轮用原文保留,10 轮以前的对话做成摘要,项目级的约定单独存放在一个 always-on 的短段落里。这样既不丢长期约定,也不会被几千轮闲聊撑爆窗口。
2.3 上下文切换时的边界划分
想象你在用同一个 AI 助手管理三个不同项目。上一个项目的对话里提到"这个项目要用 PostgreSQL",切到下一个项目时,如果上下文没有做边界处理,模型可能真的会把 PostgreSQL 当成新项目的技术选型。这就是典型的上下文污染。
边界划分要解决两件事:
- 垂直边界:不同用户、不同租户之间的数据完全隔离。
- 水平边界:同一个用户的不同任务、不同会话之间,上下文不能互相串场。
我见过一个比较取巧的实现:每次请求都带着一个context_id,它决定了 ContextManager 要从哪个存储桶里取历史。切换项目就是切换 context_id。这个方案看似简单,但非常有效。比在提示词里写"请忽略上一个项目的对话"靠谱一万倍。
还有一个细节:用户主动"开新会话"时,很多系统只是清零了聊天数组,却没有清理嵌入式缓存。结果是模型看起来不记得对话,但检索模块还能搜到旧内容。高级一点的 context-mode 会在开新会话时重建一个隔离的检索索引,而不是沿用全局索引。
3. 实现一套最小可用的 context-mode
理论讲了这么多,终究要落到代码上。下面这套实现是我自己项目里抽出来的简化版,核心就一个文件,不依赖任何特定厂商的 API,换到任何语言模型服务都能用。目标不是做产品级方案,而是把设计思路跑通,让你知道每行代码在干什么。
3.1 模式定义与开关策略
先定义四种模式:
from enum import Enum class ContextMode(str, Enum): OFF = "off" # 完全不带上下文,适合纯计算、翻译、改写 LIGHT = "light" # 只带最近一次交互,适合快速问答 FULL = "full" # 带完整会话历史,适合长任务推演 AUTO = "auto" # 根据输入信号自动判断为什么需要 OFF 模式?因为我踩过坑:有些短问题,比如"帮我把这段 JSON 转成 YAML",带上历史反而会让模型分心,甚至把之前讨论里的错误延续下来。上下文不是必备品,是增压器。很多时候,不带上下文才是正确答案。
AUTO 模式的判断逻辑,我这里用一个很轻的启发式规则:
def auto_uses_context(user_input: str) -> bool: # 出现代词或承接性短语时,一般依赖上文的指代 signals = ["它", "他们", "这个", "这个项目", "上面", "刚才", "上次", "继续", "那"] return any(token in user_input for token in signals)这就是一个很朴素但好用的基线。如果你做面向特定领域的工具,可以把这套规则换成自己的意图识别模型。记住一个原则:AUTO 模式宁可漏判,不要误判。漏判最多让用户多写几个字,误判会直接把答案带偏。
3.2 核心实现:ContextManager
下面这个类负责收集、排序、裁剪上下文。注释我会写得尽量详细:
from dataclasses import dataclass, field from typing import Callable, Optional @dataclass class ContextSource: name: str # 来源名称,如 "history"、"file"、"retrieved" content: str # 内容文本 priority: int = 5 # 数值越小越优先进入 created_at: float = 0.0 # 时间戳,用于保鲜判断 class ContextManager: def __init__(self, mode: ContextMode = ContextMode.AUTO, budget: int = 4000): self.mode = mode self.budget = budget # 上下文总预算,单位近似 token self.history: list[dict] = [] self.retriever: Optional[Callable[[str], list[str]]] = None def bind_retriever(self, fn: Callable[[str], list[str]]): """绑定一个外部检索函数,输入是用户问题,输出是一批候选文本""" self.retriever = fn return self def add_message(self, role: str, content: str): self.history.append({"role": role, "content": content}) # 历史最多保留 100 条,超过就丢弃最早的,防止内存无限增长 if len(self.history) > 100: self.history = self.history[-100:] def should_use_context(self, user_input: str) -> bool: if self.mode == ContextMode.OFF: return False if self.mode in (ContextMode.LIGHT, ContextMode.FULL): return True # AUTO 模式:看输入是否包含承接性信号 return auto_uses_context(user_input) def _collect(self, user_input: str) -> list[ContextSource]: sources: list[ContextSource] = [] # 1. 历史对话 if self.mode == ContextMode.LIGHT and self.history: tail_text = "\n".join( f"{msg['role']}: {msg['content']}" for msg in self.history[-2:] ) sources.append(ContextSource("history", tail_text, priority=1)) elif self.mode in (ContextMode.FULL, ContextMode.AUTO): history_text = "\n".join( f"{msg['role']}: {msg['content']}" for msg in self.history ) sources.append(ContextSource("history", history_text, priority=1)) # 2. 外部检索结果 if self.retriever is not None and self.mode in (ContextMode.FULL, ContextMode.AUTO): for hit in self.retriever(user_input): sources.append(ContextSource("retrieved", hit, priority=3)) return sources def build_prompt(self, user_input: str) -> str: if not self.should_use_context(user_input): return user_input sources = self._collect(user_input) # 按优先级排序,从高到低取;这里是演示,token 数直接用字符数估算 ordered = sorted(sources, key=lambda s: s.priority) blocks = [] used_chars = 0 budget_chars = self.budget * 4 # 粗略按 1 token ≈ 4 个中文字符估算 for src in ordered: piece = f"【{src.name}】\n{src.content}" if used_chars + len(piece) > budget_chars: continue blocks.append(piece) used_chars += len(piece) if not blocks: return user_input prefix = "以下是与当前任务相关的上下文,请优先参考。\n" return prefix + "\n---\n".join(blocks) + f"\n\n用户的问题是:{user_input}"这段代码里有几个设计点是刻意的。
第一,LIGHT 模式取最近两条消息而不是一条。因为只取一条常常不够,用户上一轮可能是在陈述背景,这一轮才真正发问,如果只取一轮,背景信息就丢了。取两条是很便宜又很稳的折中。
第二,token 预算用字符数估算。正式产品里一定要接一个分词器,但演示阶段用字符数可以帮你快速验证逻辑。真实项目里,这个估算函数建议替换成tiktoken或其他语言对应的 tokenizer,否则预算形同虚设。
第三,超过预算的内容直接丢弃,而不是做摘要。这是为了让你先把主链路跑通。后面你可以把drop的分支换成"调一次摘要模型,把 src.content 压缩后再放进来"。我见过很多团队一上来就搞摘要,结果代码里全是魔法,主链路都跑不稳。
3.3 接入请求流程
ContextManager 不关心你怎么把数据发给模型,它只负责产出最终的 prompt 字符串。一个典型的接入流程是这样:
def chat_with_context(manager: ContextManager, client, user_input: str) -> str: # 1. 决定要不要带上下文 if not manager.should_use_context(user_input): return client.generate(user_input) # 2. 构建带上下文的 prompt prompt = manager.build_prompt(user_input) # 3. 记录这轮的问答,供下一轮使用 manager.add_message("user", user_input) response = client.generate(prompt) manager.add_message("assistant", response) return response这段代码特别短,但你要注意add_message的调用时机。我见过不少新手把历史记录在build_prompt之后才追加,导致这一轮的问题永远进不了上下文。正确的顺序应该是:先add_message("user", user_input),再build_prompt,再add_message("assistant", response)。
严格来说,add_message("user", ...)应该放在build_prompt之前,这样上下文拼接时能把当前问题本身也考虑进去。不过如果模型接口会自动追加当前用户输入,那放在之后也可以,关键是团队内部要统一约定,否则调试时会非常痛苦。
3.4 让 AUTO 模式更聪明的两个低成本升级
上面代码里的 AUTO 只是关键词命中,实际用起来还是太糙。想升级,又不想投入太多人力,我有两个推荐方向。
第一,把"当前场景"作为判断条件。比如编辑器场景里,用户正在改某个文件,那上下文大概率需要带上"当前文件中光标附近的代码";如果用户只是在闲聊,那历史上下文就不必带。这个升级几乎不依赖模型能力,只靠产品埋点数据就能完成。
第二,把"上一个动作"作为判断条件。如果用户上一步是点击了一张图片、一段代码、一个报错信息,那么即使这轮没有代词,也应该把那个对象带进上下文。这个方案的实现就是一个简单的"最近选中对象"缓存,成本极低,但效果提升非常明显。
4. 实测踩坑:context-mode 翻车的五种姿势
代码能跑通只是第一步。真正让我对这个话题有发言权的,是后面这一堆翻车现场。我把它们按频率排序写出来,你遇到的时候能少走很多弯路。
4.1 上下文通胀:塞得越满,答得越偏
我第一次把 FULL 模式放到真实场景跑,用户反馈是"模型变笨了"。排查以后发现,问题不在模型,而在我把过去 100 轮聊天记录全塞进去了。那些对话里有大量试探性的、被否定的、最终改过三四版的方案。模型拿到这些互相矛盾的中间过程,自然容易抓错重点。
后来我做了两个调整:一是只保留最终结论,不保留过程;二是每次对话结束,让模型把当前会话浓缩成三条短状态,比如"已完成 A 模块重构""待确认数据库索引方案""用户偏好 error message 用中文"。下一轮请求优先参考这三条状态,而不是原文重放。这个做法解决了我 90% 的上下文通胀问题。
4.2 陈旧上下文被反复调用
另一个高频坑是:上下文里保留着几天前的一个临时假设,用户早已推翻,但检索系统不知道,每次提问都把它捞回来。模型一看上下文里有这个材料,就继续沿着错误方向回答。
要处理这个,得在上下文源里加一个"状态"字段,比如 active、superseded、archived。被用户明确否决的结论,应该立刻标记为 superseded,检索时排除。这个动作可以由模型自动完成:当用户说"不行,换一个"时,产品触发一次上下文状态更新,把那轮讨论标记为失效。
4.3 多用户会话串号与数据隔离
这个问题我说严重一点:它是所有 context-mode 错误里唯一会造成事故的。串逻辑看着很简单,就是一个 context_id 用错了。但真实系统里,context_id 往往不是一个值,而是一组值,比如 user_id、team_id、project_id、session_id,但凡有一个没带对,就可能读到别人的数据。
我在代码里处理这个问题时,会刻意让 ContextManager 的context字段不是普通字符串,而是一个"白名单列表":只有显式传入的 context_id 才能命中对应存储桶。并且会把history数组和retriever都绑定到同一个 context_id 上。不做这个强制绑定,只靠开发人员记得传参,早晚会出事。
4.4 盲目截断导致语义断裂
窗口超了怎么办?很多人的第一反应是"按 token 从后往前截"。这会导致一个很滑稽的结果:句子的后半截全被截掉,剩下的是一堆有头无尾的残句。模型读上下文就像读一篇被撕掉后半页的文档,理解效果当然差。
我后来的做法是:宁可保留更长的摘要,也不要保留残缺的原文。具体来说,截断之前先按句子边界切割,并且给每个片段打分,优先保留包含实体和动词的句子。打分策略可以很粗糙,哪怕只是"包含数量词的+2 分、包含疑问的+2 分",都比纯按位置截断强得多。
4.5 没有兜底策略:一次性请求的高昂代价
最后一个坑很隐蔽:不是所有用户都会妥妥地把问题写在最后一句。有的用户会先甩一篇文章,再问一句"你总结一下"。这种时候,上下文模式只拼"用户最后一句"是不对的,你得把用户当前输入的整体都当成上下文来源。
我加了一个兜底策略:如果用户本次输入超过 2000 字,默认认为这是一次"材料投喂型请求",直接把这段材料作为上下文主体,而不是把它塞进 user 消息里等待模型消化。这样模型不会因为"用户消息过长"而偷工减料。
5. 怎么证明你的 context-mode 真正变好用了
很多团队在 context-mode 上线后,只有一个主观感受:"好像变聪明了点?"这不够。要验证一套上下文系统有没有用,你必须能复现、能对比、能量化。
5.1 自建上下文评测集
我建议准备一份 50 条左右的评测集,每条包含三个部分:前置会话、用户当前问题、期望回答。特别注意包含以下类型:
- 指代型:前置会话里定义过"我们说的那个模块",当前问题问"它什么时候改完"。考察上下文模式能否正确解析指代。
- 干扰型:前置会话讨论过不相干的 A 项目,当前问题问 B 项目。考察上下文隔离是否严密。
- 长程记忆型:前置会话在第 40 轮提出一条约定,第 100 轮再次引用。考察摘要和滑动窗口是否保留了关键约定。
- 无上下文型:用户直接问"1+1",上下文模式应该不携带任何历史。考察开关策略是否够干净。
评测集建好后,每次改代码都跑一遍回归。我个人的及格线是:指代型 90% 以上答对,干扰型 100% 不串号,长程记忆型至少 70% 能还原约定。
5.2 上线指标:准确率之外还要看恢复成本
准确率只是第一个指标。真正让我下定决心调整 context-mode 的,是下面这三组数字:
- 平均响应延迟:开启 FULL 模式后,请求体积变大,延迟会明显上升。如果延迟超过用户容忍范围,即使答案更准,产品也是失败的。
- token 成本:上下文不是免费的。每多带 1KB 上下文,平均每次请求就多付出固定成本。你可以算一笔账:日活 10 万,每次多 2000 token,一天多多少钱。
- 用户修正次数:用户发出提问后,多久会再追问一句"不是,我的意思是…"。这个指标比主观满意度更能反映上下文理解效果。
我建议做一个简单的 A/B 对比:一部分用户走 FULL,一部分走 LIGHT,一部分走 AUTO,跟踪这三组指标。不需要等很久,一周就能看出明显差异。
5.3 接下来可以让 context-mode 更聪明的方向
如果你已经跑通了上面的基础实现,下面这几个方向值得继续投入。
第一,意图驱动的动态预算。不同问题的上下文需求差异很大,向量化检索相关问题需要大量知识片段,纯格式转换问题则不需要。根据意图分配 token 预算,是成本优化的下一个台阶。
第二,语义级摘要压缩。现在的摘要还是"模型把一堆文字压缩成三行",但摘要应该做到"事实级保真":人名、数字、时间、否定信息都不能丢。可以做一层规则校验,摘要生成后再拿原文做一次比对,如果重要的实体没出现在摘要里,就补充进去。
第三,长期记忆的遗忘曲线。借鉴人脑记忆规律,近期信息高权重,远期信息只保留高显著性部分。每次访问都让显著性加一,长期不访问的自动降权。这样 context-mode 就不只是"窗口滑动",而是真正有了"记忆模型"。
最后说点我的真实体会。以前我总把 context-mode 当做一个 on/off 开关,现在更愿意把它理解成一套"注意力分配策略"。我线上那个项目最终采用的是 AUTO + LIGHT 的组合:短会话什么都不带,长会话只保留最近一两轮加自动摘要,检索召回命中时才把相关片段放进去。效果比 FULL 模式稳定很多,账单也更干净。这个取舍不一定适合所有场景,但至少说明一点——context-mode 真正值钱的地方,不是有多少上下文,而是知道该给模型多少。