1. 从一次线上事故说起:为什么要啃 runLoop 这条消息流水线
去年冬天,我负责的一个智能体项目在灰度阶段翻过一次车。现象很诡异:用户在前端看到回复只吐了半句话就卡住了,后端日志里 LLM 的流式响应明明还在持续返回 token,但 Agent 侧像是"聋了"一样,后续的 chunk 全部石沉大海。排查了整整一个下午,最后定位到问题出在消息流水线的调度环节——runLoop在处理完一次工具调用之后,没有正确地把控制权交还给流式响应的消费协程,导致后续的 SSE 数据被缓冲区吞掉。
那次事故之后,我把 Pi-Agent 的runLoop相关源码从头到尾读了一遍,越读越觉得这套设计有意思。它不像很多玩具级 Agent 框架那样把"接收消息—调用模型—返回结果"写成一条直线,而是把整个交互过程抽象成了一条消息流水线,用事件驱动的方式把用户输入、LLM 流式响应、工具调用、状态回写这几个环节串起来。理解这条流水线,基本上就理解了 Pi-Agent 这个框架的骨架。
这篇内容适合三类人看:一是正在用 Pi-Agent 做二次开发、被runLoop里各种回调绕晕的工程师;二是想自己从零写一个 Agent 框架、需要参考成熟调度设计的开发者;三是单纯好奇"大模型流式响应到底是怎么被 Agent 消费掉"的技术爱好者。我会尽量把源码里的关键路径拆开讲,配上我实际调试时的观察和踩过的坑,让你读完能自己动手改这条流水线。
需要先说明一点:下面涉及的具体函数名、字段名以我手上这份源码为准,不同版本可能有细微差异,但核心思路是一致的。如果你手上的版本对不上,重点看设计意图,别死抠命名。
2. 消息流水线的整体设计:为什么不用一条直线搞定
2.1 直线式 Agent 的三大死穴
先说说为什么不能简单粗暴地写。最朴素的 Agent 实现大概长这样:收到用户消息,拼 prompt,调 LLM,等完整响应回来,解析出工具调用,执行工具,把结果再拼进 prompt,再调一次 LLM,循环直到没有工具调用为止。这条直线在 demo 阶段跑得挺欢,一上生产就露馅。
第一个死穴是流式响应没法用。直线式实现天然是"等完整响应"的思维,但用户要的是打字机效果,token 得边生成边推。你如果在直线里硬塞流式,代码会变成回调地狱,可读性直接崩盘。
第二个死穴是工具调用和流式输出会打架。LLM 在流式返回的过程中,可能前半段是普通文本,后半段突然冒出个工具调用请求。这时候你是先把文本推给用户,还是先等工具执行完?直线式实现很难优雅处理这种"边说话边干活"的场景。
第三个死穴是中断和取消。用户发了消息又反悔想取消,或者工具执行超时要中断整个流程,直线式代码里到处是if cancelled的判断,维护成本极高。
Pi-Agent 的runLoop本质上是用事件循环 + 消息队列的模式,把上面这些问题一次性解决掉。它不追求"一步到位",而是把整个交互拆成一个个离散的消息事件,让每个环节只关心自己该处理的那类事件。
2.2 runLoop 的核心抽象:AgentMessage 是血液
整条流水线里流动的"血液"就是AgentMessage。你可以把它理解成一个信封,信封上写着这封信是给谁的、什么类型、里面装了什么。源码里这个结构通常包含几个关键字段:类型标识(是用户输入、LLM 文本增量、工具调用请求还是工具执行结果)、载荷数据、以及一些元信息比如时间戳和会话 ID。
我特别喜欢这个设计的一点是,它把"LLM 流式返回的一个 token"和"一次完整的工具调用结果"统一成了同一种东西——都是消息。这样一来,runLoop的主循环就变得极其简洁:从队列里取消息,根据类型分发到对应的处理器,处理器可能产生新消息再塞回队列。整个系统就是一个不断吞吐消息的泵。
提示:如果你在调试时发现消息"丢了",第一件事是检查消息队列的消费端是不是有异常吞掉了消息。我踩过的坑里,有一半是处理器内部抛了异常但被 catch 后静默处理,导致消息凭空消失。
2.3 事件驱动相比轮询的取舍
有人会问,为什么不用轮询?比如每隔 50ms 检查一下 LLM 有没有新 token。答案是延迟和资源。轮询在流式场景下要么延迟高(间隔大),要么空转浪费 CPU(间隔小)。事件驱动是"有数据才动",LLM 每吐一个 token 就触发一次事件,天然契合流式响应的节奏。
代价是复杂度上去了。事件驱动要求你对状态管理非常小心,因为处理器的执行顺序不再是你写代码的顺序,而是事件到达的顺序。这也是为什么很多人读runLoop源码会觉得"绕"——它绕的不是逻辑,是控制流。
3. 核心细节拆解:runLoop 主循环到底在转什么
3.1 主循环的骨架结构
把runLoop的主循环抽象出来,大概是这么个结构(伪代码,帮你理解流程):
async def run_loop(self, session): while not session.is_done: message = await self.message_queue.get() handler = self.dispatcher.get_handler(message.type) if handler is None: continue try: new_messages = await handler(message, session) for msg in new_messages: await self.message_queue.put(msg) except Exception as e: await self.handle_error(e, session)看着简单,但魔鬼全在细节里。session.is_done什么时候置位?handler返回的消息会不会造成死循环?异常处理里要不要把错误也变成一条消息回灌进队列?这些才是真正决定框架好不好用的地方。
我实测下来,Pi-Agent 在is_done的判断上做得比较克制:只有当"没有待处理的工具调用"且"LLM 已经明确结束输出"且"消息队列为空"这三个条件同时满足时,才会结束循环。这个三条件判断很关键,少任何一个都会导致提前退出或死循环。
3.2 消息类型与分发策略
流水线里跑的消息类型,按我的理解大致分这么几类,用表格列出来更清楚:
| 消息类型 | 来源 | 典型载荷 | 处理后的去向 |
|---|---|---|---|
| UserInput | 外部调用方 | 用户文本 | 触发 LLM 请求 |
| LLMDelta | LLM 流式接口 | 单个 token 或片段 | 推送给前端 + 累积到缓冲区 |
| LLMCallRequest | LLM 解析器 | 工具名 + 参数 | 触发工具执行 |
| ToolResult | 工具执行器 | 执行输出 | 回灌给 LLM 作为上下文 |
| ErrorSignal | 任意环节 | 异常信息 | 决定重试或终止 |
分发策略上,Pi-Agent 用的是类型到处理器的映射表,而不是一长串if-elif。这个选择在消息类型少的时候看不出优势,但一旦你要扩展自定义消息类型(比如加个"人工介入"消息),映射表方案只需要注册一个新处理器,不用动主循环。这是典型的开闭原则落地。
3.3 流式响应的消费时机
这是整条流水线里最容易出 bug 的地方。LLM 的流式响应是一个异步迭代器,你async for它的时候,每个 chunk 到达的时机是不确定的。Pi-Agent 的做法是:在独立的协程里消费流式响应,每拿到一个 chunk 就包装成 LLMDelta 消息塞进队列,而不是在主循环里直接await整个流。
为什么要这样?因为如果主循环直接 await 流式响应,那么在流式返回期间,主循环是被占住的,没法处理其他消息(比如用户的中断请求)。独立协程消费 + 消息队列解耦,让主循环始终保持"可响应"状态。
注意:独立协程消费流式响应时,一定要处理好协程的生命周期。我见过因为流式协程没被正确取消,导致连接泄漏、内存缓慢增长的案例。取消逻辑要写在
finally块里,别偷懒。
3.4 缓冲区与增量拼接
LLMDelta 消息到达后,不能直接推给前端就完事,还得在本地维护一个累积缓冲区。原因有两个:一是工具调用的参数可能是分多个 chunk 返回的,你得拼完整了才能解析;二是有些框架需要在流式结束后对完整文本做后处理(比如敏感词过滤、格式修正)。
缓冲区的实现有个细节值得说:Pi-Agent 用的是"按消息类型分桶"的缓冲区,文本增量进文本桶,工具调用增量进工具桶。这样拼接逻辑互不干扰,也方便在流式结束后分别处理。如果所有增量都塞一个字符串里,解析工具调用时就得做复杂的字符串切分,很容易出错。
4. 实操过程:手把手跟一遍消息的完整生命周期
4.1 从用户输入到 LLM 请求发出
假设用户在对话框里敲了"帮我查一下明天北京的天气",点发送。这条文本首先被包装成 UserInput 消息,塞进消息队列。主循环取到它,分发给 UserInput 处理器。
处理器做的事情:把用户消息追加到会话历史,然后构造 LLM 请求。构造请求时有个关键决策——要不要带上工具定义。如果这个会话注册了天气查询工具,请求里就得带上工具 schema,否则 LLM 不知道有这个工具可用。Pi-Agent 在这里会根据会话配置动态决定工具列表,而不是写死。
请求构造完,不是直接同步调用,而是启动一个流式消费协程,然后把"LLM 请求已发出"这个状态记到 session 里。主循环继续转,等待后续的 LLMDelta 消息。
4.2 流式 token 的接收与推送
流式消费协程开始工作,LLM 每返回一个 chunk,就做三件事:包装成 LLMDelta 消息入队、更新 session 的累积缓冲区、记录最后活跃时间(用于超时判断)。
主循环取到 LLMDelta,分发给对应处理器。处理器把增量推给前端(通过 WebSocket 或 SSE),同时检查缓冲区里是否已经攒够了一个完整的工具调用。这里有个经验值:工具调用的 JSON 参数通常需要攒到出现完整的闭合括号才能解析,所以处理器会做一个轻量的括号配对检查,而不是每个 chunk 都尝试解析。
我实测下来,这个"攒够再解析"的策略比"每个 chunk 都试解析"要稳得多。后者在参数被切分到多个 chunk 时,会产生大量解析失败的噪音日志,排查问题时非常干扰视线。
4.3 工具调用的触发与执行
当缓冲区检测到一个完整的工具调用,处理器会生成一条 LLMCallRequest 消息。主循环取到它,分发给工具执行处理器。
工具执行处理器做几件事:校验工具名是否已注册、校验参数是否符合 schema、执行工具、把结果包装成 ToolResult 消息。这里有个容易忽略的点——工具执行应该是异步的,且要有超时控制。我见过工具因为网络问题卡死,整个 Agent 会话跟着挂掉的案例。Pi-Agent 在工具执行外层包了超时,超时后生成一条 ErrorSignal 消息,让流水线决定是重试还是告知用户。
工具执行完,ToolResult 消息入队。主循环取到它,把它追加到会话历史,然后触发新一轮 LLM 请求——因为 LLM 需要基于工具结果继续生成回复。这就形成了一个闭环:LLM 请求 → 流式响应 → 工具调用 → 工具结果 → 再请求 LLM。
4.4 会话结束的判定
什么时候算结束?前面提过三条件:没有待处理工具调用、LLM 明确结束、队列为空。实际实现里,LLM 结束的标志通常是流式响应里出现了特定的结束标记(比如 finish_reason 字段)。
这里有个坑:有些 LLM 服务在流式结束时,最后一个 chunk 只带 finish_reason 不带内容,有些则相反。Pi-Agent 的处理是两种都兼容——只要收到 finish_reason 就标记 LLM 结束,内容为空也不影响。如果你自己实现,千万别假设"最后一个 chunk 一定有内容"。
5. 常见问题与排查技巧实录
5.1 流式响应"卡住"的排查路径
这是最高频的问题。用户反馈"回复到一半不动了",排查顺序建议这样走:
先看后端日志里 LLMDelta 消息还在不在持续入队。如果入队正常但前端没更新,问题在推送环节,检查 WebSocket 连接状态和推送协程是否还活着。如果入队也停了,问题在流式消费协程,检查它是不是被异常中断了,或者 LLM 服务端是不是提前关闭了连接。
我遇到过一次特别隐蔽的:流式消费协程因为一个未捕获的解析异常退出了,但异常被上层 catch 后只记了 debug 级别日志,生产环境日志级别是 info,所以什么都没看到。后来把流式协程的异常处理改成必须记 error 级别,问题才暴露出来。
5.2 工具调用参数解析失败的常见原因
参数解析失败,八成是缓冲区拼接逻辑有问题。常见原因有这么几个:一是增量拼接时没考虑多字节字符被切分(中文尤其容易中招);二是工具调用的 JSON 里嵌套了字符串,字符串里又有括号,简单的括号配对会误判;三是 LLM 返回的参数格式本身就不合法。
针对第一个,拼接时要用字节流或确保按字符边界切分。针对第二个,别自己写括号配对,用流式 JSON 解析器。针对第三个,解析失败时把原始缓冲区内容完整记下来,方便事后分析是 LLM 的问题还是解析的问题。
5.3 消息队列积压与死循环
消息队列积压通常意味着某个处理器处理速度跟不上生产速度。比如工具执行特别慢,而 LLM 还在源源不断产生新消息。这时候要么给队列设上限并做背压,要么把慢处理器改成异步不阻塞主循环。
死循环则是另一种情况:处理器产生的消息又触发了自己。比如错误处理里生成 ErrorSignal,ErrorSignal 处理器又因为处理失败生成新的 ErrorSignal。防死循环的通用做法是给消息加个"重试次数"字段,超过阈值就强制终止并上报。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 回复中途卡住 | 流式协程异常退出 | 检查协程异常日志级别 |
| 工具参数解析失败 | 缓冲区拼接错误 | 打印原始缓冲区内容 |
| 消息队列持续增长 | 处理器阻塞主循环 | 检查处理器是否含同步 IO |
| 会话无法结束 | is_done 条件不满足 | 打印三个条件的实时状态 |
| 重复回复 | 消息被重复消费 | 检查队列的 ack 机制 |
提示:调试消息流水线时,强烈建议加一个"消息追踪 ID",每条消息从产生到消费全程带着这个 ID,日志里一搜就能看到完整链路。这个投入在排查复杂问题时回报极高。
6. 我对这套流水线设计的一点个人看法
读完 Pi-Agent 的runLoop,我最大的感受是:Agent 框架的难点从来不在"调模型",而在"调度"。调模型谁都会,但怎么让流式响应、工具调用、中断取消、错误恢复这几件事和谐共处,才是真正考验设计功力的地方。
Pi-Agent 用消息流水线 + 事件驱动的方案,把复杂度从"控制流"转移到了"状态管理"。这个转移是值得的,因为状态可以观测、可以持久化、可以回放,而混乱的控制流只能靠读代码去理解。如果你正在设计自己的 Agent 框架,我建议认真考虑这套模式,哪怕不照搬,至少把"消息作为一等公民"这个理念吸收进去。
后续如果还有机会,我想再写写 Pi-Agent 里工具注册和 schema 校验那部分的实现,那块也有不少值得琢磨的细节。