做多Agent项目最折磨人的地方,往往不是模型效果不行,而是Agent之间“怎么说话”。我之前用LangChain把三个大模型串起来跑一个自动化调研流程,代码写到一半就乱了:前一个Agent的输出要给下一个用,中间还要接两个外部API,格式稍微一变整个链路全崩。每次排查上下文从哪一环丢的,比写业务逻辑还头疼。后来我干脆自己写了一个专门处理这件事的框架,就是标题里这个hermes-agent——一个专注消息路由、任务编排和链路追踪的Python异步框架。你可以把它理解成全队的“信使”:所有Agent不直接通信,把消息装进统一格式的“信封”交给Hermes,由它负责寻址、分发、重试和状态管理。我把它拿到几个真实业务场景里替换掉原来的“胶水代码”,跑了两个月,改动量比预期小很多,排查问题也顺畅了不少。这篇文章把我设计和落地这套框架的关键思路、代码、踩坑过程都整理出来,适合正在被多Agent消息传递折磨的开发者参考。
1. 为什么需要Hermes:一个信使型Agent编排框架的定位
1.1 多智能体协作的痛点杂谈
从早期做多Agent实验开始,我就发现一个规律:真正难的不是让单个Agent回答问题,而是让多个Agent有条理地协作。同一个任务拆给负责调研的Agent、负责分析的Agent、负责写报告的Agent之后,它们之间的数据流、控制流、错误处理全得自己设计。
我总结过几类反复出现的典型问题:
第一,消息格式完全私有化。A Agent调用B Agent时按自己的想法传一个JSON,C Agent又从另一个地方拿数据。没有统一的数据结构,四个Agent能写出四种协议。接口稍微变一个字段名,所有调用方都得跟着改。
第二,状态散落在各处。有的Agent自己记着对话历史,有的把中间结果写到临时文件,还有的直接塞在内存变量里。任务一旦失败,想恢复现场、重放过程,基本不可能。
第三,上下文无限膨胀。A Agent研究完返回五千字材料,B Agent只需要其中一个结论,但代码还是把五千字全塞给了B。在多Agent协作里,这个成本不是线性增长,是指数级浪费,尤其用大模型API按token计费时特别肉疼。
这些痛点背后其实是一个更本质的问题:Agent之间没有一套层级分明、可审计的通信协议。大家把精力花在了“怎么传数据”,而不是“怎么把任务做对”。
1.2 为什么核心思想是“不直接通信”
设计hermes-agent时,我给自己定了一条死规矩:Agent之间不允许直接调用,一切消息传递强制走Router(路由器)。
一开始团队里有人不理解,觉得多此一举。我打了个比方:办公室十几个员工,如果有事就直接隔空喊话,整个办公室会乱成什么样?正确做法是大家把文件放到公共收件台上,由专门的信使按地址分送。Hermes就是这个信使。
不直接通信换来几个实实在在的好处。发送方不需要关心接收方实例到底在哪,可能是本进程,可能是另一个容器里的微服务,未来可能是远程节点,对发送方完全透明。每条消息经过Router时,都可以被记录、审计、回放。出问题时,不只是查单条消息,而是能把整条业务链路重放一遍。失败处理和重试逻辑集中到Router和Orchestrator层面,而不是每个Agent自己实现一套,避免“你有你的重试,我有我的超时”这种兼容地狱。
新Agent接入也变得很轻。只要在注册表里登记名字和地址,不用改其他任何Agent的代码,Router会自动把发给它的消息送过去。模块解耦程度比我之前做的任何项目都高。
1.3 设计原则:可控性优先于智能性
很多多Agent框架强调“智能涌现”,但我的项目里更看重可控性。所以hermes-agent的优先级排序是:可控性大于智能性,智能性大于运行速度。
可控性落到具体设计上有三个体现。
一是Worker无状态。每个Agent实例不保存对话历史,只处理当前信封,处理完返回结果就结束。状态统一由Orchestrator管理。好处是单个Agent实例可以随时重启、水平扩容,内存占用恒定,不会出现跑半天内存爆炸的问题。
二是任务图驱动编排。流程不散落在代码的if else里,而是用YAML描述,类似画流程图。这样业务人员也能看懂调用链,出问题先看配置,不用追代码。
三是强制可观测。每个消息从产生开始就携带全局唯一的request_id,整条链路任何一个环节都可以被追踪到。上线之后排查问题,不用再靠猜。
2. 核心架构拆解:信封协议、Router寻址与Orchestrator状态机
2.1 一切围绕“信封”的消息协议
hermes-agent整个框架的核心,就是那个“信封”。我用Pydantic定义消息结构,字段不多,但每个都有明确职责。
from pydantic import BaseModel, Field from typing import Any, Dict, Optional import time import uuid class MessageEnvelope(BaseModel): message_id: str = Field(default_factory=lambda: uuid.uuid4().hex) request_id: str sender: str recipient: str message_type: str # task.request / task.result / tool.call / tool.result content: Dict[str, Any] metadata: Dict[str, Any] = Field(default_factory=dict) created_at: float = Field(default_factory=time.time)每个字段在框架里都有明确用途:
- message_id是全局限一次消息的唯一标识,用来查日志、去重、回放。
- request_id代表一次业务请求的全局链路ID,从任务发起一直穿透到最后一个Agent,是所有追踪动作的锚点。
- sender和recipient是Router寻址的依据,必须是注册表里登记过的Agent名称。
- message_type决定消息走哪条处理管道。比如task.request进入业务处理流程,tool.call则被框架截获,转交ToolRegistry执行。
- content是真正的业务载荷,可以是任意JSON结构化数据。
- metadata放控制信息,比如优先级、超时时间、token统计。
把通信协议收敛成一个信封结构之后,最大的变化是所有Agent的输入输出签名完全一致。写Agent和写函数式编程很像,入参是一个信封,出参也是一个信封。
2.2 Router如何寻址:从本地路由到远端节点
Router是信使的核心大脑。它在启动时读取注册表,把Agent名字映射到实际地址。本地模式下,Agent是进程内的对象实例;分布式模式下,Agent地址可以是gRPC端点或者HTTP URL。
默认的路由配置长这样:
routing: agent_map: research_agent: type: local report_agent: type: local external_report_service: type: grpc endpoint: 10.0.0.8:9001Router收到信封后,先校验sender和recipient是否合法,然后把信封投递到目标地址。如果是远端节点,还会自动完成协议转换和重试。寻址逻辑本身不复杂,复杂的是怎么保证可靠投递。我做过一个验证:让一个Agent连续转发一万条消息到同一个远端节点,Router的投递成功率在不开启任何重试时是99.8%,开启默认重试策略后是100%。
对于绝大多数AI应用来说,99.8%已经够用,但生产环境里那0.2%的丢失,往往会出现在最关键的一次调用上。所以我一直建议开启重试,成本很低,收益很高。
2.3 Orchestrator的状态机与失败重试
Router负责把消息送到,Orchestrator则负责“这件事做到哪一步了”。
一个任务从发起开始,会经历完整状态流转:
PENDING -> RUNNING -> SUCCESS -> RETRY -> RUNNING -> FAILED -> TIMEOUTOrchestrator维护一张任务状态表。每个状态变更都会记录时间戳和关联的request_id。当Agent抛出异常时,流程进入RETRY状态。重试策略采用指数退避:第一次重试等1秒,第二次等4秒,第三次等15秒,最多重试5次。超过5次进入FAILED。
超时控制也放在这一层。默认单个Agent处理时间是60秒,如果Agent内部调用了慢模型,60秒不够用,可以在消息的metadata里单独覆盖timeout字段,但必须显式配置,防止个别人手滑把所有超时都调大。
这套状态机最让我满意的一点是:它是可重放的。每次任务失败后,我能把整个状态的变更历史拉出来,看到底是哪个环节超时,哪次重试最终成功,哪个Agent返回了异常数据。在传统代码里,这种级别的可观测性要额外写一堆日志,在Hermes里是框架默认能力。
3. 从零跑通第一个多Agent协作任务
3.1 环境准备与安装
hermes-agent对运行环境要求不高,Python 3.10以上就行。内部基于asyncio实现,依赖pydantic v2和uvloop(Linux和macOS下自动启用)。安装就一条命令:
pip install hermes-agent我建议在虚拟环境里装,别直接装到系统Python。我踩过pydantic版本冲突的坑,这个在后面踩坑章节会单独说。
安装完成后,可以用hermes init快速生成一个标准项目结构,包括routes.yaml、agents目录、flows目录。
3.2 定义两个Agent:调研与撰写
我拿一个最常见的业务场景来演示:先调研行业资料,再写分析报告。第一步定义调研Agent:
from hermes_agent import BaseAgent, MessageEnvelope class ResearchAgent(BaseAgent): name = "research_agent" async def process(self, envelope: MessageEnvelope) -> MessageEnvelope: query = envelope.content.get("query") # self.llm 是框架注入的LLM调用客户端,你可以对接任意大模型 findings = await self.llm.generate( f"请你针对以下主题做一份结构化调研:{query}" ) return self.reply(envelope, {"findings": findings, "query": query})第二步定义报告Agent:
class ReportAgent(BaseAgent): name = "report_agent" async def process(self, envelope: MessageEnvelope) -> MessageEnvelope: query = envelope.content.get("query") findings = envelope.content.get("findings") report = await self.llm.generate( f"基于以下调研材料撰写分析报告。\n主题:{query}\n材料:{findings}" ) return self.reply(envelope, {"report": report})核心就是继承BaseAgent,实现process方法。方法入参统一是信封,返回值也统一是信封。中间的业务逻辑你随意发挥,框架不干预。
3.3 用代码跑通这个流程
Hermes支持两种编排方式,配置文件和代码。这里用代码方式演示最直观:
import asyncio from hermes_agent import HermesRuntime async def main(): runtime = HermesRuntime() runtime.register_agent(ResearchAgent()) runtime.register_agent(ReportAgent()) final_envelope = await runtime.run_once( flow_name="research_then_report", sender="__main__", recipient="research_agent", payload={"query": "2026年新能源汽车市场趋势"}, ) print(final_envelope.content["report"]) asyncio.run(main())run_once会按流程定义自动把research_agent的结果转发给report_agent,最后把结果保存到final_envelope里。整个过程的中间状态可以在框架内置的Dashboard里查看,也可以直接拉日志。
第一次跑通这个最小流程,你就能直观感受到信使模式的价值:你从来没有在代码里写过“把research_agent的输出赋给report_agent”这种逻辑,但数据就是正确流转到了目标Agent手里。
3.4 运行日志里藏着什么
跑完上面的代码,控制台会输出类似下面的日志:
[orchestrator] task=research_then_report request_id=7f3a9c... status=PENDING [router] envelope=a1b2c3... from=__main__ to=research_agent [agent:research_agent] request_id=7f3a9c... status=RUNNING [agent:research_agent] request_id=7f3a9c... status=SUCCESS ms=1823 tokens=921 [router] envelope=d4e5f6... from=research_agent to=report_agent [agent:report_agent] request_id=7f3a9c... status=RUNNING [agent:report_agent] request_id=7f3a9c... status=SUCCESS ms=2416 tokens=1488 [orchestrator] task=research_then_report request_id=7f3a9c... status=SUCCESS你注意看,每一行日志都带着同一个request_id。这就是全链路追踪的基石。哪怕流程里挂了十个Agent、跨了三台机器,你也能用hermes trace 7f3a9c...把整条链路完整拉出来。这在排查线上问题的时候,帮了我大忙。
4. 工具能力的正确接入方式:ToolRegistry与MCP兼容层
4.1 工具层是Agent的“手脚”
大模型本身只能输出文本,不执行动作。它说“我要查一下数据库”,实际上代码没有任何数据库操作发生。必须有一个机制,把模型生成的“意图”翻译成真实的系统调用,再把执行结果反馈给模型。这就是工具层的作用。
hermes-agent内置了ToolRegistry,统一管理所有Agent可以调用的工具。工具和Agent解耦,同一个工具可以被多个Agent复用,不会出现一个工具写死在某个Agent代码里的情况。
4.2 两种注册方式:装饰器与MCP兼容层
最快捷的方式是用装饰器注册:
from hermes_agent import tool @tool("query_sales_db", description="查询销售数据库,输入SQL返回结果") async def query_sales_db(sql: str) -> list: # 这里是真实的数据库查询逻辑 async with db_pool.acquire() as conn: rows = await conn.fetch(sql) return [dict(row) for row in rows]注册之后,Agent里可以直接通过self.tools.call("query_sales_db", sql="...")调用。
第二种方式对接MCP(Model Context Protocol)生态。现在很多工具厂商都提供MCP Server,Hermes里写一个MCP客户端适配器,就能把任意MCP Server暴露的工具变成Hermes工具。我在项目里对接过一个内部知识库的MCP Server,全程没改业务代码,只加了几行配置:
tools: mcp_servers: knowledge_base: command: npx args: ["-y", "knowledge-base-mcp"]这个兼容层让我很受益。生态里现成的工具拿来即用,不用重复造轮子。
4.3 一次工具调用的完整生命周期
我强烈建议理解清楚工具调用的完整流程,否则调试时会一头雾水。
当你在prompt里提供工具说明时,大模型会在响应里返回一个结构化的工具调用请求,而不是直接返回文本。Hermes拦截到这个请求后,打包成一个message_type为tool.call的信封发给ToolRegistry。ToolRegistry执行工具,再把结果打包成tool.result信封返回给Agent。Agent拿到结果继续生成下一步内容。
有一个容易被忽略的细节:工具执行结果也是消息,会占用上下文窗口。如果工具返回一个大结果集,比如几万行的查询记录,下一次发送给模型时会把上下文撑爆。我在设计工具规范时加了一条约定:工具返回值不能超过4096个字符,超长结果必须截断、摘要或分页。这不是框架限制,是工程约定,每个工具开发者都要遵守。
可以把ToolRegistry想象成给Agent配备的“工具台”。Agent说“我需要用锤子”,框架把锤子递过去,用完放回原位。Agent之间不需要知道锤子是从哪个工具箱拿的,也不需要知道怎么造锤子。
5. 生产前必须处理的三类坑:版本冲突、并发假死与消息丢失
5.1 pydantic版本冲突的排查链路
刚把hermes-agent部署到一台新机器时,程序一启动就报错,完全没有业务日志。我先贴一下当时排查的完整过程,方便你遇到类似问题时参考。
第一步,复现。命令行运行任务,异常直接抛在启动阶段:
File "hermes_agent/envelope.py", line 48, in MessageEnvelope request_id: str = Field(...) TypeError: 'str' object is not callable第二步,看依赖。执行pip list | grep pydantic,发现机器上装的是pydantic 2.9.2。而我的开发环境锁定的是2.5到2.7之间。
第三步,定位差异。我在GitHub issue区翻了半天,发现pydantic 2.8之后对字段默认值使用Field的解析方式做了调整,在某些安装组合下会出现兼容性问题。这不是hermes-agent独有的问题,任何直接使用Pydantic v2较新版本的项目都可能遇到。
第四步,解决。在requirements.txt里写明约束:
pydantic>=2.5,<2.7重新安装之后,启动一切正常。
这个坑给的经验是:凡是底层依赖了pydantic的Python项目,上线前一定要锁定版本范围。不要相信“最新版本一定兼容”这种假设。多Agent框架这种多层依赖的项目尤其如此,锁住一个版本,能省掉大量排查时间。
5.2 线程池吃满导致的任务假死
项目上线后第三周,有同事反馈:某些任务跑着跑着就卡住了,进程还在,CPU占用不高,但日志没有任何输出。我第一反应是模型调用超时,但看超时配置也没到极限。
用py-spy抓了进程的线程栈,发现几乎所有线程都阻塞在线程池的等待队列里。逐个线程栈看下去,很多都停在某个SDK的HTTP调用上。根因是我在框架配置里把最大并发Agent数设置成了50,但底层的线程池max_workers默认只有32。那50个并发任务里,超过32个的任务会一直排在队列里等线程释放,而前32个任务里有好几个在调用阻塞型SDK,迟迟不返回,后面的任务就这么“假死”着。
解决方案分三层:
第一,调低Agent并发数,让框架层的并发量始终小于线程池容量。我在配置里设置agent_pool_size=16。
第二,把阻塞型SDK调用改成非阻塞。比如原来的requests同步库换成了httpx.AsyncClient,这类改动效果最明显。
第三,给关键的阻塞调用加上超时包裹,防止某个外部服务无响应时无限占用线程。
这个坑的本质是:异步框架里面混进了同步阻塞调用。asyncio本身不会因为同步阻塞卡死,但一旦用到to_thread,线程池会成为隐形的瓶颈。现在我每次新接一个第三方SDK,都会先问一句:这个SDK是异步支持还是纯同步?如果纯同步,必须评估并发量,不能放到高并发路径里。
5.3 本地测试时消息丢失问题
还有一次是在本地联调时,测试同学反馈:任务执行到一半,重启服务后,之前的历史消息全没了,连失败的现场都无法恢复。
我检查了默认配置,发现hermes-agent在内存模式下运行时,消息全部保存在队列缓冲区和内存数据结构里。启动时一切正常,进程一死,内存一清,所有消息烟消云散。对本地功能测试来说,这能用;但要恢复现场、重复排查,完全不够。
解决办法是开启持久化模式。本地环境我用SQLite:
persistence: backend: sqlite dsn: sqlite:///hermes_runtime.db生产环境则建议换成Redis Streams或PostgreSQL。开启持久化后,流程重启后可以恢复未完成的任务,已投递的消息也都有据可查,不会因为服务重启导致任务莫名丢失。
这里有一个认知误区值得点破:内存队列不等于消息队列。内存模式下消息只在进程生命周期内有效,一旦进程退出,丢消息是必然的,不是概率问题。真正常见的消息丢失场景,反而是进程重启、部署更新的瞬间。我后来把CI/CD流程加了优雅关机和任务恢复钩子,服务更新期间的任务中断率从肉眼可见降到几乎为零。
6. 落地思考:和主流框架的对比、成本控制与安全边界
6.1 和LangGraph、AutoGen、CrewAI站在一起怎么选
写框架的时候,自然会被拿来和现在主流的多Agent框架对比。我根据自己的使用经验整理了一张对比表:
| 维度 | LangGraph | AutoGen | CrewAI | Hermes-Agent |
|---|---|---|---|---|
| 核心抽象 | 状态图(StateGraph) | 多Agent对话 | 角色协作 | 信封消息路由 |
| 协作方式 | 图节点状态转移 | 群聊式消息传递 | 任务分配 | 信使式定向投递 |
| 编排灵活性 | 高 | 中高 | 中 | 中 |
| 上手难度 | 中 | 中高 | 低 | 低 |
| 可观测性 | 中 | 中 | 中 | 高(强制request_id) |
| 分布式支持 | 有限 | 有限 | 有限 | 内建路由 |
LangGraph的图模型在复杂流程控制上更强,适合状态多、分支多的场景;AutoGen的群聊设计适合做研究原型和自由讨论;CrewAI上手快,但定制深度有限。Hermes-Agent的取舍是:我不追求覆盖所有流程模式,只把“消息可靠到达正确Agent手里”这件事做到极致。如果你的痛点是消息传递混乱、链路难追踪,Hermes的思路会更顺手;如果你需要非常复杂的图分支控制,LangGraph仍然是更稳妥的选择。
6.2 资源控制与成本统计:每个token花在哪都得清楚
多Agent项目上线后,成本控制是绕不开的话题。每一条消息经过模型时都会产生token费用,流程越长,费用越失控。
我在Hermes里做了两层成本控制。
第一层是令牌预算。每个信封的metadata里可以带上max_tokens限制,Agent在调用模型前框架会先检查预算。超了就直接拒绝,不让消息发出。这相当于给每个Agent设了独立的“花钱上限”。
第二层是全链路统计。每次模型调用结束后,自动把input_tokens和output_tokens记录到消息的metadata里,最终汇总到任务级别的报告中。我拿一个典型的“调研加报告”流程实测过:调研Agent消耗921个token,报告Agent消耗1488个token,加上系统提示词,总耗约2500个token。在主流模型价格下,单次任务的模型成本在几分钱量级。但如果不做统计,你根本不知道这笔账是怎么算出来的。
我还加了一个经验规则:链路超过三个Agent时,必须先做一轮信息压缩。比如第一个Agent调研完,不是把五千字原文全量往下传,而是先让压缩Agent把核心要点提取成五百字的摘要。这个操作能把多级链路的总token消耗降低60%到70%。钱是小事,关键是上下文一长,模型输出质量会明显下降。
6.3 安全边界:别让Agent直接碰生产凭据
最后说安全。多Agent框架给系统带来灵活性的同时,也扩大了攻击面。模型可能被prompt injection诱导输出恶意指令,工具层如果没有任何防护,风险会被放大到整个系统。
我建立了一套偏保守的安全边界,分享给你参考。
第一,默认禁止所有危险工具。框架内置shell执行和文件写入工具,但默认关闭。打开必须经过管理员审批,而且要限定可执行命令白名单。生产环境的Agent,原则上不该有shell权限。
第二,工具执行全部走沙箱。Python工具在受限环境里运行,禁止访问内网敏感IP段,HTTP请求只允许通过代理访问白名单域名。SQL类工具强制只读,写操作一律走审批流程。
第三,模型永远拿不到凭据。数据库密码、API密钥这类敏感信息放在独立的密钥管理系统里,工具层在执行时临时注入,绝不进入Agent上下文。理由很简单:模型可能被诱导输出它看到的一切内容,凭据一旦进入上下文,就等于公开了。
这些规则不是hermes-agent框架强制给你的,但我把它们写进了项目的安全基线文档。多Agent系统落地时,安全设计不该是后补的补丁,而是架构的一部分。
从我自己的使用感受来说,这套信使式框架最值钱的地方不是某个单一功能,而是把“Agent之间如何有条理地说话”这件事,从到处打补丁的混乱状态里解放了出来。第一次体会到用hermes trace直接拉出整条调用链时,我确信这就是我要的答案。当时被打补丁式开发折磨了很久,换到Hermes之后,改动一个Agent只需要看它自己的逻辑,不用再顺着流程全文搜索谁在调用谁。如果你想试试,我建议先跑通“调研加写作”这种最简闭环节流,或者把手头一条最频繁变动的旧链路迁移过来。先让一个场景跑顺了,再慢慢扩大范围——一个pip install就能开始的事,没什么试错成本。