Agent长会话崩了?一文拆解上下文压缩Compaction原理与实战调参
我手上有个客服Agent刚上线那几天效果很惊艳结果跑到第四天开始频繁翻车。查会话日志的时候发现一个规律出问题的全是超过20轮的对话模型在第15轮之后就开始“失忆”——前面确认过的订单信息转头就忘工具调用链跑到一半突然回头问用户一个早就回答过的问题。最气人的是token账单也在翻着倍地涨。后来我在上下文管理模块里加上了Compaction上下文压缩才把长会话的稳定性救回来。这篇文章是Pi-Agent源码解析系列的第四篇核心就是拆解它的Compaction模块为什么需要压缩、压缩哪些内容、压缩完怎么回填以及我在实际调它的时候踩过的坑和总结出的参数经验。如果你也在做自己的Agent或者正被“上下文一长就崩”折磨这篇文章应该能给你一份直接能抄的答案。1. Compaction在解决什么问题先说结论Compaction解决的问题不是“模型记性差”而是“喂给模型的文本太长”。模型不是人类它不会主动记住什么、忘记什么它只处理你喂到上下文窗口里的内容。窗口是死的对话是活的活的东西总会把死的东西撑爆。1.1 窗口不是无限桶装不下就是装不下任何一个Agent最终都要把对话消息拼成一个列表喂给模型。这个列表的长度由上下文窗口决定窗口满了常见的兜底方案是“丢最早的消息”。但简单截断有一个致命问题截断是物理删除模型根本不知道你删了什么。举个例子。用户在第3轮说过“我不吃辣所有带辣椒的推荐都不要”第12轮你推荐了一家川菜馆——如果第3轮的消息已经被截断模型根本不知道自己在犯忌讳。更隐蔽的情况是工具调用链第8轮某个工具返回了一份很长的JSON里面有一个订单号后面所有操作都依赖这个订单号。如果这段历史被截断后面的工具调用就会拿着一个不存在的ID去查数据一路错到底。窗口满不满并不是等到报错才知道的。很多模型的错误信息是“maximum context length exceeded”可实际上在它报错之前质量已经悄悄下降了。你看到的是模型开始说胡话看不到的是上下文已经挤在悬崖边上。1.2 长上下文的隐性成本费用、延迟、质量三杀窗口不够有人会说换更大的模型不就行了这话只对了一半因为哪怕窗口够大后面还有三道坎。第一道坎是费用。现在大模型API大多按token计费输入和输出分开算。同样一轮对话上下文从4K涨到32K输入费用直接涨了将近8倍。一个每天跑几千次调用的Agent服务这个差距就是肉眼可见的账单。你做一个客服Agent本来一单的成本几厘钱用户多聊几轮成本可能直接翻倍。第二道坎是延迟。模型的prefill阶段跟输入长度强相关输入越长首字返回越慢。用户问一句“现在几点”结果十几秒才收到回复开头这种体验基本等同于服务不可用。我见过有的Agent为了省费用把上下文压得很低结果用户问什么都等半天最后流失了那点token钱实在不值一提。第三道坎是质量。业界关于长上下文有一个叫做“中间迷失”的共识模型对输入开头和结尾附近的内容关注度远高于中间段落。对话历史一旦长起来关键信息恰好卡在中间位置模型就非常容易“选择性失明”。开会做会议纪要的时候最重要的结论如果写在第三页中间翻纪要的人也很容易漏掉——模型跟你一样。1.3 Compaction和截断、摘要的区别既然截断不行那直接做摘要总行这里就要区分三个概念简单截断、文本摘要、Compaction。简单截断是物理删除最早的几条消息代价是丢失所有早期信息。文本摘要站在“总结全文”的角度输出的是一篇通顺短文但它很可能把“上一步返回的结果里有用户需要的订单号”这种看似不起眼、实际要命的内容过滤掉。Compaction的视角完全不同——它不追求文笔不追求剧情完整只看一件事后续任务还需要什么信息。说得直白一点Compaction更像“信息筛选”保留未来要用的事实和状态丢掉当下已经没价值的对话噪音。这也是为什么我会把Compaction单独当成一个模块来做而不是调一个summarize接口就完事。摘要交给通用模型压缩交给有明确筛选指令的Agent模块两者产出完全不同。2. 核心设计压缩的本质是信息筛选很多Agent项目的Compaction做不好不是prompt写得差而是压根没想清楚“什么该留、什么该丢”。这一章我按照Pi-Agent实现里隐含的设计逻辑把这个问题彻底拆开。你可以在脑子里想一个开饭馆的场景客人不断进店桌子只有那么大。前面的客人吃完之后服务员会把盘子收走但收走之前会在账本上记下“3号桌不吃香菜”“7号桌点了红烧肉还没上”。收盘子是压缩记账是信息保留。没有账本的压缩跟直接倒掉剩菜没有区别。2.1 那些绝对不能丢的信息结合Agent多轮对话和工具调用的场景有四类信息属于“打死也不能丢”的第一用户的原始目标和约束。比如“帮我订一张周五下午从A市到B市的高铁票靠窗”。关键词是“靠窗”后面所有推荐座位都不能违反这个约束。如果压缩模型把“靠窗”写成“偏好窗边座位”也许还能理解如果写成“用户需要高铁票”那后面推荐的任何座位都是碰运气。第二工具调用链的中间状态。比如已经查了车次、选了车次、正准备提交订单此时压缩掉“已选定车次”这个状态模型会重新回到起点甚至重复下单。这个问题的恶心程度在于光是重复查询还能忍重复下单就是事故了。第三已经失败并排除的方案。Agent在找票过程中尝试过某个查票接口但失败了如果不记录下来后续可能再次调用同一接口白等一圈甚至触发同样的错误。你不需要每次都重新验证一个已经被否掉的选项。第四需要记住的用户事实。比如用户说“我一般周末才处理工作邮件”这类偏好不会再次出现在最近几轮里但会影响整个会话的后续回复风格。丢了它模型不会崩但回复的“人味”会逐渐消失。我把这四类信息整理成了一个速查表压缩完拿它对照一遍基本不会漏信息类别举例丢失后果用户目标与约束靠窗、预算上限、不用某平台后续推荐全部跑偏任务状态已选定车次待提交订单重复操作或回退到起点失败记录某接口不可用、某参数报错重复踩坑浪费工具调用用户偏好周末不处理工作邮件回复风格不对味2.2 该扔掉的“对话噪音”反过来哪些信息是Compaction可以大胆丢弃的我也列三类。第一寒暄和无意义对话。用户开场说“你好”“谢谢”“在吗”这种压缩掉完全不影响任务留着反而浪费token。第二重复的中间推理。Agent有时候会在一个问题上绕好几轮模型自己重复解释同一个概念这些过程性输出对后续毫无价值属于“自己跟自己聊天”的部分。第三已经过期的临时信息。比如用户问“今天天气怎么样”查完用完就完事了天气数据本身不需要留在上下文里。又比如用户中途给过一个地址后来自己又说“地址写错了改成另一个”那旧地址就应该被当成废纸扔掉。这里要特别提醒一句什么算“噪音”不能靠关键词匹配来判断得靠“这段信息在后续还有可能被引用吗”来判断。这也是为什么Compaction必须用LLM来做而不是写个规则去过滤。规则能过滤掉“你好”但过滤不了“用户在第8轮提到了一个地址第15轮发现写错了所以第8轮的旧地址已经无效”这种需要逻辑推断的场景。2.3 压缩指令的写法让模型替你当秘书明确了要留什么、丢什么接下来就是给压缩模型下指令。我给一个我实际在用的压缩指令模板风格和Pi-Agent里那段系统提示基本一致系统指令你是一名对话历史压缩器负责把冗长的对话记录整理成一份对未来任务足够用的信息摘要。这不是剧情总结而是信息提取。输出时必须覆盖用户已经表达的目标、偏好、显式约束已经完成的工具调用及其关键结果正在进行的任务状态、待办事项已经失败并排除掉的方案后续步骤可能依赖的任何数据订单号、ID、地址、日期等必须原样保留不能改写不要输出寒暄、客套、与当前任务无关的闲聊模型自己的重复推理和过程性解释已经失效或被用户纠正过的旧信息这套指令的核心是给压缩模型的注意力画了一条线往信息提取的方向走而不是往情节复述的方向走。实际跑下来加了“原样保留ID和数值”这一条之后后面工具链断裂的情况明显变少了。为什么因为摘要模型天生倾向于把技术细节“翻译成人话”直接把订单号写成“订单信息”但后续代码里要找的偏偏是那个订单号。2.4 摘要回填的位置决定了成败压缩完了摘要放哪这个问题很多人不注意结果摘要写得很完整回填之后模型照样“失忆”。原因是把摘要塞进了user消息里。user消息在模型眼里代表“用户本轮说了什么”你塞一段第三人称的摘要进去角色上下错位模型会把摘要当成用户新提出的奇怪要求。正确姿势是把摘要放到system消息里让模型把它当作“当前对话环境的背景设定”。压缩后的消息队列长什么样我用一个实际跑通的购物场景来演示压缩前 [system] 你是购物助手帮用户筛选商品并下单。 [user] 帮我找一款500元以内的蓝牙耳机平时通勤用。 [tool] 返回商品列表共3款含价格、型号 [assistant] 推荐了3款询问用户偏好 [user] 我不喜欢入耳式的有没有非入耳的选择 [... 后续多轮筛选和对比 ...] [user] 那就选P001吧。压缩后 [system] 你是购物助手帮用户筛选商品并下单。 [system] [历史摘要] 用户目标500元以内的蓝牙耳机通勤用排除入耳式。已完成从推荐商品中筛选当前候选为P001459元和P003499元用户对P003的佩戴方式存在顾虑。当前状态用户刚表达倾向选择P001等待确认下单。 [user] 那就选P001吧。看到区别了吗用户那句话说“那就选P001吧”之前的所有消息user、assistant、tool全部退场只留一个system级别的摘要块。模型看到它就像拿到了一份前程序员留下的交接文档可以直接接着往下干活。3. 源码核心路径拆解聊完设计思想接下来看代码。Pi-Agent的Compaction模块不算复杂核心可以拆成三块CompactionConfig负责配置Compactor负责执行压缩ContextManager负责在每次调用前决定“要不要压缩”。我按照常见的开源实现方式还原一版关键代码结构方便对照阅读。3.1 触发机制token数怎么算阈值怎么定Compaction不能每次对话都跑那样太浪费了必须有一个触发条件。最常用的触发条件是历史上下文预估token数超过某个阈值。token数的估算真实项目里一般用tokenizer精确算。常见的做法是通过tiktoken这类库import tiktoken def count_tokens(messages: list[dict]) - int: encoder tiktoken.encoding_for_model(gpt-4o) total 0 for msg in messages: total 4 # 每条消息的角色标记等固定开销 total len(encoder.encode(msg.get(content, ))) return total 2如果担心tokenizer太慢也可以先按字符数粗估等真要发起LLM请求前再精确计算。我实测下来对一个几十条消息的列表调用tokenizer耗时基本可以忽略所以直接精确计算反而省心。触发逻辑的核心是ContextManager每次在调用LLM之前做一次检查def prepare_messages(self, user_input: str) - list[dict]: self.messages.append({role: user, content: user_input}) self.maybe_compact() return self.messages def maybe_compact(self): if not self.config.enabled: return current_tokens count_tokens(self.messages) if current_tokens self.config.max_history_tokens: self.messages self.compactor.compact(self.messages)这个检查非常便宜就是数一下token每轮调用前跑一次完全没负担。不要等到LLM报错再处理错误发生了就已经晚了。3.2 核心流程筛选、调用、回填Compactor.compact是真正干活的函数流程分三步拆分消息、调用压缩模型、重组消息队列。核心实现如下class Compactor: def __init__(self, llm_client, config: CompactionConfig): self.llm llm_client self.config config def compact(self, messages: list[dict]) - list[dict]: system_messages [m for m in messages if m[role] system] event_messages [m for m in messages if m[role] ! system] # 保留最近N轮更早的全部进入压缩 if self.config.keep_recent_messages 0: history_part event_messages[:-self.config.keep_recent_messages] recent_part event_messages[-self.config.keep_recent_messages:] else: history_part event_messages recent_part [] if not history_part: return messages # 对历史部分做摘要 summary self._summarize(history_part) # 用system摘要 最近N轮重组消息队列 new_messages system_messages.copy() new_messages.append({ role: system, content: f[历史摘要]\n{summary} }) new_messages.extend(recent_part) return new_messages def _summarize(self, history: list[dict]) - str: rendered render_messages(history) resp self.llm.chat.completions.create( modelself.config.compact_model, temperature0, messages[ {role: system, content: COMPACT_SYSTEM_PROMPT}, {role: user, content: rendered}, ], ) return resp.choices[0].message.content这段代码里几个细节值得标注第一system消息永远不会进入压缩池。因为它本身已经承担了角色设定和指令功能动它是在自找麻烦。第二压缩是针对事件消息做的user、assistant、tool三种角色一起进提炼机不做“谁更值得保留”的区分。好处是逻辑简单坏处是tool结果里的关键数据可能被摘要模型遗漏所以压缩指令里要反复强调“原样保留数据”。第三temperature设成0。压缩任务容不得随机发挥同一段对话每次压缩结果应该尽量一致——温度太高就会随机丢信息这是我实际吃过亏的地方。3.3 保留窗口与消息篡改的边界为什么要有keep_recent_messages这个参数因为再好的摘要也有信息损耗而最近几轮对话本来占不了多少token与其让摘要模型转述一遍不如直接保留原文让模型看到“新鲜”的上下文。这个设计与缓存系统的思路很像——最热的数据放内存冷数据落磁盘。保留窗口就是“热数据区”压缩历史就是“冷数据区”。窗口参数怎么定我给一个经验值对多数任务保留最近8到12条事件消息比较稳。太小的窗口比如2到3条会把关键的工具结果一起压掉模型拿着被压过的摘要去推理经常把“结果里的某字段”记成另一个字段太大比如50条又失去了压缩的意义因为热点区域本身已经塞满了窗口。还有一个容易踩的坑keep_recent_messages计算的是“事件消息条数”不是“轮数”。一次工具调用链往往要产生user提问、assistant调工具、tool返回结果、assistant再总结这四条消息“最近10条消息”实际只有两三轮对话。所以如果Agent要跑长工具链保留窗口条数要适当放大不然刚压完下一轮工具结果又被压进去了。4. 实测效果与调参参考代码捋完之后关键是参数怎么调。这一章把我实际跑过的几个配置和现象整理出来仅供参考。参数没有绝对标准必须结合自己的场景微调但至少可以给你一个起步的区间。4.1 阈值设多少才不亏max_history_tokens设多大是个性价比问题。设小了对话才十轮就触发压缩白白花一次摘要调用的钱设大了上下文长期处于高压状态容易在压缩前最后一轮撞到窗口上限直接报错。我的经验值是max_history_tokens设置成模型窗口的1/3到1/2之间留下足够余量给最近一轮的回复生成和工具结果。如果模型窗口是32K阈值设在8K到12K窗口是128K可以往32K以上走但尽量不要超过一半。这样即使消息已经在阈值边缘模型也还有空间生成一条比较长的回复不会因为回复长度超限而半路失败。我测试过几个配置感受最明显的是阈值设到窗口的80%以上时虽然触发次数少了但每次触发前的那一两轮对话质量明显下降模型开始前言不搭后语。把阈值降到50%之后压缩更频繁但整体回复稳定性好了很多。多花的那几次摘要调用费远小于用户流失带来的损失。4.2 增量压缩与全量压缩的取舍前面源码里展示的Compaction实现是“全量压缩”历史全部丢进摘要模型一次性出结果。全量压缩的优点是简单摘要一致性好缺点也很明显——会话越长每次压缩的输入越长重复压缩的成本越高而且摘要会越写越长最后摘要本身也会成为新的上下文膨胀源。更优雅的做法是“增量压缩”每次只把新增的一段历史合并进旧摘要里生成新摘要而不是每次从零开始抄一遍账本。用记账来类比全量压缩是“重新抄一遍整个账本”增量压缩是“在旧账本上追加新记录”。两种方式我做过对比方式优点缺点适合场景全量压缩实现简单、摘要一致性好历史越长成本越高多次压缩信息易损耗短会话、触发次数少增量压缩每次成本低、响应快摘要质量依赖上一次摘要误差会累积长会话、高频触发你可能会觉得增量压缩明显更优但我实际用下来发现它有一个隐患压缩模型会在旧摘要基础上反复改写措辞慢慢偏离原始事实最后摘要看起来信息完整实则数字和ID已经被改得面目全非。因此我在项目里的最终方案是混合策略——默认增量压缩每跑完20轮或者当摘要本身超过阈值的一半时做一次全量“复核压缩”把历史摘要和原始记忆拼在一起重新整理一遍。这样既控制成本也防止误差滚雪球。4.3 不同任务类型对压缩的敏感度Compaction不是对什么任务都友好。我实测下来对压缩敏感度最高的两种场景是代码生成与调试、多步骤数据操作。代码生成敏感高是因为中间变量、函数名、报错信息都是后续步骤的直接依赖摘要模型很容易把“这个变量叫什么”写丢或者把一个报错信息概括成“出了点小问题”——这对排查毫无帮助。多步骤数据操作敏感高是因为你容易在多次工具调用后把“已经做到哪一步”搞混到底订单提交了没有数据合并了没有全量压缩一压这个状态就模糊了。敏感度最低的是问答、闲聊、单轮信息查询这类任务上下文再长对回复质量影响也不大Compaction更多是在省成本而不是救质量。所以设计Agent时我建议不要默认给所有会话开启Compaction而是根据任务画像来决定。比如电商导购类Agent整个会话是“多轮筛选下单确认”必须开客服FAQ机器人单轮就能回答开了反而增加无谓延迟。5. 踩坑实录与排障技巧最后这一章全部是实战中踩过的坑。有些坑让我花费了两三天才定位到根因写出来希望能帮你直接跳过。5.1 压缩后“失忆”的常见原因现象做了Compaction之后模型时不时忘掉用户刚说的重要信息尤其是“不能做某事”这种否定约束。我排查了好几轮最后发现根本不是摘要的问题是保留窗口太小。对话末尾那几轮里有模型自己的长回复占据了保留窗口的条数用户最新的明确指令反而被丢进了压缩范围。压缩模型倒是把约束写进了摘要但摘要里的措辞总会有点偏差换个说法模型就理解偏了。解决方式很直接调整keep_recent_messages让它在条数上能够覆盖完整的一到两轮“用户提问—工具调用—结果返回—模型回复”周期。另外我加了保护逻辑包含tool结果的消息不做压缩候选宁可多留几条原文也不压掉工具结果。5.2 多轮压缩的信息衰减怎么控制Compaction跑到第二十次之后摘要会越来越“薄”。一开始摘要能写出“用户预算5K以内需要降噪耳机”后来慢慢退化成了“用户需要耳机”。这不是压缩指令变了而是每次压缩都是对上次摘要的再压缩压缩模型倾向于把句子写得越来越概括细节在一级级的转述中慢慢消失跟传话游戏一模一样。控制信息衰减的办法有两个。一是前面提到的混合策略定期用原始历史做一次全量复核相当于每隔一段时间把账本重新誊一遍。二是给摘要设置“最低信息密度”红线要求摘要中每个原始ID、数字、地址必须原样出现宁肯摘要变长也不要模糊表达。把这条写进压缩指令之后20轮以上的会话信息保留率明显提升。5.3 什么时候不要压缩最后一个经验反而容易被忽视有些场景应该直接关掉Compaction。第一类场景是用户有审计需求需要保留完整对话记录。有些行业要求原始消息全过程可追溯这时候任何程度的压缩都是违规操作。第二类场景是任务本身非常短两三轮就完事压根儿没有压缩的必要白白花一次摘要调用费。第三类是我在调试工具链时会临时关掉Compaction——因为要逐条核对工具返回的原始内容摘要帮不上忙还会把中间过程的细节过滤掉。自动化和可控性之间有时候得手动选一下。写了这么久的Compaction我个人最大的体会是这个模块看起来只是“多写一段摘要prompt”但它真正难在你想清楚“这段对话里到底哪些信息对未来有值”。很多Agent项目死在上下文的无限膨胀上而Compaction就是拿一次小规模的LLM调用把上下文成本和质量重新拿回来。如果你也在做长会话Agent我的建议是先把阈值和保留窗口调明白再谈加什么记忆、接什么向量检索——地基不稳上面盖什么都白搭。