自托管开源项目 Hermes Agent 的跨会话记忆由谁写入、何时写

2026-07-30

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

这个项目的记忆不是”检索出来的”,而是模型自己动手写下来的两个 markdown 文件——所以它的成败取决于两件事:写入时机的触发机制,和字符预算的硬闸门。先说清名字:这里讲的是 Nous Research 的开源自托管 Agent 项目 hermes-agent(MIT 许可证,LICENSE 署名 Nous Research),不是同一家的 Hermes 开源模型系列,也不是任何同名商标或库。

站内已有三篇相邻内容,分工先划清:Agent 记忆的三层划分 讲的是通用方法论——会话内、任务内、跨任务该怎么分开存;另一个 Agent 项目的记忆系统实操 讲的是别的项目怎么落;记忆污染与莫名失忆 讲的是通用故障面。这篇不重复它们,只做一件事:把这一个具体项目的记忆机制落到文件、函数和配置项上,让你能对着仓库自己核。

一、记忆由谁写入:一个工具、两个库、字符预算

内置记忆的全部实现集中在 tools/memory_tool.pyMemoryStore 类。它维护两个平行的库,落在 Hermes home 目录下的 memories/ 里:MEMORY.md 存 Agent 自己的笔记(环境事实、项目约定、工具怪癖、踩过的教训),USER.md 存它对你的认识(偏好、沟通风格、期待、工作习惯)。Hermes home 的默认位置按平台不同,也可以用环境变量覆盖,所以别把路径写死在你的运维脚本里。

条目之间用一个不常见的分隔符隔开,这不是随手挑的:

ENTRY_DELIMITER = "\n§\n"

条目可以跨多行,所以分隔符必须是正文里几乎不会出现的东西。解析时它按完整分隔符串切分,而不是单独按 § 切——后者会把正文里含 § 的条目劈成两半。

写入入口只有一个工具,动作三种:addreplaceremovereplaceremove 不用 ID、不用全文,而是用一段”短的唯一子串”去匹配要动的那条;匹配到多条且内容不同就直接报错让模型说得更具体,匹配到多条完全相同的重复条目才允许操作第一条。另外还有一个批量形状:一次调用里传一串操作,原子生效,并且只对最终状态做预算校验——这让模型可以在同一次调用里先删掉几条陈旧条目腾地方、再把新条目加进去,不必走”先合并、再重试”的多轮往返(那意味着整段对话上下文要重发好几遍)。

预算用的是字符数而不是 token 数,源码里给的理由很实在:字符数与模型无关。两个库各有自己的字符上限,笔记库比用户画像库宽一些;上限是构造 MemoryStore 时传进去的参数,也能在配置里覆盖,所以别把”我记得它能存多少”当常识——先看自己这套配置。

最容易被忽略的是快照机制。会话启动时 load_from_disk() 把两个库渲染成一段带用量提示的文本块,冻结成快照注入 system prompt;会话中途的写入立刻落盘(durable),但不会改动这一轮的 system prompt。这么做是为了让 system prompt 在整个会话里逐字节稳定,保住服务商的前缀缓存;代价是模型这一轮存下去的东西,要等下一次会话启动重新取快照才会”进脑子”。

