Hermes-Agent:轻量级智能体运行时的设计与实践
2026/9/8 3:50:15 网站建设 项目流程

前阵子我在重构自己的自动化工作流时,发现一个很有意思的现象:大模型越来越聪明,但真正把它接到业务系统里,中间那层"传话+干活"的胶水代码才是最费精力的。模型输出要解析、工具要编排、状态要维护、出错要重试,全堆在一起很快就变成一坨难以维护的意大利面。后来我把这一层单独抽出来,做成了一个轻量级的智能体运行时,取名 hermes-agent。这篇文章就围绕这个项目,讲讲它的定位、核心模块、落地实现,以及我在实践中踩过的一堆坑,希望对正在做 Agent 相关项目的朋友有参考价值。

1. 项目定位:Hermes-Agent 到底解决什么问题

1.1 为什么会做这样一个项目

在我最开始用大模型 API 做工具调用时,走了不少弯路。最早期的方式很简单:把用户的请求拼进 prompt,让大模型输出一段 JSON,再写代码解析这段 JSON,然后 if-else 判断要调用哪个函数。这种方式在小规模场景下勉强能用,但一旦工具数量超过五六个,问题就来了。

模型偶尔会乱写函数名、参数会漏传、JSON 偶尔带有注释文本,这些都需要手工处理。更难受的是,整个过程没有抽象,每接入一个新工具就得复制粘贴一大段解析逻辑。时间一长,我意识到自己需要的不是"更聪明的模型",而是一个稳定的代理层,专门负责三类事:

  • 接收用户的自然语言请求,拆解成可执行的任务。
  • 从工具列表中选择合适的函数,并生成正确的调用参数。
  • 把执行结果返回给模型,形成多轮推理闭环。

这就是 hermes-agent 的核心理念。它不是一个全新的 AI 框架,而是一个把"模型调用、工具执行、状态管理"串起来的最小运行时。你也可以把它理解成替代码和模型之间来回传话的通信兵,任务就一句话:让自然语言变成真实可执行的操作。

1.2 名字的由来与设计隐喻

Hermes 是神话里的信使神,负责在诸神与凡人之间传递讯息。我起这个名字,就是希望它扮演同样的角色:一端连接大模型,另一端连接具体工具和业务系统,自己尽量保持轻薄,不掺和多余的业务判断。

实际开发中,我也一直把"信使"这个定位当设计准则。举个例子,agent 本身不应该内置任何业务逻辑,例如"什么情况下应该查天气"这种判断最好交给模型去推理,agent 只负责发现模型想调用哪个工具、确保参数格式正确、然后真正把它执行掉。这个原则听起来简单,但落实起来很容易走偏。我见过不少人把 agent 写成了业务逻辑大杂烩,结果换个场景就得重写,这跟 Hermes 这个角色的初衷就是背道而驰的。

1.3 和主流 Agent 框架的定位差异

市面上其实已经有不少成熟的 Agent 框架,比如 LangChain、AutoGPT 等。那为什么还要自己造轮子?说实话,我的出发点不是"再造一个更好的框架",而是想保留对核心链路百分百的控制力。

LangChain 功能很全,抽象层也多,但出问题时的排查链路很长,尤其是版本升级时行为变更挺频繁的。AutoGPT 这类自动规划型 Agent 又太重,它会自己拆解目标、自己搜索信息,看起来炫酷,但在企业内部工具调用场景里反而不可控。我需要的是一个足够透明、依赖少、可随时打开源码改两行的代理层。hermes-agent 的代码量维持在一个很小的量级,核心只有几个文件,任何一位中级水平的开发者都能在半天内读懂全量实现。这种克制的设计,反而让它在实际业务落地时更稳。

2. 核心模块拆解:一个 Agent 真正需要的部件

很多人在设计 Agent 架构时,一上来就堆概念:记忆模块、规划模块、执行模块、反思模块、人机交互模块……但在我看来,一个真正可用的 Agent 运行时,基础部分只需要四块:意图解析、工具注册、上下文管理、可观测性。别的都是锦上添花。

2.1 意图解析与任务路由:让模型输出变成可执行指令

意图解析是 Agent 的大脑入口,它解决的第一个问题:用户说了一句话,模型到底想让我干什么?

在 hermes-agent 里,这一步不采用传统 NLP 的意图分类模型,而是直接依靠大模型的函数调用能力。具体来说,把所有可用的工具描述以 JSON Schema 的形式放进请求里,模型在生成回复时,会自动决定要不要调用某个工具,以及传入什么参数。这种方式的优势非常明显:新增一个工具时,我不需要训练任何模型,只需要在工具列表里多注册一个 schema 就够了。

