news 2026/9/9 12:00:34

从零搭建AI Agent消息中枢:hermes-agent架构设计与实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零搭建AI Agent消息中枢:hermes-agent架构设计与实践

做AI Agent这段时间,我越来越觉得,工具链里缺一个真正能“跑起来”的消息中枢。很多项目都是模型很强、Prompt写得很花,但落到实际任务上,各种工具调用、状态同步、记忆管理的问题就全冒出来了。hermes-agent 这个名字,借的是希腊神话里信使之神赫尔墨斯的名号——干的事情也差不多:在用户、大模型、外部工具之间做消息的搬运、路由和编排。这篇东西不聊概念,就聊聊我把 hermes-agent 从零搭起来的过程,包括架构怎么定的、核心模块怎么切的、踩了哪些坑,以及一些可以直接抄走的配置和代码。

这个项目适合谁?我觉得主要有三类人:一是正在做个人助理类 Agent 的开发者,二是想把多个大模型 API 封装成统一工具出口的团队,三是对 Agent 内部的消息流转机制好奇、想搞明白“模型怎么一步步调工具完成任务”的朋友。如果你只是想要一个现成的“一键部署的智能体”,那这篇东西对你帮助有限——我讲的更多是“怎么把一个 Agent 内部的事情理清楚”,而不是“怎么最快跑通一个 Demo”。

1. hermes-agent 的整体设计思路

1.1 为什么要把 Agent 拆成“消息路由”的形态

做 Agent 的第一直觉,往往是先想“我要一个什么样的助手”,然后直接写一个巨大的循环:读用户输入、调大模型、拿到回复、再读、再调。这个思路在 Demo 阶段完全没问题,但一旦任务复杂度上来,你会发现所有逻辑都挤在一个循环里,每个新功能都要改主流程,改到最后自己都不敢动了。

hermes-agent 换了个思路:把 Agent 看作一个消息路由器。用户的一句话进来,经过意图识别后变成一条结构化消息;这条消息在内部流转,被规划模块拆解成子任务;每个子任务带着自己的参数,被路由到对应的工具执行器;工具执行完,结果又变成一条消息回到模型手里,模型决定下一步是继续调工具还是给出最终答复。也就是说,整个系统的主干不是“模型的推理循环”,而是“消息的流转管线”。

这样做的好处有三个。第一,每个环节都能独立测试,规划模块、工具模块、记忆模块各管各的,出问题只要看消息在哪一步断了就行。第二,扩展新能力基本不用动主干,注册一个新工具就是往路由表里加一条记录。第三,多智能体协作变得很自然,两个 Agent 之间本质上就是互相投递消息,无非是消息的“消费者”从工具变成了另一个 Agent。

对应的代价是前期设计成本高,你必须在写代码之前就把消息格式、状态机、错误处理这些定义清楚。很多人觉得 Agent 项目“不需要设计,跑起来再说”,我恰恰认为 Agent 是最需要先设计消息格式的项目——因为模型的输出天然不稳定,如果消息格式都不稳定,后面所有环节都会跟着抖。

1.2 核心架构:四个层次和一条主线

hermes-agent 实际落地时分成四个层次:

  • 接入层:负责对接各种来源的输入,包括命令行、HTTP 接口、定时任务、Webhook。这一层只做一件事:把各种格式的输入统一转换成内部消息。
  • 编排层:核心逻辑所在,包含任务规划、工具调度、上下文组装、结果裁决。它是整个 Agent 的“大脑皮层”。
  • 工具层:所有外部能力的抽象,比如 HTTP 请求工具、数据库查询工具、文件读写工具、搜索工具。每个工具只负责“把输入参数变成输出结果”,不感知上层逻辑。
  • 存储层:短期上下文缓存、长期记忆库、任务状态的持久化。存储层看起来不显眼,但 Agent 能不能在多轮对话里“稳住”,主要看这一层。

四个层次之外,还有一条贯穿始终的主线,就是消息。我在 hermes-agent 里定义了一个统一的消息结构,不管哪一层之间传递什么内容,都走同一种格式。这个结构大概长这样:

@dataclass class AgentMessage: msg_id: str # 消息唯一 ID msg_type: str # 消息类型:user_input / plan / tool_call / tool_result / final_answer role: str # 发送方角色:user / planner / executor / memory content: dict # 核心内容,按 msg_type 不同而不同 meta: dict # 元信息:时间戳、token 消耗、重试次数等 parent_id: str | None # 父消息 ID,用于追踪整条链路

