我给自己的内部助手加了一个叫 context-mode 的开关,整个团队的反馈直接变了个样。原因并不复杂:模型还是那个模型,变的只是每次请求到底带多少历史上下文而已。context-mode,直译就是上下文模式,它在对话系统里扮演的是“记忆调度员”的角色——决定每次调用大模型时,是把整段聊天记录全量转发,还是只带最新一句,还是带一段被滑动窗口裁剪过的最近消息,又或是带一份由模型生成的压缩摘要。
这篇文章不搬算法论文,就把我在实际项目里从设计、选型、实现到填坑的完整过程记录下来。内容包括四种模式的定义与适用场景、一个可复用的上下文管理类的核心代码、以及五个我真实踩过之后才想明白的坑。适合正在做对话机器人、AI 客服、或任何接了大模型接口但还没把上下文这件事想清楚的开发者参考。如果你只是被 token 账单吓到过,这篇文章同样值得一看。
1. 为什么你的 AI 应用需要一个 context-mode 开关
刚开始做对话类应用的人,最容易犯的一个错误就是“省事优先”:把整个 session 的消息列表一股脑全部塞进每次请求。模型厂商给的上限是 32k、128k,那就用满嘛,反正窗口大。
但实际跑起来才发现,全量上下文带来的问题远不止多花几毛钱那么简单。
1.1 三个真实场景,暴露全量上下文的隐患
第一个场景是旧信息干扰。某个内部运维助手,用户问“帮我看看昨天的 QPS 曲线”,模型却在一本正经地分析某个三个月前促销活动的影响。翻日志才发现,这个 session 从促销那天起就一直没断过,消息列表里堆积了大量过期的临时数据和闲聊。模型不是不聪明,它是被塞进了太多无关上下文,而大模型对“离得近的信息”往往更敏感,旧结论很容易把新问题带偏。
第二个场景是费用失控。另一个项目最初为了省事,也是把整段历史直接转发。用户随口问一句“在吗”,请求里也躺着 8000 多个 token。单次看起来不多,按一个月几十万次请求算,大部分成本都花在转发那些没人会再看的旧消息上。更荒诞的是,历史越长,模型生成耗时也越长,用户感知到的响应速度反而更慢。
第三个场景是隐私边界。内部系统更麻烦,员工聊业务的时候难免会粘贴一些敏感数据,如果所有消息默认全量透出,就等于这些内容会跟着后续每一个请求一起离开内网。不需要等到出事,光自查和合规评审就能让你改三轮架构。
1.2 context-mode 解决的核心矛盾
我后来把这类问题的本质归纳成三对矛盾。
第一,连贯性与成本的矛盾。上下文越多,模型对前文的理解越充分,但 token 费用、生成耗时、失败重试成本也在同步上涨。没有 context-mode 时,这个平衡点只能由开发者在代码里拍脑袋定死。
第二,相关性与噪声的矛盾。长对话里真正对当前问题有用的信息,往往只集中在最近几轮。其余部分是干扰项,尤其当对话里混入临时数据、重复粘贴、已经作废的需求描述时,模型会优先采信离得最近的、或者语气更笃定的旧内容,而不是真正重要的当期信息。
第三,系统掌控感与用户掌控感的矛盾。站在公司角度,你希望上下文窗口越短越好以控制成本和风险;站在用户角度,他可能正在依赖几个回合前的某个背景信息。两边需求都合理,但没有 mode 就永远只能二选一。
所以 context-mode 的意义,就是把这三种矛盾的取舍权,从代码写死变成一个运行时可控的参数。
2. 先别急着写代码:四种模式的方案选型
很多开发者听到“加上下文模式”,第一反应是“那我在请求前拼一下消息列表不就行了”。真上手才发现,模式之间不是简单拼接的差别,而是语义上的差别。我最后沉淀下来的是四种模式:off、current、sliding、summary。
2.1 一张表定义四种模式
| 模式 | 发送给模型的内容 | 适用场景 | 主要代价 |
|---|---|---|---|
| off | 系统提示词 + 当前问题 | 单轮问答、翻译、临时查资料 | 模型完全失忆,无法承接对话 |
| current | 系统提示词 + 最近一轮交互 + 当前问题 | 两三轮内的短对话 | 超过两轮就接不上 |
| sliding | 系统提示词 + 最近 N 轮,直到 token 预算上限 | 大多数日常聊天、客服问答 | 裁剪不当会丢失早期关键信息 |
| summary | 系统提示词 + 历史摘要 + 当前问题 | 长文档讨论、跨天跨周的项目协作 | 摘要本身有信息损耗和额外调用成本 |
逐个展开说一下。
off 模式的核心价值不是省钱,而是隔离。比如你在一个多轮会话里临时想查一个不相关的问题,又不希望“污染”正在进行的讨论,切到 off 问完再切回来,非常干净。这个模式对 API 侧的要求也最低,几乎不可能因为上下文引发幻觉。
current 模式容易被理解成“单轮”,其实不是。它保留的是上一轮助手回答加上当前问题,让模型可以承接上一句的前提下回答。适合那种两三轮内就能结束的短对话,比如“翻译一下这句话”“帮我改个错别字”。
sliding 是最常用的模式。它做的事情很简单:始终保留系统提示词、最新一条用户消息,以及尽量多(或尽量新)的历史消息,直到总 token 到达预算上限,超出的部分从最旧的地方开始丢。这个模式能覆盖大多数场景,也是我线上默认开的模式。
summary 模式最重,也最值钱。当对话历史确实重要而且很长时,先把旧内容压缩成一份摘要,再带上当前问题发给模型。它比 sliding 多一次模型调用的成本,但能让模型记住跨天、跨周的全局信息,适合项目共创、长文档讨论。
2.2 为什么选显式开关而不是全自动判断
你可能想问:既然模型这么聪明,为什么不让后端根据用户输入自动决定带多少上下文?
我试过,结论是:现阶段别这么做。原因有三个。
第一,自动判断不可解释。用户问了一句“关于刚才那个方案,具体怎么落地”,系统却只发了 current,谁也没法一眼看出为什么。线上用户反馈时,你要对着日志解释半天。
第二,自动判断的错误成本高。判断错了,用户感知到的不是“上下文少了”,而是“模型失忆了”。这个信任损失很难补回来。
第三,显式开关更好测试。四种模式分别验证,回归曲线清晰。如果再加一个黑盒自动模式,等于把测试复杂度乘了一个四。
所以我的方案是:机制上做显式 mode,UI 上给一个“自动”选项。这个自动并不是真正靠模型推理,而是根据用户消息长度和 session 活跃度做一个简单的规则推荐。比如消息少于 20 字且距离上一次交互超过 10 分钟,就建议切 current;如果 session 累积轮数超过 300,就建议切 summary。这样的自动是可解释、可覆盖的。
3. 可复用的 context-mode 实现:代码与工程细节
方案定完,代码就不难写了。难点在于把“每种模式怎么处理历史”这件事收敛到一个类里,避免散落在各个接口中。
3.1 核心数据结构与流程
一个会话需要一个独立的 ContextManager 实例,这个实例持有系统提示词、历史消息、当前模式和摘要。为什么用 dataclass 而不是 dict?因为角色字段和内容字段会被反复读取,dataclass 有类型约束,重构和加字段更安全。
核心流程四条路径:
- add_message 负责写入新消息
- set_mode 负责切换模式
- _trim_history 负责滑动窗口裁剪
- build_request 负责组装最终发送给模型的 messages
整个设计的关键约束是:所有状态变更都收敛到类内部,外部拿到的永远是一份干净的请求体。
3.2 核心代码实现与注释
这是一个可以在项目里直接改改就用的版本,Python 实现:
# context_manager.py from dataclasses import dataclass from typing import Dict, List, Optional import tiktoken @dataclass class Message: role: str content: str class ContextManager: """ 按会话维度管理上下文,支持四种模式: off / current / sliding / summary """ def __init__( self, system_prompt: str = "你是一个有用的助手。", max_tokens: int = 4000, model_family: str = "cl100k_base", summary_prompt: str = ( "请将以下对话压缩成 200 字以内的摘要," "保留数字、关键结论和未解决的问题,不要添加解释。" ), ): self.encoder = tiktoken.get_encoding(model_family) self.system_prompt = {"role": "system", "content": system_prompt} self.max_tokens = max_tokens self.summary_prompt = summary_prompt self.mode = "sliding" self.history: List[Message] = [] self.summary: Optional[str] = None def set_mode(self, mode: str) -> None: self.mode = mode # 关键:切换模式时主动重建缓存,避免旧上下文“诈尸” if mode in ("off", "current"): self.history = [] def add_message(self, role: str, content: str) -> None: msg = Message(role=role, content=content) if self.mode == "off": return if self.mode == "current": self.history = [msg] # 只保留当前这一轮 else: self.history.append(msg) self._trim_history() def _count_tokens(self, messages: List[Dict]) -> int: # 快速估算:按文本整体编码长度计算,贴近实际转发消耗 return len(self.encoder.encode("\n".join(m["content"] for m in messages))) def _trim_history(self) -> None: if self.mode != "sliding": return # 至少保留一句话:单条超长时不拆消息,放到外部处理 while ( len(self.history) > 1 and self._count_tokens(self._build_messages(include_summary=False)) > self.max_tokens ): self.history.pop(0) def _build_messages(self, include_summary: bool = True) -> List[Dict]: messages = [self.system_prompt] if include_summary and self.mode == "summary" and self.summary: messages.append({"role": "assistant", "content": self.summary}) messages.extend({"role": m.role, "content": m.content} for m in self.history) return messages def build_request(self) -> List[Dict]: messages = self._build_messages() # summary 模式的触发:历史超过阈值,先压缩再发送 if ( self.mode == "summary" and not self.summary and self._count_tokens(messages) > self.max_tokens ): self.summary = self._compress() self.history = [] messages = self._build_messages() return messages def _compress(self) -> str: # 真实项目里这里会调用一次大模型接口做摘要 # 演示时返回固定话术,实际实现请替换成 LLM API 调用 text = "\n".join(m["content"] for m in self._build_messages(include_summary=False)) print(f"[summary] 正在压缩 {self._count_tokens([{'content': text}])} tokens 的历史") return "用户问了关于 context 模式的问题,给出了若干方案,正在等待最终实现。"有两处实现细节值得说明。
第一,summary 模式的压缩调用放在了 build_request 里,而不是 add_message 里。这是刻意的。一条一条消息进来时,你并不知道什么时候该做摘要;只有在真正组装请求、发现 token 超限的那一刻,才值得多付一次模型调用的成本。
第二,set_mode 里直接清空了 history。这个动作看似多余,实际是防“诈尸”的关键,后面在避坑部分专门讲。
3.3 接入流程与效果验证
在服务端维护一个 session_id 到 ContextManager 的字典。客户端请求带上 session_id 和期望的 mode。如果 mode 和当前实例不一致,先 set_mode,再加消息,再组装请求,最后把模型回复写回历史。
这个顺序不能乱:先切模式,再加消息,再构建。否则会出现“这条消息用了旧模式”的竞态。
接入代码长这样:
sessions: Dict[str, ContextManager] = {} def handle_message(session_id: str, mode: str, message: str) -> str: cm = sessions.setdefault(session_id, ContextManager()) if cm.mode != mode: cm.set_mode(mode) cm.add_message("user", message) messages = cm.build_request() reply = llm_call(messages) # 内部完成对大模型 API 的转发 cm.add_message("assistant", reply) return reply我自己习惯在验证时用 curl 打长文本,连续发三条,每次都打印请求里的 token 数量。你会发现第一条可能 1000,第三条被控制在了 4000 附近,而不是一路涨到 10000。
curl -X POST http://localhost:8000/chat \ -H "Content-Type: application/json" \ -d '{"session_id":"demo","mode":"sliding","message":"帮我看看昨天订单量"}'日志里建议同时记录 mode、请求 token、回复 token、耗时四个字段。这四列数据,基本就是之后排查用户投诉时最重要的体检表。
4. 实战调试记录:五个让我重构的坑
代码能跑通只是第一步。下面这五个坑,每一个都让当时的我改过一轮代码,甚至是改过数据结构。
4.1 切换模式不清历史,旧上下文“诈尸”
第一个版本 set_mode 只改了 self.mode,没清 history。结果用户从 sliding 切到 off,以为模型“失忆”了,但由于 build_request 还在组装旧的 history,模型依然记得上一轮的内容。更要命的是,切到 off 后 add_message 直接 return,旧的 history 永远不会被替换,等于一个永不清理的缓存。
这个 bug 很难靠自己发现,因为你自己调试时通常只测新模式,不会在同一个 session 里来回切换。线上用户切来切去,问题就爆了。
解决办法就是在 set_mode 里同步清空 history,并且加一行日志输出旧的 token 数和消息条数,方便在面板上看到清空动作发生了。代码里已经体现,这里想强调的不是写法,而是“切换动作本身要可追踪”这个工程习惯。
4.2 滑动窗口把 system 提示词挤掉了
最早的 _trim_history 把 system_prompt 也塞进同一个列表里算 token,超过就从下标 0 开始 pop。pop 来 pop 去,system 提示词被丢掉了。接着模型的行为就开始漂:时而出英文,时而拒绝回答,时而把回答格式改成 JSON。但接口测试完全正常,因为测试用例没那么长。
等用户反馈“机器人突然变傻了”的时候,已经是几天后。查日志才看到某条历史消息触发了裁剪,把 system 挤出去了。
解决办法很简单:system 永远放在裁剪池之外,用单独的字段保存,组装时再放第一位。永远不要让裁剪函数碰到 system_prompt。
4.3 单条消息超长导致窗口裁剪失效
用户把两万字的产品文档直接粘贴进来,消息轮数只有一轮。滑动窗口为了保留最新消息不会 pop 它,于是请求还是超限,要么被 API 截断,要么直接报 400 状态,用户看到报错。
这个问题本质上是输入长度超过了预算本身,不是上下文的锅。但工程上必须兜住。我后来的策略是:单条超限即截断。保留这条消息的前 500 个 token,再追加一行截断标记:注意:原消息过长,已截断,请基于可见部分回答。这个标记写进 content 而不是单独作为消息,避免破坏历史顺序。
顺便说一句,如果这个场景高频出现,说明产品需要限制输入长度或者提供文件上传路径,不建议把这种影响用户体验的变量丢给 token 管理层处理。
4.4 token 统计偏差带来虚假安全感
一开始我用英文编码去估算中文,每个汉字按 1 个 token 算。实际中文场景里,一个汉字常常要占更多 token。估算出来 3000 的历史,真实转发可能 4000 多。长时间下来,系统反复接近而非真正遵守预算,线上账单仍然失控。
token 统计对语言差异非常敏感,这也是最容易被忽略的性能坑。解决办法是靠真实用量校准:在每次模型返回时,把 API 返回的 usage 数据写日志,和你的估算值对比,算出一个该模型的修正系数。本地粗估公式可以用“字符数 × 0.6”作为中文场景的初始值,但不要把它当成真理,真实反馈才是准的。
另外,预算别卡得太死,留 15% 余量。宁可少带一点历史,也不要让请求在边缘反复试探。
4.5 summary 模式丢了关键数字
第一次写的摘要提示词是“请总结以上对话”。模型给了一段流畅但没有任何具体数字的概述。用户接着问“你刚才说某项目上线时间是什么时候”,模型答不上来。
后来我把摘要提示词改造成结构化要求,明确必须保留数字、名称、时间、未解决问题。如果某个具体数字可能被后续引用,必须原文抄录。摘要 prompt 不是给模型欣赏的,是给你未来自己的上下文看的数据结构,越结构化越好。
还有一个实际操作:summary 模式不要只存一份摘要。我在 ContextManager 里实际上维护了一个摘要列表,每满一轮阈值产生一条新的摘要,追加到 history 头部。未来要追溯时,可以按摘要链回溯。代码示例里的单份摘要只是最小演示版,生产环境建议改成摘要链。
5. context-mode 不止是聊天功能
把 context-mode 的思路放大一点看,它其实是所有“给模型投喂材料”场景的通用抽象。
5.1 从聊天扩展到 RAG 与文件级上下文
RAG 系统里,检索器返回的 top-k 片段会被直接拼进 prompt,k 的数量常常是写死的。你完全可以把这个 k 做成一个 mode:k=3 适合快速问答,k=10 适合深度调研,中间加一个路由判据。这不就是 context-mode 的移植吗?
现在的 AI 编程工具里,经常让你手动选择哪些文件作为上下文,本质上也是一种 context-mode——人在用直觉给模型挑选可见范围。你会发现,所有需要控制“模型能看到什么”的地方,都存在一个隐式的 context-mode,只是大多数产品没有把它做成显式功能。
5.2 上下文窗口的未来是分层记忆
模型上下文窗口越做越长,有人觉得 context-mode 会消失。我的判断是不会。窗口再长,成本、噪声、隐私这三个问题依然存在。未来的方向大概率是分层记忆:热数据放最近消息,温数据放结构化摘要,冷数据放向量检索。context-mode 可以成为这个分层的前端表达——用户选择哪一层可见。
先做一个极简版:sliding 代表热层,summary 代表温层,off 代表都不要。等数据量起来之后,再往中间插一层“自动路由”。这套架构现在就能开始搭。
如果让我只留一条经验,那就是:context-mode 不是锦上添花的功能,而是对话系统的基础设施。第一版我图省事,把上下文写死在调用函数里,后来补模式的时候每次都要改接口、改测试、改线上数据结构,苦不堪言。第二次做的时候,我一开始就在会话层做了一个 ContextManager,让所有模式切换都收敛到 set_mode 一个入口,整个系统才干净起来。
上线后一定要记得把每个 session 的模式、token 消耗、消息轮数打日志。你现在可以不相信,但等你开始优化成本、复现用户问题时,会发现这些日志比 prompt 本身还值钱。context-mode 很好写,难的是你愿不愿意在项目一开始就把它当成一等公民。