扫码打开虎嗅APP

搜索历史
删除
完成
全部删除
热搜词
Hermes Agent通过五层架构实现高效多平台AI代理,支持20+消息平台,采用适配器模式统一内外消息格式,核心机制包括迭代预算、工具并行、子任务委托和自进化技能系统,相比OpenClaw更强调性能优化和跨平台扩展性。 ## 1. 多平台适配与统一消息处理 - **适配器模式**:Hermes为20+平台(飞书、Slack等)实现独立适配器,将异构消息统一为内部`MessageEvent`对象,核心代码无需感知平台差异 - **消息路径标准化**:进出流程均通过适配器转换,附件处理采用`MEDIA:`前缀统一标记,平台适配成本降至单个适配器开发 ## 2. 核心执行机制设计 - **迭代预算系统**:父/子Agent分别限制90/50轮推理,PTC(程序化工具调用)脚本执行可退还预算,同等任务比串行工具调用节省87.5%预算(8轮→1轮) - **工具并行策略**:三类工具并行规则(禁用并行/路径隔离并行/安全并行),8线程池使多工具任务耗时降低60-70%(10次→2-3次API往返) - **子任务委托**:子Agent独立运行50轮预算,禁用5类工具(含`delegate_task`防递归),父Agent通过心跳实现级联中断控制 ## 3. 记忆与上下文管理系统 - **双文件记忆**:`MEMORY.md`(2200字符)和`USER.md`(1375字符)冻结为会话快照,写入时需通过5类安全正则检测 - **上下文压缩**:100K上下文触发压缩时保留20K尾部token,摘要模型12000token上限,session链式存储实现历史追溯与成本控制分离 - **8种外部记忆集成**:支持Honcho等Provider,但运行时仅允许激活1个外部记忆源 ## 4. 自进化技能体系 - **双信号驱动**:系统提示词引导主动记录+每10轮强制复盘(后台线程执行),确保技能沉淀不影响主任务响应速度 - **技能安全机制**:技能文档需通过威胁模式检测,`.hermes.md`等上下文文件加载时触发10条正则扫描阻断注入攻击 - **结构化存储**:技能按Markdown文档存储,会话启动时仅加载目录,`skill_view`工具实现按需加载完整内容 ## 5. 错误恢复与系统健壮性 - **14类错误分类器**:错误映射到4种恢复标记(重试/压缩/换KEY/降级),区分402额度耗尽与429限流等相似错误 - **用户中断处理**:伪造工具结果保证消息结构合法,避免中断导致后续会话异常 - **SSL证书自动探测**:启动时检查`/etc/ssl/certs`等路径,确保HTTP库加载前完成证书配置
2026-04-27 08:35

OpenClaw vs Hermes:拆解Hermes Agent 五层架构

本文来自微信公众号: 叶小钗 ,作者:叶小钗,原文标题:《OpenClaw vs Hermes:拆解 Hermes Agent 五层架构》


书接上文:


  1. 《理论:从工程角度为你拆解OpenClaw》


  2. 《万字:拆解OpenClaw:从Gateway、Memory、Skills、多Agent到Runtime》


  3. 《万字:拆解OpenClaw上下文工程/记忆系统》


  4. 《万字:OpenClaw核心机制Skills全解析》


  5. 《实践:实现一个Mini-OpenClaw》


Hermes Agent最近火得挺厉害:两个月4.7万星,v0.8.0发布当天一天涨了6400,功能清单网上满天飞...


以致于大家都说小龙虾不行了,现在是爱马仕的时代,但我感兴趣的是另一件事,它内部到底怎么实现的,跟OpenClaw又有什么不一样,所以前几天对他进行了简单解读:


《万字:OpenClaw vs Hermes》


熟悉我们的同学会清楚,我们复杂的事情肯定不会想要一蹴而就,于是系统性的解读也就展开了,我们会去读Hermes的核心源码:



关于读Agent源码这件事,我们已经做过几次了,面对那种庞大的信息量,最好用的一条路径是:跟一条消息走一遍。


五层架构


