数据截至 (上游 commit 4ac938ddecce)
一轮对话是怎么跑完的 —— 主循环与模型接入
30 秒导读: 你敲一句话回车,Hermes 要跑的不是"一次模型调用",而是一个循环:调模型 → 模型要工具 → 执行工具 → 把结果塞回去 → 再调模型……直到模型不再要工具为止。这一章讲清这个循环的边界在哪(预算/中断/退出原因),以及"调模型"这一步在厂商各不相同、网络还会抖的现实里是怎么活下来的。
本章不讲上下文压缩的算法(见 02-context-engineering),也不讲具体工具怎么实现(见 04-tools-and-environments)。本章只管节拍器和电源线。
1. 先建立直觉:一轮 ≠ 一次 API 调用
1.1 名词先对齐
三个词在本章有严格区分,不要混用:
| 词 | 含义 | 代码里的量 |
|---|---|---|
| 一轮(turn) | 用户发一条消息 → Hermes 给出一个最终答复 | run_conversation() 的一次调用 |
| 一次迭代(iteration) | 循环体跑一趟 = 一次模型 API 调用 + 可能的一批工具 | api_call_count 加一 |
| 一次重试(retry) | 同一次迭代内,因为报错/响应无效而重发请求 | retry_count 加一 |
一轮里有很多次迭代,一次迭代里可能有很多次重试。预算管的是迭代,不是重试 —— 这是理解整个循环的关键。
1.2 一轮长什么样
一次典型的"帮我改个 bug"是这样跑的:
用户: "修一下 utils.py 里的除零崩溃"
│
├─ 迭代 1 → 模型说:我要 read_file(utils.py) → 执行工具 → 结果回填
├─ 迭代 2 → 模型说:我要 patch(utils.py, ...) → 执行工具 → 结果回填
├─ 迭代 3 → 模型说:我要 terminal("pytest") → 执行工具 → 结果回填
└─ 迭代 4 → 模型不要工具了,直接出文字 → 循环结束,这是最终答复
循环的唯一正常出口是"模型这次没要工具、给了文字"。其它所有出口(预算耗尽、被打断、模型连续返回空、护栏刹车)都是异常出口,Hermes 会给它们各自留一个 _turn_exit_reason 字符串,后面第 7 节有完整对照表。
2. 顶层全景
2.1 三段式:开场 → 循环 → 收尾
run_conversation 是一个约 6,600 行的函数(agent/conversation_loop.py:1766-8410),但骨架只有三段。看懂这三段,后面所有细节都有地方挂。
run_conversation() agent/conversation_loop.py:1704
│
│ ① 开场(每轮只跑一次)
├──────────────────────────────────────────────┐
│ build_turn_context() agent/turn_context.py:431
│ · 守 stdio · 洗消息 · 灌 todo/nudge 计数
│ · 系统提示 restore-or-build · 崩溃续跑落库
│ · preflight 压缩 · pre_llm_call 插件钩子
│ · 外部记忆预取
└──────────────────────────────────────────────┘
│
│ ② 主循环(while,可跑 N 次) conversation_loop.py:1922
│ ┌────────────────────────────────────────────┐
│ │ 查中断 → 扣预算 → 排空 /steer → 拼 api_messages │
│ │ ↓ │
│ │ [内层重试循环] 调模型 :2741 │
│ │ ↓ │
│ │ normalize_response → 有 tool_calls? │
│ │ ├ 有 → 执行工具批次 → 回到循环顶 │
│ │ └ 没有 → 是最终答复 → break │
│ └────────────────────────────────────────────┘
│
│ ③ 收尾(每轮只跑一次)
└──▶ finalize_turn() agent/turn_finalizer.py:30
· 预算耗尽时补一次"请总结" · 存轨迹 · 会话落库
· 打 turn-ended 诊断日志 · 组装 result 字典
· 排空遗留 /steer · 触发后台复盘(记忆/技能)
怎么读这张图: 从上往下是时间顺序;只有中间那个方框会重复执行。
2.2 部件一句话职责
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
run_conversation | 一轮的总编排,持有循环和所有退出分支 | agent/conversation_loop.py:1766 |
build_turn_context | 把每轮前置全部收进来,返回循环要读的那几个局部变量 | agent/turn_context.py:431 |
IterationBudget | 线程安全的迭代计数器,父(网关缺省 500)/ 子(缺省 250) | agent/iteration_budget.py:17 |
TurnRetryState | 内层重试的一次性恢复开关,dataclass 有 21 个字段 | agent/turn_retry_state.py:33 |
ProviderTransport | 一条抽象数据通路,把厂商差异关在里面 | agent/transports/base.py:16 |
classify_api_error | 把厂商五花八门的报错归成一个 FailoverReason | agent/error_classifier.py:765 |
execute_tool_calls_* | 工具批次按段分派的并发/串行/分段三条执行路 | agent/tool_executor.py:1070、:1893、:2698 |
finalize_turn | 循环之后的全部收尾,返回 result 字典 | agent/turn_finalizer.py:120 |
3. 开场:build_turn_context 到底干了多少活
3.1 为什么要单独抽出来
run_conversation 原本开头有约 470 行直线代码,和循环没有任何回指关系:它跑一次、产出一组固定的值、循环再消费。把它整块搬进 agent/turn_context.py 之后,循环体只剩解包 + 跑循环(agent/conversation_loop.py:1843-1875)。
这个搬迁是纯移动:builder 仍然在大幅修改 agent 上的状态(计数器、线程 id、缓存提示、会话库),返回的 TurnContext 只带局部变量。
3.2 前置清单
按代码里的真实顺序:
| 步骤 | 做什么 | 位置 |
|---|---|---|
| stdio 守护 | 包住 stdout/stderr,防 systemd/headless 下断管 OSError | turn_context.py:459 |
| MCP 轮间刷新 | 上一轮之后才连上的 MCP server,这一轮才进工具快照 | turn_context.py:524-540 |
| 消息净化 | 剥掉用户输入里的孤代理项(surrogate),否则 SDK 的 json.dumps 会崩 | turn_context.py:542-546 |
| 计数器重置 | _empty_content_retries、_thinking_prefill_retries、护栏状态等一把清零 | turn_context.py:566-602 |
| 新建预算 | agent.iteration_budget = IterationBudget(agent.max_iterations) | turn_context.py:606 |
| todo/nudge 注水 | 从历史里重建 todo store 和"该提醒复盘记忆了"的计数 | turn_context.py:652-663 |
| 系统提示 restore-or-build | 见 3.3 | turn_context.py:735 |
| 崩溃续跑持久化 | 会话行先建;用户这轮在 preflight/预取之后、首个 LLM 调用前落库,进程中途死了也不丢 | turn_context.py:740、:1355 |
| preflight 压缩 | 请求还没发就估 token,超阈值先压最多 3 轮 | turn_context.py:860-1043 |
pre_llm_call 插件钩子 | 插件返回的上下文注入用户消息,不进系统提示 | turn_context.py:1175 |
| 外部记忆预取 | prefetch_all(query),整轮复用一份结果 | turn_context.py:1284 |
最后一条值得单独记:插件上下文和记忆上下文都注入用户消息,不注入系统提示。原因写在 conversation_loop.py:2305-2308 的注释里 —— 改系统提示会打断前缀缓存。
3.3 系统提示的"恢复优先"策略
Hermes 有一条不变量:系统提示一个会话只拼一次,之后逐字重放。因为 Anthropic 一类的前缀缓存要求字节完全一致,重拼一次就是一次缓存全失效。
_restore_or_build_system_prompt(agent/conversation_loop.py:809)因此把"数据库里那一行"分成四态来处理,并且每一态都有对应日志,让静默的缓存击穿变得可见:
| 存储状态 | 含义 | 处理 |
|---|---|---|
missing | 还没有会话行 | 正常首轮,直接构建 |
null | 有行,system_prompt 列是 NULL | 历史遗留/迁移残留,历史非空时告警 |
empty | 有行,列是空字符串 | 上一轮的写入静默失败了,必定告警 |
present | 有可用提示 | 逐字复用 |
即使命中 present,还要过一道 _stored_prompt_matches_runtime(:380):比对提示里最后一行 Model: / Provider:,和当前运行时不符就判为 stale_runtime 并重建。这挡的是"上一轮回退到别的厂商、提示里写着别人的名字"。
4. 主循环:一轮怎么被踩住刹车
循环条件本身就说明了边界(agent/conversation_loop.py:1959,真实源码,为版面宽度做了换行):
while (api_call_count < agent.max_iterations
and agent.iteration_budget.remaining > 0) or agent._budget_grace_call:
两个独立的闸门 —— 本地计数 api_call_count 和共享对象 iteration_budget —— 加一个"宽限一次"的旁路。
4.1 迭代预算:能扣也能退
IterationBudget(agent/iteration_budget.py:17)是个加锁的整数,只有三个动作:consume() 扣一格并返回是否成功、refund() 退一格、remaining 查余额。
它的价值在默认值的分配:
| 角色 | 上限 | 来源 |
|---|---|---|
| 主 agent | 网关缺省 500 | 网关按 max_turns 传入(tui_gateway/server.py:7179);AIAgent 签名缺省已改为不限(sys.maxsize,agent/agent_init.py:523) |
| 每个子 agent | 250 | hermes_cli/config_defaults.py:1920 的 delegation.max_iterations(旧值 50 有自动迁移) |
注意子 agent 拿的是独立预算,不从父预算里切。所以"父 + 全部子"的总迭代数可以超过父的上限 —— 这是设计选择,写在 iteration_budget.py:20-27 的类文档里。
更有意思的是退款。循环里有三处主动 refund():
| 退款场景 | 理由 | 位置 |
|---|---|---|
| Ollama 运行时上下文太小 | 这次请求根本没发出去 | conversation_loop.py:2543 |
| 压缩后重来 / 内容过滤回退后重来 | 换了消息重发,不该算两次 | :6468、:6507 |
这批工具只有 execute_code | 编程式工具调用是廉价 RPC,不吃预算 | :7288-7289 |
第三条尤其像"给自己开小灶":一批工具全是 execute_code 时,_tc_names == {"execute_code"} 成立就退一格。
至于 _budget_grace_call("宽限调用"):它在初始化时置 False(agent/agent_init.py:993),而生产代码里没有任何地方把它置 True —— 循环里只有把它清掉的那一句(:1951-1952)。所以它目前是个预留的口子,不是活跃路径。