KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
02 · 主循环:回调注入式事件流与 90 轮预算 — keel 龙骨
hermes-agent 的主循环住在一个近 8000 行的模块里,但循环本体只有一行 while。真正值得看的不是那行循环,而是它周围的四件事:前置的 turn context 构建、把「还能不能再来一轮」拆成三个独立变量、把「这一轮为什么结束」做成一个贯穿全流程的诊断字符串,以及用 19 个回调替代事件总线的宿主接入方式。
hermes-agent 的主循环住在一个近 8000 行的模块里,但循环本体只有一行 while。真正值得看的不是那行循环,而是它周围的四件事:前置的 turn context 构建、把「还能不能再来一轮」拆成三个独立变量、把「这一轮为什么结束」做成一个贯穿全流程的诊断字符串,以及用 19 个回调替代事件总线的宿主接入方式。
这一章回答:
run_conversation()之前先做什么,为什么必须先做;- 循环条件里的三个变量各自防的是什么;
- 一轮内部按什么顺序做七件事,哪些是必须串行的;
- 8 路工具并发怎么被「起始顺序门」与「授权门」收住;
- 为什么这里没有事件总线,而是一堆
*_callback。
一、入口函数与前置闸门
主循环的入口是 agent/conversation_loop.py:1691: def run_conversation(...)。它不是从零开始跑的——真正的准备发生在 build_turn_context()(agent/turn_context.py,在 conversation_loop.py:44 被导入,在 :1793 被调用):
_ctx = build_turn_context(
...
effective_task_id=effective_task_id,
should_review_memory=_should_review_memory,
)
注释(conversation_loop.py:1790)说明它是从内联代码里抽出来的,并且会就地修改 agent 对象。也就是说:turn context 构建不是「准备一份数据给循环用」,而是「把本轮所需状态灌进 agent 实例」。这一步之后,while 才有资格开始。
同一段还有两个分支值得注意:api_mode == "codex_app_server"(conversation_loop.py:1900)会提前把控制权交给另一个 runtime;上下文引擎的选择在 _apply_context_engine_selection()(conversation_loop.py:1555)里做,且被注释标为 fail-open。
二、循环条件:三个变量
agent/conversation_loop.py:1909 就是那个 while:
while (api_call_count < agent.max_iterations and agent.iteration_budget.remaining > 0) or agent._budget_grace_call:
三个变量各管一件事:
agent.max_iterations:本轮的硬上限文字面量,默认 90(run_agent.py:446、agent_init.py:504;agent_init.py:623处agent.max_iterations = max_iterations完成赋值)。agent.iteration_budget.remaining:跨会话共享的计数预算,类型是agent/iteration_budget.py:17: class IterationBudget,线程安全,提供consume()/refund()/used/remaining。agent._budget_grace_call:宽限一轮标志。循环开始时if agent._budget_grace_call:就把自己置回False(conversation_loop.py:1938-1939),然后把这一轮跑完;因为elif not agent.iteration_budget.consume()被跳过,所以即使预算已耗尽也不会break。下一轮条件重算时,宽限已消费,循环自然退出。
IterationBudget 有一个容易误读的地方:它的模块 docstring 写着「父 agent 预算上限来自 max_iterations(default 500)、子 agent 来自 delegation.max_iterations(default 50)」。500 是过时描述——实际默认值是 90,而子 agent 的 50 在 docstring 里没有对应的当前默认值可核。两个数字都与 execute_code 的 refund() 有关:程序化工具调用的轮次会被退还,不占预算。
三、一轮的七件事
while 体内按固定顺序执行:
- drain redirect(
conversation_loop.py:1910):_redirect_text = agent._drain_pending_redirect()。若有,则_apply_active_turn_redirect(...)把用户中途的纠正并入original_user_message(拼成User correction during the turn: ...),并立刻agent._persist_session(...)。 - interrupt 检查(
:1924):if agent._interrupt_requested:→_turn_exit_reason = "interrupted_by_user"→break。 - 计数与消费(
:1931、:1940):api_call_count += 1,随后elif not agent.iteration_budget.consume():走budget_exhausted退出。 - step_callback(
:1947-1970):向宿主发「第 N 步」事件。它在这里做了一件有点重的事——反向扫描messages,把上一批 assistanttool_calls与其 tool 结果按tool_call_id配对,组成prev_tools一并回传。整段被try/except包住,失败只logger.debug。 - 技能 nudge 计数(
:1976-1978):仅当_skill_nudge_interval > 0且"skill_manage" in agent.valid_tool_names时_iters_since_skill += 1。 - API 调用(
api_messages构建在:2316一带):按api_mode分派到不同协议路径(chat_completions/codex_responses/anthropic_messages/bedrock_converse等在文件内反复出现分支)。 - 工具执行或收尾:有
tool_calls则进入工具执行,无则收尾。工具调用的唯一入口是conversation_loop.py:7204:
agent._execute_tool_calls(assistant_message, messages, effective_task_id, api_call_count)
注意这个调用的位置——_execute_tool_calls 定义在 run_agent.py(:8168),但调用点在 conversation_loop.py:7204。把这两处认成同一文件会读错。
四、退出原因是诊断字符串
本轮为什么结束,被记在局部变量 _turn_exit_reason 上,初值是 "unknown"(conversation_loop.py:1864),在 15 个以上站点被覆写,最后随 _turn_exit_reason=_turn_exit_reason(:8258)交给 agent/turn_finalizer.py。
可核实的字面量包括:
| 值 | 位置 | 含义 |
|---|---|---|
interrupted_by_user |
conversation_loop.py:1926 |
轮间检测到中断标志 |
budget_exhausted |
:1941 |
consume() 返回 False |
interrupted_during_api_call |
:6430 |
API 调用期间被中断 |
all_retries_exhausted_no_response |
:6505 |
重试耗尽仍无响应 |
ollama_runtime_context_too_small |
:2458 |
运行时上下文过小 |
compaction_handoff_not_actionable |
:2639 / :6450 / :7344 |
压缩交接不可执行 |
session_persistence_failed |
:7181 / :7210 |
落库失败 |
guardrail_halt |
:7217 |
熔断器判定停止 |
partial_stream_recovery |
:7449 |
流式半途恢复 |
fallback_prior_turn_content |
:7480 |
回退到上一轮内容 |
empty_response_exhausted |
:7760 |
空响应重试用尽 |
text_response(finish_reason=…) |
:8142 |
正常文本收尾(模板族) |
local_processing_error(…) |
:8232 |
本地处理异常 |
error_near_max_iterations(…) |
:8235 |
逼近上限时的错误 |
max_iterations_reached(n/N) |
turn_finalizer.py:175 / :182 |
撞到轮次上限 |
看得出设计意图:这是一个给人和给日志看的诊断串,而不是给程序分支用的枚举。turn_finalizer.py:153 只对 "unknown" / "budget_exhausted" 做特判,:226 用 startswith("text_response(") 判断是否正常收尾——模式匹配,而非穷举。
五、8 路并发与两道门
_execute_tool_calls(run_agent.py:8168)是个分派器,把批次交给两个实现之一:_execute_tool_calls_concurrent(run_agent.py:8293)或 _execute_tool_calls_sequential(run_agent.py:8298)。
并发侧的 worker 上限是 _MAX_TOOL_WORKERS = 8,两处各定义一次并互相镜像:run_agent.py:265 与 agent/tool_executor.py:96(后者注释:「Mirrors the constant in run_agent for tests/imports that look here」——为测试与导入便利而重复)。底层是 concurrent.futures。
真正值得注意的是 agent/tool_executor.py 顶部那几个超时常量,它们对应两道门:
_START_ORDER_GATE_TIMEOUT_S = 120.0 # 起始顺序门
_AUTHORIZATION_GATE_LOCK_TIMEOUT_S = 360.0 # 授权门串行锁兜底
- 起始顺序门(
tool_executor.py:101-105):一个 worker 若排在较后的序号,会等前面的工具「推进」后才允许乱序执行。上界 120 s——注释说这个值「长到能覆盖一次正当的授权往返,短到让一个卡死的分派不至于永久饿死整批」。 - 授权门(
tool_executor.py:106-112+_authorization_gate_lock_timeout()):等待授权提示序列化时,不能无限期持锁。有效上界由tools/approval.py: human_wait_ceiling()推导(tool_executor.py:127),保证「合法的人类回答审批」不会被误判为卡死,而真正卡住的持有者会被改判。
六、没有事件总线,只有 19 个回调
AIAgent.__init__ 接受约 19 个回调参数(run_agent.py:465-484):tool_progress_callback / tool_start_callback / tool_complete_callback / thinking_callback / reasoning_callback / clarify_callback / read_terminal_callback / read_preview_callback / read_window_below_callback / setup_mcp_callback / step_callback / stream_delta_callback / interim_assistant_callback / tool_gen_callback / status_callback / notice_callback / notice_clear_callback / event_callback / reaction_callback。
它们在 agent/agent_init.py:816-830 被逐个挂到 agent 实例上:
agent.tool_progress_callback = tool_progress_callback
agent.tool_start_callback = tool_start_callback
...
agent.step_callback = step_callback
agent.stream_delta_callback = stream_delta_callback
含义很直接:事件不是被广播的,而是被注入的。内核不认识「宿主的类型」,只是在若干固定位置检查 if agent.step_callback is not None: 然后调用。因此 CLI / gateway / ACP / TUI 各宿主必须自己把回调接到自己的渲染或传输层,内核不做适配。step_callback 那段 try/except 是这个约定的必然结果——回调是宿主的代码,内核不能假设它不抛。
除此之外还有一道熔断:agent/tool_guardrails.py:273: class ToolCallGuardrailController,配套 ToolCallGuardrailConfig(:64)、LoopCapConfig(:140)、ToolCallSignature(:177)、ToolGuardrailDecision(:194)。触发后对应 guardrail_halt 退出原因。
代码地图
| 机制 | 位置 | 要点 |
|---|---|---|
| 主循环入口 | agent/conversation_loop.py: run_conversation |
第 1691 行;返回前把 _turn_exit_reason 交给 finalizer |
| 前置闸门 | agent/turn_context.py: build_turn_context |
在 conversation_loop.py:44 导入、:1793 调用;就地修改 agent |
| 循环条件 | agent/conversation_loop.py: 1909 |
max_iterations + iteration_budget.remaining + _budget_grace_call |
| 预算对象 | agent/iteration_budget.py: IterationBudget |
第 17 行;consume() / refund() / remaining,全线程安全 |
| 宽限一轮 | agent/conversation_loop.py: 1938-1939 |
进入即置 False,本轮跳过 consume(),下轮自然退出 |
| 默认轮次上限 | run_agent.py: max_iterations |
第 446 行,默认 90;agent_init.py:504 同默认、:623 赋值 |
| redirect 排空 | agent/conversation_loop.py: 1910 |
agent._drain_pending_redirect(),用户中途纠正并入本轮 |
| 中断检查 | agent/conversation_loop.py: 1924 |
_interrupt_requested → interrupted_by_user |
| step 事件 | agent/conversation_loop.py: 1947-1970 |
反向配对上一批 tool_calls 与结果,异常只 debug |
| 技能 nudge 计数 | agent/conversation_loop.py: 1976-1978 |
_iters_since_skill += 1,受 _skill_nudge_interval 门控 |
| 工具执行唯一入口 | agent/conversation_loop.py: 7204 |
调用 agent._execute_tool_calls(...) |
| 工具分派 | run_agent.py: _execute_tool_calls |
第 8168 行;并发 :8293,串行 :8298 |
| worker 上限 | run_agent.py: _MAX_TOOL_WORKERS |
第 265 行,值 8;agent/tool_executor.py:96 镜像同一常量 |
| 起始顺序门 | agent/tool_executor.py: _START_ORDER_GATE_TIMEOUT_S |
第 105 行,120.0 s,防乱序 worker 永久饿死批次 |
| 授权门兜底 | agent/tool_executor.py: _AUTHORIZATION_GATE_LOCK_TIMEOUT_S |
第 112 行,360.0 s;实际值由 tools/approval.py: human_wait_ceiling 推导 |
| 回调注入点 | agent/agent_init.py: 816-830 |
19 个 *_callback 逐项挂到 agent 实例 |
| 熔断控制器 | agent/tool_guardrails.py: ToolCallGuardrailController |
第 273 行;触发对应 guardrail_halt 退出 |
| 退出原因收口 | agent/turn_finalizer.py: _turn_exit_reason |
:175 / :182 生成 max_iterations_reached;:153、:226 做模式特判 |
关键取舍
把 max_iterations(90)与 IterationBudget 并存,代价是两个上限可能不一致。max_iterations 是每轮的迭代次数闸,iteration_budget 是跨轮共享的计数器(默认上限同为 90),循环条件对两者取与。加上 _budget_grace_call 的宽限一轮,实际最多可能多跑一轮。收益是子 agent 可以有独立预算而不影响父 agent(iteration_budget.py docstring 明说「父 + 子总迭代数可以超过父的上限」);代价是「到底能跑几轮」要靠读三个变量和一处 elif 才能算出来。
IterationBudget 的 docstring 停留在 500,而实际默认是 90。
这是一个真实的文档漂移:数字从 500 调到 90 时,模块说明没跟着改。代价很小(不影响行为),但它说明这类「默认值写在两处」的设计天然容易腐烂——正确来源永远是 run_agent.py:446。
退出原因用字符串而不是枚举,代价是只能靠模式匹配消费。_turn_exit_reason 有十几个字面量、还有三个 f-string 模板族。好处是诊断串能携带参数(text_response(finish_reason=stop)、max_iterations_reached(90/90)),日志里一眼可读;代价是下游只能用 startswith("text_response(") 这种脆弱方式判断「是否正常收尾」,新加一个模板族就会漏判。
两道门的超时都取「很长但有限」,代价是慢路径会真的慢 120–360 秒。
起始顺序门 120 s、授权门 360 s。注释里的权衡写得很清楚:上界必须覆盖「人真的在点审批」的时长,否则会误杀合法等待;但也不能无限,否则一个卡住的分派会饿死整批。代价是当真的出现挂死时,用户要等最长 6 分钟才看到恢复。
用 19 个回调而不是事件总线,代价是宿主必须自己接线且回调异常要各自兜。
内核只在固定点位 if callback is not None 后调用,不做类型适配、不做广播、不做顺序保证。收益是内核零依赖宿主、零事件类型定义;代价是每加一种事件就要在 AIAgent.__init__ 签名上再加一个参数(签名已经排到 run_agent.py:484),且每个调用点都得自带 try/except 与 logger.debug。
自测题
_budget_grace_call为什么必须在循环体开头就置回False,而不是在consume()失败时才置?如果改成后者,会多跑几轮、或者少跑几轮?conversation_loop.py:1909用了and连接两个上限再用or连接宽限。请写出四种组合(max_iterations够/不够 ×remaining够/不够)下循环的实际行为,并指出哪一种组合下宽限标志必然已被消费。IterationBudget.refund()会把_used减一。请问这个 API 的存在使conversation_loop.py的循环条件在什么情况下不能被当作「上限」来推理?- 起始顺序门与授权门都出现在
tool_executor.py的并发路径上,但_execute_tool_calls_sequential并不需要它们。请解释这两道门分别解决的是「并发带来的哪一类问题」,以及串行路径为什么天然免疫。 - 假设你要给某个新宿主(比如一个语音助手)接入进度事件。按本章的回调机制,你最少需要接哪几个回调才能让用户知道「正在调工具」与「正在思考」?为什么内核不直接提供一个
agent.emit(event)接口?