Dark Dwarf Blog background

Agent Runtime 架构整理:从 Agent Loop 到可插拔运行时

Agent Runtime 架构整理:从 Agent Loop 到可插拔运行时

最近在整理自己写过的几个 Agent 项目(nonoka-agent、nonoka-cli、cable-hermes、supermodel-agent),发现一个尴尬的事实:代码是自己写的,但如果面试被问”一个 Agent Runtime 应该怎么架构”,我大概率只会说个 ReAct 然后被拷打。这篇文章就是把这几个项目的源码重新读了一遍之后的系统性整理,顺带附上一些我觉得值得读的开源项目分析和文章。

1. Agent Loop 的骨架:一轮里真正发生的事

几乎所有 Agent 的内核都是同一个循环:

while not done:
    response = await llm.chat(messages, tools)
    if not response.tool_calls:
        return response.content      # final answer
    results = await execute_tools(response.tool_calls)
    messages.append(...)

这就是大家熟知的 ReAct。但问题在于:生产级的 Agent Loop 里,llm.chat 和 execute_tools 这两行可能只占总代码量的 20%,剩下 80% 都是围绕这个循环的守卫、治理和状态管理。以 nonoka-agent 的 ReActAgent.run()(nonoka/core/paradigm.py:103-498)为例,一轮完整 turn 的实际控制流是:

[1] begin_model_turn        # 预算检查:取消?终止?deadline?max_turns?
[2] enforce_context_budget  # 上下文压缩
[3] LoopExtension.before_turn        # 扩展点:注入 system 反馈 / 禁工具
[4] 构建 messages + tool schemas    # 过滤被封禁的工具
[5] LLM 调用                # 超时 = min(model_timeout, 剩余 wall-clock)
[6] 分支判断
    ├─ 无 tool_calls → before_final_answer 扩展 → CompletionContract 校验
    │                  → result_type 解析 → COMPLETED + checkpoint
    └─ 有 tool_calls → 写入 assistant 消息 → checkpoint(!)
                       → reserve_tool_calls(整批预占预算)
                       → 并发执行工具
                       → 结果写回(defer_budget)→ 再次压缩
                       → after_tool_batch 扩展 → 循环检测 → checkpoint

hermes 的 run_conversation()(agent/conversation_loop.py:436)结构略有不同但骨架一致,它把一轮明确拆成了三段:prologue(build_turn_context)→ loop → finalizer(finalize_turn)。prologue 负责重置每轮计数器、恢复 primary runtime、预检压缩、跑插件 pre_llm_call 钩子;finalizer 负责预算耗尽总结、trajectory 保存、持久化和 post_llm_call 钩子。

所以回答”Agent Loop 怎么架构”这个问题时,比较准确的说法是:LLM↔Tool 的推理循环只是骨架,Runtime 的真正工作是在每一轮的关键边界上做预算、上下文、持久化、循环检测和终止校验。这些边界点也是后面所有扩展机制的挂载点。

2. 配置、状态、执行的分离

几个项目对比下来,最一致的一个架构共识是把三个角色拆开:

角色回答的问题nonoka-agentsupermodelhermes
配置用什么模型、挂什么工具、预算多少Agent(frozen dataclass)AppConfig(Zod 校验)init_agent() 装配的字段
状态这次运行到哪了、花了多少Session / SessionStateWorkflowContext + Run ledgerAIAgent 状态袋
执行谁驱动循环、谁装配组件Runner + ParadigmErrorAnalyzerAgent + 外层 RunCoordinatorrun_conversation

nonoka-agent 里这个分离是最彻底的:Agent 是个没有任何运行时状态的 frozen 配置对象,Session 是唯一可变的事实源(可序列化、可持久化的状态机),Runner 是无状态的执行协调器,只负责把配置和状态组装起来、选择范式、驱动循环。

这个分离不是洁癖,它直接决定了 resume 能不能工作:Runner 不持有运行状态,所以进程崩了之后可以由一个新进程加载 checkpoint 重建 Session 继续跑。nonoka-cli 就是每次新 spawn 一个 server 进程、靠 sessionId 恢复之前的对话。

hermes 则是另一个(更现实的)极端:AIAgent 是一个承载了整个会话状态的”重型状态袋”,但所有方法都是转发器(forwarder),把调用路由到 agent/ 目录下的小模块:

# run_agent.py:5211
def run_conversation(self, ...):
    """Forwarder — see ``agent.conversation_loop.run_conversation``."""
    from agent.conversation_loop import run_conversation
    return run_conversation(self, ...)

