资讯详情

资讯详情

轻量级AI Agent实战:从零构建hermes-agent内核

做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_factorylist) 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(ftool {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_asciiFalse) }) 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( fhttps://user.internal.example.com/api/v1/users/{user_id}, timeout5 ) resp.raise_for_status() return resp.json()注册时description写清楚“根据用户ID获取用户资料”并把参数说明补上。模型如果不知道user_id从哪里来会在对话中主动追问用户。数据库工具也是一样只是把HTTP请求换成SQL执行。必须强调一点数据库直连权限一定要严格控制。在hermes-agent里我把所有写操作默认禁止只有白名单内的函数才允许非SELECT语句执行。4.2 案例自动生成运维日报这个案例是团队实际每天都在用的。需求很简单每天早上生成一份运维日报内容包括昨日线上告警数量、核心服务可用性、异常日志条数和简要趋势。用hermes-agent实现后整个流程只靠两个工具查询告警平台API、查询日志系统API。用户在对话里说一句“生成昨天的运维日报”agent会自行决定先查告警还是先查日志然后汇总成一段带标题和数据的文字。我第一次跑通过程中遇到一个有意思的问题模型查询日志时把时间范围写错了。它生成start_time2025-06-09 00:00:00end_time2025-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我不会增加更多功能只会把现在这些模块打磨得更稳。做信使跑得快与传得准永远是第一优先级。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →