
1. Compaction机制要解决的根本矛盾做Agent开发的同行应该都有同感上下文管理是比模型能力更让人头疼的事。Pi-Agent这个项目迭代到第四期源码解析我特意挑了上下文压缩Context Compaction这个模块来讲因为它在整个Agent的长期稳定运行里起到的作用比大多数人想象的更关键。先说一个很直观的现场。一个Agent跑多轮任务调度前20轮对话状态都正常等到第30轮左右就开始出现“记忆力错乱”——要么是忘了前面已经确认过的用户偏好要么是把不同会话里的信息张冠李戴。你去查日志发现每次请求塞给模型的历史消息动辄一万多字符上下文窗口占用率长期贴着上限跑。这不是模型变笨了是窗口被撑爆了模型被迫在“近因信息”和“早期关键信息”之间做取舍而它通常会选择近因于是早期信息就“被遗忘”了。这个问题的本质是窗口资源是固定的对话历史是持续增长的两者之间的矛盾只能靠主动管理来解决。Pi-Agent的Compaction模块就是干这件事的——在对话轮次增长到临界点之前把对话历史做一次有损压缩让早期对话里真正有长期价值的决策依据、用户偏好、任务状态被保留下来把寒暄、临时性指令、重复性确认这类低价值内容舍掉。我在这个项目里主要做了三件事一是梳理清楚Compaction的触发链路和消息筛选策略二是反复调优摘要生成的模板与窗口上限的匹配关系三是在真实长会话场景下压测它的稳定性。这篇文章会把源码层面最关键的那部分逻辑拆开来讲同时把我在调优过程中踩过的坑一并写出来。适合正在做Agent记忆管理、或者被长对话“失忆”问题困扰的开发者参考。2. 为什么窗口上限和触发阈值要分层设计我开始看Pi-Agent的Compaction源码时第一反应是找它的触发条件结果发现这个模块没有简单写死“超过多少条就压缩”而是把窗口管理和压缩触发分成两套参数体系。这是非常成熟的做法值得先讲清楚。2.1 压缩触发与模型窗口的关系Pi-Agent里有两个核心常量一个是模型允许的最大上下文窗口常规配置下等效于模型的max_tokens上限另一个是Compaction的触发阈值默认是最大窗口的85%。这两者分开设计原因很实际你不能等窗口满到100%才动手压缩因为压缩动作本身也要消耗上下文空间——你需要先塞进一小段“压缩指令”再把摘要结果写回上下文。如果已经满到极限这条指令根本没地方放压缩动作就永远执行不了。85%这个阈值本质上是给压缩操作预留了约15%的缓冲地带。这部分空间除了容纳压缩指令本身还要容纳压缩期间的中间变量和输出token开销。换句话说触发阈值不是“窗口快满的警报线”而是“还能从容操作的最后时机”。源码里这个比例是通过一个独立的配置项控制的没有写死在逻辑里这意味着换不同窗口大小的模型时不用改代码逻辑调配置文件就行。2.2 消息按角色的权重取舍逻辑触发条件看够了还有个细节值得单独说不是所有消息在压缩时都被平等对待。源码里对消息做了一次遍历把对话历史分成三个等级核心决策类消息比如用户明确指定的任务要求、Agent输出的阶段性结论、常规问答消息、以及可以被安全丢弃的临时消息。我第一版实现时想过直接在字符串层面做长度裁剪后来发现这是错的。原因在于单纯按字符数截断会把一句“用户要求使用PostgreSQL存储”这种关键约束从中间劈开导致摘要结果语义缺失。源码里最终采用的是按消息实体为单位做筛选而不是按字符数做截断——这条经验后来帮了大忙。每个消息在存储时带有role标签system/user/assistant和消息序号Compaction逻辑按规则批量匹配system级别的基础指令永不压缩user和assistant的对话消息按重要度打分低于阈值的进丢弃队列高于阈值的保留进摘要。这个打分用的不是花哨的AI分类器而是基于关键词权重和消息长度的启发式规则。这样的分层设计给我一个很深的体会上下文压缩不是一个“写个循环截断字符串”的简单功能它本质上是对话系统的记忆管理工作。先确认触发时机再确认取舍规则最后才是动手执行摘要顺序颠倒任何一个环节压缩质量都会大幅下降。3. 核心数据结构与压缩主流程源码拆解源码拆解这块我建议从入口函数往下读。Pi-Agent的Compaction入口函数语义很直白核心思路可以简化为确认当前上下文占用率如果超过上限则选取待压缩消息集合生成摘要并替换掉原消息序列。3.1 消息列表切片与压缩候选区先看一段关键的数据结构定义消息列表本质上是一个有序数组每条消息有唯一的消息ID和角色标识。需要压缩时Compaction模块会取出一个时间窗口内的连续消息切片作为候选区。这个切片我称为compaction range。// 伪代码定义消息结构和压缩候选区 class Message { constructor(id, role, content, timestamp) { this.id id; this.role role; // system | user | assistant this.content content; this.timestamp timestamp; } } function selectCompactionRange(messages, currentIndex) { // 从当前消息往前回溯窗口大小 const startIndex Math.max(0, currentIndex - COMPACTION_WINDOW_SIZE); return messages.slice(startIndex, currentIndex); }这段伪代码对应源码里的核心思想不是每次压缩全部历史消息而是只压缩窗口内的一段。我见过一些项目图省事压缩时把从头到当前的所有消息全部处理一遍这在会话早期没问题会话拉长后开销会指数级增长。Pi-Agent选择固定大小窗口每次只压缩最近的一段兼顾了压缩效果和执行开销。3.2 摘要生成与替换的完整链路选定候选区消息后进入核心的摘要生成链路。这一步在源码里不是自己写摘要逻辑而是构造一段prompt模板把消息切片内容塞进去交给模型生成摘要文本。// 伪代码摘要生成与消息替换 async function compactMessages(messages) { const candidateRange selectCompactionRange(messages, currentIndex); const summary await callLLM({ model: CONFIG.summaryModel, prompt: buildSummaryPrompt(candidateRange) }); const summaryMessage new Message( generateUUID(), system, summary, Date.now() ); const compactedMessages [ ...messages.slice(0, candidateRange.startIndex), summaryMessage, ...messages.slice(candidateRange.endIndex) ]; return compactedMessages; }这段代码有一个容易被忽略的细节新的摘要消息被标记成system角色而不是assistant角色。这样做的目的是让后续模型在读取上下文时把这段摘要当作“事实背景”而非“对话内容”在注意力的分配上会有本质区别。对话内容会被模型视为与自身相关的过程性信息而system角色的背景描述则更多被当作既定事实来引用。源码里还做了个我没意料到的处理摘要替换完成后候选区里的原始消息并不是立即从存储里物理删除而是被打上truncated标记继续存在磁盘里。这个机制应对的场景是用户可能在后续对话中要求“回到刚才说的那个细节”此时系统可以回溯到原始消息做精确检索。代价是存储空间换容错性针对Agent类应用来说这个取舍是合理的。3.3 调用链路的异常兜底LLM调用天然可能失败网络抖动、token超限、模型返回格式异常。源码里对Compaction的LLM调用异常做了一层兜底逻辑如果第一次摘要生成失败系统会自动缩小候选区范围重试一次如果缩小后仍然失败则本次压缩放弃保留原始消息等下一次对话轮次再尝试。这个兜底设计第一次看会觉得激进——压缩失败居然不影响主流程直接放过去了。后来在线上的实际运行数据让我理解了这样设计的必要性Compaction属于背景任务它慢一点、晚一点完成不影响当前这轮对话的响应但如果压缩失败反而阻塞了对话主流程那才是真正的事故。这种“非关键路径故障应该尽量静默”的设计哲学在Agent工程里非常受用。4. 摘要质量不行的深层原因与处理策略源码逻辑跑通之后真正难啃的是摘要质量问题。这一步和代码逻辑关系不大反而和任务语言、prompt风格、token控制高度相关。4.1 摘要prompt的分段式结构设计Pi-Agent的摘要prompt不是简单写一句“总结以上对话”而是采用了分段式结构化指令。最前面是需要被总结的原始对话消息中间是四类抽取任务最后是输出格式的硬约束。四类抽取任务分别是用户明确表达过的偏好和限制、已经达成的决策结论、尚未完成的任务和待办事项、当前会话的时间线大纲。这个设计的思路很像做会议纪要——不是把全部发言复述一遍而是提取决定、行动项和关键背景。纯摘要生成的模型输出其实是在做“信息蒸馏”蒸馏的目标不是保留最多信息而是保留对后续对话最有用的信息。源码里这个prompt里还有个容易被忽略的细节它在结尾写死了输出格式要求摘要用分点列表呈现而不是长段落文本。这个约束直接影响后续对话上下文的可读性。长段落摘要被拼接进对话历史后模型需要花更多注意力去割裂文本分点列表则天然具有结构引导性能明显提升后续对话的响应准确率。4.2 摘要token上限与保留消息的平衡生成摘要时token不能无限长Pi-Agent里按最大窗口的25%作为摘要长度上限。这个比例是我反复测试后认为的平衡点——太短则信息丢失严重太长则压缩没有意义。实际操作时我需要动态计算候选区原始消息总长度和摘要上限的比例如果压缩比小于2:1就提示系统本段窗口不适合压缩。这个比例判断在源码里对应一个保护逻辑避免了“压缩完等于没压缩”的尴尬场景。我记得有一次压测时候选区里塞了大量代码片段每条消息字符数都很长但实际信息密度很低。摘要prompt掐头去尾之后勉强生成了一段两百字的总结结果后续对话模型反而搞混了上下文。后来调整了策略对代码类消息不做正文摘要只保留文件的路径和功能一句话描述。这个改动带来的摘要质量提升非常明显。4.3 摘要库与历史对话的一致性维护摘要生成后Pi-Agent的内部摘要库会同步更新。摘要库里按会话ID维护多份摘要历史。每次新摘要生成时不会覆盖旧摘要而是新开一条记录。这样做的好处是可以回溯任何时间点的上下文快照方便排查问题。但摘要库也会带来一致性问题如果后续对话里用户推翻了一个之前达成的决策摘要库里上一轮摘要仍然保留了旧决策的记录这两者在后续对话里同时存在模型可能产生混乱。Pi-Agent的解决思路是新摘要生成时会同步构建一条隐式的冲突消解指令放在摘要最前面明确标识“以下决策已经被后续对话更新”而不是在旧摘要上做修补。这个操作给我的启发是摘要不是数据库里的静态快照它应该被当作对话流里的一等公民和普通消息一样有版本、有状态、有冲突处理机制。5. 压缩时机与性能开销的实测数据复盘弄懂源码逻辑之后我花了两周时间在模拟项目X上做压测双路对话场景每路持续跑100轮以上观察Compaction的触发规律和模型响应变化。5.1 不同会话长度的触发频率按默认85%阈值运行长会话场景下Compaction大约每隔20到25轮触发一次每次耗时在800毫秒到2.5秒之间波动。耗时差异主要来自候选区消息长度和模型输出长度的差异。核心的20轮是用户从状态确认到信息收集再到指令下发的典型节奏压缩频率和对话节奏基本匹配。有趣的是在短会话高频场景下Compaction几乎没有触发过。原因是消息数量虽多但总token数远未达到触发阈值。这印证了一个判断Compaction应该由token占用率触发而不是由消息条数触发。消息条数只是表象token才是真正消耗上下文窗口的变量。5.2 压缩前后模型响应的质量变化压测时我在第30轮、第60轮、第90轮分别记录模型对同一组问题模板的响应准确率。数据如下表会话轮次未启用Compaction准确率启用Compaction准确率30轮92%94%60轮74%90%90轮51%88%数据最能说明问题。到第90轮时未启用Compaction的模型已经近乎“半失忆”而启用Compaction后准确率依然可以维持在88%上下。这个差距在真实业务场景里非常可观。5.3 清理时机对实时交互性能的影响Compaction执行期间对实时对话的影响是另一个需要关注的指标。消息替换操作是同步的在主线程里执行意味着压缩期间来新消息会排队等待。为了降低这个影响源码里用了小技巧把LLM摘要生成放到异步任务里拿到结果后再回主线程执行消息替换。这样模型响应耗时只受摘要生成耗时影响但消息入队延迟几乎无感。我实测下来2.5秒的压缩耗时在异步设计下对用户的体感影响微乎其微因为用户在这个间隔里通常还没来得及发出新消息。如果按同步方式设计2.5秒的卡顿在交互场景里已经算明显的等待了。性能调优上有两个经验我认为值得单独记录第一摘要模型和对话模型完全可以分离Pi-Agent默认用对话模型同时承担摘要任务后来我改成用轻量模型做摘要压缩耗时直接降到0.6秒以内质量损失在可接受范围第二针对系统消息的摘要不做缓存否则用户更新偏好后旧摘要会被错误引用。6. 实际工程落地时的配置策略与关键检查表源码层面的逻辑讲完之后最后补充落地配置这块。不同业务场景对上下文压缩的诉求差别很大Pi-Agent的Compaction给我提供一个基础框架但生产环境里仍需要按场景定制几个关键参数。6.1 窗口阈值和摘要上限的调参建议配置项默认值短对话场景建议长决策场景建议压缩触发阈值窗口的85%70%90%摘要长度上限窗口的25%15%35%保留系统消息是是是压缩窗口大小20轮10轮40轮短对话场景里对话轮次少但频率高压缩触发阈值尽量调低让系统在早期就养成压缩习惯避免某一次大段上下文突然被压缩导致信息震荡。长决策场景里对话轮次密集且单条消息长度大需要把压缩窗口加大确保摘要能够覆盖足够长的决策链。6.2 易踩坑场景与排查方向我在日志面板里看过不少次Compaction出错的情况大部分可以归结为四类问题触发过早导致频繁压缩、摘要质量下降但日志无异常、压缩后模型行为发生突变、多次压缩累积后上下文主题漂移。针对触发过早问题需要检查配置的触发阈值是否过低以及窗口内消息的平均长度是否偏大。摘要质量下降重点检查候选区消息里是否混入大量代码或多样格式的文本摘要prompt对这种噪声文本比较敏感。模型行为突变大概率是摘要消息的替换位置有误插入顺序颠倒了。上下文漂移则基本可以确定是摘要长度上限太小多轮压缩后信息损失持续累积需要调大摘要长度上限。6.3 常见问题排查清单压缩触发频率过高检查是否单条消息token数异常大或者在遍历消息长度统计时把工具调用结果也计入了上下文。压缩后关键约束丢失检查摘要prompt的四类抽取任务是否覆盖全部用户偏好特别是那些以否定形式出现的偏好如“我不喜欢MySQL”。压缩动作执行但无效果检查是否消息替换逻辑的正确性摘要消息有没有真正插入到对话列表的正确位置。摘要生成超时检查摘要模型的服务响应是否稳定必要时切换异步执行方案。7. 我对Compaction方案边界的一些思考最后说点源码之外的个人体会。Compaction方案再完善也只是一个“尽力而为”的记忆策略它并不能等同于完美记忆。碰到一次极端的消息时序错乱摘要与原始消息同时出现在上下文里模型重复引用旧信息我当时花了整整一个下午去追问题最后发现压缩轮次之间追加了新的用户指令摘要里还保留着旧的状态信息。这暴露了Compaction链路的一个局限它只管把历史信息压紧但历史信息与最新信息的优先级排序规则还比较粗糙。解决这类问题的思路是在摘要消息加入上下文时额外标记它的生成时间和引用状态后续模型在读取时可以据此判断该摘要是否仍然有效。这条优化路径我目前还在实验已经跑出一版原型效果比预期好。后续如果有突破我会单独写一篇展开讲。从实际工程的视角看Compassion读源码的价值不只是把逻辑照搬进自己项目里更重要的是理解它设计时的那几个取舍瞬间——为什么窗口阈值先于压缩执行、为什么摘要消息用system角色、为什么压缩失败选择静默放弃。这些设计背后都是真金白银的线上问题堆出来的答案也是我从这篇源码里收获最多的地方。