这是 god file 拆分时的兼容策略——对外 API 和测试 patch 点不变,实现分散到小文件。不算优雅,但在长期演进的项目里很管用。

3. Bootstrap:Agent 干 Agent 的事,别的插进来

这是我在 supermodel-agent 里看到的最喜欢的设计。它的 src/index.ts 只调用一个 bootstrap()(src/bootstrap.ts:56),而 bootstrap() 是一个教科书式的组合根(Composition Root)——手动 new 出所有实现并显式注入,没有隐式全局单例:

// src/bootstrap.ts:56-120(简化)
export async function bootstrap(): Promise<Application> {
  const config = await loadConfig();                    // Zod 校验
  const lifecycle = createLifecycleManager(...);
  const cache = createCache(config.redis);              // Redis 失败降级内存
  const runRepository = createRunRepository(...);       // SQLite run ledger
  const runObserver = new CompositeRunObserver(
    new DurableRunObserver(runRepository),              // 必须可靠
    langfuseObserver ? [langfuseObserver] : [],         // best-effort
  );
  const durableStepExecutor = config.execution?.enabled
    ? new DurableStepExecutor(runRepository, ...)
    : undefined;                                        // 可选横切能力
  const runCoordinator = new RunCoordinator(runObserver, ..., durableStepExecutor);
  const llmService = new RetryableLlmService({ primary: ..., fallback: ... });
  const toolRegistry = new ToolRegistry();
  registerDefaultTools({ registry: toolRegistry, ... });
  if (wecomDocMcpService) {                             // MCP 失败不阻塞启动
    await wecomDocMcpService.connect();
    registerWeComDocTools(toolRegistry, wecomDocMcpService);
  }
  const agent = new ErrorAnalyzerAgent(config, toolRegistry, ...);
  ...
}

关键不在于装配顺序本身,而在于装配完之后的职责划分:

  • Agent 的主循环 runConversation 只负责 LLM↔Tool 的推理编排;
  • Run 的创建、心跳、状态机迁移、delivery 重试由外层的 RunCoordinator 负责;
  • 崩溃恢复、幂等 checkpoint、重试调度由 DurableStepExecutor + RunReconciler 负责;
  • 可观测性由实现 RunObserver 接口的组件负责。

a.a. 横切能力怎么做到”可插拔”

supermodel 用了一个很巧妙的机制:通过 AsyncLocalStorage 把当前 run 的上下文(runId、observer、stepExecutor)传播到任意 await 深处:

// src/observability/run-context.ts:7-31
export interface RunExecutionContext {
  runId: string;
  traceId: string;
  observer: RunObserver;
  stepExecutor?: DurableStepExecutor;
  recovering?: boolean;
}
const storage = new AsyncLocalStorage<RunExecutionContext>();

Agent 内部在关键步骤调用 executeCurrentDurableStep:如果 bootstrap 没启用 durable execution,stepExecutor 是 undefined,这个调用直接返回 undefined 走原路径——Agent 代码对 durable 完全无感知。同一套 Agent 代码既能跑轻量对话,也能跑生产级可恢复任务,本地开发和测试不需要拉起 durable 基础设施。

b.b. 可观测性的分层:可靠的必须可靠,观测的可以挂

RunObserver 接口(startRun / transition / startStep / completeStep / checkpoint)有两个实现:

  • DurableRunObserver:写 SQLite ledger,是业务正确性的来源(状态机、checkpoint、恢复都依赖它);
  • LangfuseRunObserver:外部 trace sink,纯观测。

CompositeRunObserver 用 Promise.allSettled 调用 best-effort observer——Langfuse 挂了不会把 Agent 拖垮。代码注释能看出来这是踩过坑的:外部 trace 系统故障曾经拖垮过请求。

原则只有一个:观测系统和权威状态必须解耦——Trace 回答”发生过什么”,权威状态(ledger / 状态文件)回答”现在能不能走下一步”。trace sink 挂了可以降级,权威状态不能。

c.c. 对比:nonoka 和 hermes 的扩展机制

nonoka-agent 的思路类似但形态不同,它提供两层扩展点:

  1. Hooks(中间件):观测与拦截事件(on_llm_request、on_tool_start_intercept 等),HITL 审批、可观测性、git checkpoint 都是这么挂进去的;
  2. LoopExtension:Loop 层级的受限扩展,四个生命周期钩子(before_turn / after_tool_batch / before_final_answer / after_run),只能返回一个 ExtensionDecision(feedback / continue_loop / replacement_content / failure / disable_tools)——明确不能执行工具、不能改 tool calls、不能放宽预算。

