【hermes】上下文压缩

$

上篇我们把 system prompt 的组装逻辑拆开了——原来不是写死的,是动态拼出来的。到这一篇,agent 已经会调工具、有持久化记忆、能拼装完整的系统提示词。能力上了一个台阶。

但能力越强,问题也来得越快。

agent 能做的事越多,上下文膨胀的速度就越快。读一个大文件,几千行文本塞进去了。跑一条长命令,大段输出进来了。多轮工具调用下来,旧结果越积越多。桌面越来越乱,而桌面面积是有限的。

这篇拆开上下文压缩。不聊代码,聊决策——面对越来越满的桌面,你会怎么整理?Hermes Agent 又是怎么选的?

你要解决的第一个问题:桌面到底有多大

在动手整理之前,先搞清楚两个概念。

上下文窗口,就是模型这一轮真正能一起看到的输入容量。比如 200K tokens。你可以把它理解成办公桌面积——桌子就那么大,能同时摊开的文件有限。不管你的文件柜里存了多少东西,桌面上同时只能放这么多。

活跃上下文,是桌面上”当前继续工作最值得看到的那部分”。你历史上跟 agent 说过一百句话,但不是每一句都需要一直摊在桌面上。真正需要的,是当前这轮工作相关的部分。

所以”压缩”不是把文件柜里的东西扔掉。它是整理桌面——把不再需要的文件收回文件柜,腾出空间给正在干的活。

文件柜里的东西没丢(S03 的 session 链保留着完整历史),只是桌面变清爽了。

搞清楚这个,问题就变成了:怎样在不丢掉工作连续性的前提下,把桌面重新腾出空间?

第二步:先扔废纸——旧工具输出裁剪

桌面越来越乱,你的第一反应是什么?

大概率是:先把最明显的垃圾扔掉。

Hermes 也是这么干的。在所有消息里,有一类特别占地方——旧的工具输出。你让 agent 读了一个 2000 行的文件,这个文件内容作为 tool 输出塞进了上下文。但过了十轮对话之后,你早就在做别的事了,那个 2000 行的文件内容还占着位置。

你的选择有哪些?

选择一:不管它。反正上下文窗口够大,先放着。——结果就是越积越多,等到撞上限的那天手忙脚乱。

选择二:把旧的工具输出换成一句占位提示。比如 [Old tool output cleared to save context space]。——不费脑子,不需要 LLM,纯字符串替换。

Hermes 选了选择二。保留最近 3 个工具结果不动(可能还在用),更早的全部替换成一句占位符。

这就像先把桌面上最明显的废纸扔进废纸篓。 不需要思考”这份文件要不要归档”,废纸就是废纸,直接扔。成本为零,但能先腾出一批空间。

注意:这不是删除。旧历史通过 session 链完整保留在数据库里(S03 讲过)。你只是把活跃上下文里的表示换短了。就像把废纸扔进桌边的废纸篓,而不是碎掉——需要的时候还能捡回来。

⚠️ 初学者最容易犯的错: 以为压缩等于删除。不是。压缩是”换一种更短的表示”,旧历史永远可追溯。

第三步:整理桌面中间——保护头尾找边界

废纸扔完了,桌面还是满的。怎么办?

你不能全扔。有些东西不能动。

你的选择有哪些?

选择一:全部重新整理一遍,能压的都压。——问题是,你把最近正在看的文件也收起来了,agent 立刻忘记刚才在做什么。

选择二:保护头和尾,只整理中间。——头部是任务定义和系统提示词(”桌角钉着的项目说明书”),尾部是最近的工作(”你正在翻的文件”),中间那些用过的材料可以整理。

Hermes 选了选择二。

头部保护前 N 条消息不动。尾部保护最近约 20K tokens 不动。注意,尾部不是固定”最后 N 条”,而是按 token 预算计算。这样不管最近的消息长短,保留的信息量相对稳定——你不会因为最近几条消息特别长就只保留了两条,也不会因为最近的消息短就保留了五十条。

中间那部分,就是”要被压缩的区域”。

但切的时候有个陷阱。

消息流里有一种特殊的配对关系:assistant 发出一条 tool_calls(”我要调用某个工具”),后面紧跟一条 tool 响应(”工具返回了这个结果”)。这两条必须成对出现。

如果你切边界的时候,把 assistant 的 tool_calls 留在了尾部,但把对应的 tool 响应切进了中段的压缩区——下一次调 API 就会被拒。因为模型看到了一条”我要调用工具”的指令,却找不到对应的结果。

这就像拆快递不能拆一半。 包裹和它的拆封记录必须在一起。你不能把包裹寄到 A 地址、拆封记录寄到 B 地址。

Hermes 的解法:切点在两端都”吸附”到下一个非 tool 消息上。碰到 tool 消息就往后挪,直到找到 assistant 边界。这样切出来的中段,始终是若干完整的 assistant 和 tool 的配对。怎么压都不会出孤儿。

这比”压完再扫一遍清孤儿”更彻底——从源头就保证消息流合法。

第四步:请实习生写摘要——辅助 LLM 结构化摘要

