Agent Loop 的运行流程:从单体循环到三阶段 Pipeline

echo-agent 前身为 2025 年 11 月启动的个人助理项目 fubot,最初面向长期陪伴型个人智能体,围绕认知记忆、上下文延续、用户偏好沉淀、任务闭环与持续自我优化展开。随着真实场景迭代,项目逐步形成多入口接入、统一事件模型、消息总线、Agent Loop、多模型抽象、工具调用、MCP 接入、任务调度、权限审批、运行轨迹、长期记忆和受控自演进等能力。目前已支持微信、QQ、CLI、Gateway、Webhook、Cron 等入口,服务用户超过 20 万、累计下载超过 50 万,是面向长期运行、记忆增强和可持续成长智能体的开源 Agent Runtime。

项目地址:github.com/fuyuxiang/e...

你可能见过这样的 Agent demo:一个 while True 循环里调用模型,模型要工具就执行工具,把结果塞回去,再继续问模型,直到它输出最终回答。

这个写法很适合解释 ReAct 的基本思想。问题是,一旦进入真实工程,它很快会膨胀:会话要不要加锁?流式消息谁来发?审批命令要不要进模型?工具执行失败怎么追踪?最终回答保存在哪里?

如果这些问题都塞进同一个循环,Agent Loop 很快就不再是"循环",而是一个难以测试、难以观测、难以扩展的大函数。

本篇只讲一个点:Agent Loop 不是模型内部的 while 循环,而是一次入站事件的系统级生命周期控制

问题入口

很多 Agent 教程会把 Loop 简化成下面这样:

ini 复制代码
while True:
    user_input = read_user_input()
    message = call_llm(user_input)
    if message.tool_calls:
        result = run_tool(message.tool_calls)
        continue
    return message.content

这个模型没有错,但它只覆盖了推理阶段里的一小段:模型、工具、观察、继续推理。

真实系统面对的不是一行用户输入,而是一个事件。事件可能来自 CLI、HTTP Gateway、WebSocket、企业微信、定时任务,也可能来自测试代码中的直接调用。事件进入系统后,还要带上 session、身份、通道、trace、流式输出方式和可能的媒体信息。

会调用工具只说明系统有行动接口;能不能成为 Agent,要看工具调用是否进入受状态、权限和追踪约束的闭环。

所以,生产级 Agent Loop 的最小单位不应该是 user_input -> text,而应该是:

rust 复制代码
InboundEvent -> Pipeline -> OutboundEvent / response_text

为了不停留在抽象层面,下面以 echo-agent 的实现为例。

事件契约

echo-agent 的 Agent Loop 首先守住的是事件契约。通道层负责把外部消息翻译成统一的 InboundEvent,Loop 不直接关心企业微信怎么验签、HTTP 怎么鉴权、CLI 怎么读取标准输入。

这带来一个直接好处:Agent Loop 不绑定具体入口。只要入口能生成 InboundEvent,就可以进入同一条核心运行路径。

输出也不能被假设成"立即返回字符串"。在异步通道里,回答可能通过 MessageBus 发布成 OutboundEvent;在 CLI 或测试场景里,也可以由 process_direct 返回 response_text。这就是 publish_response 这类参数存在的原因。

事件契约里还有两个很硬的工程边界。

第一,同一 session 内的事件要串行处理。用户连续发两条消息,如果第一条还在执行工具,第二条已经开始构造上下文,历史记录、工具结果和记忆快照就可能交错写入。不同 session 可以并发,但同一 session 必须保持语义顺序。

第二,每次事件处理都要创建可追踪边界。echo-agent 会为事件生成 trace_id,再用 TraceLogger 记录模型调用、工具调用和错误信息。没有这层追踪,Agent 的最终回答只能靠零散日志猜。

控制循环

"Loop" 这个词容易误导人。很多人听到 Agent Loop,会自然想到模型内部的 ReAct 循环:思考、调用工具、观察结果、继续思考。

但系统层面的 Agent Loop 更像控制循环。它包含四个要素:输入、状态、控制决策和输出。

以"帮我修复测试失败"为例,输入是一次 InboundEvent;状态包括当前会话、仓库上下文、历史工具结果、审批状态和模型路由健康;控制决策包括是否加载历史、是否暴露写文件工具、是否需要审批、是否继续迭代;输出可能是进度消息、工具调用 trace、最终回答、会话保存和后台记忆整理。

这和 ReAct 循环不是一回事:

层级 关注点 典型步骤
ReAct 循环 模型如何使用工具 推理、工具调用、工具结果、继续推理
系统循环 一次事件如何被治理 事件进入、会话加载、上下文构建、推理执行、响应保存、输出投递

把两者混在一起,会得到一个常见坏味道:所有逻辑都往推理循环里塞。上下文拼接、审批、工具执行、会话保存、后台整理、错误兜底,最后全挤进一个函数。

