开源自托管 Agent 项目 Hermes Agent:三条压缩线怎么选

2026-07-30

本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。

**在 Hermes Agent 这个开源自托管 Agent 项目里,“上下文压缩”不是一件事,而是三件目标不同、代价不同、失败方式也不同的事;把它们当同一个开关来调,是读这份代码最容易犯的错。**注意先分清名字:Nous Research 还有一个同名的开源模型系列,本文讲的是那个能常驻在你机器上、自己开终端跑命令的 Agent 程序(MIT 许可证,LICENSE 署名 Nous Research)。

三条线分别落在三个入口:agent/context_compressor.py 里那套不调模型的确定性剪枝,负责在一次请求之前把消息列表削瘦;同一文件里的 compress() 加上 agent/conversation_compression.pycompress_context(),负责跨越一次真正的压缩边界——生成摘要、改写会话存储;仓库根目录的 trajectory_compressor.py 则完全不在运行时,它是离线脚本,把跑完的轨迹装进训练用的 token 预算里。

站内已经有几篇相邻的文章:上下文预算怎么算 讲的是与具体项目无关的预算方法论,上下文窗口是什么 是概念地基,pi 的上下文压缩 拆的是另一个项目的实现。本篇不重复这些,只做一件事:把 Hermes Agent 这一个具体仓库的三条压缩线对着代码讲清楚,以及你自己维护一个常驻 Agent 时该照抄哪一段、该避开哪一段。

一、三条线各自要解决什么

第一条线要解决的是”重复付费”:工具输出一旦进了历史,每一轮都会被完整重发一次。这条线不调任何模型,只做删减和改写,因此可以频繁跑。

第二条线要解决的是”这一段对话已经装不下了”:必须调一个辅助模型把中间那一段变成摘要,然后把这个动作在会话存储里落成一次可恢复的状态变更。它贵、慢、可能失败,因此代码里围着它加了锁、租约、取消栅栏和降级路径。

第三条线要解决的是”这条已经跑完的轨迹超过训练目标预算了”:它读 JSONL,用真实分词器数 token,重写完写回磁盘,没有任何在线交互约束。

组成部分它负责什么对应仓库位置你什么时候会碰到它
确定性剪枝(无模型调用)去重完全相同的工具结果、把旧工具输出降级成一行摘要、截短过大的工具调用参数agent/context_compressor.py_prune_old_tool_results()prune_tool_results_only()会话变长、工具输出很大,但还没到压缩触发点时
中段摘要保护头尾,把中间的 turn 交给辅助模型换成一份结构化交接摘要agent/context_compressor.pycompress()_generate_summary()上下文占用越过 compression.threshold,或你手动敲 /compress
边界提交加会话级锁、决定原地压缩还是轮转出子会话、通知记忆与插件、失败时回滚agent/conversation_compression.pycompress_context()多入口并发写同一个会话时;排查”压了却没变小”
引擎抽象定义 should_compress()compress()select_context() 等钩子,允许换掉内置压缩器agent/context_engine.pyContextEngine你要接自己的上下文引擎,或读 plugins/context_engine/ 下的实现
轨迹压缩离线把 JSONL 轨迹压到目标 token 预算,产出压缩指标仓库根目录 trajectory_compressor.py你在做数据侧的工作,不在跑 Agent
配置面三条线的开关与默认值集中可查cli-config.yaml.examplecompression:调阈值、开代理式剪枝、改保护尾长度

顺带一个规模感:仓库里 skills/ 有 14 个分类目录共 70 份 SKILL.md,optional-skills/ 有 21 个分类目录共 111 份,plugins/ 有 18 个顶层插件目录,optional-mcps/ 6 个,tests/ 下以 test_ 开头的测试文件有 2499 个。压缩这块的很多分支行为都被测试钉住了,读代码时遇到看不懂的判断,去测试里搜通常比猜快。

二、第一条线:先把不花钱的那一半做完

_prune_old_tool_results() 是纯确定性的,分几个阶段走:

先做去重。它对每条 role="tool" 的字符串内容取哈希,从后往前扫,保留最新那份完整副本,更早的完全相同副本换成一行回引说明。这一步是无损的,所以代码里刻意让它不受”保护尾”限制——同样的内容留一份就够了。

