内存压缩机制

内存压缩机制

受众: Hermes Agent 贡献者、网关开发人员以及调优长时间运行会话的高级用户。 最后更新: 2026-08-16

概述

Hermes Agent 通过摘要(压缩)对话记录的中间部分而非直接截断,让长时间对话能够继续保持可用。本文档端到端地描述它的工作原理:磁盘上的表示形式是什么、什么会触发压缩、压缩之后消息长什么样、压缩失败时会发生什么。

整个系统由三个松散耦合的部分组成,理解它们各自的角色是阅读本文档的前提:

第一部分是压缩引擎,负责真正地重写对话记录。它把过长的中间部分浓缩成一段摘要,保留关键的首尾信息,然后把新的对话记录写回数据库。

第二部分是触发系统,决定什么时候调用压缩引擎。它有十几种触发方式——基于 token 数量的阈值、基于消息条数的硬上限、长时间空闲后的惰性触发、模型返回错误时的兜底触发,以及用户通过命令手动触发的即时压缩。这些触发分布在对话循环的多个检测点和网关的入口处。

第三部分是跨会话回忆层,也就是用户搜索历史对话时实际接触到的功能。它使用 SQLite 的全文检索能力,对数据库里的所有消息文本建立索引,用户输入关键词后返回命中的原文片段。

这三个部分以一种反直觉但又非常关键的方式相互影响:压缩操作不会真正删除任何旧消息。也就是说,数据库里同时存在两种内容——摘要之前的原始文本和摘要文本本身。这意味着无论会话被压缩过多少次,用户的关键词搜索永远能命中最初的那句话。这是整个设计的核心特性,也是理解其它细节的前提。


1. 磁盘上的表示

每条消息在数据库里都有两个标志位:活动状态(是否还参与当前的实时上下文)和压缩状态(是否被摘要过程归档过)。这两个标志的不同组合产生三种行:

第一种是实时消息,活动状态为真、压缩状态为假。这是当前对话上下文里实际使用的消息,原始文本,是压缩引擎的输出目标。

第二种是被压缩过程归档的旧消息,活动状态为假、压缩状态为假。这一点至关重要:原始文本并没有被删除,它仍然完整地保留在数据库里,只是从实时上下文中被移除了。

第三种是被用户回退的消息,活动状态为假、压缩状态为假。这通常是用户主动使用重新生成或撤销功能时产生的。这类行在默认情况下既不参与实时上下文,也不进入搜索索引。

由于压缩不会删除原始文本,数据库在一段时间后会出现混合状态:少量实时消息、一段摘要消息、大量被归档的旧消息。这就是它在磁盘上的真实样子。


2. 跨会话回忆——为什么搜索到的还是原文

回忆层使用 SQLite 自带的全文检索引擎作为索引,它对消息的原始内容字段建立索引。当用户输入关键词时,系统在索引里查找匹配,然后返回原文片段。

由于上一节提到的归档行依然保留在数据库里、依然在索引中,用户搜索时不需要关心消息是否已经被压缩。一个可能只出现在第十轮对话中的关键词,几年后(如果会话还活着)依然能够被检索到。

命中之后,系统返回三个层次的内容:首先是关键词所在行的带高亮的摘录,这就是用户最直观看到的片段;其次是匹配消息加上前后各几条消息形成的窗口,让用户看到上下文;最后是窗口两端的极简书签,帮助用户定位这个消息在整段对话里的大致位置。

整个召回路径没有任何大模型调用,纯靠数据库索引完成。提速方式是多样的:默认使用通用的 Unicode 字符分词器,对英文比较友好;针对中文和日韩文有专门的二元分词后备索引;针对比较模糊的子串匹配(比如用户只记得某几个字母),还有三元组分词索引兜底。

需要特别说明的是,摘要消息本身也在索引里。如果用户搜索的关键词恰好出现在摘要文本中,摘要也会被命中。摘要和原始消息在搜索系统里没有身份差异——决定返回谁的是全文检索的相关性打分本身,对它们一视同仁。(摘要和原始消息在搜索系统里没有身份差异,决定返回谁的是相关性打分本身。一般情况下原始消息密度高、关键词具体,会优先返回;只有当关键词只在摘要里、原始消息被删了、或者关键词太宽泛时,摘要才会被命中。)


