ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

hermes-agent:轻量级智能体框架的架构设计与任务循环实践

hermes-agent:轻量级智能体框架的架构设计与任务循环实践 hermes-agent 这个项目最初只是我在本地机器上写的一个任务调度脚本后来一步步长成了现在这个能接模型、调工具、留记忆的轻量智能体框架。它解决的问题很朴素大模型可以写出非常像样的回答但让它真去读文件、查数据库、调接口、把结果写回某个系统绝大多数模型做不到——模型本身不会“动手”它只负责做决定。hermes-agent 就是替它动手的那层中间件。如果你正在做个人自动化助手、团队内部 Bot或者单纯想搞懂 Agent 底层到底是怎么转起来的这篇内容就是一次完整的产品与技术复盘。我会从架构设计、工具注册、任务主循环、记忆管理一路讲到实测踩坑尽量把每个“为什么这么做”都说清楚而不是只丢给你一堆概念。1. 为什么我会自己写一个 hermes-agent而不是直接套编排框架1.1 它到底解决什么问题现在市面上的 Agent 框架非常多随便一搜就是一堆。但我在实际使用中最大的感受是框架给出的抽象层太多出了问题之后很难判断到底是我自己的逻辑错了还是框架某个封装环节吞了错误信息。hermes-agent 的初衷很简单用最少的概念把“模型决策”和“工具执行”这两个环节焊死在一起。名字里的 Hermes来自希腊神话里的信使负责传话和引路。给项目取这个名字是想强调它在模型和执行端之间传递信息、把用户意图翻译成具体动作的核心角色。这个定位也决定了代码结构所有模块都在为一次可靠的传话服务。它适合处理的任务大概是这样的用户说“把 /tmp/report.md 里的金额汇总生成 CSV 存到 /tmp/result.csv”Agent 要能判断这涉及读文件、解析文本、做汇总、写文件四步Agent 要能一步步调用对应工具而不是只给用户一段“你应该这样做”的建议。这类任务现在很常见。但如果你试过就知道直接把整个任务说明和一个大 prompt 丢给模型期望它自己写代码跑完结果往往不可控。hermes-agent 做的事情就是把不可控的部分掐死在工具调用这一层。1.2 设计边界哪些功能刻意不做我见过太多项目一上来就追求“全知全能”结果把自己拖垮。hermes-agent 在初始设计时列了一个明确的不做清单第一不做模型训练和微调。模型能力不够就换模型或者通过提示词约束绝不在这个框架里引入训练流程。第二不做知识库。知识库是另一个复杂系统跟 Agent 耦合在一起只会让问题更难排查。hermes-agent 只提供长期记忆的键值存储和任务记录不负责文档切块、向量检索、重排序这些东西。第三不做复杂 UI。管理界面有一块简单的 Web 页面就够核心交互仍然走命令行和 API。因为 Agent 这个东西最重要的是逻辑可复现而不是界面好看。这样划定边界之后整个项目的复杂度立刻降下来后续每一次新增功能都能清楚知道该放在哪一层。1.3 技术选型依赖越少越容易查问题hermes-agent 的技术栈很克制Python 3.11类型标注用起来舒服跑批任务也够稳pydantic 负责工具参数校验和配置解析SQLite 存任务记录、工具调用日志和长期记忆模型接入采用 OpenAI 兼容接口协议这样可以同时接本地模型服务和各类云端模型服务。我刻意没有引入重量级编排框架。不是说那些框架不好而是它们的抽象层会掩盖大量细节。比如工具调用失败时有些框架会自动重试有些会改写 prompt 再试一次你很难从日志里看出真实链路。自己写这套东西之后每一个环节都是我自己的代码出错时看堆栈就能定位不用再猜框架内部做了什么。2. 整体架构一条可审计的任务流水线而不是一个聊天循环2.1 核心模块划分hermes-agent 的代码结构分成五个核心模块各管一件事input_parser处理用户输入提取任务目标和约束条件判断是否需要多步规划decision_engine负责和模型交互把当前对话状态转化成模型请求接收模型返回的工具调用指令tool_layer所有外部能力的统一入口包括文件读写、Shell 命令、HTTP 请求、数据库查询等memory_store管理短期上下文和长期记忆保证任务执行过程中信息不丢executor真正驱动工具执行的地方负责参数校验、异常捕获、结果回写。这五个模块之间没有循环依赖数据流是单向的输入进入决策引擎决策引擎产出工具调用指令executor 执行工具并返回值返回值再回到决策引擎继续下一步判断。2.2 流水线里的数据流一条任务在 hermes-agent 里是这么走的用户输入进入 input_parser被解析成标准化的任务对象决策引擎把任务对象和当前上下文组装成 messages请求模型模型返回结果。如果结果里包含 tool_calls就进入工具层工具层从注册表里找到对应函数校验参数执行执行结果被包装成一条 tool 消息放回 messages回到第 2 步继续请求模型直到模型返回最终文本结果或者达到迭代上限。这个流程看起来像循环但本质上不是无脑聊天循环而是一个带终止条件的任务流水线。每一步都有日志、有状态、可以中断也可以人工介入。2.3 为什么没有把逻辑全塞进一个 chat 循环早期版本我确实只写了一个 while True把用户消息往模型里塞模型返回什么就拼什么。这种方式在 demo 阶段跑得很开心一旦接上真实工具就出问题模型可能反复调用同一个工具可能在前一轮工具报错后依然假装执行成功可能上下文越积越长最后把模型自己都绕晕。后来我把问题归类清楚这不是模型的问题而是缺少流程控制。所以 hermes-agent 把每一次模型调用都视为一个“决策节点”每个决策节点都有明确的输入、输出、校验和终止条件。这样即便模型抽风我也能在日志里清楚看到它是在哪一步抽的为什么抽然后针对性修。3. 工具注册与参数校验让模型按剧本来调用3.1 用装饰器把 Python 函数暴露成工具在 hermes-agent 里把普通函数变成模型可调用的工具只需要加一个装饰器。这个设计目标很直接工具维护者不需要理解任何 Agent 底层协议只需要写一个带类型标注的普通 Python 函数。# tools/registry.py from __future__ import annotations import inspect from typing import Any, Callable, get_type_hints _TOOL_REGISTRY: dict[str, dict[str, Any]] {} def tool(name: str | None None, description: str ): def decorator(func: Callable) - Callable: tool_name name or func.__name__ schema build_json_schema(func, description) _TOOL_REGISTRY[tool_name] { function: func, schema: schema, } return func return decorator def build_json_schema(func: Callable, description: str) - dict[str, Any]: hints get_type_hints(func) sig inspect.signature(func) properties {} required [] for param_name, param in sig.parameters.items(): if param_name in (self, return): continue typ hints.get(param_name, str) if typ is str: properties[param_name] {type: string} elif typ is int: properties[param_name] {type: integer} elif typ is float: properties[param_name] {type: number} elif typ is bool: properties[param_name] {type: boolean} else: properties[param_name] {type: string} if param.default is inspect.Parameter.empty: required.append(param_name) return { type: object, properties: properties, required: required, description: description, }这段代码的核心价值在于函数签名本身就是工具的契约。类型标注决定了 JSON Schema 里每个字段的 type必选参数自动进入 required 列表。模型拿到的不是一段含糊的工具说明而是一份机器可读的参数规范。3.2 JSON Schema 是模型和函数之间的翻译层很多人在接入工具调用时容易忽略 JSON Schema 的重要性觉得“模型能理解自然语言描述就够了”。实际用下来这个想法会让成功率掉得很厉害。举个例子如果工具函数长这样tool(description下载指定 URL 的内容) def fetch_url(url: str, timeout: int 30) - str: ...因为 timeout 没有写清楚单位模型完全可能传timeoutthirty或者timeout30.0。如果 schema 里明确要求type: integer模型在推理时就会尽量往整数上靠。虽然它偶尔还是会犯错但至少大多数情况下能传对。更重要的一个设计是工具执行层在真正调用函数之前必须先用 pydantic 做一次严格校验。校验失败就直接把错误信息返回给模型让模型重新调整参数而不是让 Python 抛出一个堆栈错误给用户看。3.3 参数描述怎么写才能减少乱传参这是我在反复测试里总结出来的经验工具描述和参数描述一定要写“边界条件”而不是只写“功能说明”。比如一个读文件工具如果描述只写“读取文件”模型不知道它能不能读二进制文件、不知道路径是本地路径还是远程 URL、不知道编码默认是什么。更合适的写法是工具描述里写清楚适用场景读取 UTF-8 编码的本地文本文件不支持二进制文件和远程 URL参数描述里写清楚取值约束path 必须是本地绝对路径例如 /data/input.txt有默认值的参数说明默认行为和单位。这样看起来只是在写多几句话但对模型的工具选择准确率影响非常大。模型本身不做真正的文件系统探测它只能根据描述判断该用哪个工具、传什么参数。描述越精确判断越准确。4. 任务主循环一条指令从进来到执行完的完整链路4.1 主循环代码骨架hermes-agent 的主循环是所有任务执行的发动机。它不复杂但每个字段和分支都有明确用途。# agent/loop.py from typing import Any def run(task: str, max_iterations: int 8) - str: messages [{role: user, content: task}] for _ in range(max_iterations): resp llm.chat( messagesmessages, toolslist_tool_schemas(), ) msg resp.choices[0].message if not msg.tool_calls: return msg.content messages.append(msg) for call in msg.tool_calls: result execute_tool(call.function.name, call.function.arguments) messages.append({ role: tool, tool_call_id: call.id, content: str(result), }) return 达到最大迭代次数任务未完成这里有几个容易被忽略的细节第一messages.append(msg)不能省。模型返回的 tool_calls 必须原样放回对话里否则后面跟上 tool 消息时模型不知道这些工具结果对应的是哪一次调用。第二工具结果必须带上tool_call_id。这是把工具返回值和模型发起的调用请求关联起来的关键。很多模型服务要求这个字段严格匹配漏了就会报错。第三迭代上限必须存在。即便是最简单任务也不能允许模型无限循环下去。实际使用中大多数任务 3 到 5 次工具调用就能完成超过 8 次基本说明模型已经绕晕了硬跑下去只会浪费时间和 tokens。4.2 失败与重试策略工具调用不是每次都成功。文件可能不存在接口可能超时数据库可能连不上。hermes-agent 的策略是让工具自己抛出明确的异常由 executor 捕获之后转成一段结构化错误信息再返回给模型。def execute_tool(name: str, arguments_json: str) - Any: entry _TOOL_REGISTRY.get(name) if not entry: return f错误工具 {name} 不存在 try: args entry[schema].validate_json(arguments_json) return entry[function](**args) except Exception as exc: return f错误工具执行失败原因{exc}关键点是不要捕获所有异常后默默吞掉而是要把错误原因原原本本返回给模型。模型会根据这个错误判断是自己参数写错了还是需要换一个工具还是直接告诉用户任务无法完成。这个闭环才像真正的 Agent 行为。4.3 实际任务轨迹示例我用一个实际跑过的任务来说明完整链路。用户输入是读取 /data/sales.txt统计总销售额把结果追加到 /data/summary.loghermes-agent 的实际执行轨迹大致是模型第一次决策调用read_file(path/data/sales.txt)工具返回文件内容模型看到里面有 12 行销售记录模型第二次决策调用summarize_sales(content...)或者直接通过计算工具完成汇总工具返回汇总结果模型第三次决策调用append_to_file(path/data/summary.log, content总销售额: 12345)工具返回写入成功模型没有新的工具调用返回最终回答“已完成”。这个轨迹看起来顺理成章但如果没有主循环把这些步骤串联起来模型是没法独立完成的。因为它每一步都只看到一个局部信息只有循环才能让局部决策累积成完整任务。5. 记忆管理短期上下文与长期状态分开存5.1 上下文膨胀问题Agent 项目做到后面几乎都会撞上上下文膨胀。原因很简单每调用一次工具就要把工具结果塞回 messages 里。如果工具返回的是大文件内容几次调用之后上下文就可能从几千 token 涨到几万甚至十几万 token。一开始我偷懒直接把所有工具结果都保留。跑了几次之后发现两个问题一是模型响应越来越慢二是它会在几十步之后忘掉最开始的任务目标开始对着中间结果自由发挥。5.2 短期记忆裁剪、摘要、关键信息保留hermes-agent 的短期记忆策略分三层完整保留最近 6 轮对话消息更早的消息用摘要替换摘要里保留任务目标、已完成的工具调用、当前关键结论每轮工具结果在塞回上下文之前先做一个长度检查超过阈值就截断并标注“内容过长已截断”。这个策略的实现不复杂但效果非常直接。模型不会被大量中间数据淹没又能通过摘要掌握任务全局。5.3 长期记忆用 SQLite 记录任务与工具调用长期记忆这块我没有做成“向量库 相似度检索”那种形态而是用 SQLite 落盘了每一次任务和工具调用。原因很简单大部分 Agent 的长期记忆真正需要的是“审计追溯”而不是“语义回忆”。表结构大致长这样CREATE TABLE task_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, task TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime(now)), finished_at TEXT ); CREATE TABLE tool_calls ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_run_id INTEGER NOT NULL, tool_name TEXT NOT NULL, arguments TEXT NOT NULL, result TEXT, error TEXT, created_at TEXT NOT NULL DEFAULT (datetime(now)), FOREIGN KEY (task_run_id) REFERENCES task_runs(id) );每次任务结束后我还会把任务最终结果、用户反馈、是否成功这三项写入一个简单的键值表。这样下次遇到类似任务时能把历史结果作为参考提示注入给模型。这种用法比向量检索更可靠因为你看到的是一条完整可解释的历史记录而不是一段模糊的相似文本。6. 实测踩坑三个让我最头疼的问题及排查过程6.1 模型在 JSON 里夹带解释文字这是接本地模型时最常遇到的问题。模型返回的 tool_calls 参数原本应该是严格 JSON但实际返回可能是{city: 北京, date: 2025-06-01} 这个查询可以帮助用户了解明天的天气。后面那句就是多余内容。用标准json.loads解析会直接抛异常导致整个任务中断。排查这个问题的过程让我意识到不能把所有希望寄托在模型输出规范上。解决办法是在 executor 里加一层容错解析先尝试标准解析如果失败提取第一个左大括号到最后一个右大括号之间的内容再解析一次。同时在系统提示词里明确写一句“工具参数必须以 JSON 对象返回不要添加任何解释文字”。6.2 工具反复调用同一个参数导致死循环有一次任务模型连续四次调用read_file(path/data/sales.txt)每次拿到同样的内容又继续重复调用。日志看下来发现模型第一次调用后其实已经拿到数据但后面因为上下文里混入了太多其他信息它找不到这个结果在哪里于是重新读文件。这个问题不能只靠迭代上限兜底因为限制次数只解决了“别跑太久”没有解决“为什么重复跑”。后来我在主循环里加了一个检测逻辑如果连续两次工具调用使用相同的工具名和相同的参数就把第二次及以后的重复调用直接拦截并返回“上一次调用仍有效结果如下”。这个改动看似粗暴实际效果很好。因为绝大多数重复调用都是模型“失忆”导致的而复用上一次结果完全不会影响最终正确性。6.3 工具报错被模型包装成“成功”反馈这个坑特别隐蔽。有一次文件写入权限不足工具层返回了错误信息“Error: Permission denied”模型在下一轮却回复用户“文件已成功写入”。用户看到成功提示去检查文件发现根本不存在。根因是工具结果没有明确的成功/失败标记。模型看到一段字符串返回倾向于顺着用户预期说“成功了”。修复方案是在工具返回结构里增加is_error字段并在系统提示词里强调“如果工具结果包含 is_errortrue你必须如实告诉用户失败原因不得编造成功结果”。{ is_error: true, message: Permission denied: /data/summary.log }从那以后工具错误很少再被模型“洗白”。这里我也得到一个更通用的经验给模型的反馈信息一定要结构化纯文本最容易产生歧义。7. 快速跑起来最小可复现配置7.1 requirements 与配置文件如果你想在自己的机器上把 hermes-agent 跑起来最简配置是这样。依赖只需要三个核心库openai1.0.0 pydantic2.0.0 pyyaml6.0.0配置文件config.yamlmodel: api_base: http://127.0.0.1:8000/v1 api_key: sk-local model_name: qwen2.5-14b-instruct temperature: 0.0 max_tokens: 2048 agent: max_iterations: 8 memory_db: ./data/agent.db summary_threshold: 20 tools: - fs_tools - calc_tools - http_tools配置里最关键的是temperature: 0.0。工具调用场景必须把温度设成 0不然模型每次决策都会有一点随机性同一个任务跑两遍可能得到完全不同的工具调用序列这会让调试变得非常痛苦。7.2 运行方式和验证启动命令很简单python -m hermes_agent --config config.yaml然后在命令行输入一个需要多步工具调用的任务。我建议第一个验证任务不要选得太复杂比如生成一个包含 1 到 10 平方数的 CSV 文件保存到 ./output.csv这个任务模型必须调用计算工具和文件工具而且判断标准非常清晰打开 CSV 看内容对不对。跑通这一步说明整个主循环、工具注册、参数校验、结果回写链路都是通的。7.3 扩展方向并行工具、人工审批、注解式日志如果基础链路已经稳定可以按下面几个方向继续扩展并行工具调用把循环里串行执行 tool_calls 改成线程池并发执行适合多个互相独立的工具调用人工审批在危险工具删除文件、执行 Shell、提交订单执行前增加确认环节用配置文件里的tool_approval: true控制注解式日志把每次模型请求的 prompt、响应、token 消耗、耗时都记录成 JSON 日志方便以后做成本分析和效果评估。我自己的经验是先把基础链路跑扎实再碰这些扩展能力。很多 Agent 项目死在功能太多、基础不稳上而不是死在能力不够上。最后再分享一个我在实际使用中的体会如果只能给 Agent 项目定一条铁律我会选“所有执行结果都要结构化落盘”。你不需要完全相信模型的解释但你可以相信记录下来的每一次工具调用、每一个参数、每一条错误信息。hermes-agent 能做到现在这个状态靠的并不是某个模型突然变聪明了而是每一轮不可控的决策都被这个框架兜回了可控的轨道上。
返回列表