跳到主要内容

数据截至 (上游 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把厂商五花八门的报错归成一个 FailoverReasonagent/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 下断管 OSErrorturn_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.3turn_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)
每个子 agent250hermes_cli/config_defaults.py:1920delegation.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)。所以它目前是个预留的口子,不是活跃路径。

4.2 中断:三个检查点

用户 /stop 或网关取消,不会立刻杀线程,而是置一个标志,由三个检查点收敛:

循环顶 退避 sleep 中 工具批次前
│ │ │
_interrupt_requested? 每 200ms 轮询 _interrupt_requested?
│ │ │
▼ ▼ ▼
interrupted=True 直接 return 一个 整批工具跳过,
exit=interrupted_by_user interrupted 结果字典 每个都补一条"已取消"
:617-622 :3795-3808 tool_executor.py:298

第三条很关键:即便整批被跳过,每个 tool_call_id 也必须有一条 role=tool 的回执,否则下一轮消息序列非法,严格厂商直接 400。取消回执长这样:"[Tool execution cancelled — {name} was skipped due to user interrupt]"(agent/tool_executor.py:1095-1111)。

4.3 /steer:在模型思考时插话

/steer 是"别停下来,顺带把 X 也做了"。难点在时机:steer 往往在上一次 API 调用还在飞的时候到达。

Hermes 的解法是调 API 之前先排空一次(conversation_loop.py:2030-2079):

steer 到达(后台线程) ──▶ agent._pending_steer

下一次迭代顶部 _drain_pending_steer()

┌─────────────────────┴─────────────────────┐
倒着找到最后一条 role=tool 找不到 role=tool
│ │
把 steer marker 追加进它的 content 原样塞回 _pending_steer,
(模型这一次调用就能看见) 等下一批工具再说

为什么不直接追加一条 user 消息?因为那会破坏角色交替(tool → useruser → user),严格厂商会拒。注释把这点写死了(:681-685)。

如果一轮结束了 steer 还没送出去,finalize_turn 会把它放进 result["pending_steer"] 交还调用方,由调用方当成下一条用户消息(agent/turn_finalizer.py:755-757)。

4.4 一个岔路:codex_app_server

循环开始之前有一个提前返回(conversation_loop.py:1950-1957):api_mode == "codex_app_server" 时,整轮交给 Codex 的 app-server 子进程(终端、文件操作、打补丁全在 Codex 里跑),Hermes 默认的工具分发完全绕开。这是可选的 opt-in 运行时,不是主路径。


5. 调模型这一步

5.1 传输层:一条数据通路关住厂商差异

Hermes 要接的后端差异极大:OpenAI 兼容的、Anthropic Messages 的、Bedrock Converse 的、Codex Responses 的。ProviderTransport(agent/transports/base.py:16)把差异关进一条固定的数据通路,模块文档第 4 行把这条通路写成一行:convert_messages → convert_tools → build_kwargs → normalize_response

必须实现的(带 @abstractmethod):

成员职责位置
api_mode(属性)声明这个 transport 认哪个 api_modebase.py:21
convert_messagesOpenAI 格式消息 → 厂商原生格式base.py:26
convert_toolsOpenAI 工具定义 → 厂商原生 schemabase.py:35
build_kwargs主入口,内部调上面两个,再补模型专属配置base.py:43
normalize_response厂商响应 → 统一的 NormalizedResponsebase.py:60

可选覆写(基类给了默认实现,不写也能跑):

成员默认行为位置
validate_response一律返回 Truebase.py:67
extract_cache_stats一律返回 Nonebase.py:75
map_finish_reason原样返回厂商的停止原因base.py:83

这个分层本身就是一条信息:格式转换是强制的,词表翻译是可选的——停止原因和 OpenAI 相近的厂商一个字都不用写。

模块文档第 6-7 行还明确划了另一条边界:transport 不拥有 client 构造、流式、凭据刷新、prompt 缓存、中断处理、重试逻辑 —— 那些都留在 AIAgent 上。这条边界让 transport 保持无状态、可单测。