3. 压缩输出的样子

压缩不会把整段对话都改写成短消息,它只动中间部分。压缩后的对话记录由三段组成:

头部是开头的一定数量的消息,原始文本完整保留。系统提示词和最初几轮对话都在这里,永远不会被摘要。默认保护三条消息。

尾部是最近的一定数量的消息,原始文本完整保留。尾部里至少要有一条真实的用户消息,这是为了让压缩后的对话上下文里仍然包含用户的最近意图。默认保护二十条消息。

中间才是被压缩的部分。这段原始消息被归档,然后在它原来的位置插入一条新的摘要消息。摘要消息带有特殊的前缀标记和后缀标记,让后续解析时能清楚识别边界。

最终结果是一条由"原始头部 + 摘要消息 + 原始尾部"拼起来的新对话记录,长度上比压缩前短得多,但前后的关键信息一字不少。


4. 什么触发压缩

大约有十五种触发方式,分为自动、手动和特殊路径三类。

4.1 token 阈值——最常见的自动触发

主路径上,每次与模型 API 通信之前和之后,都会检查当前对话的 token 数是否超过阈值。超过则触发压缩。

阈值的默认计算方式是:用模型上下文窗口减去输出预留空间,再乘以一个比例系数,默认是百分之五十。同时有一个下限保护:无论上下文窗口多大或多小,阈值都不会低于六万四千个 token。这个下限是为了避免压缩一个非常小的会话——压缩本身有成本,一次大模型调用、一次提示缓存失效、一次数据库重写,压缩一个只有几千 token 的会话是净亏损。

不同模型有不同的阈值覆盖。上下文窗口小于五十一万二的模型把阈值提升到百分之七十五,因为它们的窗口本来就紧凑。某些推理型模型(会在输出前先思考的模型)被进一步提升到百分之八十五。Codex 系列的小窗口模型(gpt-5.3-codex-spark)则降到百分之七十。还有一些特殊的覆盖规则,通过模型名匹配最长的子串来生效。

阈值的检查在两个时刻进行:API 调用之前使用请求压力 token 数进行预检,API 调用之后使用模型实际返回的 token 数进行精确检查。

4.2 消息数量硬限制——死亡螺旋的安全阀

网关在每一轮用户消息进来时、会话交给代理之前,会做一次轻量级的体检。这次检查完全依赖本地数据,不调用任何外部服务。它统计消息条数,再用本地启发式算法粗略估算 token 数。

如果消息条数达到或者超过五千条,无论 token 估算结果是什么,都强制触发压缩。

这个五千条硬上限专门用来应对一种危险场景:当 API 连接断开时,模型实际返回的 token 数就无法获取,token 阈值触发的逻辑完全失效。如果只有这一条触发路径,会话就会在 API 断开期间不断膨胀,每次膨胀又让下一次 API 调用更可能失败,进入一个越膨胀越容易失败的恶性循环。消息条数硬上限完全独立于 API 响应,只要数数据库行就能触发,所以即使整个网络都挂了,只要消息量到了上限,压缩依然能执行。

这个设计的语义是双保险:正常情况下 token 阈值工作得很好也很快,消息条数是闲置的;当 API 路径失效时,消息条数是唯一的兜底。

4.3 空闲时间触发——默认关闭

如果用户配置了一个非零的空闲秒数,当会话空闲超过这个时长时,系统会检查是否要压缩。

但仅靠时间是不够的。即便用户设置了一小时,即使会话真的空了一小时,如果当前 token 数还不到阈值下限(六万四千),系统也不会压缩。原因是利用率太低的会话压缩起来性价比很低。时间触发只是"提醒",真正的决策权在 token 数。

4.4 错误响应触发——API 报错时的兜底

当模型返回特定错误时也会触发压缩。这些错误意味着当前对话已经大到模型无法处理:

第一种是负载过大错误,请求体的总大小超过了 API 限制。第二种是长上下文窗口触发的限流,同时系统还会把上下文窗口声明值降到二十万,强制压缩才能继续。第三种是输出请求过大,比如设置的输出上限和当前对话规模不匹配。第四种是通用的上下文溢出错误。

这些响应都意味着"不压缩就没法继续",所以处理逻辑是直接调用压缩,忽略冷却期之类的保护。