hermes 则是在数据边界上预留扩展点:请求级 middleware(改 LLM/Tool 请求 payload)、执行级 middleware(包装真实调用,可做重试/限流/影子请求)、插件 hooks(pre_llm_call、pre_tool_call、transform_llm_output……),连 context engine 都是可替换的(config.yaml 配置 → 插件目录 → 内置 ContextCompressor 兜底)。

三种形态,同一个原则:Loop 保持精简,横切能力通过装配注入,扩展点的能力边界要显式约束。

4. 生产级细节:面试官爱拷打的部分

这一节按”问题”组织,每个问题对比几个项目的解法。

a.a. 怎么防止 Agent 跑飞?—— 预算守卫

开放的 while True 循环在生产上是不可接受的,几个项目都做了多重预算守卫:

预算nonokasupermodel(bounded 模式)hermes
轮次max_model_turnsmaxRounds(默认 3)max_iterations(默认 90)
工具调用max_tool_calls(整批预占)maxToolCallsguardrails 熔断
时间wall_timeout_secondsmaxRunMs(默认 45s)—
Token/成本max_total_tokens / max_cost_usdmaxTotalTokens(默认 12000)—
无进展循环检测四层启发式consecutiveNoProgress >= 2ToolCallGuardrails

两个有意思的细节:

  • nonoka 的 reserve_tool_calls() 是整批预占的:模型一轮返回 5 个 tool calls,先检查 当前用量 + 5 是否超预算,超了整批中止,而不是执行到一半才发现超限;
  • hermes 有”grace call”设计:预算耗尽时给模型最后一次调用机会做总结,并且纯程序化的 execute_code 调用会 refund() 退还预算——不是所有调用都该消耗同一个预算。

b.b. 上下文爆了怎么办?—— 压缩策略

这是几个项目工程深度差异最大的地方:

nonoka:三层确定性优先策略(nonoka/core/memory.py)

  1. Microcompaction:同一逻辑工具(同文件 read、同 pattern grep)的旧结果替换为占位符,条目不删除,保持协议完整;
  2. Protocol-aware compaction:按完整的 ASSISTANT(tool_calls) + TOOL×N 协议单元弹出(OpenAI/DeepSeek API 要求 tool 消息必须紧跟声明它的 assistant 消息,粗暴滑动窗口会直接报 400),被弹出的内容整理成 [Compacted evidence ledger] 保留工具名、参数、exit_code 等审计信息;
  3. LLM summary(可选):摘要最老的约 1/3 历史,摘要 LLM 连续失败 3 次后熔断回退到确定性压缩——LLM 辅助优化不能成为可用性依赖。

另一个细节:工具结果写回时用 defer_budget=True,等整批结果写完再统一压缩,避免先弹出的 tool 结果让父 assistant 消息成为孤儿。

supermodel:业务摘要 + 实体校验(src/core/context.ts:205-215)

不是简单截断,而是做确定性业务摘要,并用 validateEntityPreservation 校验关键实体(sessionId、错误码等)是否在摘要中保留,校验失败就放弃这次压缩:

const candidate = buildDeterministicBusinessSummary(previousSummary, oldMessages, entities, targetTokens);
const validation = validateEntityPreservation(sourceText, candidate);
if (!validation.valid) {
  logger.error('候选摘要关键实体校验失败,保留旧 active checkpoint', ...);
  return;   // 宁可不压缩,也不能丢关键实体
}

hermes:多触发点 + 防抖动

压缩有三个触发点:turn 开场预检、每次 tool 后按真实 token usage 检查、provider 返回 413/context_overflow 时强制压缩。should_compress() 里有个防抖动设计:连续 2 次压缩无效(压完还是超)就不再压了,避免每轮都做无用功。

c.c. 工具调用能并行吗?—— 声明式并发

模型一轮返回多个 tool calls 时,大多数框架要么全串行要么 asyncio.gather 全并行。nonoka 选了中间道路:让工具作者声明副作用语义,执行器据此切分波次:

# nonoka/core/execution.py:11-38
@dataclass(frozen=True)
class ToolExecution:
    read_only: bool = False
    mutates_workspace: bool = False
    exclusive: bool = False
    stateful_action: bool = False