四个已注册实现,靠模块导入时自注册(agent/transports/__init__.py:49_discover_transports):

api_modemap_finish_reason 覆写的特点
chat_completionsChatCompletionsTransport近似恒等;保留 extra_content(Gemini 3 的 thought_signature,不回放就 400)
anthropic_messagesAnthropicTransport_STOP_REASON_MAP 查表(anthropic.py:234),未知一律 stop
bedrock_converseBedrockTransportguardrail_intervened / content_filtered 都映射成 content_filter(bedrock.py:145-146)
codex_responsesResponsesApiTransportincomplete → length,细粒度还要另看 incomplete_details

get_transport() 在注册表未命中时会再跑一次发现(__init__.py:36-43),因为测试里的乱序直接导入可能只填了一半注册表。

5.2 两个不走 transport 的特例

Anthropic 适配器(agent/anthropic_adapter.py)是被 transport 委托的实现体:convert_messages_to_anthropic:2334convert_tools_to_anthropic:1646build_anthropic_kwargs:2398create_anthropic_message:2694。transport 里每个方法都是一行转发,不复制逻辑。

Gemini 原生适配器(agent/gemini_native_adapter.py)走的是另一条路:它不注册 transport,而是伪装成一个 OpenAI 兼容的 client(GeminiNativeClient:834),让 gemini 这个 provider 继续用 api_mode='chat_completions'。文件头的理由很直白 —— Google 的 OpenAI 兼容端点在多轮工具循环下"auth churn、tool-call 重放怪癖、thought-signature 要求"太脆,原生 API 才是正路。请求/响应翻译在 build_gemini_request:405translate_gemini_response:503

5.3 内层重试循环:恢复动作的阶梯

外层是"再调一次模型",内层是"这一次调用没成功,怎么救"。内层循环从 conversation_loop.py:2814 开始,配一个 TurnRetryState(:2733)。

TurnRetryState(agent/turn_retry_state.py:33)的存在理由写得很清楚:这批恢复分支原本是一堆裸 bool 局部变量,穿过 2,400 行循环体(模块 docstring 自述"~16 个",现在的 dataclass 落成 21 个字段)。收进一个 dataclass 后,每个分支"这一次调用里最多触发一次"的语义变得可读、可测。它分四组:

字段举例保证
各厂商 OAuth 刷新codex_auth_retry_attemptedanthropic_auth_retry_attemptednous_auth_retry_attemptedcopilot_auth_retry_attempted每家最多刷新一次
格式/载荷恢复thinking_sig_retry_attemptedimage_shrink_retry_attemptedllama_cpp_grammar_retry_attempted每种畸形最多修一次
传输/限流primary_recovery_attemptedhas_retried_429防止在同一次调用里反复退避
重启信号restart_with_compressed_messagesrestart_with_length_continuationrestart_with_rebuilt_messagesrestart_with_redirected_messages由外层读取,决定要不要重建请求再来

第四组是唯一会"跳回外层"的:外层在 :6452-6532 依次检查这四个信号,压缩重来和消息重建重来都会退一格预算continue,而长度续写重来会提高输出 token 上限(retry 1 → 2× base,retry 2 → 3× base,封顶 32768)再 continue

5.4 报错分类:一次分类,处处复用

每个异常都先过 classify_api_error(agent/error_classifier.py:765),在 conversation_loop.py:4662 调用。它返回一个 ClassifiedError(:84),里面除了 reason 还带四个行动提示:retryableshould_compressshould_rotate_credentialshould_fallback

这个设计的价值:重试循环不再自己做字符串匹配,只读这四个布尔量。FailoverReason(:24)是个枚举,共 21 个成员,几个关键的:

reason含义期望动作
auth / auth_permanent401/403 可刷新 / 刷新后仍失败刷新凭据 / 放弃
billing402 或确认额度耗尽立即换凭据或换厂商
rate_limit429 或配额节流退避后轮换
overloaded / server_error503/529 / 500/502退避重试
context_overflow / payload_too_large上下文/载荷过大压缩,不是换厂商
content_policy_blocked安全过滤拒绝同样输入必然复现,不重试
model_not_found404 或模型名无效换模型

