echo-agent 前身为 2025 年 11 月启动的个人助理项目 fubot,最初面向长期陪伴型个人智能体,围绕认知记忆、上下文延续、用户偏好沉淀、任务闭环与持续自我优化展开。随着真实场景迭代,项目逐步形成多入口接入、统一事件模型、消息总线、Agent Loop、多模型抽象、工具调用、MCP 接入、任务调度、权限审批、运行轨迹、长期记忆和受控自演进等能力。目前已支持微信、QQ、CLI、Gateway、Webhook、Cron 等入口,服务用户超过 20 万、累计下载超过 50 万,是面向长期运行、记忆增强和可持续成长智能体的开源 Agent Runtime。

项目地址:https://github.com/fuyuxiang/echo-agent

12-cover

你让 Agent 连续工作两个小时:读代码、跑测试、查日志、修改文件、整理结论。前半小时它很清醒,后面开始重复问已经确认过的问题,甚至忘了你早就说过“先不要改部署脚本”。

这不是模型突然变笨,而是上下文正在失控。

长会话里的历史消息、工具结果、文件内容、计划步骤和模型回答会持续累积。即使上下文窗口很大,也会遇到成本、延迟、噪声和结构合法性问题。上下文压缩要解决的不是把历史写短,而是让 Agent 在有限窗口里继续正确执行任务。

问题入口

最直接的做法是滑动窗口:只保留最近 N 条消息。

这对普通聊天有用,但对工具型 Agent 很危险。关键事实未必在最近几轮里。用户最早给出的路径、禁令、测试命令、设计约束,可能在几十轮之后仍然决定下一步能不能做。

更麻烦的是,Agent 消息不是普通文本队列。一次工具调用通常包含 assistant 的 tool_calls,后面必须跟对应 tool result。如果压缩时把其中一半裁掉,模型 API 可能直接拒绝这组消息。

工具结果又往往很长。一个 read_fileexec 或搜索结果就可能占掉几千到几万字符。只保留最近消息,并不等于保留了最有价值的信息。

做法 适用场景 主要风险
最近 N 条消息 普通闲聊、短问答 丢掉早期约束
单次摘要 简单历史压缩 可能破坏工具结构
结构化压缩流水线 长任务 Agent 实现和测试成本更高

会摘要历史,只说明上下文变短了;能否支撑 Agent 继续运行,要看任务状态、工具结构和用户约束是否仍然可用。

压缩边界

上下文压缩至少要同时满足三个目标。

第一是信息保真。压缩后仍要保留当前目标、未完成步骤、文件路径、错误信息、用户偏好、审批结果和已经产生的副作用。

第二是结构合法。tool_calltool_result 不能被随意拆开,消息列表不能以孤立 tool result 开头,也不能留下没有对应调用的 tool 消息。

第三是成本可控。旧的、重复的、大段工具输出应该先被低成本压缩;最近窗口和当前问题则尽量原样保留。

为了不停留在抽象层面,下面以 echo-agent 的实现为例。当前压缩模块位于 echo_agent.agent.compression,它不是一个摘要函数,而是一条五阶段压缩流水线:

  1. Tool output pruning:工具输出剪枝。

  2. Boundary resolution:压缩边界解析。

  3. Archive middle segment:归档中间段。

  4. LLM summary generation:生成结构化摘要。

  5. Message reassembly + validation:重组消息并做结构校验。

12-压缩流水线

这个顺序很关键。系统先处理最便宜、最确定的问题,再调用模型做有损摘要,最后用校验器保护消息协议。也就是说,echo-agent 把压缩当成上下文治理,而不是把一段历史直接丢给模型“总结一下”。

触发位置

压缩发生在 ContextStage.build 中,位置在读取历史之后、当前用户消息写入之前。

这有两个含义。第一,压缩对象是历史消息,不是当前用户消息。当前问题必须保持原文,并作为最后一条 user 消息进入模型。第二,压缩会带上 focus_topic=event.text,让摘要器优先保留与当前问题有关的信息。

简化后的时序可以写成这样:

history = session.get_history(max_history)
​
if compressor.should_compress(history):
    result = await compressor.compress(
        deepcopy(history),
        focus_topic=event.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 控制。默认 enabled=Truetrigger_ratio=0.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 result;tail 边界向后对齐,避免 tool_call 和 tool result 被拆开;最近一条 user 消息必须留在 tail 中,因为它通常代表当前任务的最新意图。

12-边界保护

可以把边界解析理解成下面的伪代码:

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 生成摘要。

顺序不能反。摘要一定是有损过程,会遗漏细节、合并相似信息、弱化时间顺序,甚至把未验证猜测写成确定事实。先归档原文,后生成摘要,才给审计、恢复和重新摘要留下余地。

12-摘要归档

摘要提示词要求保留的不是普通聊天重点,而是 Agent 继续执行所需的信息:

必须保留 原因
关键决策与理由 防止重复推翻已确认判断
文件路径、函数名、代码标识 工程任务需要可定位对象
错误信息与解决情况 决定下一步是重试还是换路径
用户偏好与约束 防止越权或违背明确要求
当前进度和下一步 支撑任务连续执行
影响决策的工具结果 保留行动依据

LLM 摘要前,消息会被序列化为紧凑文本:用户消息带 turn 计数,assistant 消息记录工具调用,tool 消息记录工具名和截断结果。长内容保留头尾,中间用 omitted 标记替代。

摘要失败也不等于整个压缩失败。书稿里提到一个重要测试场景:摘要模型失败时,压缩仍可能返回 was_compressed=True,但 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 留在 tail,ToolOutputPruner 要测试不同工具的摘要格式和重复结果处理,LLMSummarizer 要测试 cooldown 与预算裁剪,MessageValidator 要测试孤立 tool result 和缺失结果补全。

这些测试说明压缩系统不是一个文本工具函数,而是 Agent 运行时的一部分。它影响模型未来看到的历史,因此也影响未来行动。

长上下文模型会降低压缩频率,但不会消除压缩需求。窗口越大,成本越高,噪声越多,过期事实和冲突事实也越容易混进当前推理。成熟的 Agent 仍然需要判断:哪些信息进入当前上下文,哪些进入记忆,哪些进入归档,哪些可以遗忘。

小结

上下文压缩的目标不是得到一段漂亮摘要,而是保持任务可继续。

对问答系统来说,摘要主要保留主题和结论;对工具型 Agent 来说,摘要必须保留操作状态:读过哪些文件,哪些命令失败,用户禁止了什么动作,哪些假设被推翻,当前修改是否验证。

echo-agent 的五阶段压缩给出了一条清晰路径:先剪掉低价值大文本,再解析协议边界,归档原始中间段,生成面向任务延续的摘要,最后重组并校验消息结构。

压缩不是遗忘历史,而是把历史重新建模为当前任务可以继续使用的工作视图。

(全篇完)


本文为 echo-agent 设计笔记系列第 12 篇。项目源码已开源至 GitHub。如果你对工业级 Agent 的工程落地感兴趣,欢迎加入技术交流群参与日常讨论。下一篇我们将探讨 《Agent 工具系统:把模型意图变成可控能力》,敬请期待。

更多推荐