ToolExecutionCoordinator 把一轮调用按模型输出顺序切成”read-only 波”(并发)和”stateful 波”(串行),保证写操作不会超越前面的读/写。关键默认:未知语义的工具一律视为 stateful,强制串行——fail-safe 默认。

hermes 的做法类似但更偏规则:默认串行,只有判定为只读/无状态/路径不冲突的批次才进 ThreadPoolExecutor(上限 8 个 worker),write_file/patch 还要按路径判断是否重叠。

d.d. 模型死循环怎么检测?

  • nonoka:四层启发式——连续相同工具、相同参数重复、短周期 A→B→A→B、结果相似度,触发后先注入 system 反馈纠正,3 次后强制终止,并且会临时封禁肇事工具(_blocked_tools);
  • supermodel:按 工具名:归一化参数 的 fingerprint 去重(duplicate_tool_call)+ 连续无进展计数(no_progress),触发后以 finishGuardedRun 返回降级提示而不是硬报错;
  • hermes:ToolCallGuardrailController 跟踪失败签名(同工具反复失败、同参数重复失败、只读工具无进展),默认只 warn,可配置 hard_stop_enabled 熔断。

e.e. 进程崩了怎么办?—— 三种恢复哲学

nonoka:消息级 checkpoint + dangling replay。每次状态变更都 save_session(SQLite),关键设计是在执行工具之前先 checkpoint 带 tool_calls 的 assistant 消息。崩溃后 resume 时扫描内存,找到”有 assistant tool_calls 但缺少对应 TOOL 结果”的 dangling calls 并重放它们(paradigm.py:513-583)。代价是副作用可能重复,所以注释里明确说要配合 git checkpoint/rollback 缓解。

supermodel:步骤级 durable execution。关键步骤(intent_route、retrieval、tool_execution、synthesis)通过 DurableStepExecutor 包装,用 stepKey + inputHash 查已完成的 checkpoint,命中就直接复用结果不重复执行:

// src/execution/durable-step-executor.ts:45-78
const inputHash = durableInputHash(spec.input);
const cached = this.repository.findCompletedStep(spec.runId, spec.stepKey, inputHash);
if (cached?.checkpoint) {
  return this.repository.loadCheckpointPayload(cached.checkpoint.id);  // 幂等复用
}

崩溃的 Run 由后台 RunReconciler 周期性认领(SQLite 乐观锁 lease 防多实例抢同一个 run),交给 recovery handler 重放输入、复用已合成结果、重试投递。

hermes:文件系统级 checkpoint。CheckpointManager(tools/checkpoint_manager.py:579)维护一个单 shadow git 仓库(GIT_DIR + GIT_WORK_TREE,不污染用户项目),在 write_file/patch/破坏性命令前自动快照,每轮 new_turn() 重置去重;回滚前还会再打一个 pre-rollback 快照。

三种思路分别对应三个层次:对话状态恢复(nonoka)、执行步骤幂等(supermodel)、副作用可回滚(hermes)。完整的 runtime 其实三个都需要。

f.f. 模型说”完成了”就完了吗?—— 终止校验

经典 ReAct 的终止条件是”模型没再调工具”,这在 coding 场景下很容易被模型幻觉欺骗(还没改完就宣布完成)。几种解法:

  • nonoka 的 CompletionContract:把”完成”变成可验证证据——要求 workspace mutation、observed effect、focused verification 等,未满足时注入 system 反馈让循环继续;配合 VerifierRepairExtension 挂在 before_final_answer,最终答案先过确定性验证器,不通过就打回重修(有次数上限,修满还不行就 failure 终止);
  • supermodel 的 finishGuardedRun:任何预算/异常终止都不直接抛错,而是返回带 terminationReason 的降级提示,保证用户体验的连续性;
  • hermes 的 _handle_max_iterations:预算耗尽时主动生成一轮总结,而不是把半截状态丢给用户。