content_policy_blocked 那条尤其体现克制:提示不变的话拒绝是确定性的,所以直接终结这一轮,并走一个共享的结果构造器 _content_policy_blocked_result(conversation_loop.py:1381),保证"HTTP 200 拒答"和"抛异常被分类"两条路径返回完全同形的结果字典。

5.5 退避:抖动是为了防羊群

jittered_backoff(agent/retry_utils.py:90)= min(base × 2^(n-1), max) 再加一段 [0, 0.5×delay] 的随机量。种子取 time_ns 异或一个加锁自增计数器,所以即使多个网关会话在同一毫秒重试,退避时刻也不会撞在一起。

adaptive_rate_limit_backoff(:100)是给一个具体厂商开的小灶:Z.AI Coding Plan 的 GLM-5.2 端点经常返回 429 code 1305("服务暂时过载"),短退避只会持续砸同一个过载窗口。它前 3 次仍走正常短退避,之后切到 30s → 60s → 90s → 120s 的长档。识别条件写得很窄(is_zai_coding_overload_error:80:必须同时满足 429 + 特定 base_url + 特定 model + 报文含 1305),避免把普通配额 429 也拖成两分钟。

5.6 容错三级阶梯

真正的容错不是"重试三次",而是三级台阶,一级比一级贵:

一次 API 调用失败

classify_api_error

┌─────┴───────────────────────────────────────────────────┐
│ 第 1 级:同 provider 换凭据 │
│ _recover_with_credential_pool() run_agent.py:4177 │
│ CredentialPool.mark_exhausted_and_rotate() │
│ credential_pool.py:1480 │
│ 成功 → continue,连模型都不用换 │
└─────┬───────────────────────────────────────────────────┘
│ 池子里没有可用的
┌─────┴───────────────────────────────────────────────────┐
│ 第 2 级:退避重试同一 provider │
│ jittered_backoff / adaptive_rate_limit_backoff │
│ 退避期间每 200ms 查一次中断 │
└─────┬───────────────────────────────────────────────────┘
│ retry_count 用尽,或错误类型判定"等也没用"
┌─────┴───────────────────────────────────────────────────┐
│ 第 3 级:跨 provider 回退 │
│ _try_activate_fallback() │
│ chat_completion_helpers.py:1115 │
│ 换 client + 换 model slug + 换 provider,原地生效 │
└─────────────────────────────────────────────────────────┘

第 1 级 —— 同 provider 多凭据。 CredentialPool(agent/credential_pool.py:650)管一个 provider 下的多把钥匙,每把有 ok / exhausted / dead 三态。mark_exhausted_and_rotate(:1480)优先用 api_key_hint 定位真正失败的那一把,而不是简单地"轮到下一把" —— 因为另一个进程可能已经轮转过,此时 current() 是 None,盲目取下一把会打错人(:1489-1497)。判定为 dead 的会永久出局直到重新认证。

这里还有一个反循环的补丁:持久的上游 401 会让 try_refresh_current() 在单条 OAuth 凭据上"永远刷新成功",所以循环开头每轮重置一个 _auth_pool_refresh_counts 计数表,给同一条凭据的刷新次数封顶,逼它把机会让给回退链(conversation_loop.py:1931-1936)。

第 3 级 —— 跨 provider 回退链。 链从配置读出来,get_fallback_chain(hermes_cli/fallback_config.py:80)把新键 fallback_providers(有序)和旧键 fallback_model(追加)合并,按 (provider, model, base_url) 三元组去重。

