Agent 的上下文压缩:让长会话持续运行

Agent 的上下文压缩:让长会话持续运行 echo-agent 前身为 2025 年 11 月启动的个人助理项目 fubot最初面向长期陪伴型个人智能体围绕认知记忆、上下文延续、用户偏好沉淀、任务闭环与持续自我优化展开。随着真实场景迭代项目逐步形成多入口接入、统一事件模型、消息总线、Agent Loop、多模型抽象、工具调用、MCP 接入、任务调度、权限审批、运行轨迹、长期记忆和受控自演进等能力。目前已支持微信、QQ、CLI、Gateway、Webhook、Cron 等入口服务用户超过 20 万、累计下载超过 50 万是面向长期运行、记忆增强和可持续成长智能体的开源 Agent Runtime。项目地址https://github.com/fuyuxiang/echo-agent你让 Agent 连续工作两个小时读代码、跑测试、查日志、修改文件、整理结论。前半小时它很清醒后面开始重复问已经确认过的问题甚至忘了你早就说过“先不要改部署脚本”。这不是模型突然变笨而是上下文正在失控。长会话里的历史消息、工具结果、文件内容、计划步骤和模型回答会持续累积。即使上下文窗口很大也会遇到成本、延迟、噪声和结构合法性问题。上下文压缩要解决的不是把历史写短而是让 Agent 在有限窗口里继续正确执行任务。问题入口最直接的做法是滑动窗口只保留最近 N 条消息。这对普通聊天有用但对工具型 Agent 很危险。关键事实未必在最近几轮里。用户最早给出的路径、禁令、测试命令、设计约束可能在几十轮之后仍然决定下一步能不能做。更麻烦的是Agent 消息不是普通文本队列。一次工具调用通常包含 assistant 的tool_calls后面必须跟对应 tool result。如果压缩时把其中一半裁掉模型 API 可能直接拒绝这组消息。工具结果又往往很长。一个read_file、exec或搜索结果就可能占掉几千到几万字符。只保留最近消息并不等于保留了最有价值的信息。做法适用场景主要风险最近 N 条消息普通闲聊、短问答丢掉早期约束单次摘要简单历史压缩可能破坏工具结构结构化压缩流水线长任务 Agent实现和测试成本更高会摘要历史只说明上下文变短了能否支撑 Agent 继续运行要看任务状态、工具结构和用户约束是否仍然可用。压缩边界上下文压缩至少要同时满足三个目标。第一是信息保真。压缩后仍要保留当前目标、未完成步骤、文件路径、错误信息、用户偏好、审批结果和已经产生的副作用。第二是结构合法。tool_call和tool_result不能被随意拆开消息列表不能以孤立 tool result 开头也不能留下没有对应调用的 tool 消息。第三是成本可控。旧的、重复的、大段工具输出应该先被低成本压缩最近窗口和当前问题则尽量原样保留。为了不停留在抽象层面下面以 echo-agent 的实现为例。当前压缩模块位于echo_agent.agent.compression它不是一个摘要函数而是一条五阶段压缩流水线Tool output pruning工具输出剪枝。Boundary resolution压缩边界解析。Archive middle segment归档中间段。LLM summary generation生成结构化摘要。Message reassembly validation重组消息并做结构校验。这个顺序很关键。系统先处理最便宜、最确定的问题再调用模型做有损摘要最后用校验器保护消息协议。也就是说echo-agent 把压缩当成上下文治理而不是把一段历史直接丢给模型“总结一下”。触发位置压缩发生在ContextStage.build中位置在读取历史之后、当前用户消息写入之前。这有两个含义。第一压缩对象是历史消息不是当前用户消息。当前问题必须保持原文并作为最后一条 user 消息进入模型。第二压缩会带上focus_topicevent.text让摘要器优先保留与当前问题有关的信息。简化后的时序可以写成这样history session.get_history(max_history) ​ if compressor.should_compress(history): result await compressor.compress( deepcopy(history), focus_topicevent.text, ) history result.messages ​ if result.was_compressed: session.messages ( session.messages[:session.last_consolidated] result.messages ) await sessions.save(session) ​ messages build_context(history, current_user_message)写回 session 时保留session.messages[:session.last_consolidated]是为了避免压缩破坏已经完成长期记忆整理的边界。触发条件由CompressionConfig控制。默认enabledTruetrigger_ratio0.7也就是估算 token 超过上下文窗口 70% 时开始压缩。token 估算优先使用真实 tokenizer没有 tokenizer 时才退化为字符长度估算。工具剪枝压缩流水线第一步是ToolOutputPruner。它不调用模型只对旧工具结果做低成本缩减。它会先按 tail budget 找到受保护的尾部消息。尾部通常保存当前任务最近状态不应该过早剪枝。尾部之外的旧工具结果会被替换成摘要占位[pruned] terminal: ran command - 3 lines output [pruned] read_file: read file (12345 chars, 300 lines) [pruned] search_files: search - 12 matches [pruned] tool: (duplicate result, see latest)这个阶段保留“工具做过什么”和“输出规模如何”但不保留完整内容。对已经离当前任务较远的大段 stdout、重复搜索结果、旧文件读取内容这是合理取舍。剪枝器还会截断过长的 tool call arguments。很多人只压缩工具结果却忘了工具参数本身也可能塞入大段代码、文件内容或 JSON。如果不处理参数压缩收益会被吃掉。边界解析剪枝之后BoundaryResolver把消息分为 head、middle、tail。head 保留会话开头或关键设定tail 保留最近上下文middle 进入归档和摘要。问题在于这三个区域不能按 token 数硬切。echo-agent 做了三类边界保护head 边界向前对齐避免 head 后面直接留下孤立 tool resulttail 边界向后对齐避免tool_call和 tool result 被拆开最近一条 user 消息必须留在 tail 中因为它通常代表当前任务的最新意图。可以把边界解析理解成下面的伪代码def resolve(messages): head_end align_forward_over_tool_results( min(head_protect_count, len(messages)) ) tail_start find_tail_start_by_budget(messages, head_end) tail_start align_backward_to_tool_call(tail_start) tail_start ensure_last_user_in_tail(tail_start) ​ return head, middle, tail压缩边界不是文本切割点而是消息协议边界和任务语义边界。摘要与归档确定 middle segment 后echo-agent 会先把这段原始消息归档再交给LLMSummarizer生成摘要。顺序不能反。摘要一定是有损过程会遗漏细节、合并相似信息、弱化时间顺序甚至把未验证猜测写成确定事实。先归档原文后生成摘要才给审计、恢复和重新摘要留下余地。摘要提示词要求保留的不是普通聊天重点而是 Agent 继续执行所需的信息必须保留原因关键决策与理由防止重复推翻已确认判断文件路径、函数名、代码标识工程任务需要可定位对象错误信息与解决情况决定下一步是重试还是换路径用户偏好与约束防止越权或违背明确要求当前进度和下一步支撑任务连续执行影响决策的工具结果保留行动依据LLM 摘要前消息会被序列化为紧凑文本用户消息带 turn 计数assistant 消息记录工具调用tool 消息记录工具名和截断结果。长内容保留头尾中间用 omitted 标记替代。摘要失败也不等于整个压缩失败。书稿里提到一个重要测试场景摘要模型失败时压缩仍可能返回was_compressedTrue但summary_text is None。因为工具剪枝、边界解析、重组和校验仍然可以减少上下文压力。这是生产系统需要的降级策略。如果外部摘要模型故障就完全放弃压缩长会话可能立刻超过上下文窗口。消息重组摘要生成后MessageAssembler会把 head、summary、tail 重新组装成新的消息列表。摘要以 user 消息形式插入但带有明确前缀说明它是 earlier conversation turns 的压缩摘要是 reference material不应被当成 active instructions。随后系统再插入一条 assistant 确认表示已经理解这段摘要并将从这里继续。这个设计的关键是来源标注。压缩摘要不是用户当前指令也不是系统规则而是早期会话的参考材料。模型应该用它理解上下文但不能把其中的解释当成新的命令。最后MessageValidator做结构校验移除开头孤立 tool result移除没有对应 tool call 的 tool 消息并为缺失结果的 tool call 补占位结果。def validate(messages): messages remove_leading_tool_results(messages) messages remove_orphan_tool_results(messages) messages patch_missing_tool_results( messages, content[result lost during context compression], ) return messages这一步看似琐碎但非常关键。许多模型 API 对工具调用消息结构要求严格。压缩不能为了节省 token把 provider 无法接受的消息列表交出去。语义漂移上下文压缩最大的风险不是少了几个字而是语义漂移。用户说“暂时不要修改部署脚本先看看可能原因”摘要如果写成“用户要求修改部署脚本”下一轮 Agent 就可能越过授权边界。工具输出说“疑似环境缺少依赖”摘要如果写成“代码存在缺陷”修复方向也会被带偏。所以摘要不是原始事实的完全替代品而是一个新的上下文对象。它有来源、范围、生成时间、损失程度和适用边界。压缩、记忆、归档也必须分工模块回答的问题作用域压缩为了继续当前任务历史应怎样变短当前会话记忆哪些事实值得未来任务召回跨会话归档摘要不够时原始内容能否找回审计与恢复如果把压缩摘要当长期记忆系统会保存大量临时细节污染未来任务。如果把长期记忆当压缩摘要当前执行过程中的工具结果、失败尝试和授权边界又会丢失。echo-agent 的分工是ContextStage负责压缩相关上下文治理ResponseStage负责触发记忆整理。前者服务“继续当前任务”后者服务“沉淀长期经验”。生产可用性判断一个上下文压缩系统是否接近生产可用不能只看压缩率。更重要的是压缩后任务能不能继续结构是否合法风险是否可追溯。检查项可检验标准触发策略按 token 预算触发而不是只按消息条数裁剪工具剪枝大段旧工具结果能被压缩最近关键结果受保护边界对齐不拆散tool_call/tool_result配对当前意图当前用户消息保持原文并留在尾部上下文摘要来源摘要明确标注为参考材料不伪装成系统规则原文归档有损摘要前保存 middle segment支持回查失败降级摘要模型失败时仍可通过剪枝和重组降低压力结构校验压缩后消息能通过 provider 的工具调用协议质量统计记录压缩次数、节省 token、失败时间和退化警告回归测试覆盖结构验证、事实验证、行为验证和攻击性输入书稿中特别强调了测试覆盖BoundaryResolver要测试最近 user 留在 tailToolOutputPruner要测试不同工具的摘要格式和重复结果处理LLMSummarizer要测试 cooldown 与预算裁剪MessageValidator要测试孤立 tool result 和缺失结果补全。这些测试说明压缩系统不是一个文本工具函数而是 Agent 运行时的一部分。它影响模型未来看到的历史因此也影响未来行动。长上下文模型会降低压缩频率但不会消除压缩需求。窗口越大成本越高噪声越多过期事实和冲突事实也越容易混进当前推理。成熟的 Agent 仍然需要判断哪些信息进入当前上下文哪些进入记忆哪些进入归档哪些可以遗忘。小结上下文压缩的目标不是得到一段漂亮摘要而是保持任务可继续。对问答系统来说摘要主要保留主题和结论对工具型 Agent 来说摘要必须保留操作状态读过哪些文件哪些命令失败用户禁止了什么动作哪些假设被推翻当前修改是否验证。echo-agent 的五阶段压缩给出了一条清晰路径先剪掉低价值大文本再解析协议边界归档原始中间段生成面向任务延续的摘要最后重组并校验消息结构。压缩不是遗忘历史而是把历史重新建模为当前任务可以继续使用的工作视图。全篇完本文为 echo-agent 设计笔记系列第 12 篇。项目源码已开源至 GitHub。如果你对工业级 Agent 的工程落地感兴趣欢迎加入技术交流群参与日常讨论。下一篇我们将探讨 《Agent 工具系统把模型意图变成可控能力》敬请期待。