1. 项目概述:context-mode 到底是什么
1.1 从一次翻车现场说起
先讲一个我亲身经历的场景。半年前我在做一个智能客服机器人,用户和机器人聊了大概二十轮之后,机器人开始"失忆"——一会儿说"刚才您说要退款,请问订单号是多少",一会儿又重复推荐已经推荐过的产品。排查到最后,罪魁祸首就是上下文管理这块:我傻乎乎地把所有对话历史全部塞进模型,token 爆了不说,关键信息根本没被模型记住,等于钱花了、效果也没有。
后来我把这套上下文管理逻辑单独抽出来,起名 context-mode,专门解决"该记住什么、该丢掉什么、该压缩什么"的问题。这里的 context-mode 可以理解为一套通用的上下文管理模式,它不绑定某个具体模型,而是一种设计思路加实现方案,用来在有限的上下文窗口里,尽可能高效地保留对回答最有用的信息。
它解决的痛点非常明确:对话越来越长之后,模型要么超出窗口限制直接报错,要么把早期重要信息"冲"掉,要么被无关闲聊占据大量 token 导致回答质量下降。无论是做客服机器人、AI 写作助手、代码生成工具还是 Agent 系统,只要你跟大模型打交道超过三轮对话,就一定会遇到这个问题。
这篇文章适合谁看?如果你正在用大模型 API 做产品,已经跑通了基本功能,但发现多轮对话效果不稳、token 费用失控,或者想给团队沉淀一套可复用的上下文处理方案,那这篇内容就是给你准备的。我会把 context-mode 的设计思路、核心实现、参数配置和踩坑记录都摊开来讲,照着做就能落地。
1.2 context-mode 的核心职责
一句话概括:context-mode 是对话系统里负责"上下文调度"的中间层。它夹在用户请求和大模型 API 之间,干三件事。
第一件事是筛选。对话历史里不是每条消息都值得保留,比如用户连续说了三遍"你好"、中间夹了一条"在吗",这些信息对当前回答几乎没有贡献,留着只会浪费 token。context-mode 需要判断哪些消息有保留价值。
第二件事是压缩。对于早期的重要信息,比如用户的订单号、偏好、已经确认过的事实,原文保留太费 token,可以用摘要代替。比如把二十条关于退换货政策的讨论压缩成一句"用户已知晓退换货规则:七天内可无理由退换"。
第三件事是组织。筛选和压缩之后的内容,要按照什么样的顺序、什么样的格式拼装起来喂给模型?系统提示词放哪里、历史对话放哪里、当前问题放哪里、检索到的背景资料放哪里,这些排列方式直接影响模型的注意力分布。
这三件事说起来简单,做起来全是细节。很多人以为上下文管理就是"把历史消息都传过去",实际上那是最低级的做法,也是效果最差的做法。context-mode 的价值就在于把这三件事做成一套可配置、可观测、可替换的工程化模块。
2. 设计思路拆解:为什么上下文管理这么难
2.1 上下文窗口的资源约束
要理解 context-mode 的设计,必须先搞清楚模型的上下文窗口是个什么脾气。你可以把上下文窗口想象成一张有限的"草稿纸",模型的所有推理都在这张纸上进行。草稿纸越大,能写的内容越多,但有两个代价:费用和注意力。
费用很好理解,按 token 计费,传进去多少就收多少钱。上下文从 2000 token 涨到 8000 token,单次请求成本直接翻好几倍,如果每天有十万次请求,这笔账非常可观。
注意力的问题更隐蔽但更要命。Transformer 模型对长文本的注意力是"稀释"的——信息越长,模型对每条信息的关注度就越低,特别容易忽略夹在中间的内容。有研究表明,模型对长上下文中间部分的记忆是最差的,开头和结尾记得牢,中间全忘了。如果你把二十轮对话一股脑传进去,模型真正盯着看的可能只有最后两轮。
所以 context-mode 的本质是:在有限的草稿纸上,只写模型最需要看到的内容,把字数控制在合理范围,同时保证关键信息一个不少。这就像面试官面前只有一张纸,你要把候选人最重要的经历写在上面,而不是把所有简历页都堆过去。
2.2 三种主流压缩策略
我在设计 context-mode 的时候,筛选对比了市面上几种常见的上下文处理策略,各有各的适用场景。
第一种是滑动窗口。只保留最近 N 轮对话,更早的一律丢弃。这个方案最粗暴也最省事,实现起来就一个数组加一个截断条件。问题是它不分轻重,早期提到的关键信息,比如用户的地址、手机号,如果是三十轮之前说的,到第三十一轮就丢了,模型会问"麻烦提供一下你的收货地址",用户体验非常糟糕。
第二种是先入先出淘汰(FIFO)。也保留最近 N 轮,但把更早的内容压缩成摘要,塞在系统提示词里。这样早期信息不会完全丢失,但摘要怎么生成、多久更新一次、摘要和原始对话之间如何取舍,都需要仔细设计。摘要本身也是要花钱调模型的,频繁生成摘要在 token 成本上也不低。
第三种是语义筛选。对每轮对话做重要度评分,保留高价值信息,丢弃低价值信息。这是最聪明但也是实现成本最高的方案,需要引入额外的分类模型或规则引擎。比如检测到用户消息里包含订单号、金额、日期等实体,就把这条消息标记为高价值强制保留。
我在 context-mode 里用的是第二种为主、第三种为辅的混合策略:正常的短对话走滑动窗口加摘要,检测到关键实体命中时走语义保留规则。这个组合在效果和成本之间找到了一个不错的平衡点。
2.3 我的策略选型
为什么不做纯语义筛选?两个原因。第一是延迟,每次对话都要跑一遍实体识别模型,增加了几百毫秒的响应时间,放到实时聊天场景里挺明显。第二是不确定性,分类模型有出错的可能,万一误判把关键信息丢掉了,故障排查时很难定位。
所以我的原则是:用确定性规则处理 80% 的常规场景,用模型能力处理 20% 的复杂场景。具体来说:
- 常规对话轮次用固定规则判断,比如超过 10 轮就把第 1-5 轮摘要化。
- 实体命中规则用正则或者字典匹配,检测到手机号、订单号、日期、金额等关键词立刻标记为高优先级保留。
- 摘要生成只在两种情况下触发:一是轮次超限,二是用户主动切换了话题(通过意图分类判断)。
这套策略的另一个好处是可预测。我随时能算出来当前上下文的 token 占用,不会出现"这次突然爆了"的情况。之前那个纯滑动窗口方案就吃过亏——窗口设置了 20 轮,但用户每轮都发很长很长的消息,结果 5 轮就撑爆了 token 上限,模型直接报错。
注意:无论采用哪种策略,都必须在代码里做 token 数量的实时估算,不能只按轮数截断。用户消息的长度分布差异极大,按轮数控制根本不可靠。
3. 核心实现:context-mode 的关键环节
3.1 上下文分层结构设计
context-mode 的上下文在内存里是一个分层结构,一共四层,每一层都有自己的职责和更新策略。
第一层是系统提示词层。存放角色设定、行为规范、输出格式要求。这一层基本是静态的,只有在产品配置变更时才更新。需要注意的是,系统提示词不应该被对话内容挤占位置,它是所有回答的"地基",优先级最高,永远完整保留。
第二层是用户画像层。包括用户已经确认过的身份信息、偏好、关键事实。比如用户说过"我需要发票",这就是一条高价值信息,要放进这一层。这一层在每次对话结束时更新,把新发现的关键信息追加进去,同时做去重。
第三层是对话历史层。分两个子区:摘要区和明细区。摘要区存放早期对话的压缩摘要,明细区存放最近几轮的原始对话。明细区采用滑动窗口,默认保留最近 6 轮,摘要区通过定期合并生成。
第四层是即时输入层。就是用户当前这一条消息,以及上下文检索到的参考材料(如果有 RAG 的话)。这一层永远放最前面,因为模型对最近内容的注意力最强。
这个四层结构映射到 API 调用时的消息数组,顺序是:系统提示词 → 用户画像 → 摘要区 → 明细区 → 即时输入。你可以想象成写论文:摘要放前面让读者先了解背景,细节放中间提供证据,最后是当前要解决的问题。模型读消息的顺序跟人一样,先读的先建立基调,最后读的最容易被注意。
3.2 Token 预算分配
分层结构定下来之后,下一件事就是给每层分配 token 预算。我把模型窗口比作一个总盘子,比如选用 8K 窗口的模型,实际可用量按 7K 算,留 1K 给系统预留和 API 返回余量。
我的初始分配方案是这样的:
| 层级 | Token 预算 | 策略 |
|---|---|---|
| 系统提示词 | 1,000 | 超过则精简提示词文案 |
| 用户画像 | 500 | 超限时按最近更新时间淘汰 |
| 对话摘要区 | 2,000 | 超限时对旧摘要再次合并 |
| 对话明细区 | 1,500 | 滑动窗口,丢弃最旧原始消息 |
| 即时输入 | 1,500 | 超长则截断或提示用户精简 |
| 检索参考材料 | 500 | 按相关性分数截断 |
这个分配不是拍脑袋定的。最早我做过一版,把 70% 的预算全给了对话历史,结果发现回答质量反而下降了——因为模型读了太多历史,对当前问题的注意力被稀释了。后来我刻意压缩历史占比,把更多预算留给即时输入和检索参考材料,效果明显改善。这个经验放在大模型领域也同样适用:该省的省,该花的地方要舍得花。
预算分配还有一个技巧,就是动态调整。用户画像如果长时间没有新增信息,说明这段对话没有出现新的关键事实,可以把这部分预算临时让给检索材料。我实现了一个简单的"预算借贷"机制:每层有一个基础预算和一个弹性上限,用完基础预算但弹性上限没到的时候,可以从当前不太活跃的层级借。
3.3 上下文持久化与恢复
context-mode 不能只活在内存里,用户聊到一半关掉页面,过两天再回来,上下文需要能从存储中恢复。我用的方案是 Redis 加 JSON 序列化,以会话 ID 为 key,把四层结构完整存进去。
Redis 的 TTL 设置为 72 小时。短于 24 小时,用户隔一天回来就失忆了;长于 7 天,存储成本虽然低,但上下文内容大多已经过时,恢复出来反而干扰判断。72 小时是个平衡值。
恢复的时候有一个细节值得注意:摘要区要原样恢复,明细区可以部分丢弃。用户隔了三天回来,最近 6 轮的原始对话里可能有大量过期的临时信息,比如闲聊的"哈哈""好的",留着反而稀释注意力。我恢复时会把明细区只保留最近 2 轮,其余合并进摘要区,这样既省 token,又不丢关键事实。
持久化还有一个不可忽视的问题:并发写。用户可能在多个设备上同时打开会话,两个请求同时更新上下文,后写的会覆盖先写的,导致信息丢失。我的处理是给每个会话加了一个版本号,更新时检查版本号,冲突则做合并:保留两边各自的新增信息,删除两边都认为是旧信息的内容。这个方案不完美,但足够解决绝大多数场景。
4. 实操过程与核心环节实现
4.1 关键代码结构拆解
context-mode 我建议用独立模块实现,不要跟业务逻辑混在一起。我习惯把它写成一个类,对外暴露两个方法:add_message(sender, content)和build_messages(current_query)。
add_message负责接收新消息、更新各层内容、处理摘要合并、更新持久化存储。build_messages负责把四层结构拼装成 API 需要的消息数组,同时做 token 估算和超限裁切。
伪代码大概长这样:
class ContextMode: def __init__(self, session_id): self.session_id = session_id self.system_layer = load_system_prompt() self.profile_layer = load_profile(session_id) self.summary_layer = load_summary(session_id) self.detail_layer = load_detail(session_id) self.budget = TokenBudget(7000) def add_message(self, sender, content): # 1. 检测关键实体,命中则写入用户画像层 entities = extract_entities(content) if entities: self.profile_layer.update(entities) # 2. 追加到明细层 self.detail_layer.append(sender, content) # 3. 明细层超轮次上限时,触发摘要合并 if self.detail_layer.count() > MAX_DETAIL_TURNS: old_turns = self.detail_layer.pop_oldest(SUMMARY_TRIGGER_COUNT) new_summary = self.summarize(old_turns) self.summary_layer.merge(new_summary) def build_messages(self, current_query): messages = [] messages.append({"role": "system", "content": self.system_layer.text()}) if self.profile_layer.text(): messages.append({"role": "system", "content": "用户已知信息:" + self.profile_layer.text()}) if self.summary_layer.text(): messages.append({"role": "system", "content": "历史摘要:" + self.summary_layer.text()}) for turn in self.detail_layer.get_turns(): messages.append({"role": "user", "content": turn.user_text}) messages.append({"role": "assistant", "content": turn.assistant_text}) messages.append({"role": "user", "content": current_query}) return self.budget.trim(messages)这个结构里最需要注意的是摘要合并的时机。我用了两个参数来控制:MAX_DETAIL_TURNS设为 12,SUMMARY_TRIGGER_COUNT设为 5。意思是明细区最多保留 6 轮(每条消息算半轮?不对,一轮是 user + assistant 两条,所以 12 条消息等于 6 轮),超过后每多 5 条,就触发一次摘要合并。
4.2 摘要生成的踩坑
摘要这块是我踩坑最多的地方。第一次实现的时候,我用了一个很蠢的方案:每次触发摘要都对整个明细区做一次完整摘要,结果发现摘要有时候比原文还长,而且摘要里出现了原文没有的信息,幻觉问题很严重。
后来我改了两点。第一点是摘要只对"即将被移出明细区"的那部分内容做,已经摘要过的历史不参与,避免重复摘要导致的累计噪声。第二点是摘要生成时拆成两个步骤做重点区分:先让模型提取硬性事实(实体、数字、日期、确认过的决定),再让模型概括争议和偏好。硬性事实用 JSON 格式输出,方便后续放入用户画像层;偏好和结论才放入摘要区。
这里有一个非常关键的提示词技巧。摘要生成时,我会在提示词里强调:千万不要出现原文中没有的信息,不确定的信息直接丢弃。哪怕摘要短一点,也不能编造。原因很简单,摘要一旦出现幻觉,后续所有回答都会在这个错误基础上继续推理,错误会被放大。
4.3 突发超限的兜底策略
就算做了预算分配和裁切,还是会遇到极端情况。比如用户一次粘贴了 5000 字的文章进来,直接把即时输入的预算打爆了。这种时候不能傻傻地截断到 1500 token,用户体验会很差。
我的兜底策略分三步走。第一步,检测到即时输入超长,先做一次快速过滤——把明显的无意义字符、重复内容、超大段空白过滤掉。第二步,对内容按段落做重要性排序,保留最重要的段落。这个排序我用了关键词匹配的近似方案,不跑模型,速度控制在 50ms 以内。第三步,如果仍然超限,把处理后的内容拆成两段,第一段跟当前问题拼装请求模型,第二段作为"补充材料"附带在下一轮再传。
这套策略实测下来,用户的极端输入从"直接 500 报错"变成了"能正常得到回答,虽然部分细节需要追问才能获取",这在产品体验上完全是可以接受的结果。
还有一个差点被忽略的细节:API 返回的内容也要算进 token。很多人在估算上下文时只算请求里的输入,忘了模型的输出也会占用窗口。如果模型返回一个 2000 token 的长篇回答,而你的窗口已经被输入占了 6500 token,这次请求大概率会失败。所以TokenBudget.trim()里我会预留至少 1500 token 给模型输出,宁可让输入少一点,也要保证请求能成功发出。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
把这段时间别人问得最多的问题和我自己踩过的坑整理成一张表,有类似症状的直接查。
| 现象 | 根本原因 | 解决方案 |
|---|---|---|
| 模型重复提问已提供过的信息 | 早期关键信息被滑动窗口丢弃 | 引入实体识别,命中实体写入用户画像层 |
| 上下文 token 超限报错 | 只按轮数截断,未按 token 估算 | 使用 tokenizer 做实时计数,预留输出空间 |
| 回答质量随对话轮次下降 | 历史占比过高,稀释当前问题注意力 | 压缩历史预算,提高即时输入比例 |
| 摘要内容与原文矛盾 | 摘要模型产生幻觉或在摘要中臆测 | 摘要提示词强调"不提原文未出现内容" |
| 恢复会话后模型"失忆" | 持久化只存明细区,摘要区未保存 | 四层结构全部序列化存入 Redis |
| 多端并发导致上下文错乱 | 更新冲突,后写覆盖先写 | 增加版本号,冲突时做合并策略 |
| 摘要越合越长,费用飙升 | 每次全量重摘要 | 只摘要即将移出明细区的部分 |
5.2 如何观测上下文状态
context-mode 如果只是一套代码,出问题时很难排查。我在设计时就加了一个调试模式,关键是两条:快照输出和成本统计。
快照输出很简单,在build_messages返回前把最终的 messages 数组打到一个日志文件里,每条消息打上所属层级的标记,比如 [SYSTEM]、[PROFILE]、[SUMMARY]、[DETAIL]、[CURRENT]。出问题时,我可以直接看到模型到底收到了什么,一眼就能定位是没传进去还是传了不该传的。
成本统计是我后来才加的。每次请求记录三个数:输入 token 数、输出 token 数、费用估算。看板展示后我发现一个有意思的现象:摘要生成的 token 消耗占了总消耗的 20%,这比我预期的高不少。于是我调整了摘要触发频率,从"每 5 条合并一次"改成了"每 8 条合并一次",成本立刻降下来,回答质量也没有明显变化。这个经验说明:上下文管理模块本身的成本也要被监控,不然很容易在优化主流程时忽略了管理模块自己烧掉的钱。
提示:调试模式下日志量大,生产环境建议只保留异常请求的快照,正常请求只记录 token 统计。我吃过一次亏,全量快照一天写了几十个 GB 日志,把磁盘打满了。
5.3 排查超限问题的一个硬核技巧
我分享一个排查 token 超限问题的做法。不要只看最终报错信息,报错只告诉你超了,不告诉你超在哪层。正确做法是让 context-mode 在构建消息数组时输出每层的 token 占比。
我在TokenBudget.trim()里加了这么一段:
for layer, text in layers.items(): layer_tokens = estimate_tokens(text) percentage = layer_tokens / max_budget * 100 logger.info(f"layer={layer}, tokens={layer_tokens}, percentage={percentage:.1f}%")然后按百分比排序,哪层超了、哪层吃掉了大头,一目了然。有一次我排查某个会话说 token 异常飙升,一看日志,发现用户画像层居然积累了 3000 个 token——原来是用户在对话里无意中提到了很多个编号,号码全部被实体识别规则当成高价值信息塞进了画像层。我赶紧补了一条规则:实体数量超过 20 条时,只保留最近 10 条,其余移到摘要区。问题立刻解决。
这个技巧的核心思想是:先量化,再优化。不要靠猜去排查上下文问题,先让系统把数据摊开给你看。
6. 我最后想说的几件事
context-mode 这套设计我前前后后改了四个版本,从最初的纯滑动窗口到现在这个四层结构加预算控制的方案,每一步都是被真实业务问题逼出来的。
如果让我重新做一遍,我会在一开始就想清楚两件事。第一件事是上下文管理的目标不是"记住所有内容",而是"让模型在当前时刻做出最好的回答"。想明白这一点,很多取舍就简单了——该丢的丢,该省的省,一切以当前回答质量为最高优先级。第二件事是可观测性要和功能同步建,不要等功能写完再补日志,否则你根本不知道问题出在哪一层。
最后分享一个我一直在用的技巧:每次调完上下文策略,不要只看一两轮对话的效果,要跑一个 50 轮以上的长会话测试脚本,专门检查早期信息能否在后期被正确引用。这个测试脚本我强烈建议你写一个,因为它能把大多数上下文管理的问题提前暴露出来——等真实用户帮你测出问题的时候,成本就高多了。