Hermes 101|02|Agent Loop

Agent Loop 不是一段简单 while 循环,而是 Agent Runtime 里的状态机:它要处理消息协议、工具配对、并发执行、打断、压缩、失败恢复、预算和观测面。

Hermes 101|02|Agent Loop

第一篇我们把 Hermes 看成一个 Agent Runtime。第二篇开始拆核心:Agent Loop。

很多人讲 Agent Loop 时,会把它简化成一段很短的伪代码:把用户消息发给模型;如果模型要调用工具,就执行工具;把工具结果发回模型;直到模型输出最终回答。这个模型没有错,但它太干净。真实系统里的 Agent Loop 更像一条运行时管线。它要处理消息格式、工具调用配对、并发工具、用户打断、上下文压缩、模型失败、预算耗尽、状态保存、平台回调,以及不同 provider 之间的协议差异。

Hermes 的 AIAgent 正好提供了一个完整样本。它仍然有一个“模型-工具-模型”的循环,但循环外面包了很多工程边界。少了这些边界,Agent 可以 demo;有了这些边界,Agent 才能长时间工作。

读完本文,你应该能回答

  • 为什么 Agent Loop 不能只写成一个 while 循环?
  • 一次用户请求在 Hermes 里会经历哪些运行时阶段?
  • tool_call 与 tool_result 为什么必须严格配对?
  • 什么时候应该压缩、重试、保存或终止循环?

本篇在系列中的位置

上一篇建立了 Agent Runtime 的总图,本篇进入核心执行路径:一次任务如何在模型调用和工具调用之间推进。下一篇会把 loop 里的“工具执行”单独拆出来,看 Tool Runtime 如何把模型意图变成受控行动。

贯穿案例

贯穿这个系列,可以一直带着同一个任务来读:用户说“帮我修复这个 repo 里的 failing tests”。不同章节会回答同一个任务在运行时的不同问题:入口怎样进入、上下文怎样准备、模型怎样决定下一步、工具怎样执行、状态怎样保存、失败后怎样恢复。

最小循环为什么不够

最小 Agent Loop 大概长这样:

while (true) {
  const response = await model.call(messages, tools)
  messages.push(response)

  if (!response.toolCalls.length) {
    return response.text
  }

  for (const call of response.toolCalls) {
    const result = await tools.execute(call)
    messages.push({ role: "tool", tool_call_id: call.id, content: result })
  }
}

这段代码适合解释 function calling 的基本控制流,但它故意忽略了几个麻烦问题。

第一,模型返回的不是“指令”,而是一段协议消息。不同 provider 对消息顺序、工具调用字段、reasoning 字段、streaming delta 的要求不同。你的 loop 必须把它们折叠成一种内部格式,否则每个分支都会长出一套工具执行逻辑。

第二,工具调用不是普通函数调用。工具会读写文件、启动进程、访问网络、修改外部系统。它们需要权限、审批、进度回调、错误封装和结果裁剪。

第三,循环可能永远不结束。模型可能反复调用工具,工具可能返回无效 JSON,provider 可能报 429,用户可能中途发 /stop,上下文可能在第十轮后爆掉。Agent Loop 必须定义这些情况的控制流,而不是把它们留给“异常处理”。

Agent Loop 的真实边界

所以更准确的说法是:Agent Loop 是一个状态机。while 循环只是它的骨架。

Agent Loop 状态表

把 Agent Loop 看成状态机之后,很多边界会更清楚。

状态 进入条件 退出条件 失败风险
Prepare Turn 收到用户消息或恢复 session system prompt、messages、tools 准备完成 上下文缺失、工具暴露错误
Preflight 即将调用模型 token、预算、压缩检查通过 历史过长、tool 配对破坏
Model Call provider request 已构造 assistant message 返回 429、超时、协议差异
Tool Dispatch assistant 返回 tool_calls 所有 tool result 回填 权限拒绝、并发失败、结果过长
Continue tool result 已进入 messages 下一轮 model call 或压缩 循环失控、重复调用工具
Finalize assistant 无 tool_calls 保存 session、返回用户 未保存状态、平台投递失败

这张表解释了为什么真实 Agent Loop 不能只写成 while(true)。循环本身很短,难的是每个状态的进入条件、退出条件和失败恢复。

Hermes 的一轮执行

Hermes 的核心入口是 AIAgent.run_conversation()。从外部看,它接收用户消息,返回最终回答。从内部看,它做的是一次完整的 turn lifecycle。

一次 turn 的生命周期

这条路径里有几个关键点。

用户消息不会直接被发给模型。Hermes 先创建或恢复 session,绑定日志上下文,设置当前 task id,清理上一次 provider 故障留下的连接,恢复 todo / memory nudge 状态,然后才把 user message 追加到内部消息列表里。

系统提示词也不是每次都重建。Hermes 会缓存 system prompt。继续会话时,它优先从 session store 取上一次保存的 system prompt,而不是读取最新 memory 再拼一遍。原因很现实:频繁改变系统提示词会破坏 prompt cache,也会让模型在同一个 session 里看到不一致的前缀。只有压缩等事件需要刷新时,系统提示词才会重新构造。

进入模型调用前,Hermes 会做 preflight compression 判断。如果上下文压力太高,中间历史会被压缩,最近消息和关键 tool call / tool result 配对会被保护。压缩不是 Agent Loop 的附属功能,它直接决定下一次模型调用能不能成立。

然后才是模型调用。Hermes 支持多种 API mode:OpenAI-compatible Chat Completions、OpenAI Codex Responses、Anthropic Messages。它们在外部协议上不同,但在 Agent Loop 内部会被整理成 OpenAI 风格的 messages:systemuserassistanttool

模型返回后,loop 分两条路:

  • 如果有 tool calls,Hermes 执行工具,把结果按协议追加回 messages,然后继续下一轮模型调用。
  • 如果没有 tool calls,Hermes 保存 session,刷新 memory,返回最终文本。

这就是表面的循环。但真正让它能工作的,是每一步周围的控制面。

消息顺序是硬约束

很多 Agent bug 看起来像“模型不听话”,实际是消息历史坏了。

对 tool calling 来说,消息顺序不是随便的聊天记录。一个 assistant 消息如果包含 tool_calls,后面必须有对应的 tool 消息。tool 消息必须带回正确的 tool_call_id。并行工具可以产生多个连续的 tool 消息,但它们仍然要和前一个 assistant 消息里的 tool_calls 对齐。

tool_call 与 tool_result 的配对

Hermes 文档里把这个约束写得很明确:系统消息之后,普通对话应该维持 User → Assistant 的交替;工具调用期间是 Assistant(with tool_calls) → Tool → Tool → Assistant。不能出现两个 assistant 连在一起,也不能出现两个 user 连在一起。只有 tool role 可以连续出现,因为多个工具结果可能属于同一个 assistant tool call 批次。

这个细节容易被低估。比如一个工具返回的是图片内容,而当前 provider 不支持 image_url。你不能简单删除这个 tool 消息,因为删除后前一个 assistant 的 tool_call 就没有匹配结果了。Hermes 的做法是把内容替换成文本占位符,保留 tool_call_id 链接,让消息历史仍然合法。

把这个规则抽象成类型,可以写成这样:

type AssistantToolMessage = {
  role: "assistant"
  content?: string
  tool_calls: Array<{
    id: string
    function: { name: string; arguments: string }
  }>
}

type ToolResultMessage = {
  role: "tool"
  tool_call_id: string
  name?: string
  content: string
}

function appendToolResults(
  messages: Message[],
  assistant: AssistantToolMessage,
  results: ToolResultMessage[],
) {
  const expected = assistant.tool_calls.map(call => call.id)
  const actual = results.map(result => result.tool_call_id)

  assertSameSet(expected, actual)
  messages.push(assistant)
  messages.push(...sortByToolCallOrder(results, assistant.tool_calls))
}

真正的实现还要处理 provider 差异、streaming、工具失败、打断和平台回调。但底层不变:tool result 不是日志,它是协议的一部分。

工具执行也有调度策略

模型一次可能返回多个 tool calls。最简单的做法是顺序执行。但顺序执行会浪费时间,尤其是多个 web search、read_file、session_search 彼此独立的时候。

Hermes 在 run_agent.py 里有一组并发判断规则。clarify 这种交互式工具不能并发;只读工具通常可以并发;文件工具要看路径是否重叠;其它有副作用或共享状态的工具默认走顺序路径。通过这套规则,Hermes 可以在安全时并发,在不确定时保守。

工具执行的并发决策

这看起来像性能优化,其实也是语义保护。

如果两个工具同时写同一个文件,结果不可预测。如果一个工具在等用户澄清,另一个工具继续修改系统,用户看到的状态就会混乱。如果工具结果按完成时间追加,而不是按原始 tool_call 顺序追加,某些 provider 可能拒绝消息,模型也可能把结果对应错。

所以并发工具执行至少要满足三个条件:

function shouldParallelize(calls: ToolCall[]): boolean {
  if (calls.length <= 1) return false

  const names = calls.map(call => call.name)
  if (names.some(name => NEVER_PARALLEL.has(name))) return false

  const reservedPaths: string[] = []

  for (const call of calls) {
    if (PATH_SCOPED_TOOLS.has(call.name)) {
      const path = normalizePath(call.args.path)
      if (overlapsAny(path, reservedPaths)) return false
      reservedPaths.push(path)
      continue
    }

    if (!PARALLEL_SAFE_TOOLS.has(call.name)) return false
  }

  return true
}

Hermes 的并发路径还有一个重要约束:结果按原始 tool_call 顺序写回 messages,而不是按哪个工具先完成写回。这是 Agent Loop 的协议纪律,不是显示层选择。

有些工具不能只走 registry

理想情况下,所有工具都在统一 registry 里注册,Agent Loop 只负责派发。Hermes 也基本这样做:每个工具文件通过 registry.register() 自注册,model_tools.py 负责发现工具、生成 schema、执行 handler。

但 Hermes 也有一类 agent-level tools,会被 AIAgent 截获,而不是直接走普通 registry。比如 todomemorysession_searchdelegate_task。它们之所以特殊,是因为它们不是纯外部能力,而是会触碰 Agent 自己的运行时状态。

  • todo 读写当前 agent 的任务状态
  • memory 写入长期记忆,并可能通知外部 memory backend
  • session_search 查询当前 session DB,并排除不该混入的上下文
  • delegate_task 会创建子 agent,继承或隔离部分运行时配置

这说明 Agent Loop 不是“模型发工具名,runtime blindly dispatch”。它必须知道哪些工具属于执行环境,哪些工具属于 Agent 自身的控制面。

一个更接近 Hermes 的派发结构是这样:

async function invokeTool(call: ToolCall, agent: AgentRuntime): Promise<string> {
  const args = parseJsonOrEmpty(call.arguments)

  const blocked = await agent.plugins.beforeToolCall(call.name, args)
  if (blocked) return json({ error: blocked.message })

  const guardrail = agent.guardrails.beforeCall(call.name, args)
  if (!guardrail.allowed) return syntheticBlockedResult(guardrail)

  switch (call.name) {
    case "todo":
      return agent.todoStore.apply(args)
    case "memory":
      return agent.memory.write(args)
    case "session_search":
      return agent.sessionSearch.query(args)
    case "delegate_task":
      return agent.delegation.spawn(args)
    default:
      return agent.toolRegistry.dispatch(call.name, args)
  }
}

这个分层很重要。没有它,工具系统会变成一堆函数;有了它,工具系统才成为运行时的一部分。

API call 必须可打断

Agent Loop 里最难处理的不是工具,而是等待。

模型调用可能持续几十秒。用户可能在这期间改变主意,或者发 /stop。如果 API call 在主线程里阻塞,Agent 就无法及时响应。Hermes 的 _interruptible_api_call() 把实际 HTTP 请求放到后台线程,主线程同时等待三件事:response ready、interrupt event、timeout。

可打断模型调用

如果用户打断,Hermes 不会把半截响应塞进消息历史。它会丢弃那次响应,让 Agent 可以处理新的输入或干净退出。这一点很关键。半截 assistant message 如果进入 history,后面很容易出现两个问题:模型看到不完整状态;tool_call 配对关系被破坏。

可打断调用的伪代码大概是这样:

async function interruptibleCall(request: ModelRequest, signal: InterruptSignal) {
  const apiPromise = runInBackgroundThread(() => provider.call(request))

  while (true) {
    if (signal.interrupted) {
      apiPromise.abandon()
      return { type: "interrupted" }
    }

    if (apiPromise.done) {
      return { type: "response", value: apiPromise.result }
    }

    if (apiPromise.timedOut) {
      return { type: "timeout" }
    }

    await sleep(50)
  }
}

这不是为了用户体验的小优化,而是 Agent Runtime 的控制权问题。只要 Agent 会运行长任务,它就必须能被打断。

失败不是异常,是分支

真实 Agent Loop 里的失败路径很多。

provider 会返回 429、5xx、401/403。模型可能输出空内容。工具参数可能不是合法 JSON。Codex Responses 或 Anthropic Messages 可能有自己的停止原因。工具可能执行失败,但失败信息仍然要作为 tool result 回到模型,让模型决定下一步。

Hermes 把这些失败拆成几类处理:

  • API 失败可以触发 credential refresh 或 fallback provider
  • 无效 JSON / 无效工具调用有有限重试
  • 工具失败会作为结构化结果返回,而不是直接崩掉 loop
  • 上下文太长会触发压缩或上下文长度探测
  • 达到 iteration budget 后,会请求模型总结已完成工作

这说明 Agent Loop 的退出条件不是简单的“没有 tool calls”。更完整的状态机至少包含:done、needs_tools、retryable_error、fallback、compress、interrupted、budget_exhausted。

type LoopState =
  | { kind: "call_model" }
  | { kind: "execute_tools"; calls: ToolCall[] }
  | { kind: "compress_context" }
  | { kind: "retry"; reason: string }
  | { kind: "fallback_model"; provider: ProviderConfig }
  | { kind: "interrupted" }
  | { kind: "budget_exhausted" }
  | { kind: "done"; text: string }

把失败当成状态,系统才有机会恢复。把失败当成异常,Agent 往往只能停止。

iteration budget 是安全边界

Agent Loop 如果不受限制,就可能陷入工具调用循环。模型一次次搜索、读文件、修补,再继续搜索。某些任务确实需要很多轮,但没有上限是不负责任的。

Hermes 用 IterationBudget 控制每个 turn 的模型调用轮数。默认上限是 agent.max_turns。达到上限后,Hermes 不会继续盲跑,而是要求模型总结当前完成情况。如果模型仍然不给最终文本,系统会强制收束。

这里的重点不是“省 token”。iteration budget 是三个边界:

  • 成本边界:防止一次任务无限消耗模型调用
  • 时间边界:防止用户等一个永远不结束的循环
  • 副作用边界:防止 Agent 在不确定状态下继续执行工具

一个长期在线的 Agent,不能只相信模型会自己停下来。

Agent Loop 需要观测面

如果 Agent 只在终端里运行,print 日志勉强够用。但 Hermes 同时运行在 CLI、TUI、Gateway、ACP、Cron。不同入口需要不同反馈:终端要 spinner,消息平台要进度提示,IDE 要状态更新,Cron 要最终投递。

Hermes 在 AIAgent 上提供了多种 callback surface:tool progress、thinking、reasoning、clarify、step、stream delta、tool generation、status。Agent Loop 不直接关心 Telegram 或 IDE,但它会在关键节点发事件,让入口层决定怎样展示。

这就是“运行时”和“界面”的分离。Agent Loop 负责告诉外部:我开始思考了、我准备调用工具了、工具执行完了、我被打断了、我返回最终结果了。入口层负责把这些事件变成用户能理解的体验。

没有观测面,Agent 很容易变成黑盒。用户只看到它卡住,开发者也很难知道它卡在模型、工具、权限、网络还是上下文压缩。

一个更像生产系统的 Agent Loop

把上面的部分合起来,一个更接近生产系统的 Agent Loop 可以写成这样:

async function runConversation(input: UserInput, runtime: AgentRuntime) {
  const taskId = runtime.ids.newTaskId()
  const messages = await runtime.session.loadOrCreate()

  runtime.logs.bindSession(runtime.session.id)
  runtime.connections.cleanupStaleProviderSockets()
  runtime.state.hydrateFromHistory(messages)

  messages.push({ role: "user", content: input.text })

  let systemPrompt = await runtime.prompt.getStablePrompt()
  let apiCallCount = 0

  while (true) {
    if (!runtime.budget.consume()) {
      return await runtime.model.requestSummary(messages)
    }

    if (runtime.context.shouldCompress(messages)) {
      await runtime.memory.flush()
      const compressed = await runtime.context.compress(messages)
      messages.replaceWith(compressed.messages)
      systemPrompt = await runtime.prompt.refreshAfterCompression()
    }

    const request = runtime.adapters.toProviderRequest({
      systemPrompt,
      messages,
      tools: runtime.tools.schemas(),
    })

    const response = await runtime.model.callInterruptibly(request)

    if (response.interrupted) {
      return runtime.interrupts.cleanExit(messages)
    }

    if (response.retryableError) {
      const recovered = await runtime.recovery.retryOrFallback(response.error)
      if (recovered) continue
      throw response.error
    }

    const assistant = runtime.adapters.toInternalAssistantMessage(response)
    messages.push(assistant)
    apiCallCount += 1

    if (!assistant.tool_calls?.length) {
      await runtime.session.save(messages)
      await runtime.memory.flushIfNeeded()
      return assistant.content ?? ""
    }

    const results = await runtime.tools.executeBatch({
      calls: assistant.tool_calls,
      taskId,
      apiCallCount,
      preserveOrder: true,
    })

    messages.push(...results)
    await runtime.callbacks.step({ messages, apiCallCount })
  }
}

这段伪代码比最小 loop 长很多,但它仍然省略了不少细节。现实中的 Hermes 还要处理 prompt caching marker、reasoning content、credential pool、provider capability detection、streaming scrubber、plugin hooks、checkpoint、approval callback、subagent iteration budget、memory nudge、skill nudge、文件变更验证等。

这正是重点:Agent Loop 的复杂度不是偶然堆出来的。它来自真实使用场景里的约束。

mini-agent-harness 应该怎样实现这一层

后面这个系列会持续使用一个极简学习框架 mini-agent-harness。如果要为它实现第二篇讨论的 Agent Loop,不应该一开始就复制 Hermes 的全部复杂度。更合理的做法是保留结构,压低规模。

最小版本可以包含这些模块:

type MiniAgentHarness = {
  model: ModelClient
  tools: ToolRegistry
  session: SessionStore
  prompt: PromptBuilder
  context: ContextManager
  budget: IterationBudget
  callbacks: AgentCallbacks
}

第一版只需要支持一种 OpenAI-compatible tool calling 协议,一种本地 session store,一个同步工具执行器,一个简单的压缩占位接口。但接口要留对:messages 要保存 tool_call_id,tool registry 要区分 schema 和 handler,Agent Loop 要有 budget,模型调用要有可打断抽象,工具结果要按原始顺序回填。

学习框架的目标不是功能少,而是把关键边界暴露出来。

mini-agent-harness 的 Agent Loop

如果这个边界设计对了,后续加 memory、skills、gateway、cron、MCP、observability 都不会推翻 Agent Loop。它们只是接入同一个运行时状态机。

这篇的结论

Agent Loop 不是“问模型,调工具,再问模型”。那只是最外层的形状。

真正的 Agent Loop 至少要回答这些问题:

  • 内部消息格式是什么?
  • provider 协议差异在哪里被吸收?
  • tool_call 和 tool_result 怎样保证配对?
  • 哪些工具可以并发,哪些必须顺序?
  • 工具失败是异常还是可供模型读取的结果?
  • 用户打断时,正在进行的 API call 怎样处理?
  • 上下文太长时,是压缩、降级,还是失败?
  • 模型失败时,是重试、换 credential,还是 fallback provider?
  • 循环什么时候必须停止?
  • 状态何时保存,memory 何时 flush?
  • 外部界面怎样观察 loop 的进度?

Hermes 的答案不是唯一答案,但它很完整。它把 Agent Loop 从“演示工具调用的 while 循环”推进成一套可恢复、可观测、可约束的运行时状态机。

这也是 Agent Engineering 的核心变化:我们不再只是在写 prompt,而是在设计一个会把模型输出变成真实操作的软件系统。

参考资料