try_activate_fallback(agent/chat_completion_helpers.py:2427)负责真正切换,里面有三处值得学:

  • 跳过等于当前后端的条目(:1168-1184):回退到刚刚失败的那个后端只会复现失败。除了 provider+model,还比 base_url,因为两条不同的 custom_providers 可能指向同一个代理。
  • 改写系统提示里的身份行(rewrite_prompt_model_identity:1086):只改最后一处 Model: / Provider:,因为靠前的同名行可能是用户内容(记忆快照、上下文文件);而且不写回数据库 —— 主 provider 恢复时,内存里的提示会重新和库里那份逐字相同,前缀缓存继续有效。
  • 链耗尽后 armed 一段冷却(:1143-1151):否则下一轮 _restore_primary_runtime 会把 _fallback_index 归零,再把整个上下文在所有厂商之间重放一遍。

改完提示还差一步:当前这次调用的 api_messages[0]切换前拼的。_sync_failover_system_message(conversation_loop.py:1490)就地把它刷新,并返回新的 active_system_prompt。这条配对是硬不变量:_try_activate_fallback 在循环里有 10 个调用点,_sync_failover_system_message 也正好有 10 个 —— 漏掉一个就会整轮带着旧身份发出去。

5.7 触发回退的几个入口

回退不是只在"报错"时发生。循环里有多个入口:

入口触发条件位置
无效响应响应结构非法(常见于限流的软失败):1338-1346
内容过滤中断流stub 上带 _content_filter_terminated,同厂商续写只会再撞一次:1717-1743
限流/账单rate_limitbilling,立即切:2919-2958
传输失败timeout / overloaded,先重试 2 次再切同上,retry_count >= 2
认证持续失败classified.is_auth 且刷新已试过:2975-2991
模型反复返回空空响应重试用尽:4632-4656
Nous 限流另一会话已记录 Nous 在限流,直接跳过调用:997-1036

限流那条有个前置判断:_pool_may_recover_from_rate_limit(run_agent.py:310)—— 如果同 provider 的凭据池还可能救回来,就别急着跨厂商切。这修的是"池子里明明还有钥匙却整个换厂商"的浪费。


6. 工具批次:什么时候敢并发

模型返回 tool_calls 之后,agent._execute_tool_calls(run_agent.py:8314)先做一次分段规划(真实源码节选):

if len(tool_calls) <= 1:
return self._execute_tool_calls_sequential(...)

segments = _plan_tool_batch_segments(tool_calls, execution_cwd=_exec_cwd)

if len(segments) == 1:
kind = segments[0][0]
if kind == "parallel":
return self._execute_tool_calls_concurrent(...)
return self._execute_tool_calls_sequential(...)

from agent.tool_executor import execute_tool_calls_segmented
return execute_tool_calls_segmented(
self, assistant_message, messages, effective_task_id, api_call_count,
segments=segments,
)

6.1 并发闸门:从"全有全无"到分段规划

_plan_tool_batch_segments(agent/tool_dispatch_helpers.py:117)是纯函数,把批次切成有序的 ("parallel", calls) / ("sequential", calls) 段:段序完全保持模型给定的调用顺序,后面的调用绝不越过更早的屏障;老的全有全无函数 _should_parallelize_tool_batch(:238)退居为它的薄封装,只在规划结果恰好是单个全并行段时才返回 True。逐个调用的规则还是那几条,但违反者不再拖垮整个批次,而是自己当屏障:

批次进来,逐个看工具:
① 含 _NEVER_PARALLEL_TOOLS(目前只有 clarify:要问用户)──▶ 屏障(串行)
② 参数 JSON 解析失败 / 不是 dict ──▶ 屏障
③ 是 read_file/search_files/write_file/patch(_PATH_SCOPED_TOOLS)
│ → 取不出保留路径 → 屏障
│ → 与段内已保留路径冲突 → 关闭当前并行段,自己开新段
│ (保留路径带读写角色:reader↔reader 重叠无害,仍可并行;
│ 任一方是 writer 的重叠才算冲突;search_files 把搜索根
│ 默认 cwd 也按 reader 保留)
├ 在 _PARALLEL_SAFE_TOOLS 白名单里(只读、无共享状态)→ 并行
└ 都不是 → 问 MCP:这台 server 声明过并行安全吗?否 → 屏障

段内不足 2 个调用 → 降级串行;相邻串行段合并

