news 2026/8/24 21:27:23

Hermes Agent run_agent的源码解读

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Hermes Agent run_agent的源码解读

关键词:Hermes Agent、AIAgent、源码分析、tool calling、对话循环、Python

文章目录

    • 一、背景:run_agent的介绍
    • 二、入口执行链:从命令到对话循环
    • 三、分层架构:五层职责拆解
      • 第 1 层:门面层(`run_agent.py`)
      • 第 2 层:初始化层(`agent/agent_init.py`)
      • 第 3 层:每轮前置(`agent/turn_context.py`)
      • 第 4 层:对话主循环(`agent/conversation_loop.py`)
      • 第 5 层:工具执行 + 收尾(`agent/tool_executor.py` + `agent/turn_finalizer.py`)
    • 四、核心机制:四个值得抄的工程实践
      • 机制 1:双层预算的主循环
      • 机制 2:段式工具调度(并行安全分段)
      • 机制 3:合成消息标记(synthetic scaffolding)
      • 机制 4:错误分类,避免白烧重试预算
    • 五、真实旅程复盘:一条消息的完整生命周期
    • 六、总结:设计取舍与"最值得抄的三件事"
      • 设计取舍对照表
      • 边界与代价(诚实说明)
      • 最值得抄的三件事

一、背景:run_agent的介绍

Hermes Agent 是 Nous Research 开源的模型无关 AI Agent 框架。它的核心运行时入口是一个叫run_agent.py的文件——单文件 。我第一次打开它的时候以为核心逻辑都在里面,读完才发现被"骗"了:这是一个典型的门面(Facade)+ 兼容层,真正干活的逻辑几乎全被拆进了agent/

先看几个真实的规模数据(本机实测,Hermes v0.20.0):

文件行数职责
run_agent.py8206门面 + 兼容层 + CLI 入口
agent/conversation_loop.py7524对话主循环(真正实现)
agent/agent_init.py2823初始化(模型路由/凭据/工具装配)
agent/tool_executor.py2403工具执行(并发/串行/分段)
agent/turn_context.py1281每轮前置处理
agent/tool_dispatch_helpers.py732段式工具调度规划
agent/turn_finalizer.py772回合收尾与结果组装

这个文件要解决的工程难点,恰恰是它行数膨胀的原因:

  1. 门面与实现分离:上层有 CLI、gateway(Telegram/Discord/飞书)、desktop 多个入口,它们要共享同一个对话引擎。run_agent.py提供统一 API,实现放agent/包,改行为不动接口。
  2. 向后兼容的符号重导出:历史上有约 28 个测试文件用mock.patch("run_agent.OpenAI")这种写法,大量生产代码from run_agent import X。重构时实现挪走了,这些符号必须留在run_agent模块命名空间里,于是满屏# noqa: F401 # re-exported for tests
  3. 既是库又是 CLI:作为库被 import 时不能拖慢启动、不能因缺依赖崩掉;作为 CLI 直接跑时又要能列工具、选工具集。这两个诉求在同一文件里被小心翼翼地平衡。

下面我沿着"用户输入一条消息 → 最终输出"这条链路,把它的实现逻辑和关键代码讲清楚。

二、入口执行链:从命令到对话循环

一条消息进入 Hermes 的完整链路是这样的(以 CLI 为例):

用户敲 hermes chat

hermes_cli/main.py main
argparse 分发

cmd_chat
前置决策: resume/cwd/provider

cli.main / cli.chat

后台线程: agent.run_conversation()

run_agent.py:7798
AIAgent.run_conversation(forwarder)

agent/conversation_loop.py:1358
run_conversation(真正实现)

build_turn_context
每轮前置

while 主循环
API 调用 + 工具执行

finalize_turn
组装结果 dict

关键点在这一步:run_agent.py里的AIAgent.run_conversation只是一个forwarder,真正的实现被转发到了agent/conversation_loop.py:

