前阵子在调一个企业知识库问答应用,遇到一个特别典型的问题:用户明明在前几轮对话里强调过“我是财务部门的,预算口径按年度算”,结果模型聊到后面,开始用自然年口径回答,甚至把另一个部门的数据也算了进来。翻日志一看,根本不是模型能力不行,而是最开始的约束早被挤出了上下文窗口。当时跟我一起排查的老同事丢过来一句:你缺的其实是一个明确设计过的context-mode,而不是一味地往提示词里塞更多背景。
这句话点醒了我。过去大半年我一直在做基于大语言模型的应用开发,从简单的聊天机器人到复杂的RAG管线,踩过不少坑之后,现在我可以负责任地说:上下文模式(context-mode)不是一个花哨的概念,而是决定AI应用能不能在真实场景里稳定工作的核心工程问题。这篇文章把我自己的理解、代码实现和踩坑记录整理出来,希望能给正在做类似项目的朋友一些参考。
1. 为什么要单独设计一个“上下文模式”:日常开发里那些对话断篇的瞬间
1.1 没有上下文管理的AI应用:看似能聊,实则失忆
先看一个最简单的例子。你用messages数组直接往模型接口里传数据,每一轮用户输入后,把user消息和assistant回复追加进去。这看起来没问题,但实际跑上几轮就会发现:上下文越长,模型越容易“抓不住重点”。
我归纳过三种常见的失忆现象:
- 初期约束被淹没:用户在第2轮说的规则,到了第15轮已经被大量中间内容挤占,模型开始按照最近几轮的局部信息作答。
- 关键实体被遗忘:人名、项目编号、日期口径等具体信息,如果没有被持续强化,很容易在长对话里丢失。
- 回答风格漂移:用户最初要求“用表格形式输出”,过了一段时间模型开始用长段落回答。
这些现象的根源都很一致:我们默认上下文是一个“追加式”的列表,但模型实际注意力是有限且非均匀分配的。当总量超过窗口的一定比例后,早期内容虽然还在 tokens 里,但有效权重已经很低了。
1.2 context-mode的本质:把“记住什么”从模型手里拿回来
context-mode这个想法,本质上就是改变“上下文完全由对话历史堆砌”的默认状态,把“记住什么、忘记什么、怎么组织”这件事,从模型隐式处理变成我们显式管理。
你可以把上下文窗口想象成办公室里的白板。什么都不管的话,白板上会被写满各种临时信息,重要的事项反而被覆盖掉。context-mode 就是一套白板管理制度:哪些内容必须长期贴在角落,哪些写完后可以擦掉,哪些需要压缩成一句话贴上去。
在代码层面,这种思维转换体现在几个地方:
- 消息列表不再只是简单的
Array<Message>,而是有分区的结构。 - 系统提示词中要动态注入最新状态,而不是只写死一段初始说明。
- 每次请求前需要对历史信息做一次“压缩、丢弃、保留”的决策。
从这以后,我的项目里开始出现了一个专门的模块,名字直接就叫context_mode,负责所有与上下文组装、切换、压缩相关的逻辑。接下来的部分,我会展开讲讲我在实际工程中借鉴和沉淀的几种落地形态。
2. context-mode在真实项目中的四种落地形态与取舍
具体做设计的时候,不能上来就套一个“万能方案”。不同的业务场景对上下文的要求差别很大,我根据自己的实践,把常见的 context-mode 分成四种形态。
2.1 会话级上下文模式:最轻量,但只在短期对话里有意义
会话级模式是最容易理解的一种:上下文只存在于一次会话中,会话结束就清空。适合客服一次性咨询、临时问答、以及不需要跨会话记忆的工具类应用。
实现这个模式时,我一般只做两件事:
- 限制最大会话轮数。比如超过20轮后,强制把最早的对话归档。
- 定期用摘要替代原始历史。每5轮对之前的对话生成一句话摘要,插入到上下文中。
这种模式的好处是简单可靠,出问题容易排查。但它不解决“跨会话”的问题——用户第二天回来接着聊,AI还是什么都不记得。
2.2 任务级上下文模式:以“完成一件事”为边界组织上下文
真实工作中,用户很少漫无目的地聊天,更多是围绕某个任务连续操作。比如“帮我分析这份销售数据”“然后对比上个月”“再把结论写到周报里”。任务级上下文模式,就是以“任务”为单位来管理记忆。
我是这样做的:
- 当检测到新的意图(比如用户上传了一个新文件),开启一个新的任务上下文块。
- 每个任务块内部保持完整的对话历史。
- 切换任务时,上一个任务块被压缩成“任务总结”,只保留结论、关键数字和待办事项。
- 最新任务的上下文完整保留,确保模型专注于当前目标。
这种模式的优点是:用户在不换任务时,体验很连续;一旦换任务,模型也不会被过去大量无关历史干扰。实现的关键是任务边界识别,我通常用意图分类模型或简单的关键词规则来完成。比如出现“换个话题”“先放一放”“看下另一个”这类标志性短语时,就会产生一个任务切换提示。
2.3 知识库级上下文模式:引入RAG后的上下文剪枝与召回排序
做知识库问答的时候,上下文模式面临更大挑战。因为除了对话历史,还有检索回来的知识片段要一起塞进去。很多成熟的RAG应用,很容易出现“对话历史太长,挤走了检索片段”的问题。
我在这个层面的策略是给不同来源的信息分优先级:
| 信息类型 | 默认优先级 | 原因 |
|---|---|---|
| 系统指令与固定规范 | 最高 | 决定输出格式和边界 |
| 当前问题对应的检索片段 | 高 | 回答的主要事实依据 |
| 最近2-3轮对话历史 | 中 | 保持多轮追问的连贯性 |
| 任务总结与状态信息 | 中 | 提供跨轮次的全局信息 |
| 全部历史对话 | 最低 | 默认不完整保留,只保留关键部分 |
这个优先级表并不是固定的,而是要动态调整。比如当问题明显在纠错(“刚才的结论不对,重算”),那么检索片段的优先级就要暂时降低,把最近对话的原文保留下来,避免模型被新的检索结果带偏。
2.4 记忆分层模式:短期缓冲、中期总结、长期摘要的三层设计
最接近“理想记忆”的方式,是模仿人脑做三层记忆结构。这也是我现在在项目里验证最完整的一套方案:
- 短期缓冲层:最近几轮对话原文,每次请求都完整带上。
- 中期总结层:每 N 轮对话被压缩成结构化的中间摘要,这部分是在用户暂停或转任务时异步生成的。
- 长期摘要层:整个会话的关键信息、用户偏好、已完成事项,被整理成key-value或自然语言摘要,每次请求都注入系统提示词,保证不丢失。
三层之间是流水线关系。举个直观例子,用户第1轮说“我是HR部门的”,第50轮说“请按我们部门口径统计”,如果只有短期缓冲,早期信息早就被冲掉了;但如果长期摘要层里一直存着“user_department = HR”,模型随时能读到这个全局变量。长期摘要不是一次性生成的,而是增量更新的——每轮对话结束,我们都会检查摘要里是否有需要新增或修改的内容。
3. 动手实现一个context-mode:工程代码与关键设计点
概念说再多,不如直接看代码。下面我分享一下我目前实际使用的最小可运行实现。为了便于阅读,我用 Python 风格写,并做了简化,但核心设计思路是完整的。
3.1 数据结构:如何用Message对象管理模态切换
微信搜一搜,先定义基础的Message对象。
from dataclasses import dataclass from enum import Enum from typing import Optional class MessageRole(str, Enum): SYSTEM = "system" USER = "user" ASSISTANT = "assistant" SUMMARY = "summary" # 新增角色,用于标记压缩后的摘要 TOOL_RESULT = "tool_result" # 工具调用结果 @dataclass class Message: role: MessageRole content: str metadata: Optional[dict] = None def to_openai_format(self): return {"role": self.role.value, "content": self.content}这里的核心思路,是把摘要信息独立成一种角色。把它和普通 assistant 消息混在一起,模型很难通过角色差异来理解“这是历史摘要”,但独立角色可以在组装上下文时灵活控制:比如短期缓冲中保留最后5条消息,无论它们是不是摘要;长期信息则全部来自SUMMARY类型的消息。
3.2 模式切换策略:何时压缩、何时丢弃、何时保留原文
只有数据结构还不够,还需要一个决策函数。它根据当前上下文大小、业务规则和会话状态,决定下一步动作。
class ContextMode(str, Enum): FULL = "full" # 完整保留所有消息 RECENT = "recent" # 只保留最近N轮 SUMMARY = "summary" # 用摘要替换早期内容 CUSTOM = "custom" # 完全自定义规则 class ContextManager: def __init__(self, max_tokens=8000): self.max_tokens = max_tokens self.short_term_rounds = 6 # 最近6轮完整保留 self.messages: list[Message] = [] def decide_next_mode(self) -> ContextMode: total_tokens = self._estimate_tokens(self.messages) if total_tokens < self.max_tokens * 0.6: return ContextMode.FULL elif total_tokens < self.max_tokens * 0.85: return ContextMode.RECENT else: return ContextMode.SUMMARY def add_message(self, message: Message): self.messages.append(message) self._maybe_compress() def _maybe_compress(self): if self.decide_next_mode() != ContextMode.SUMMARY: return # 触发压缩:生成摘要,替换短期缓冲区外的早期内容 self._summarize_and_compact()这段代码里藏着几个工程细节,值得单独说一下:
- 阈值0.6和0.85不是拍脑袋定的。0.6 以下模型在生成长回答时有足够的余量,不会突然截断;0.85 以上继续增加上下文,会导致回答生成长度受限,还容易被 tokens 超限报错。这两个阈值是可以调的,建议根据实际问题长度统计来决定。
estimate_tokens不能简单用len(content)。中文、英文、代码混合文本的字符和token比例差别很大。我用的是tiktoken分词器,开销不大,但比估算准确得多。- 压缩是异步的?不一定。如果应用是同步调用,可以直接在
add_message里同步触发压缩;但对于大模型应用,压缩本身也要调用一次LLM,开销不小,建议放到后台队列异步执行。
3.3 一个基于长度感知的自动模式切换实现
再往下,我用一个重写过的ContextManager来说明“摘要生成”和“模式切换”怎么结合。
class SmartContextManager(ContextManager): def __init__(self, llm_func, max_tokens=8000): super().__init__(max_tokens) self.llm_func = llm_func # 压缩摘要时需要调用的llm函数 def _summarize_and_compact(self): # 找出超过短期保留轮数的消息 overflow = self.messages[:-self.short_term_rounds * 2] if not overflow: return base_text = "\n".join(m.content for m in overflow) system_prompt = ( "你是一个上下文压缩器。将下面的对话历史压缩成一份结构化摘要," "必须保留:用户明确给出的个人偏好、约束条件、任务目标、关键数据。" "不要编造原文中没有的信息。只输出摘要。" ) summary_content = self.llm_func(system_prompt, base_text) # 新的消息列表:摘要 + 最近N轮完整消息 keep_recent = self.messages[-self.short_term_rounds * 2:] self.messages = [ Message(role=MessageRole.SUMMARY, content=summary_content, metadata={"source": "compressed"}) ] + keep_recent这里的llm_func是一个可注入的函数,在实际项目里可能是openai.ChatCompletion.create或者别的模型接口。把LLM调用封装成函数传入的好处是,压缩逻辑可以被单独测试和替换。
3.4 上下文窗口适配:给token预留多少安全边际
即使是设计好的context-mode,也不能把上下文用到极限。我给自己的经验值:
- 总窗口的80%以内是安全区,超过80%必须触发压缩。
- 回答预留空间:如果你的模型输出上限是500 token,那请求前上下文最好不超过窗口上限减500。
- 同时准备失败降级方案:即使触发了压缩,如果某轮输入特别长,仍然可能超限。这时我会做二级降级——只保留最后两轮对话,并在系统提示中注明“早期信息已省略”。
这类兜底逻辑往往决定一个应用在高峰期是否可用。我曾经吃过亏:某次压测时,用户一次性粘贴了2000行日志,窗口直接爆掉,应用丢出了一个404错误。加了上面这个二级降级之后,再也没有出现过这种情况。
4. 上线后真实踩过的坑:上下文一致性、费用暴涨与用户错觉
把 context-mode 从示例代码搬到线上,会碰到很多“理论上没毛病”但实际很麻烦的问题。这里集中讲四个我印象最深的。
4.1 模式切换时“忘了之前说过什么”:状态迁移的一致性
最头疼的一类问题,出现在模式从FULL切换到RECENT再切换到SUMMARY的过程中。假设用户前10轮在讨论一个数据分析项目,第10轮说“记住,最终输出要带注释”,然后继续聊。如果压缩过程没有把这条约束写进摘要,下一次模型回答时可能就忘了带注释。
问题根源是压缩器的 pass-through 能力不足。我后来在压缩器的系统提示词里明确加了清单式要求,让它必须检查这几项:
- 用户的身份信息
- 用户明确的约束条件
- 尚未完成的任务
- 已经得到的结论
压缩之后我还会跑一个一致性校验:把压缩摘要和原始对话同时给一个小模型,问“摘要有没有违反或者遗漏原文中的关键信息”,只输出 yes/no。虽然会增加一点延迟,但大促场景里稳定大于速度。
4.2 Token账单翻倍问题:上下文重复注入
另一个很隐蔽的坑是重复注入。有段时间我为了让模型始终记得当前时间、用户位置等动态信息,把动态信息写在了 system prompt 的末尾。结果发现每次请求的 tokens 里,这种动态信息出现了两次——一次来自 system prompt,一次来自对话历史里的原始提及。
账单数字翻倍还不是最致命的;真正烦人的是模型会因此产生混淆。它看到同一个信息出现在两个位置,且措辞不完全一致(比如用户说的“下周”和系统解析出的“2025-07-01”),容易选择后者,导致时间口径错误。
我的解决方案是建立“权威信息源”机制:能放进 system prompt 的动态信息,就不允许再出现在对话历史中。但这需要有配套的预处理逻辑,比如在组装上下文前,把历史消息里的时间类内容替换成占位符,避免和权威信息源冲突。
4.3 用户对“记忆”的错觉:到底是AI记住了,还是我们存了
context-mode 做得越好,用户越容易产生“AI真的记得我”的错觉。这会带出一个产品设计问题:用户以为AI会主动回忆起所有事情,但实际只是我们做了上下文压缩。
有一次测试,用户在第3轮说“我叫小林”,第100轮问“我叫什么”,系统答对了。用户兴高采烈地说“AI太智能了”。但实际上那是长期摘要层存了一个user_name=小林的kv记录。这不叫智能,叫工程。
这种边界很重要。如果你是产品经理或技术负责人,一定要区分“记忆”和“上下文管理”:
- 真正的记忆系统是能主动想起、主动关联的,甚至能形成对用户的长期画像。
- context-mode 只是一种让上下文适配窗口的手段,它的目标是“不遗忘”,不是“理解用户”。
把这两件事分清楚,能够避免很多产品层面的过度承诺。在实现上,我现在给摘要层加了source字段,标记每一条摘要的来源是“用户主动说明”“系统推断”还是“历史摘要”,这样既方便溯源,也能让上层系统知道哪些信息是可靠的。
4.4 评测方式:如何验证上下文模式真的起作用
很多人写完 context-mode 就直接上线,这是很危险的。上下文管理的效果好坏,必须有量化指标。我这段时间用的评测方案分享给你:
- 长对话召回测试:人为设计一个20轮以上的对话,在第5轮埋一个关键信息,在第18轮提问。看模型能否正确使用这个信息。
- 干扰项测试:在对话中间插入大量无关内容,看关键信息是否仍能被召回。
- 压缩保真度测试:把原文和压缩摘要给人类标注员打分,看摘要是否保留了所有关键实体和数值。
- 费用回归测试:统计每次请求的 token 数和成本,看 context-mode 是否真的把平均单次请求成本降下来了。
有一次我发现压缩后模型的回答准确率明显下降,仔细排查后发现是摘要生成时把“人民币”和“美元”搞混了。这种细节,光靠人工看对话是发现不了的,必须依赖结构化的评测数据集。
我还做了一个简单的召回率评估脚本:
def evaluate_recall(conversation: list[Message], questions: list[tuple[str, str]]): correct = 0 for question, expected_answer in questions: response = run_model_with_context(conversation, question) if expected_answer in response: correct += 1 return correct / len(questions) if questions else 0这个脚本的核心思路是让“上下文有没有用”变成可度量的数字。维护一套容易遗漏的黄金测试集,比任何空泛的评价都管用。
5. context-mode的下一步:更聪明的上下文,而不是更长的上下文
现在再回头看最初的问题——模型忘记用户是财务部门的,最直接的原因是上下文管理太粗放。光靠买更大窗口的模型,或者堆更多提示词,解决不了根本矛盾。context-mode 的价值在于:它迫使你思考每一条信息在上下文中的位置、权重和生命周期。
5.1 把context-mode做成可观测的内部状态
工程上我很推荐把上下文模式做成可观测的状态机。每次组装请求时,都打一条结构化日志,记录:
- 当前模式(FULL / RECENT / SUMMARY)
- 各层消息数
- 预估token数
- 触发压缩的原因
有了这些日志,用户可以投诉“AI忘了我说的话”时,你能立刻定位到是压缩器丢信息,还是检索召回范围不对,而不是对着聊天记录瞎猜。
我在项目里用一个非常轻量的方案:把日志打到 JSONL 文件里,每行一个请求快照。线上排查问题时,用grep按会话ID捞出来看,效率很高。如果要更精细,可以接上指标监控系统,但那是锦上添花,不是必要前提。
5.2 从手动模式扩展到自动路由:根据意图自动切换
现在的 context-mode 更多是开发者配置好规则,程序自动执行。但更理想的状态是能够根据用户行为自动选择模式:判断用户当前是在闲聊、做深度分析、还是查阅知识库,然后动态调整上下文的组织策略。
我自己在尝试的一个简单路径是:在请求入口加一个intent_router,用较小的模型(或者规则)先判断用户意图,再选择不同的ContextManager配置。比如:
- 闲聊场景:使用 RECENT 模式,回复短而轻快。
- 深度分析场景:使用 SUMMARY 模式,把历史结论和背景全部注入。
- 检索问答场景:使用 CUSTOM 模式,把检索片段优先级调高。
这个做法不需要对现有框架做大改,只是把 context-mode 的配置点前移,值得一试。
5.3 后续可以这样扩展:长期记忆与多会话复用
如果已经拥有了良好的上下文管理模式,下一步可以自然地延伸到“跨会话记忆”。具体做法是:把每次会话结束时的SUMMARY消息存到数据库里,下次新会话开始时作为初始摘要加载。这样用户隔天再回来,AI还能记住“昨天讨论过 XX,结论是 YY”。
这已经接近一个轻量级用户记忆系统了,仍然是在 context-mode 基础上的合理延展。
从我自己的实践来看,做 AI 应用开发的这两年多,最大的心得是:别把上下文静态拼接奉为不可变的前提,把上下文当资源,当状态,当产品的一部分来看待,很多棘手问题都会峰回路转。context-mode 在设计上并不高深,但它带来的思维方式值得足够重视。如果你正在做一个有长期价值的 AI 应用,我建议尽早把上下文管理层放到架构图里,而不是等项目出问题再补课。