三个集合都很小、很保守。白名单是一个 12 项的 frozenset(:48-61),全部是只读且无共享会话状态的工具:

分类成员
Home Assistant 只读查询ha_get_stateha_list_entitiesha_list_services
本地只读read_filesearch_files(在白名单里,但先被 ③ 的路径规则接走)
检索与技能只读session_searchskill_viewskills_list
外部只读vision_analyzeweb_extractweb_search
生图image_generate

另外两个集合更小:路径工具四个,按角色分成 _PATH_SCOPED_READERS(read_file、search_files,:69)与 _PATH_SCOPED_WRITERS(write_file、patch,:70),合并成 _PATH_SCOPED_TOOLS(:73);永不并行只有 clarify(_NEVER_PARALLEL_TOOLS,:45)。

路径重叠用 _paths_overlap(:333)判定:比较两个绝对路径的 parts 前缀,任一方是另一方的祖先就算重叠。所以 a/b/c.pya/b 会被判为冲突。规范化交给 _canonical_path(:251):expanduser 后把相对路径挂到执行环境 cwd,再 realpath + normcase 消掉符号链接与大小写差异 —— 对尚不存在的文件,realpath 只解析到已存在的组件为止。

6.2 并发执行的三个坑

execute_tool_calls_concurrent(agent/tool_executor.py:1070)用 ThreadPoolExecutor,worker 上限 8(_MAX_TOOL_WORKERS,:96),实际取 min(批内调用数, 上限),含 image_generate 的批次还会再压到其并发配置(_max_workers_for_tool_batch,:237)。三个细节值得记:

坑一:ContextVar 不会自动跨线程。 提交时包一层 propagate_context_to_thread(_run_tool)(:1475),把这一轮的审批会话 key、sudo 回调等带进 worker,退出时清理。这是一条安全修复(GHSA-qg5c-hvr5-hjgr)。

坑二:中断要能扇出到 worker。 _run_tool(:1257)第一件事是把自己的 tid 注册进 agent._tool_worker_threads,并且立刻补查一次中断 —— 防止"扇出发生在注册之前"的竞态(:1271-1283)。注销必须在 finally 里,因为 KeyboardInterrupt 这类 BaseException 会绕过 except Exception,漏掉就会把中断位留在被回收的线程上,毒害下一个任务(:1424-1436)。

坑三:解释器正在关闭。 executor.submit 可能抛 "cannot schedule new futures after interpreter shutdown"。这时不是崩,而是给所有未提交的调用各补一条错误回执再退出(:1484-1515),保证 tool_call_id 一一对应。

工具结果的顺序始终按模型给的原始顺序写回 messages(results[index] 按下标填),不按完成先后 —— 否则模型看到的因果关系会乱。

6.3 空转守卫:纯函数版的"你在鬼打墙"

agent/tool_guardrails.py 是一个完全无副作用的控制器:它只记账、只返回决定,要不要变成警告、合成结果还是刹车,由运行时决定(文件头 1-7 行)。

它盯三种病:

病症判定默认阈值(警告 / 硬停)
同一调用反复失败工具名 + 参数规范化后的 sha256 相同,且失败2 次 / 5 次
同一工具反复失败只看工具名3 次 / 8 次
只读工具零进展幂等工具返回同一份结果哈希2 次 / 5 次

阈值在 ToolCallGuardrailConfig(:63)。默认 warnings_enabled=Truehard_stop_enabled=False —— 交互式 CLI 只会被轻推一下,真正的断路器要在 config.yaml 里显式打开。

签名用 ToolCallSignature.from_call(:135):参数排序后压成紧凑 JSON 再取 sha256,不留原始参数值,所以决定的元数据可以安全地上报(to_metadata:139)。

工具名分两类:IDEMPOTENT_TOOL_NAMES(:20,read_file、web_search、各种 mcp_filesystem 读操作)才做"零进展"检查;MUTATING_TOOL_NAMES(:41,terminal、patch、memory……)天然会改变世界,重复调用不算病(_is_idempotent:377)。