从你在终端敲下一句话,到最后一个字回到屏幕上,中间发生的所有事,就是这个Agent的全部骨架:一条消息从头跟到尾,就能顺出它背后的完整链路。


消息接收、平台适配、会话管理、上下文组装、记忆注入、技能发现、流式执行、工具调用、上下文压缩、子Agent分发、错误恢复与凭证轮换、状态持久化


所以,我们在终端敲hermes新起会话,输入:


帮我搜集今天的热点新闻


每条新闻要分类(科技、财经、社会、国际等)


并附上简要分析和总结


看起来一句话,但拆开来看,至少要做这些事:


  1. 搜索当天的热点新闻


  2. 对每条新闻做分类判断


  3. 对每条新闻写简要分析


  4. 整理成结构化的格式输出


这个过程涉及多轮工具调用(web_search搜新闻、web_extract提取详情)、信息整合、分类归纳。如果新闻来源多、数据量大,可能还需要拆分子任务并行搜集。


那Hermes Agent是怎么把这些事串起来的?先从整体架构说起。



  • 入口层:CLI+二十多个消息平台适配器(飞书、钉钉、Telegram、Discord、Slack、WhatsApp、iMessage、Email、SMS……)


  • 网关层:GatewayRunner常驻进程,管连接、会话生命周期、斜杠命令


  • 执行层:AIAgent(run_agent.py),组装上下文、调模型、跑工具、处理错误,整个项目的心脏


  • 扩展层:工具注册中心、技能系统、子Agent委托、MCP客户端、8个外部记忆Provider


  • 存储层:SQLite+FTS5、MEMORY.md/USER.md、Skills目录、config.yaml、.env


一条消息的完整路径:


终端输入→CLI解析→会话加载→上下文组装→模型推理→工具执行→流式输出→状态落盘


下面我们一步步来看。


一、适配器模式的内外统一



终端是最直接的入口,但Hermes支持20+平台。每个平台消息格式都不一样:Telegram长轮询、Slack WebSocket、Email IMAP、SMS HTTP Webhook。


Hermes Agent给每个平台写了一个适配器,全部继承自BasePlatformAdapter。


classBasePlatformAdapter(ABC):


@abstractmethod


asyncdefconnect(self)->bool:...


@abstractmethod


asyncdefdisconnect(self)->None:...


@abstractmethod


asyncdefsend(self,chat_id,content,reply_to=None,metadata=None)->SendResult:...


但这个基类里只定义了connect/disconnect/send,我们没有看到消息转换的接口定义,仔细看了下各个平台代码的实现,转换逻辑藏在每个适配器的connect()里,监听回调拿到平台原始消息后,自己构造MessageEvent,再交给基类统一处理。SMS适配器收到Twilio webhook时长这样:


event=MessageEvent(


text=text,


message_type=MessageType.TEXT,


source=source,


raw_message=form,


message_id=message_sid,


)


各平台的消息获取方式差异比较大,平台并没有抽象一个统一接口来处理消息转换。


所以它是约定而不是约束:各自监听、各自构造MessageEvent,后续所有代码对着同一个内部对象干活。


这是标准的适配器模式:进来时把外部差异统一成内部对象,出去时反向拆回各平台格式。不只是Agent开发,几乎所有要支持多平台的系统都会这么干。


二、Gateway的Profile隔离


Gateway启动按顺序做四件事:


  • SSL证书自动探测(/etc/ssl/certs/ca-certificates.crt等路径逐个试,必须在任何HTTP库导入之前完成)、


  • 加载~/.hermes/.env、


  • 桥接config.yaml到环境变量(YAML支持${ENV_VAR}引用)、


  • 启动启用的平台适配器。


这些都是工程标配,没有什么好说的。


值得一提的是Profile隔离:


hermes profile create coder--clone#复制当前profile的配置、密钥、记忆


hermes-p coder chat#一次性切换


hermes profile use coder#设为默认


coder chat#别名脚本,等同上面


