标题只有“context-mode”一个词,确实很考验人。我先说结论:这不是一个只能停留在概念层面的术语,它是每个做 AI 应用的人迟早都要正面刚的问题。如果你正在开发智能客服、AI 助手、文档问答这类产品,你会发现模型能力再强,上下文这块处理不好,效果可以直接拉胯一整条链路。这篇文章我把自己在项目中反复踩坑、反复调整之后沉淀下来的完整方案整理出来,从原理到核心代码再到排查实录,尽量讲透。
1. 先搞明白:context-mode 到底是什么,解决什么问题
1.1 从“对话记忆”说起
我最早接触“context-mode”这个词,是在做大模型应用的时候。你给模型发一段请求,模型只能看到你这一次请求里装了什么,它本身是没有“记忆”的。所谓 context-mode,翻译成大白话就是:你用什么样的方式,把历史对话、背景信息、用户偏好这些东西组织起来,塞进每一次请求里。
打个比方,你和一个新同事对接工作,你不可能每次都从自我介绍、项目背景开始讲。你会默认这个同事已经知道一些事,只需要把增量信息说清楚就行。但大模型没有这个“默认”,每一次请求它都像是第一天上班的新人。所以需要有一套机制,把“它应该知道的背景”和“本次要解决的新问题”拼装在一起。这套机制的运作方式,就是上下文模式。
做得好的上下文模式,用户会觉得这个 AI“懂我”;做得不好的,用户会觉得这个 AI“失忆了”。实际体验差距非常大,这也是为什么看起来只是技术细节的事情,最后变成了产品体验的分水岭。
1.2 为什么不能一次性把所有内容都塞给模型
很多人第一反应是:上下文模式?那我把所有历史记录都怼给模型不就行了?听起来简单,但实际上有几个硬约束。第一个是 Token 上限。任何模型都有上下文窗口,就像你手里只有一张有限的便签纸,GPT-4o 这类模型一般能写十几万 token,一些开源小模型可能只有几千。超过窗口的请求直接报错,或者被静默截断。
第二个是成本。Token 是按输入量计费的,你每次请求塞进去的内容越多,账上的钱烧得越快。假设你的客服机器人平均每轮对话塞入 5000 token 历史,每个用户每天聊 30 轮,一个月下来光是输入 token 的费用就是一笔不小的开销。很多团队上线第一天没注意,月底看到账单直接被吓到。
第三个是效果。这是最容易忽视的。模型在超长上下文里并不是“全部都能注意到”,大量研究结果表明,模型对中间部分的信息利用率明显低于开头和结尾。你塞了 50 轮历史进去,模型可能只记住了最早的一句和最晚的一句,中间用户明确说过的要求反而被忽略。所以与其无脑堆积,不如设计一套上下文管理模式,把真正重要的信息精准地放进窗口里。
1.3 常见的四种上下文模式选型
项目做久了,我总结下来,实际可用的上下文模式基本就四类:固定窗口模式、滑动窗口模式、摘要压缩模式、检索增强模式。它们各有适用场景,很多成熟产品用的是它们的组合,而不是单一模式。
固定窗口模式最简单,只保留最近 N 轮对话,更早的不管。优点是稳定、成本可控,缺点是如果用户很早之前提过一个偏好,比如“我不吃辣”,聊了 20 轮之后这个信息就被挤出了窗口,模型就会忘掉这个偏好。
滑动窗口模式其实就是固定窗口的变体,区别在于窗口不是按“轮数”算,而是按 token 数算。每来一条新消息,就往消息队列里追加,同时把超出 token 上限的旧消息从头部弹出。
摘要压缩模式是另一个思路:当历史消息超过阈值,不再保留原始消息,而是让模型把前面所有内容浓缩成一段摘要。下次请求时,系统提示词里带这段摘要,再加最近几轮的原始消息。这种模式在长对话场景下表现很好,但摘要本身会丢失细节。
检索增强模式就是 RAG 的思路:把历史消息向量化存入数据库,每次请求前先做语义检索,把和当前问题最相关的历史片段召回,再拼进上下文。这种方式解决的是“跨时间段的信息关联”,比如用户三天前提过一个需求,今天又问起相关事情,只有这种模式能找回来。
做一个项目,核心问题不是“用哪种模式”,而是“怎么组合”。我会在实操部分给出一个组合方案。
2. 核心细节拆解:窗口、Token 与信息优先级
2.1 上下文窗口到底怎么算:token 是硬通货
要落地上下文模式,第一件事就是得懂 token。Token 不是一个“字”或一个“单词”,而是模型内部用来处理文本的基本单位。拿中文来说,一个汉字大约对应 0.6 到 1.5 个 token,具体取决于模型分词器。英文一个常见单词大约 1 到 1.3 个 token。
所以我做上下文模式时,第一步永远是给项目定一个“token 预算”,而不是“消息条数预算”。上下文窗口是 8000 token,我的分配方案一般是:系统提示词占 800 到 1000 token,业务背景信息占 1500 到 2000 token,历史对话占 3000 到 4000 token,剩下的预留空间给模型输出和突发内容。
预算定了,后续的所有截断和压缩逻辑就有了标尺。没有 token 预算是很多新手项目混乱的根源——今天塞 10 条记录,明天塞 20 条,最后模型报错或者行为不稳定,还不知道问题出在哪。
还有一个很关键的细节:模型请求里除了 messages 内容,系统提示词、工具定义、示例对话都会被计入 token。很多人只算用户对话,把提示词漏了,结果每次请求都在超限边缘疯狂试探。我通常在代码里封装一个 token 估算函数,把所有要发送的内容统一计算,而不是只看 messages。
2.2 哪些信息必须留,哪些信息可以丢
搞清楚了 token 预算,下一步就是给信息分门别类。我习惯把上下文里的信息分成三个优先级级别。
最高优先级是“当前会话的即时目标”。用户这一轮问了什么、需要模型做什么,这是必留的。其次是“跨轮的关键约束”。用户明确说过的偏好、强调过的事情,比如“答案是给非技术人员看的,别写代码”“我只关心近 30 天数据”,这种信息一旦丢失,整个对话就会跑偏,必须单独保存。最低优先级是“纯过程性寒暄和流水账”,比如“好的”“嗯”“让我想一下”,这类信息尽早丢弃。
我给一个很直观的场景。用户说:“帮我写个邮件模板,语气正式一点,不要用 emoji。”这句话里的关键约束是“语气正式”“不要 emoji”,如果系统只在当前窗口里保留这句原话,那还好;但如果这句是在第 2 轮说的,到第 20 轮才用到,普通的固定窗口早就把它挤出去了。所以我建议把关键约束单独抽出来,放进一个“长期记忆”对象里,每一轮都拼进系统提示词。
这条经验是我在真实项目里踩了无数次坑之后总结出来的。之前我用最简单的滑动窗口,结果用户经常抱怨“我都说过不要 emoji 了,为什么还给我加”,原因就是关键约束被窗口挤掉了。单独存储之后,这个问题基本绝迹。
2.3 上下文截断的三种手段:直接切、摘要压、检索补
当上下文超限,处理手段就三种,每一种效果和成本都不同。
直接切是最粗暴的,就是把最早的消息从窗口里删掉。操作成本最低,但很容易误伤,因为最早的对话里可能藏着用户的关键需求。我的建议是:直接切可以,但要结合优先级——先切寒暄类消息,再切无结论的讨论过程,最后才切带约束的消息。
摘要压是让模型把一段对话浓缩成几百字。成本相对高一些,因为每次压缩都要多一次模型调用,但效果显著。这个手段的关键点在于摘要的“格式”,不要存成一段流水账,建议用条目化的要点,比如“用户偏好:正式语气;当前状态:等待价格确认;历史结论:已排除 A 方案”。结构化摘要后续检索和拼装都更容易。
检索补是成本最高的方案,也是效果上限最高的方案。把历史消息向量化,按需召回。它适合会话轮数特别多、信息跨度特别大的场景。但要提醒一点,检索补的召回率直接依赖向量模型质量,如果模型不行,召回的内容和问题不相关,效果反而比直接切还差。
我自己落地的方案是“摘要为主、检索为辅”:大部分会话用摘要压缩保住长期记忆,遇到模糊的关联型问题,再启用检索召回历史片段。两者结合,既能控制成本,又能照顾到信息关联场景。
3. 实操:手写一个可用的上下文模式管理器
3.1 设计目标与数据结构
这部分我直接上手写代码。为了让方案可落地,我设计了一个 ContextModeManager 类,支持切换“滑动窗口模式”和“摘要压缩模式”,并且把关键约束单独维护。它的目标只有一个:输入新的对话,输出一份符合 token 预算、且信息不塌陷的消息列表。
数据结构我分了四块:
- system_prompt:系统提示词,固定内容。
- context_memory:关键约束和长期记忆,用键值对保存。
- messages:原始对话消息列表。
- summary:历史摘要文本,进入摘要模式后启用。
使用过程就是:每次新消息进来,先正常 append 到 messages,再调用 prepare_messages() 方法,在这个方法里做窗口裁剪、摘要压缩、关键约束拼装,最后返回真正要发给模型的消息数组。
from typing import List, Dict, Optional class ContextModeManager: def __init__( self, max_tokens: int = 8000, mode: str = "sliding", summary_threshold: int = 6000, reserve_tokens: int = 1000 ): self.max_tokens = max_tokens self.mode = mode # "sliding" or "summary" self.summary_threshold = summary_threshold self.reserve_tokens = reserve_tokens self.system_prompt = "" self.context_memory: Dict[str, str] = {} self.messages: List[Dict[str, str]] = [] self.summary = "" def add_message(self, role: str, content: str) -> None: self.messages.append({"role": role, "content": content}) def set_context_memory(self, key: str, value: str) -> None: self.context_memory[key] = value def estimate_tokens(self, text: str) -> int: # 中文场景的粗略估算,英文可以按 len(text) / 4 计算 return max(1, int(len(text) * 0.8))3.2 核心代码实现
接下来实现 prepare_messages 方法。这个方法里做了三件事:检查总 token 数是否超限;在超限时根据模式进入裁剪或压缩逻辑;始终在消息数组开头拼上系统提示词和上下文记忆。
def _build_system_messages(self) -> List[Dict[str, str]]: memory_text = ";".join( f"{key}: {value}" for key, value in self.context_memory.items() ) system_content = self.system_prompt if memory_text: system_content += "\n\n长期约束与关键记忆:\n" + memory_text return [{"role": "system", "content": system_content}] def _trim_to_sliding_window(self) -> None: # 计算当前消息总 token total = sum(self.estimate_tokens(msg["content"]) for msg in self.messages) budget = self.max_tokens - self.reserve_tokens - sum( self.estimate_tokens(msg["content"]) for msg in self._build_system_messages() ) while total > budget and len(self.messages) > 1: removed = self.messages.pop(0) total -= self.estimate_tokens(removed["content"]) def _update_summary(self) -> None: if len(self.messages) <= 4: return history_for_summary = self.messages[:-4] joined = "\n".join( f"{m['role']}: {m['content']}" for m in history_for_summary ) # 实际项目里这里调用 LLM 做摘要,为了演示用简单截断 # 生产代码请接一个真实 LLM:例如 llm.summarize(joined) candidate_summary = joined[:300] + "..." self.summary = candidate_summary self.messages = self.messages[-4:] def prepare_messages(self) -> List[Dict[str, str]]: if self.mode == "summary": # 先看历史消息是否超出阈值,超出则生成摘要 total = sum(self.estimate_tokens(msg["content"]) for msg in self.messages) if total > self.summary_threshold: self._update_summary() elif self.mode == "sliding": self._trim_to_sliding_window() system_messages = self._build_system_messages() # 如果启用摘要模式且已有摘要,追加为一条 user 消息 if self.summary: return system_messages + [ {"role": "user", "content": f"历史对话摘要:\n{self.summary}"} ] + self.messages return system_messages + self.messages有几个细节我得强调一下。_build_system_messages 里把 context_memory 放在系统提示词的尾部,这是因为模型对开头和结尾的内容注意力更高,把关键约束放在系统提示词最后,相当于放在整个请求的“次开头”位置,被注意到的概率更大。
_trim_to_sliding_window 里的预算计算是动态的:先用总预算减去系统消息费用,得到历史消息的可用预算。这样不会因为系统提示词变长导致主消息超限。
_update_summary 里的 300 字截断只是代码演示,生产环境必须换成真实 LLM 调用,否则摘要会丢失大量语义。我后面章节会单独讲这一点。
3.3 参数计算:你的场景该选多大窗口
参数这东西,直接抄答案没有意义,我给你推导一遍。
假设我做一个电商客服助手。用户每轮对话平均长度按 1000 token 算(用户问题 + 助手回复),上下文窗口是 8000 token。我的系统提示词约 500 token,关键记忆约 200 token。那么留给历史消息的预算就是 8000 - 1000(预留输出) - 500 - 200 = 6300 token。这个预算大约能容纳 6 轮完整对话。
对客服场景来说,6 轮对话通常够用。但如果是“帮客户选型”的复杂销售场景,一次要对比多个产品的参数,聊 20 轮很正常。6 轮肯定不够,所以这时候我会换用摘要模式:保留最近 4 轮原始消息(约 4000 token),再加上之前的摘要(约 800 token),这样模型既能感知最近细节,又不会丢失更早的结论。
再给一个公式,你可以拿去直接用:
历史预算 = 上下文窗口 - 系统提示词 token - 长期记忆 token - 预留输出 token 可保留轮数 = 历史预算 / 单轮平均 token如果你算出来可保留轮数小于你的业务最低值,说明要么窗口选小了,要么需要切换摘要模式。这一步的推导逻辑我在每个项目里都会做一遍,算是上下文模式设计的“第一性原理”。
3.4 接入 API 请求
代码写好后,接入大模型 API 就很简单了。我用一个典型的 OpenAI 兼容接口演示。需要强调的是,我不在这里涉及任何具体服务的配置,只给出一段标准用法,你根据自己的模型服务商替换 endpoint 即可。
from openai import OpenAI client = OpenAI( api_key="your-api-key", base_url="your-endpoint-url" ) manager = ContextModeManager( max_tokens=8000, mode="summary", summary_threshold=6000, reserve_tokens=1000 ) manager.system_prompt = "你是电商客服助手,回答简洁,礼貌,不编造价格信息。" manager.set_context_memory("用户偏好", "喜欢性价比高的商品,预算 300 左右") manager.add_message("user", "帮我看看有没有适合办公的机械键盘") manager.add_message("assistant", "可以考虑 A 款,红轴,约 280 元。") # 最新一轮用户输入 manager.add_message("user", "有静音的吗?我在办公室用") response = client.chat.completions.create( model="your-model-name", messages=manager.prepare_messages() ) print(response.choices[0].message.content)这里最关键的一点是:每次发送请求前,必须临时调用 prepare_messages() 来生成 messages,而不是直接把 manager.messages 原样发出去。因为上下文管理是在发送前即时发生的动态过程,消息列表会随 token 总量变化而变化。很多人容易在这里踩坑——消息是动态裁剪的,如果缓存了旧列表,就会出现上下文不一致。
还有一个建议:把上下文管理器状态持久化到 Redis 或者数据库。因为实际服务都是多请求的,用户每次发一条消息,都是一个独立的 HTTP 请求。如果不持久化消息列表和摘要,下一次请求就变成“失忆”状态了。我在生产项目里一般按 session_id 为维度,把 ContextModeManager 对象序列化存到 Redis,每次请求加载,处理完再存回去。这样既保证效率,又保证上下文连贯。
4. 常见问题与排查技巧实录
4.1 上下文“越聊越笨”:信息被挤丢了
这是最普遍的投诉:“一开始聊得好好的,后面发现它忘了之前说的事。”排查方向就一条:看它是不是把关键约束挤出去了。用我上面的 ContextModeManager,先检查 context_memory 里有没有命中关键约束。没有的话,说明你可能把它放在了普通消息流里,然后被滑动窗口弹出了。
解决方案就是严格执行我上面说的分级方案。所有能定义为“约束”“偏好”“决策”的信息,一旦识别出来,立刻写入 context_memory,而不是依赖对话原话。这里涉及到一个信息抽取的环节。我的做法是在系统提示词里加一条指令,让模型在对话过程中持续产出结构化记忆。比如:“当用户表达明确的偏好、约束或决策时,输出一个 JSON 块,内容格式为 memory_key/memory_value。”然后我用代码解析这个 JSON,写入 context_memory。
有读者可能觉得这样做会增加 token 消耗。确实会,但不多。相比整个对话被遗忘带来的体验崩溃和重问率,这点成本非常划算。
4.2 并发场景下上下文串味
上下文串味这个坑,多线程或者微服务项目特别容易踩。典型症状是:用户 A 的对话框里,出现了用户 B 说过的话。排查时先别怀疑模型出了幻觉,直接查你的上下文存储隔离。
我在代码审计时看到最常见的错误是:把 manager 对象定义成了模块级全局变量。这种单例对象在多用户场景下必炸。每个用户的请求都会往同一个 messages 列表里追加消息,谁都拿别人的历史当自己的上下文。
正确的做法是:每个 session_id 一个 manager 实例,存放在 Redis 这类带过期时间的存储里。Redis key 的过期时间建议设 30 分钟到 1 小时,避免长期占用存储空间。我通常会把 key 设计成 session:{user_id}:{conversation_id} 这种三级结构,方便跨设备同步和历史回溯。
4.3 成本突然飙升
上下文模式做得越好,成本控制一般越好。但如果代码没写好,成本反而更高。我见过一个项目,核心问题在于每次都把完整历史发出去,却没有任何裁剪逻辑。明明只需要最近 4 轮,前 40 轮也全带上,输入 token 直接翻五倍。
排查成本问题有个回追技巧:给每次请求打好日志,记录 request_message_tokens 和 response_message_tokens。不需要逐条分析,哪天账单异常,直接按 session_id 聚合请求,找 token 消耗最大的会话,基本一眼就能定位问题。加日志不会让系统变慢多少,但能救命。我之前做一个上线项目,前端页面明明没有几个人用,账单却涨得离谱。查日志发现是某个定时任务没有清空 manager 状态,导致历史消息不断累加。这个 bug 如果不做 token 日志,光靠猜,估计要折腾一周。
另一个隐蔽的成本杀手是摘要模式下的重复压缩。如果阈值设得太低,比如历史到 3000 token 就去生成摘要,每几轮对话就触发一次摘要调用,摘要调用的 token 成本会像滚雪球一样越滚越大。解决办法是加一个冷却机制:摘要生成成功后,至少再积累 2000 token 才允许下一次摘要。这个“冷却距离”我一般取历史预算的四分之一左右。
4.4 token 估算不准导致请求报错
我上面代码里的 estimate_tokens 用的是粗略估算,生产环境绝对不能依赖这个。不同模型的分词器差异巨大,最常见的情况是:你估算 7000 token,实际一发出去变成 8200,直接超出窗口,API 报参数错误。
解决方案分两步。第一步,不要自己写估算法,直接用模型 SDK 自带的分词器,比如 tiktoken。中文、英文、代码混合的内容,用不同编码方式测量,误差都能控制在 5% 以内。第二步,也是更稳妥的方案——在请求外层包一层试运行逻辑。先用 prepare_messages 算出消息,如果发现超限,强迫进入 trim 模式,不要直接报错。
我在生产环境处理这个问题的方式是:在 ContextModeManager 的 prepare_messages 末尾加一个保险检查。如果最终消息总 token 超出 max_tokens 减去输出预留,就把 mode 强制切换成摘要模式再跑一遍,确保发送到 API 的消息一定在窗口内。虽然这会让极端场景下多一次摘要调用,但至少不会因为报错导致用户请求直接失败。
4.5 摘要模式丢了关键用户偏好
摘要压缩虽然能保住“语义”,但保不住“细节”。我在客服项目里遇到过一个案例:用户在第 3 轮说“我的收货地址是北京市朝阳区 XX 路 XX 号”,后面聊了 30 轮推荐商品,没有再用到地址。第 33 轮用户说“下单吧”,模型回复确认时,地址已经不在上下文里了。
这种敏感信息,摘要模型不一定会原样保留。不能指望摘要帮你记住地址、手机号、身份证号,这些必须走结构化记忆。我给项目的约定是:凡涉及实体信息(地址、电话、邮箱、日期、订单号)、用户偏好(价格区间、风格、忌口)、状态决策(已决定、已放弃、待确认),必须同时写入 context_memory。摘要只负责记录“情感脉络”和“对话进展”,不承担关键实体存储责任。
这样设计的另一个好处是,摘要不再需要输出过长内容,300 到 500 token 就够。也正因为摘要更精简,token 预算会更宽裕,形成一个良性循环。
我个人在实际操作中还有一个习惯:每次会话结束,把最终的 context_memory 快照保存到数据库。这样即使用户隔了几天再来继续聊,也能从数据库加载他的长期约束。很多团队把注意力放在了“窗口内如何组织”,忽略了“窗口外如何持久化”,其实窗口外的功夫才是正题。长期记忆的存取策略,决定了你这个上下文模式的上限在哪一层。这个点值得每个做 AI 应用的同仁认真对待。