上篇我们把 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 显式调工具 | 永远完整 |

摘要退回到了它该待的位置——”中段对话的备忘”,告诉模型”这段时间大致发生了什么”。它不再承担”连续性关键载体”这种扛不住的职责。
目标和进度,由 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 的钱就不花”:
- 第 1 层:旧工具输出先裁剪——免费,纯字符串替换
- 第 2 层:保护头尾,对齐到 assistant 边界,只压中间——计算成本
- 第 3 层:用辅助 LLM 生成结构化摘要——小钱
加上 TaskState 这个不可压缩区,把”目标和进度”从有损的消息流里搬到无损的便签条上。
和前四篇拼在一起看:
| 层 | 职责 | 不管什么 |
|---|---|---|
| S01 循环 | 消息 → API → 工具调用 → 下一轮 | 不管工具怎么找到的,不管消息存哪 |
| S02 工具 | 按名字查表、分发执行、同步/异步桥接 | 不管循环逻辑,不管持久化 |
| S03 存储 | 写入数据库、读出历史、WAL 并发安全 | 不管循环怎么跑,不管工具怎么执行 |
| S04 Prompt | 动态拼装 system prompt、缓存复用 | 不管压缩,不管任务态 |
| S05 压缩 | 三层递进压缩 + TaskState 不可压缩区 | 不管错误恢复(→ S06) |
从 S01 到 S05,每一层只管自己那一摊事,层与层之间靠清晰的接口衔接。加新能力不用改旧代码。这就是好的架构——不是”什么都在一起”,而是”每件事都有且只有一个地方管”。
最后,三个能带走的判断。
一,先便宜后贵。 三层递进的核心逻辑是”能不花 LLM 的钱就不花”。先裁剪旧输出(免费),再找边界(计算),最后才请辅助模型(小钱)。做产品也一样——先用最低成本的方案试,不够再加资源。不要一上来就请总监整理文件柜。
二,摘要有它的能力边界。 摘要能记住”中段发生了什么”,但扛不住”目标、进度、下一步”这种连续性关键信息。让每个机制只做它最擅长的事。做产品也一样——不要指望一个功能解决所有问题,每个组件都有它的能力边界。
三,不可压缩区是设计出来的。 TaskState 不是”想到了就加”,而是从”摘要为什么扛不住”这个问题一步步推出来的。当你发现某个东西丢了就不行,就该把它从可损层搬到不可损层。做产品也一样——关键信息不能交给有损通道,必须有一个”永远看得到”的地方。
下一篇,拆开”错误恢复”——agent 跑着跑着报错了怎么办?