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 Loop

Agent 再把这个结果作为 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 内部工具例外,例如:

  • todo
  • memory
  • clarify
  • delegate_task
  • session_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()