本节摘要:本节解剖
run_agent.py(9215 行)的AIAgent类——内循环的心脏。先看类定义与约 60 个构造参数的分类(model 配置/工具集/回调/会话与平台/预算与备援/审计);再看同步 while 主循环的真身——它其实住在agent/conversation_loop.py:2017,循环头同时受max_iterations、iteration_budget与_budget_grace_call三重约束;然后拆解_interruptible_api_call的双线程设计(HTTP 在后台线程跑,主线程等"响应就绪/中断/超时"三事件);最后讲消息交替规则、工具执行的串行/并发分派,以及与hermes_state.py的 SQLite 持久化协作和优雅收尾。
内容来源:原项目源码
run_agent.py:422-616(类与构造)、agent/conversation_loop.py:1822-2100(主循环)、agent/chat_completion_helpers.py:1335(可中断调用)、run_agent.py:8323-8365(工具执行分派);docswebsite/docs/developer-guide/agent-loop.md。
⚠️ 注意:
run_agent.py虽有 9215 行,但它正在经历"巨型文件减重":__init__已是转发器(真正逻辑在agent/agent_init.py,3070 行),run_conversation/_execute_tool_calls等也大量转发到agent/子模块。读码时认准"转发注释"——"""Forwarder — see ``agent.agent_init.init_agent``."""——顺着它找真身,别在转发壳上浪费时间。
阅读完本节,你应当能够:
_interruptible_api_call 的双线程时序:主线程等什么,后台线程做什么。run_agent.py:422 起是类定义:
422 class AIAgent: 423 """ 424 AI Agent with tool calling capabilities. 425 426 This class manages the conversation flow, tool execution, and response handling 427 for AI models that support function calling. 428 """ 429 430 _TOOL_CALL_ARGUMENTS_CORRUPTION_MARKER = ( 431 "[hermes-agent: tool call arguments were corrupted in this session and " 432 "have been dropped to keep the conversation alive. See issue #15236.]" 433 )
docstring 很朴素,但类常量已经暴露了这个类的性格:它要在脏数据里活下去——工具调用参数在会话存储中损坏时,不崩溃,而是替换为标记并继续。构造函数签名(run_agent.py:445-526,节选)共约 60 个参数,按职责分六类:
| 类别 | 代表参数 | 职责 |
|---|---|---|
| model 配置 | base_url/api_key/provider/api_mode/model/max_tokens/reasoning_config |
谁来当大脑、用什么协议 |
| 工具集 | enabled_toolsets/disabled_toolsets |
会话可见的工具面(第 3 章) |
| 回调 | tool_progress_callback/thinking_callback/reasoning_callback/clarify_callback/step_callback/stream_delta_callback 等 20+ 个 |
平台无关的 UI/网关事件面 |
| 会话与平台 | session_id/platform/user_id/chat_id/thread_id/gateway_session_key/session_db/parent_session_id |
挂在哪个会话、来自哪个平台 |
| 预算与备援 | max_iterations/iteration_budget/run_budget_seconds/fallback_model/credential_pool |
循环的安全阀 |
| 审计与快照 | save_trajectories/checkpoints_enabled/checkpoint_max_snapshots 等 |
轨迹留存与检查点 |
注意 max_iterations 的默认值(run_agent.py:456):
456 max_iterations: int = sys.maxsize, # Default: unlimited tool-calling iterations (shared with subagents)
代码内默认"无限",真正的上限来自配置层 agent.max_turns(默认 500)。而 20 多个回调参数是理解"平台无关核心"的关键:CLI 的 spinner、网关的进度消息、ACP 的状态更新,都通过回调注入,AIAgent 本身不知道前端是谁。构造体最后整体转发:
527 """Forwarder — see ``agent.agent_init.init_agent``.""" 535 from agent.agent_init import init_agent 536 init_agent(self, base_url=base_url, api_key=api_key, ...)
agent_init.py 在这里完成 provider 探测、api_mode 解析(下一节)、client 构建、工具发现、预算初始化等重活。
run_conversation 的入口在 run_agent.py:8492,同样是个转发器——真身在 agent/conversation_loop.py:1822。转发壳并非无事可做:它先取消可能冲突的后台 review、解析 MoA 配置,还要处理跨进程的 session 轮次租约(防止 Desktop/CLI/网关多进程同时写一个会话)。循环头(conversation_loop.py:2017)是全节最值得背下的一行:
2017 while (api_call_count < agent.max_iterations and agent.iteration_budget.remaining > 0) or agent._budget_grace_call:
循环体每个迭代的固定流程(节选自 2018-2067):
2018 _redirect_text = agent._drain_pending_redirect() # 用户中途改主见 2031 # Check for interrupt request (e.g., user sent new message) 2032 if agent._interrupt_requested: 2033 interrupted = True 2034 _turn_exit_reason = "interrupted_by_user" 2036 agent._safe_print("\n⚡ Breaking out of tool loop due to interrupt...") 2037 break 2054 api_call_count += 1 2056 agent._touch_activity(f"starting API call #{api_call_count}") 2061 if agent._budget_grace_call: # 宽限调用:预算耗尽后 2062 agent._budget_grace_call = False # 再给模型最后一次机会总结 2063 elif not agent.iteration_budget.consume(): 2064 _turn_exit_reason = "budget_exhausted" 2066 agent._safe_print(f"\n⚠️ Iteration budget exhausted (...)") 2067 break
三个值得驻足的细节:其一,循环顶部先 drain "redirect"(用户在模型思考时发来的纠偏消息),纠偏文本会追加进消息历史而不是丢弃;其二,_turn_exit_reason 为每次退出记因(interrupted_by_user/budget_exhausted/...),可观测性内建到循环骨架里;其三,_budget_grace_call 是预算耗尽后的"临终关怀"——多给一轮让模型交出工作摘要,而非硬切。
API 调用本身经由中间件包裹后落到 agent._interruptible_api_call(conversation_loop.py:3174-3207 节选):
3174 def _perform_api_call(next_api_kwargs): 3175 if agent.api_mode == "codex_responses": 3176 next_api_kwargs = agent._get_transport().preflight_kwargs(...) 3182 if _use_streaming: 3183 return agent._interruptible_streaming_api_call( 3184 next_api_kwargs, on_first_delta=_stop_spinner) 3188 return relay_llm.execute(next_api_kwargs, agent._interruptible_api_call, ...)
agent/chat_completion_helpers.py:1335 的 interruptible_api_call 是内循环最精巧的部件之一:
1335 def interruptible_api_call(agent, api_kwargs: dict): 1336 """ 1337 Run the API call in a background thread so the main conversation loop 1338 can detect interrupts without waiting for the full HTTP round-trip. 1339 1340 Each worker thread gets its own OpenAI client instance. Interrupts only 1341 close that worker-local client, so retries and other requests never 1342 inherit a closed transport. 1343 1344 Includes a stale-call detector: if no response arrives within the 1345 configured timeout, the connection is killed and an error raised so 1348 provider fallback. 1349 """ 1350 if should_use_direct_api_call(agent): 1351 return direct_api_call(agent, api_kwargs) # cron 等非交互场景内联执行 1355 result = {"response": None, "error": None}
设计要点:HTTP 请求在后台线程跑;主线程同时等三件事——响应就绪、中断事件、超时。用户在 Telegram 上发新消息或敲 Ctrl+C 时,当前这个未完成的请求被直接抛弃,不会有半个响应混进会话历史(官方文档明确:No partial response is injected)。源码里还有一段血泪注释值得读:_close_request_client_once 区分"属主线程"与"陌生人线程"——中断线程只能 shutdown 套接字让阻塞的 recv 自然解体,不能代跑 client.close(),否则内核可能把刚释放的 TLS 文件描述符复用给 kanban.db,而仍然存活的 SSL 对象会把 24 字节的 TLS 记录写进 SQLite 文件头(源码引用编号 #29507)。这是多线程资源回收的经典陷阱,值得抄进每个人的笔记本。
内循环以 OpenAI 消息格式为内部规范,并强制交替规则(官方文档口径):system 之后 User→Assistant 严格交替;工具调用段是 Assistant(带 tool_calls)→Tool→Tool→...→Assistant;绝不连续两条 assistant、绝不连续两条 user;只有 tool 角色可以连续(并行工具结果)。违反序列会被 provider 直接 400 拒收。模型返回工具调用后,执行分派在 run_agent.py:8323:
8323 def _execute_tool_calls(self, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None: 8324 """Execute tool calls from the assistant message and append results to messages. 8326 The segment planner splits the batch into maximal contiguous runs of 8327 parallel-safe calls (read-only tools, non-overlapping file targets, 8328 opted-in MCP tools) separated by sequential barriers (interactive, 8329 unsafe, or unrecognized tools). 8333 """ 8339 if len(tool_calls) <= 1: 8340 return self._execute_tool_calls_sequential(...) # 单调用:主线程直接跑 8344 segments = _plan_tool_batch_segments(tool_calls, ...) # 多调用:分段规划 8349 if len(segments) == 1: 8351 if kind == "parallel": 8352 return self._execute_tool_calls_concurrent(...) # 纯并行段:线程池 8355 return self._execute_tool_calls_sequential(...) 8359 from agent.tool_executor import execute_tool_calls_segmented # 混合批:分段执行
单个工具调用直接在主线程执行;多个调用走并发(ThreadPoolExecutor),但像 clarify 这类交互式工具强制串行;混合批次由分段规划器切成"并行安全段+串行栅栏",无论完成顺序如何,结果都按原始 tool_call 顺序回填——顺序稳定性是消息交替规则的延伸。底层每个工具最终经 model_tools.handle_function_call 路由(第 3 章)。
一轮结束(拿到纯文本回复、或中断、或预算耗尽)后的收尾:消息写入会话库(hermes_state.py 的 SessionDB,SQLite+WAL,_ensure_db_session 在首轮惰性建行);记忆变更刷写到 MEMORY.md/USER.md(在上下文丢失前先落盘,官方文档 Compression 小节明确 Memory is flushed to disk first);会话可通过 /resume 恢复。中断路径的契约也很干净:循环 break 后由 finalizer 统一 _turn_exit_reason、释放租约、清中断标志,保证缓存的 agent 实例能服务下一轮。
💡 循环要点:AIAgent 的主循环用三行防御工程换来了生产级的鲁棒——循环头三重预算约束(次数/迭代/宽限)、双线程可中断调用(响应/中断/超时三路等待)、分段式工具分派(并行安全性与顺序稳定性兼得)。而约 60 个构造参数里占半壁江山的回调,正是"一个 AIAgent 服务所有平台"的解法:平台差异在入口层消化,循环本身对前端一无所知。
run_agent.py:422 定义,__init__ 转发到 agent/agent_init.py;构造参数约 60 个,分 model 配置/工具集/回调/会话平台/预算备援/审计六类。max_iterations 代码内默认 sys.maxsize,实际默认 500 来自配置 agent.max_turns。agent/conversation_loop.py:2017:while 同时检查 api_call_count < max_iterations、iteration_budget.remaining > 0、_budget_grace_call。_turn_exit_reason;宽限调用给模型最后一次总结机会。_interruptible_api_call 双线程:HTTP 在后台线程,主线程等响应/中断/超时;被中断的请求整体丢弃,无部分响应入史;"陌生人线程"只 shutdown 套接字不代跑 close(#29507 的 FD 复用事故)。_plan_tool_batch_segments 分段;结果一律按原顺序回填。/resume 可恢复会话、finalizer 统一清场。下一节回答"循环怎么说话":三种 API 模式(chat_completions/codex_responses/anthropic_messages)如何统一收敛到 OpenAI 消息格式,以及 agent/ 目录的五种 provider 适配器如何分工。