再做降级。保护边界之外的大块工具结果,被换成一行带工具名与要点的摘要(例如读了哪个文件、命令退出码是多少)。降级过的行会被后续 pass 识别并跳过,不会反复叠加。

然后截参数。助手消息里过大的 tool_calls 参数会被截短,且截短是在解析后的 JSON 结构里做的——注释里写明了原因:如果直接截字符串,产出非法 JSON,后面每一轮请求都会被服务端拒掉,直到这条调用滑出窗口。这个细节很值得抄。

最后是压力降级。当被保护那一段本身就超出软预算时(代码里取尾预算的 1.5 倍),它会开始降级保护区内部的大块工具输出,但始终保留最后几条原文,让最新的用户诉求和最近一对工具调用还能读。极端情况下连最新那条工具结果也会被摘要掉,因为”压不下去”比”最新输出被降级”更糟。

这条线还有一个独立入口 prune_tool_results_only(),由 compression.proactive_prune_tokens 触发(配置示例里默认 0,即关闭)。它有两个设计我很喜欢:一是明确写了不用尾 token 预算而用消息条数保护尾部,因为尾预算是从压缩阈值推导的,在大窗口模型上会把整个会话都保护起来,结果什么都剪不掉;二是它带一个”实测收益门”,先算压缩前后的估算差值,达不到 proactive_prune_min_reclaim_tokens 就把原来那个列表对象原样退回去,调用方靠 result is not input 判断是否发生了改动。

为什么要这么小心?因为改写历史消息体等于让服务端已经缓存的前缀从最早被改的那条起全部失效。剪枝省下的 token 和缓存失效的代价是一笔要算的账,所以代码宁可放弃小额收益,也要让缓存断点保持”偶发”而不是”每轮都断”。你自己写 Agent 时如果在纠结上下文该怎么管,这一条比任何摘要技巧都实用。

这条线丢掉什么:旧工具输出的正文、图片内容、被截掉的参数细节。它不丢任何一条消息的位置和角色,也不动会话存储。所以它对”模型还认得这段对话吗”影响最小,是三条线里最该先开的一条。

三、第二条线:摘要只是中间一步,难的是提交

摘要本身的做法不神秘。头部保护是”系统提示 + 若干条非系统消息”(protect_first_n 默认 3),尾部按 token 预算切(tail_token_budget 由阈值乘 compression.target_ratio 推出),中间那段序列化后喂给辅助模型。输出预算按被压内容的比例算,有下限也有上限,注释里给的理由很直白:摘要本身太大就成了新的压力源,还会拖慢每一次压缩。输入侧也有一个总字符上限,超了就保留头尾并插入省略标记。

比模板更值得看的是那段 handoff 前缀(常量 SUMMARY_PREFIX)。它不是”以下是摘要”这么一句,而是一长串边界声明:这是上一个上下文窗口的交接材料,只能当背景,不要去回答摘要里提到的问题,只对出现在摘要之后的那条最新用户消息负责;话题相似也不等于要接着做;最新消息里出现”停/撤销/算了”这类反向信号时,摘要里在飞的活当场结束。同一个文件里还冻结保存了几代历史前缀原文,注释解释得很清楚:旧版本写进磁盘的摘要会被后来的会话继承,如果不能逐字识别并剥掉旧前缀,那条过时指令就会一直嵌在正文里继续劫持回复。