在实际落地时,我建议把系统提示词里固定放一段"你可以调用以下工具来完成任务,如果无法确定,请向用户提问"之类的说明。这听起来像废话,但它能明显降低模型虚构工具名的概率。因为模型在生成函数调用时,如果工具列表为空,它会倾向于编造一个不存在的函数。给一个明确边界,反而能让它的输出更稳定。

任务路由则是在意图解析之后的一层轻量逻辑。比如多个工具都声明能处理"查询"类任务时,到底调哪个,我的选择是:全部交给模型去判断,不在代码里硬编码规则。换来的是更自然的意图理解,代价则是偶尔会出现工具选择错误。针对这一点,我会在第三部分的排错章节详细展开。

2.2 工具注册中心:怎么设计才能既灵活又不失控

工具注册是 Agent 项目的命门。我在第一版设计里用的是最笨的办法:维护一个巨大的 Python 字典,key 是工具名,value 是函数和它的描述。问题很快暴露出来:字典越写越长,参数文档容易过期,而且调用时缺少参数校验,错误会留到运行时才暴露。

后来我改成了装饰器注册模式,效果好了很多。每一个工具函数只要加上@tool装饰器,系统就会自动从函数的类型注解和 docstring 里提取信息,生成给模型看的 JSON Schema。代码结构大概是这样的:

@tool def get_weather(city: str, date: str = "today") -> dict: """查询指定城市在指定日期的天气情况。 Args: city: 城市名称,比如 北京、上海。 date: 日期,格式为 YYYY-MM-DD,默认是今天。 """ # 这里是实际的天气查询逻辑 return {"city": city, "date": date, "temperature": 24, "condition": "晴"}

装饰器内部会通过typing.get_type_hintsinspect.signature来提取参数类型,再把 docstring 里的描述整理成 OpenAI 风格的 JSON Schema。这样做最大的好处是:工具定义和工具实现放在同一个地方,不会出现"文档里写着三个参数、代码里实际上是四个参数"的错位问题。

我在实际使用中还会给@tool加上一个category参数,用来在日志和权限控制里区分不同工具组。比如category="weather"的工具只允许查询,category="admin"的工具则要求调用者权限校验通过。这个设计在后来的多租户场景里帮了大忙,也让工具列表在 Launcher 界面上更好分组展示。

2.3 上下文管理与记忆裁剪:一个最容易被低估的模块

开发初期,我认为上下文管理不就是把所有历史消息全塞给模型吗?直到线上出现第一个超 Token 报错,我才认真考虑这个问题。模型上下文窗口有限,而真实业务中用户和 agent 的对话往往会延续几十轮,每一轮里还夹杂大段工具返回结果,如果不做裁剪,几百个 Token 的消息很快就能涨到几万。

hermes-agent 的上下文管理分三层:原始消息层、摘要层、窗口层。原始消息会完整保留在本地数据库里,但真正发送给模型的内容,只保留最近 N 轮完整消息。如果历史太长,就把更早的消息交给一个轻量模型做摘要,把摘要文本放回内存中的消息队列。这样既保留了对话的关键脉络,又避免上下文撑爆。

这里有个细节值得说:工具返回结果特别容易膨胀。天气查询返回 100 个 Token,地图搜索可能返回 2000 个 Token,如果不做处理,多轮调用下来上下文立刻告急。我的办法是给每个工具的返回结果加一个truncate策略,默认只保留前 2000 字符,同时把关键信息(比如状态码、时间戳)放在返回值的固定字段,保证后续模型关心的重要数据不收影响。

2.4 可观测性与执行追踪:排障时最需要的东西

Agent 类应用的调试难度比普通后端服务高不少,问题往往不是简单的"这段代码挂了",而是"模型没有选择正确的工具"。面对这种不确定性,日志必须足够详细,否则很难判断问题出在模型理解、工具选择还是参数构造上。

我在 hermes-agent 集成了一个轻量级的 Tracker:每次和模型的交互都会记录以下信息:

  • 请求时间、模型名称、Token 消耗。
  • 发送给模型的完整消息列表(包括系统提示、历史消息、工具定义)。
  • 模型返回的原始内容(文本部分 + function calls 部分)。
  • 每个工具的执行时间、返回值摘要、报错信息。

这套追踪数据除了用来排查问题,还有一个意外收获:用来评估模型质量。我会定期把 Tracker 里的数据导出,统计工具调用成功率、平均轮数、平均 Token 消耗,看看换模型版本前后的差异。拿数据说话,比拍脑袋决定哪种模型更好用靠谱得多。