AgentLoop 的核心不是让模型一直说话,而是把模型放进受状态和策略约束的运行闭环。

三阶段 Pipeline

echo-agent 当前把 Agent Loop 拆成三阶段 Pipeline:

rust 复制代码
ContextStage -> InferenceStage -> ResponseStage

这不是为了让目录看起来更架构化,而是按时间切分一次事件处理。

ContextStage 负责"进入模型前"。它把会话历史、记忆、技能、知识库、媒体、系统提示词和工具定义组装成模型可用输入。

InferenceStage 负责"模型循环中"。它执行模型推理与工具调用循环,包括模型调用、工具执行、观察结果回填,以及触达终止条件。

ResponseStage 负责"模型完成后"。它处理最终回答、会话保存、输出发布状态,以及记忆整理、技能复盘等后台任务。

可以把 _process_event 理解成一个很薄的编排器:

ini 复制代码
async def _process_event(event, trace_id, publish_response=False):
    session = await sessions.get_or_create(event.session_key)
    ensure_working_memory(event.session_key)
​
    command_response = await handle_approval_command(event)
    if command_response:
        save_command_result(session, event, command_response)
        return ProcessResult(response_text=command_response)
​
    stream = TokenStreamPublisher(...)
    ctx = await context_stage.build(
        event=event,
        session=session,
        trace_id=trace_id,
        stream_publisher=stream,
    )
    inference = await inference_stage.run(ctx)
    response = await response_stage.finalize(ctx, inference)
​
    return ProcessResult(
        response_text=response.response_text,
        outbound_sent=response.outbound_sent,
    )

这段逻辑真正重要的是"没有做什么":它没有直接拼系统提示词,没有直接调用模型,没有直接执行工具,也没有直接保存最终会话。这些事情分别落在三个阶段里。

这样拆的收益很明确:上下文构造可以单测,推理循环可以单测,响应后处理也可以单测。书稿里提到,tests/test_pipeline_stages.py 可以直接测试 PipelineContext 默认值、InferenceResult 默认值、ContextStage.build 输出、任务类型推断和 ResponseStage.finalize 后处理行为。

如果只能通过完整 Agent Loop 间接触发这些逻辑,测试会慢,也会脆。

共享上下文

Pipeline 拆开以后,阶段之间必须共享状态。最粗糙的做法,是每个阶段传一长串参数:事件、会话、消息、工具定义、检索结果、任务类型、流式发布器。

这种写法很快会失控。参数越传越长,阶段依赖越隐蔽,后续重构也越危险。

echo-agent 使用 PipelineContext 作为一次事件处理的上下文载体。它包含四类字段:

类型 字段示例 作用
请求边界 eventsessiontrace_idpublish_response 标识本次处理属于谁、如何追踪、如何输出
模型输入 system_promptmessagestool_defs 进入模型调用的核心内容
推理辅助 retrievaltask_typeexecution_plan 影响路由、计划和提示词构造
输出控制 intro_textstream_publisher 控制首次介绍语和流式发布

这里要注意,PipelineContext 不是全局状态。它只属于一次事件处理,不跨请求复用。正因为生命周期明确,它才能安全地在三个阶段之间传递。

类似地,InferenceResult_ProcessResult 也要分层。前者面向 Pipeline 内部,关心最终回答、工具调用次数、是否需要技能复盘和记忆复盘;后者面向 Agent Loop 外层,只关心最终文本和是否已经发送。

这类"小而明确"的数据结构,会限制阶段越权。一个阶段不应该随手改动不属于自己的发布状态、推理统计或持久化结果。

运行边界

Pipeline 解决的是主路径拆分,但 Agent Loop 还要守住外围边界。

_on_inbound 是入站事件的最外层边界。它先检查 _running 状态,已经停止的 Agent 不应继续进入模型或工具执行。随后它会处理审批命令快路径,例如 /approvals/approve/deny

审批命令不应该每次都进入完整 Pipeline。它们是控制面命令,不是普通自然语言任务。如果先构造上下文、调用模型、暴露工具定义,再处理审批,延迟和风险都没有必要。

之后,Loop 获取 SessionManager.acquire(event.session_key) 返回的异步锁。同一 session 拿同一把锁,不同 session 使用不同锁。这保护的是对话因果,不只是内存一致性。

流式输出也是边界的一部分。echo-agent 的 _TokenStreamPublisher 不只是把每个 token 原样发出去。它维护完整文本、待发送缓冲和非最终消息状态,并根据 stream_flush_charsstream_flush_interval_msstream_paragraph_mode 控制刷新。

段落模式下,系统优先寻找段落边界,其次寻找句子边界,最后才按时间阈值强制刷新。这样做是为了避免用户在通道里看到大量半句话碎片。

