开源自托管项目 Hermes Agent:一轮对话经历的会话与回合边界

2026-07-30

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

判断一个 Agent 能不能长期跑下去,别看它单轮答得多漂亮,看它为「这一轮没走完」写了多少代码。 这里说的 hermes-agent(仓库 https://github.com/NousResearch/hermes-agent ,MIT 许可证,LICENSE 署名 Nous Research)是一个常驻自托管的开源 Agent 项目:它长期跑在你自己的机器上,接你的聊天账号、开终端执行命令、往磁盘写文件。请先和 Nous Research 的 Hermes 开源模型系列区分开,也和若干同名商标、同名库区分开 —— 本文全程讲的是那个程序,不是那批权重。它的 docs/session-lifecycle.md 把会话这一层的状态机写得相当直白,而 agent/turn_context.pyagent/conversation_loop.pyagent/turn_finalizer.py 是回合这一层的实现。文档和代码对着读,一个长跑 Agent 绕不开的那些边界就都摊开了。

站内已有的 pi 的 Agent 循环拆解 讲的是另一个项目的循环骨架,Agent 上下文管理无限循环与停机判据 讲的是不挑项目的通用方法论;这篇只做一件事 —— 把这个具体仓库的文档和代码对照着看,看它把那些边界落成了哪几行。

一、先把「会话」和「回合」分成两层

这个项目里最容易读串的,是两个长得很像的名字。session_key 是会话车道的标识,由平台、聊天类型、聊天 ID、线程 ID、参与者 ID 拼出来,格式在文档里写得很清楚:

agent:main:{platform}:{chat_type}[:{chat_id}][:{thread_id}][:{participant_id}]

session_id 是这条车道的当次化身,形如 YYYYMMDD_HHMMSS_ 加八位十六进制。车道是稳定的,化身是可以被换掉的 —— 所谓「重置会话」,本质就是给同一个 key 换一个新的 id,旧 id 的记录在 SQLite 里被标记结束。想清楚这一点,后面所有标志位才有意义。

一条会话的元数据落在 SessionEntry 上,除了时间戳和累计 token 计数,它带着一组布尔标志构成一个很小的状态机:suspended 是硬清空信号;resume_pending 是软恢复标记,下次访问保留原有 session_id,用户继续在同一份记录上说话;was_auto_reset 记录上次是因策略过期被自动重置,用来一次性注入提示;is_fresh_reset 专门区分「用户自己敲了重置指令」和「系统判它过期」,免得给用户看一条误导性的过期通知;expiry_finalized 防止收尾动作跨重启重复执行。

关键在于优先级,文档里明确列了顺序:硬清空最高,软恢复第二,策略过期第三,都不触发才返回原条目并刷新活跃时间。这个顺序不是随手排的 —— 软恢复必须能被硬清空压住,否则一个卡死的会话会被反复「恢复」回同一个坑里。先把「车道 / 化身 / 一组标志位 / 一个固定优先级」这四件事装进脑子,再去看网关代码,否则每个分支看起来都像特例。

二、回合开场:build_turn_context 里的一次性准备

agent/turn_context.py 的模块注释交代得很实在:run_conversation 原来开头有大约四百七十行直线式准备代码,跟循环没有任何回指关系,于是被整块搬进 build_turn_context,返回一个 TurnContext 数据类。这份「开场」里有几件事值得单独说。

一是重置的范围。各种重试计数器、工具护栏状态、流式内容清洗器都在这里归零,迭代预算重新构造为 IterationBudget(agent.max_iterations)。但注释特意标了两个计数器不重置 —— 记忆提醒和技能提醒的间隔计数是跨回合累积的。这类「哪些该重置、哪些不该」的取舍,是长跑 Agent 里最容易埋 bug 的地方。

二是顺序约束。系统提示词先恢复或构建,再创建数据库里的会话行,再做压缩。注释解释得很具体:先建行的话缓存提示词还是空,写进去的快照就是 NULL,后面会白给一次「存的提示词是空的」告警和一次前缀缓存未命中;而会话行又必须早于压缩,因为就地压缩会插入引用该会话的消息行、轮换模式会创建指向它的子会话,外键约束打开时缺父行两个插入都会失败。

三是两种压缩触发。一种按闲置时长触发,默认关闭,配置里对应压缩段下那一项;判定逻辑被提成纯函数,读起来很清爽:

if not enabled or idle_after_seconds <= 0:
    return False
if idle_gap_seconds < idle_after_seconds:
    return False
if cooldown_active:
    return False
return tokens > floor_tokens

注意最后一行:上下文已经低于「压缩后会降到的目标大小」时不做压缩,省掉一次不产生收益的摘要调用。另一种按 token 阈值触发,多趟执行,趟数受配置里的压缩最大尝试次数约束,默认 3。判断「这趟有没有进展」时它不只看消息条数 —— 摘要工具输出可能条数不变但 token 明显下降,只看条数会误报「无法继续压缩」,所以判据是条数减少或者 token 至少降 5%。

四是整个开场里最有价值的一条不变量:发出去的字节要等于存下来的字节。记忆预取结果和插件注入的上下文只加在用户消息的「API 副本」上,存进记录的正文保持干净;可下一回合重放这条消息时不会带注入,请求前缀就在这里分叉,后面整轮的助手与工具链全部要重新预填。它的解法是给消息挂一个 api_content 边车字段,把这一回合真正发出去的字节原样存下来,重放时替换回去。围绕它有三个配套函数:组合、替换、以及在任何重写了正文的路径上主动丢弃边车 —— 重放一份过期边车等于把你刚删掉的内容再发一遍,丢掉它的代价只是一次缓存边界未命中。

五是压缩之后要重新定位当前这条用户消息。压缩会用新副本替换列表条目,还可能在存活副本之后追加待办快照消息,压缩前记下的下标就废了;它的做法是从尾部往前找最后一条正文完全匹配本回合文本的用户消息,找不到才退回最后一条用户消息。

六是崩溃前先落库:第一次模型调用之前用户消息就写进 SQLite,而且刻意排在压缩和插件钩子之后,好让这一行一次写全、带上最终的边车。

三、循环体:那些「模型没按套路来」的分支

agent/conversation_loop.py 有七千行出头,其中 run_conversation 的主循环就是一台错误处理机器。挑几条对你有迁移价值的。

中断和改口。 循环顶部先排空「中途改口」,把用户的修正接进当前回合。这里有一条用大写 INVARIANT 标出来的规则:原始思维链绝不能被序列化进可重放的消息正文。注释写了后果 —— 助手回合的正文里内联自己的思维链,会被输出侧分类器读成推理注入,而这个被污染的检查点是持久化并且每次调用都重放的,于是会话永久性死掉,任何重试都逃不出来。它的处理是只保留可见文本并降级成普通文本,修正作为真实用户消息追加;屏幕上原本什么都没有时,这一行直接标成隐藏类型,仍然重放给模型,但所有记录界面都不显示。

先落库再执行工具。 助手的工具调用回合在任何工具副作用之前先增量写库,理由是某个破坏性工具可能重启或终止进程,恢复逻辑得看见那批已经执行过的调用。写库失败就直接判回合失败并跳出:不拿只存在于本进程内存里的状态去跑有副作用的工具,也不用同一个未落库的回合把迭代预算耗光。工具执行完之后还会再检查一次落库是否失败,判断相同。

批次里混进非法工具名。 助手消息保留模型发出的每一个调用,只对非法的那些就地回一条错误结果,再把它们从待执行集合里剔掉 —— 因为提供方要求每个调用都有配对的结果,直接删调用会让请求非法。各家提供方的校验严格程度不同且会调整,以官方最新说明为准。

声明了工具调用却没带调用。 注释记录了一个真实现象:某些线路上返回的结束原因是工具调用,但解析出来的调用数组是空的,模型只把打算做的事叙述了一遍;走到收尾时这段叙述会被当成最终答案,任务其实一步没动。它的处理是重新提示,上限三次连续失败,任何一次成功的工具回合都会把额度清零,两条脚手架消息都打标记不写进持久记录。

空回复的三级兜底。 先看流式里是否已经吐出过有效内容,有就直接当最终回答(并且明确不标记为「已预览」,好让网关补发异常说明);否则看上一回合是不是已经给出真实内容、且那一轮的工具全是记忆、待办这类事务性工具 —— 是的话说明模型确实没别的要说,直接复用;两条都不成立才走提示重试和思考预填。分级判据落在「上一轮的工具是不是事务性的」,因为上一轮调的若是终端、搜索、写文件,那段内容更可能是任务中途的旁白,模型是卡住了而不是说完了。

四、一个回合到底由哪些部件收尾

组成部分它负责什么对应仓库位置你什么时候会碰到它
会话生命周期文档车道键生成、标志位状态机、重置策略、重启恢复、消息排队docs/session-lifecycle.md配置群聊隔离或排查「会话怎么自己重置了」
会话数据模型与存储消息来源描述、会话条目、存储层与键生成gateway/session.py改隔离粒度、加平台、读写会话元数据
网关运行器过期巡检、Agent 实例缓存、重启恢复、消息排队gateway/run.py长期常驻后内存增长、重启后自动续跑
回合开场计数器重置、提示词与会话行、两种压缩触发、边车与预落库agent/turn_context.py排查前缀缓存频繁失效、首回合告警
回合主循环模型调用、工具分发、各类恢复分支、压缩与剪枝agent/conversation_loop.py排查「它怎么就停了」「回答是空的」
回合收尾预算兜底、脚手架剥离、落库、诊断日志、结果字典agent/turn_finalizer.py回答丢失、记录里少一条助手消息
迭代预算一个回合允许多少次模型调用,以及退款agent/iteration_budget.py想调「它最多折腾几轮」
压缩协作层压缩后的历史重建、锁跳过判定、状态文案agent/conversation_compression.py多路径并发压缩同一会话
消息序列修补角色交替修复、中断时补齐工具序列、字符清洗agent/message_sanitization.py提供方拒绝请求、上一轮被截断
停止前验证改过文件就再顶一轮的提示生成agent/verification_stop.py它改完代码不验证就收工

收尾里有三条硬账。

第一条是预算耗尽时怎么给用户一个答案。如果答案是被验证门主动扣住的,就复用那份答案,而不是再赌一次模型调用;只有确实没有任何待用答案时,才做一次剥掉全部工具的总结调用。那个显式待用值同时是来源守卫 —— 不相干的错误或恢复路径永远进不了这个分支。

第二条是一条不变量:交付出去的最终回答,必须在记录里有对应的助手行。不遵守的后果写在注释里 —— 持久记录以工具或用户消息结尾,下一回合模型看到一串「没人回答」的用户消息,就会把每条都重新答一遍。处理有两种:尾巴不是助手行就追加一条;尾巴是助手行但只有工具调用、没有自己的可见文本,就把回答填进那一行的正文,同时把「已写库」标记弹掉,让下一次落库把填好的内容重写进去,否则恢复会话时读回来还是空的。

第三条是收尾三个动作各自独立保护:保存轨迹写文件、清理任务资源跨网络关远端、落库写 SQLite,三个都会抛错,于是各包一层,错误收进结果字典的专门字段,而不是让异常穿出函数把用户等着的那份回答弄丢。同一段里另有两个实用设计:一轮中失败的写文件或补丁、且之后没有对同一路径的成功写入,会在回答末尾追加提示,让模型没法笼统宣称「所有文件都改好了」;回合退出原因一定进日志,最后一条消息是工具结果又不是用户中断时级别提到警告 —— 这正是用户口中「它就这么停了」的情况。

五、边界与代价:这套设计放弃了什么

放弃了简单。 换来长跑能力的代价,是一个状态机、一组标志位,以及大量带 issue 编号的特例分支。这些分支通常有真实故障作背书,但理解成本是实打实的。想抄这套设计之前先确认你的场景真会跑到这些边界上;一次性的无状态调用套这套东西纯属自找麻烦。

过期就是过期。 会话被判定过期并完成收尾后,SQLite 里的会话行会被推进到重置结束状态,注释写明目的是让陈旧路由恢复没法把过期会话连着完整历史一起复活。别指望「过期了再接回来」,这条路是被有意堵掉的。

压缩就是丢信息。 摘要式压缩本质有损,仓库把「有没有材料性进展」量化到 5%、给缓存断裂设了最小回收阈值,这些只让损失可控、可预期,不让它消失。

并发写同一会话没有保护,只有报警。 开场里有一段绊线逻辑:本回合若在上一回合的收尾落库之前就开始,会带着两个回合标识打告警。它告诉你出问题了,但不阻止你出问题。

边车字段是缓存稳定性的必要代价。 做审计的场景要明白:记录里的干净正文并不等于当时发给模型的字节,两者有意不同。

默认值的方向不一致。 群聊和频道默认按用户隔离,线程默认所有参与者共用一份上下文。对一个会读写文件、执行命令的常驻程序来说,隔离粒度配错的后果不是体验问题,是把一个人的上下文交到另一个人手里。

它明确不管的事。 后台还有活跃进程的会话永不过期也不清理,这条豁免写在策略评估里;模型该不该被信任、终端里该不该允许这条命令,不在这几个文件的职责范围内。

六、上手与避坑清单

先手推一遍你的车道键,再动隔离配置。 会踩的原因:群聊默认隔离、线程默认共享,两个默认值方向相反,凭直觉配必错一个。避法:按文档里的键格式,用你实际的聊天类型手推一遍完整键,确认参与者标识该不该出现在末尾,再改配置。

别照抄文档里的重置策略默认值。 会踩的原因:docs/session-lifecycle.md 的策略章节表格把「空闲与每日二者取先」标成默认,附录的配置示例里注释写的却是不自动重置为默认 —— 文档两处标注不一致。避法:以你自己配置文件里的实际值为准,并且真等一次空闲窗口过去,看它到底重置没有。

别用重启来「清干净」一个卡住的会话。 会踩的原因:没有干净退出标记时,启动流程会把最近活跃的会话标成待恢复并自动续跑,你以为是清零,其实是接着跑。避法:要清空就走显式停止那条硬清空路径;连续三次重启都还活跃会被自动挂起,那是兜底不是常规手段。

改任何重写消息正文的代码,记得同步丢掉边车。 会踩的原因:正文改了而边车还留着,重放会把你刚删掉的内容原样再发一遍。避法:仓库里已有专门做这件事的辅助函数,加新的重写路径时照着调。

排查「它怎么就停了」,先读回合退出原因。 会踩的原因:从用户视角看是回答为空或只有半句,容易当成模型能力问题。避法:找那条固定格式的回合结束日志,里面有退出原因、调用次数、预算用量、最后一条消息的角色和回答长度,最后一条是工具结果时是警告级别,很好找。

别指望「到点重置」一定会发生。 会踩的原因:只要会话还挂着活跃后台进程,过期和清理都会跳过它。避法:先查有没有没收干净的后台进程,再去怀疑策略配置。

往下有三条路:想搞清楚会话数据怎么存、键怎么生成,读 gateway/session.py;想搞清楚常驻之后内存怎么不涨、实例缓存怎么淘汰、重启怎么续跑,读 gateway/run.py 里的过期巡检任务;想搞清楚压缩策略和多路径并发时的让路逻辑,读 agent/conversation_compression.py。另外 tests/ 下有两千多个 test_ 开头的测试文件,改上面任何一条边界之前先去那里找对应用例 —— 这些分支几乎条条有真实故障作背书,靠读代码猜意图容易猜偏,用例才是它们的行为说明书。

本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管 Agent 项目 Hermes Agent 的仓库结构导读开源自托管 Agent 项目 Hermes Agent 的 gateway

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