上个月我把一个跑在讨论群里的答疑机器人从“单轮问答”升成了“可记忆的多轮对话”,前两天的效果让我很兴奋,第三天白天就开始出事了。用户连续追问了几轮之后,日志里开始频繁出现 400 错误,费用曲线也直着往上走。我把发给模型的入参打出来看了一眼:光是把历史消息按原样拼回去,就已经占掉了快两万个 token,模型上下文窗口一大半都在用来复读旧内容。那之后我花了一个周末写了一个叫 context-mode 的小模块,专治这种“上下文被打爆”的问题。它做的事情说简单也简单:按预算把历史消息切成窗口、摘要和原始日志三层,在超限前主动压缩,而不是被动截断。如果你的应用也在做多轮对话、长文档分析、Agent 工具调用这类吃上下文的功能,这篇文章的拆解和实践记录应该能给你省下不少时间。
1. context-mode 到底在治什么病:上下文被打爆的现场
先说一个具体场景。这个机器人背后接的是一个通用大语言模型,基础能力没问题,瓶颈全在我这边怎么组织消息历史。最开始的设计很粗暴:把每次的用户问题、机器人回答,连同工具返回结果,全部塞到一个数组里,每次请求都原样发给模型。这样做的优点是简单,缺点是当你真的跑起生产流量时,上下文管理就会变成一场看不见头的账务危机。
- Token 超限:模型上下文窗口是硬上限,超了直接报错,用户看到的就是机器人突然断片。
- 注意力稀释:即便没超限,几千 token 的历史埋在里面,模型对“用户现在到底想要什么”的判断会被旧信息干扰,回答质量直线下降。
- 成本浪费:每次请求重复计算整段历史,Prompt 越长推理成本越高,长对话场景下成本涨幅几乎是线性的。
- 错误传染:历史里如果有一条早期的错误回答,模型很容易把错误当事实延续下去,而且排查起来特别费劲。
我把这些问题整理过一张对照表,方便你一眼看出自己的项目走到了哪个阶段:
| 现象 | 表面原因 | 真实病灶 |
|---|---|---|
| 多轮后经常超窗口报错 | 历史消息堆得太多 | 缺少预算控制和压缩机制 |
| 模型复述旧内容而不是回答新问题 | 上下文过长导致注意力分散 | 没有把“关键摘要”和“完整历史”分开 |
| 单次请求账单涨得吓人 | Prompt 字节太多 | 每次全量发送,缺少分级存储 |
| 历史里一条错误被不断放大 | 模型把旧输出当事实 | 缺少对历史消息的审校和限制 |
这些症状以前分散在我好几个项目里,一直没有放到一起看。直到这次机器人的成本报表出来,我才意识到:与其在模型层反复调参,不如在应用层做一个明确的上下文管理模块。context-mode 这个名字也由此而来——它不是一个魔法模型,而是一套“先算预算、再分级存储、最后按需重组”的处理流程。
1.1 为什么临时截断不是解决方案
第一次遇到超限错误时,我的第一反应是:把超过窗口的部分丢丢掉不就行了吗?于是写了个history = history[-30:],留下最后三十条消息。很快新问题出现:用户在两轮前提到的关键约束条件被截掉了,模型在后续回答里完全无视这些约束,日志里全是用户“你刚才不是答应过吗”的反馈。截断的问题在于它是无差别丢弃,只照顾了长度指标,没有照顾信息价值。一个系统提示里规定的硬性规则,和一次闲聊里的语气词,在截断逻辑眼中是等价的。
1.2 摘要替换为什么也会翻车
接着我尝试了更“聪明”的方案:用一行提示词让模型对历史生成一段总结,然后用总结替代历史发送。这个做法的典型问题有两个。第一,摘要本身会引入压缩失真,模型总结时容易把“用户说过”写错成“模型认为”,后续回答就会顺着这个错误走。第二,总结占用的空间并不小,一次长对话的摘要可能就有两三千 token,再叠加正常消息很容易又撞到窗口。context-mode 最终没用“摘要替换全部”这种单一方案,而是采用了三层结构,下面一节我会展开讲。
2. context-mode 的核心抽象:把历史拆成三种切片
真正动手设计时,我问了自己一个问题:对话历史里到底哪些信息必须保留原始状态?答案是:将来可能要回溯排错的细节。对模型回答有帮助的则是另一类信息:已经聊完的结论、当前正在处理的目标、用户反复强调的约束。这两类信息的生命周期完全不同,不应该塞在同一个数组里。
context-mode 因此定义了三个存储层。
原始事件日志(Raw Event Log)是永远追加的全量记录,每条消息带时间戳和消息类型,完整落盘到 JSONL 文件里。它不参与请求组装,只负责回溯和审计。会话摘要(Episode Summary)是压缩后的高阶信息,用来替代那些已经很遥远、但仍有参考价值的对话片段。活动窗口(Active Window)是真正会拼到请求里的近期消息集合,长度受预算控制。
用一张图来类比的话,活动窗口是“当前正在处理的桌面”,摘要区域是“贴在墙上的便利贴”,原始日志则是“仓库里的完整档案”。窗口可以频繁切换内容,便利贴定期重写,档案永远不删。
2.1 活动窗口的滚动规则
窗口滚动的依据不是消息条数,而是 token 预算。context-mode 里我用了一个很简单的公式:
- 每次请求前读取
MAX_CONTEXT_TOKENS,这是模型上下文窗口的上限。 - 预留
RESERVED_FOR_COMPLETION,这部分空间留给模型生成回答,不能被历史占用。 - 可用的历史预算 =
MAX_CONTEXT_TOKENS - RESERVED_FOR_COMPLETION - SYSTEM_PROMPT_TOKENS。 - 从最新消息向前累加 token 数,直到接近历史预算上限,之前的消息全部进入摘要区。
这里有一个容易踩的细节:永远不要假设窗口上限等于可用历史空间。不同模型押 token 的方式不一样,同样两万 token 的窗口,设置reserved_for_completion之后才能真正给生成留出余量。我目前跑业务用的模型窗口是 32k,但max_context_tokens配置只开到 20k,历史预算再砍到 18k,剩下的全部留给输出。宁可让对话稍早进入压缩,也不要让模型在关键时刻出现 truncated 输出。
2.2 摘要触发的条件
摘要不是每次请求都去生成,那样既慢又贵。context-mode 设了两个触发条件:一是当前消息组估算 token 数超过活动窗口预算,二是累计新增的对话轮次超过预设阈值。两个条件触发任意一个,就对“最早的那段活动窗口”执行一次摘要合并,然后把摘要结果放入摘要区,窗口尾部的新消息不动。
这样设计的好处是压缩动作单次影响面小,不会出现一次压缩把所有记忆都抹平的情况。实际运行几轮后,摘要区会变成一个层层叠加的压缩链:第一轮摘要只覆盖最早几十条消息,第二轮摘要会把第一轮摘要和后面新增的消息再合并一次。链条可以很长,但只要每次压缩时都带上原始消息片段,失真就不会无限累积。
2.3 为什么暂时不需要向量库
很多朋友看到“分层存储”四个字,第一个反应就是上向量数据库。我的建议是先把规模搞清楚再决定。对于一个单租户、长会话式应用,历史消息通常只有几百条,线性扫描和简单的关键词提取完全够用。向量检索的优势只在历史条目超过几千、需要跨会话语义查询的场景才能体现。context-mode 的第三层原始日志落盘格式足够简单,将来真的需要上向量库,也可以写个离线脚本做全量 Embedding 回填,不阻塞当前功能。
3. 具体实现:token 预算计算与压缩执行
接下来是动手部分。context-mode 的核心逻辑用 Python 实现,核心依赖只有两部分:tokenizer 计算库和 JSONL 读写。先看预算计算这一段。
import json import tiktoken MODEL_ENCODING = "o200k_base" # 根据实际模型选择对应 tokenizer ENCODER = tiktoken.get_encoding(MODEL_ENCODING) def count_tokens(text: str) -> int: return len(ENCODER.encode(text)) def count_messages_tokens(messages: list[dict]) -> int: return sum(count_tokens(json.dumps(msg, ensure_ascii=False)) for msg in messages)这里有个关键点:tokenizer 必须和模型实际使用的保持一致,否则估算会跑偏。如果你用的是 OpenAI 系模型,cl100k_base和o200k_base都有对应关系;换到其他家模型时,有的模型官方提供了兼容 BPE tokenizer,有的模型直接复用同一套编码。千万不要拿英文字符数当 token 数,中文场景下绝大多数 tokenizer 会把汉字拆成多个 token,文字长度和 token 数的关系远非线性。
压缩执行流程我设计成一个无状态函数,输入是消息列表,输出是压缩后的消息列表和本次是否发生了压缩的布尔值。
def compress_if_needed(messages, max_context, reserved_completion): available_budget = max_context - reserved_completion system_prompt = [m for m in messages if m["role"] == "system"] history = [m for m in messages if m["role"] != "system"] if count_messages_tokens(history) <= available_budget: return messages, False recent, older = split_by_minutes(history, cutoff_minutes=30) summary_result = generate_summary(older) # 调用摘要模型 if summary_result is None: # 摘要失败不能阻塞主流程,退回截断 older = older[-20:] compressed = system_prompt + [{ "role": "system", "content": f"[context-mode summary]\n{summary_result}" }] + recent return compressed, True上面代码里split_by_minutes做了一个按时间切分的操作:30 分钟内的消息全部进入活动窗口,超过 30 分钟的消息变成摘要候选。这个时间窗口值不固定,我按业务场景调过好几次,最终发现对客服机器人设为 30 分钟比较合理,对 10 分钟一轮快速讨论的场景则改成 15 分钟,太短会频繁压缩,太长又省不了多少 token。
摘要调用本身我会额外加一个instruction字段,要求模型只输出结论和约束,不要复述过程:
请把下列对话压缩成两段:第一段是用户提出的关键需求及约束,第二段是已经确认的结论和待办。 不要出现猜测性内容,不要加入原文没有的信息。这里必须强调一个坑:摘要结果放进system角色,而不是user或assistant角色。如果放进assistant,模型会把摘要视为前一轮回答的延续,在生成新回答时容易用“我刚才说过”的口吻复述摘要;放进system后,摘要就变成了固定的元信息,角色冲突少很多。
3.1 预算分配的实际计算示例
假设模型上下文窗口是 32k token,设置如下:
max_context_tokens = 20000reserved_for_completion = 4000system_prompt = 800
那么当前请求最多可以有20000 - 4000 - 800 = 15200token 用于历史消息。组装消息时,从最新的一条开始向前累加,累加到 13000 左右就停,留出 2000 的余量给避免边界抖动。超过部分的旧消息进入摘要候选。这个“留出 10% 余量”的习惯是从一次线上事故学来的——当时差 23 个 token 就触发截断截掉了模型输出的一半,后来我把余量从 0 改成了 2000,就再没出现过类似问题。
3.2 原始日志的 JSONL 落盘格式
每一轮请求结束后,context-mode 会把组装前的完整消息列表追加到日志文件:
{"ts": 1719292800, "session": "s_01a", "type": "request", "role": "user", "content": "把上一轮的结论整理给我"} {"ts": 1719292812, "session": "s_01a", "type": "request", "role": "assistant", "content": "上一轮我们确认了三件事:..."} {"ts": 1719292820, "session": "s_01a", "type": "summary", "content": "用户要求整理结论,已完成三个事项的复述"}日志文件按小时轮转,分目录存储。这样设计不是为了花哨,而是为了排查“上下文污染”问题时可以完全重放历史。曾经出现过一次很刁钻的问题:压缩摘要里包含了错误日期,模型基于这个日期做预约提醒,连续错了三次。靠日志回放我才能定位到是摘要生成时的 format prompt 写坏了,而不是模型本身的问题。没有原始日志,这种问题几乎无解。
4. 接入 context-mode 后踩过的坑:你以为缩完了其实没缩
任何方案在纸上看着合理,落地时都会有意外。这一节我把实际运行中踩过的三个比较深的坑完整记下来,每个都花了不少时间去定位。
4.1 摘要被模型当成了“用户说的话”
某次排查时发现,接入 context-mode 后机器人偶尔会把历史摘要里的内容拿出来“复述确认”,语气就像在引用用户原话,但用户其实根本没说过。比如摘要里写着“用户提到希望下午三点前完成”,模型会在下一轮回答里说“您刚才说希望下午三点前完成,我记住了”,可用户实际只在很早之前暗示过一个模糊的时间。
原因定位到三个细节。第一,历史摘要和当轮回话连在一起时,没有做显式的角色区分;第二,摘要 prompt 允许模型带着主观语气输出;第三,原对话里缺失的角色字段让模型不得不自己“脑补”来源。修复方式也直接:摘要一律以system角色注入,且用<summary>XML 标签包起来,prompt 里明确写“下面的内容来源于第三方压缩器,不代表用户原话,回答时不要引用对方原话”。加上这两句话之后,复述现象基本消失。
4.2 压缩边界抖动导致同一问题问两遍
我一开始的压缩逻辑是超过预算就压缩,压缩完再把新请求追加进去。结果发现一个高频现象:用户连续追问同一个问题,模型第一次回答后消息超了预算被压缩掉,第二次追问时组装出来的历史里只剩摘要,摘要里没有记录第一次回答的具体内容,于是模型就当成了新问题回答。用户侧感知是“我重复问了一次,机器人又重复答了一遍”。
这个问题的根因是压缩后没有把最近一次完整回答保留下来。摘要虽然是高层信息,但用户关心的具体执行结果往往压在很底下的细节里。修复方案:压缩执行时强制保留最近几轮完整消息,至少 6 条原始消息不可被摘要替换,同时摘要里增加“最近一轮回答的结论摘录”。配置项是keep_recent_messages: 12,也就是最近 12 条消息永远不入摘要区。
4.3 工具调用结果逃出压缩范围
这个机器人还接了不少工具调用,比如查订单、查物流。工具返回结果经常很长,一次查询可能产生几千 token。刚接入 context-mode 时我没把工具结果单列处理,结果压缩器把工具结果当成普通消息一起压缩。工具结果被压缩后会发生一个很严重的事:模型看到的是“摘要说查询成功”,但不知道具体订单号,后续所有引用都是在拿一个残缺的摘要做推理。
处理方式是给消息类型增加一个tool_result标记,压缩逻辑里对这类消息做了特殊规则:如果单条工具结果超过了预算的 20%,直接截断工具结果内部的详细字段,优先保留核心识别码和状态字段;最近 3 条工具结果永远不压缩,保证当前任务所需的关键返回数据是完整的。
我把三类消息的处理策略汇总一下,后面配置时可以直接照抄:
| 消息类型 | 是否可压缩 | 特殊规则 |
|---|---|---|
| system | 否 | 永远原样保留 |
| user | 可压缩 | 保留最近 12 条 |
| assistant | 可压缩 | 保留最近 12 条 |
| tool_result | 部分可压缩 | 核心字段保留,详细内容截断;最近 3 条不压缩 |
4.4 摘要质量监控:给压缩器也上一道保险
压缩器本身也是大模型调用,它也会犯错。我遇到过一个案例:摘要模型在压缩一段关于价格调整的讨论时,把“上调 3%”写反成了“下调 3%”,后续三轮业务回答全部基于这个错误成本计算。等发现时,机器人已经发了好几条带错误价格的消息给客户。
现在 context-mode 里加了一道轻量校验:摘要生成后,会把摘要和原始对话的“关键数字差”做一个简单对比,凡是检测到数字变化超过阈值,就丢弃这轮摘要,保留原文进入下一轮压缩。这个校验不能完全防住推理性错误,但至少能拦住绝大多数数字级故障。我的经验是,摘要模块的 failure mode 必须独立处理,不能让它和主请求共用一个 try-except 就直接吞掉异常。
5. 生产环境里我最终落地的配置与边界
最后这部分分享一套可以直接用的配置,以及一些关于“什么时候不该用 context-mode”的判断。
示例配置(Python dict 或 JSON/YAML 均可):
context_mode: model: qwen-plus tokenizer: backend: tiktoken encoding: o200k_base limits: max_context_tokens: 20000 reserved_for_completion: 4000 system_prompt_tokens: 800 keep_recent_messages: 12 keep_recent_tool_results: 3 history_available_budget: 15200 compression: trigger_min_history_tokens: 12000 trigger_interval_messages: 8 summarize_instruction: "compress_to_conclusion_and_constraints" retry_on_failure: false fallback_keep_recent_messages: 20 log: raw_event_dir: "./log/raw" compress_event_dir: "./log/compress" rotation_minutes: 60 monitor: compression_count_enabled: true token_under_estimate_alert_ratio: 0.15这些参数的核心意图是:限制开得保守一点,压缩触发得早一点,最近消息保留得多一点。宁可牺牲一些 token 效率,也要保住对话的连续性。压缩次数我每天都看监控,正常情况下一个活跃会话一天触发的压缩次数在 5 到 12 次之间,如果这个数字超过 20,就说明活动窗口配小了,模型会在短对话里反复被压缩,体验反而变差。
5.1 何时不要启用 context-mode
有三类场景我明确建议不做上下文压缩。
第一类是单轮工具调用,每次请求只处理一个独立任务,根本不需要跨轮记忆,加 context-mode 反而会引入不必要的延迟和摘要成本。第二类是强实时数据查询类对话,比如用户连续问几个不同订单的物流状态,历史上下文价值极低,每次新建独立对话即可。第三类是需要在多个历史片段之间做精确对比的场景,比如“把这次和上次的合同条款并排比对”,这种需求必须保留完整原始文本,摘要层的压缩会丢失做精确比对的必要细节。
判断标准就一句话:模型回答时如果主要依赖“最近一轮的准确细节”,就不该压缩;如果主要依赖“已经聊定的结论和约束”,就适合压缩。前者多半发生在单任务业务里,后者则常见于长时间项目的持续协作。
5.2 再补一条我绕了很多路的经验
上下文管理最忌讳把“压缩”做成“吞信息”。如果你在摘要 prompt 里不明确要求“保留用户的所有约束条件和明确数字”,模型整体倾向是保留概括性描述,丢掉细颗粒度事实。所以我在summarize_instruction里永远放着一条硬性规定:“所有数字、日期、产品型号、用户明确表态的否定词,必须原样保留在摘要中。”这条规则花费的成本几乎为零,但拯救了我很多次。
另外,压缩过程一定要设一个独立的超时和容错。摘要模型调用如果失败,我的策略是跳过这次压缩,走fallback_keep_recent_messages: 20的临时截断方案,保证主请求仍然能发出,只是遇到更长上下文时可能被截断,但至少不会因为压缩失败导致整条链路中断。等你发现摘要模型频繁失败时,再去排查具体的网络或模型问题,而不是让用户请求一起陪葬。
context-mode 在我的项目里从星期实验变成常驻服务,前后也就一个月左右。它没有让模型变得更聪明,但让模型在长对话里保留住了应有的准确性。现在团队里其他接入方聊起这层逻辑,最常说的一句话是:“原来让模型记住对话,不是一个模型该单独扛的事。”确实如此,上下文这件事,放在应用层解决,比放在模型层解决更可控、更省钱,也更好排查。后面我们还在做跨会话记忆和摘要质量可视化监控,等跑稳了再拿数据出来分享。