所有消息都带parent_id,这是我最坚持的一个设计。有了这一条,任何一个任务都可以回溯完整链路:用户当时说了什么、模型分了几步、每步调了什么工具、工具返回了什么、最后怎么得到的结论。排查问题的时候,这条链路就是案发现场的监控录像,省了无数口舌。

这一节先讲整体,接下来把每个层次的细节拆开说。

2. 核心模块的细节设计与选型理由

2.1 任务规划:ReAct 循环在 hermes-agent 里的实现

ReAct 是目前单体 Agent 里最稳的模式之一。它的核心思路特别朴素:模型不是在“一口气回答问题”,而是在“一步一步做决策”。每一步,模型先输出自己的思考(Thought),再决定要不要调用工具(Action),然后观察工具返回的结果(Observation),接着再进入下一步思考。这个循环一直持续到模型认为自己拿到了足够的信息,才输出最终答案(Final Answer)。

hermes-agent 的规划模块就是把这个循环显式地做成了一个状态机。你看到的所谓 Agent“会自己思考”,其实就是这个状态机在后台转圈。状态机的状态有:

  • NEED_PLAN:刚收到用户输入,准备开始规划
  • WAITING_TOOL:模型请求调用工具,等待工具执行
  • PROCESSING_RESULT:工具执行完毕,正在把结果喂回模型
  • READY_ANSWER:模型认为可以给出答案,停止循环

为什么用状态机而不是一个while循环加几个if?因为 Agent 在真实环境里不是一直往下走的。工具调用可能超时、可能报错、可能返回的数据不完整,这些情况都需要在状态层面单独处理。举个例子,工具超时的时候,你不想让整个循环死掉,而是想让规划器生成一条“工具超时,请换一种方式”的消息继续走流程。用状态机来管理,每条边的条件都清清楚楚,调试的时候一目了然。

这里有一个很多人容易犯的错误:把所有的 Prompt 都堆在一个巨大的 system prompt 里,然后指望模型自动“ReAct”。实际上,ReAct 的推理格式最好通过函数调用来约束,而不是靠 Prompt 提示。你可以告诉模型“请一步一步思考”,但更可靠的是给模型一个明确的工具调用接口,让它只能在规定的工具里选、只能用规定的参数结构。为什么会这样?因为大模型在自由文本输出时,格式漂移的概率远比我们想象的高;而一旦走函数调用通道,模型输出的就是结构化的 JSON,后续解析就不容易出错。

2.2 工具注册与 Function Calling 的参数约束

工具层是 hermes-agent 里最简单、也最容易写乱的一层。简单在于每个工具本质就是“输入参数转输出结果”的函数;容易写乱在于,工具的输入参数描述如果不规范,模型根本不知道该传什么。

我在项目里做了一个工具注册表,核心是一个装饰器:

@tool( name="http_get", description="发起一个 HTTP GET 请求,返回响应正文。适用于抓取网页、调用公开 API。", parameters={ "type": "object", "properties": { "url": {"type": "string", "description": "目标 URL"}, "timeout": {"type": "integer", "description": "超时时间,默认 10 秒"} }, "required": ["url"] } ) def http_get(url: str, timeout: int = 10) -> str: # 实际实现省略 return response_text

这个注册表有两个关键作用。第一,它在系统启动时把所有工具的描述和参数 Schema 汇总成一份 JSON,塞进模型接口的tools参数里,模型据此决定调用哪个工具、填什么参数。第二,它像一个插件系统,业务方只要按这个格式写一个函数,不需要改任何主流程代码,新工具就自动接入。

参数描述这件事,我踩过最大的坑是“描述写得太简单”。比如一个查询数据库的工具,参数只写了sql: string,模型就会自由发挥,生成各种表名不存在的 SQL。后来我把描述改成:

"sql": { "type": "string", "description": "要执行的 SQL 语句。注意:只能使用 employees、orders、products 三张表,必须包含 WHERE 条件,禁止使用 DELETE 和 UPDATE。" }

模型调用的准确率立刻上了一个台阶。工具描述的详细程度,直接决定了模型在边界场景下的表现。你把它当 API 文档写,它就会表现得像一个认真读文档的程序员;你只给它一个函数名,它就只能靠猜。

