做 AI 应用的时间一长,大概率都会撞上同一个尴尬场景:用户聊了十几轮,突然提到第一轮交代过的背景,模型却开始"失忆"反问起来。这真不是模型变笨了,而是应用层没有真正把 context-mode(上下文模式)做实。这个模式听着玄乎,说白了就是给 AI 装上"选择性记忆"——既要它在连续对话里记住关键信息,又不能让上下文无限膨胀把窗口撑爆。我这两年在好几个项目里反复折腾这套东西,踩过不少坑,也总结出了一套能直接落地的工程方案。这篇文章就把架构设计、代码实现、问题排查完整过一遍,想给团队引入 context-mode 的,可以直接照着抄。
1. 为什么说 Context Mode 是 AI 应用的记忆中枢
1.1 上下文窗口不是越大越好
很多团队选模型时盯着参数表看,觉得 128K、200K token 的窗口已经绰绰有余。真上线了才发现,窗口光是"装得下"远远不够。首先是成本问题,每次请求都会把全部上下文发给模型,输入 token 一多,账单直接起飞。其次是延迟,首字返回时间会随着上下文长度明显变长,用户那边就是"转了半分钟没反应"。更隐蔽的是模型对超长上下文的注意力会退化,你塞了 80K 的日志进去,模型反而抓不住最后几轮对话里的关键指令。这就像让一个人同时读十份合同,他能读完,但你要他准确说出第三份合同的第三条条款,就得翻半天。
context-mode 要解决的,本质上就是资源调度问题:在有限的上下文预算内,把最重要的信息放到模型面前。不是所有历史消息都值得让模型读一遍,很多对话过了几轮之后,细节就变得无关紧要了,保留反而是一种噪音。真正有价值的,往往只是几类信息:用户的核心意图、已经确认的事实、尚未完成的任务、以及最近几轮的即时语境。
1.2 用户体验和成本之间的博弈
没有 context-mode 的应用,通常只有两个极端状态。一个极端是无状态设计,每次请求都只带当前用户输入和固定 system prompt,模型永远记不住五分钟前的事,用户需要反复重复自己的需求,体验极其割裂。另一个极端是盲目全量,把所有历史消息一股脑塞进去,短期看是"记住了",成本和延迟也同步失控了。
这两个极端我都见过真实项目踩进去过。无状态的方案适合那种一次性问答,比如文档问答里用户每次都是独立问题;但放到客服、写作助手、编程助手这种强交互场景,用户天然默认 AI"应该记得我们之前聊了什么",一旦忘了就会被认为产品很蠢。盲目全量的方案在 Demo 阶段很爽,演示时整个会话都顺畅,但一上生产、并发一起来,账单和超时告警会逼着你马上重构。
context-mode 就是在两端之间划出的那条中间路线:既保留会话的连续感,又通过机制主动管理记忆的存储和释放。它的本质不是让模型更聪明,而是让应用更懂取舍。
1.3 从"无状态接口"到"有状态应用"的跳跃
做传统后端的人对状态管理其实不陌生,Session、Redis、数据库事务,都是解决状态的手段。但 AI 应用的状态管理有个完全不同的特点:它的"状态"不是结构化数据,而是自然语言。你可以把用户 ID 存在 MySQL 里,但你不能要求模型每次回答前都去查一次数据库把用户意图读出来。语言本身就是模型的状态载体。
所以 context-mode 的核心逻辑变成了一条流水线:接收原始对话,识别哪些信息值得保留,怎么保留(原文、摘要、还是结构化槽位),下次请求时用什么样的顺序把记忆重新组装出来,最后塞给模型。这条流水线就是 AI 应用的记忆中枢,后面的所有架构设计、代码实现,都是围绕这条流水线展开的。想清楚这一层,再去看各种 Context Management 框架,思路就通透了。
2. Context Mode 的整体架构设计
2.1 先拆模块:记忆不只是缓存
我见过不少团队把 context-mode 简单理解成"Redis 里存一份聊天记录",这其实只完成了存储环节。一个能上生产的上下文模式,至少要拆成五个模块:会话存储层、上下文组装器、预算控制器、压缩策略器、检索增强器。每个模块都有明确的职责边界,互相之间只通过接口通信,这样后续替换组件(比如从 Redis 换到向量库)才不会牵连一片。
会话存储层管的是原始消息的持久化,它得支持按会话 ID 存取消息列表,也得上消息编辑和删除接口,因为用户经常会撤回或修改说法。上下文组装器是核心枢纽,负责从存储层捞数据,按既定策略决定哪段用原文、哪段用摘要、哪段走检索,最终拼出一次请求的 prompt。预算控制器是个"会计",所有进 prompt 的内容都要经过它的 token 计量,超标就打回重来。压缩策略器是"记忆的橡皮擦",当会话累积到一定程度,就把旧消息压成摘要或者抽取成结构化的事实。检索增强器负责从更久远的记忆里捞回被压缩掉的内容,通常基于向量召回。五个模块协作,才能实现既有短期记忆、又有长期记忆的完整上下文模式。
下面这张表可以直观看到每个模块的职责和常见选型:
| 模块 | 核心职责 | 常见实现选型 |
|---|---|---|
| 会话存储层 | 持久化原始消息,支持增删改查 | Redis、PostgreSQL、MongoDB |
| 上下文组装器 | 按策略拼接 prompt 片段 | 自定义组装逻辑,可做成 Pipeline |
| 预算控制器 | 计量 token,控制窗口占用 | tiktoken、模型官方 Tokenizer |
| 压缩策略器 | 生成摘要、抽取关键事实 | 大模型摘要、规则抽取、分层摘要 |
| 检索增强器 | 从长期记忆召回相关片段 | Embedding + 向量库(pgvector、Milvus) |
2.2 三种核心模式怎么选
context-mode 在实践中会派生出很多变体,但底层跑不出三种模式:短窗口全量、滚动摘要、检索增强。短窗口全量最简单,就是只保留最近 N 轮对话原文,超过 N 轮的直接丢弃,适合对话轮次少、上下文短的应用,比如单轮工具调用。滚动摘要则是在会话超过阈值后,让模型把旧消息总结成一段摘要,每次请求都带上这份摘要加最近几轮原文。检索增强更进一步,把历史消息先向量化存进向量库,请求时通过语义相似度捞回相关片段,再拼进上下文。
选型没有银弹,得看业务场景。客服机器人这种"用户问题重复度高、但每次会话独立"的,滚动摘要就够了;代码助手这种"用户改了半小时代码,每个改动都有上下文依赖"的,短窗口全量加关键文件全文更靠谱;企业知识管家这种"用户随时会问三天前聊过的某个细节"的,就必须上检索增强。我自己的习惯是优先组合使用,比如滚动摘要保底、检索增强补细节,这比押注某一种模式稳妥得多。
2.3 一个容易忽略的决策:组装顺序
prompt 拼装不是简单把记忆倒进去就完事,顺序直接影响模型的理解效果。上下文窗口里有个很出名的"Lost in the Middle"现象:模型对开头和结尾的内容敏感,对中间部分容易忽略。所以组装时要刻意做"位置管理"。
我的标准顺序是这样的:system prompt(全局规则)放最前面,让模型先构建行为基线;然后是检索出来的长期记忆片段,这部分是辅助信息,放前面比放中间好;再是历史摘要,用于补充背景;最后才是最近几轮的原文对话,它们要贴着用户当前问题,确保模型对最新意图有最直接的感知。检索结果和摘要之间空一行分隔符,能明显减少模型把不同信息源混淆的概率。这个顺序我反复测过,效果比把历史全部堆在开头好不少。
3. 核心实现:上下文管理器实操
3.1 数据结构设计:先定好"记忆单元"
动手写代码前,先设计数据模型。消息不能只存"谁说了什么",还需要状态标记,否则后续压缩、检索、组装都无从下手。我在项目里定义了一套消息结构,每条消息至少包含这几个字段:消息 ID、会话 ID、角色(user/assistant/system)、内容、时间戳、状态(raw/compressed/retrieved)、元数据(可扩展存 token 数、话题标签等)。元数据看起来是加分项,实际是必备项,压缩和检索都依赖它做筛选。
下面是一个最小可用的数据结构示例:
# 消息模型的最小定义 @dataclass class ChatMessage: message_id: str # 全局唯一 ID session_id: str # 会话 ID role: str # user / assistant / system content: str # 文本内容 created_at: datetime # 创建时间 state: str = "raw" # raw / compressed / retrieved token_count: int = 0 # 预计算的 token 数 metadata: dict = field(default_factory=dict) # 扩展字段 # 会话聚合根,管理整个对话状态 @dataclass class Conversation: session_id: str system_prompt: str messages: list[ChatMessage] summary: str = "" # 滚动摘要,压缩策略写入 summary_token: int = 0 facts: list[dict] = field(default_factory=list) # 抽取出的关键事实这个结构看着简单,但解决了两个关键问题:一是 state 字段让上下文组装器能区分原始消息和压缩产物,组装时按需取用;二是 summary 放在会话对象上,而不是每条消息上,因为它描述的是整个会话的演化过程。token_count 是写入时就计算好的,避免组装时逐条现算,省下不少延迟。
3.2 Token 估算与预算分配
预算控制是整个 context-mode 的命脉,预算算不准,后面全白搭。我强烈建议用模型官方的 tokenizer 做精确估算,而不是按字符数拍脑袋。以 OpenAI 系模型为例,tiktoken 库可以按模型类型拿到准确的 token 数;其他家的模型也都有对应的 tokenizer,务必用官方实现,因为不同 tokenizer 的分词结果差距很大,估算偏差会导致截断或浪费。
预算分配我一般按比例切,而不是给每一段定死绝对数值。默认的分配策略是 system prompt 占 10%,近期对话原文占 55%,滚动摘要占 20%,检索结果占 10%,留 5% 的安全余量。这个比例可以根据模型窗口总量换算成绝对 token 数,比如窗口是 32K,那 system 就是约 3.2K,近期对话约 17.6K。安全余量非常重要,因为模型回答本身也要占窗口,超了会直接报错,预留 5% 到 10% 是经验值。
计算过程可以封装成一个 BudgetCalculator,组装前先预估本次请求的总量,如果超出预算,就触发压缩,而不是组装到一半才发现爆了。这个预检动作在线上的价值很大,能拦截大部分"上游正常、下游报错"的诡异问题。我在代码里一般把预算校验放在组装器入口,任何拼装都先过这道卡口。
3.3 压缩策略:从"全量记忆"到"结构化记忆"
压缩是整个环节里最依赖模型能力的一步,也是最容易出质量事故的一步。常见的压缩形式有三种:原文截断、摘要生成、事实抽取。原文截断最无脑,超过条数直接丢最老的消息,适合没有强依赖的闲聊场景。摘要生成让模型把旧对话总结成几百字的摘要,能保留大部分背景信息,但摘要生成本身有成本和延迟。事实抽取是把对话里的关键实体、偏好、任务状态抽成结构化字段,比如"用户偏好 = 简洁回复""任务 = 订购机票未完成",这种最稳定,后续组装时可以当成伪 system prompt 用。
我的做法是三层配合,而不是只选一种。新消息先全量保留;当会话轮数超过阈值(比如 20 轮)时,把前 10 轮压缩成摘要;同时每轮都可能做轻量的事实抽取,把用户交代过的偏好、约束、待办提取出来。这样组装时既有摘要提供背景,也有事实列表提供确定性信息,比单一摘要的抗遗忘能力强很多。压缩的触发条件建议用预算占比来控制,比如当近期原文 token 数超过预算的 60% 就触发,而不是死等窗口爆掉。
下面是一个简单的压缩触发和摘要生成实现:
import tiktoken class ContextManager: def __init__(self, model="gpt-4", max_context_tokens=32000): self.tokenizer = tiktoken.encoding_for_model(model) self.max_context_tokens = max_context_tokens def estimate_tokens(self, content: str) -> int: return len(self.tokenizer.encode(content)) def compress_if_needed(self, conv: Conversation, max_dialogue_tokens: int): dialogue_tokens = sum(m.token_count for m in conv.messages if m.state == "raw") # 超过预算 60% 即触发压缩,带一点提前量 if dialogue_tokens <= max_dialogue_tokens * 0.6: return False # 压缩旧消息:这里会调用模型生成摘要,为便于说明只给出伪代码示意 conv.summary = self._generate_summary(conv.messages[:-10]) # 被压缩的消息标记状态,不再进入上下文组装 for msg in conv.messages[:-10]: msg.state = "compressed" conv.summary_token = self.estimate_tokens(conv.summary) return True有个细节必须提醒:_generate_summary 是同步调用大模型的,会引入几百毫秒到一两秒的额外延迟,绝不能让用户在这一轮对话里干等。我的做法是压缩异步化,在检测到需要压缩时先把摘要生成任务丢进后台队列,当前请求继续用压缩前的内容回复,等下一轮请求到来时摘要已经生成好了,直接用。这套思路跟读缓存和写缓存的异步刷新是一个道理。
3.4 检索增强:让 AI 找到被压缩掉的旧记忆
压缩必然带来信息损失,摘要压得再精细,用户突然问起"我上周提过的那个需求细节"时,摘要里大概率没有。这时就得靠检索增强把原始消息捞回来。检索增强的整体思路是:把所有原始消息切块后做 embedding,存入向量库;用户提问时,先对当前问题做 query 改写,再用向量相似度召回最相关的历史片段,把这些片段当作"临时记忆"拼进上下文。
切块策略直接影响召回效果。我的建议是按对话轮次切块,而不是按字符数硬切。每一轮 user 和 assistant 的对话作为一个完整语义单元,embedding 后存储,因为一轮对话本身就包含了完整的前因后果,硬按字符切会把语义拦腰截断。块的长度控制在模型 embedding 的推荐范围内(比如 512 token 以内),太长向量会被稀释,太短语义不完整。召回数量上,top-k 我一般取 5 到 10 条,具体看上下文预算,宁缺毋滥,塞太多无关片段反而会增加噪音。
query 改写这一步很多人会跳过,但实际效果差异很大。用户说"那个方案怎么样了"时,直接拿这句话去检索,"那个方案"是模糊指代,命中率很低。改写例程可以让模型把当前问题补全成带上下文信息的独立表述,比如"(结合之前讨论的登录优化)那个方案怎么样了",再去检索,命中率立刻上一个台阶。改写本身也消耗一次模型调用,可以做轻量化,用一个较小的模型或者事先预设好的模板来解决。
3.5 上下文组装:所有记忆的临门一脚
组装器是最后把所有记忆拼到一起的地方,也是最容易出低级 bug 的地方。我在组装器里维护了一个严格的顺序列表,每次请求都按固定顺序拼接,并通过预算控制器做总量校验。组装逻辑大致如下:先追加 system prompt 和抽取出的关键事实清单,这两者是全局约束;再追加检索召回的历史片段;然后追加滚动摘要;最后追加最近 N 轮原文消息,紧跟用户当前输入。每段之间加一个明确的分隔标记,比如<history>、<summary>、<current>,让模型能清晰区分信息源。
def assemble_prompt(self, conv: Conversation, current_query: str, retrieved_chunks: list[str] | None = None) -> str: parts = [] # 1. 系统规则与关键事实 system_block = conv.system_prompt if conv.facts: system_block += "\n\n[Key Facts]\n" + "\n".join( f"- {f['content']}" for f in conv.facts) parts.append(system_block) # 2. 检索到的历史片段 if retrieved_chunks: parts.append("[Retrieved History]\n" + "\n\n".join(retrieved_chunks)) # 3. 滚动摘要 if conv.summary: parts.append(f"[Conversation Summary]\n{conv.summary}") # 4. 最近几轮原文,紧随当前问题 recent_raw = [m.content for m in conv.messages[-6:] if m.state == "raw"] parts.append("[Recent Messages]\n" + "\n".join(recent_raw)) parts.append(f"[User Current]\n{current_query}") return "\n\n---\n\n".join(parts)这段代码刻意用了简单的列表拼接,没有引入重的框架,是因为组装逻辑本身不复杂,但业务上经常要调整顺序或者加字段,保持轻量反而更容易改。真正要在工程上做扎实的是中间那些 if 判断:哪些记忆在什么条件下该加入,什么条件下不该加入。比如新会话没有历史摘要时,summary 块就得跳过,不加空标记;检索结果为空时,也不能硬塞一个空块占位。这些边界处理决定了组装出来的 prompt 在极端情况下是否还能正常工作。
4. 常见问题与排查技巧实录
4.1 上下文割裂:模型"忘事"的第一现场
症状很典型:用户上一轮说"我叫小明,帮我记录一下",下一轮问"我叫什么名字",模型答不上来。很多人第一反应是模型不行,但排查后会发现是 context-mode 在组装时把记录名字那轮对话丢掉了。我在自己项目里就踩过这个坑,原因是压缩策略按轮数硬切,总是只保留最近 10 轮,用户一提早于 10 轮的关键信息就彻底失忆。
排查方法很简单,把组装后的完整 prompt 打日志打出来,肉眼检查关键信息在不在里面。修复方式有两种:一是做关键事实抽取,把"用户名字""偏好""约束条件"这类结构化信息单独拎出来放进 facts 列表,组装时固定放在 system 区域,这样无论多少轮以前都不会丢。二是在压缩时做"硬保留",把含有高价值信息(比如包含用户明确指令、数字细节、邮箱电话等)的消息设成不可压缩状态。两种方式不冲突,可以同时用,实际效果是模型的"长期记忆"会明显变稳。
4.2 Token 预算超限:明明算过了还爆
明明预算控制器算得好好的,线上还是会出现"maximum context length exceeded"的报错。这种问题九成出在模型回答的长度上。很多场景的 prompt 本身就接近预算上界,模型回答又占了 1K 到 2K token,总输入加输出就超了。我之前只算输入侧预算,忽略输出侧,上线第一周就被这个坑了个措手不及。
解决办法是给输出侧留够空间。先把 max_tokens 参数显式设好,然后预算控制时把"输入预算 = 总窗口数 - max_tokens - 安全余量",如果安全余量取 5%,那输入侧实际可用比例就不到 95% 了。更严谨的做法是把 max_tokens 也纳入预算控制器,组装前做的预检直接检查"当前输入 + 预估输出"是否在限内,超了就提前触发压缩或裁剪检索结果。这样能把报错率压到接近零,毕竟模型请求一旦报错对用户体验的伤害,比多花一点 token 的代价大得多。
4.3 压缩失真:摘要把关键信息"消化"掉了
滚动摘要用久了,容易在某个节点把重要信息压没了。比如用户之前反复强调"回复要口语化、不要用敬语",摘要模型生成时觉得这是次要信息,直接省略,后续所有回复风格都跑偏。这种问题最难排查,因为摘要模型是黑盒,你不知道哪句话被丢了。
我的排查思路是给摘要加"要素自检清单":生成摘要时,在 prompt 里要求模型按固定结构输出,必须覆盖用户明确提出的偏好、所有未完成任务、重要的实体名词、已确认的决策和变更点。不只是让它"总结这段对话",而是给一个模板让它往模板里填。另外,摘要生成时可以同时让模型输出一行"信息完整度评分",低于某个阈值就报警,至少能让问题在第一时间暴露出来,而不是等用户投诉了才发现。这里还有个土办法很有效,定期把摘要和原始对话放一起抽样比对,人工看一眼就能发现摘要是不是开始偷懒了。
4.4 延迟陡增:记忆环节成了性能瓶颈
加了 context-mode 之后,接口延迟不降反升的情况也常有。主要嫌疑集中在三处:一是压缩策略里同步调用大模型生成摘要,每轮请求都多了一次模型往返;二是检索增强走的向量库查询串行执行,没有和模型调用并行;三是 token 估算阶段,如果每条消息都现算一遍,长会话的场景光 tokenize 就能吃掉几十毫秒,累计起来也可观。
对症下药的方法:压缩尽量拆到异步任务里,用户当前的请求不走压缩那条链路;向量检索和模型调用之间如果业务允许,可以在拿到用户 query 后并行发起,检索结果回来后拼进 prompt 再调模型;token 数写入消息时就算好缓存,变更时增量更新,避免每次都全量重算。这些优化不会影响功能正确性,纯粹是工程上的性能磨刀,但对 P99 延迟的改善非常明显。我在一个客服机器人项目上做完这三步,接口平均延迟从 2.4 秒降到了 1.1 秒,效果立竿见影。
下面把几类高频问题整理成一张速查表,方便定位时对照:
| 症状 | 常见根因 | 排查切入点 | 解决建议 |
|---|---|---|---|
| 模型忘了早前对话 | 关键信息被压缩/裁剪 | 查看组装日志里的完整 prompt | 引入事实抽取,硬保留高价值消息 |
| 请求报上下文超限 | 忽视输出 token 占用 | 检查预算控制器是否包含 max_tokens | 输入预算 = 窗口 - 输出 - 余量 |
| 回复风格突然变味 | 摘要丢失了用户偏好 | 检查摘要内容并比对原始消息 | 用摘要模板强制覆盖偏好、任务、实体 |
| 接口延迟明显升高 | 压缩/检索串行同步调用 | 打点看各环节耗时 | 压缩异步化,检索与模型调用并行 |
| 检索结果文不对题 | query 指代不清或切块不完整 | 查看召回片段与 query 改写结果 | 加 query 改写,按对话轮次切块 |
5. 从能用到好用:监控体系怎么搭
上下文模式上线后,光靠功能正常运转还不够。我这边的经验是,一定要有可观测性,否则内存管理的问题会像慢性病一样折磨你。我在系统里埋了几个关键指标,第一个是上下文预算使用率,能看出系统整体是不是经常逼近上限;第二个是压缩触发次数,如果压缩过于频繁,说明会话长度设计不合理,或者应该更早启动检索分流;第三个是摘要生成的成功率和平均耗时,这直接关系接口延迟。
日志层面,每次组装完成的完整 prompt 务必要落盘,方便排查一切"模型怎么答成这样"的疑难杂症。可以在日志里加上 team 标识(类似 request_id),把一次请求的原始输入、检索结果、摘要、最终 prompt、模型输出串成一条链路,排查问题时一次拉全,不用到处拼证据。这个习惯帮我省了无数个半夜排查的夜晚。有条件的话,可以加一层自动评测,在测试集上定时对比不同压缩策略和检索参数的问答准确率,让优化有数据支撑,而不是每次靠感觉调参。
5.1 先小流量试点再全量
上下文模式是深度影响模型回复质量的一层,不能拍脑袋全量上线。我建议先在内部或者小范围白名单用户里跑一版,观察摘要质量、延迟、报错率这几个核心指标,跑几天再逐步放开。这类改动最怕的是线上出了问题你还不知道是组装策略还是模型本身的锅,小流量试点能让你在可控范围内把问题都暴露出来,再集中修掉。我自己每次改压缩策略或者检索参数,都是先在一部分会话上做 A/B,对比用户满意度或任务成功率,确认正向收益才全量推。
5.2 别把 context-mode 做成一把梭
最后想提醒一句:上下文模式不是万能药,它只解决"在有限窗口里高效利用记忆"的问题。如果你的业务本质上是单轮问答,完全不需要引入这套复杂度,老老实实把 system prompt 写好就够了;如果你的业务动辄需要几十万字的长文档分析,那问题重点在文档切片和检索,而不是对话记忆管理。看清场景边界,再决定投入多少,才是做技术选型的正确姿势。这个判断,比任何框架和代码都重要。
我在实际项目里把 context-mode 落地过好几轮,从最早的手写拼接,到后来用框架,再到最后返璞归真自己维护核心逻辑,最深的体会是:这套东西真正的难点不在某一个模块怎么写,而在模块之间的衔接是否顺滑——存储层状态改没改对,预算器和组装器有没有一致,压缩和检索会不会互相覆盖。把这些衔接打磨顺了,AI 应用的记忆体验才会有质的提升。如果你正准备动手,建议先把我上面讲的五个模块的职责边界理清楚,再一行代码一行代码去填,比急着找现成框架要靠谱得多。