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

时间:2026-07-05 08:28:47 来源:互联网

大多数初识Agent的开发者,或许都见过这样的演示:一个while True循环反复调用模型,模型主动请求工具就执行,结果回填后继续追问,直到输出最终答案。

这种写法虽能清晰阐述ReAct的核心原理,但在实际工程中会迅速暴露出诸多问题:会话锁机制、流式消息分发、审批命令流程、工具执行失败追踪、最终答案持久化等。

若将这些粗活儿全塞进同一个循环,Agent Loop将不再简单是“循环”,而蜕变为一个既难测试、难观测、又难扩展的巨型函数。

本文聚焦一个核心论点:Agent Loop不是模型内部的while循环,而是一次入站事件的系统级生命周期控制

问题入口

多数Agent教程把Loop简化为如下代码:

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 Loop的最小单位不应该是user_input -> text,而应该是:

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这类参数存在的原因。

img_6a49a53f7210630.webp

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

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

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

控制循环

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

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

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

这与ReAct循环不同:

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

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

三阶段 Pipeline

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

ContextStage -> InferenceStage -> ResponseStage

此举并非为了让目录更架构化,而是按时间切分一次事件处理。

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

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

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

img_6a49a53f84e1b31.webp

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

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使用不同锁。这保护的是对话因果,不只是内存一致性。

img_6a49a53f9929d32.webp

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

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

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

终止条件

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

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

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

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

错误边界也应该和阶段边界一致。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,本质上并非代码风格变化,而是对复杂性的重新归类:ContextStage构建模型输入,InferenceStage控制工具行动,ResponseStage沉淀系统状态。Agent Loop的核心不是写一个会调工具的循环,而是让循环进入可测试、可观测、可停止的系统边界。

(全篇完)