4.5 预检触发——轮次前的多轮循环

在每一轮对话开始之前,系统会先做一次预检:如果当前对话估算的 token 数已经超过阈值、或者消息数超过可压缩窗口的大小,就进入多轮压缩循环,逐步把会话压缩到目标水位。

此外,插件引擎(比如对话上下文管理插件)可以通过一个专门的接口请求在阈值以下触发压缩,用于实现自定义的压缩策略。

4.6 手动触发——用户命令

用户可以通过四种方式手动触发压缩,全部绕过冷却期和防抖动保护:

命令行版本的全局压缩命令、命令行版本的局部压缩命令(只压缩指定位置附近)、网关通道里的压缩命令、ACP 协议适配器的压缩命令。

手动触发的设计意图是:用户明确知道现在需要压缩,因此即使冷却期还没过、即使系统认为刚压缩过没意义,用户也要能强制执行。

4.7 特殊路径——绕过主压缩器

有几种场景下,压缩不由 Hermes 完成,而是由外部系统完成:

第一种是 Codex 应用服务器模式。在这种模式下,Codex 自己管理线程的压缩,Hermes 不介入。可以通过配置选择 Codex 原生压缩、让 Hermes 调用 Codex 的压缩接口、或者完全关闭自动压缩。

第二种是 OpenAI 原生服务端压缩。这是一个可选功能,仅限于特定模型系列。OpenAI 的服务端在检测到会话过大时自己会压缩,Hermes 的本地压缩触发被故意压低一些,让服务端永远先触发。

第三种是微压缩。这是一个可选的细粒度压缩模式,每一轮对话结束后把最早的一组消息合并进滚动摘要。代价是每一轮都会破坏提示缓存(即让模型提供商为同一段对话预先缓存的内容失效),所以默认关闭。

第四种是主动裁剪。在主压缩被冷却期或防抖动挡住、但 token 数已经超阈值时,系统会跳过压缩完整的摘要,转而只对工具调用的返回结果做确定性的去重和裁剪。这个操作不调用大模型,纯本地完成。

4.8 总开关

主开关关闭所有自动触发的路径,但手动触发不受影响——用户随时可以手动压缩。


5. 网关卫生检查与死亡螺旋

网关是所有外部消息渠道的统一入口。无论用户从 Telegram、Discord、终端界面还是 ACP 协议发来消息,都先经过网关再做处理。

网关卫生检查是每一轮用户消息到来时、会话交给代理之前的一道预检。它做两件事:数消息条数,粗估 token 数。两者任一超过阈值就强制压缩。

5.1 为什么需要这道检查

token 阈值是更精细的检测点,但它有一条致命的依赖:必须收到模型 API 的响应才能拿到真实的 token 数。如果网络断开、模型服务宕机或者超过超时时间,API 响应就不回来,token 阈值触发逻辑完全失效。

更糟的是,会话越长,下一次 API 请求的负载就越大,负载越大越容易在网络层面失败。这意味着会话膨胀和 API 失败之间形成正反馈:会话变大 → 下次请求更容易失败 → 拿不到 token 数据 → 不压缩 → 会话继续变大。

如果不加干预,这个循环会一路走下去:会话越来越大,越来越难成功调用 API,最终导致单次启动时间过长、内存溢出或者数据库撑爆。

5.2 消息条数硬上限如何打破循环

消息条数硬上限完全依赖本地数据:只数数据库里的行数,不调用任何外部服务。这个独立性让它在 API 路径完全失效时依然能工作。

当消息条数达到五千时,无论网络是否可用、模型是否可达、token 数据是否新鲜,压缩都会被强制执行。即使整个外网都断了,压缩也能照常运行。

这就是设计文档里所说的"安全阀"——主路径精细但脆弱,安全阀粗糙但顽强。两者组合起来,既能在正常情况下精细控制压缩时机,又能在异常情况下避免会话陷入死循环。


6. 压缩输出的长度限制

压缩生成的摘要消息在长度上是有约束的,但约束方式很反直觉。

6.1 软预算区间

摘要的预算主要取决于被压缩内容的多少:先按被压缩内容的百分之二十估算,再设下限为两千个 token、上限为一万个 token。无论被压缩的内容有多小,摘要都不会少于两千个 token;无论被压缩的内容有多大,摘要都不会超过一万个 token。

