搞AI应用开发的人,这两年应该都有一种相同的体感:大模型的推理能力越来越强,但真要把模型接进自己的业务系统,总会卡在同一个地方——模型只会“说”,不会“做”。你想让它查个库存、调个接口、写个文件,它要么一本正经地编参数,要么干脆把工具调用格式写错,你只能一遍遍修正提示词,最后提示词长得比业务代码还复杂。
我去年开始认真折腾这个问题,想做一个轻量的、可扩展的代理框架,把模型和真实工具之间的那层胶水一次性做好。前后推翻了三版设计,最终沉淀下现在这套,名字就叫Hermes-Agent。Hermes是希腊神话里的信使神,负责传话、引路、穿针引线——一个Agent框架最核心的职责正好就是这个:做LLM和外部世界之间的翻译官和调度员。
这篇文章是我对Hermes-Agent整个项目的完整复盘,包括设计思路、架构拆解、关键模块的代码实现思路,以及我在生产环境里踩过的一堆坑。如果你正准备用GPT、Claude或者开源模型做工具调用、任务编排,但苦于上下文管理混乱、工具接入不优雅、多轮任务经常断片,那么这篇内容应该能帮你少走不少弯路。
1. 项目定位:Agent框架到底在解决什么问题
1.1 从对话到行动:大模型落地卡在哪一步
先说个很直白的观察。大部分团队接入大模型的第一版,都是把模型当“高级搜索引擎”用:用户提问,模型回答,完了。但业务方真正想要的往往是“让AI把事办了”——比如自动整理报销单、根据邮件生成周报并发送、监控数据异常并触发告警。这些场景有个共同点:模型不能只输出文字,它必须调用外部工具、依赖外部系统状态、根据执行结果继续决策。
问题就在这里踩出来了。第一,工具调用的格式不稳定。今天模型输出的函数参数是合法JSON,明天同样的请求它就给你多个注释、少个引号,解析直接崩。第二,多步骤任务的上下文很难维护。任务执行到第三步,模型已经忘了第一步的结果,只能把全部历史一股脑塞回上下文,token消耗爆炸。第三,新加一个工具太痛苦。每接入一个API,都要改提示词、改解析逻辑、改错误处理,工具一多代码就成了一团乱麻。
1.2 Hermes-Agent的定位与设计目标
所以做Hermes-Agent的时候,我给它的定位就三条,非常明确。
第一条叫工具接入标准化。新加一个工具,只需要写一个普通Python函数,加一个装饰器声明参数结构,框架自动完成注册、参数校验、错误捕获和调用转发,绝不要求改核心逻辑。
第二条叫任务执行可观测。模型到底做了哪些决策、调了哪些工具、每步花了多少token,全都要有日志有轨迹,出了问题能回溯。这不是锦上添花,是生产环境的基本要求。
第三条叫知识状态可分离。短期会话上下文放内存或Redis,长期业务知识进向量库,模型每次只需要拿到“当前任务真正需要的那部分信息”,而不是把整个聊天记录都喂进去。这既是效果问题,也是成本问题。
一句话概括:Hermes-Agent不是让模型变得更聪明,而是让模型更容易被约束、被使用、被接入真实系统。它的价值不在模型层,在工程层。
2. 核心架构拆解:一条消息从进入到执行完毕
2.1 四组件架构:调度中枢、工具总线、记忆层、代理运行时
整个框架被拆成四个互相独立的组件,我分别叫它们Dispatcher(调度中枢)、ToolBus(工具总线)、MemoryStore(记忆层)、AgentRuntime(代理运行时)。
- Dispatcher:入口网关,接收用户请求,做意图识别、任务规划,把大任务拆成有序的子任务序列,并负责任务状态的流转。
- ToolBus:所有外部能力的注册中心和执行通道。每个工具在ToolBus里都有一个描述信息(名称、功能、参数Schema),运行时统一做参数校验和调用转发。
- MemoryStore:记忆服务。短期会话记忆、长期知识记忆、任务执行轨迹都通过它统一读写,对外提供干净的存取接口。
- AgentRuntime:真正“跑模型”的地方。它把任务、工具描述、记忆内容组装成Prompt,调用LLM,解析结果,再决定下一步是继续执行还是终结。
这四个组件全部通过事件通信,彼此不直接依赖。实际部署时,Dispatcher和AgentRuntime可以水平扩展多个实例,ToolBus和MemoryStore作为独立服务存在。这样做的直接好处是:想换模型,只改Runtime;想加能力,只注册Tool;想换记忆后端,只替换MemoryStore的实现。其他地方一行不用动。
2.2 一条消息的完整生命周期
给你走一遍全流程,你就知道组件之间怎么配合了。
用户发来一句话:“帮我把昨天的销售数据汇总一下,生成一张每周趋势图,然后发到团队群里。”
- Dispatcher接收请求,启动规划器。规划器调用一次LLM,把这句自然语言拆成四个子任务:A. 查询销售数据;B. 汇总计算;C. 生成趋势图;D. 发送到群聊。
- Dispatcher按依赖关系把任务排成执行序列(A和B有先后,C依赖B,D依赖C),依次下发。
- AgentRuntime拿到“查询销售数据”这个任务,从ToolBus查找到匹配的
query_sales_data工具,从MemoryStore读取必要的连接信息,组装Prompt,调用LLM生成调用参数。 - ToolBus校验参数,执行工具,返回结果。
- 结果写回MemoryStore,任务状态更新为完成,Dispatcher继续下发下一个任务。
- 中途如果某个工具调用失败,AgentRuntime带着错误信息触发局部重规划,只重试失败的那一步,而不是从头再来。
- 所有子任务完成,Dispatcher把最终汇总结果返回给用户。
整个过程中,用户拿到的是一次完整的服务,但背后模型被调用了很多次,每次只聚焦一个小任务。这比“把四件事写在一个巨型Prompt里让模型一口气做完”要稳定得多,也便宜得多。
2.3 关键数据结构:任务、工具、状态
代码层面,我定义了三个核心数据结构,整个框架都是围绕它们转的。
Task是任务描述,包含任务ID、类型、输入参数、依赖关系、状态和重试次数。ToolDescriptor是工具元数据,包含工具名、功能描述、参数Schema、超时时间、是否需要用户确认。AgentState是代理运行状态,包含当前任务栈、已收集的上下文、执行的轨迹记录。
这三个结构对应了Agent框架里最常出问题的三个点:任务会拆错、工具会选错、状态会丢。把数据结构先定清楚,后面怎么写都不容易乱。
@dataclass class Task: task_id: str intent: str # 任务意图,如 "query_sales" inputs: dict # 输入参数 depends_on: list[str] # 依赖的任务ID列表 status: str # pending / running / success / failed retry_count: int = 0 @dataclass class ToolDescriptor: name: str description: str # 供LLM理解工具用途 param_schema: dict # JSON Schema,用于参数校验 timeout: float = 30.0 require_confirm: bool = False # 是否需要用户二次确认3. 关键模块设计与实操实现
3.1 任务规划器:先拆解,再动态修正
任务规划是整个框架里最影响体验的模块。我最初用的方案是“一次规划到底”:用户请求进来,让LLM一次性生成所有子任务和完整执行顺序,然后按顺序执行。这种方式在任务简单时效果不错,但一旦遇到执行中出现意外(比如某个工具返回的数据格式和预期不符),整个计划就全盘失效了。
后来我改成混合模式:先规划,后修正。初始计划仍然由LLM一次性生成,但执行过程中每个子任务完成后,都会做一个“状态评估”。如果发现结果和预期不一致,只触发局部重规划——把当前失败点及其后续依赖重新拆解,已完成的步骤不重复执行。
这样做的好处非常实际。一是大幅减少了token消耗,因为不需要每步都让模型思考全局计划。二是提升了容错率,单个工具失败不会导致整个任务链崩溃。三是日志更清晰,每一步做了什么、基于什么状态决策的,都能追溯。
3.2 工具注册与调用:让Agent真正能用上你的系统
ToolBus的设计目标是“让接入工具像写函数一样简单”。我用Python装饰器实现了一套声明式注册机制,一个工具本质上就是一个普通异步函数。
@tool( name="query_sales_data", description="查询指定日期范围的销售数据,返回按日汇总的销售额列表", params={ "start_date": {"type": "string", "description": "开始日期,格式YYYY-MM-DD"}, "end_date": {"type": "string", "description": "结束日期,格式YYYY-MM-DD"} }, timeout=60 ) async def query_sales_data(start_date: str, end_date: str) -> list: # 业务逻辑:查数据库,聚合统计 ...框架在注册时自动完成几件事:把函数名和描述注册进ToolBus,生成参数Schema,并关联对应的执行函数。LLM在推理前会拿到所有可用工具的描述和Schema,它只需要按格式给出“调哪个工具、传什么参数”,ToolBus负责参数校验、类型转换、超时控制、异常捕获。
这里有一个很重要的工程细节:参数校验必须放在ToolBus侧,而不是LLM侧。我一开始天真地指望LLM每次都输出完全合法的参数,后来发现纯属幻想。模型经常会把日期格式写错、字段名张冠李戴、甚至凭空造一个参数。所以在ToolBus里必须要做两件事:第一层是Schema硬校验,参数类型不对、缺字段、有未知字段,直接拒绝并明确告诉Agent错在哪里;第二层是业务前置校验,比如查询日期不能早于系统上线日期,这类规则用代码写死,绝不让模型自由发挥。
参数校验失败后还有个易被忽视的环节:错误反馈要结构化。不要只返回“参数错误”,要把“哪个参数错、期望什么格式、你给的是什么”都一起返回。模型的自我纠错能力完全取决于错误信息的质量,这一点在调试时感受特别深。
3.3 记忆管理:短期上下文与长期知识分离
记忆模块是Agent稳定性的基础,也是最容易翻车的地方。我踩过最大的坑就是“把所有对话历史全部塞进上下文”,结果任务到后半段上下文窗口直接拉满,模型开始胡言乱语,费用还翻了好几倍。
现在我的MemoryStore分三层。第一层是短期会话记忆,用Redis存最近若干轮的关键信息摘要,默认保留10轮,每轮做完即时压缩。第二层是任务轨迹记忆,记录当前任务链中的中间结果和工具返回数据,子任务执行时可按需读取,任务结束后自动清空。第三层是长期知识记忆,用向量库存储业务知识、历史执行经验、常用查询模板,在任务开始时做一次语义检索,只取出相关片段注入提示词。
特别要提一下短期记忆的压缩策略。每轮对话之后,我会让模型产出一个“结构化摘要”,只保留与任务目标强相关的信息(用户偏好、已确认的事实、待办事项),舍弃寒暄、重复、无关细节。实测下来,这个摘要机制能把后续轮次的输入token减少40%到60%,而且模型专注度明显提升,因为上下文里没有太多干扰信息。
3.4 安全与可控:给模型装上刹车
Agent能调用工具之后,安全问题就绕不开了。一个会自动执行操作的Agent,如果没有约束,比一个只会聊天的模型危险得多。我在Hermes-Agent里加了几条硬性安全策略。
工具白名单:不是所有注册的工具都允许Agent自主调用。默认只有标注了safe的工具可以自动执行,涉及发消息、删除数据、支付下单等敏感操作的工具,必须经过用户二次确认。这个确认机制在ToolBus层实现,Agent生成的调用请求先转成“待确认状态”,用户批准后才真正执行。
执行超时与重试上限:每个工具都有独立的超时时间,超时即失败。每个子任务最大重试次数默认设为2次,超过直接标记失败并进入人工兜底流程。这样能避免Agent面对持续报错时陷入死循环——我之前见过一次它在3分钟内重复调用同一失败接口17次,那次之后我就彻底定死了重试上限。
调用链路审计:所有工具调用都有完整日志,记录触发Agent的任务ID、调用参数、返回结果、耗时、token消耗。不为别的,就为出问题时能搞清楚发生了什么。
4. 部署实践与问题排查:真实环境里的坑
4.1 环境选型与基础安装
如果你要复现这套框架,环境方面我推荐这样配:
| 组件 | 技术选型 | 说明 |
|---|---|---|
| 编程语言 | Python 3.11+ | asyncio支持成熟,类型注解友好 |
| API服务 | FastAPI + Uvicorn | 轻量,原生支持异步,适合做Dispatcher入口 |
| 数据校验 | Pydantic v2 | 用于参数Schema校验,性能比v1提升明显 |
| 记忆存储 | Redis + SQLite-VSS | Redis管短期,SQLite-VSS管长期向量检索 |
| 消息通信 | Redis Stream / 内存事件总线 | 单机用asyncio队列,多机用Redis Stream |
| 模型接口 | OpenAI SDK兼容层 | 可切换到任意兼容OpenAI格式的服务 |
安装依赖非常简单,核心包就那几个:fastapi、uvicorn、pydantic、redis、openai、numpy。我强烈建议在虚拟环境里装,Python版本至少3.10,否则一些类型特性用不了。
python -m venv .venv source .venv/bin/activate pip install fastapi uvicorn pydantic redis openai numpy4.2 常见问题与排查技巧
我把开发和生产过程中遇到的高频问题整理成了速查表,都是真实踩过的坑。
| 现象 | 根因 | 解决办法 |
|---|---|---|
| 模型频繁输出非法JSON参数 | 模型对工具Schema理解不足 | 在工具描述里增加“参数示例”;校验失败时返回明确错误信息 |
| 多轮任务越做越迷糊 | 上下文被无关内容污染 | 启用摘要压缩机制,只保留关键信息 |
| 同一工具反复调用失败 | 缺少重试上限约束 | 设置最大重试次数;失败原因分类,区分“可重试”和“不可重试” |
| Token消耗超出预算 | 每次调用都塞全量上下文 | 实现上下文裁剪,只注入当前任务检索到的片段 |
| Agent选择错误的工具 | 工具描述不清晰 | 重写工具描述,避免模糊词汇,写明适用场景和边界 |
| 本地模型工具调用格式不稳定 | 模型能力不足 | 降低任务粒度,增加结构化指令示例;或换用能力更强的模型 |
第一个问题多说一句。非法JSON是最常见的失败原因,而且大概率出在“嵌套结构”上。我在开发时经常遇到模型把数组里的对象少写一个花括号、日期字符串里带中文、数字被写成千分位格式。解决思路是两层:第一,ToolBus参数校验时不要直接硬解析,先做一次容错修复(比如去除多余换行、补全缺失的引号);第二,如果修复失败,把“哪里解析失败、期望是什么”返回给模型,让它重新生成参数。这套组合下来,参数解析成功率能从85%提升到98%以上。
第二个坑在记忆方案上。最初我把“最近N轮消息”直接拼接进上下文,理由是简单省事。结果任务稍微复杂一点,模型就开始丢失早期的重要约束,比如用户第一条消息明确的格式要求,到了第五轮早被新信息挤没了。改成摘要压缩之后,约束信息会定期被固化进摘要,基本没有再犯。
4.3 成本与性能调优记录
最后讲讲成本和性能。我实测过一组数据:不做任何优化时,一次需要调用5次模型的“数据汇总+图表生成+发送”任务,总token消耗大约是4.8万。做完上下文裁剪和记忆压缩优化后,同样任务降到约2.1万,成本下降超过一半,任务耗时也从28秒降到15秒左右。
我的调优不是靠某一个大杀器,而是三个小技巧叠加。
模型分级。不是所有任务都需要最强模型。意图识别、任务规划这类对推理要求较高的步骤用它;参数抽取、简单文本转换这类重复性工作,用更小的模型完全够。分完之后成本直接砍掉不少,准确率几乎没受影响。
结果缓存。对于数据库查询、API请求这类幂等操作,可以根据请求参数做缓存。短时间内相同参数直接返回缓存结果,省一次工具调用就省一次模型的后续处理成本。
并发控制。当多个用户同时使用Agent时,如果每个Agent的LLM调用都不设限制,很容易把API配额打爆。我在Dispatcher层加了一个简单的信号量机制,控制全局并发请求数,配合Redis做跨实例限流。宁可让用户等两秒,也不能让服务雪崩。
最后分享一个实操心得
这个项目从最初的想法到能稳定跑生产,我最大的体会是:Agent框架的复杂度从来不在模型层,而在工程约束层。模型的能力已经足够强了,真正难的是设计一套机制,让它正确、稳定、成本可控地接入真实世界。工具要能标准化注册,任务要能拆解和追溯,记忆要能分层管理,执行要有刹车。
如果你也想自己做一套类似的Agent框架,我的建议是不要一上来就追求大而全,先跑通一个最小的闭环:一个能够调用两三个工具的Agent,把任务规划、工具注册和基础记忆做起来,然后再逐步加安全策略、加分布式、加记忆压缩。一开始就铺太大的摊子,很可能淹死在各种边缘情况里。
另外一个很私人的经验:错误信息写得好不好,直接决定Agent的下限。你花了多少心思在工具返回给模型的结构化错误信息上,模型就能帮你省多少事。这件事多花点时间,绝对值得。