多轮对话长上下文截断实战:Token管理与摘要压缩策略 多轮对话系统跑了一段时间之后最让人头疼的问题往往不是模型能力不够而是上下文“爆了”。明明单看每一轮回答都没毛病但对话一长要么请求直接报错要么模型开始“失忆”把之前确认过的事情答得牛头不对马嘴。这个问题我前前后后在好几个项目里踩过后来专门梳理了一套多轮对话长上下文截断的方法今天把这套思路和实现细节完整写出来给同样被上下文管理折磨的朋友做个参考。1. 多轮对话为什么要做长上下文截断1.1 不是“够不够用”是“越用越乱”先说一个非常现实的前提当前主流大模型都有上下文窗口限制常见的在 4k、8k、32k大窗口版本能到 128k、200k 甚至更大。窗口越大确实能装更多内容但多轮对话一旦放开用消耗速度远超直觉。用户每发一句话、模型每回一段内容都会作为新消息追加进上下文。假设平均每轮对话消耗 800 token十轮就是 8000二十轮就 16000再算上系统指令、检索结果、工具调用返回往往不到十五轮就把一个 8k 窗口撑满了。窗口满了之后有两种典型表现。第一种是直接抛异常提示超出最大上下文长度请求失败用户正聊到关键处突然断掉第二种更隐蔽不少服务端会静默丢弃最早的消息或者要求调用方自己处理结果模型后期缺少了早期关键信息回答质量明显下滑。无论哪种本质都是同一个问题上下文作为模型的“工作台”没有做主动管理。还有个容易被忽略的因素是成本。大模型 API 按 token 计费输入 token 与输出 token 分开计价长上下文主要是输入膨胀。一个 4k 窗口的请求和 32k 窗口的请求价格差异可能接近一个数量级。对话系统如果上线后不做截断控制每天的 token 消耗会非常可观到月底账单出来才意识到问题就晚了。1.2 截断不是“删数据”是“留最小有效信息”很多初做对话系统的同学一听截断脑子里首先想到的是把最早的消息砍掉。这个思路表面上没错但实际操作会发现它有个致命缺陷最早几轮往往埋着用户的核心需求。比如用户第一轮说“我要做一个项目管理系统主要管理软件开发团队的任务进度角色有项目经理、开发、测试”这句话是整个对话的锚点。如果十轮之后它被直接删掉模型后续就只能靠猜轻则理解偏差重则直接跑偏。所以我的核心观点很明确截断不是简单删数据而是做信息取舍。目标是让对话上下文在 token 预算内保留“最小有效信息”。什么叫有效用户核心诉求、关键决策、未完成的事项、约束条件这些都属于必须保住的已经执行完的工具返回明细、重复啰嗦的中间讨论、过时的临时内容这些属于可以被压缩或丢弃的。这句话说起来容易落地起来涉及两个关键问题一是怎么判断哪些信息有效二是用什么策略把有效信息保留下来。下面几个小节分别展开。1.3 先搞明白上下文到底是什么结构在讨论截断方案之前建议先统一对“对话上下文”的理解。现在主流大模型接口基本都接受一个消息列表每条消息有角色和内容通常按时间顺序排列。一个典型结构长这样system 消息设定角色、输出规范、约束条件通常优先级最高user 消息用户本轮输入以及历史输入assistant 消息历史回答内容tool 消息工具调用及返回结果有些场景也会有 function 消息截断的本质就是对这个消息列表做增删改。删是移除某些消息改是把某些消息替换成压缩版本增是插入一条摘要消息或者注入了外部记忆的内容。同一个对话里我们可以针对不同角色、不同时间、不同长度的消息采取完全不同的处理方式。这套思路比单纯“从头部删几条”要细腻得多处理效果也稳定得多。2. 五种常用截断方案与选型思路2.1 方案A最早丢弃又叫滚动窗口最早丢弃是最容易实现、也是很多项目第一版会采用的方案。维护一个固定长度的消息队列新消息进来就追加超出长度时把最旧的消息逐出。实现起来几乎没什么成本两三行代码就能搞定。它的问题也很明显。队列长度以“条数”为单位但每条消息真实 token 消耗差异巨大。一条 tool 返回可能是 2000 token一条普通用户输入可能只有 100 token。按条数截断上下文里可能残留大量长消息实际 token 依然超限按 token 数截断就得自己维护计数复杂度上来了。更尴尬的是直接丢弃早期消息等于在没有任何补偿的情况下删掉锚点。用户第一轮确定的偏好、任务背景、约束条件一旦滚动出窗口后面整个对话都缺乏支撑。我的看法是滚动窗口可以作为整个截断系统的“底座”但不能是唯一机制。底座负责兜底保证不爆窗口其他机制负责在丢弃之前先把重要信息抢救出来。2.2 方案B按消息类型优先级裁剪如果为每条消息定义优先级截断时从最低优先级开始删直到总 token 满足要求就比单纯按时间删靠谱得多。优先级可以根据角色和消息类型来定比如系统指令最高优先级永远不删、不压缩最近的用户输入与对应回答高优先级直接影响当前回答准确性中间过程消息用户补充信息、分步讨论中高优先级工具调用记录与返回结果低优先级用户关心的只是结果不是底层明细日志、调试信息、重复性内容最低优先级能删就删按类型裁剪的好处是符合直觉系统指令是模型行为的“宪法”绝对不能动工具返回是典型的高 token 低价值数据优先压缩或删除能节省大量空间。我实际跑过一个客服工单查询场景工具返回的工单详情平均 1500 token用户真正关注的只有“处理状态”和“负责人”其余字段全是冗余。把这类消息优先处理掉之后可容纳的对话轮数直接翻了接近一倍。不过这种方式也有弱点优先级是静态规则但对话价值是动态变化的。某一个“中间过程消息”可能在后面几轮被反复引用这时候它其实很重要。所以推荐的做法是消息进入上下文时打上类型标签与时间戳裁剪时先按类型粗筛再在同一类型内部按时间从早到晚处理程序逻辑更可控。2.3 方案C摘要压缩把旧对话“提炼”成一段话摘要压缩是我目前在真实项目里用得最多、也最推荐优先考虑的一种方案。思路非常直接对早期消息列表调用一次模型生成摘要然后用一段比较短的摘要替换掉整段旧消息。比如早期 30 轮内容总共 5000 token用摘要提炼成 500 token 塞回上下文信息保留度能做到七八成以上。摘要的实现并不复杂但有几个细节需要注意。第一个是触发时机没必要每隔几轮就摘要一次那样既浪费 token 又可能打断上下文连贯性。我比较习惯的做法是当上下文长度超过阈值的 70% 时触发如果对话轮数增长很快也可以在“超过 N 轮且剩余空间不足”时触发。第二个细节是摘要的对象划分。不要把整个历史一次性摘要最好是按“段落”切。比如把每 5 轮对话作为一个 segment独立生成摘要标记为摘要信息。再往后如果窗口依然紧张再对多个旧摘要做二次摘要。这样做的原因是对话主题往往分阶段变化一次性粗暴摘要会把不同阶段的关键信息揉在一起反而丢失细节。第三点是摘要也要保持消息的角色语义。用一个占位的 system 或者 user 消息承载摘要内容是比较自然的做法摘要本质上不是用户说的原话而是系统对历史对话的提炼用 system 角色更合理。2.4 方案D关键信息固化把“锚点”单独存出来摘要压缩能保留大部分信息但它是“压缩”不是“结构化”。实际操作中我发现有些关键信息值得被单独抽取出来长期保存不随摘要波动而丢失。比如用户一再强调的偏好、最终的决策结论、不能触碰的约束这些信息如果混在摘要里摘要一生成可能就弱化了。关键信息固化的思路是每次完成一轮对话后对新增内容做一次轻量抽取识别出“实体、偏好、约束、待办事项”维护成结构化的记忆对象。下次组装上下文时把系统指令、记忆对象、最近对话、必要摘要按顺序放进去。举一个典型例子。用户在对话早期说“不要用专业术语我基础比较弱”如果这句话在一段摘要里只被概括成“用户对语言风格有要求”那就丢失了具体的表达倾向。而关键信息固化会把“语言风格避免专业术语期望通俗易懂”单独存进记忆字段之后每一轮系统消息都会带上这条信息模型就不会再犯专业术语过多的问题。我把这四种方案放在一个对比维度里看它们不是互斥关系而是互补关系。滚动窗口是底座类型优先级是过滤规则摘要压缩是容量腾挪手段关键信息固化是防止重要信息衰减的保险丝。实际系统里我通常组合使用完整流程放在下一节细说。2.5 方案选型速查方案实现成本信息保留能力适合场景主要风险最早丢弃极低弱对话轮数少、早期信息无价值的场景早期锚点丢失固定长度强制截断低中token 约束极严格、临时演示系统长消息挤压有效消息按类型优先级裁剪中中上消息类型丰富、工具调用多的场景动态价值判断不准摘要压缩中高中上通用型多轮对话强烈推荐摘要质量波动、摘要过期关键信息固化中高连续性强的场景如助理类、销售类抽取逻辑复杂需要维护结构如果只让我选一套组合拳我的默认配置是按类型优先级裁剪 摘要压缩 关键信息固化底部再挂一个硬性的 token 上限兜底。这套组合能适应多数场景稳定性比单用任何一种都要好。生活化一点来理解桌面快堆满时先把快递盒工具返回扔掉再把旧合同早期对话扫进电子档案夹摘要重要的客户偏好关键信息贴到显示器边框上最后设一个“桌面最多放多少东西”的上限。逻辑清晰容错也高。3. 一个可落地的“分层截断器”实现3.1 定义消息结构与优先级规则我实现截断器的时候不会直接操作原始消息数组而是先做一个中间消息结构额外挂两个字段message_type 和 priority。message_type 表示这条消息属于哪种类型比如 system、user_input、assistant_reply、tool_result、summary、memorypriority 是一个整数数值越大越不可裁剪。下面是一个简单的 Python 数据结构用 pydantic 定义方便校验和扩展from pydantic import BaseModel from typing import Optional import time class Message(BaseModel): role: str # system / user / assistant / tool content: str message_type: str # system / user_input / assistant_reply / tool_result / summary / memory priority: int 5 # 数值越大越不可裁剪 timestamp: float time.time() extra: Optional[dict] None # 可放 request_id、工具名等信息每次构造对话上下文时我会先给每条消息打上正确的 message_type再根据类型算出 priority。系统指令、记忆类消息、最近两轮对话优先级最高中间过程其次工具返回和日志类优先级最低。这样可以保证整个系统在任何截断执行之前就有一套统一的判断标准。3.2 核心类设计我建议把截断逻辑封装成一个独立的类不应该跟业务逻辑混在一起。类名可以叫 LongContextTrimmer输入原始消息列表 最大 token 预算输出处理后的消息列表。核心结构大概长这样class LongContextTrimmer: def __init__(self, token_counter, max_tokens: int): self.token_counter token_counter # token 计数函数 self.max_tokens max_tokens # 硬性预算 self.summarizer None # 摘要生成器 self.memory_store None # 关键信息存储器 def add_summarizer(self, summarizer_fn): self.summarizer summarizer_fn def add_memory_store(self, memory_store): self.memory_store memory_store def trim(self, messages: list[Message], **kwargs) - list[Message]: messages self._inject_memory(messages) messages self._remove_low_priority_tool_results(messages) if self._count(messages) self.max_tokens: return messages messages self._summarize_old_segments(messages) if self._count(messages) self.max_tokens: return messages messages self._hard_trim_to_budget(messages) return messages这个流水线顺序是我反复调过多次的先注入记忆再清理低价值内容然后做摘要最后硬截断兜底。每一步都有可能直接结束不需要每次走到最后一步。3.3 关键逻辑拆解优先级裁剪、摘要触发、滑动窗口收尾优先级裁剪这一步核心是对 tool_result 做处理。我做了一个保守且有效的策略工具返回消息保留最近两轮的更早的全部删除但在删除前检查返回内容里是否有用户可能在后续对话中引用的关键数据如果有就把该关键字段改写为一条 summary 类型消息。比如工单查询返回了一堆字段我只保留“编号、状态、负责人、更新时间”这一段剩余字段全部清掉。也就是说把工具结果“提炼”而不是“整段砍掉”。摘要触发我用的是“剩余空间不足”逻辑而不是轮数逻辑。def _summarize_old_segments(self, messages): total self._count(messages) if total int(self.max_tokens * 0.7): return messages target_budget int(self.max_tokens * 0.5) old_msgs [m for m in messages if m.timestamp time.time() - CACHE_TIME_WINDOW] if not old_msgs: return messages for segment in self._split_into_segments(old_msgs, size5): if self._count(messages) target_budget: break summary_text self.summarizer(segment) new_msg Message( rolesystem, contentf[历史摘要] {summary_text}, message_typesummary, priority8 ) # 将摘要放回原位置保持相对顺序 idx messages.index(segment[0]) messages [m for m in messages if m not in segment] messages.insert(idx, new_msg) return messages摘要放在原位置这一点非常重要。如果把所有摘要统一放到最前面原始时间顺序就被打乱了。模型对时间不敏感但对顺序很敏感顺序一乱它很难判断哪些是最近发生的很容易产生逻辑混淆。最后一步硬截断走滑动窗口思路。从最旧的消息开始从低优先级到高优先级逐条删除直到 token 数小于预算。这一步不可能删到合理但能保证请求不会再超限。def _hard_trim_to_budget(self, messages): while self._count(messages) self.max_tokens: removable [m for m in messages if m.priority 10] if not removable: break candidate min(removable, keylambda m: (m.priority, m.timestamp)) messages.remove(candidate) return messages注意这里给系统指令和 memory 消息设了 priority10永远不可删除这也是硬截断的边界。如果整批消息都是 priority10 还超出预算那就需要升级处理比如强制压缩 system 指令但这属于架构层面的问题应该在设计期避免。3.4 参数与经验数值的取舍第一个关键参数是 token 预算。不要直接把模型上限当成预算要给响应输出留空间。如果模型支持 8k 上下文我会把 max_tokens 设为 6000 左右剩余 2000 留给输出。太贪心会导致请求直接超过真实上限。第二个参数是摘要触发的阈值。我习惯设置 70%也就是上下文占用超过预算 70% 时触发摘要。这个值不能太低太低会在对话中段就频繁做摘要白白消耗 token也不能太高太高则摘要来不及执行就可能爆窗口。70% 是我在多个项目里实测性价比最高的值。第三个参数是段大小。按 5 轮一个段生成摘要比较合适。轮数太少摘要内容太碎一条摘要消息可能比原文还长轮数太多摘要会严重失真。5 轮是将粒度与失真平衡得最好的经验值如果对话场景以长轮次为主也可以调成 3-4 轮。4. 实战中踩过的坑与排查心得4.1 坑1tokenizer 不一致数据“虚胖”做截断系统最基础的一步是 token 计数。很多开发者图省事直接用 len(text) 或者 text.split() 的长度当作 token 数结果发现实际请求总是超限。根本原因是大模型服务端的 tokenizer 跟普通字符串长度完全不是一回事。中文字符一个 token 可能覆盖 1-2 个汉字英文一个单词可能拆成多个 token代码片段的 token 密度也高。最好的做法是拿到对应服务商的官方 tokenizer。找不准对应版本时用一个通用 tokenizer 加 20%-30% 安全余量也行但上线前一定用真实请求日志校准一次。4.2 坑2摘要吞掉关键数字与条件摘要压缩最头疼的问题就是模型生成摘要时“想当然”地省略。用户说“预算控制在 5000 元以内超过 8000 元就不做了”摘要很可能变成“用户对预算有要求”直接把具体金额丢了。后续模型就会按没有约束的情况回答给用户推荐远超预算的方案。针对这个问题我把关键信息固化的优先级提得非常高。凡是出现数字、日期、名称、否定词、边界条件的句子都优先抽取到 memory不放进摘要。摘要再不准memory 里存着的硬约束不会丢模型组装上下文时这两块内容同时存在的效果要好得多。4.3 坑3截断打乱消息相对顺序有段时间我在线上环境发现模型经常答非所问排查日志发现是截断器把摘要统一插到了最前面后面的消息仍然按原始时间排但摘要与原始消息的先后关系乱了。模型读到摘要说“用户决定用方案A”后面原始消息却在讨论“要不要换方案B”它就会混乱到底最终定了哪个后来我调整了策略摘要消息插入位置严格遵循“被摘要消息在原列表中的位置”顺序问题就消失了。凡是涉及顺序调整的代码都要在日志里打上 before/after 对比肉眼看完以后再上线。4.4 排查心得日志与标签的必要性截断器在系统里相当于隐形操作者它每时每刻都在修改模型看到的内容。如果没有日志线上出了问题基本无从下手。我习惯给每次截断动作打一个结构化标签记录触发原因类型裁剪 / 摘要压缩 / 硬截断、裁掉多少条、占用多少 token、摘要后的消息 ID。调试的时候只要把这些标签翻出来马上就能定位是哪一步出了问题。另外建议在设计阶段就为截断器加一个开关。测试阶段全部关闭用完整上下文跑通流程上线前打开逐日对比开关前后的对话质量、请求成功率、token 消耗。这个对比数据能帮你判断当前截断参数是否合理也方便在质量与成本之间做权衡。提示无论截断策略设计得多好都不要忘记为系统留一个“强制失败”出口。预算再紧也不能把用户当前输入和系统核心指令裁掉。一旦发现无路可退宁可直接报错让上层重新规划也不能生成质量崩坏的回复。从我自己的项目经验来看截断器从来不是一次性开发完就结束的组件。每次换模型、换业务场景、换 token 计费方式都需要重新校准参数。比较稳妥的工作方式是在系统里把截断做成可配置项把 token 预算、摘要触发阈值、段大小、优先级表全部放到配置中心而不是硬编码在代码里。线上数据反馈一轮就能有针对性地调整参数而不是每次都要发版改动。最后再分享一个小技巧上线前准备一份固定测试对话集内容涵盖用户偏好、工具调用、长文本粘贴、修改历史指令等典型情况。每次调整截断逻辑后先跑一遍这份测试集看看哪些信息被正确保留、哪些被错误压缩、哪些被误删。这套回归测试看起来笨但确实是保证截断系统稳定性的最有效手段。