before_call 返回 blockafter_call 返回 halt 时,决定会存进 _halt_decision;循环在工具批次结束后读它(conversation_loop.py:7344-7365),置 _turn_exit_reason = "guardrail_halt",合成一条说明性的 assistant 消息并主动推给流式回调,免得看起来像崩溃。


7. 退出原因表:这轮为什么停了

_turn_exit_reason 初值 "unknown"(conversation_loop.py:1914),循环的每个出口都会覆盖它,最后交给 finalize_turn。它有两个消费者:一条永远打在 INFO 的诊断日志,和一段面向用户的解释文案。

_turn_exit_reason什么情况位置正常?
text_response(finish_reason=...)模型不要工具了,给了文字:4815✅ 唯一的正常出口
interrupted_by_user循环顶部发现中断标志:619用户主动
interrupted_during_api_callAPI 调用/退避期间被打断:3821用户主动
budget_exhaustediteration_budget.consume() 返回 False:634异常
max_iterations_reached(n/m)循环自然跑满,收尾阶段补判turn_finalizer.py:182异常
guardrail_halt空转守卫刹车:4335异常
empty_response_exhausted空响应重试 + 回退链都用尽:4664异常
partial_stream_recovery流被打断,把已经吐出来的部分当答复:4451降级
fallback_prior_turn_content上一轮"文字 + 纯家务工具",这轮空,复用上一轮文字:4482降级
all_retries_exhausted_no_response重试用尽,response 仍是 None:3865失败
ollama_runtime_context_too_small本地 Ollama 的 num_ctx 撑不下工具:940失败
error_near_max_iterations(...)外层异常且已逼近上限,防死循环:4870失败
unknown没有任何分支覆盖:589说明有漏网路径

7.1 两个消费者

诊断日志(turn_finalizer.py:485-504)固定格式:reason / model / api_calls / budget / tool_turns / last_msg_role / response_len / session。有一个特别的判定:如果最后一条消息是 role=tool 且不是用户打断,就升级为 WARNING —— 这正是用户报"agent 干到一半就不动了"的那个场景,日志里能一眼捞出来。

用户可见解释(_format_turn_completion_explanation,run_agent.py:3818)把 reason 翻成一句人话 + 下一步建议。它对 text_response(...) 一律返回空字符串,所以一句干脆的"Done." 不会被加上噪音尾巴。

调用侧的门槛也卡得很细(turn_finalizer.py:547-585):只有在这轮确实没有可用答复时才动手 —— 空字符串、(empty) 哨兵,或者一个 ≤24 字符且结尾没有句末标点的碎片(比如只吐出一个 "The")。真短答复保持原样。

7.2 completed 是怎么算的

completed = final_response is not None
and not failed
and (api_call_count < max_iterations 或 是 text_response 出口)

turn_finalizer.py:226-234。第三项那个"或"很关键:恰好在最后一次迭代给出正常文字答复,不该被算作失败。

7.3 预算耗尽的补救:再问一次"请总结"

finalize_turn 开头(:53-70)有个特殊分支:预算跑光但 final_response 还是 None,就调 _handle_max_iterations —— 剥掉全部工具,注入一条 user 消息,再发一次请求,让模型至少交代一下做到哪了。

顺带还有个联动:如果这是个 kanban worker(环境变量 HERMES_KANBAN_TASK),会以 outcome="timed_out" 记一次失败(:85-122)。理由写在注释里 —— 工具被剥掉了,模型自己没法调 kanban_block;而且必须计入 consecutive_failures,否则一个总是超预算的任务会被反复自动重排,永远没有信号。


8. 收尾:finalize_turn 的顺序有讲究

循环之后的一切都在 agent/turn_finalizer.py:120,顺序如下:

预算耗尽补救 → 存轨迹 → 清理任务资源 → 会话落库 → 诊断日志
→ 文件改动核验页脚 → 异常退出解释 → transform_llm_output 插件
→ post_llm_call 插件 → 抽本轮 reasoning → 组装 result 字典
→ 排空遗留 steer → 触发后台复盘(记忆/技能) → on_session_end 插件