g.g. 其他值得一提的细节

  • hermes 的双副本消息流水线:内部维护完整的 messages(含 reasoning、finish_reason、内部 item id),每次 API 调用前复制出 api_messages 做 provider 适配清洗。系统提示只构建一次并缓存(保 prompt cache 命中),API 副本可以注入 ephemeral 内容而不污染持久化历史。跨 provider 的 reasoning 兼容(DeepSeek 要 echo reasoning_content、Anthropic 要 signed thinking blocks、Codex 有 encrypted replay)全部收敛到三个边界函数里,而不是散落在 loop 中;
  • hermes 的 Tool Search 渐进披露:MCP/插件工具太多时不全塞进 prompt,折叠成 tool_search / tool_describe / tool_call 三个桥接工具按需披露;
  • supermodel 的意图路由:强规则先守住能力边界,规则搞不定才调 LLM,而且 LLM 只输出意图类型,实体(sessionId、错误码)由服务端从原文重新提取——防止模型幻觉出实体进而调用错误工具;
  • supermodel 的 reasoning-only 恢复:推理模型有时会把 token 预算耗尽在 thinking 上导致 content 为空,检测到后关闭 thinking 再做一次恢复生成;
  • nonoka-cli 的 External Tool Receipt:外部宿主(OpenCode)执行的工具必须返回结构化 receipt,含 workspace 前后 digest、created/modified/deleted 文件列表;声明了 mutates_workspace 但没返回 attestation 的直接报错——把信任边界显式化,Agent 内核不需要猜 shell 语义。

5. 开源项目与阅读地图

自己闷头写容易陷进自己的思维定式,下面是我觉得值得对照阅读的资料:

资料值得看什么
Anthropic: Building Effective Agents概念地基。workflow vs agent 的区分、五种 workflow 模式、“用最简单的方案”的原则。面试概念题的标准答案来源
humanlayer/12-factor-agents工程原则。Factor 3(Own your context window)、Factor 8(Own your control flow)、Factor 10(Small, focused agents)和本文的预算/压缩/装配讨论直接对应
Codex CLI 内部实现解析Codex CLI 的完整拆解:agent loop、sandbox 隔离、tool calling、streaming、prompt 的级联装配(内置工具 + API 工具 + MCP 工具统一进 tools 字段)
shareAI-lab/analysis_claude_codeClaude Code v1.0.33 逆向工程:实时 steering 的双缓冲异步队列、多 Agent 隔离架构、智能上下文管理
逆向深扒 Claude Code 源码(12 层渐进式包装)“核心极简 while 循环 + 12 层包装”的视角,和本文”loop 保持精简、能力外挂”的观点互相印证
How OpenAI Codex Works (ByteByteGo)快速建立 Codex 整体认知的科普向文章

读这些资料时建议带着一个问题去读:它的 loop 里除了 LLM→tool 还有什么,那些东西是怎么挂进去的——这比记结论有用得多。

6. 速查表

最后把全文的对应关系压缩成一张表,面试前可以扫一眼:

问题一句话答案本文案例
Agent Loop 怎么架构?推理循环是骨架,runtime 的价值在循环边界上的预算/上下文/持久化/终止治理nonoka 12 步 turn 流水线、hermes prologue-loop-finalizer
核心抽象怎么划分?配置(Agent)、状态(Session)、执行(Runner)分离nonoka 三层、supermodel 组合根
扩展能力怎么挂?组合根显式装配 + 受限扩展点,不塞进 loopsupermodel AsyncLocalStorage + RunObserver、nonoka Hooks/LoopExtension、hermes middleware
怎么防跑飞?多维预算守卫(轮次/工具/时间/token/无进展),整批预占三家预算表
上下文管理?确定性压缩优先、协议单元完整、LLM 摘要可熔断、关键实体校验nonoka 三层压缩、supermodel 实体校验
工具并发?声明副作用语义,只读并发、写串行、未知默认串行nonoka ToolExecution 波次
崩溃恢复?对话 checkpoint + 步骤幂等 + 副作用可回滚,三个层次nonoka dangling replay、supermodel durable step、hermes shadow git
终止条件?不只信模型自述,用证据契约/确定性验证器校验CompletionContract、VerifierRepairExtension
可观测性?权威状态(ledger)必须可靠,trace sink 可以 best-effortsupermodel CompositeRunObserver

7. 总结

整理下来,我自己对”Agent Runtime 通用架构”的回答收敛成了三句话:

  1. Agent Loop 不等于 Runtime。while 循环谁都会写,Runtime 的工作是在循环的关键边界上做预算、上下文、持久化、循环检测和终止校验;
  2. Agent 干 Agent 的事,别的模块插进来。启动时用组合根显式装配,横切能力(可观测性、durable execution、评测)通过接口和受限扩展点注入,loop 保持精简;
  3. 确定性的事情交给代码。预算记账、状态机迁移、恢复重放、完成校验这些都不该依赖模型的自觉——模型负责不确定的理解与生成,Runtime 负责可验证的推进。

第三个观点和我在 Spec Engineering、面向 Agent 的轻量 Workflow 设计两篇文章里的结论是一致的,算是这几个项目做下来最稳定的一条心得了。