上周把 Hermes-Agent 的源码从头到尾完整过了一遍好几处设计让我有一种“原来是这么回事”的感觉。这个项目不是那种简单的 Demo 级 Agent而是一个把大模型编排、工具调用、记忆管理、事件驱动全部串在一起的完整框架。这篇文章我不打算做成官方文档的翻译件而是想站在一个准备拿它做二次开发的人的角度把架构分层、核心流程、关键模块以及真正动手改代码时最容易踩坑的地方一次性讲透。不管你之前有没有做过 Agent 开发看完这篇文章之后你应该能回答这样几个问题Agent 框架到底在解决什么问题任务从进来到最终返回结果中间经历了什么如果要给它新增一个工具、换一个大模型、改一套规划策略代码应该从哪里入手如果这些问题正好也是你想搞清楚的这篇源码解析就是冲着你写的。1. 这个项目到底做了什么先看懂它的定位与架构分层1.1 Agent 框架解决的是“大脑没有手脚”的问题先说一个很基础的判断大模型本身是一个“大脑”但它没有手脚。它可以在对话里说得头头是道却没办法真正去查询数据库、调用接口、读写文件、操作浏览器。Agent 框架存在的意义就是给这个大模型装上手脚并且设计一套神经系统去控制手脚。这个思路说起来简单真去实现的时候才会发现问题。假如用户提了一个任务“帮我查一下模拟项目 X 的订单数量”模型只凭训练记忆是不可能知道这个数字的它必须被允许去调用一个“查询订单”的工具。但这就引出一连串问题模型怎么知道该调用哪个工具调用时参数从哪来工具返回的结果要怎么喂回去如果工具报错了是终止还是换一种方式再试整个循环什么时候才算结束所以一个 Agent 框架的源码本质上就是在编排三样东西任务目标、可用工具、当前状态。Hermes-Agent 的代码里最厚、最绕的部分几乎全都在处理这三者之间的关系。如果你只是简单地把大模型 API 和几个 Python 函数拼在一起那根本不叫 Agent那叫“带函数调用的聊天机器人”。Agent 和聊天机器人之间真正的分水岭就是有没有那一套“观察—思考—行动—反馈”的循环机制。1.2 分层架构每层管好自己的事读完整个项目的源码结构我第一感受是它的目录划分非常规整。虽然不同版本的代码细节有差异但大体上可以分成四层。第一层是接入层负责接收外部请求做参数校验、会话创建然后把任务交给核心层。这一层通常很薄主要做一些 HTTP 接口或者 CLI 命令的适配。第二层是核心层这是整个框架的心脏里面运行着 Agent 的主循环、状态机、任务调度逻辑。第三层是能力层包括大模型 Provider、工具 Provider、记忆 Provider它们通过抽象接口挂在核心层下面。第四层是基础设施层包括配置管理、日志系统、事件总线、存储组件。这样的分层带来的最大好处是替换成本极低。你今天用商业大模型 API明天想换成某个本地开源的模型只需要在 Provider 层面做适配核心循环完全不用动。你想新增一个工具也只需要在工具层注册一个函数Agent 的逻辑不会因为你多了一个工具而需要改代码。我用一个类比来解释这个架构把 Agent 框架看成一个餐厅。核心层是店长负责整体调度大模型是主厨的大脑负责判断该做什么菜工具是后厨的设备负责把菜做出来记忆模块是食材仓库记录着客人喜欢什么口味。你二次开发的时候通常只需要换设备或者给仓库加货不会动不动就把店长撤掉重招。层与层之间的调用关系也非常克制核心层不直接依赖某个具体的大模型 SDK也不直接调用你的业务函数它依赖的是抽象接口。源码里大量出现 BaseProvider、BaseTool、BaseMemory 这样带 Base 前缀的类本质上就是在为扩展留口子。我做二次开发时的体会是读懂这几个抽象接口的约束比读懂几百个具体类都有用。2. 核心流程源码级拆解从任务进来到结果返回的完整链路2.1 主循环观察—思考—行动—反馈的四步舞Agent 的核心流程如果压缩成代码大概就是下面这个循环while not agent.is_done(): observation agent.collect_input() # 获取用户输入、工具结果、系统状态 thought agent.think(observation) # 调用大模型生成决策 action agent.parse_action(thought) # 解析出工具调用指令 result agent.execute(action) # 执行工具并得到结果 agent.update_memory(observation, thought, result) # 更新上下文这段伪代码虽然只有六行但源码里对应的是六套完整的机制。collect_input 要做的不仅仅是把用户最新的消息拼进去还要考虑历史消息、系统提示词、记忆检索结果think 要做的是把消息列表转换成模型需要的数据结构调用 Provider再解析返回值parse_action 是最容易出问题的一步因为大模型返回的内容并不总是合法 JSONexecute 要处理工具找不到、参数校验失败、执行超时等各种异常。主循环的终止条件也是源码里值得琢磨的地方。项目里至少会有三道保险最大迭代轮数、模型主动返回“最终答案”标记、外部中断信号。我曾经见过一个没做过终止控制的简易 Agent 在大模型反复自我质疑时无限循环下去token 烧掉一大把才被手动终止。所以看源码的时候我特别注意了 max_iterations 这个配置项它本质上是给 Agent 上的保险丝。2.2 规划器与执行器两套机制的协同Agent 圈子里一直有两套主流的任务处理范式Hermes-Agent 源码里对它们做了兼容并且允许通过配置切换。第一种是 Plan-and-Execute先规划再执行。大模型收到任务后先生成一份步骤列表比如“第一步查库存第二步计算均价第三步汇总报告”然后 Agent 按照这个列表逐步执行。这种方式的优势是计划清晰、token 开销相对可控适合目标任务明确、步骤相对固定的场景。缺点是一旦中间某一步的实际结果和预期不符后续步骤可能全部作废。第二种是 ReAct 风格边想边做。模型每一轮都基于当前最新的观察结果做决策现在看我现在该调用哪个工具或者我现在已经可以回答问题了。这种方式的灵活度很高特别适合需要多轮试探的场景缺点是 token 开销大、可能绕弯子。Hermes-Agent 的默认风格更偏向 ReAct因为它能比较好地应对不确定性。源码里这个选择是有意为之的与其赌模型一次规划就能想出所有步骤不如让它每一步都基于真实反馈做决策。我在实际使用中也发现对于“多轮试探”型任务ReAct 的成功率明显更高。2.3 消息协议与上下文管理Agent 流的“物流系统”主循环跑的每一轮中间都在传递各种数据结构。源码里会看到一组常见的消息类型UserMessage、AssistantMessage、ToolCall、ToolResult、SystemMessage。这组数据协议是整个 Agent 流的“物流系统”所有信息都靠它们在不同模块间传递。一个特别容易让新手误解的地方是工具结果并不只是一条日志它必须被当作一条真实的消息送回给大模型否则模型根本看不到工具返回的数据。比如你让 Agent 查订单量工具返回了“12345”这个“12345”必须进入下一轮大模型调用的上下文模型才能写出“订单量为 12345”的最终答复。如果只把“工具调用成功”六个字写进上下文模型拿什么编答案它只能胡编。既然工具结果要回填上下文上下文管理就成了源码里最不能马虎的机制。因为大模型的输入长度是硬约束你不可能让历史消息无限膨胀下去。Hermes-Agent 的做法包括滑动窗口裁剪旧消息、对过长历史做摘要压缩、把记忆模块检索出的关键信息置顶。这些策略单独看都不复杂难的是把它们组合在一起既要控制 token 预算又不能把关键决策信息丢掉。3. 关键模块源码导读三个必读的核心类3.1 LLM Provider为什么值得单独做一层封装如果项目里只有一两个模块可以选着读我一定会推荐先读 LLM Provider 这个抽象接口因为它是整个 Agent 的“算力入口”。不做封装会是什么样子每个大模型平台的请求格式都不一样有的用 messages 数组有的用 prompt 字符串有的支持 function calling有的不支持有的输出 JSON 模式需要额外指定 format有的则是靠提示词“骗”出来的。如果业务代码直接面向某一个平台 SDK 开发以后每换一次平台都要改一遍 Agent 主循环代价非常大。Hermes-Agent 的做法是定义一套统一的内部消息格式和调用接口比如 chat(messages, toolsNone, streamFalse)然后写若干平台各自的适配实现。这套抽象的价值在流式输出上体现得最明显。流式返回是一段一段的Provider 需要把流式解析成事件回调比如每来一个 token 就触发一次回调这样调用方既能实时看到生成过程又不用关心底层是 SSE 还是 WebSocket。重试机制也是 Provider 层要认真处理的细节。我见过不少人把超时重试写成了无脑重试结果遇到速率限制时越重试越糟糕。正确的做法是先判断异常类型网络错误和服务器 5xx 可以重试认证失败和参数错误重试多少次都没用速率限制则需要按响应头里的时间窗口等待。源码里这些分类处理逻辑虽然不起眼但直接决定了 Agent 在线上跑得稳不稳。3.2 工具注册中心从函数到 function calling schema工具模块是我认为 Hermes-Agent 里设计得最漂亮的部分。传统开发模式下你写一个函数想让大模型能调用它需要手动写一份 JSON Schema 描述函数名、参数、含义。问题是函数一变Schema 就过期两边经常对不上。Hermes-Agent 用装饰器把这个过程自动化了。你在源码里会看到类似这样的写法tool def query_order(product_id: str) - str: 查询某个产品的订单数量。 Args: product_id: 产品编号。 Returns: JSON 字符串包含 total_count 字段。 ... return result装饰器背后做的事情是利用语言的反射能力提取函数名、参数签名、docstring自动生成一份 function calling 用的 JSON Schema。这样开发者只需要写一个普通 Python 函数工具注册中心就能自动完成注册。工具调用真正执行的时候源码要处理的问题就多了解出模型返回的 tool_name 和参数 JSON找到对应的注册函数做参数类型转换和必填项校验执行函数捕获异常把返回值封装成 ToolResult。整个流程里最容易翻车的就是参数解析因为大模型生成的参数有时候字段名会缺一个字母、值类型会写错所以项目里会在执行前做一层容错纠正。这个细节我在做二次开发时还专门测试过确实能拦住不少低级错误。3.3 记忆模块短期、长期与上下文压缩记忆模块是 Agent“像人”的关键也是源码里区分度最大的一块。短期记忆其实就是当前会话的上下文消息列表它的生命周期和一次 Agent 运行是一致的。长期记忆则通常依赖向量化存储把重要的历史信息做嵌入存入向量数据库需要时通过相似度检索捞回来。你可以把长期记忆想象成一个人的笔记本重要的线索记下来下次遇到类似问题时翻一翻。关于长期记忆源码里有一个很关键的设计抉择什么时候写入记忆如果每一轮都写记忆库会迅速膨胀检索效率下降如果只在整个任务结束时写中间很多有价值的中间结论就丢了。项目里往往会选择在“关键节点”写比如一次工具调用成功之后、拿到阶段性结论之后而不是每轮无脑记录。检索策略同样是源码里一个有意思的点。它不是简单地把所有记忆都塞进上下文而是先做相关性排序再按 token 预算截取。这样做的背后逻辑是模型处理大量无关信息时容易被干扰不如少而精地提供若干条高相关记忆。我在读源码时看到这个设计心里默默给它点了个赞。4. 二次开发实战指南从改工具到换模型4.1 新增自定义工具的完整流程如果你只打算给 Hermes-Agent 做一件事大概率就是“让它能调用我自己系统的接口”。这个流程我拆解给你看。第一步写一个普通函数函数名清晰参数明确返回值用 JSON 字符串。第二步给函数加上 tool 装饰器并在 docstring 里写清楚工具的使用场景和参数含义。第三步把工具类注册到工具管理器里。第四步在配置文件中启用这个工具或者确认自动发现机制已经扫描到它。第五步跑一个最小对话测试让 Agent 调用它。这里我重点说说 description 怎么写。很多人在这一步偷懒写一句“查询订单数量”就完事结果模型根本不会主动调用这个工具。一个真正有效的 description 应该包括什么场景下使用、每个输入参数的含义、输出结果的格式。我自己常用的写法是“当用户询问某个产品的订单数量时使用。输入 product_id 是产品编号。返回 JSON 字符串包含 total_count 字段。”这样的描述是在告诉模型“你什么时候该用它”而不是机械地说明“我这个函数能做什么”。另外提醒一句不要在描述里写“不要调用”之类的否定句式模型经常选择性忽略否定词。4.2 替换或新增大模型 Provider如果想把默认的大模型换成别的做法比想象中简单。核心是继承 BaseProvider实现聊天接口再把自己注册到 Provider 工厂里。class MyProvider(BaseProvider): name my_llm def chat(self, messages, toolsNone, streamFalse): # 把内部 messages 格式转换成这个平台的请求格式 # 调用平台 API # 把结果统一转换成 ChatResult 返回 ...源码里最爽的一点是你一旦注册好了这个 ProviderAgent 主循环、工具调用、记忆全部不需要改。你只需要把配置里的 provider 名换成 my_llm 即可。但这里有几个我实际踩过的坑。第一不同平台对系统提示词的位置要求不一样有的需要拼到 messages 的 system 角色里有的只能放在请求参数里。第二部分平台不支持 function calling你需要自己开一个“提示词兜底”模式把工具说明写进系统提示词让模型输出特殊格式指令后再解析。第三不同模型的输出稳定性差异很大建议先单独测试 provider 的返回再挂上 Agent 跑完整流程不要一步到位。4.3 调整 Agent 的规划策略与提示词模板Agent 跑得不好很多问题不在代码而在提示词模板。Hermes-Agent 通常会把提示词模板单独放在一个目录里方便二次开发时修改。我建议你至少关注三个参数。第一个是 max_iterations控制最大循环轮数新手建议设小一点比如 5先跑通再放宽。第二个是 temperature考察任务模式建议调低到 0.2 以下减少随机发挥。第三个是 stop 序列有些模型会自己加戏需要在系统提示词里明确“只输出最终答案不要额外解释”。这里补充一个我自己的经验在系统提示词里加一句“你只能调用已被提供的工具当没有可用工具时明确告知无法完成”能显著减少模型胡编工具结果的情况。别小看这句话它等于给模型划了一条红线。4.4 通过 Hook 机制做监控、限流与人工干预源码里最容易被忽略但又非常实用的部分是事件总线。项目通过事件系统暴露了一组 Hook 点比如 on_tool_start、on_tool_end、on_llm_response。你可以注册自己的回调函数在不修改核心代码的前提下插入额外的逻辑。我实际用 Hook 做过三件事。第一件事是打点统计每次工具调用开始和结束时记录时间和耗时用来排查慢查询。第二件事是限流当某个工具在短时间内被调用太多次时主动抛错中断防止逻辑死循环。第三件事是人工审核在 on_tool_start 里加一个可配置的 pause把待执行的工具参数打印出来让人在控制台确认“Y/N”后再继续。后面这个功能在早期调试时救过我好几次不然模型乱调工具的时候根本拦不住。5. 高频问题与调试经验实录5.1 五个最容易让 Agent“翻车”的场景我见过很多人在开发 Agent 时栽在同一批坑里。这里整理成一张速查表方便你排查时对照。现象根因排查方向Agent 在同一个工具上反复调用模型没有从结果里得到足够信息检查工具返回的字段是否齐全提示词是否说明“结果无效时换路径”模型编造了不存在的工具名工具描述不够清晰或模型自身能力弱检查工具注册列表、Schema 完整性必要时降级模型上下文一长就答非所问历史消息过多、超限后被粗暴截断启动摘要压缩、滑动窗口、记忆检索工具执行报错但 Agent 还继续异常被当成了正常 ToolResult检查异常处理分支是否把错误消息送回给模型模型直接给出答案但没有真正调用工具提示词没有约束“必须先调用工具”在 System Prompt 中强制要求引用工具结果5.2 调试三板斧日志、Mock、人审第一板斧是开启 DEBUG 日志看原始请求和响应。不要只盯着 Agent 最后的回答看要去看每次大模型往返的完整消息列表尤其是中间有没有出现工具调用标记、返回参数是不是合法的 JSON。我会把原始请求导出成 JSON 文件在编辑器里格式化后逐段观察很多“幽灵问题”一看到原始消息就现出原形。第二板斧是 Mock 模型。日常开发一个很痛苦的地方是大模型的输出不可控导致你没法确定是 Agent 逻辑的 bug 还是模型抽风。解决办法是写一个模拟 Provider不调用真实模型直接按预设返回指定内容比如固定返回“调用 query_order 工具参数是 xxx”。这样调试 Agent 流程时每次复现的行为都完全一致可复现性大幅提高。第三板斧是人工审核模式就是我们前面说到的 pause 机制。在工具执行前加一道人工确认看起来会对自动化程度有一点影响但早期调试阶段几乎是性价比最高的手段它能让你看到模型每一步的决策是否正确而不是等最终结果出来再倒推。5.3 成本与性能控制token 和工具调用的度量衡Agent 项目的成本大头是 token这一点几乎没人反对。主循环每多跑一轮就要把越来越多的上下文重新发送给模型一次。读源码时我看到框架已经提供了很多省 token 的设计但作为使用者你还需要主动做三件事。第一给工具结果加长度限制。很多工具会把一大坨数据返回给 Agent但模型真正需要的可能只是其中几个关键字段。可以在工具返回前做一次后处理只保留摘要字段。第二设置合理的 max_iterations既能防止失控也能倒逼 Agent 更高效地完成任务。第三给每次工具调用和 LLM 调用都打上耗时和 token 计数日志定期复盘。我见过有人上线 Agent 一周之后才发现某个工具被高频空转白白烧掉大量 token。另外一个容易被忽略的点是并发测试。Agent 不是单次调用的 HTTP 接口它是一段长流程。压测的时候要观察超时率、上下文内存占用、事件队列堆积情况而不是只看 API 的成功率。我最后再说一点个人体会吧。源码看多了你会发现Agent 框架里真正难的不是某个算法而是对不确定性的管理模型输出不可控、工具随时会挂、上下文有硬上限。所有看起来复杂的架构设计其实都是在为“不确定性”兜底。所以如果你想上手这个项目我的建议是别急着铺开一堆功能。先把一个工具做到稳定跑通默认循环再把原始日志完完整整看一遍最后再谈优化和扩展。你踩过几次坑之后会明白那些“看起来很酷”的功能都不如一套可靠的调试手段来得实在。