3. 实操:从零搭出 Hermes-Agent 最小可用版本

理论聊了一堆,这一节直接进入实操。我会展示一个最小可用版本的核心代码,这个版本能完成的任务是:用户问"明天北京适合出门吗?",agent 会调用天气工具,把结果整理成自然语言回复。整个过程不依赖重型框架,只使用 Python 标准库加一个 OpenAI SDK。

3.1 定义工具接口与注册机制

首先定义@tool装饰器。它要做的事情有三件:读取函数签名、生成 JSON Schema、把函数注册到全局工具表里。

import inspect import json from functools import wraps TOOL_REGISTRY = {} def tool(func): sig = inspect.signature(func) properties = {} required = [] for name, param in sig.parameters.items(): if name == "return": continue properties[name] = {"type": "string", "description": f"Parameter {name}"} if param.default is inspect.Parameter.empty: required.append(name) tool_schema = { "type": "function", "function": { "name": func.__name__, "description": func.__doc__ or "", "parameters": { "type": "object", "properties": properties, "required": required, }, }, } TOOL_REGISTRY[func.__name__] = func @wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) wrapper.tool_schema = tool_schema return wrapper

这个实现为了便于理解做了简化,真正的生产版本会多解析 docstring 里的 Args 描述,参数类型也会从 annotation 里推断为 JSON Schema 类型。但核心思想就是这个:一个函数 + 一个装饰器,自动变成模型可调用的工具。

3.2 接入大模型与结构化输出解析

主运行循环的逻辑并不复杂,核心是chat_completion请求里带上tools参数,然后判断返回结果里有没有tool_calls

import openai client = openai.OpenAI() def run_agent(user_input: str, max_steps: int = 5) -> str: messages = [{"role": "system", "content": "你是一个能调用工具的助手。"}, {"role": "user", "content": user_input}] tools = [func.tool_schema for func in TOOL_REGISTRY.values()] for _ in range(max_steps): resp = client.chat.completions.create( model="gpt-4o-mini", messages=messages, tools=tools, ) msg = resp.choices[0].message if not msg.tool_calls: return msg.content messages.append(msg) for tc in msg.tool_calls: args = json.loads(tc.function.arguments) result = TOOL_REGISTRY[tc.function.name](**args) messages.append({ "role": "tool", "tool_call_id": tc.id, "content": json.dumps(result, ensure_ascii=False), }) return "已达到最大迭代轮数,任务未完成。"

这个循环体现了 Agent 的核心模式:模型生成函数调用,代码执行,再把结果作为工具消息反馈给模型,模型继续推理。max_steps参数非常重要,它防止模型在某个工具链路上无限循环,我的经验是常规任务最多 5 步就够,多了大概率是陷入了逻辑死胡同,这时候直接退出并抛出警告更合理。

3.3 跑通"查天气+设提醒"的完整流程

现在把工具定义补上。除了天气查询,我再加一个创建提醒的工具,这样用户可以说"明天北京如果高于 25 度就提醒我涂防晒",这正好展示 agent 的多工具协作能力。

@tool def get_weather(city: str, date: str = "today") -> dict: """查询指定城市的天气。 Args: city: 城市名,如 北京。 date: 日期,默认今天。 """ # 实际项目里这里会接天气预报 API,这里为了演示直接返回模拟数据 data = { "北京": {"today": {"temperature": 26, "condition": "晴"}, "tomorrow": {"temperature": 28, "condition": "多云"}}, "上海": {"today": {"temperature": 22, "condition": "小雨"}, "tomorrow": {"temperature": 24, "condition": "阴"}}, } return data.get(city, {}).get(date, {"temperature": 0, "condition": "未知"}) @tool def create_reminder(text: str, remind_at: str) -> dict: """创建一条提醒。 Args: text: 提醒内容。 remind_at: 提醒时间,ISO 格式。 """ return {"status": "ok", "text": text, "remind_at": remind_at}

调用一下刚才的run_agent

if __name__ == "__main__": reply = run_agent("明天北京适合出门吗?如果温度高于25度,帮我设置一个明天早上8点的防晒提醒。") print(reply)

在我的测试环境里,模型的推理路径大致是这样的:先调用get_weather拿到北京明天的温度,发现是 28 度,触发create_reminder,最终输出一段自然语言回复,比如"明天北京多云,28 度,适合出门但要注意防晒。我已经帮你设置了明早 8 点的防晒提醒。"整个过程中,我作为开发者的代码只关心"调度",不关心"理解细节",这就是 agent 化带来的最直观好处。

4. 上线前必须处理的坑:常见问题与排查实录