# run_agent.py:7798defrun_conversation(self,user_message:Any,system_message:str=None,conversation_history:List[Dict[str,Any]]=None,task_id:str=None,stream_callback:Optional[callable]=None,...)->Dict[str,Any]:"""Forwarder — see ``agent.conversation_loop.run_conversation``."""fromagentimportrelay_runtimefromagent.conversation_loopimportrun_conversation...# 1. 会话协调器加锁,防止同一 session 并发跑relay_lease=relay_runtime.SESSION_COORDINATOR.acquire_conversation(...)relay_turn=relay_runtime.SESSION_COORDINATOR.begin_turn(...)# 2. 发布 portal 标签 + 记账上下文token=set_conversation_context(self._conversation_root_id())acct_token=set_accounting_context(...)# 3. 真正调对话引擎withbind_subagent_parent(self),scoped_runtime_main({}):result=run_conversation(self,user_message,...)returnresult

__init__同理,60 多个参数原样透传给agent/agent_init.pyinit_agent这就是"门面模式"在大型 Agent 项目里的真实用法:接口定义和契约留在门面层,行为实现放到可独立测试的模块里,所有入口(CLI/gateway/desktop)复用同一套对话引擎。

三、分层架构:五层职责拆解

按职责可以把整条链路切成五层:

第 1 层:门面层(run_agent.py)

对外暴露AIAgent类,三个身份:

  • 库入口:from run_agent import AIAgent
  • 符号重导出锚点:让mock.patch("run_agent.X")的测试不崩
  • CLI 入口:文件末尾fire.Fire(main)支持python run_agent.py --query=...

有一个很讲究的设计:fire只在__main__块里 import,不在模块顶部:

# run_agent.py 顶部注释# NOTE: `fire` is ONLY used in the `__main__` block below ...# It is imported there, not here, so that importing run_agent from a# daemon thread (e.g. curator's forked review agent) never fails with# ModuleNotFoundError on broken/partial installs where `fire` isn't present.

这是"库/CLI 双身份"的经典取舍:import 成本高的、可能缺失的依赖,延迟到真正需要时才加载。

第 2 层:初始化层(agent/agent_init.py)

init_agent(第 459 行)负责把 60+ 个参数变成一份可运行的 agent 状态:模型路由、provider 识别、凭据池、工具集装配、客户端构建。注意它签名里max_iterations: int = 90——这是工具调用迭代的硬上限,后面会看到它和软预算的配合。

第 3 层:每轮前置(agent/turn_context.py)

build_turn_context(第 343 行)是"每轮一次"的 prologue,全部集中在一个函数里,避免污染主循环:

# agent/turn_context.py:343defbuild_turn_context(agent,user_message,system_message,...)->TurnContext:# 1. 防 broken pipe 的 stdio 守卫(daemon/headless 场景)install_safe_stdio()# 2. 恢复因压缩轮换的会话recovered_history=recover_rotated_compression_session(agent)# 3. 给本线程日志打 session id 标签set_session_context(agent.session_id)# 4. 恢复主运行时(上一轮可能激活了 fallback)agent._restore_primary_runtime()# 5. 通知 auxiliary_client 当前生效的 provider/modelset_runtime_main(...)

把"每轮要做的杂事"收拢成一个TurnContext返回,主循环只读结果,这是把前置逻辑和循环逻辑解耦的关键手法。

第 4 层:对话主循环(agent/conversation_loop.py)

核心,下面第四节单独展开。

第 5 层:工具执行 + 收尾(agent/tool_executor.py+agent/turn_finalizer.py)

工具执行有并发、串行、分段三种执行器;finalize_turn(第 7500 行调用)负责把循环结果组装成标准 dict:

returnfinalize_turn(agent,final_response=final_response,api_call_count=api_call_count,interrupted=interrupted,failed=failed,messages=messages,...)

四、核心机制:四个值得抄的工程实践

机制 1:双层预算的主循环

这是整个对话引擎的心脏,在conversation_loop.py第 1540 行:

while(api_call_count<agent.max_iterationsandagent.iteration_budget.remaining>0)oragent._budget_grace_call:# 1. drain redirect:用户 /redirect 纠正方向# 2. 检查 interrupt_requested:用户发新消息打断# 3. 消费 iteration budget# 4. 构建请求 → API 调用(内层还有 retry 循环)# 5. 按 finish_reason 分叉:stop / tool_calls / length / content_filter# 6. tool_calls → 工具执行 → 追加结果 → continue# 7. stop → 最终回复 → break

