跳转到内容

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:中断当前 Turn
  • Approval:审批工具执行请求
  • 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.forEachdiscard: true + concurrency: "unbounded"
  • 工具完成后通过 Deferred 通知 Processor,Processor 将结果喂回 LLM
  • 文本输出和工具执行互不阻塞

Doom Loop 检测:当 Agent 连续 N 次(如 3 次)调用同一工具且输出相同错误时,视为 “Doom Loop”,自动终止循环并向用户报告。这是防止 LLM 陷入无效重试循环的关键安全机制。

工具调用生命周期管理

在流式工具调用模式下,每个工具调用经历四个阶段:

阶段状态行为
PendingpendingLLM 发起调用,工具尚未开始执行
Runningrunning工具正在执行,可能正在读写文件或运行命令
Completedcompleted工具执行成功,输出结果
Errorerror工具执行失败,返回错误信息

关键设计决策

  • 状态转换通过 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

参考来源


补充: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 的执行流程:

  1. 运行 PreCompact Hook(允许外部脚本拦截)
  2. 执行激进截断(quarter 策略:截掉最后 1/4 对话历史)
  3. 如果仍然超出,触发 summarize_task 工具让模型生成摘要

与通用策略的区别:Cline 采用 Hook-first 设计,允许用户在截断前注入自定义逻辑(如保存重要上下文到外部存储)。这比硬编码截断策略更灵活。


补充:CrewAI 的两套 Agent 执行循环

来源:CrewAI(crewAIInc/crewAI)lib/crewai/src/crewai/agent/core.pylib/crewai/src/crewai/experimental/agent_executor.pylib/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 观察,失败时 replan1/步
high完整管道:观察 → 决策 → replan/refine2+/步

设计取舍

  • ReAct 循环简单可靠,但缺乏计划能力,容易在复杂任务中迷失方向
  • Plan-and-Execute 支持并行和重规划,但 LLM 调用次数更多,planning 本身可能失败
  • CrewAI 通过 planning 配置让用户选择:简单任务用 ReAct,复杂任务用 Plan-and-Execute

补充:Flowise 的图驱动 Agent 循环

来源:Flowise(FlowiseAI/Flowise)packages/server/src/utils/buildChatflow.tsbuildAgentGraph.tsbuildAgentflow.ts,commit bc22bf8

Flowise 代表了一种与 Session/Turn 模型完全不同的 Agent 循环范式——图驱动执行模型

三种执行模式

Flowise 根据 Flow 类型路由到不同的执行引擎:

模式触发条件执行引擎适用场景
Chatflow普通 Chat FlowBFS 图遍历(executeFlow简单 LLM 调用、RAG 管线
AgentGraphMulti Agents / Sequential Agents 类别节点LangGraph StateGraph(compileMultiAgentsGraph / compileSeqAgentGraphSupervisor/Worker 多 Agent 协作
AgentFlow V2chatflow.type === 'AGENTFLOW'有向图 BFS + 条件等待节点(executeAgentFlow复杂条件分支、循环、迭代

图驱动循环的核心流程

关键设计决策

  1. BFS 而非 DFS:保证节点按拓扑序(层级)执行,避免深层递归栈溢出
  2. 动态 require:节点实例在运行时按需加载(import(nodeInstanceFilePath)),支持热插拔
  3. 变量解析管道resolveVariables 处理 {{variable}} 引用链,包括 $vars$flow$question 等内置变量
  4. 多 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(模型调用)之前,启动三个独立的后台线程:

  1. 记忆创建start_memory_future):基于当前对话提取用户记忆
  2. 学习提取start_learning_future):从交互中提取可复用知识
  3. 文化知识创建start_cultural_knowledge_future):更新组织级共享知识

这三个线程与主执行管线并行运行,不阻塞 LLM 调用。在步骤 12 等待它们完成。这种设计将”非关键的后台工作”与”关键的前台响应”分离,确保用户体验不受记忆维护的延迟影响。

取消检查点

管线在每个关键步骤之间插入 raise_if_cancelled(run_id) 检查:

  • Session 创建后
  • 依赖解析后
  • 消息准备后
  • 模型调用前
  • 模型调用后

这使得外部调用方可以随时取消运行,且不会出现”取消后仍继续执行”的资源浪费。

重试逻辑

重试在管线外层循环,每次重试完整重走 16 步管线(而非只重试模型调用):

num_attempts = agent.retries + 1
for attempt in range(num_attempts):
# 完整走一遍 16 步管线

支持指数退避(exponential_backoff=Truedelay_between_retries 每次翻倍)。