2.3 记忆管理:短期上下文与长期存储的取舍

记忆是 Agent 里最容易被低估的模块。很多 Demo 用一次对话或者临时会话就完事了,但真实场景下,用户会在不同的时间点问相关的问题,期望 Agent 记得之前的上下文。

我把记忆分成两层:短期上下文和长期记忆。短期上下文就是当前任务窗口里的对话和工具调用记录,直接通过消息列表传给大模型。长期记忆则放在外部存储里,一般是向量数据库,按语义相似度检索后,把相关片段作为背景信息拼进当前上下文。

为什么不能把所有历史都塞进去?主要受制于上下文窗口长度和成本。上下文越长,单次请求的 token 消耗越大,响应延迟也越高,而且模型在超长上下文里的注意力会稀释,反而容易忽略关键信息。所以 hermes-agent 的做法是:当前任务相关的消息完整保留,任务结束之后做一次摘要,摘要入库,原始消息按策略清理或压缩。

这个取舍过程其实很像人的记忆。你不会记得昨天每一顿饭吃了什么,但你会记得“昨天和朋友讨论过某件事的结论”。Agent 也一样,保留结论、丢弃过程,才能在长期运行中既不爆 token 又不丢关键信息。实际项目里,我通常设定一个阈值:短期消息超过 30 条或者超过 8000 token 时,触发一次摘要归档。归档不是简单删掉,而是把关键事实、结论、待办事项抽取出来,以结构化文本存入长期记忆库。

3. 实操:把 hermes-agent 跑起来

3.1 项目初始化与环境配置

先看目录结构。我当时建项目的时候刻意保持了目录的克制,没有一上来就堆一堆微服务,单体优先,方便迭代:

hermes-agent/ ├── hermes/ │ ├── __init__.py │ ├── messages.py # 消息结构定义 │ ├── router.py # 消息路由与主循环 │ ├── planner.py # 任务规划器 │ ├── memory.py # 记忆管理 │ └── tools/ │ ├── __init__.py # 工具注册表 │ ├── http_tools.py │ ├── db_tools.py │ └── file_tools.py ├── config.py ├── main.py └── requirements.txt

依赖方面,我当时最核心的只有四个:大模型 SDK(OpenAI 兼容接口即可)、pydantic 做数据校验、tenacity 做重试、chromadb 做长期记忆存储。没有特殊情况不要随便引入重量级框架,Agent 这类项目最大的变数在逻辑编排,不在基础设施,越轻越好跑。

环境变量主要配两个:模型 API 的地址和 Key,还有默认的模型名称。这里有个经验之谈:把模型名称也放到配置里,别写死在代码中。开发调试的时候用便宜的轻量模型,正式跑的时候切到更强的模型,这是个能省不少钱的操作。

3.2 核心代码:消息循环与工具调用的最小实现

整个 hermes-agent 的灵魂,是这个主循环。我把它精简到只剩核心逻辑,方便看清结构:

# router.py 核心逻辑(精简版) def run_agent(user_input: str): # 1. 组装初始消息 messages = [system_prompt(), {"role": "user", "content": user_input}] max_steps = 10 for step in range(max_steps): # 2. 调用大模型,传入工具 Schema response = llm.chat(messages=messages, tools=tool_schemas) # 3. 如果模型没有请求调用工具,说明可以给出最终答复 if not response.tool_calls: return response.content # 4. 把模型的工具调用请求追加到消息列表 messages.append(response.message) # 5. 逐个执行工具,并把结果回传给模型 for tool_call in response.tool_calls: result = execute_tool(tool_call) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": result }) return "抱歉,步骤太多,我还没整理好结论。"

这段代码是浓缩后的骨架,实际项目里你还要加:异常重试、工具超时、上下文截断策略、每一步的日志记录。但从阅读角度,这个骨架已经能让人理解 Agent 的运行逻辑了:模型提需求,环境给结果,循环往复,直到模型认为信息足够。

max_steps这个参数是我强烈建议保留的。很多 Agent “失控”的场景,本质就是模型在一个任务上绕圈子,反复调同一个工具拿不到有意义的结果。加了上限之后,至少不会无限烧 token。我一般设 8 到 15,具体看任务复杂度。