这个区间是有意为之的。小于两千 token 的摘要信息密度太低,丢三落四的;超过一万 token 的摘要本身又成了新的上下文负担,反而让压缩失去意义。

6.2 为什么不在 API 层硬性截断

按照通常的做法,调用大模型生成摘要时应该设置输出上限,防止模型说得太多浪费 token。但这里故意不传这个上限。

原因是某些模型(尤其是带推理能力的模型)会先在内部思考。如果设了输出上限,模型可能把整个预算都花在思考上,留给实际输出的 token 几乎为零,结果返回的是"只有思考没有正文"的废摘要。历史上确实发生过这种情况:摘要被截断在中间,产生了不完整的摘要,进而触发再次压缩,再次触发同样的问题,陷入循环。

所以这里的设计是:不设上限,让模型自己决定输出长度,但通过提示词明确告诉它"目标在两万到十万 token 之间"。模型听话就老老实实写那么多,不听话就真的没办法——但这种风险比上面那种截断崩盘的风险小得多。

6.3 输入侧的限制

喂给摘要模型的输入也有限制。每条消息的内容最多六千字符(截断时保留头部四千字符和尾部一千五百字符);整个摘要提示词最多十六万字符(约四万 token)。

如果输入超过十六万字符,系统会保留头部的百分之四十五和尾部的百分之五十五,中间部分丢弃,并明确标注"输入已截断"。这保证了大会话也能被处理,只是摘要会丢失一些中间细节。

6.4 失败时的兜底

如果摘要本身的大模型调用失败(限流、超时、网络断),系统不会让会话就此卡住。它会使用一个本地代码生成的"占位摘要":把被压缩的内容拼接成可读文本,超过八千字符就截断。这条路径完全本地,完全确定,不依赖任何外部服务。

注意:本地兜底的最大长度是八千字符,而大模型输出的摘要上限是一万个 token。两者度量不同,但精神一致——保持在一个可接受的范围内。


7. 压缩失败时的三层兜底

压缩不可能永远成功。当摘要调用失败时,系统有三种保护机制防止会话陷入更糟糕的状态。

7.1 冷却期

每次摘要失败后,系统记录一个冷却时间戳。在这个时间戳之前,所有自动触发的压缩都会被跳过。这个冷却状态被持久化到数据库,所以即使进程重启、新启动的代理也会尊重这个冷却。

冷却期的默认时长是六百秒,即十分钟。设计的初衷是:摘要失败如果是因为限流,那短期重试大概率会再次失败,不如等一会儿再说。

7.2 防抖动保护

如果连续两次压缩的节省效果都低于百分之十(即每次压缩几乎没把会话缩短多少),系统会进入防抖动状态:接下来的五分钟内不再触发压缩,除非是一次试探性触发。

试探性触发的目的是检测"是否真的没东西可压了"。如果试探性压缩仍然效果很差,就重新进入防抖动;否则就解除。

防抖动状态同样被持久化,进程重启后不丢失。

一句话总结:防抖动就是"压缩没效果就别再压了"——连续两次节省率低于百分之十就锁住五分钟不再触发,五分钟到了再试探一次,如果还是没用就重新锁住直到情况改变。

7.3 强制重置会话

如果冷却期和防抖动都失效了,又连续失败太多次,系统会向网关报告一个"压缩耗尽"信号。网关收到这个信号后,会自动重置会话——保留摘要之前的对话历史,丢弃之后的乱七八糟内容,从一个干净的状态重新开始。

这是最后的逃生通道。日常情况下不会走到这一步,只有当系统状态已经严重异常时才会触发。

7.4 不同路径的失败行为不同

主动裁剪(基于本地算法的工具结果去重)失败时静默退让,会话保持原样,不报错。

微压缩失败时内存中的拼接照常进行,但数据库同步失败。下次启动时原始消息和摘要会一起出现,直到下一次批量压缩清理。

主压缩失败时异常会向上抛出,调用方会识别这是"压缩被锁住"的信号并退还尝试预算。连续失败超过最大尝试次数后,网关会重置会话。

主路径没有"会话继续膨胀"的优雅退化选项——一旦主压缩反复失败,唯一的兜底就是会话重置。