每个Profile有独立配置、密钥、记忆、会话历史。实现靠一个HERMES_HOME环境变量,在CLI入口处、任何模块导入之前就设置好,所有后续代码通过get_hermes_home()拿主目录,切换时全自动生效。


删除profile:


hermes profile delete coder#需输入名称确认


hermes profile delete coder--yes#跳过确认直接删除


删除时会彻底清理:停Gateway进程→清理systemd/launchd服务→移除别名→删目录→如果是当前活跃profile就重置为default。


一个环境变量控制整棵目录树,切换不同的工作环境。于是你可以在一台机器上同时跑"工作Agent"和"个人Agent",互不打扰。


三、Agent主循环


消息到AIAgent,进入整个项目最核心的地方,这个地方一定要详细读、重复读:


主循环骨架


whileiteration_budget.remaining>0:


response=client.chat.completions.create(


model=model,messages=messages,tools=tool_schemas,stream=True


)


ifresponse有tool_calls:


执行工具(可能并行)


iteration_budget.consume()


else:


returnresponse.content#没有工具调用,返回最终结果


主循环有三种退出路径:


  • 模型给最终文本:本轮没有tool_calls,走else分支把response.content返回给用户,正常完结。


  • 预算耗尽:while条件不再成立,iteration_budget.remaining归零。这是硬上限,防止模型在错误循环或幻觉里把token烧光。


  • 用户中断:_interrupt_requested被外部置位。用户Ctrl+C或者直接发新消息都会触发,Agent在每轮开头检查这个标志。收到中断后不是raise抛异常,而是break出循环,持久化已有结果并补齐消息结构。

迭代预算



系统设计有循环迭代预算限制:父Agent上限90轮,子Agent 50轮。


模型每推理一轮消耗1次迭代预算,不管这一轮并行调了几个工具。


有意思的是refund()的触发条件:


_tc_names={tc.function.namefortcinassistant_message.tool_calls}


if_tc_names=={"execute_code"}:


self.iteration_budget.refund()


当本轮工具调用里只有execute_code一种,刚扣掉的那1次迭代会被退还,这轮等于白送。


execute_code是PTC(Programmatic Tool Calling):模型不是直接挨个调工具,而是写一段Python脚本,脚本内部通过RPC把web_search、read_file、write_file这些工具串起来跑。


换个角度对比一下:同样要做8次信息获取,


  • 走普通工具调用:模型调web_search→拿结果→再推理下一步调什么→调read_file→拿结果→再推理……8次工具执行要8轮模型推理,吃掉8次迭代预算。


  • 走PTC:模型一轮里写出一整段脚本,脚本自己连调8次工具。1轮模型推理就打包干完。


PTC已经把8次工具调用折成1轮推理,系统再把这1轮也免掉,执行脚本在预算里等于零成本。


退还的真正作用是预算管理:脚本密集型任务可能要连写十几个脚本才做完,一次扣1轮的话,90轮预算很快被脚本执行吃掉,留给真正推理轮次的就不够了。索性让脚本执行零成本,预算就能全留给需要推理的轮次。


工具并行执行


系统维护三个集合决定一批工具能不能并行:


_NEVER_PARALLEL_TOOLS=frozenset({"clarify"})#会跟用户交互


_PARALLEL_SAFE_TOOLS=frozenset({#只读,无共享状态


"read_file","search_files","session_search",


"skill_view","skills_list",


"vision_analyze","web_extract","web_search",


"ha_get_state","ha_list_entities","ha_list_services",


})


_PATH_SCOPED_TOOLS=frozenset({"read_file","write_file","patch"})#路径不重叠才能并行


路径工具的冲突检查原理:提取每次调用的目标路径,两两比对看有没有重叠。


重叠的判定包括两种情况:同一个路径,或者一个路径是另一个的祖先(比如/a和/a/b.txt)。


只要重叠,就可能撞上读写竞态(一个线程正在写,另一个读到半拉状态),必须排队串行;路径完全独立则放并行。


举两个例子:


  • read_file("/a/b.txt")+write_file("/a/b.txt"):同一个文件,一个读一个写,并行会出乱子,必须串行


  • read_file("/a/x.txt")+read_file("/b/y.txt"):两条路径完全独立,可以并行