3.3 实战案例:让 agent 自动完成一次信息收集与总结

举个完整的例子。假设用户说:“帮我查一下杭州未来三天的天气,然后告诉我适不适合户外跑步。”

hermes-agent 处理这个任务时,消息流转是这样的:

第一轮,模型判断需要天气数据,于是发起一次工具调用。内部的 tool_call 大致是:

{ "name": "http_get", "arguments": { "url": "https://api.example.com/weather?city=hangzhou&days=3", "timeout": 15 } }

第二轮,工具返回天气数据,包括温度、降水概率、风力。模型拿到数据后,并不会立刻回答,而是先做一次推理:未来三天有没有雨、气温适不适合跑步、空气质量如何。这一步没有工具调用,模型直接输出 final answer,循环结束。

整个过程的 token 消耗,我实测下来大约在 2000 到 4000 之间,具体取决于天气数据的长度和模型的输出习惯。如果你发现某次任务消耗异常高,多半是模型在“反复试探”——调了搜索工具、又调了天气工具、还调了地图工具,最后才确定答案。这种时候,要么限制工具数量,要么在工具描述里写明“这个任务只需要查天气”。

实际跑起来还有个容易被忽略的点:工具返回的数据格式。如果天气 API 返回的是嵌套 JSON,直接原样塞给模型,模型也能读,但会浪费 token 而且容易理解偏。更稳妥的做法是在工具内部先做一层“文本化”,把 JSON 转成一行摘要,比如“杭州 3 月 15 日:晴,12-22 度,降水概率 10%,风力 3 级”。工具的输出越接近自然语言,模型的理解成本越低,这算是我在 hermes-agent 里踩过坑之后总结出的一个通用原则。

4. 常见问题与排障实录

4.1 工具参数格式不对导致调用失败

这是我在 hermes-agent 里遇到频率最高的问题。症状很统一:模型明明调用了工具,但服务端报参数校验失败,或者工具执行时直接抛异常。

排查思路分三步。第一步,看模型传的参数到底是什么。我在工具执行器入口加了一行日志,把 tool_call 的完整参数打印出来,几乎 80% 的问题在这一步就能定位。第二步,看参数 Schema 是否合理。最常见的是必填项没标清楚,或者描述里没写取值范围,模型只能靠猜。第三步,如果是嵌套参数结构,考虑把它拆扁平。模型对深层嵌套 JSON 的生成稳定性,远不如对扁平结构。

另外,我强烈建议在每个工具内部做一层容错:参数解析失败时,不是直接抛异常让整个 Agent 崩溃,而是返回一条结构化错误消息给模型,比如“参数错误:缺少 city 字段,请补充后重试”。模型看到这条消息后,通常会自己修正参数再调一次。这个机制大大提高了工具调用的成功率。

4.2 上下文长度超限怎么处理

多轮任务跑多了,一定会碰到 context length exceeded。这个问题的根源是消息列表只增不减,工具返回的结果动辄几百上千 token,积累几轮就爆了。

我的处理策略按优先级排序:先裁剪不重要的工具输出,只保留最终结论;再压缩历史对话,把旧对话用摘要替换;最后才考虑换更大上下文的模型,因为这只是把问题延后,不是解决。实际代码里就是一段简单的文本处理:

def compress_messages(messages, max_tokens=8000): # 保留 system prompt # 保留最新 2 轮用户消息 # 中间的历史消息:调用摘要模型生成一句总结,塞回原位置 return compressed

这个压缩动作要放在主循环里,每次调用模型之前检查消息总长度。我一般以 token 数而不是消息条数为判断依据,因为不同工具返回的 token 差异太大。

4.3 多轮对话中的“失忆”问题

“失忆”和“上下文超限”是两个相反方向的病。一个是塞太多,一个是存太少。最典型的表现:用户第二句话提到“刚才说的那个任务”,Agent 一脸茫然。原因无非两种:短期上下文在上一轮结束后被清掉了,或者长期记忆没有接入。

解决办法是给 hermes-agent 加一层“会话级持久化”。每一轮对话结束时,把这一轮的关键结论抽出来,写入长期记忆库;新一轮开始时,先在记忆库检索与当前问题相关的历史片段,组装进上下文。我在 memory.py 里用了 chromadb,检索时按相似度取 Top 3 片段,每条片段不超过 300 token。这样既不会丢关键信息,也不会把乱七八糟的历史全填进去。

