轻量级AI Agent实战:从零构建hermes-agent内核
2026/9/8 13:56:06 网站建设 项目流程

做AI agent这件事,我是从一次很实际的痛感开始的。公司内部有大量工具:监控告警平台、日志检索系统、统一认证中心、日常报表服务,每个都是独立入口,干一次活要在五六个页面之间来回切换。我想做一个类似“智能信使”的中间层,让用户用自然语言下达需求,它负责把需求翻译成对各类工具的操作,再把结果整理好拿回来。于是就有了hermes-agent。Hermes是希腊神话里的信使之神,负责在众神之间传递消息和执行指令,这正好对应agent在技术体系里的位置:不替代业务系统,只负责理解意图、调度能力、回传结果。今天这篇就聊聊这个项目的设计思路、核心实现,以及落地过程中踩到的那些坑。

项目适合两类人看:一是准备在团队内部搭建私有agent、但不想被重型框架绑死的开发者,二是想从零理解agent内部机制的学习者。我尽量不堆术语,把“为什么这么做”讲清楚,代码部分也给最小可运行的实现。

1. 项目定位与设计起点

1.1 为什么没直接用现成框架

动手之前我花了两周时间调研市面上的agent框架。坦白说,现有方案已经很强了,但在我们的场景里始终有几个别扭的地方。

第一是抽象层级太厚。很多框架把“agent”包装成了接近低代码平台的形态,光概念就有Chain、Graph、Memory、Callback、ToolSpec等一大堆。对一个只需要“调用工具—拿到结果—回复用户”的轻量场景来说,学习成本比收益还高。

第二是模型绑定问题。不少框架对模型厂商做了深度集成,换模型、换base_url、调整temperature这类基础操作反而要翻文档找参数。我想要一个模型无关的层,今天可以接商用模型,明天也可以换开源模型部署的服务。

第三是调试体验。框架封装越深,越难看清“模型到底看到了什么、为什么调用这个工具、在哪一步断掉”。排障时经常要在底层日志里翻半天。

所以hermes-agent最初的定位定得很死:只做一个轻量级的agent运行内核,核心循环是“接收任务—规划—调用工具—整理结果”,其它能力通过插件和扩展点解决,不强加抽象。这个定位后来帮我省了大量时间,因为需求一变,改一个几百行的内核比改一个几千行的框架容易太多了。

1.2 核心能力和边界

hermes-agent能做的事情集中在三块:任务规划、工具调度、上下文管理。

任务规划指模型将用户目标拆成可执行步骤,不需要人工硬编码流程;工具调度指通过统一的函数调用协议,把外部能力注册成agent可调用的工具;上下文管理指在多轮交互中控制token使用,避免对话一长模型就“失忆”。

同时我也刻意划了边界。它不做GUI、不做项目管理、不内置知识库,也不强依赖某个向量数据库。这些能力全部沿用团队已有的基础设施,agent只通过工具去访问。换句话说,hermes-agent不碰业务数据,它只负责“信使”的角色。这个边界非常重要,一旦agent框架开始绑定业务存储和UI,最后一定会变成一个大而全的怪兽,维护成本直线上升。

2. 整体架构与关键技术决策

2.1 四个核心模块

整个项目拆成四个模块:模型网关、任务循环、工具注册中心、上下文管理器。

模型网关负责屏蔽不同模型的接口差异。对外只暴露一个chat(messages, tools)方法,内部根据配置把请求转换成OpenAI兼容格式或其他厂商格式。之所以选择兼容OpenAI协议为“默认方言”,是因为目前绝大多数开源模型和代理服务都支持这个协议,接入成本最低。

任务循环是agent的中枢,负责驱动模型和工具之间的交互。它的逻辑其实很简单:把用户消息和工具定义发给模型,模型决定是直接回复还是调用某个工具,如果调用就把结果追加进对话,再发给模型继续判断,直到模型给出最终回复。所有复杂行为都是在这个循环上长出来的。

工具注册中心维护了一个Python函数与JSON Schema的映射表。每注册一个工具,系统自动生成模型可读的函数定义,并负责在模型请求调用时做参数校验和实际执行。这个模块是agent的能力边界,也是安全控制的关键位置。

上下文管理器负责控制历史消息的取舍。它按照时间、重要性和系统prompt三个维度做压缩,避免多轮对话后上下文超长。

2.2 消息与工具协议设计

工具协议是agent设计的核心。模型本身不具备调用函数的能力,它只会“请求”调用一个函数,并给出一组参数。所以协议是否稳定,直接决定了agent的上限。