并行池最多8个工作线程同时跑。


如果模型判断要同时读5个文件、搜2个关键词、查3个网页时,串行要10次API往返,并行可能2-3次搞定。每次API调用都是时间+金钱。

delegate_task



delegate_task是个特殊工具:模型选它的时候,会fork一个新的AIAgent。


子Agent有自己独立的上下文,也有自己独立的50轮迭代预算,父子之间只通过任务描述(传入)和最终摘要(传出)通信,除此之外彼此看不见。子Agent被禁用5个工具:


  • delegate_task:防套娃。Agent嵌套本身就有开销,再允许无限递归成本会爆


  • clarify:子Agent不能反问人,因为用户不在场,只有父Agent是跟人对话的那一层


  • memory:子Agent不能写共享记忆,避免一次临时委托里抓到的噪声,污染所有未来会话


  • send_message:子Agent不能直接往平台发消息,对外沟通只能经由父Agent


  • execute_code:子Agent定位就是一步步推理把事做完,不该再用PTC折叠(PTC是主Agent用来节省轮次的,子Agent本来就分到了独立预算,用不着)


结构上还有两条硬约束:委托深度只有1层(父→子,子Agent禁用了delegate_task无法再委托)、并发上限3个。


源码里虽然设了MAX_DEPTH=2,注释写"parent(0)→child(1)→grandchild rejected(2)",但子Agent已经拿不到delegate_task工具了,这个深度检查是双重保险,防的是工具集被手动调整绕过黑名单的极端情况。


父Agent每30秒给子发一次心跳,一旦父被用户中断或者自己挂了,心跳断开,子Agent就会连锁停下,这就是"级联中断"。没有这个机制,用户按了Ctrl+C之后,后台还会有一堆子Agent继续烧token。


子Agent的系统提示词强调的是边界而不是人格:做这一件事、给摘要、不用关心父Agent在干什么。


主Agent的上下文只会看到委托调用本身和最终摘要,看不到子Agent那可能20次工具调用的中间过程。


主Agent能处理多少轮用户消息才触顶上下文压缩,取决于它的上下文保持得多干净,一次把重活甩给子Agent、只把摘要收回来,等于用一点并行开销换主Agent的长寿。


回到开头那个新闻例子:主Agent给科技/财经/国际各委托一个子Agent并行跑,拿摘要自己汇总分类。主Agent只花1次迭代预算,子Agent的50次预算各自独立。


四、系统提示词


模型推理之前,系统提示词要拼好。实际顺序:


身份→工具行为引导→外部系统提示→记忆→技能索引→项目上下文→运行时元数据(时间/环境/平台)


每一层都有什么,我们简单看下:


  • 身份:默认是一段"You are Hermes Agent..."的声明。用户在~/.hermes/SOUL.md里写了自定义人格就会替换掉默认那段。


  • 工具行为引导:下面单独讲,这一层信息密度最高,还会根据模型家族(GPT/Gemini/Grok/Claude)注入不同内容。


  • 外部系统提示:网关层、API或用户配置注入的补充指令,可选。


  • 记忆:MEMORY.md(Agent笔记)、USER.md(用户画像),加上可选的外部记忆Provider回忆到的内容,Step 5细讲。


  • 技能索引:只放一个紧凑目录(标签包起来),模型看到目录后通过skill_view工具按需加载完整技能内容,不是启动时就全塞进来。


  • 项目上下文:从工作区扫到的.hermes.md/AGENTS.md/CLAUDE.md等指令文件,注入前要过安全扫描,下面讲。


  • 运行时元数据:当前时间、WSL/Termux等特殊环境提示、以及飞书/Discord/Telegram这些消息平台的格式约定(比如WhatsApp不渲染Markdown)。


这个顺序的门道:越稳定的内容越靠前,动态内容靠后。配合前缀缓存,前缀不变就能命中,只有尾巴会变。


针对不同模型的工具使用约束