三个设计点:

清理绝不能吞掉答复。 存轨迹(文件 I/O)、清理资源(远程 VM/浏览器,走网络)、会话落库(SQLite)三步都可能抛。以前任何一步抛出去就会带走已经拿到的 final_response(子进程包装器看到空 stdout、没有 traceback)。现在三步各自 try/except,错误汇总进 result["cleanup_errors"](:146-191)。

落库前先扔掉私有脚手架。 _drop_trailing_empty_response_scaffolding 先跑,否则下一轮 "continue" 会把 assistant("(empty)") 当成真答复重放,把会话卡进空响应循环(:163-168)。中断的场景另补一条:最后一条是 tool 结果时要合成一条 assistant 收尾,否则持久化成 tool → user,Gemini/Claude 会拒或者接着幻觉用户消息(:170-186)。

后台复盘在答复交付之后。 记忆/技能复盘只在"有答复且没被打断"时才 _spawn_background_review(:453-461),不跟用户的任务抢模型注意力。这个闭环见 03-self-improvement-loop

result 字典(:381-409)是给所有上层的统一契约:CLI、网关、定时任务都读它。除了 final_response / messages / completed,还带整轮 token 与费用统计、turn_exit_reasonresponse_previewed(流式是否已经预览过,决定网关要不要再发一遍)。


9. 巧妙之处

  1. 系统提示"一次拼、逐字重放",连回退都不写回库。 回退时只改内存里最后一处身份行,主 provider 恢复后提示自动和库里那份逐字相同,前缀缓存不断(chat_completion_helpers.py:2369-2395)。
  2. 注入位置的纪律:一切临时上下文进用户消息,不进系统提示。 记忆预取、插件上下文、MoA 聚合结果都追加在 user 侧(conversation_loop.py:2220-2236:2309-2322),系统提示留给 Hermes 内部,缓存前缀永远稳定。
  3. 为 KV 缓存做字节级归一化。 每次调用前把 content 两端空白剥掉,把 tool_call 的 arguments 用 sort_keys + 紧凑分隔符 重新序列化(:894-919)。目的是让 llama.cpp / vLLM / Ollama 的前缀匹配命中率上去。
  4. 退款制的迭代预算。 没发出去的请求、被压缩后重来的请求、纯 execute_code 的批次都退一格(:946:3826:4374),避免"重试把预算烧光"。
  5. 并发闸门默认拒绝。 白名单 + 路径不冲突 + 参数可解析,过不了关的调用自己当屏障、不拖垮整批,分段保序执行(tool_dispatch_helpers.py:117)。宁可慢,不制造竞态。
  6. 抽象方法只关强制项,可选项给默认。 transport 的四步数据通路是抽象的,停止原因翻译带默认实现(transports/base.py:83)—— 接一个词表相近的厂商,一行覆写都不用写。
  7. 护栏是纯函数。 ToolCallGuardrailController 只记账返回决定,副作用留给运行时;签名只留哈希不留参数值(tool_guardrails.py:245),元数据可以安全上报。
  8. 每个出口都留下可解释的字符串。 _turn_exit_reason 既进日志也进用户文案,并对正常出口保持沉默(run_agent.py:3847-3848)。"agent 无声无息停了"这个类问题因此可诊断。
  9. 两条路径共用一个结果构造器。 内容策略拒绝有 HTTP 200 和抛异常两条来路,都走 _content_policy_blocked_result(conversation_loop.py:1381),保证形状不漂移。