组成部分它负责什么对应仓库位置你什么时候会碰到它
内置文件存储与三种动作两个 markdown 库的读写、字符预算、快照渲染tools/memory_tool.py开启记忆后的第一轮就在用
轮次计数器数用户轮,到点了置位”该复盘记忆”agent/turn_context.py每隔若干个用户轮触发一次
后台复盘分叉回复交付后另起一个 Agent 去审对话、决定存不存agent/background_review.py计数器到点且本轮正常收尾时
多后端编排内置 provider 加最多一个外部 provider 的生命周期调度agent/memory_manager.py配了外部记忆后端才活
后端配置声明每个后端把自己的可配字段声明出来,由通用面板渲染plugins/memory/config_schema.py在桌面界面填密钥、base URL 时
技能库策展者技能的老化与归档(不管记忆agent/curator.py只影响技能目录,不动记忆文件

二、什么时候它被提醒去写:计数器加一次独立复盘

模型不会每轮都想起来存东西,所以项目里有一个显式的提醒机制。agent/turn_context.py 在每个用户轮开始时给计数器加一,到阈值就置位并归零:

    should_review_memory = False
    if (agent._memory_nudge_interval > 0
            and "memory" in agent.valid_tool_names
            and agent._memory_store):
        agent._turns_since_memory += 1
        if agent._turns_since_memory >= agent._memory_nudge_interval:
            should_review_memory = True
            agent._turns_since_memory = 0

间隔从配置里的 nudge_interval 读出来,设成 0 就等于把提醒整个关掉(判断的第一条就是间隔为正)。三个前置条件都要满足:间隔为正、记忆工具确实在本次会话的可用工具名单里、内置存储已经建起来。同一处还有一段从历史里补计数的逻辑——恢复一段旧会话时,用历史里的用户轮数对间隔取余,免得计数器永远从零开始、提醒永远不触发。

真正的动作发生在 agent/turn_finalizer.py:只有在本轮拿到了最终回复、并且没有被中断的情况下,才会去起一个后台复盘。注释写得很清楚——它跑在回复交付之后,这样就不会跟用户的正事抢模型注意力。

复盘本身是 agent/background_review.py 里一个 fork 出来的 Agent,用的提示词很短,只问两件事:用户有没有透露关于自己的东西(人设、偏好、个人细节),有没有表达对 Agent 该怎么表现、按什么风格工作的期待;没有就回一句 “Nothing to save.” 停手。

这个分叉的隔离处理值得单独看,因为它踩过很贵的坑。它继承父 Agent 的存储对象,所以复盘里的写入照样落盘;但它把自己的提醒间隔清零防递归,把持久化整体关掉,并且不带自己的会话数据库。注释里写明了原因:它和父 Agent 共用同一个会话 ID(为了命中同一段前缀缓存),如果不关持久化,那句”审一下上面的对话”的 harness 提示词会被写进用户真实的会话记录里,用户下一轮开口时,Agent 读到这条像是常驻指令的历史消息,就”变成”了那个复盘角色,拒绝干真正的活。它同时关掉了上下文压缩(共用会话 ID 的分叉赢了压缩竞争会把父会话轮换成一个没人认领的子会话),并且给这条线程装了自动拒绝危险命令的回调。

三、记忆多了之后的反效果

条目越多,代价不是”检索变慢”,而是三件更具体的事。

**第一,快照是每次会话的固定开销。**它进 system prompt,意味着这一轮之后每一次请求都带着它。字符上限就是这笔固定成本的闸门,把它调大等于给每一轮加常驻负担。

第二,满了之后的合并动作发生在用户等着回答的那一轮里。add 会撑破上限时,工具返回的不是一句”满了”,而是当前全部条目加用量,并要求模型在这一轮内先合并或删除、再重试。这个循环有熔断。MemoryStore 上有一个每轮清零的失败计数器(轮起点由 agent/turn_context.pyreset_consolidation_failures() 归零),和一个类属性形式的每轮失败上限 _MAX_CONSOLIDATION_FAILURES_PER_TURN。同一轮里的合并失败次数超过这个上限,工具就改口:不再给”这一轮里自己纠正后重试”的指令,而是返回一个带 done 标记的终结性结果,明确告诉模型停止重试、把这条事实留到以后的轮次、先把回复给用户。源码注释把这个判断写得很直白:一个失败的副作用绝不能吃掉这一轮该给用户的回答。

反方向也做了限制——写入成功时返回的响应故意回显全部条目,只给用量百分比、条目数和一句”这次更新完成了,别重复”。理由是回显整份清单会诱使模型”再找点东西修”,观测到的行为是第一次调用就给出了正确的批量操作,然后又重发了五次冗余的同样操作。

**第三,记忆没有自动老化机制。**这一点要跟同一个仓库里的技能库对比才看得清:agent/curator.py 给技能准备了完整的生命周期——按不活跃时长自动标记陈旧、更久不用就归档(只归档不删除,归档可恢复),被固定的技能和被定时任务引用的技能一律跳过,另外还有一个默认关闭的、用辅助模型做合并的可选环节。记忆没有这一套。陈旧条目要么靠模型自己在下一次 replace/remove 时清掉,要么靠你人工介入。顺带说,那个策展分叉本身也是明确不碰记忆的。

四、可插拔的记忆后端意味着什么

agent/memory_manager.py 是编排层。它的规则很硬:内置 provider 永远排第一,外部 provider 同一时间只允许一个,第二个注册请求会被拒绝并留一条警告,提示你去配置文件里选。理由写在模块开头——避免工具 schema 膨胀和后端互相冲突。

管理器给 provider 开的口子是一整套生命周期钩子:贡献 system prompt 片段、轮前召回、排队下一轮的召回、轮后同步、会话结束、会话 ID 轮换、压缩前、内置记忆工具写入时的镜像通知、子任务完成通知。仓库里捆绑了若干个后端目录(plugins/memory/ 下能看到 byterover、hindsight、holographic、honcho、mem0、openviking、retaindb、supermemory),用户自己装的后端放到 Hermes home 的 plugins/ 下,同名时捆绑的优先。

这层的工程细节暴露了”接外部服务”的真实成本:

  • 召回有超时。内置 provider 的召回是直接同步调的;外部 provider 的召回被放到一条守护线程上,主线程只 join 有限的一段时间(模块常量 _EXTERNAL_PREFETCH_TIMEOUT_S,构造管理器时可以覆盖)。超时就跳过这一轮并警告,在那个卡住的调用返回之前一直跳过它;上一次召回线程还活着时,这一轮也直接跳过。也就是说召回不保证到齐,而且一个 provider 挂了不阻塞别人。
  • **同步不在主路径上。**轮后同步走一个单 worker 的后台执行器串行落盘(保证第 N 轮先于第 N+1 轮)。注释里记了促成这个改动的现场:一个配置错误的后端守护进程阻塞了很久才失败,当时是内联执行,整个对话调用被挂住,各界面把 Agent 标成”运行中”迟迟不动,用户忍不住又发消息,触发了激进的中断处理。
  • 关机时只给有界的排空窗口。shutdown_all() 等在飞的同步与召回收尾的时间由 _SYNC_DRAIN_TIMEOUT_S 限住,超窗会明确统计并记录被放弃的写入数和召回数;worker 是守护线程,超窗的活随解释器一起死。最后一轮的同步不保证落地。
  • **召回内容是被围栏包住的。**召回文本会被包进一对 <memory-context> 标签,块内带一句系统说明(写明这是回忆起来的记忆上下文、不是新的用户输入),并且会先剥掉 provider 自己预包的围栏。流式输出还有一个跨 chunk 的状态机清洗器,防止围栏里的内容漏到界面上;碰到没闭合的围栏时它选择丢弃而不是泄漏。
  • 工具 schema 形状必须归一。有的 provider 返回的已经是包好的工具形状,再包一层就得到一个内层没有顶层 name 的东西,严格的服务商会以”某个工具缺 name”为由直接拒掉整个请求——一个坏 schema 就能废掉整套工具、废掉每一轮。编排层做了归一化,解析不出名字的直接跳过并警告;核心工具名(比如澄清、派发子任务那几个)也不允许被 provider 抢注。

配置面板走的是声明式路线。plugins/memory/config_schema.py 是一个纯数据模块,定义了字段种类(文本、下拉、密钥、布尔、数字、JSON)和两种存储后端,每个 provider 在自己目录下放一份 config_schema.py 声明字段,界面和读写接口都由通用逻辑驱动,加一个后端的配置界面等于纯声明、不写界面组件。密钥类字段单独存进环境变量存储,接口只回一个”是否已设置”的标志,不回读明文。另外两个细节能省你的调试时间:schema 文件是按路径加载而不是包导入(provider 的 __init__.py 会把 Agent 运行时拉进来,不能进 web 服务进程),加载失败不进缓存(否则一次失败会把空面板钉到重启为止)。

五、边界与代价:它明确不管的事

内置记忆工具的说明里有一段明确的排除项:琐碎或显然的信息、容易重新发现的事实、原始数据倾倒、任务进度、已完成工作的日志、临时的待办状态——后面这几类指向会话检索工具,不该占记忆的额度。可复用的操作流程也不归记忆,归技能。这个取向很清楚:这两个库存的是”稳定的偏好与环境事实”,不是工作日志。

其余代价按重要性排:

  • 中途写入不改本轮 system prompt。存了不等于本轮立刻用得上,这是换前缀缓存稳定性付的账。
  • 外部后端只能有一个。想同时用两套的,第二套只会留一条警告,现象是”另一个后端没生效”。
  • 召回与同步都可能被跳过或被放弃(上面两个超时常量)。它不是事务性的记忆系统。
  • 装第三方记忆后端等于往 Agent 进程里加第三方代码。后端目录是被 importlib 按路径加载执行的 Python 包,跑在 Agent 自己的进程和权限里,能看到每一轮的用户消息与回复;捆绑目录在同名时优先,你放进 Hermes home 的同名目录会被静默忽略,排查时容易误判成”我的后端没生效”。装之前先读那个后端的源码,别只看它的 README。
  • 这个项目会常驻在你的机器上、开终端执行命令、往磁盘写文件、访问外部服务,也能接你的聊天账号。记忆文件是明文 markdown,躺在你本机的 home 目录下——能读你 home 的人就能读它。配了外部后端,等于把每一轮的用户内容按轮同步到那个服务;自托管和云端在这件事上完全不是一回事,选之前先想清楚。权限面怎么收,参考 最小权限的 Agent 设计
  • 记忆是一条注入面,因为它进 system prompt。项目在两处做了扫描:写入前扫,加载建快照时也扫,命中的条目在快照里被替换成一个 [BLOCKED: …] 占位,原文保留在活状态里让你能看见并删掉——注释里说明了不静默丢弃的理由:那会把攻击藏起来。但这是模式匹配,不是保证;配套的思路见 提示注入的防御

六、上手与避坑清单

**以为装上就有记忆。**内置存储只在两个开关(记忆本身、用户画像)至少有一个打开时才创建;两个都关着,工具函数拿到的存储是 None,每次调用都直接返回一句”记忆不可用,可能在配置里被关了”。会踩是因为这两个开关跟你的配置档案绑在一起,换个 profile、换台机器就不一致。判断有没有真开,最直接的是去 Hermes home 的 memories/ 下看有没有那两个 markdown 文件、里面有没有条目——命令行里的 /memory 斜杠命令只管待审列表和审批开关,不会把库里的条目列给你,别指望用它当”查记忆”的入口。

**手工用编辑器改记忆文件。**项目有一个漂移守卫:改写类动作(replace/remove/批量)执行前会检查磁盘内容能不能原样往返解析,或者有没有单条超过整库上限;一旦判定漂移,它备份一份带时间戳的 .bak 并拒绝这次写入,还会告诉你怎么收拾。会踩是因为手改很容易破坏 § 分隔结构。要么把文件整理成干净的分隔条目列表,要么把多出来的内容移走,然后重试。

**用补丁工具或 shell 往记忆文件里追加长文本。**追加进去的内容会被当成一条巨大的条目,单条超过整库上限就直接触发漂移判定。会踩是因为它看起来就是个 markdown 文件,很像笔记本。它不是——它是一个有预算的条目列表。

**指望记忆记住任务进度。**工具说明把任务进度、已完成工作日志、临时待办都排除了。会踩是因为”跨会话记住”听起来就该包括这些。写进去的直接后果是挤掉真正稳定的偏好,然后触发前面说的那套满库合并。

**打开写入审批却只依赖后台复盘。**审批开关打开后,前台轮里的写入是内联提示你确认的,而后台复盘线程不能阻塞在提示上,它的写入会被暂存并给回一个待审 ID。会踩是因为体感上”记忆功能突然不工作了”。去看待审列表并批准,写入才会被回放到存储上。

**并发会话同时写。**有独立的锁文件(跨平台两套实现)和临时文件加原子替换,读者永远看到完整文件。但 add 走的是”读—改—写整个文件”,所以文件存在却读不出来时,它会拒写而不是当成空库——否则一次瞬时读失败就能把整个库重写成刚加的那一条。看到”拒绝写入”不是 bug,是保护,过一会儿重试。

**自己写外部后端时赌工具 schema 的形状。**编排层会帮你归一化和跳过无名 schema,但严格服务商拒的是整个请求,一轮全废,排查时看起来像模型坏了。自己写的时候按裸函数 schema 给,别包第二层,也别抢核心工具名。

收尾:一份能自己核的清单

对着仓库过一遍这几个问题,你就知道要不要开、开到什么程度:两个库分别该存什么,我说得出口吗;提醒间隔和字符上限跟我的会话长度匹配吗;陈旧条目谁来清——如果答案是”没人”,那就得靠人工排期;外部后端我打算把对话内容同步到哪里,那个地方我信得过吗;写入审批我要不要打开,打开之后待审列表谁去看。

阅读顺序建议照着链路走:先 tools/memory_tool.py(读到快照与批量那两段就够本),再 agent/turn_context.py 里那个计数器,然后 agent/background_review.py 看提醒之后到底发生什么,接着 agent/memory_manager.py 看多后端的调度与超时,最后 plugins/memory/config_schema.py 看配置面是怎么声明出来的。想对照通用方法论,再回头看开头提到的那篇三层划分里给的判别表。

本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管项目 Hermes Agent开源自托管 Agent 项目 Hermes Agent 的会话检索链路

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