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-agent | supermodel | hermes |
|---|---|---|---|---|
| 配置 | 用什么模型、挂什么工具、预算多少 | Agent(frozen dataclass) | AppConfig(Zod 校验) | init_agent() 装配的字段 |
| 状态 | 这次运行到哪了、花了多少 | Session / SessionState | WorkflowContext + Run ledger | AIAgent 状态袋 |
| 执行 | 谁驱动循环、谁装配组件 | Runner + Paradigm | ErrorAnalyzerAgent + 外层 RunCoordinator | run_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接口的组件负责。
横切能力怎么做到”可插拔”
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 基础设施。
可观测性的分层:可靠的必须可靠,观测的可以挂
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 挂了可以降级,权威状态不能。
对比:nonoka 和 hermes 的扩展机制
nonoka-agent 的思路类似但形态不同,它提供两层扩展点:
- Hooks(中间件):观测与拦截事件(
on_llm_request、on_tool_start_intercept等),HITL 审批、可观测性、git checkpoint 都是这么挂进去的; - 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. 生产级细节:面试官爱拷打的部分
这一节按”问题”组织,每个问题对比几个项目的解法。
怎么防止 Agent 跑飞?—— 预算守卫
开放的 while True 循环在生产上是不可接受的,几个项目都做了多重预算守卫:
| 预算 | nonoka | supermodel(bounded 模式) | hermes |
|---|---|---|---|
| 轮次 | max_model_turns | maxRounds(默认 3) | max_iterations(默认 90) |
| 工具调用 | max_tool_calls(整批预占) | maxToolCalls | guardrails 熔断 |
| 时间 | wall_timeout_seconds | maxRunMs(默认 45s) | — |
| Token/成本 | max_total_tokens / max_cost_usd | maxTotalTokens(默认 12000) | — |
| 无进展 | 循环检测四层启发式 | consecutiveNoProgress >= 2 | ToolCallGuardrails |
两个有意思的细节:
- nonoka 的
reserve_tool_calls()是整批预占的:模型一轮返回 5 个 tool calls,先检查当前用量 + 5是否超预算,超了整批中止,而不是执行到一半才发现超限; - hermes 有”grace call”设计:预算耗尽时给模型最后一次调用机会做总结,并且纯程序化的
execute_code调用会refund()退还预算——不是所有调用都该消耗同一个预算。
上下文爆了怎么办?—— 压缩策略
这是几个项目工程深度差异最大的地方:
nonoka:三层确定性优先策略(nonoka/core/memory.py)
- Microcompaction:同一逻辑工具(同文件 read、同 pattern grep)的旧结果替换为占位符,条目不删除,保持协议完整;
- Protocol-aware compaction:按完整的
ASSISTANT(tool_calls) + TOOL×N协议单元弹出(OpenAI/DeepSeek API 要求 tool 消息必须紧跟声明它的 assistant 消息,粗暴滑动窗口会直接报 400),被弹出的内容整理成[Compacted evidence ledger]保留工具名、参数、exit_code 等审计信息; - 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 次压缩无效(压完还是超)就不再压了,避免每轮都做无用功。
工具调用能并行吗?—— 声明式并发
模型一轮返回多个 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 还要按路径判断是否重叠。
模型死循环怎么检测?
- nonoka:四层启发式——连续相同工具、相同参数重复、短周期 A→B→A→B、结果相似度,触发后先注入 system 反馈纠正,3 次后强制终止,并且会临时封禁肇事工具(
_blocked_tools); - supermodel:按
工具名:归一化参数的 fingerprint 去重(duplicate_tool_call)+ 连续无进展计数(no_progress),触发后以finishGuardedRun返回降级提示而不是硬报错; - hermes:
ToolCallGuardrailController跟踪失败签名(同工具反复失败、同参数重复失败、只读工具无进展),默认只 warn,可配置hard_stop_enabled熔断。
进程崩了怎么办?—— 三种恢复哲学
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 其实三个都需要。
模型说”完成了”就完了吗?—— 终止校验
经典 ReAct 的终止条件是”模型没再调工具”,这在 coding 场景下很容易被模型幻觉欺骗(还没改完就宣布完成)。几种解法:
- nonoka 的 CompletionContract:把”完成”变成可验证证据——要求 workspace mutation、observed effect、focused verification 等,未满足时注入 system 反馈让循环继续;配合
VerifierRepairExtension挂在before_final_answer,最终答案先过确定性验证器,不通过就打回重修(有次数上限,修满还不行就 failure 终止); - supermodel 的 finishGuardedRun:任何预算/异常终止都不直接抛错,而是返回带
terminationReason的降级提示,保证用户体验的连续性; - hermes 的
_handle_max_iterations:预算耗尽时主动生成一轮总结,而不是把半截状态丢给用户。
其他值得一提的细节
- hermes 的双副本消息流水线:内部维护完整的
messages(含 reasoning、finish_reason、内部 item id),每次 API 调用前复制出api_messages做 provider 适配清洗。系统提示只构建一次并缓存(保 prompt cache 命中),API 副本可以注入 ephemeral 内容而不污染持久化历史。跨 provider 的 reasoning 兼容(DeepSeek 要 echoreasoning_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_code | Claude 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 组合根 |
| 扩展能力怎么挂? | 组合根显式装配 + 受限扩展点,不塞进 loop | supermodel 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-effort | supermodel CompositeRunObserver |
7. 总结
整理下来,我自己对”Agent Runtime 通用架构”的回答收敛成了三句话:
- Agent Loop 不等于 Runtime。
while循环谁都会写,Runtime 的工作是在循环的关键边界上做预算、上下文、持久化、循环检测和终止校验; - Agent 干 Agent 的事,别的模块插进来。启动时用组合根显式装配,横切能力(可观测性、durable execution、评测)通过接口和受限扩展点注入,loop 保持精简;
- 确定性的事情交给代码。预算记账、状态机迁移、恢复重放、完成校验这些都不该依赖模型的自觉——模型负责不确定的理解与生成,Runtime 负责可验证的推进。
第三个观点和我在 Spec Engineering、面向 Agent 的轻量 Workflow 设计两篇文章里的结论是一致的,算是这几个项目做下来最稳定的一条心得了。