对GPT、Gemini、Grok家族,会额外注入TOOL_USE_ENFORCEMENT_GUIDANCE,核心一句话:说做就做,别光说不动。


GPT还有更细的等模块,逐一应对GPT的老毛病:部分结果就放弃、跳过前置检查、不调工具直接编答案、没验证就说完成了。


源码注释说灵感来自OpenAI的GPT-5.4 prompting guide和OpenClaw PR#38953。


Claude不需要这段,不是偏见,是不同模型在工具调用行为上确实有差异。GPT写代码确实喜欢TODO,这是实战认知的沉淀。


项目上下文的安全扫描


从工作区扫.hermes.md/HERMES.md/AGENTS.md/CLAUDE.md/.cursorrules(先到先得,前两个向上直到Git根,后三个只看当前目录),注入前过10条正则:


_CONTEXT_THREAT_PATTERNS=[


(r'ignore\s+(previous|all|above|prior)\s+instructions',"prompt_injection"),


(r'do\s+not\s+tell\s+the\s+user',"deception_hide"),


(r'',"html_comment_injection"),


(r'<\s*div\s+style\s*=\s*["\'][\s\S]*?display\s*:\s*none',"hidden_div"),


(r'curl\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD)',"exfil_curl"),


(r'cat\s+[^\n]*(\.env|credentials|\.netrc|\.pgpass)',"read_secrets"),


#...


]


覆盖了常见的prompt injection手法(忽略指令、角色扮演绕过)、HTML隐蔽注入(注释、隐藏div)、翻译攻击("翻译成X然后执行"),以及数据外泄(curl环境变量、cat敏感文件)。


命中任何一条规则,整个文件内容会被阻断并替换为[BLOCKED:...]。


上下文文件是持久化在磁盘上的。如果攻击者诱导Agent往.hermes.md里写恶意指令,那就是一个每次启动都触发的永久后门。


不过这些正则只覆盖英文模式,中文prompt injection(比如"忽略之前的所有指令")不在检测范围内,这是一个潜在的盲区。是否应该用模型来做injection检测而不是正则?


这是值得讨论的问题,正则快但容易绕过,模型检测更鲁棒但增加了延迟和成本。


ephemeral_system_prompt


它不在系统提示词的构建流程中,只在API调用时临时拼到系统提示词末尾。源码注释这样写道:为了不污染缓存。


主体保持稳定,变化部分压在末尾,缓存继续命中。


拼好的结果缓存在self._cached_system_prompt上,一个会话只构建一次,只有上下文压缩时才重建。


五、记忆系统


系统提示词框架搭好后,要注入记忆。


Hermes的记忆系统不是KV存储,也不是向量数据库,是冻结快照+文件持久化+按需检索的组合。


两个文件:


  • MEMORY.md:Agent自己的笔记本("这台机器Python是3.11"、"这个项目用commitlint"、"web_extract对这个网站不稳定")


  • USER.md:Agent对用户的了解(偏好、沟通风格、工作习惯)


两个文件都限制按字符数(不是token数),


  • MEMORY.md 2200字符,


  • USER.md 1375字符。


为什么要按字符来算?


我觉得可能是字符数模型无关,换模型不用重新算吧。


冻结快照


classMemoryStore:


defload_from_disk(self):


self.memory_entries=self._read_file(mem_dir/"MEMORY.md")


self.user_entries=self._read_file(mem_dir/"USER.md")


#捕获冻结快照


self._system_prompt_snapshot={


"memory":self._render_block("memory",self.memory_entries),


"user":self._render_block("user",self.user_entries),


}


记忆在会话开始时注入系统提示词,之后整个会话期间不再更新。


会话期间通过工具写入的记忆会立刻持久化到磁盘(不丢数据),但系统提示词里的快照不变。下次新会话才从磁盘加载最新。


这里的设计还是为了能命中前缀缓存。每轮写记忆都改系统提示词,缓存就没法命中。这是用一致性换性能的工程权衡。


记忆写入也要过安全扫描