这个机制跑通之后,你会明显感觉到 Agent 的“人味”上来了:它记得你上次提到过的项目、记得你偏好的回复风格、甚至记得你讨厌在回复里用表情符号。如果说主循环是 Agent 的心脏,记忆模块就是它的长期肌肉记忆。

5. 一些零散但重要的经验

写到这里,再补几个不写成单独章节但很要紧的实践心得。

第一,日志一定要带 trace_id。我在 AgentMessage 里保留 parent_id 的另一个原因,就是日志系统可以直接按链路 ID 汇聚所有相关日志。没有这个,排查多轮任务的问题会痛不欲生。

第二,控制一次任务里暴露给模型的工具数量。工具太多,模型容易“挑花眼”,明明用 A 工具就能完成,非要去调 B 工具绕一圈。我通常把工具按领域分组,根据用户意图先做一轮粗筛,只把相关组的工具塞给模型。

第三,为每个工具调用设置超时和重试上限。我用 tenacity 统一控制,HTTP 类工具默认 15 秒超时、最多重试 2 次。工具长时间不返回,整个 Agent 的响应都会被拖住,这种问题在真实环境里比模型出错还常见。

第四,不要迷信“更强的模型就能解决所有问题”。我试过把某个特别复杂的 Agent 任务从轻量模型切换到旗舰模型,结果确实好了不少,但代价是单次调用成本翻了十倍。后来我仔细看日志发现,真正救场的是我修好了工具参数描述,模型只是正常发挥。先优化工程,再升级模型,这个顺序能省不少钱。

我个人在实际操作里的体会是:hermes-agent 这类项目,真正难的不是写代码,而是把“模型的不确定性”和“工程的确定性”之间的缝隙填平。状态机、消息结构、工具注册、记忆管理,所有这些设计,本质上都是在这个缝隙里打补丁。你打的补丁越规整,Agent 就越像一个可靠的系统,而不是一个偶尔灵光的玩具。如果你也正在做类似的 Agent 项目,我建议你先别急着堆功能,花一个下午把消息格式和主循环理清楚,后面的路会顺很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 11:57:31

头歌实践教学平台:Java入门-分支结构(四)

第6关:来吧,我是BOSS!任务描述 结合本章节所学内容,完成本关所有的编程题。相关知识 扫描仪(Scanner)已经创建,用户输入的数据也已经获取,请按照题目要求通关。第一题 编写一个Java程…

作者头像 李华
网站建设 2026/9/9 11:57:08

基于.NET的开源跨平台自动升级组件设计与实践

1. 自动升级这件事,为什么值得自己做一套组件先聊个很实际的问题:你的应用程序做完了,功能、界面、性能都调好了,接下来最容易被忽略、却又最影响用户体验的是什么?答案是升级。我见过太多团队在产品上线后才开始头疼—…

作者头像 李华
网站建设 2026/9/9 11:54:42

magnitude不是CLI命令,而是嵌入向量的L2模长度量

1. “magnitude”不是命令行工具,而是本地AI推理服务的底层度量引擎很多人第一次在终端里敲下magnitude,期待它像git或curl那样立刻响应——结果却只收到command not found。这背后没有玄机,也没有被隐藏的二进制文件;“magnitude…

作者头像 李华
网站建设 2026/9/9 11:54:21

IoT版本治理实战:固件、配置与设备模型的独立版本管理

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/9 11:53:41

WorkBuddy效率智能体从零上手:安装部署、Skill与连接器实操指南

之前在整理个人知识库和自动化流程时,经常在笔记、数据表、消息通知之间来回切换,工具装了一堆,真正用起来的没几个。后来花时间系统梳理了 WorkBuddy 的完整使用流程,才发现它解决的不只是“聊天”问题,而是把模型、工…

作者头像 李华
网站建设 2026/9/9 11:52:50

C#调用佳能EDSDK开发实战:从P/Invoke封装到相机控制完整指南

简介:这份C#开发示例面向需要以编程方式控制佳能相机的.NET开发者,系统演示了EDSDK在设备管理、实时预览、参数调整、远程快门及图像下载等环节的调用方法。压缩包仅73KB,包含18个文件,其中8个cs源码为主要内容,覆盖相…

作者头像 李华