10. 边界与局限

  • run_conversation 仍是一个约 6,600 行的函数(agent/conversation_loop.py:1766-8410)。前置和收尾已经抽走(turn_context.py / turn_finalizer.py),但内层重试的十几个恢复分支仍然内联在循环体里,靠 TurnRetryState 的一次性 bool 维持秩序。
  • 恢复分支大量依赖厂商报错文本匹配。 error_classifier.py 里是几百条字符串模式(_BILLING_PATTERNS_RATE_LIMIT_PATTERNS 等)。厂商改一次文案就可能错分类。
  • 并发白名单是手工维护的。 _PARALLEL_SAFE_TOOLS 是一个写死的 11 项 frozenset;新工具默认串行,除非有人手动加名单,或者 MCP server 主动声明并行安全。
  • 路径重叠判定是前缀比较,不看符号链接。 _paths_overlap 只比 parts 前缀,故意不 resolve()(因为文件可能还不存在),所以两条指向同一文件的软链会被判为不重叠。
  • _budget_grace_call 目前是死代码路径。 循环条件里读它、循环里清它,但生产代码没有任何地方置 True(agent_init.py:993)。
  • 子 agent 预算独立,总量无上限。 父(网关缺省 500)+ 每个子(缺省 250),总迭代数可以远超父的上限,类文档明确承认了这点(iteration_budget.py:20-27)。
  • codex_app_server 路径完全绕开本章描述的循环。 它在循环开始前就 return 了(conversation_loop.py:1950),工具执行、护栏、并发闸门都不生效。

11. 代码地图

主题文件路径关键符号
一轮总编排agent/conversation_loop.pyrun_conversation
系统提示恢复/构建agent/conversation_loop.py_restore_or_build_system_prompt_stored_prompt_matches_runtime
回退后同步系统消息agent/conversation_loop.py_sync_failover_system_message
截断续写提示词agent/conversation_loop.py_get_continuation_prompt
内容策略终结结果agent/conversation_loop.py_content_policy_blocked_result
每轮前置agent/turn_context.pybuild_turn_contextTurnContext_should_run_preflight_estimate
每轮收尾agent/turn_finalizer.pyfinalize_turn
迭代预算agent/iteration_budget.pyIterationBudget.consume.refund
内层重试状态agent/turn_retry_state.pyTurnRetryState
工具分发入口run_agent.pyAIAgent._execute_tool_calls
并发/串行执行agent/tool_executor.pyexecute_tool_calls_concurrentexecute_tool_calls_sequential_run_tool_apply_tool_request_middleware_for_agent
并发闸门agent/tool_dispatch_helpers.py_should_parallelize_tool_batch_PARALLEL_SAFE_TOOLS_paths_overlap_extract_parallel_scope_path
空转守卫agent/tool_guardrails.pyToolCallGuardrailController.before_call.after_callToolCallSignatureToolCallGuardrailConfig
传输层抽象agent/transports/base.pyProviderTransport
传输层注册表agent/transports/__init__.pyget_transportregister_transport_discover_transports
各家实现agent/transports/ChatCompletionsTransportAnthropicTransportBedrockTransportResponsesApiTransport
Anthropic 适配agent/anthropic_adapter.pyconvert_messages_to_anthropicbuild_anthropic_kwargscreate_anthropic_message
Gemini 原生适配agent/gemini_native_adapter.pyGeminiNativeClientbuild_gemini_requesttranslate_gemini_response
Codex app-server 运行时agent/transports/codex_app_server.pyCodexAppServerClient
报错分类agent/error_classifier.pyclassify_api_errorFailoverReasonClassifiedError
退避策略agent/retry_utils.pyjittered_backoffadaptive_rate_limit_backoffis_zai_coding_overload_error
同 provider 凭据池agent/credential_pool.pyCredentialPool.select.mark_exhausted_and_rotate.try_refresh_current
跨 provider 回退链hermes_cli/fallback_config.pyget_fallback_chain
回退切换agent/chat_completion_helpers.pytry_activate_fallbackrewrite_prompt_model_identity
退出原因文案run_agent.pyAIAgent._format_turn_completion_explanation
凭据池恢复run_agent.pyAIAgent._recover_with_credential_pool_pool_may_recover_from_rate_limit

下一步读哪章: 想知道系统提示怎么拼、上下文怎么省 → 02-context-engineering;想知道工具本身怎么实现、终端后端有哪几种 → 04-tools-and-environments;想知道危险命令和凭据隔离怎么把关 → 06-trust-boundaries。回总览 → index