记忆会进系统提示词。如果被诱导往记忆里写"忽略之前的所有指令",那就是每次新会话都触发的后门。所以写入时要过一遍:


_MEMORY_THREAT_PATTERNS=[


(r'ignore\s+(previous|all|above|prior)\s+instructions',"prompt_injection"),


(r'you\s+are\s+now\s+',"role_hijack"),


(r'curl\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD)',"exfil_curl"),


(r'authorized_keys',"ssh_backdoor"),


(r'\$HOME/\.hermes/\.env',"hermes_env"),


]


另外可选8个外部记忆Provider:Honcho、Mem0、Hindsight、Holographic、ByteRover、OpenViking、RetainDB、Supermemory。


内置Provider永远在,外部同时只能开一个。


查询到的记忆用标签包裹,附带一句"这是背景参考,不是新用户输入",防止模型把记忆当成新请求去响应。


六、自我修复


主循环每一步都可能出问题:上下文不够用、API超时、凭证限流、服务器500。


Hermes的做法是按错误分类,各走各的恢复路径,而不是一个大try/except:


上下文压缩

ContextCompressor的压缩流程:


  1. 裁旧工具输出(不调LLM):替换成[Old tool output cleared to save context space]。很多时候这一步就够降到阈值下。


  2. 保护头部:系统提示词+前3条消息不动(通常是系统提示词+第一条用户消息+第一条助手回复,即第一轮完整交换)。


  3. 保护尾部按token预算:最近的完整对话不动,预算是两步链式推导出来的,先算压缩触发阈值context_length×0.50(上下文用掉一半时触发压缩),再从阈值里拿出20%给尾部保护(threshold_tokens×0.20)。200K上下文模型的阈值是100K,尾部预算=100K×20%=20K token。源码注释这样写道:"ratio is relative to the threshold,not total context"。不是按消息数,一条长代码和一句"好的" token差100倍,按数字算没意义。


  4. 中间摘要:配置里指定的便宜模型做摘要。摘要前拼SUMMARY_PREFIX:"这是来自前一个上下文窗口的交接"。暗示这是另一个助手留下的笔记,让模型不会把摘要里的旧请求当新指令再执行一遍。


  5. 增量更新:二次压缩在已有摘要上更新,不从头重压。摘要token上限12000,防自己膨胀。


压缩触发时主动调_invalidate_system_prompt()+_build_system_prompt()重建系统提示词,冻结的记忆快照重新生成,加载最新的记忆内容。


错误分类器


API调用会失败的原因各种各样:认证失败、额度耗尽、限流、上下文超限、模型不存在、网络中断……


FailoverReason枚举把这些归了14种。每个错误抛出时先过一道分类器,再封装成ClassifiedError,只带四个布尔恢复标记:


retryable:bool#能不能直接重试


should_compress:bool#要不要先压缩上下文再重试


should_rotate_credential:bool#要不要切换到下一个API Key


should_fallback:bool#要不要切到fallback模型


主循环拿到ClassifiedError之后不自己做字符串匹配,只看这四个标记决定下一步。


所有"这条报错里带rate_limit、那条带insufficient_funds、还有一条是openai模块抛的BadRequestError"之类的脏活儿全集中在分类器里,一次性把错误映射到恢复动作,主循环只负责dispatch。


为什么要分得这么细,一个典型的对比是HTTP 402和429。它们表面都是"限额"类错误,但处理方式完全不同:


  • 429是临时限流:Provider告诉你"请求太快,歇一下再来",退避重试同一个Key就能恢复


  • 402是额度耗尽:账户上的钱已经扣光了,同一个Key短期内不会恢复,必须立即切到下一个Key


不分清楚的话,Agent会在一个已经没钱的Key上反复退避到天荒地老。


分类器把这两种错误映射到不同的恢复标记组合(429置retryable=True,402置should_rotate_credential=True),主循环看标记就知道该退避还是该换钥匙。


用户中断


每轮开头检查_interrupt_requested。用户Ctrl+C或发新消息触发时不raise而是break:持久化已有结果,返回interrupted=True。


