恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
大模型对话上下文管理:Token预算与三层压缩机制实践
首页
资讯中心
/
大模型对话上下文管理:Token预算与三层压缩机制实践
大模型对话上下文管理:Token预算与三层压缩机制实践
发布时间:2026/10/8 21:32:31
上个月我把一个跑在讨论群里的答疑机器人从“单轮问答”升成了“可记忆的多轮对话”前两天的效果让我很兴奋第三天白天就开始出事了。用户连续追问了几轮之后日志里开始频繁出现 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_asciiFalse)) 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_minutes30) 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角色注入且用summaryXML 标签包起来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 在我的项目里从星期实验变成常驻服务前后也就一个月左右。它没有让模型变得更聪明但让模型在长对话里保留住了应有的准确性。现在团队里其他接入方聊起这层逻辑最常说的一句话是“原来让模型记住对话不是一个模型该单独扛的事。”确实如此上下文这件事放在应用层解决比放在模型层解决更可控、更省钱也更好排查。后面我们还在做跨会话记忆和摘要质量可视化监控等跑稳了再拿数据出来分享。