两层循环要理解清楚:

  • 外层while管 tool-calling 迭代次数,由max_iterations(硬上限)和iteration_budget(软预算)双重兜底;
  • 内层while retry_count < max_retries(第 2305 行)管单次 API 调用的重试,处理限流、fallback、凭据刷新、指数退避。

还有一个我很喜欢的细节:预算可以退。如果这一轮模型只调了execute_code这种 RPC 式的便宜调用,就把预算退回去:

# conversation_loop.py:6590 附近_tc_names={tc.function.namefortcinassistant_message.tool_calls}if_tc_names=={"execute_code"}:agent.iteration_budget.refund()

机制 2:段式工具调度(并行安全分段)

run_agent.py_execute_tool_calls(第 7632 行)是段式调度的入口,核心规划逻辑在tool_dispatch_helpers.py_plan_tool_batch_segments(第 116 行):

def_plan_tool_batch_segments(tool_calls,*,execution_cwd=None):"""把一批工具调用切成有序的 (kind, calls) 段。"""segments=[]reserved_paths=[]# (路径, 是否写) 的占位表fortool_callintool_calls:tool_name=tool_call.function.name# 交互式工具 → 顺序屏障iftool_namein_NEVER_PARALLEL_TOOLS:_add_sequential(tool_call);continue# 参数解析失败 → 顺序屏障try:function_args=json.loads(tool_call.function.arguments)exceptException:_add_sequential(tool_call);continue# 路径域工具:读读可并行,读写/写写冲突则关闭当前并行段iftool_namein_PATH_SCOPED_TOOLS:...ifany((is_writerorexisting_is_writer)and_paths_overlap(...)):_close_parallel()# 冲突,当前段结束reserved_paths.extend(...)current.append(tool_call);continue# 其余并行安全工具或 opt-in 的 MCP 工具 → 并行iftool_namein_PARALLEL_SAFE_TOOLSor_is_mcp_tool_parallel_safe(tool_name):current.append(tool_call);continue_add_sequential(tool_call)

这个设计的精妙之处在于用"路径占位表 + 读写角色"来判定并行安全:

  • read_file读同一个文件、两个search_files读同一子树,是 reader↔reader,可以并行(读操作可交换);
  • 只要涉及 writer(写文件、patch),并且目标路径和已占位的路径重叠,就关闭当前并行段,让冲突的调用排到前一段执行完之后。

这样既保住了"模型原始调用顺序"和"副作用边界",又能在安全子集里并发,把延迟打下来。对比那种 all-or-nothing 的"要么全并行、要么全串行"的粗粒度方案,这是一个明显的工程升级。

机制 3:合成消息标记(synthetic scaffolding)

这是整个代码库里最容易踩坑、也最见功底的地方。主循环里为了驱动内部重试,会往 messages 里注入一些"假消息"——空响应恢复、验证 nudge、kanban 收尾 nudge、dropped tool-call nudge。这些消息只用于驱动下一轮 API 调用,绝不能写进持久化 transcript,否则 resume 会话时会把这些内部指令当作用户上下文重放,污染会话。

解决办法是给每条假消息打标记,持久化层见到标记就剥掉:

# run_agent.py:234_EPHEMERAL_SCAFFOLDING_FLAGS=("_empty_recovery_synthetic","_empty_terminal_sentinel","_thinking_prefill","_verification_stop_synthetic","_pre_verify_synthetic","_kanban_stop_synthetic","_dropped_toolcall_nudge",)def_is_ephemeral_scaffolding(msg:Any)->bool:returnisinstance(msg,dict)andany(msg.get(flag)forflagin_EPHEMERAL_SCAFFOLDING_FLAGS)

标记用_前缀不是随手写的,注释里讲得很清楚:wire sanitizer 会在请求离开进程前剥掉所有顶层_前缀 key,所以这些内部字段永远不会泄漏到严格的 OpenAI 兼容网关。