如果前面tool_calls已追加但没执行,会补一个伪造的错误tool result,保证消息结构对API合法,下次恢复对话不会被Provider拒。


这个伪造错误tool result非常有用,如果你做过Agent肯定知道这是个什么梗,用户中断后,如果工具执行也被中断,没有记录结果,那么下一次调用就会报错。


七、消息返回



模型给了最终回答,文本沿着和进来相反的方向走回去。


流式token通过_fire_stream_delta()边生成边推。CLI下直接写进prompt_toolkit的patch_stdout,Gateway下由stream_consumer.py按1秒节流编辑同一条消息(不是每个token发一条,那样会被平台限流干死)。


附件的处理方式。


在文本里加MEDIA:/absolute/path/to/file前缀就能发附件


模型不管是飞书、Discord还是iMessage,吐出的都是同样格式的MEDIA:行。


Gateway侧在文本展示前把MEDIA:指令剥掉,交给对应平台适配器转成各自的附件API:飞书走上传素材接口,Discord拼file attachment,iMessage走BlueBubbles的attachment字段。


回头看第1步,消息进来时也是同一套逻辑,各平台适配器把五花八门的消息统一成MessageEvent。


一进一出,两层适配把平台差异挡在核心之外:


进来把各平台消息统一成MessageEvent,出去把统一格式的MEDIA:再拆回各平台附件机制。


核心代码(主循环、工具、记忆、技能)从头到尾只跟统一协议打交道,不用知道消息从哪来、要到哪去。想接新平台,写一个适配器就够。


八、自进化


这里又是核心了,也是Hermes区别于OpenClaw的所在:



模型给了最终回答,终端/平台也推送完了。这时候如果什么都不做,这一轮产生的经验都会随进程退出蒸发。


run_conversation()返回前还有三件事:落盘、记忆同步、后台复盘。都在用户看完回复、Agent表面"闲下来"之后发生,对用户零感知。


记忆同步


  • 内置MEMORY.md/USER.md:每次memory工具调用立刻atomic rename写磁盘;下次新会话load_from_disk()时快照才刷新。既保前缀缓存命中,又不丢数据。


  • 外部Provider:每轮结束调sync_all(用户原话,最终回复),推整轮交换给Provider,让它自己抽事实;on_session_end不是每轮调,只在CLI退出、/reset或Gateway判定会话过期时调一次。


注释把这个区别写得很直白:


run_conversation()is called once per user message in multi-turn sessions.Shutting down after every turn would kill the provider before the second message.


每条用户消息=一次run_conversation,但一个会话包含多条消息。"每轮小sync、会话末大flush",生产系统常见节奏。


后台复盘


这里是真正让它"自进化"的地方,先把"技能"这个概念铺一下,否则这一节不好读。


Hermes的技能(Skills)是存在Skills目录里的一堆Markdown文档,每一篇是一段"做过之后沉淀下来的操作笔记"。


会话启动时,这些文档的标题和简介会被拼成一个紧凑目录(第4步讲过的技能索引)塞进系统提示词。


模型在对话里遇到相关任务,看到目录条目,再用skill_view按需加载完整内容。


技能越攒越多,Agent下次做类似任务就越不用从头摸索,这是"自进化"的物质基础。


但技能不会自己长出来,得有人(或Agent自己)往目录里写。Hermes的做法是两条信号并行:


信号一:系统提示词里的主动引导。SKILLS_GUIDANCE告诉模型"复杂任务完成后主动存、用到过时的技能立即patch",让模型在合适的时刻自己调skill_manage写文件。


信号二:后台强制复盘。默认_skill_nudge_interval=10,每消耗10次模型推理轮次触发一次"技能复盘",和Step 3的迭代预算是同一个计数口径:每轮推理+1,不管这轮并行调了几个工具。


execute_code脚本内部不管串了多少次工具调用,也只算1轮。计数器还跨用户消息累加,不会因为用户发了新消息就归零。


如果这10轮里Agent自己已经调过skill_manage(说明信号一生效了),计数器会被重置,再过10轮才会再触发,避免刚存完又逼着复盘一次。


