很多人都觉得,只要把大模型 API 一接,再丢给它几个工具函数,一个 AI 智能体就算做完了。可真到了实际项目里,你会发现 prompt 写得再花哨,只要工具一多、任务一长,agent 就开始“胡言乱语”,要么调错工具,要么绕来绕去就是不肯收尾。这也是为什么我折腾了大半年之后,最终把自己的智能体架构收敛成了一个叫hermes-agent的项目。名字借用了希腊神话里那个跑得飞快、负责传递信息的信使神,说白了就是想让智能体把“理解请求、调度工具、汇总结果”这件事,干得像个靠谱传话人一样利落。
hermes-agent不是一个颠覆式的“新框架”,而是一整套我自己在生产环境里反复打磨过的智能体骨架。它核心解决的是三个问题:工具多了怎么不乱、任务长了怎么不丢上下文、出了问题怎么快速定位。适合正在自己搭智能体、或者想从“demo 玩具”走向“可维护项目”的开发者参考。这篇文章会把设计思路、核心模块、关键代码、常见坑,从头到尾讲清楚。
1. 为什么叫 hermes-agent:定位与设计思路
1.1 名字背后的定位:信使神与智能体路由层
在希腊神话里,赫尔墨斯(Hermes)是奥林匹斯山的信使,负责在众神之间传递消息、引导灵魂、调解纠纷。这个名字放在 agent 项目里其实特别贴切:一个智能体,本质上就是大模型和外部世界之间的信使——模型负责思考和生成,agent 负责把模型的想法变成真实的动作,再把动作的结果带回来。
我当时给这个项目起名,就是想强调一个容易被忽视的定位:agent 不该是一个什么都往里塞的“万能程序”,而应该是一层薄薄的路由和调度层。它不需要知道天气 API 内部怎么实现,也不需要懂数据库表结构,它只需要做到三件事:听懂用户要什么、找到能干的工具、把结果整理成人话。
这个定位听起来简单,实际做起来却很容易走偏。很多人一开始就往 agent 里塞记忆模块、多智能体协议、向量数据库,结果项目越做越重,反而连最基础的“查个天气再算个乘法”都跑不顺。hermes-agent的初始设计原则特别朴素:先解决“路由精度”,再谈扩展能力。
1.2 和 LangChain / AutoGPT 这类框架有什么不一样
现在市面上的 agent 框架很多,LangChain、AutoGPT、BabyAGI 各有拥趸。但说句实话,这些框架在 demo 里都惊艳,一上真实业务就开始暴露问题:LangChain 抽象层太厚,出了问题追到源码里要翻好几层;AutoGPT 太“放飞”,任务稍微复杂一点就开始自我发挥,不可控。
hermes-agent跟他们最大的区别,是我刻意做了减法。
- 不去发明复杂的 agent 协议,就用最经典的 模型-工具-记忆 三角结构。
- 不追求全自动,每一步执行都有可观测的日志和中断点,方便人工介入。
- 不把编排逻辑藏进框架,执行循环就是一段能读懂的 Python 代码,谁接手都能快速上手改。
有人可能会问,这样不是“开倒车”吗?智能体不就应该越自动越好吗?我的观点是:自动化的前提是可控。一个每十次就有一次会调错工具的 agent,就已经不具备无人值守的资格。与其给它更多自主权,不如先把路由和调度打磨到 99% 的准确率。这也是hermes-agent和那些“重量级框架”最大的理念分叉。
2. 老生常谈但必须拆细:智能体最核心的四个模块
2.1 模型接入层:所有对话的“大脑皮层”
模型接入层是整个 agent 的地基。这个模块做的事情看起来简单——把用户请求发给大模型,拿到回复——但实际设计的时候要回答几个问题:支持哪些模型?多模型之间怎么切换?模型返回的格式不稳定怎么办?
hermes-agent在这一层做了一个非常朴素的抽象:不管底层是哪个厂家的模型,对外只暴露一个chat(messages, tools)接口。内部把各个模型厂商的 API 封装成统一的请求格式,同时在配置里留好model_provider字段,方便随时切换。
这里有一个真实的经验教训:永远不要信任模型返回的 JSON 格式。不管是再强的模型,都有概率在输出里多一个注释、少一个引号、甚至突然开始“自言自语”。所以我在模型接入层做了三层防护:第一层是让模型强制以 JSON 输出,第二层是对返回结果做“提取式解析”(用正则把最像 JSON 的部分抓出来),第三层是解析失败后自动重试一次。这三层下来,格式异常的几率从最初的 10% 降到了 1% 以下。
2.2 工具注册与调用:给 agent 一把把“趁手的兵器”
工具层是hermes-agent里迭代次数最多、也最值得讲的一个模块。它的核心职责有两个:让模型知道有哪些工具可用,以及让模型学会正确地调用它们。
很多教程会教你用一个大数组把所有工具描述丢给模型,然后让模型自己选。这在工具少于五个的时候确实没问题,但工具一多——比如我自己的项目里挂了二十多个工具——模型就开始“选择困难”了。不是把参数填错,就是选了不是最优的那个工具。
hermes-agent的工具层为此做了一件事:给每个工具都绑定一个“路由规则”。
每个工具在注册时,除了提供名字、描述、参数 schema,还要提供一个routing_keywords字段,用来告诉 agent“这个工具适合什么场景”。比如天气查询工具会绑定“天气、气温、下雨、PM2.5”,计算器工具会绑定“计算、加减乘除、算术”。模型在收到请求后,系统 prompt 里会明确要求它先根据routing_keywords做一次粗筛,再在候选列表里精确选择。
这一招的效果立竿见影。工具数量从 5 个增加到 20 个之后,调用准确率不仅没有下降,反而因为召回范围变小而提升了。
2.3 记忆与上下文管理:不要让对话变成“金鱼记忆”
做过 agent 的人都有一个直觉:上下文越长,模型表现越差,成本还越高。但完全不要记忆,agent 就是一个没有灵魂的问答机器,用户说了“帮我查下刚才那个”它就懵了。
hermes-agent的记忆模块采用了一个非常实用的分层策略:工作记忆 + 滚动摘要。
工作记忆就是最近几轮对话的完整消息,保持在上下文窗口内;滚动摘要是对更早对话的压缩总结,每隔几轮触发一次,由模型自己把之前的聊天记录整理成摘要。这两层记忆合在一起,既保证 agent 不会忘记关键信息,又不会让上下文无限膨胀。
这部分还有一个很容易被忽略的细节:记忆不只是存对话记录,还要存“事实”和“状态”。比如用户说了一个重要的偏好(“我平时都在上海办公”),或者一个任务执行到一半(“刚才查到的那个数据还没用上”),这些信息如果只是躺在聊天记录里,模型不一定能准确回忆出来。我后来单独加了一个fact_store,把模型从对话中抽取出的关键事实存成结构化表单,每次请求时自动注入到系统提示里。这个改动把多轮交互中的“记忆相关错误”减少了大约一半。
2.4 执行循环与任务编排:agent 的心脏跳动方式
执行循环是 agent 的“主循环”,也是hermes-agent里最核心的一段代码。它的工作流大致是这样:
- 接收用户请求,组装 messages。
- 把 messages 发给模型,带上工具列表。
- 模型返回两种可能:直接回答,或者请求调用某个工具。
- 如果是工具调用,就执行工具,把结果追加到 messages 里,回到第 2 步。
- 如果模型给出最终回答,就返回给用户,结束。
这个循环看起来简单,真正写起来有很多细节要考虑:最大轮数限制设多少合适?工具调用报错了怎么反馈给模型?模型连续调用同一个工具三次以上是不是死循环?
hermes-agent的做法是:默认最大轮数设为 8,超过即中断并提示用户“任务复杂度超出限制”;工具调用失败时,会把错误信息格式化后追加到上下文里,并且要求模型“换一种方式实现同一个目标”,而不是让模型重复报错。这些细节单独看都不起眼,合在一起才让整个循环真正“稳”。
3. 手把手搭一个 hermes-agent:关键代码与配置
3.1 最小可运行的骨架:麻雀虽小,五脏俱全
与其空谈设计,不如直接看代码。hermes-agent的最小骨架,我用 Python 写出来大概是这样的感觉:
from dataclasses import dataclass, field from typing import Callable, Any, Optional @dataclass class Tool: name: str description: str parameters: dict function: Callable[..., Any] routing_keywords: list[str] = field(default_factory=list) class ToolRegistry: def __init__(self): self._tools = {} def register(self, tool: Tool): self._tools[tool.name] = tool def list_tools(self) -> list[dict]: return [ { "name": t.name, "description": t.description, "parameters": t.parameters, } for t in self._tools.values() ] def get(self, name: str) -> Optional[Tool]: return self._tools.get(name)这看着非常简单,但它已经是整个工具系统的“地基”。ToolRegistry 的职责非常单一:注册、列出、按名字取。后面所有复杂的设计——路由、校验、容错——都是在这个基础上长出来的。
具体的执行循环,核心逻辑也不复杂,大概长这样:
def run_agent(user_input: str, registry: ToolRegistry, max_rounds: int = 8): messages = [{"role": "user", "content": user_input}] tools = registry.list_tools() for step in range(max_rounds): response = chat_completion(messages, tools=tools) msg = response.choices[0].message if msg.tool_calls: messages.append(msg) for call in msg.tool_calls: tool = registry.get(call.function.name) if not tool: messages.append({ "role": "tool", "tool_call_id": call.id, "content": f"工具 {call.function.name} 不存在", }) continue try: result = tool.function(**json.loads(call.function.arguments)) except Exception as e: result = f"工具执行出错: {e}" messages.append({ "role": "tool", "tool_call_id": call.id, "content": str(result), }) else: return msg.content return "任务太复杂了,请拆分成多步操作后再试。"这个循环虽然短,但已经是hermes-agent的核心。每次工具调用后,结果会以tool消息追加回上下文,模型就能基于真实执行结果继续思考,而不是凭空猜测。
3.2 工具注册的规范:参数 schema 写得好不好,直接决定调用准不准
在hermes-agent里,工具注册是一个“写文档”的过程:你必须把工具的参数定义成 JSON Schema,模型才能理解怎么调用。而这一步,恰恰是大多数人做得最敷衍的。
举一个我实际踩过的例子。我给一个“发送邮件”工具注册时,参数 schema 里to字段只写了“收件人”,没写格式。结果模型调用的时候,直接把一个数组传给了to,导致校验失败。后来我把描述改成“收件人邮箱地址,如果是多个收件人请用逗号分隔拼成一个字符串”,模型就再也没犯过这个错。
工具描述这件事,本质上是写给模型看的,不是写给程序看的。
hermes-agent对这个规范做了两条硬性要求:
- 每个参数的 description 都必须回答“我应该怎么填”而不是“这个参数是什么”。
- 所有枚举值、单位、格式都要写清楚,模型不会“猜”你的意思。
靠近这两条,工具调用成功率会肉眼可见地上升。这也是我反复跟身边的人强调的:如果你的 agent 工具调用总出错,检查一下是不是工具描述写得太“人类”了——那个只有人类才懂的“你懂的”,模型真的不懂。
3.3 请求处理流程:从用户输入到最终输出的完整链路
如果把hermes-agent的一次请求全过程画出来,大概是这样的流程(文字版):
- 预处理:检查输入是否包含攻击性内容,过滤异常符号,提取用户 ID 和会话 ID。
- 上下文组装:从记忆模块拉取当前会话的工作记忆和滚动摘要,再带上
fact_store里的关键事实,一起塞进系统提示。 - 工具粗筛:根据系统提示里的路由规则和当前用户输入,从注册表里筛出候选工具子集。
- 模型推理:把完整上下文和候选工具列表发给模型,拿到回复。
- 动作执行:如果模型要求调用工具,就按第 3 节里的循环执行;否则直接输出最终结果。
- 记忆更新:把这一轮对话写入工作记忆,如果触发条件就启动摘要压缩。
- 日志记录:把这次请求的完整耗时、令牌消耗、工具调用链写入结构化日志。
这 7 个环节,任何一个做不好,都会直接影响用户体验。比如记忆更新不及时,用户会察觉 agent“忘了事”;日志记录不全,出了问题只能靠猜。
在实际项目里,我几乎从不在原有链路里新增“一次性”逻辑,而是坚持所有能力都拆成模块再接入主流程。这样做的收益是,主流程永远保持清晰,任何环节出了问题都能单独降级或关闭,而不是整个 agent 一起崩溃。
4. 实操过程:让它真正跑起来
4.1 从零到一:环境准备与依赖清单
hermes-agent的依赖非常轻,核心只有一个openai风格的 SDK(用来调大模型),外加一个pydantic(用来做参数校验)。如果你打算接国内的大模型,也只需要把 base_url 换一下就行,其他逻辑不变。
建议的安装方式:
pip install openai pydantic配置方面,我用一个 YAML 文件来管理所有模型和工具相关设置:
model: provider: openai name: gpt-4o-mini temperature: 0.2 agent: max_rounds: 8 memory_window: 10 summary_trigger: 15 tools: - name: get_weather enabled: true - name: calculator enabled: true这里把temperature设成 0.2,是因为 agent 工具调用场景需要的是确定性,而不是创造性。温度太高,模型会开始乱发挥;太低,模型可能变得死板,连多义词都分不清。实测下来 0.1 到 0.3 之间是工具调用场景的“甜区”。
4.2 典型场景演练:查天气、做计算、查文档的三合一
光看不练没意思,我用hermes-agent跑一个典型的多工具场景:用户先问“上海今天要去见客户,需要带伞吗”,紧接着又说“顺便帮我算一下打车到徐家汇大概多少钱,距离按 8 公里算”。
这个请求涉及三个能力:天气查询、位置理解、计算。hermes-agent的处理过程大致是这样:
第一轮,模型识别出用户想查天气,于是调用get_weather(city="上海", date="今天")。天气接口返回“阴转小雨,降水概率 70%”。
第二轮,模型看到要下雨的结论,结合用户“带伞吗”的提问,正准备组织回答。但用户又追加了“打车价格”的请求,所以模型继续调用calculator(expression="8 * 2.5"),其中 2.5 是当地出租车每公里报价(这个报价来自另一个工具,先查了计价规则)。
第三轮,模型拿到天气和计算两个结果后,最终给出完整回复:“今天上海有小雨,建议带伞。打车到徐家汇按 8 公里计算,大约 20 元左右,但下雨天可能有溢价。”
在这个例子里,要注意一个关键点:一次请求里模型可以连续调用多个工具,只要它觉得有必要。hermes-agent的执行循环天然支持多步调用,每个中间结果都会作为上下文继续给到模型,直到模型认为信息充分,给出最终回答。
4.3 性能与成本的控制手段:别让你的账单“飞上天”
智能体项目的一大痛点就是成本不可控。一个复杂任务跑下来,可能要调用几十次模型,每次都要把大量上下文重发一遍,token 消耗十分可观。
hermes-agent在控制成本方面做了三个务实的设计:
- 工具粗筛后只发送候选工具的描述,而不是把全部二十个工具一股脑塞进系统提示,每条工具描述省下的 token 虽然不多,但累积起来很可观。
- 记忆摘要压缩,把早期对话转换成摘要,避免上下文无限增长。
- 执行轮数上限,默认 8 轮。如果模型 8 轮还搞不定,说明这个任务超出了当前能力边界,继续跑纯属烧钱。
另外还有一个容易忽视的点:工具返回的内容本身也会占上下文。如果一个工具返回了一个一万字的文档,然后模型只用了其中一句话,剩下九千多字都是浪费。所以我给工具层加了一个max_output_length自动截断逻辑,超出部分直接裁掉,因为被截断的工具结果通常不会影响最终回答质量。
这三招叠加起来,我的智能体在同等任务量下,月均模型调用成本大约下降了 40%。控制成本这件事,与其等账单炸了再优化,不如在设计时就提前留好“节流阀”。
5. 常见问题与排查技巧实录
5.1 工具调用失灵:不是模型变笨了,而是上下文脏了
我调试hermes-agent的时候,遇到最多的一类问题就是“模型突然开始调用不存在的工具”。
最开始我以为是模型理解能力不行,后来发现真正的原因,是上下文里残留了旧错误。比如某次工具调用因为网络超时报错,报错信息被写回上下文,模型看到了就会在后续轮次里反复尝试同一个错误工具。这就是所谓的“上下文污染”。
解决办法有两个:
- 每次工具报错后,在上下文中追加一句提示:“之前的工具调用失败了,请换一种方式,不要重复相同的调用。”这能有效阻止模型钻牛角尖。
- 严格控制工具列表的更新方式,只有在模型应该看到新工具时才把新工具追加到列表里,避免模型引用已经“下架”的工具。
本质上,模型没有人类那样的“清零能力”,它会带着之前的信息继续思考。所以 agent 框架要主动帮助模型随时“纠偏”,而不是指望它自己发现问题。
5.2 上下文爆炸:为什么 agent 越跑越慢、越跑越贵
上下文爆炸可能是所有 agent 项目里最普遍的问题。症状是:任务刚开始很流畅,几轮对话之后,模型的响应开始变慢,甚至开始丢三落四。
原因其实很好理解——每次请求都要把所有历史消息重新发送一遍。对话越长,每次消耗的 token 就越多,模型处理时间也越长。
hermes-agent的记忆模块就是为这个问题准备的。我在前面提到过“工作记忆 + 滚动摘要”,这里具体展开一下参数设置:
memory_window=10:保留最近 10 轮完整对话。summary_trigger=15:当累计对话超过 15 轮时,触发一次摘要压缩。- 摘要本身存放为一条
system消息,放在消息队列最前面。
这个策略能保证上下文长度始终维持在一个稳定区间,不会无限膨胀。但也要注意,摘要压缩是有损的,假如早期对话里有某个关键事实被遗漏,后面 agent 就可能“失忆”。所以我后来把“关键事实抽取”单独抽出来做成fact_store,相当于给 agent 配了一个“重点笔记”,即使摘要丢失细节,笔记还在。
5.3 工具参数校验失败的三种典型场景
工具参数校验失败,几乎是 agent 项目里最让人抓狂的错误。明明工具列表就在那里,模型却总是把参数填错。
hermes-agent的实践中,我总结出三个高频场景:
- 字符串枚举出错:模型在
city字段里填了一个 “Shanghai City”,而接口期望的是 “Shanghai”。这时候你需要在参数描述里把合法值尽量列全,干脆写成“请使用城市代码,例如 shanghai、beijing、guangzhou”,并且去做归一化。 - 缺失必填参数:模型漏掉了某些必填项。我采用的办法是:在
parameters的required列表里明确标记,并且在调用工具前做一次本地校验,缺参就直接返回友好错误,不调工具。 - 数组和对象的嵌套格式错:模型把
tags数组写成了逗号分隔字符串。这个问题靠描述很难彻底解决,我最后是用一个“修复层”:调用工具前先把参数 JSON 解析出来,然后按照 schema 做一次类型强制转换。
这个“修复层”是我非常推荐大家尝试的思路。与其指望模型每次都能完美输出,不如在工具入口处加一道“清洗工序”,把模型偶尔的小毛病驯化掉。
5.4 可观测性怎么补:没有日志,排查问题等于大海捞针
开发智能体项目的时候,最痛苦的事情就是“我明明给了答案,为什么不按我的想法来”。靠肉眼看 LLM 的回答、靠猜 prompt 哪里有问题,效率极低。必须有结构化的日志。
hermes-agent里每个请求我都会记录这几个字段,存在本地文件或日志平台:
| 字段 | 说明 |
|---|---|
request_id | 一次请求的唯一标识 |
session_id | 会话 ID,用于关联多轮对话 |
steps | 每一轮模型调用的完整轨迹 |
tool_calls | 本次请求触发了哪些工具,入参出参是什么 |
token_usage | 输入输出 token 数及总量 |
latency_per_step | 每一步的耗时明细 |
error_info | 如果出错,记录是模型异常、工具异常还是超时 |
有了这份日志之后,绝大多数问题都能“一看定位”。比如用户投诉“agent 答非所问”,打开日志一看,原来是系统提示里混进了一段无关的历史对话;再比如“计算错误”,看日志发现模型把 8 公里乘成了 8.5。没有日志,这些问题就只能靠复现,而复现 LLM 的随机性几乎不可能。
6. 写在最后的一点个人体会
hermes-agent从最初的一个玩具脚本,到现在能稳定跑在生产环境里,中间踩过的坑比我预想的多得多。最大的体会是:智能体项目最难的从来不是模型能力,而是工程化。模型输出的不确定性、工具调用的边界情况、上下文的膨胀,这些东西不自己动手跑一遍,光看文档永远感受不到。
另外一个让我印象很深的经验是,别把所有希望都寄托在“更强的模型”上。很多人觉得调用 GPT-5 之后,agent 就能自动变聪明。但实测下来,更强的模型确实能减少一部分工具调用错误,可它依然会犯“参数填错”“上下文污染”这类基础错误。这些问题的解法,仍然要靠框架层面的设计去兜底。
所以如果你也在折腾自己的智能体,我建议你从小而稳入手:先把一条工具调用链路跑通,再逐步加记忆、加路由、加多智能体。别一开始就追求“大而全”,那只会让你连“哪里坏了”都查不清楚。hermes-agent这个项目,本质上就是我在一次次“哪里坏了”的排查中,慢慢长出来的答案。