这一节的内容基本都来自我实际踩过的坑。很多问题不跑到真实业务压力下根本碰不到,但碰到了就足以让人加班到深夜。我按出现频率从高到低列出来,方便你直接对照排查。

4.1 上下文超限与“记忆断层”问题

现象:长对话进行到十几轮时,突然开始答非所问,或者直接报错说超出上下文限制。

原因通常是两组问题叠加:一是消息列表没有裁剪,二是工具返回结果太大。我在前面讲过上下文管理,这里补充一个更具体的处理技巧:不要只按条数裁剪消息,还要按 Token 数估算。OpenAI 提供tiktoken库,可以在发送前计算当前消息列表的 Token 数量,超限时从最早的 system 消息之外的 user 消息开始淘汰,老的工具结果优先淘汰,保留最近两轮的完整轮次,再把更早的内容用摘要替代。

消息裁剪优先级(从先删除到后删除): 1. 过期的 tool 消息(尤其是高 Token 工具结果) 2. 最早的 assistant 消息 3. 最早的 user 消息 4. system 消息永远保留

实测这个策略能把单会话的可对话轮数从十几轮拉到几十轮,而且对回复质量的影响不大。如果业务场景是大量多轮工具调用,建议第一步就先做这个优化。

4.2 模型幻觉工具名与错误调用

现象:模型返回了一个工具名,但在TOOL_REGISTRY里找不到,报KeyError

我最初以为是极低概率事件,直到线上日志里连续出现get_weatherrsearch_city这种不存在的名字才引起警觉。这是因为模型对函数名的记忆并不是 100% 准确的,尤其在工具列表很大的时候。解决思路有三个层面:

  • 在系统提示里强调"只可以使用给定的工具,不要编造工具名"。
  • 在解析tool_calls时做一层模糊匹配兜底,比如用字符串相似度库difflib把最接近的工具名提示出来。
  • 最重要的是:发现非法工具名时,把错误明文返回给模型,让它自己修正。这其实利用了模型的自我纠错能力。
try: func = TOOL_REGISTRY[tc.function.name] except KeyError: messages.append({ "role": "tool", "tool_call_id": tc.id, "content": json.dumps({"error": f"工具 {tc.function.name} 不存在,请从给定工具列表中选择。"}), }) continue

这样处理之后,模型通常下一轮就会纠正自己,而不是直接中断整个会话。

4.3 并发请求与状态隔离

现象:多个用户同时使用同一个 agent 实例时,A 用户的工具调用结果被 B 用户看到了。

这个坑几乎把我坑惨了。原因是我最初把messages列表设计成了模块级全局变量。单用户测试时一切正常,但一旦并发上来,请求 A 的临时消息会被请求 B 覆盖,Agent 以为还在跟同一个用户对话,实际已经窜频道了。

解决方案:把整个run_agent的对话状态封装进一个 Session 对象里,每个用户创建独立的 Session:

class AgentSession: def __init__(self, session_id: str, user_id: str): self.session_id = session_id self.messages = [] self.created_at = time.time() self.lock = asyncio.Lock()

如果有状态服务,比如 Redis,还可以把messages序列化后存进去,实现多机横向扩容。这一步做完,并发问题才真正解决。你如果在做 Web 后端接入 Agent,一定要在一开始就考虑状态隔离,别像我一样等生产事故了才回头补。

4.4 排查工具箱:日志追踪与重放

最后分享一个排障神技:把 Tracker 记录下来的请求数据保存成 JSONL 文件,出问题时直接重放给另一个模型版本,对比两个模型的输出。这个思路能帮你快速判断"是模型问题还是代码问题"。

我通常会记录三个字段:request_idmodelraw_requestraw_response。排查时写一个小脚本遍历 JSONL,重新发一次同样的请求给不同模型,对比工具调用链路的差异。实测下来,这个重放机制比任何日志分析工具都直观,尤其是在做模型迁移时能省下大量回归测试时间。

后续还可以这么扩展

写到这里,hermes-agent 这个项目最核心的思路和实现都已经拆开讲完了。如果想继续往下做,我建议优先考虑三件事:引入多 Agent 协作机制,让不同角色的 agent 分别处理不同的任务域;加上更细粒度的权限控制,让敏感工具必须经过人工审批;最后是做一个可视化的任务链路回放界面,把每一步模型推理和工具调用都展示给用户。我个人实际做下来的体感是:Agent 项目的复杂度并不在算法上,而在工程细节的控制上,把消息循环、工具注册、上下文裁剪这些基础打扎实,比追新概念有用得多。下次有人问起 Agent 项目怎么做,我还是会先说一句,先把它变成一个可靠的"信使"再说别的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询