Hermes Agent Loop 技术知识与执行流程
一句话理解
Hermes 的 agent loop 本质上是:
模型想一步,Hermes 执行一步;执行结果再交给模型;直到模型不再需要工具、可以直接回答用户。
它不是一个神秘的“智能循环”,更像一个不断往返的工作流:
用户提出问题
↓
Hermes 把问题和历史对话发给模型
↓
模型判断:
- 能直接回答:返回最终答案,循环结束
- 需要工具:返回 tool_calls
↓
Hermes 执行工具
↓
Hermes 把工具结果作为 tool message 放回对话
↓
再次调用模型生活化比喻
可以把 Hermes 想成“模型的执行秘书”。
模型负责思考:
我需要看文件。
我需要跑测试。
我需要查历史会话。
我需要让子 agent 处理一部分任务。Hermes 负责执行:
好,我去读文件。
好,我去跑命令。
好,我去查 session。
好,我去调 delegate_task。工具执行完以后,Hermes 会把结果告诉模型:
文件内容是……
测试结果是……
命令输出是……模型再继续判断下一步,直到给出最终回答。
核心代码位置
Hermes agent loop 的主入口:
run_agent.py
└── AIAgent.run_conversation()核心工具执行入口:
run_agent.py
├── _execute_tool_calls()
├── _execute_tool_calls_sequential()
├── _execute_tool_calls_concurrent()
└── _invoke_tool()普通工具分发入口:
model_tools.py
└── handle_function_call()主流程
1. 初始化本轮对话
run_conversation() 开始时会做几件事:
- 生成或接收
task_id - 重置各种 retry 计数器
- 创建本轮
IterationBudget - 拷贝历史对话
conversation_history - 把用户当前消息 append 到
messages
可以理解为:先准备一张新的“工作记录纸”。
2. 进入 agent loop
主循环大致长这样:
while api_call_count < self.max_iterations and self.iteration_budget.remaining > 0:
# 1. 准备 api_messages
# 2. 调用模型
# 3. 解析模型回复
# 4. 如果模型要调用工具,就执行工具
# 5. 如果模型给出最终答案,就结束真实代码里还多了一个 _budget_grace_call,用于在预算耗尽时给模型一次收尾机会。
3. 构造发给模型的消息
Hermes 内部维护的是 messages。
但每次真正发给模型前,会复制出一份 api_messages,并做一些处理:
- 注入 system prompt
- 注入 memory/context/plugin 提供的临时上下文
- 清理模型 API 不接受的内部字段
- 规范化 tool call 参数
- 处理 provider 特有格式,例如 reasoning 字段、prompt caching、Codex Responses 字段等
关键点:
messages是 Hermes 内部保存的真实对话状态;api_messages是每次发给模型的临时副本。
这样做可以避免污染历史记录,也能保护 prompt caching。
4. 调用模型
Hermes 把 api_messages 和可用工具 schema 发给模型。
模型可能返回两种结果。
第一种:普通文本。
这是最终答案……这种情况下,没有 tool_calls,循环结束。
第二种:工具调用。
{
"tool_calls": [
{
"function": {
"name": "terminal",
"arguments": "{\"command\":\"scripts/run_tests.sh\"}"
}
}
]
}这种情况下,Hermes 不会直接结束,而是进入工具执行阶段。
5. 校验工具调用
执行工具前,Hermes 会先做安全和格式检查:
- 工具名是否存在
- 工具参数是不是合法 JSON
- 是否需要修复工具名
- 是否有重复工具调用
- 是否超过 delegate_task 限制
- 是否被 plugin hook 阻止
如果工具名不存在,Hermes 会把错误作为 tool result 返回给模型,让模型自己修正。
也就是说,模型调用错工具时,Hermes 不会马上崩掉,而是给模型一次“你调错了,请重试”的反馈。
6. 执行工具
工具执行入口是:
_execute_tool_calls(assistant_message, messages, effective_task_id, api_call_count)它会判断这批工具能不能并行:
- 如果工具之间相互独立,可以走
_execute_tool_calls_concurrent() - 否则走
_execute_tool_calls_sequential()
并行执行常用于读文件、搜索等互不冲突的工具。
顺序执行更适合会改变状态的工具,比如写文件、patch、terminal 等。
7. 工具结果回填给模型
工具执行完以后,Hermes 会把结果 append 回 messages:
{
"role": "tool",
"content": function_result,
"tool_call_id": tool_call.id,
}这一步非常关键。
模型并不会自动知道工具执行结果。Hermes 必须把结果作为新的上下文发回给模型。
于是下一轮模型看到的是:
用户问题
模型:我要调用工具
工具结果:测试失败,错误是……模型再基于这个结果继续思考。
8. 重复循环,直到结束
如果工具结果还不够,模型会继续请求工具。
例如:
模型:我要读失败测试对应的源码文件。
Hermes:读取文件。
模型:我要修改代码。
Hermes:写文件或 patch。
模型:我要重新跑测试。
Hermes:跑测试。
模型:测试通过,可以回答用户。当模型最终不再返回 tool_calls,而是返回普通文本,Hermes 就把这个文本作为最终回答。
一个完整例子
用户说:
帮我看看为什么测试失败流程可能是:
第 1 轮
用户 → Hermes → 模型
模型:我要跑 scripts/run_tests.sh
第 2 步
Hermes 执行 terminal 工具
工具结果:test_xxx failed,错误在 foo.py:42
第 2 轮
Hermes 把工具结果发给模型
模型:我要读取 foo.py
第 3 步
Hermes 执行 read_file 工具
工具结果:foo.py 内容……
第 3 轮
Hermes 把文件内容发给模型
模型:我知道问题了,需要修改 foo.py
第 4 步
Hermes 执行 patch 工具
工具结果:修改成功
第 4 轮
Hermes 把修改结果发给模型
模型:我要重新跑测试
第 5 步
Hermes 执行 terminal 工具
工具结果:测试通过
第 5 轮
Hermes 把测试结果发给模型
模型:最终回答用户Mermaid 流程图
flowchart TD A["用户输入"] --> B["run_conversation 初始化本轮状态"] B --> C["进入 while agent loop"] C --> D["构造 api_messages"] D --> E["调用模型"] E --> F{"模型返回 tool_calls?"} F -- "否" --> G["返回最终回答"] F -- "是" --> H["校验工具名和 JSON 参数"] H --> I{"校验通过?"} I -- "否" --> J["把错误作为 tool message 回填给模型"] J --> C I -- "是" --> K["_execute_tool_calls 执行工具"] K --> L["工具结果 append 到 messages"] L --> M{"需要压缩上下文或处理预算?"} M --> C
例子:写入一个文件
假设用户说:
我需要写入文件到 /tmp/hello.txt,内容是 hello这句话不会让 LLM 自己直接写文件。LLM 只是读到需求,然后基于 agent 提供的工具列表,决定下一步需要调用写文件工具。
第一次模型调用:
Agent → LLM:
用户想写入文件。
你现在可用的工具里有 write_file。
请决定下一步。LLM 可能返回一个结构化的工具调用请求:
{
"tool_calls": [
{
"id": "call_123",
"function": {
"name": "write_file",
"arguments": "{\"path\":\"/tmp/hello.txt\",\"content\":\"hello\"}"
}
}
]
}这个返回值的意思是:
我要调用 write_file 工具。
参数是 path=/tmp/hello.txt,content=hello。然后 agent 脚本进入判断:
response = call_llm(messages, tools)
if response.tool_calls:
for tool_call in response.tool_calls:
name = tool_call.function.name
args = json.loads(tool_call.function.arguments)
result = handle_function_call(name, args)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result,
})真正写文件的是本地工具函数,不是 LLM 本体:
write_file(path="/tmp/hello.txt", content="hello")工具执行完以后,可能返回:
{
"success": true,
"path": "/tmp/hello.txt",
"bytes_written": 5
}Agent 会把这个结果作为 tool 消息放回 messages:
{
"role": "tool",
"tool_call_id": "call_123",
"content": "{\"success\":true,\"path\":\"/tmp/hello.txt\",\"bytes_written\":5}"
}然后第二次调用 LLM:
Agent → LLM:
刚才 write_file 工具执行成功。
结果是:写入 /tmp/hello.txt,写入 5 字节。
请继续。LLM 这次看到工具已经成功,就返回最终自然语言:
已经写入 /tmp/hello.txt。于是 agent loop 结束。
完整链路:
用户
↓
Agent
↓
LLM 返回 tool_calls
↓
Agent 判断并调用 write_file
↓
本地文件系统完成写入
↓
Agent 把工具结果发回 LLM
↓
LLM 生成最终回答
↓
Agent 回复用户例子:通过 Obsidian CLI 写入笔记
假设用户说:
把 agent loop 的知识写进 Obsidian如果当前环境有 obsidian CLI,并且 agent 暴露了 terminal 工具,那么 LLM 可能会决定:
我需要调用 terminal 工具,执行 obsidian create。它返回的工具调用可能类似:
{
"tool_calls": [
{
"id": "call_obsidian_1",
"function": {
"name": "terminal",
"arguments": "{\"command\":\"obsidian create name=\\\"Hermes Agent Loop\\\" content=\\\"# Hermes Agent Loop\\\\n...\\\"\"}"
}
}
]
}Agent 收到后,不是把这段 JSON 当作回答展示给用户,而是进入工具执行流程:
Agent 解析 tool_calls
↓
发现工具名是 terminal
↓
解析 command 参数
↓
在本机 shell 中执行 obsidian create ...如果命令成功,本机 shell 返回:
Created note: Hermes Agent LoopAgent 再把这个结果作为 tool message 回填给 LLM:
{
"role": "tool",
"tool_call_id": "call_obsidian_1",
"content": "Created note: Hermes Agent Loop"
}然后 LLM 基于这个工具结果生成最终回复:
已经写入 Obsidian。所以 Obsidian CLI 这条链路是:
用户:写进 Obsidian
↓
Agent 把任务 + 工具列表发给 LLM
↓
LLM 返回:我要用 terminal 执行 obsidian create
↓
Agent 调用 terminal 工具
↓
terminal 调用本机 obsidian CLI
↓
Obsidian CLI 写入 vault
↓
terminal 返回执行结果
↓
Agent 把结果发回 LLM
↓
LLM 生成最终回答如果环境中没有 obsidian CLI,agent 需要根据实际情况调整策略。例如这次实际执行时,obsidian 命令不在 PATH 中,所以流程变成:
Agent 检查:which obsidian
↓
发现没有 obsidian CLI
↓
读取 Obsidian 本地配置
↓
找到 vault 路径
↓
直接把 Markdown 文件写入 vault 目录
↓
返回用户:已写入,但不是通过 obsidian CLI 命令完成这也说明 prompt 里为什么不能假设运行环境固定。
贯通版:中间到底发生了什么
用 Obsidian 例子串起来看,完整过程不是:
用户 → LLM → Obsidian而是:
用户
↓
Agent 程序
↓
LLM
↓ 返回 tool_calls
Agent 程序
↓ 调用 terminal/write_file 等工具
本机系统 / Obsidian CLI / 文件系统
↓ 返回执行结果
Agent 程序
↓ 把工具结果发回 LLM
LLM
↓ 生成最终回答
Agent 程序
↓
用户这里有两次关键的 LLM 调用:
第一次 LLM 调用:
LLM 决定“我要用工具”。
第二次 LLM 调用:
LLM 看完工具结果,决定“我可以回复用户了”。三者分工:
LLM:负责判断下一步该做什么。
Agent:负责解析 tool_calls、校验参数、调用工具、维护循环。
Tool:负责真实执行,例如读写文件、运行命令、调用 Obsidian CLI。和普通函数调用的区别
普通函数调用通常是:
调用函数 → 得到结果 → 结束Agent loop 是:
调用模型 → 模型决定下一步 → Hermes 执行 → 结果回给模型 → 模型再决定下一步所以 agent loop 的关键不是某一个工具,而是这个“模型决策 + 工具执行 + 结果回填”的闭环。
为什么需要预算和中断
如果不加限制,模型可能一直调用工具,导致死循环或成本失控。
所以 Hermes 有几个保护:
max_iterations:最多循环多少轮IterationBudget:本轮可用预算_interrupt_requested:用户打断时退出循环- retry counter:避免无效工具名、坏 JSON、空回复无限重试
- context compression:上下文太长时压缩历史
这些机制保证 agent 能持续工作,但不会无限跑下去。
工具分发机制
普通工具最终会走:
handle_function_call()它会调用:
registry.dispatch(function_name, function_args, ...)也就是说,工具不是写死在 agent loop 里的。
工具通过 registry 注册,模型只要返回对应工具名,Hermes 就能通过 registry 找到具体实现。
少数 agent 内部工具例外,例如:
todomemoryclarifydelegate_tasksession_search
这些工具需要访问 agent 内部状态,所以在 run_agent.py 中有特殊处理。
记忆口诀
模型负责想,
Hermes 负责做,
做完告诉模型,
模型继续想。再压缩成一句:
Agent loop = LLM 决策循环 + 工具执行循环 + 消息回填循环。
延伸阅读入口
在 Hermes 项目中可以优先看这些位置:
/Users/huapai/OpenSourceProject/hermes-agent/run_agent.py
- AIAgent.run_conversation()
- AIAgent._execute_tool_calls()
- AIAgent._execute_tool_calls_sequential()
- AIAgent._execute_tool_calls_concurrent()
- AIAgent._invoke_tool()
/Users/huapai/OpenSourceProject/hermes-agent/model_tools.py
- handle_function_call()
/Users/huapai/OpenSourceProject/hermes-agent/tools/registry.py
- registry.register()
- registry.dispatch()