废纸扔了,中间也找出来了。最后一步:中间那堆用过的材料怎么办?

你的选择有哪些?

选择一:直接截断,只保留一部分。——丢信息,而且丢得没规律。

选择二:请一个便宜的辅助模型,把中间那堆东西写成一份结构化摘要。

Hermes 选了选择二。

这里有两个关键决策。

第一个决策:用辅助模型,不用主模型。 压缩是系统操作,不是用户请求。你不该花贵模型的预算来做这种”整理桌面”的脏活。用一个便宜的、快的模型就够了。就像你不会请总监来整理文件柜,这种活交给实习生就行。

第二个决策:给摘要一个固定模板。 不是让实习生”随便写个总结”,而是给他一个表格——”项目目标是什么、做到哪了、做过什么关键决定、改过哪些文件、下一步干什么”。

Hermes 的摘要模板固定五个区块:

  • Goal(目标)
  • Progress(进度)
  • Key Decisions(关键决定)
  • Files Modified(改过的文件)
  • Next Steps(下一步)

这样写出来的摘要,换谁看都能接上活。模型读到这份摘要,就知道”之前在干什么、干到哪了、接下来该干什么”。

还有一个细节:如果之前已经压缩过,新一次压缩时会把旧摘要也传给辅助模型。新摘要变成”更新旧摘要”而不是”从零写一份”。信息损失更小——就像你改文档用修订模式,而不是每次重写一版。

摘要消息的身份也有讲究。 它用 assistant 角色,不用 user 角色。为什么?因为 user 角色会被模型理解为”用户的新一轮发言”,触发重新规划——agent 可能以为用户又下了一道新指令,把已经做过的事重新做一遍。用 assistant 角色配合 [CONTEXT COMPACTION - system-generated] 前缀,明确告诉模型”这是系统生成的摘要,不是新指令”。

⚠️ 初学者最容易犯的错:

一,用主模型做摘要。浪费钱。压缩是系统操作,辅助模型就够了。

二,摘要写成一句空话。”用户让 agent 做了些操作”——这种摘要没有任何价值。必须保住目标、决定、文件、下一步。

更深层的问题:摘要扛不住的重任

三层递进压缩讲完了。看起来问题解决了?

还没有。有一个更深层的问题。

来看一个真实案例。有客户报告:让 agent “读取 agents 目录下的所有文件”。agent 一边读文件一边把内容塞进上下文。读到第 4、5 个文件就撞上了压缩阈值。中间几十条 tool 输出被压成一段摘要。

摘要里写着:

  • Goal:读取 agents 下所有文件
  • Progress:已读若干文件
  • Next Steps:继续读剩余文件

看起来没问题?但下一轮压缩时,这份摘要又被进一步浓缩。”已读 s01, s02, s03″ 变成了 “已读若干文件”。再下一轮,”若干文件”变成了”已读部分文件”。信息熵指数级衰减。

模型不知道还剩哪些文件没读,也不知道哪些已经读过了。它只能重新规划,甚至重复读已经读过的文件。

这就是客户感受到的”一直压缩、一直循环”。

问题的根源是什么?摘要是”有损压缩”。 它注定会丢信息。你把”目标和进度”这种连续性关键信息托管给摘要器,多轮下来必丢。

这就像你让实习生每周五写一份项目周报。第一周写得很详细。第二周他基于第一周的周报更新,丢了一些细节。第三周再基于第二周的更新,又丢了一些。到了第四周,周报上只剩”项目进行中”——你根本不知道具体做到哪了。

有一类问题是”再短的摘要也救不了”的——模型对当前任务的目标和进度,必须无损可读。

第五步:贴一张便签条——TaskState 不可压缩区

怎么解决?

你的选择有哪些?

选择一:让摘要更详细,尽量不丢信息。——治标不治本。摘要有损压缩的本质没变,多轮下来还是会丢。

选择二:把目标和进度从消息流里搬出来,放到一个压缩算法永远看不到的地方。

Hermes 选了选择二。

具体来说,引入了一个 TaskState(任务态)。它包含两个字段:goal(目标)和 todos(待办列表,每个待办有 id、名称、状态)。

它有三个关键性质:

第一,不进消息流。 压缩算法看不见它,永远不会被裁剪或摘要。它就在那里,不动。

第二,每轮都看得到。 每次调 API 之前,系统把 TaskState 的内容拼到 system prompt 末尾。模型每一轮都能看到完整的目标和待办列表。

第三,agent 自己维护。 通过三个工具:task_set_goal(设目标)、todo_write(列步骤)、todo_update(标进度)。agent 每完成一步就更新状态,模型下一轮立刻看到。

这就像贴在显示器上的便签条。 桌上堆再多东西,便签条永远在那里——”目标:读完所有文件。进度:s01 完成、s02 完成、s03 进行中、s04 未开始。”你不需要翻桌面上的文件就知道下一步干什么。

到这里,上下文被切成了两层:

性质 由谁维护 压缩处理
消息流 可有损 模型自动产生 按预算压缩
任务态 不可损 agent 显式调工具 永远完整
消息流 vs 任务态双层架构:消息流可有损被压缩,任务态不可损永远完整
消息流 vs 任务态双层架构:消息流可有损被压缩,任务态不可损永远完整

摘要退回到了它该待的位置——”中段对话的备忘”,告诉模型”这段时间大致发生了什么”。它不再承担”连续性关键载体”这种扛不住的职责。

目标和进度,由 TaskState 这个不可压缩区来保。

注意:TaskState 是单次任务内的活跃状态,会话结束就丢了。它和 S07 要讲的 memory(跨会话持久化的长期记忆)是两回事。一个管”这次任务做到哪了”,一个管”这个用户喜欢什么”。

Hermes 的三个独特设计

除了三层压缩和 TaskState,Hermes 在工程实现上还有三个值得注意的设计。

第一,Preflight 压缩。

进入主循环之前就检查 token 数。如果已经超了——比如用户从大窗口模型切到小窗口模型——在第一次 API 调用之前就压缩。不等到 API 报错再处理。

这就像出发前检查油量,不是等抛锚了才找加油站。 主动防御比被动恢复好得多。

第二,压缩触发 session 分裂。

压缩之后,系统创建一个新的 session,通过 parent_session_id 指向旧 session(S03 讲过的机制)。旧 session 的完整历史不删除,通过链条可以追溯。

这意味着压缩不是”覆盖”,而是”分叉”。新 session 从摘要开始继续工作,旧 session 完整保留。

第三,CompressionStuckError——压缩失败要早退。

极端情况下,头部本身就吃掉了大部分预算。比如用户第一条消息就塞了一整篇文档,compress 找不到可压的中间段。再多压几轮也只是空转。

Hermes 的做法:比较压缩前后的 token 估算,如果没降到原值的 90% 以下,抛出一个 CompressionStuckError。主循环捕获后立即退出当轮,告诉用户”会话已无法压缩,请新开 session”。

这就像你整理桌面,发现桌上只钉着一份巨型蓝图,什么都扔不了、什么都移不动。 这时候该换一张更大的桌子,而不是反复整理。

不 early exit 会怎样?系统会一直压缩、一直循环,直到达到最大迭代次数。用户就卡在那里了。这就是 issue #2 里客户报告的那个现象的直接原因之一。

复盘:三层递进 + 一个不可压缩区

回头看,这篇其实就做了一件事:在”桌面越来越满”这个约束下,找到了一套成本最低、信息损失最小的整理方案。

三层递进压缩:裁剪旧工具输出 → 保护头尾只压中间 → 辅助LLM写摘要
三层递进压缩:裁剪旧工具输出 → 保护头尾只压中间 → 辅助LLM写摘要

三层递进压缩的核心逻辑是”能不花 LLM 的钱就不花”:

  • 第 1 层:旧工具输出先裁剪——免费,纯字符串替换
  • 第 2 层:保护头尾,对齐到 assistant 边界,只压中间——计算成本
  • 第 3 层:用辅助 LLM 生成结构化摘要——小钱

加上 TaskState 这个不可压缩区,把”目标和进度”从有损的消息流里搬到无损的便签条上。

和前四篇拼在一起看:

职责 不管什么
S01 循环 消息 → API → 工具调用 → 下一轮 不管工具怎么找到的,不管消息存哪
S02 工具 按名字查表、分发执行、同步/异步桥接 不管循环逻辑,不管持久化
S03 存储 写入数据库、读出历史、WAL 并发安全 不管循环怎么跑,不管工具怎么执行
S04 Prompt 动态拼装 system prompt、缓存复用 不管压缩,不管任务态
S05 压缩 三层递进压缩 + TaskState 不可压缩区 不管错误恢复(→ S06)

从 S01 到 S05,每一层只管自己那一摊事,层与层之间靠清晰的接口衔接。加新能力不用改旧代码。这就是好的架构——不是”什么都在一起”,而是”每件事都有且只有一个地方管”。

最后,三个能带走的判断。

一,先便宜后贵。 三层递进的核心逻辑是”能不花 LLM 的钱就不花”。先裁剪旧输出(免费),再找边界(计算),最后才请辅助模型(小钱)。做产品也一样——先用最低成本的方案试,不够再加资源。不要一上来就请总监整理文件柜。

二,摘要有它的能力边界。 摘要能记住”中段发生了什么”,但扛不住”目标、进度、下一步”这种连续性关键信息。让每个机制只做它最擅长的事。做产品也一样——不要指望一个功能解决所有问题,每个组件都有它的能力边界。

三,不可压缩区是设计出来的。 TaskState 不是”想到了就加”,而是从”摘要为什么扛不住”这个问题一步步推出来的。当你发现某个东西丢了就不行,就该把它从可损层搬到不可损层。做产品也一样——关键信息不能交给有损通道,必须有一个”永远看得到”的地方。

下一篇,拆开”错误恢复”——agent 跑着跑着报错了怎么办?

📚 Hermes Agent 源码精读 · 系列第 05 篇

← 上一篇:04-Prompt组装

| 下一篇:06-错误恢复