我在hermes-agent里沿用了JSON Schema来描述工具。每个工具声明包含三块:名称、描述、参数结构。其中描述字段比参数结构更影响效果,因为模型主要通过描述来判断“什么时候该用这个工具、传什么参数”。

一个典型的工具定义长这样:

{ "name": "query_database", "description": "执行只读SQL查询并返回结果集,用于获取数据报表和业务指标。仅允许SELECT查询。", "parameters": { "type": "object", "properties": { "sql": { "type": "string", "description": "完整的SQL语句,必须以SELECT开头" } }, "required": ["sql"] } }

这里有两个容易被忽略的细节。第一个是描述里明确写了“仅允许SELECT查询”,这是安全约束,直接表达给模型,比在代码里做二次拦截更自然,模型会在规划阶段就绕开危险操作。第二个是参数描述不能太泛,必须写清楚格式要求,比如“必须以SELECT开头”,否则模型可能生成很随意的参数。

2.3 为什么任务循环用“步骤”而不是“图”

很多框架喜欢把agent流程设计成DAG图形编排,节点间有清晰的上下游关系。这种做法适合流程相对固定的场景,但在处理开放任务时有个问题:模型下一步做什么在运行时才能确定,强行建模成图会限制灵活性。

hermes-agent选择让任务循环保持“步骤化”。理论上这个循环可以无限执行,直到满足终止条件。我设了三个终止条件:模型返回直接回复、步骤数超过上限、工具调用报出致命错误。这种做法更接近人类解决问题的直觉:做完一步检查一步,不对就调整。

当然,“步骤化”也有代价。如果任务可以提前规划,比如“先查数据库,再调接口,最后生成文档”,那前面的规划结果没有缓存,每一步都要靠模型重新判断。为了缓解这个问题,我在任务循环里加了一个轻量的计划缓存:当检测到用户请求是“连续型任务”时,先把模型输出的计划步骤存下来,后续步骤优先参考计划而不是重新推理。这就是在灵活性和效率之间做的平衡。

3. 核心实现与实操过程

3.1 最小可运行的程序骨架

下面这段代码是hermes-agent的最小实现,去掉一切装饰,只保留任务循环的核心逻辑。为了方便演示,我用一个假模型返回预设结果,实际接入时替换成真实LLM调用即可。

import json from dataclasses import dataclass, field from typing import Callable, Any @dataclass class AgentContext: messages: list = field(default_factory=list) step_count: int = 0 max_steps: int = 10 class ToolRegistry: def __init__(self): self._tools = {} def register(self, name: str, schema: dict, func: Callable): self._tools[name] = {"schema": schema, "func": func} def schema_list(self) -> list: return [t["schema"] for t in self._tools.values()] def execute(self, name: str, arguments: dict) -> Any: tool = self._tools.get(name) if not tool: raise ValueError(f"tool {name} not found") return tool["func"](**arguments) class HermesAgent: def __init__(self, tools: ToolRegistry): self.tools = tools self.context = AgentContext() def chat(self, user_message: str) -> str: self.context.messages.append({"role": "user", "content": user_message}) while self.context.step_count < self.context.max_steps: self.context.step_count += 1 response = self._llm_call(self.context.messages, self.tools.schema_list()) if response["type"] == "final": return response["content"] elif response["type"] == "tool_call": result = self.tools.execute(response["tool_name"], response["arguments"]) self.context.messages.append({ "role": "assistant", "content": "", "tool_calls": [{ "id": response.get("call_id", "call_1"), "type": "function", "function": { "name": response["tool_name"], "arguments": json.dumps(response["arguments"]) } }] }) self.context.messages.append({ "role": "tool", "tool_call_id": response.get("call_id", "call_1"), "content": json.dumps(result, ensure_ascii=False) }) else: return "无法理解模型输出" return "步骤数超限,终止执行" def _llm_call(self, messages, tools): # 这里替换为真实模型调用 # 模拟:如果用户消息包含“查天气”,则调用工具 last_user = messages[-1]["content"] if "查天气" in last_user and "tool_done" not in last_user: return { "type": "tool_call", "tool_name": "get_weather", "arguments": {"city": "北京"} } return {"type": "final", "content": "查询完成,结果已返回"}

实际项目中模块会比这个复杂,但核心循环就这一百行左右。只要理解了这段代码,后面的所有功能都是在“模型返回-工具执行-结果回填”这个环上加东西。

3.2 工具注册与参数校验

工具注册中心看起来简单,实际要注意的点很多。我在第一版时直接执行模型返回的函数,结果经常出现参数类型错误,比如模型传了一个字符串"2024-06-01"但函数需要date对象。后来我引入了一个中间层:模型返回参数后,先通过JSON Schema做类型校验和转换,再执行真正的函数。

