Agent 核心循环设计
Agent 核心循环设计
学习目标
读完本章后,你将能够:
- 理解 Session/Turn 两层循环模型的设计动机和适用场景
- 掌握 Submission Queue / Event Queue 事件驱动架构的实现要点
- 分析 Agent 循环中的中断、并发和状态一致性挑战
- 在自己的 Agent 项目中设计合理的循环结构
前置知识
- 异步编程基础(async/await、通道/Channel)
- 状态机基本思想
核心概念
1. Session/Turn 两层循环模型
Agent 的核心循环通常分为两个层次:
| 层次 | 职责 | 生命周期 |
|---|---|---|
| Session | 管理 Agent 的整体生命周期、配置、持久化状态 | 用户启动到主动退出 |
| Turn | 处理单次用户交互:接收输入 → 模型推理 → 执行动作 → 返回结果 | 一次用户提交到 Agent 响应完成 |
为什么分层?
- 隔离性:每次 Turn 的配置(模型参数、沙箱策略、权限等)可以独立变化,不影响 Session 级别的状态
- 可恢复性:Turn 失败或中断时,Session 可以继续处理下一个 Turn
- 可观测性:每个 Turn 的输入输出、耗时、Token 用量可以独立追踪和审计
2. Submission Queue / Event Queue 模式
SQ/EQ 是一种双向异步通信模式,广泛用于事件驱动的 Agent 系统:
Submission Queue(提交队列):承载用户发起的操作,典型操作包括:
UserInput:用户文本输入Interrupt:中断当前 TurnApproval:审批工具执行请求ThreadSettings:修改线程配置
Event Queue(事件队列):承载 Agent 输出的流式事件,典型事件包括:
ItemStarted/ItemCompleted:工具/消息的开始和完成OutputDelta:增量输出(流式渲染)TurnAborted:回合中止ErrorEvent:错误事件
设计优势:
- 解耦用户输入和 Agent 响应,支持流式输出和并发工具调用
- 统一的协议层使得 TUI、CLI、MCP 服务器等不同前端可以复用同一套后端
- 便于添加中间件(日志、遥测、拦截器)
3. Turn 上下文隔离
每个 Turn 应该携带完整的配置快照,而非引用共享的可变状态:
TurnContext { model: ModelConfig, // 本次使用的模型 sandbox: SandboxPolicy, // 沙箱策略 permissions: PermissionSet, // 权限集合 environment: EnvVars, // 环境变量 ...}关键原则:Turn 执行过程中,即使 Session 级别的配置被修改,也不会影响当前 Turn 的行为。这种”不可变快照”设计避免了并发修改带来的状态竞争。
设计模式详解
单任务串行 + 可中断
大多数 Agent 采用单任务串行模型:一个 Session 同时只能执行一个 Turn。但这不意味着系统无法处理并发:
- 用户输入并发:用户可以在当前 Turn 执行时提交新输入,新输入进入队列等待
- 工具调用并发:单个 Turn 内可以并行执行多个独立的工具调用
- 可中断性:用户可以随时发送 Interrupt 信号,终止当前 Turn
中断的实现要点:
- 使用
CancellationToken(Rust tokio-util)或类似机制传播取消信号 - 工具执行层需要监听取消信号,及时终止子进程
- 模型 API 的流式响应也需要支持中途关闭
输入队列管理
输入队列不仅管理用户输入,还可能承载 Agent 间通信(Inter-Agent Communication):
InputQueue { user_inputs: VecDeque<UserInput>, inter_agent_messages: VecDeque<IacMessage>, // 优先级:IAC 消息通常优先于用户输入}当多 Agent 协作时,一个 Agent 可能需要向另一个 Agent 发送消息。输入队列负责排序和去重。
问题与规避
中断时的状态一致性
问题:Turn 被中断时,已经执行的部分工具可能已修改了文件系统或数据库,导致状态不一致。
对策:
- 在 Turn 开始时创建配置快照,中断后恢复到快照状态
- 对文件系统操作使用事务或副本机制
- 工具执行结果在 Turn 完成前不入库,仅在内存中累积
事件流背压
问题:当模型输出速度超过前端渲染速度时,Event Queue 可能无限增长,导致内存溢出。
对策:
- 使用有界通道(bounded channel),当队列满时阻塞生产者
- 对增量输出(delta)进行合并,减少事件数量
- 前端实现跳帧渲染,丢弃过期的中间状态
并发工具调用的资源竞争
问题:并行执行的多个工具同时读写同一文件或同一数据库连接。
对策:
- 工具执行前检查资源依赖图,有冲突的工具串行执行
- 使用读写锁(RwLock)控制:读锁允许并行,写锁强制串行
- 对文件系统操作使用临时目录隔离
设计取舍
串行单任务 vs 多任务并行
| 方案 | 优势 | 代价 | 适用场景 |
|---|---|---|---|
| 串行单任务 | 实现简单、状态一致性好、用户可预测 | 吞吐量低、用户等待时间长 | 编程助手、对话机器人 |
| 多任务并行 | 高吞吐量、用户无需等待 | 状态管理复杂、竞争条件多 | 后台自动化、批量处理 |
大多数终端 Agent(如 Codex CLI、Claude Code)选择串行单任务模型,因为用户的注意力是单线程的,同时展示多个任务的进度反而会造成认知负担。
同步事件流 vs 异步回调
| 方案 | 优势 | 代价 |
|---|---|---|
| 事件流(推模式) | 实时性强、解耦彻底、支持多消费者 | 需要处理背压、事件顺序保证 |
| 轮询(拉模式) | 实现简单、消费者控制节奏 | 延迟高、资源浪费 |
| 回调 | 延迟低、集成简单 | 容易形成回调地狱、错误处理困难 |
现代 Agent 系统普遍采用事件流(推模式),配合有界通道和背压机制。
流式工具调用模式
传统 Agent 循环是”等 LLM 输出全部完成后再执行工具”,但这会增加延迟。更高效的模式是边流式输出边并行执行工具:
实现要点:
- LLM 流式输出中解析
tool_call事件,立即启动工具执行 - 多个独立工具调用并行执行(通过
Effect.forEach的discard: true+concurrency: "unbounded") - 工具完成后通过
Deferred通知 Processor,Processor 将结果喂回 LLM - 文本输出和工具执行互不阻塞
Doom Loop 检测:当 Agent 连续 N 次(如 3 次)调用同一工具且输出相同错误时,视为 “Doom Loop”,自动终止循环并向用户报告。这是防止 LLM 陷入无效重试循环的关键安全机制。
工具调用生命周期管理
在流式工具调用模式下,每个工具调用经历四个阶段:
| 阶段 | 状态 | 行为 |
|---|---|---|
| Pending | pending | LLM 发起调用,工具尚未开始执行 |
| Running | running | 工具正在执行,可能正在读写文件或运行命令 |
| Completed | completed | 工具执行成功,输出结果 |
| Error | error | 工具执行失败,返回错误信息 |
关键设计决策:
- 状态转换通过 Deferred 实现:工具启动时创建一个
Deferred<void>,完成时 resolve,Processor 等待 Deferred 完成 - AbortSignal 传递:如果用户中断 Turn,AbortSignal 通过 Tool Context 传递,工具执行层监听并及时终止
- 错误类型化:使用
TaggedError(如InvalidArgumentsError)使上游可以精确匹配错误类型,而非字符串匹配
Doom Loop 检测与行为循环识别
传统 Doom Loop 检测关注”同一工具 + 同一错误”的重复模式。更广义的行为循环检测应该覆盖:
- 动作序列重复:Agent 在多个 turn 中执行相同或相似的动作序列(如反复点击不同索引但同一类的元素)
- 页面状态停滞:Agent 执行动作后,页面 DOM 指纹(文本内容哈希 + 元素计数)未发生有意义的变化
- 渐进式退化:Agent 的动作逐渐退化为无意义操作(如反复 scroll 但从未找到目标)
实现策略:
- 维护一个滑动窗口的动作历史记录
- 每次动作后计算页面指纹(DOM 文本表示的哈希 + 交互元素数量)
- 当检测到 N 个连续动作中,动作名重复率超过阈值且页面状态停滞时,注入引导消息
- 引导消息应逐步升级:温和提醒 → 明确建议改变策略 → 强制终止
为什么这比传统 Doom Loop 检测更有效:传统检测只看工具名和错误消息,容易漏掉”Agent 在不断尝试但每次换不同参数”的隐性循环。结合页面状态指纹可以更早发现。
规划与 ReAct 循环的融合
将规划(Planning)集成到 ReAct 循环中,可以在多步任务中显著提升成功率:
- 延迟规划:允许 Agent 先探索几步再创建计划,而非一开始就强制规划(探索限制通常为 3-5 步)
- 动态更新:Agent 可以在执行中通过
plan_update字段重写整个计划,而非只能推进当前计划 - 停滞触发重规划:连续失败 N 次后,自动注入”建议重规划”的引导消息
- 步骤级预算警告:当 Agent 使用了 75% 的步骤预算时,注入警告,引导其优先保存结果并调用 done
设计权衡:
- 优点:Agent 既有方向感,又有灵活性
- 代价:plan 字段增加 Token 消耗;低能力模型可能无法正确维护计划状态
- 替代方案:对简单任务禁用 planning(
flash_mode),直接 ReAct
参考来源
- ReAct: Synergizing Reasoning and Acting in Language Models, Yao et al., 2022
- OpenAI Responses API Documentation
补充:Hermes Agent 的同步单线程循环实现
来源:Hermes Agent(NousResearch/hermes-agent)agent/conversation_loop.py,commit 2517917
同步主循环 + 迭代预算
Hermes 采用同步单线程的 ReAct 循环,与常见的异步事件驱动架构不同:
while (api_call_count < max_iterations and budget.remaining > 0) or grace_call: 1. 检查中断信号 → 退出 2. 消费预算计数器 → 不足则退出 3. 构建 api_messages(注入记忆上下文 + 插件钩子) 4. 调用 LLM API 5. 处理工具调用(并发最多 8 线程,或顺序) 6. 检查结果,继续循环迭代预算(IterationBudget):线程安全的迭代计数器,父 Agent 默认 90 次,子 Agent 委派默认 50 次。与 max_iterations 双重保护。
One-turn Grace 机制
当预算耗尽时,不是立即终止,而是给予一次额外的 grace 调用:
if not agent.iteration_budget.consume(): if not agent._budget_grace_call: break # 真正退出 agent._budget_grace_call = False # 消耗 grace# grace 轮继续执行,执行完毕后循环必然退出为什么需要 grace? 避免模型在预算即将耗尽时被截断,导致工具调用不完整或回复被截断。Grace 轮让模型有机会完成当前任务或生成合理的终止回复。
系统提示缓存与前缀缓存保护
Hermes 的系统提示只构建一次,缓存到 _cached_system_prompt,并通过 SQLite 持久化:
- 首次构建:
_build_system_prompt()→ 写入session_db.update_system_prompt() - 后续轮次:从 SQLite 读取,复用 verbatim → Anthropic prefix cache 前缀匹配
- 仅在上下文压缩后重建(压缩使缓存失效)
缓存保护设计:所有插件上下文、记忆 prefetch、技能内容都注入到用户消息而非系统提示,保持系统提示跨轮次字节级稳定。
预飞行上下文压缩
在进入主循环前,Hermes 会预检当前消息列表是否已超模型上下文阈值:
if len(messages) > protect_first_n + protect_last_n + 1: tokens = estimate_request_tokens_rough(messages, system_prompt, tools) if compressor.should_compress(tokens): # 最多 3 轮迭代压缩,直到低于阈值 for _pass in range(3): messages, system_prompt = compress(messages, ...) if len(messages) >= _orig_len: break设计启示:这种”进入循环前先瘦身”的策略避免了在循环内部才发现问题、导致整个 turn 浪费的情况。特别是用户切换小上下文模型时,历史对话可能已经超出新模型的窗口。
插件钩子与缓存感知
pre_llm_call 钩子在每次 turn 前触发,插件可注入上下文。关键约束:注入到用户消息,不修改系统提示,以保护前缀缓存。
Slash 命令中的缓存感知设计:--now 标志控制立即/延迟失效,默认为延迟(下轮会话生效),避免中间途修改导致缓存失效。
补充:Cline 的循环推动与检测机制
来源:Cline(cline/cline)apps/vscode/src/core/task/index.ts,commit 791d238
循环推动机制(noToolsUsed)
Cline 的 Agent 循环使用 while (!abort) 结构,核心方法 recursivelyMakeClineRequests 在一次 LLM 调用后,如果模型返回了工具调用但没有调用 attempt_completion(完成任务信号),循环自动继续。
关键问题:如果模型既不调用工具也不完成任务怎么办?
Cline 的解决方案是注入 noToolsUsed 提示词:当模型返回的工具调用列表为空(或未识别到任何已知工具),系统自动向下一轮请求中注入一段引导提示词,明确告诉模型”你必须调用工具来推进任务”。
# 伪代码:循环推动逻辑while (!abort) { const result = await recursivelyMakeClineRequests(...) if (result.terminationReason === "tool_use") { continue // 模型调用了工具,继续下一轮 } if (result.terminationReason === "no_tools") { injectPrompt("你必须调用工具来完成任务...") // 推动模型继续 continue } break // 任务完成或用户中断}设计启示:Agent 循环必须有明确的”继续/终止”判定条件。如果循环中某个步骤没有产出可执行动作,Agent 应该主动推动模型,而非等待用户介入。
循环检测协议(loop-detection.ts)
Cline 实现了两级阈值的循环检测:
| 阈值 | 触发条件 | 行为 |
|---|---|---|
| 软阈值(3 次) | 相同工具 + 相同参数连续调用 3 次 | 发出警告,注入引导提示词 |
| 硬阈值(5 次) | 相同工具 + 相同参数连续调用 5 次 | 强制终止任务,向用户报告 |
检测维度:
- 工具名称匹配
- 参数哈希对比
- 连续调用计数
升级策略:软阈值触发时,向模型注入”你正在重复调用同一工具,请改变策略”的提示;如果模型仍未改变行为,达到硬阈值后强制终止。
为什么两级阈值? 单次重复可能是合理的(如网络重试),但连续 5 次几乎可以确认是模型陷入了无效循环。两级设计给了模型自我纠正的机会,同时设置了明确的终止边界。
流式失败重试与指数退避
Cline 的 attemptApiRequest 方法在流式调用 LLM 时实现了自动重试机制:
- 首次失败:自动重试,指数退避(2s → 4s → 8s)
- 连续 3 次失败后:询问用户是否继续重试
- 用户选择否:终止任务
设计启示:网络请求失败不应该立即终止 Agent 任务。指数退避可以应对短暂的 API 限流或网络抖动;超过阈值后降级到用户决策,避免无意义的重试循环。
潜在陷阱:退避时间的选择。太短(<1s)可能无法缓解 API 限流;太长(>30s)会让用户体验极差。Cline 选择 2s 起步、8s 上限是平衡点。
上下文窗口超限处理
当上下文窗口使用超过阈值时,Cline 的执行流程:
- 运行
PreCompactHook(允许外部脚本拦截) - 执行激进截断(quarter 策略:截掉最后 1/4 对话历史)
- 如果仍然超出,触发
summarize_task工具让模型生成摘要
与通用策略的区别:Cline 采用 Hook-first 设计,允许用户在截断前注入自定义逻辑(如保存重要上下文到外部存储)。这比硬编码截断策略更灵活。
补充:CrewAI 的两套 Agent 执行循环
来源:CrewAI(crewAIInc/crewAI)lib/crewai/src/crewai/agent/core.py、lib/crewai/src/crewai/experimental/agent_executor.py、lib/crewai/src/crewai/agents/crew_agent_executor.py,commit 77a6127
CrewAI 存在两套并行的 Agent 执行循环,代表从 ReAct 到 Plan-and-Execute 的架构演进。
旧版:CrewAgentExecutor(ReAct 循环,已废弃)
while not AgentFinish: 1. 检查 max_iter 限制 2. RPM 限速 3. 调用 LLM 4. 解析响应 → AgentAction 或 AgentFinish 5. if AgentAction → 执行工具 → 追加消息 → 循环 6. if 上下文超限 → 压缩或继续双路径:
- 原生工具路径:如果 LLM 支持 function calling,直接使用结构化工具调用 API(
_invoke_loop_native_tools()) - ReAct 文本路径:通过正则解析
Action: xxx\nAction Input: yyy格式(_invoke_loop_react())
ToolUsage 解析策略:4 种尝试依次解析 LLM 输出中的工具调用——JSON → AST → json5 → repair_json,最多 3 次重试。
新版:AgentExecutor(Flow-based Plan-and-Execute,默认)
核心状态(AgentExecutorState):
messages: list[LLMMessage] # 对话历史iterations: int # 当前迭代次数todos: TodoList # 待办事项列表plan: str | None # 生成的执行计划observations: dict[int, StepObservation] # 每步观察execution_log: list[dict] # 审计日志PlannerObserver 三级推理努力:
| 级别 | 行为 | LLM 调用 |
|---|---|---|
low | 启发式观察(不调 LLM),直接继续 | 0 |
medium | 每步 LLM 观察,失败时 replan | 1/步 |
high | 完整管道:观察 → 决策 → replan/refine | 2+/步 |
设计取舍:
- ReAct 循环简单可靠,但缺乏计划能力,容易在复杂任务中迷失方向
- Plan-and-Execute 支持并行和重规划,但 LLM 调用次数更多,planning 本身可能失败
- CrewAI 通过
planning配置让用户选择:简单任务用 ReAct,复杂任务用 Plan-and-Execute
补充:Flowise 的图驱动 Agent 循环
来源:Flowise(FlowiseAI/Flowise)packages/server/src/utils/buildChatflow.ts、buildAgentGraph.ts、buildAgentflow.ts,commit bc22bf8
Flowise 代表了一种与 Session/Turn 模型完全不同的 Agent 循环范式——图驱动执行模型。
三种执行模式
Flowise 根据 Flow 类型路由到不同的执行引擎:
| 模式 | 触发条件 | 执行引擎 | 适用场景 |
|---|---|---|---|
| Chatflow | 普通 Chat Flow | BFS 图遍历(executeFlow) | 简单 LLM 调用、RAG 管线 |
| AgentGraph | Multi Agents / Sequential Agents 类别节点 | LangGraph StateGraph(compileMultiAgentsGraph / compileSeqAgentGraph) | Supervisor/Worker 多 Agent 协作 |
| AgentFlow V2 | chatflow.type === 'AGENTFLOW' | 有向图 BFS + 条件等待节点(executeAgentFlow) | 复杂条件分支、循环、迭代 |
图驱动循环的核心流程
关键设计决策:
- BFS 而非 DFS:保证节点按拓扑序(层级)执行,避免深层递归栈溢出
- 动态 require:节点实例在运行时按需加载(
import(nodeInstanceFilePath)),支持热插拔 - 变量解析管道:
resolveVariables处理{{variable}}引用链,包括$vars、$flow、$question等内置变量 - 多 Ending Node 处理:当存在多个终止节点时,执行最后一个节点(通常是输出节点)
AgentFlow V2 的条件等待机制
AgentFlow V2 引入了比简单 BFS 更复杂的执行模型:
IWaitingNode { nodeId: string // 等待的节点 ID receivedInputs: Map // 已收到的输入 expectedInputs: Set // 期望的输入集合 isConditional: boolean // 是否为条件节点 conditionalGroups: Map // 条件分组(OR 逻辑)}条件节点支持 OR 逻辑:当任一条件分组的输入全部到位时即可执行,无需等待所有输入。这与 AND 节点(所有输入到位才执行)形成对比。
Loop 计数淘汰:每个节点维护 loopCounts Map,当循环次数超过阈值时自动终止,防止无限循环。
与 Session/Turn 模型的对比
| 维度 | Session/Turn 模型 | 图驱动模型(Flowise) |
|---|---|---|
| 控制流 | 代码定义的循环 | 图结构定义的拓扑序 |
| 灵活性 | 需要改代码才能调整循环 | 拖拽修改图即可调整流程 |
| 复杂度 | 适合复杂的状态管理 | 适合线性/分支流水线 |
| 调试 | 断点调试 | 节点级日志追踪 |
| 学习曲线 | 高(需要编程能力) | 低(可视化拖拽) |
设计启示:图驱动模型将”Agent 循环的逻辑”从代码层外化到图结构中。这使得非程序员也能构建复杂的 Agent 流程,但代价是图本身无法表达所有编程模式(如递归、动态生成的控制流)。
补充:Goose 的流式事件驱动 Agent 循环
来源:Goose(aaif-goose/goose)crates/goose/src/agents/agent.rs,commit 1cb5cb0
流式 reply() 架构
Goose 的 Agent::reply() 返回 BoxStream<'_, Result<AgentEvent>>,整个 Agent 循环是一个异步流:
reply(user_message, session_config, cancel_token) -> Stream<AgentEvent> ├── AgentEvent::Message(text_delta) // 流式文本输出 ├── AgentEvent::McpNotification(...) // MCP 工具调用状态 ├── AgentEvent::HistoryReplaced // 上下文压缩后历史替换 └── AgentEvent::ToolConfirmation(...) // 需要用户确认与同步循环的差异:
| 维度 | 同步循环(如 Hermes) | 流式循环(Goose) |
|---|---|---|
| 返回类型 | Future<Response> | Stream<Event> |
| UI 更新 | 完成后一次性展示 | 实时逐 chunk 渲染 |
| 工具执行 | 全部完成后返回 | 边流式输出边并行启动 |
| 中断 | 需要检查 CancellationToken | 流自然终止 |
| 内存 | 累积全部响应 | 逐 chunk 消费 |
Turn 循环内部流程
上下文修复管道(fix_conversation)
在每次调用 LLM 前,Goose 对对话历史应用一系列修复:
- 合并连续同角色消息:如果连续两条 user 或 assistant 消息,合并为一条
- 移除空消息:删除无内容的消息
- 修复孤立的工具调用:没有对应 tool_result 的 tool_call 被清理
- 确保首尾消息是 user:第一条和最后一条消息必须是用户输入
- 空对话占位:如果对话为空,添加占位消息
为什么需要修复? LLM API 对消息格式有严格要求(user/assistant 交替、工具调用必须有对应结果)。Agent 循环中的压缩、中断、错误恢复等操作可能破坏这些不变量。修复管道在每次调用前确保格式正确,比依赖调用方保证更可靠。
并发工具执行
工具调用使用 stream::select_all 并发执行所有独立工具:
// 伪代码:并发工具执行let tool_streams: Vec<_> = tool_requests.iter().map(|req| { execute_tool(req) // 每个工具返回 Stream<AgentEvent>}).collect();
stream::select_all(tool_streams) // 合并为单一流设计优势:多个独立工具(如 read_file + grep)可以并行执行,减少整体延迟。同时每个工具的进度事件(开始、完成、错误)仍然可以实时传递给 UI。
补充:Agno 的确定性执行管线
来源:Agno
libs/agno/agno/agent/_run.py,2026-05-30
Agno 实现了一个 16 步的确定性执行管线(_run 函数),每一步都有明确的职责和取消检查点:
后台并行线程模式
Agno 在步骤 6 之后、步骤 8(模型调用)之前,启动三个独立的后台线程:
- 记忆创建(
start_memory_future):基于当前对话提取用户记忆 - 学习提取(
start_learning_future):从交互中提取可复用知识 - 文化知识创建(
start_cultural_knowledge_future):更新组织级共享知识
这三个线程与主执行管线并行运行,不阻塞 LLM 调用。在步骤 12 等待它们完成。这种设计将”非关键的后台工作”与”关键的前台响应”分离,确保用户体验不受记忆维护的延迟影响。
取消检查点
管线在每个关键步骤之间插入 raise_if_cancelled(run_id) 检查:
- Session 创建后
- 依赖解析后
- 消息准备后
- 模型调用前
- 模型调用后
这使得外部调用方可以随时取消运行,且不会出现”取消后仍继续执行”的资源浪费。
重试逻辑
重试在管线外层循环,每次重试完整重走 16 步管线(而非只重试模型调用):
num_attempts = agent.retries + 1for attempt in range(num_attempts): # 完整走一遍 16 步管线支持指数退避(exponential_backoff=True,delay_between_retries 每次翻倍)。