复盘触发后,_spawn_background_review()在一个独立的后台线程里再fork一个mini Agent出来:max_iterations=8(最多跑8轮)、quiet_mode=True(输出不回显给用户),喂给它的prompt大意是:


回顾上面这段对话。里面有没有用到过非平凡的方法(试错过、中途改过主意、或者用户期待的结果和实际不一样)?有就存成新技能或更新现有技能;没有就说"Nothing to save."直接停下。


这个mini Agent拿整段对话当背景,任务就这一件:判断值得存,就调skill_manage写一份新技能或更新旧技能;不值得,就退出。


工程上最关键的一点写在源码注释里:


Background memory/skill review—runs AFTER the response is delivered so it never competes with the user's task for model attention.


背景线程必须在回复已经发给用户之后才启动,绝对不和用户正在等的响应抢模型资源。这是两种做法的根本分歧:


  • 让模型在每次回答的过程中自己顺带想一下要不要写技能:会拖慢用户看到回复的延迟,还分散模型对主任务的注意力


  • 另起一个背景线程定期复盘:对用户零感知,模型在答用户时心无旁骛,复盘的时候又能拿完整对话慢慢想


Hermes选了后者,再配上信号一的主动引导兜底。Agent自己主动存是理想情况,背景线程是防它漏掉或者偷懒的保险。两条信号一起,技能库才能长期健康地长肉。


这就是所谓"自进化"真正发生的地方:用户看不到它学,但每过10轮模型推理,就可能有一段新的经验被沉淀进技能库。下一次遇到类似任务,它就不用从头摸索了。


上下文压缩


上下文压缩有一个很容易被忽略的副作用:摘要是有损的。


用户今天聊了一大段,明天回来问"昨天你说的那个函数名叫什么来着",模型在当前session里看到的只是摘要,细节可能已经被压缩掉,答不上来了。如果压缩是"就地覆盖"旧对话,那对用户来说就是历史丢了。


Hermes的做法是,每次上下文压缩时,SessionDB里做三件事:


  1. 结束当前session,原始对话完整保留在数据库里,不删


  2. 开一个新session,把压缩后的摘要作为新session的起点


  3. 新session的parent_session_id指回旧session的ID


连续压缩几次就会形成一条链:新session的parent指回上一次的session,一路能追溯到最初的那一轮对话。


这样设计之后,"省成本"和"不丢历史"两个看起来矛盾的目标,用分层各自满足:


  • 模型层(当前session):只装系统提示词+摘要+近期对话,token成本不会随对话无限膨胀,也不会撞到模型上下文窗口的上限


  • 数据层(SQLite):所有session的原始消息全部留着,FTS5索引全文可搜。用户再问"昨天那个函数",session_search工具直接命中老session的原文,把片段返给模型,模型就能答得上来


模型看到的是压缩版,数据库存的是完整版。两个目标不是真的矛盾,只是不该用同一份数据同时扛。用数据模型把它们分开承载,矛盾就消解了。


但要注意,"能搜到历史"和"Agent记住了"是两回事。


session_search是按需检索:模型得主动调用这个工具才能拿到旧对话的片段,搜索结果只是当次推理的临时上下文,不会自动写入MEMORY.md,也不会更新系统提示词里的记忆快照。下次遇到类似问题,模型还得再搜一次。


真正持久的"记忆"只有一条路:模型主动调memory工具写入,下次新会话启动时才从磁盘加载进快照。


换句话说,session链保的是原始数据不丢,记忆系统保的是经验沉淀不丢,两条通道各管各的。


有了session链不代表可以不写记忆,前者是被动存档,后者是主动学习。


......

本内容来源于网络 原文链接,观点仅代表作者本人,不代表虎嗅立场。
如涉及版权问题请联系 hezuo@huxiu.com,我们将及时核实并处理。

支持一下

赞赏

0人已赞赏

大 家 都 在 搜

好的内容,值得赞赏

您的赞赏金额会直接进入作者的虎嗅账号

    自定义
    支付: