第2章:Agent Loop —— 让一个请求持续到任务完成
一、直接问模型为什么不够
直接调用模型只有一个动作:输入消息,得到文本。Agent 需要面对的是一个不确定的状态机:模型可能要求读文件、执行命令、调用浏览器、生成子任务,然后再根据结果决定下一步。真正的循环是:
用户输入
→ 组装可见上下文
→ 请求模型
→ 读取 assistant 文本与 tool calls
→ 执行工具并追加 tool result
→ 再次请求模型
→ 直到 stop / interrupt / failure / budget
Hermes 还在每一圈加入重试、provider failover、上下文压缩、空响应修复、消息交替修复、用量记账和流式显示。因此“循环”既是控制流,也是所有边界的汇合点。
二、核心对象:AIAgent 与 run_conversation
run_agent.py:409 的 AIAgent 更像“运行时容器”,而不是一个无状态服务。它持有的状态至少包括:
- 当前 provider、model、API 模式和请求参数;
- 当前
session_id、历史 messages、来源 platform 和 task id; ContextCompressor或其他ContextEngine;MemoryManager、工具注册表和 toolset 过滤结果;- 中断标志、当前工具、工作线程集合、回调和显示模式;
- 重试、计费、prompt cache、relay 和 session DB 相关状态。
run_agent.py:7153 的 AIAgent.run_conversation 负责设置一次运行的边界,agent/conversation_loop.py:1142 的 run_conversation 负责更细的请求循环。前者处理“这个 Agent 如何运行”,后者处理“这一轮如何推进”。
三、把一个 Turn 拆成六个阶段
阶段 1:确定运行身份
运行前要确定 session_source、cwd、profile、task id、当前模型和可用工具。CLI session 可以记录启动 cwd;Gateway 或远程后端则不能把宿主 cwd 当成 Agent 工具的 cwd。run_agent.py 的 _launch_cwd_for_session 明确区分了这一点。
阶段 2:准备系统提示词和历史
agent/system_prompt.py:152 的 build_system_prompt_parts 将身份、平台提示、工具说明、环境提示、技能清单、上下文文件和订阅/能力提示组合起来。conversation_loop.py 在真正发请求前还会:
- 去掉只用于持久化或显示的字段;
- 把外部记忆和插件注入放进当前 user message 的 API sidecar;
- 修复坏掉的 tool call JSON;
- 修复 role alternation 和孤立 tool result。
关键取舍是:干净 transcript 与发送到模型的 payload 可以不同。这样记忆或平台信息不会污染可搜索、可恢复的原始会话。
阶段 3:请求模型
模型调用不直接写死在 loop 里,而是落到 transport/adapter。loop 只消费统一的 assistant message、usage、finish reason 和错误分类。流式 delta 可以先进入显示和 relay,完整 assistant message 仍要进入历史,才能保证下一轮 API 请求有完整的 tool call。
阶段 4:处理 stop reason
概念上可以把停止信号分成四类:
| 信号 | 含义 | 后续动作 |
|---|---|---|
| 普通文本结束 | 模型认为任务已完成 | 返回结果 |
| tool calls | 模型要求外部世界发生动作 | 进入工具执行 |
| interrupted | 用户或上层取消 | 追加取消结果,安全收尾 |
| error / budget | 请求、配额或策略失败 | 分类、重试、failover 或结束 |
Hermes 不把异常都当成“停止”。某些 provider 的 401、超时、空响应、上下文过长或 billing block 需要不同的恢复策略。
阶段 5:执行工具
agent/tool_executor.py 提供 concurrent、sequential 和 segmented 三种入口。工具结果按模型给出的 tool call 顺序写回消息,即使执行实际使用线程池并发完成。这样既能利用并发,又不破坏 OpenAI/Anthropic 风格的成对消息约束。
阶段 6:决定是否再转一圈
工具结果写入后,循环再次检查:是否有 steering/redirect、是否需要压缩、是否达到 iteration budget、是否应执行 follow-up、是否收到中断。只有当模型给出可接受的终止状态,或者运行时明确拒绝继续时,Turn 才结束。
四、并发工具为什么不能简单 gather
Hermes 的并发执行还要处理:
- 同一批工具的显示顺序与消息顺序;
- 共享审批/授权 gate;
- 每个 worker 的中断传播和线程清理;
- checkpoint 需要在真正执行破坏性命令之前建立;
- tool output budget 和过长结果截断;
- 工具 middleware 的前置/后置回调。
execute_tool_calls_concurrent 中的 _begin_in_order 用条件变量约束“开始阶段”的顺序;worker 完成后把 (name, args, result, duration, error, blocked, trace) 放回原位置。这里体现一个很好的原则:并发是执行优化,不应改变对话协议的可观察顺序。
五、异常不是一个 except Exception
agent/error_classifier.py 把错误分类为可用于决策的原因;conversation_loop.py 还会对 stale credential、provider entitlement、上下文长度、图片尺寸、Copilot credential 等情况给出不同指导。这样的层次是必要的:
原始异常
→ provider/HTTP/SDK 细节
→ FailoverReason / billing / entitlement 分类
→ 用户可理解的提示
→ 是否重试、换 key、换 provider 或结束
如果只把错误字符串放回模型,模型可能会无限重试;如果只返回“失败”,用户又无法知道应该补 key、缩短图片还是降低上下文。
六、Loop 的四个可迁移结论
- 把 Turn 和 Trace 分开:一轮用户输入可以有多个 API call;一次 session 又可以包含很多 turn。
- 让终止由结构化信号驱动:不要用“文本里有没有 done”判断结束。
- 把副作用放在可拦截的边界:工具、模型请求、持久化和投递都需要 middleware 或回调。
- 保持干净状态与线缆状态分离:可显示/可恢复的 transcript 不等于每次实际发出的 payload。
下一章进入 Loop 的“翻译层”:同一套 Agent 如何对接不同 provider、认证方式和请求协议。