这是开发 AIAgent 最有价值的一条实践:凡是"驱动内部状态机的消息"和"真实的对话历史",必须用显式标记区分,并且持久化层要能识别并剔除前者。不做这一步,你的 agent 一旦支持 resume,就会出诡异的"重放污染"bug。

机制 4:错误分类,避免白烧重试预算

主循环外层有个巨大的except,但它不是无脑重试,而是先分类(第 7405 行):

exceptExceptionase:# 通过 traceback 模块名判断:本地处理错误 vs API 错误tb_module_names=set()_tb=e.__traceback__while_tbisnotNone:tb_module_names.add(os.path.splitext(os.path.basename(_tb.tb_frame.f_code.co_filename))[0])_tb=_tb.tb_next _hit_local=bool(tb_module_names&_LOCAL_PROCESSING_MODULES)_hit_api=bool(tb_module_names&_API_CALL_MODULES)_is_local_processing_error=_hit_localandnot_hit_api# 本地 bug 是确定性的,重试必失败 → 立即停,不烧预算if_is_local_processing_errororapi_call_count>=agent.max_iterations-1:...break

判断逻辑是:看异常 traceback 有没有穿过已知的"本地后处理模块"(比如把多模态 content 塞进正则导致的 bug),如果没穿过任何 API 调用模块,那几乎可以断定是本地 bug——重试必败,直接停。这个区分把"上游 API 抖动(值得重试)"和"自己的确定性 bug(不值得重试)"分开,省下宝贵的迭代预算。

五、真实旅程复盘:一条消息的完整生命周期

光读代码不够,我用sqlite3查了本机真实的状态库,拿一条带工具调用的会话验证主循环的轮次。先看会话统计:

$ sqlite3 ~/.hermes/state.db"SELECT id, source, message_count, tool_call_count, input_tokens, output_tokens FROM sessions WHERE tool_call_count > 0 ORDER BY id DESC LIMIT 3;"20260823_103542_f20b5f|cli|50|29|69465|1261520260823_103334_372047|cli|13|5|27428|126220260823_094705_523adb|cli|4|1|140|70

取最后一条(最干净,只有 1 次工具调用、4 条消息)看 role 序列:

$ sqlite3 ~/.hermes/state.db"SELECT role, tool_name, substr(replace(content, char(10),' '), 1, 60) FROM messages WHERE session_id='20260823_094705_523adb' ORDER BY id;"user||用 terminal 工具执行echotool-ok 并把输出原样告诉我 assistant||tool|terminal|{"output":"tool-ok","exit_code":0,"error":null}assistant||tool-ok

这个 role 序列user → assistant(空) → tool → assistant正好对应主循环的完整轮次:

  1. user消息进入,build_turn_context完成前置;
  2. 第一次 API 调用,模型返回finish_reason=tool_calls,assistant 消息内容为空、带tool_calls(这就是为什么第一条 assistant 内容为空);
  3. 主循环走工具执行分支,调terminal工具,结果以role=tool追加进 messages;
  4. 循环回到顶部,continue发起第二次 API 调用,这次模型拿到工具结果,返回finish_reason=stop,输出最终回复tool-ok,循环 break。

持久化证据和源码读到的循环轮次一一对应,这就是"真实输出铁律"在源码分析里的落地:不只贴 stdout,还贴数据库里的 ground truth。

六、总结:设计取舍与"最值得抄的三件事"

设计取舍对照表

设计点Hermes 的做法通用启示
多入口共享引擎门面 + 实现分离,forwarder 转发到agent/接口定义与行为实现分层,改行为不动契约
死循环防护max_iterations硬上限 +iteration_budget软预算双层预算,软预算还能按调用成本退款
工具并行安全路径占位表 + 读写角色判定分段用"资源占用 + 读写语义"判定并发安全,优于粗粒度开关
内部状态机消息_前缀 synthetic 标记,持久化层剔除显式区分"驱动重试的假消息"和"真实历史"
重试策略traceback 模块名分类本地 bug vs 上游错误确定性错误不重试,只重试值得重试的
库/CLI 双身份重依赖延迟到__main__才 import延迟加载,守护线程 import 不崩