最终保存时,又不能把中间碎片当成历史。ResponseStage 要保存的是完整、干净的 assistant 回答;流式过程只是用户体验层的输出形态。

终止条件

Agent Loop 不只要知道怎么开始,还要知道何时停下。

终止可以来自多种信号:模型给出最终回答,最大迭代次数耗尽,工具熔断触发,审批拒绝或超时,用户取消,任务转入后台。

这些信号不能都当成普通错误。最大迭代表示系统主动防止失控;审批拒绝表示行动边界被阻断;工具失败可能需要模型尝试替代方案;用户取消则应尽量停止副作用并保存现场。

echo-agent 中的 max_iterationsToolCircuitBreakerApprovalGate 和后台任务管理,都在给 Loop 设置停止条件。

会做事很重要,会停下来同样重要。没有终止条件的 Agent,本质上没有行动边界。

错误边界也应该和阶段边界一致。ContextStage 失败,多半是无法构造正确输入;InferenceStage 失败,多半是模型、工具、审批或执行问题;ResponseStage 失败,则可能是保存、输出或后台整理问题。不同失败性质不同,处理策略也不应该混成一个大 except

生产可用性

判断一个 Agent Loop 是否生产可用,不能只看"能不能循环调用模型"。至少要检查这些工程项:

检查项 可检验标准
事件契约 多入口统一成 InboundEvent,Loop 不直接绑定通道 API
会话串行 同 session 串行处理,不同 session 可并发推进
阶段拆分 上下文、推理、响应后处理有明确输入输出
工具治理 模型可见工具来自注册表和策略过滤
权限审批 写操作、高风险工具有统一审批路径
可观测性 每次事件有 trace_id,模型调用和 tool call 可追踪
流式输出 中间增量和最终会话保存分离
终止条件 最大迭代、熔断、审批拒绝、用户取消可区分
后台任务 记忆整理、技能复盘等任务可追踪、可清理
回归测试 阶段行为、并发锁、错误路径有测试覆盖

这张表背后的判断很简单:Agent Loop 不是越厚越强,而是越能稳定协调多个子系统,越不需要亲自知道所有细节。

新增模型 provider 不应改 Loop,新增通道不应改 Loop,新增记忆策略也不应改 Loop。Loop 应该依赖稳定契约,例如 LLMProviderToolRegistryMessageBusSessionManager 和 Pipeline Stage。

小结

从单体循环到三阶段 Pipeline,本质上不是代码风格变化,而是对复杂性的重新归类。

ReAct 解决模型如何使用工具;Agent Loop 解决一次事件如何在会话、上下文、权限、工具、输出、持久化和后台任务之间被治理。

ContextStage 让模型看到正确的世界,InferenceStage 让模型意图变成受控行动,ResponseStage 让本次行动沉淀为系统状态。AgentLoop 则保持生命周期顺序和关键不变量。

理解这一点后,再看 Agent 工程,会少很多误判。真正难的不是写一个会调工具的 while 循环,而是让这个循环进入一个可测试、可观测、可停止、可演进的系统边界。

(全篇完)


本文为 echo-agent 设计笔记系列第 07 篇。项目源码已开源至 GitHubgithub.com/fuyuxiang/e... Agent 的工程落地感兴趣,欢迎加入技术交流群(QQ群号:47572014)参与日常讨论。下一篇我们将探讨 《ContextStage 设计笔记:让模型看到正确的世界》,敬请期待。

相关推荐
宋哥转AI1 小时前
Spring AI Alibaba实战:通过MCP协议串联Graph编排与RAG检索
agent·mcp
勇敢的先登1 小时前
MCP 是什么?为什么 Function Call 之后还需要它
agent·ai编程
码流怪侠2 小时前
【GitHub】 Headroom 深度解析:AI Agent 上下文压缩层的完整技术拆解
人工智能·github·agent
leeyi2 小时前
Tool 组件:让 Agent 学会「动手」的统一接口
aigc·agent·ai编程
啾啾Fun2 小时前
【LLM应用可靠性】3-Agent 事故响应:当 AI 系统行为异常时的 SRE Runbook
ai·llm·agent·生产应用
张申傲2 小时前
拆解 harness9(4):Skills 系统架构
aigc·agent·deepseek·harness
小七-七牛开发者2 小时前
周一上线|瑞幸把咖啡做进 CLI,Fable 5 短暂登场,Stonk Rider 骑上 K 线图
ai·chatgpt·大模型·agent·claude·codex·skill·claudecode·ai coding
Solis程序员2 小时前
Raft:分布式系统的定海神针
java·分布式·kafka·rabbitmq·agent·raft
云烟成雨TD2 小时前
Agent Scope Java 2.x 系列【13】权限系统
java·人工智能·agent