8. 可调的旋钮

所有压缩相关的设置都在配置文件的压缩块下,按功能分组。

8.1 阈值相关

主开关控制是否启用所有自动压缩。关闭后只有手动压缩可用。

阈值比例控制默认的百分之五十触发点。小窗口模型的覆盖阈值(约百分之七十五)也是从这个比例派生的。

阈值上限允许设置一个绝对的 token 数字,让系统取比例计算结果和这个上限中的较小值。

按模型覆盖允许针对特定模型名设置不同的阈值比例。匹配规则是最长模型名子串胜出。

8.2 输出形态

目标比例控制压缩后的目标长度占压缩前的比例。默认是百分之二十,即压缩后只剩五分之一。但前面说过,软预算有下限保护,实际摘要总是在两千到一万 token 之间。

头部保护消息数、尾部保护消息数、尾部必须包含的用户消息数,都可以通过配置调整。

最大尝试次数限制每一轮最多重试压缩多少次。

归档就地开关决定压缩时是原地更新现有消息,还是为新一轮会话创建新会话。

摘要失败中止开关决定摘要失败时是中断整个压缩(保守策略)还是插入占位摘要继续运行。

8.3 主动裁剪

主动裁剪的 token 阈值、最小节省要求、最小可裁剪工具结果大小,都可以通过配置调整。

8.4 微压缩

微压缩的总开关、每次合并多少轮(默认每轮一次)、滚动摘要的整理阈值,都可调。

8.5 空闲时间

唯一参数是空闲秒数。零表示禁用。

8.6 网关卫生

消息条数硬上限(默认五千)、卫生压缩的最大等待时长、总的最大超时、失败冷却时间,都可调。

8.7 模型与服务相关

Codex 应用的自动压缩策略(原生、托管、关闭)、OpenAI 原生压缩的开关与服务端触发阈值,都可调。

8.8 超时

压缩上下文的空闲超时和总超时,决定了一次压缩调用最多可以等待多久。

所有这些参数都通过配置文件设置,没有对应的环境变量。


9. 完整流程示例

假设用户从 Telegram 发来一条消息,整个会话的压缩流程如下:

第一步,网关收到消息。第二步,卫生检查运行——消息条数八百、估算 token 十二万,两者都低于阈值,放行。第三步,代理加载会话,调用模型 API。第四步,模型返回响应,附带这次请求实际使用的 token 数:十一万。第五步,阈值检查——阈值是十万,十一万超过,触发压缩。第六步,压缩引擎启动:保护头部三条消息,保护尾部二十条消息,中间部分作为摘要输入。第七步,计算摘要预算——假设算出来是六千五百 token。第八步,把被压缩内容序列化成结构化的提示词,附带目标长度说明,发给摘要模型。第九步,摘要模型返回约五千八百 token 的摘要。第十步,组装新对话记录——头部三条 + 摘要消息 + 尾部二十条。第十一步,写回数据库:被压缩的旧消息标记为已归档(但保留原始文本),新对话记录插入并标记为活跃。第十二步,代理继续处理。

经过这次压缩,数据库里有三种消息:被归档的原始消息(数百条,全部保留文本)、摘要消息(一条)、活跃消息(头部和尾部,约二十三条)。当用户后续搜索某个关键词时,无论这个关键词出现在压缩前的哪条消息里,都能被检索到。


10. 核心设计原则

第一,压缩是非破坏性的。原始文本永远保留在数据库里,搜索功能在压缩后依然能命中原文。这是整个设计的基石。

第二,头部和尾部永远不被压缩。只有中间部分可被摘要。这意味着系统提示词、最初几轮对话、最近的对话上下文始终保持完整。

第三,摘要长度由提示词引导,不由 API 硬性截断。系统故意不传输出上限,避免推理模型的输出被截断成废摘要。

第四,消息条数硬上限是 token 路径的备份。当 API 不可达时,条数路径依然能触发压缩。

第五,三层失败保护防止压缩循环。冷却期、防抖动、会话重置,层层兜底。

第六,手动压缩绕过所有保护。用户主动发起的压缩是万能逃生通道。

第七,压缩后的对话记录是头部 + 摘要 + 尾部,是整段拼接,不是很多条短消息的集合。

评论(0)

评论加载中…