边界与代价(诚实说明)

  • 门面 + 重导出层是有代价的:8206 行里大量是的兼容代码,新人容易误以为逻辑在这里,实际改这里多半不生效或只影响一个调用路径。要改行为,必须定位到agent/包的实现。
  • 段式调度只优化了"工具执行"这一段的并发,API 调用本身(单请求)仍然是串行的,模型生成的延迟没有并行化的空间。
  • 本文行号基于 Hermes v0.20.0(2026-08 实测),版本更新后行号会漂移,引用前先重新定位。

最值得抄的三件事

  1. 门面 + forwarder 的架构:让 CLI/gateway/desktop 共享同一对话引擎,同时保住历史测试的 patch 写法。
  2. synthetic 标记机制:任何"驱动内部重试的假消息"都必须显式标记并在持久化前剔除,否则 resume 会话会中毒——这是 Agent 项目里最容易忽视、后果最隐蔽的坑。
  3. 段式工具调度:用路径占位表和读写角色判定并行安全,而不是无脑全并行或全串行,这是工具调用延迟优化的正确姿势。
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/24 21:27:14

【multisim仿真】D类放大器电路设计(仿真图)

前言TDA2030 是一款常用的集成功放芯片&#xff0c;很多同学容易混淆&#xff1a;TDA2030 属于 AB 类功放&#xff0c;并不是 D 类放大器&#xff0c;图中标注 D 类放大器属于常见误区。本文基于 Multisim 搭建 TDA2030 单电源双电源均可工作的 OCL 功率放大电路&#xff0c;完…

作者头像 李华
网站建设 2026/8/24 21:26:51

图吧工具箱 TubaWinUi3:82款硬件检测工具装进一个WinUI 3应用

图吧工具箱 TubaWinUi3&#xff1a;82款硬件检测工具装进一个WinUI 3应用 【免费下载链接】tubatools 图吧工具箱 winUI3 版 项目地址: https://gitcode.com/gh_mirrors/tu/tubatools 电脑变慢、新硬件到手要验货、装机后不知道瓶颈在哪——这些时刻的共同解药&#xff…

作者头像 李华
网站建设 2026/8/24 21:24:23

IEC 61499与边缘计算:下一代分布式工业控制架构实践

一、IEC 61499标准概述 IEC 61499是国际电工委员会发布的分布式工业过程测量与控制系统标准&#xff0c;定义了一种基于功能块&#xff08;Function Block&#xff09;的事件驱动控制架构。与传统IEC 61131-3&#xff08;PLC编程语言标准&#xff09;相比&#xff0c;IEC 61499…

作者头像 李华
网站建设 2026/8/24 21:23:30

【multisim仿真】JK触发器显示0123循环电路

前言最近用 74LS112 下降沿 JK 触发器搭建同步二进制计数器&#xff0c;原本目标是 3 位八进制计数器&#xff0c;但 Multisim 仿真实际现象为0、1、2、3 循环&#xff0c;只能计到 3&#xff0c;无法输出 4~7。本文结合电路图分析故障现象、原理推导、错误定位与修正方案&…

作者头像 李华
网站建设 2026/8/24 21:23:23

昆明燃气/电热水器维修上门服务-欧米到家持证师傅正规检修|深度排查不出热水漏水忽冷忽热等故障

冬季洗澡水温异常、热水器漏水、不出热水、运行异响、水温忽冷忽热&#xff0c;是昆明家庭热水器最常见的故障。很多用户网上找维修师傅&#xff0c;容易遭遇小病大修、隐形收费、劣质配件等问题。市面上多数维修内容内容零散、实用性差&#xff0c;无法适配昆明高原气压、水质…

作者头像 李华
网站建设 2026/8/24 21:23:08

智能体操作系统:构建AI自动化应用的基础设施与工程实践

如果你是一名开发者&#xff0c;最近一定被各种“AI智能体”刷屏了。从能自动写代码的Devin&#xff0c;到能联网处理任务的GPTs&#xff0c;再到各种宣称能“一键自动化”的Agent框架&#xff0c;似乎AI已经准备好接管一切。但当你真正想用它们来解决一个具体问题&#xff0c;…

作者头像 李华