1. 项目概述:Context Mode是什么,解决什么问题
在做大模型应用落地的时候,最容易被忽略、但直接决定用户体验上限的,往往不是提示词写得好不好,而是 context-mode——上下文模式。简单说,它就是“每次请求到底带多少历史信息给模型”的控制开关。很多人把大模型接到项目里,第一版跑通了就觉得万事大吉,结果用了一段时间发现,模型越聊越笨、越聊越“失忆”,甚至上一句说过的事下一句就忘了。问题大概率就出在上下文模式没设计好。
1.1 一个真实的“翻车”现场,也是这个项目的起点
先说一个我自己的经历。去年做一个客服问答机器人,最初用的是最省事的方案:每次调用模型只把用户当前问题发过去,不携带任何历史消息。上线后客服反馈说“这个机器人是个金鱼脑子,用户说‘我刚才那个订单怎么还没发货’,它一脸懵”。
后来改成无脑拼接历史消息,把所有对话记录一股脑塞给模型。结果是:能记住之前的订单了,但用户只要多聊几轮,系统就报错,提示超出最大 token 限制;即便没报错,模型也经常被很早之前的无关对话带偏。比如用户中途聊了几句天气,模型回答就突然开始聊天气,完全忘记正事。
踩了这个坑之后我才意识到,真正的核心不是“带不带历史”,而是“怎么带、带多少、带哪些”。这个需求抽象出来,就是 context-mode 要解决的问题:在有限的上下文窗口里,用一套可控的策略,让模型在每一轮都能看到最关键的、最相关的那部分信息,同时把历史信息的存储成本和控制复杂度都降下来。
1.2 Context Mode的核心价值:让模型“记得住”又不“记太多”
大模型的上下文窗口是硬约束。哪怕是支持超长窗口的模型,也不是真的让你把所有内容都堆进去——内容一长,注意力会被稀释,模型对早期信息的召回率会明显下降,而且每次请求的 token 消耗直接和成本挂钩。Context Mode 的价值,就是在“记得住”和“记太多”之间找到一个工程上可落地的平衡点。
具体来说,一个好的上下文模式应该做到三件事:
- 控制范围:明确每次请求携带哪些内容,是最近几轮对话、是系统设定、还是从知识库里检索出来的片段。
- 控制长度:在不超过模型上下文窗口上限的前提下,把最重要的内容保留下来,优先级低的内容该丢就丢。
- 控制成本:因为 token 即成本,同样的功能,1000 token 能解决的事,就不要花 4000 token。
这篇文章我会从设计思路、实现细节、代码示例到问题排查,完整讲一遍我在实际项目中落地 context-mode 的过程。无论你是刚接触大模型开发,还是已经被上下文问题折磨过一阵子,下面这段内容都值得看完。
2. 核心细节解析:三种Context Mode的实现思路
Context Mode 并不是一个固定的代码模板,它是一套策略。不同场景适合不同的策略,选错了策略,后面怎么调优都别扭。我把它拆成三种基础模式来讲,理解清楚这三种,基本就能覆盖绝大多数业务需求。
2.1 单轮模式:最稳,但最傻
单轮模式很好理解:每次请求只传当前输入,不传任何历史。这是最不容易出 bug 的模式,因为模型每次面对的都是一段独立文本,不会被之前的错误信息带偏。适合的工具场景包括:一次性文本分类、单条内容审核、独立翻译、模板化生成等。
但它的缺点同样明显。只要业务涉及多轮对话,单轮模式基本不可用。你让它“结合我们刚才讨论的方案,写一份总结”,它根本不知道“刚才”发生了什么。我第一次做客服机器人时用的就是这种模式,结果被喷得很惨。
如果项目初期只求快速验证某个 prompt 效果,用单轮模式是可以的。它能把变量控制到最少,让你先确认模型对单条输入的输出质量达不达标。但正式做产品时,几乎不会只用它。
2.2 滑动窗口模式:最常用的默认选择
滑动窗口模式是目前多轮对话场景里最常用、也最稳妥的一种 context-mode。思路很简单:始终携带最近 N 轮对话,再早的内容直接丢弃。这个 N 不是拍脑袋定的,而是根据模型上下文上限、平均每轮消耗 token 数、以及业务需要的“记忆深度”一起算出来的。
举个例子,假设你用的是一个窗口上限 8000 token 的模型,业务上希望用户能连续聊 20 轮不“失忆”。如果平均每轮用户输入加模型回复消耗 400 token,那 20 轮就是 8000 token,正好卡满。但实际不能真的卡满,因为每轮还要附带 system prompt、工具返回结果、当前问题等信息,这些都会额外占 token。所以你需要留出至少 20% 的余量,把实际可用的历史空间控制在 6000 token 左右,再反推出到底保留最近多少轮。
这种模式的优点是实现成本低、行为可预测、不会因为历史太长导致模型注意力涣散。缺点是它天然“记不住太久远的事”。用户聊了 50 轮,它只能看到最近 20 轮,早期信息彻底消失。如果你的业务中用户有跨多天、多轮次的长期记忆需求,这种模式就不够了。
2.3 摘要模式:突破窗口上限的“记忆压缩”
摘要模式是我个人认为最有技术含量的一种。它的核心思想是:不要直接丢旧消息,而是把旧消息做一次“压缩”,生成一段摘要,存进 memory 里。以后每次请求,把这段摘要放在历史消息最前面,再拼接最近的对话,这样既保留了早期信息,又不会撑爆窗口。
具体做法有两种。一种是在对话过程中定期触发摘要生成,比如每 10 轮就让模型把之前所有对话总结成 200 字以内的要点;另一种是采用固定的“长期记忆 + 短期记忆”双层结构,长期记忆存摘要,短期记忆存最近几轮原始消息。
摘要模式最大的坑在于摘要本身可能失真。模型在压缩时可能会丢掉关键细节,比如用户提到过的具体订单号、时间、金额。如果这些信息是业务关键字段,用摘要模式就要非常小心。我的做法是:摘要里只保留“事实性要点”,比如用户诉求、已确认的结论、待办事项;而把原始消息归档到外部存储,需要查细节时再单独检索。
这三种模式不是互斥的,实际项目里经常是混着用。比如先用滑动窗口处理短期对话,同时用摘要机制维护一个跨会话的长期记忆,检索增强再负责从知识库中补充相关信息。这相当于给模型做了分工:哪些信息靠窗口记住,哪些靠摘要记住,哪些靠外部检索临时拉取。
3. 实操过程与核心环节实现:写一个可落地的Context Mode组件
有了设计思路,接下来就是动手实现。我这次分享的版本会尽量保持轻量,方便你直接抄进自己的项目里改。我选择了 Python 作为示例语言,因为大模型生态里 Python 的库最全,也最容易调试。
3.1 前置设计与工具选型
在写代码之前,先明确几个边界:
- 模型接口选型:我以 OpenAI 兼容接口为例,因为国内主流模型服务大多提供兼容接口,换 base_url 就能直接切换。你用别的平台也没关系,核心逻辑是通用的。
- Token 计算方法:建议用 tiktoken 库做精确计算,而不是自己按字数瞎猜。不同模型的 tokenizer 不一样,同一个字符串在 gpt-4 和 Claude 上算出来的 token 数可能差很多。
- 存储方案:历史消息先放内存里,便于理解逻辑。生产环境建议换成 Redis 或者数据库,按 session_id 存储。
3.2 ContextMode核心代码与注释
下面这段代码是我实现的一个小型 context-mode 管理器,支持滑动窗口和摘要模式。
import json from typing import List, Dict, Optional, Literal from dataclasses import dataclass, field try: import tiktoken except ImportError: tiktoken = None @dataclass class ContextMode: """ 一个轻量的上下文模式管理器。 mode: "single" | "sliding" | "summary" """ mode: Literal["single", "sliding", "summary"] = "sliding" max_context_tokens: int = 6000 max_turns: int = 20 system_prompt: str = "你是一个乐于助人的助手。" summary_prompt: str = "请用不超过200字总结以上对话的核心信息,包括用户诉求、已确认结论和待办事项。" history: List[Dict[str, str]] = field(default_factory=list) summary: str = "" _token_buffer: int = 0 def __post_init__(self): if tiktoken: self.enc = tiktoken.encoding_for_model("gpt-4") else: self.enc = None # 初始化时把 system prompt 放进 history self.history = [ {"role": "system", "content": self.system_prompt} ] def _count_tokens(self, text: str) -> int: if self.enc: return len(self.enc.encode(text)) # 备用估算:中文约0.6 token/字,英文约0.25 token/字符 return int(len(text) * 0.6) + 5 def _current_total_tokens(self) -> int: return sum(self._count_tokens(msg["content"]) for msg in self.history) def add_message(self, role: str, content: str) -> None: """添加用户/助手消息,并触发上下文管理策略""" self.history.append({"role": role, "content": content}) if self.mode == "sliding": self._apply_sliding_window() elif self.mode == "summary": self._maybe_apply_summary() def _apply_sliding_window(self) -> None: """滑动窗口:保证在 max_context_tokens 内,且保留最近 max_turns 轮消息""" # 先按轮数限制裁剪 # history[0] 是 system prompt,所以消息从 index=1 开始 while len(self.history) - 1 > self.max_turns * 2: # 丢最老的一条 user/assistant 消息 self.history.pop(1) # 再按 token 数限制裁剪 while self._current_total_tokens() > self.max_context_tokens and len(self.history) > 1: self.history.pop(1) def _maybe_apply_summary(self) -> None: """摘要模式:当 token 超过阈值时,触发摘要压缩""" if self._current_total_tokens() < self.max_context_tokens: return # 取出所有历史消息(不包括 system prompt) messages_to_compress = self.history[1:] compressed_summary = self._generate_summary(messages_to_compress) self.summary = compressed_summary # 用 system prompt + 摘要 替代全部历史 self.history = [ {"role": "system", "content": self.system_prompt}, {"role": "system", "content": f"【历史对话摘要】\n{compressed_summary}"} ] def _generate_summary(self, messages: List[Dict[str, str]]) -> str: """ 这里应该调用 LLM 生成摘要。 为了演示,我只把消息拼起来当摘要,实际项目中请替换。 """ content = "\n".join(f"{m['role']}: {m['content']}" for m in messages) # TODO: 接入 LLM 生成真正摘要 return content[:200] def build_messages(self, current_input: str) -> List[Dict[str, str]]: """构造最终送入模型的 messages 列表""" msgs = self.history.copy() msgs.append({"role": "user", "content": current_input}) return msgs def clear(self) -> None: """清空历史,保留 system prompt""" self.history = [ {"role": "system", "content": self.system_prompt} ] self.summary = ""这里有一点必须说明:_generate_summary方法是简化的,实际项目中必须调用一次 LLM 接口来生成摘要。比如可以复用同一个模型,也可以用小一点的模型专门做摘要,成本更低。
3.3 Token计算方法与窗口预算
很多新手在这里会踩坑:凭感觉设置max_context_tokens。比如模型支持 32K,他就设成 32000,结果调用时报错,或者费用高得吓人。原因在于,模型接口限制的是单次请求总 token 数,你塞进去的history、system prompt、当前输入、还有模型输出的 token,全部算在一起。所以预留输出空间是必须的。
我一般按下面这个公式来预算是比较稳的:
可用历史上下文窗口 = 模型上限 × 0.8 - 系统提示词 - 当前用户输入 - 预计输出长度以模型上限 8000 为例:
- 留 20% 余量,即上限按 6400 计算;
- 系统提示词占 200;
- 当前用户输入平均 500;
- 预计输出最长 1000。
那么可用历史窗口就是 6400 - 200 - 500 - 1000 = 4700 token。用这个数字作为max_context_tokens传给 ContextMode,滑动窗口就会自动在这 4700 token 内保留尽量多的最近对话。别贪心把窗口设满,否则一旦遇到超长输入或者模型一次性输出太多,就会报错,而且报错后重试的代价更高。
如果要精确计算 token,建议用 tiktoken。第一次使用前先安装:
pip install tiktoken然后在上面的代码里,_count_tokens方法会自动用 tiktoken 精确计算。需要注意:tiktoken 的encoding_for_model只支持 OpenAI 模型列表,如果用国产模型或者开源模型,可能需要用对应模型的 tokenizer,或者用粗略估算方式。
4. 常见问题与排查技巧实录
代码写完了,跑起来不难,难的是调好。我把自己实际调试 context-mode 时踩过的坑整理成一份问题速查表,按出现频率排序,你遇到类似问题时可以直接对着排查。
4.1 上下文被“污染”导致回答串台
现象:用户问订单进度,模型却开始聊他上次提到的旅游计划;用户说“那个事情算了”,模型以为用户要把订单取消。
原因:滑动窗口保留了太多无关早期信息,或者摘要模式生成的摘要没有区分“事实”和“闲谈”。模型注意力被无关内容稀释了。
解法:
- 加大系统提示词的权重,强制模型优先关注最近一轮的意图。
- 摘要模式里只保留事实性信息,过滤掉寒暄、情绪化内容。
- 如果用户明确切换了话题,考虑清空早期上下文,重新开始一轮新记忆。这也是 context-mode 里一个很实用的扩展:话题重置开关。
4.2 上下文一长就“失忆”与截断策略
现象:对话超过 50 轮之后,模型突然忘记用户最初提出的核心需求,尽管这些信息还在窗口里。
原因:超过一定长度后,模型对早期 token 的注意力会自然衰减。这不完全是 mode 的问题,而是模型本身的局限性。窗口越大不代表越“记得住”。
解法:
- 不要只看“有没有”超过窗口,要看“最关键的几个 token”是否被挤到太靠前的位置。
- 把核心约束放进 system prompt 里,让它始终出现在最前面。
- 在摘要模式中,把“用户初始目标”“最终要交付的东西”单独摘出来,跟着摘要一起放在窗口头部。
- 对关键信息做外部持久化,需要时检索回来,而不是完全依赖窗口。
4.3 费用账单吓人,怎么降本
现象:引入上下文模式后,每次请求 token 数明显变大,账单跟着上涨。尤其是摘要模式,因为额外多了一次摘要生成的调用。
解法:
- 使用滑动窗口时,把
max_turns调小一点。很多场景只需要最近 6 到 8 轮,再早的信息用处不大。 - 摘要生成可以选择小模型。比如主对话用大模型,摘要用一个小型模型,成本能降一个数量级。
- 给摘要触发加阈值,比如至少 15 轮才触发一次,避免频繁压缩。
- 在
build_messages里定期清理超过 24 小时的会话,从存储层删除会话时一并清除。
4.4 调试Context Mode时必看的几个关键日志
context-mode 出问题时,最难受的就是“不知道模型到底看到了什么信息”。这条是我自己的经验:如果只盯着最终输出,很难判断是提示词问题、上下文问题还是模型本身的问题。
我的做法是在每次请求前把build_messages的结果落一条日志,至少包含:
- 当前模式(mode);
- 当前总 token 数;
- history 消息条数;
- 是否触发了 summary 压缩;
- 最终 messages 的前 200 个字符。
有了这些日志,再看到模型回答异常时,就能迅速回溯到“当时它到底看到了什么”。这个习惯帮我解决了很多看起来像玄学的问题。
我个人更推荐在开发阶段把这个日志输出到本地控制台,在测试环境保留最近 100 条记录。生产环境如果担心敏感信息泄漏,只记录 token 数和消息条数,不记录完整内容,也能满足大部分排查需求。
最后分享一个我后来一直在用的小技巧:把所有 context-mode 的配置参数,比如 mode、max_turns、max_context_tokens,都做成可配置项,放进配置中心或环境变量里。因为线上出问题的时候,你大概率需要在不改代码的情况下临时调整窗口大小。提前把参数暴露出来,能少一次紧急发布,也能让后续调优快很多。