这一步非常值得做。模型对参数类型的掌控并不稳定,尤其日期、嵌套对象、空数组这些复杂结构,十次里可能错三四次。如果没有强校验,错误会在函数执行到一半时暴露,不仅浪费一次调用,还会让整个任务循环进入不可恢复的中间状态。

校验层我直接用了Python的jsonschema库,简单可靠。额外加了一个钩子:校验失败时,把错误信息原样返回给模型,让它修正参数后重新发起调用。这比直接中断任务人性化很多。比如模型传了sql: "select * from foo limit 10",但我们的协议要求大写SELECT,校验层会返回“SQL必须以SELECT开头”,模型看到之后马上会修正并重试。

3.3 流式输出与超时重试

生产环境里,模型响应动辄几十秒,用户如果看不到中间状态会以为挂了。所以我在hermes-agent里把“工具调用的实时状态”和“最终回复”都做成了流式输出。

工具调用的流式状态通过事件回调实现。每执行一步,就向回调函数推送一条事件,比如{"step": 1, "tool": "query_database", "status": "running"}。前端或IM机器人收到事件后,可以显示“正在查询数据库…”这类提示。

超时重试则分两层:网络请求层和任务循环层。网络请求层用tenacity库做指数退避重试,只针对网络错误;任务循环层的重试要小心,因为整个循环会积累上下文,盲目重试可能让模型看到重复错误信息而陷入混乱。我的做法是:同一工具调用失败超过两次后,不再自动重试,而是把错误信息完整抛给模型,让它决定是换个思路还是直接回复失败原因。实测下来,这种“让模型兜底”的方式比硬重试效果好很多。

4. 接入真实工具链的完整案例

4.1 对接HTTP API和数据库

工具注册本身不区分数据来源,HTTP接口、数据库、本地脚本都可以统一封装成函数。关键在封装时保持函数的“原子性”:一个工具只做一件清晰的事,不要让模型去猜。

比如对接HTTP API时,我通常写成这样:

import requests def call_profile_api(user_id: str) -> dict: """根据用户ID获取用户资料,来自内部用户中心。""" resp = requests.get( f"https://user.internal.example.com/api/v1/users/{user_id}", timeout=5 ) resp.raise_for_status() return resp.json()

注册时description写清楚“根据用户ID获取用户资料”,并把参数说明补上。模型如果不知道user_id从哪里来,会在对话中主动追问用户。

数据库工具也是一样,只是把HTTP请求换成SQL执行。必须强调一点:数据库直连权限一定要严格控制。在hermes-agent里我把所有写操作默认禁止,只有白名单内的函数才允许非SELECT语句执行。

4.2 案例:自动生成运维日报

这个案例是团队实际每天都在用的。需求很简单:每天早上生成一份运维日报,内容包括昨日线上告警数量、核心服务可用性、异常日志条数和简要趋势。

用hermes-agent实现后,整个流程只靠两个工具:查询告警平台API、查询日志系统API。用户在对话里说一句“生成昨天的运维日报”,agent会自行决定先查告警还是先查日志,然后汇总成一段带标题和数据的文字。

我第一次跑通过程中遇到一个有意思的问题:模型查询日志时把时间范围写错了。它生成start_time="2025-06-09 00:00:00"end_time="2025-06-09 23:59:59",但用户说的是“昨天”,而当天其实是6月11日。后来我在工具参数的description里明确写了“时间是相对于当前日期的自然语言日期,请先换算成具体时间戳”,模型就基本不再犯这个错。

这就是agent工程里反复出现的规律:模型出错,很多时候不是模型不行,而是你没把“工具的使用说明书”写清楚。

4.3 多agent协作的简单实现

在日报之外,我们还做了一个告警分析场景。这个场景要把“查告警、查日志、查变更记录”三个动作串起来,单个agent做容易上下文混乱。我的方案是拆成三个子agent,由一个主控agent统一调度。

主控agent不直接调用工具,而是把任务发给子agent,等子agent返回结果再决定下一步。每个子agent的角色prompt和工具集都不一样,相当于一个专家团队。代码上实现得很朴素,就是在工具注册中心里,把“调用子agent”也注册成一个普通工具。

子agent之间不直接通信,所有信息都通过主控agent中转。这避免了复杂的消息路由设计,代价是主控agent的上下文会比较长。实测下来,三个子agent协作、每个会话控制在5分钟以内的任务,效果是完全可以接受的。如果子agent数量更多,就需要引入独立的消息队列,那就是另一个量级的工程了。