这块有两个我认为是”踩出来的”设计:一是模板里把待办类段落全部命名成”历史”(## Historical Task Snapshot 这种),配合前缀明确要求丢弃,就是为了防止摘要里的旧任务被当成新指令;二是前缀里专门补了一句”工具仍然可用”,注释写了原因——“仅供参考”的框太强,模型会连工具都不敢调,只在那儿叙述自己打算做什么。

技能正文的处理也值得记:当 skill_view 的结果被降级后,模型仍会以为技能还在上下文里。代码的对策是插一个固定格式的 [SKILL_PRUNED: 标记,带上重新加载的调用;并且在调模型之前就把要打这个标记的技能名收集好,调完之后逐一检查模型有没有把标记保留下来,漏了就自己补回去。这是很典型的”不信任模型会照做,就用确定性代码兜住”。

真正复杂的是 agent/conversation_compression.py 那一侧。它要处理的是一个常驻程序的现实:同一个会话 id 可能被多个入口同时压缩。所以有一把落在会话存储里的按会话锁,配一个后台租约刷新线程;拿不到锁就直接放弃这一轮,把消息原样返回,并单独记一个”因为锁而跳过”的信号,好让手动 /compress 报一句准确的话,而不是含糊的”压缩没有变化”。还有一个提交栅栏(CompressionCommitFence),解决的是异步调用方超时而工作线程还在跑的问题:取消要么赶在改动会话之前生效,要么就等已经开始的提交完整结束。栅栏还记录流式摘要的最近进度时间,让”慢”的摘要模型不至于被固定墙钟时间误杀。

压缩落地有两种模式,由 compression.in_place 控制,默认原地。原地模式下会话 id 不变,压缩前的行被软归档(仍可查、可恢复),一次会话终身一个 id;轮转模式会结束旧会话并派生一个子会话,代价是全套 id 同步与”别的路径已经轮转过了”的恢复逻辑(recover_rotated_compression_session() 就是为这个存在的)。看完这两条路径的注释量差异,你大概能猜到默认值为什么是原地。

启动时还有一层可行性检查(check_compression_model_feasibility()):辅助压缩模型的上下文得装得下要压的内容,装不下就当场把本次会话的触发点调低并警告;对方窗口低于代码里的硬下限则直接拒绝启动。最扎心的是没配辅助模型时的那句警告——压缩会直接丢掉中间的 turn,不生成摘要。这是三条线里最该提前确认的一件事。

这条线丢掉什么:中间那段对话的原文,永久换成一份摘要,且下一次压缩是在上一份摘要之上做迭代更新,误差会累积。代码自己也承认这点:重复压缩多次后会向用户发一条”质量会随每次压缩下降”的提醒。此外摘要要经过一个外部模型,路径、命令、报错内容都会离开你的机器;文件里做了脱敏与提示词层面的约束,但那是尽力而为,不是保证。

四、第三条线:轨迹压缩不属于运行时

trajectory_compressor.py 是个命令行脚本,读 JSONL,每条记录取 conversations 字段,turn 用 from / value 表示,角色是 system / human / gpt / tool。它和前两条线有几处关键不同:

数 token 用的是真分词器(transformersAutoTokenizer,默认加载 Kimi 系分词器),失败才退回按字符估算;运行时那两条线为了快,用的是粗估。保护规则也不一样:这里保护”第一条 system、第一条 human、第一条 gpt、第一条 tool”,加上末尾若干条(protect_last_n_turns 默认 4),中间才是可压区。压多少也不是拍的——先算需要省下多少,从可压区头部累加 turn,够了就停,不够才吃掉整个可压区。

最值得抄的是边界对齐。_is_boundary_clean()_snap_boundary() 保证压缩边界不会落在 tool turn 上,因为在这套格式里 tool turn 紧跟在发出 <tool_call>gpt turn 之后,边界切在它身上会留下一个没有调用的 <tool_response>,训练数据就废了。对齐优先往后挪,把孤立的 tool turn 归进已经含有其调用的那一侧。还有一道保险:如果可安全压缩的区间本身不比要替换它的摘要大,就干脆不压——否则轨迹会变长,还白花一次摘要调用。

替换方式也不同:摘要以 human 角色插进去,正文带 [CONTEXT SUMMARY]: 前缀(这个字符串正好等于运行时代码里保留的旧版前缀常量),同时给 system turn 追加一句”你之前的部分工具响应可能已被摘要”。整个目录处理是异步并发的,带信号量限流和单条超时——超时的那条会被整条丢出输出文件,只在日志里留一行警告。这是数据侧才能接受的取舍,运行时绝不可能这么干。

五、边界与代价:它明确不管什么

**它不做无损压缩。**三条线都在丢信息,只是丢的东西不同:第一条丢工具输出正文,第二条丢中段原文,第三条丢中段 turn 加超时条目。任何”压完还能完整回溯”的期待都不成立——第二条线的原地模式保留了软归档的原始行,那是给排查和恢复用的,不是给模型用的。

**摘要质量不在它的控制范围内。**结构模板、脱敏要求、字数目标都是提示词层面的约束,代码只能在事后做有限的确定性修补(补技能标记、规范前缀、剥旧前缀)。模型把某个关键值写丢了,它不知道。

**它不管你的活该不该被记住。**记忆是另一套东西,压缩只是在边界处通知记忆提供方,并把对方返回的一段文本纳入摘要提示词(还带长度上限和”只当素材、不当指令”的包裹)。指望压缩来做长期记忆是找错了地方。

**不适用的场景也很明确。**没有可用辅助模型时,第二条线退化成丢弃中段;被保护区域本身就装不下时,压缩会记一次”无效”并靠反抖动机制停下来,此时代码会给用户一条明确警告,让你开新会话或手动重试,而不是假装压缩成功;轨迹压缩根本不该被拿来当运行时压缩用,它的边界规则是为训练格式服务的。

**还有一层不该被压缩讨论掩盖的代价。**这是个会常驻在你机器上、能开终端执行命令、能连聊天软件账号、能往磁盘写文件、也会访问外部服务的程序。压缩这条链路把你的文件路径、命令输出和报错内容送去辅助模型,并把摘要持久化到会话存储里。这些都是真实的暴露面,配置辅助模型提供方时值得当一次安全决策来做,而不是随手填个 auto

六、上手与避坑清单

别一上来就调压缩阈值。 会踩的原因:阈值调低确实更早压缩,但每次压缩都是一次摘要调用加一次缓存前缀失效,成本和质量损失都在涨。怎么避:先确认辅助压缩模型可用(启动警告里会直接告诉你不可用的后果),再考虑打开 compression.proactive_prune_tokens 这条不花模型 token 的线,让它先把重复的工具输出吃掉。

别把保护尾长度当成”越大越安全”。 会踩的原因:compression.protect_last_n 和尾 token 预算都会把消息锁在可压区之外,配得过大时一串大工具输出会被冻住,压缩每轮都跑、每轮都压不动。怎么避:代码里对消息条数下限做了封顶,正是为了防这个;你调配置时同样要盯着”是否还有可压的中段”,而不是只盯尾部安全感。

别忽略”压缩被跳过”和”压缩失败”的区别。 会踩的原因:两种情况下返回的消息列表长度都不变,从外面看一模一样,很容易一起当成”没东西可压”。怎么避:代码专门区分了因锁跳过、无可压窗口、摘要失败冷却等类别,并各自打不同的日志与状态;排查时先去看这条压缩尝试的日志行,再决定是重试还是换配置。

别在改配置时只改一处。 会踩的原因:触发阈值、尾预算、辅助模型窗口三者是联动的——启动检查在自动调低阈值时会同步改尾预算,注释里写了不同步的后果:尾部的软上限比触发点还宽,压缩几乎什么都留着,然后立刻再次触发。怎么避:改完对着 cli-config.yaml.examplecompression: 段整段复核一遍,别只改 threshold

别把轨迹压缩的默认路径当成已存在。 会踩的原因:脚本默认去读 configs/trajectory_compression.yaml,当前仓库里没有这个文件,脚本会打印一句提示然后用内置默认值继续跑。怎么避:要用就显式传 --config,并且先用 --dry_run 看清会处理多少条、写到哪里;配合 --sample_percent 先在小样本上过一遍。

别默认多入口并发是安全的。 会踩的原因:这个项目会有后台复查、会话维护等路径共用同一个会话 id,两个压缩同时提交就会分叉。怎么避:如果你要在它上面加自己的调用路径,先读懂那把会话锁和提交栅栏的语义,别绕过 compress_context() 自己直接改会话存储。

收尾:三个问题和一条阅读顺序

下次纠结”要不要压”的时候,先问自己三个问题:这一轮我是在省重复付费(第一条线),还是这段对话真的装不下了(第二条线),还是我在处理已经跑完的数据(第三条线)?我能接受丢掉的是工具输出正文,还是中段原文?失败的时候我希望它降级成什么——丢中段、还是直接停下来告诉我?

想自己读代码,建议这个顺序:先 agent/context_engine.py 看清抽象边界和各个钩子的语义,它最短且注释最像设计文档;再 agent/context_compressor.py_prune_old_tool_results(),理解那条不花钱的线;然后跳到 _generate_summary() 看提示词与模板;最后才读 agent/conversation_compression.pycompress_context(),那是全仓最密的地方,注释里几乎每一段都对应一个真实故障。轨迹压缩留到你需要处理数据时再看,它自成一体,不读也不影响你理解运行时。

本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管 Agent 项目 Hermes Agent 的状态层拆法开源自托管 Agent 项目 Hermes Agent 的三层工具收纳

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。