5. 生产化落地经验

5.1 token消耗与上下文管理

agent项目跑起来之后,最大的成本往往不在模型本身,而在token浪费。最典型的问题是多轮对话中,历史消息越积越多,每一轮调用都要重新把所有历史发给模型,成本线性增长。

我用了三层上下文控制。第一层是长度截断:超出窗口后,按时间从旧到新丢弃。第二层是摘要压缩:调用一个轻量模型,把太旧的对话浓缩成要点。第三层是工具结果瘦身:像SQL查询这种工具,返回结果往往有几百行,我会在工具内部做截断,只保留前50行和行数统计。

这三层叠加之后,token消耗大约下降了一半。特别是工具结果瘦身,收益最明显。很多agent框架没有做这一步,导致模型频繁“被淹没”在大量无关数据里,反而降低了回答质量。

5.2 并发、限流与安全

接入IM机器人后,自然会遇到多个用户同时提问的情况。默认情况下,每个会话在内存里独占一个任务循环,这没问题。但要注意的是,一个agent循环可能会调用多次模型接口,如果同时有20个用户发起请求,模型API的QPS可能瞬间打满。

我的方案是给每个会话加信号量,限制并发数为2,其余请求排队。同时给整个agent进程加了一个全局的每分钟调用上限,超过上限直接返回“系统繁忙”。这些限流策略一开始就可以加上,后面再补会比较麻烦。

安全方面有几条红线:数据库工具只读、外部HTTP请求只能访问白名单域名、模型生成的代码绝不直接执行、所有工具执行前都要过权限校验。这四条我写进了代码注释里,也写进了每轮对话的系统prompt里,让模型自己能意识到边界。

5.3 模型选型建议

hermes-agent在设计上是模型无关的,但实际运行效果和模型能力强相关。根据我的实测经验,如果只做单轮工具调用,7B级别的开源模型勉强能跑;一旦涉及多步规划,推理能力不够的模型会频繁“自说自话”,编造工具参数。

团队目前生产环境用的是商用模型,延迟和效果比较稳定。个人测试或内部demo则用开源模型配合vLLM部署,效果也可以接受。选型时我建议大家用同样的任务集评价模型,而不是单独看benchmark分数。agent场景最看重的不是知识量,而是指令跟随、格式遵循和错误恢复能力。

6. 常见问题与排查思路

6.1 问题速查表

现象可能原因解决方向
agent反复调用同一个工具工具返回信息没有改变模型的判断依据检查工具返回内容是否结构化,是否包含了模型需要的关键信息
模型编造工具参数工具描述不清晰,或模型本身推理能力弱强化参数描述,增加校验层,必要时升级模型
对话一长就“失忆”上下文被截断,缺少摘要或记忆机制增加摘要压缩,把关键信息写入显式记忆区
工具调用成功后没有继续执行循环终止条件过于激进,或模型把工具结果当作最终回复检查任务循环的终止逻辑,确认“工具调用后回填”是否成功
模型频繁输出JSON格式错误提示词中格式要求不够具体在提示词中提供few-shot示例,或调整模型温度
接口偶发超时导致任务失败缺少重试和超时处理网络层加重试,超时时间要按最慢工具单独配置

6.2 排查技巧

第一招是开详细日志。我习惯把每一轮发送给模型的messages、tools、模型返回内容都落盘,文件名带上会话ID。排障时直接翻日志,能清楚看到模型是在哪一步开始跑偏。

第二招是画“会话回放”。把上述日志稍作格式化,导出成一个HTML页面,可以像看聊天记录一样查看agent的每一次思考和行动。做这个工具花了一天时间,但对排查复杂问题帮助巨大。

第三招是让agent说“我不知道”。我在系统prompt里明确写了:如果无法确定用户的意图,或者工具返回结果与问题无关,直接回答“无法完成”,不要硬编结果。这一条极大减少了误报和幻觉。

7. 一点个人体会

回头看hermes-agent这个项目,最值钱的经验不是某个算法或某个框架技巧,而是对agent边界的理解。agent类应用天然是“易学难精”,易在把模型接口拼起来并不难,难在让它在真实环境里稳定、可控、低成本地跑下去。

我的建议是,无论项目大小,都先把“不做什么”想清楚。让agent聚焦在调度和信使角色,把工具做强、做稳、做好描述,效果远比给agent堆一堆模糊能力要好。如果让我重写一遍hermes-agent,我不会增加更多功能,只会把现在这些模块打磨得更稳。做信使,跑得快与传得准,永远是第一优先级。

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

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

立即咨询