错误处理与流式输出:分层重试 + EventStream + reducer
Agent系统的”可靠性”和”流式输出”看似两个话题,在aptbot里其实是同一个——都是关于”事件如何从agent流到用户、出错时如何处理”。这篇文章把这两条线索拧在一起,看aptbot的错误处理哲学、事件流抽象、流式输出机制如何协作。
一、概念:错误处理与流式输出是同一个问题
在传统应用中,错误处理和流式输出是两个独立的关注点。后端负责”不出错”,传输层负责”把数据流出去”。但在agent系统中,这两者高度耦合——agent的输出本身就是流式的(LLM逐token返回、工具调用与结果交错出现),错误可能在任何时刻发生在任何一层(网络错误、LLM错误、工具执行错误),而消费端需要实时接收到”当前agent在做什么”和”出错了怎么办”。
换个角度看:流式输出的核心挑战就是错误处理。因为agent的运行过程充满了不确定性——LLM可能输出非法JSON、工具可能超时、Provider可能不可用——每一次不确定性都是一次潜在的”错误”。一个好的agent系统不是”不出错”,而是”出错时让消费端知道发生了什么、系统正在做什么、接下来会怎样”。这才是流式输出的核心。
二、通用设计方案:错误处理的两种模式
2.1内联处理模式
最朴素的做法是:错误在核心循环内处理。agent loop里直接写try-catch、重试逻辑、回退逻辑。看起来简单直接——哪里出错就在哪里处理。
但这种模式有一个根本问题:核心循环会膨胀。循环的本职是”推理→行动→观察”的编排,错误处理加进去后,循环既要管正常流程,又要管异常流程。随着错误处理策略越来越复杂(重试几次?退避多久?切不切provider?要不要问用户?),循环的代码量会从100行膨胀到400+ 行,变成一个”什么都知道但难以修改”的god function。
2.2事件驱动模式
另一种做法是:核心循环只做”执行 + 报错”,上层做决策。核心循环产生事件(”工具调用完成了””LLM报错了””provider超时了”),上层订阅事件并做出决策(”重试””切换provider””报告用户”)。
这让每一层保持纯粹——执行层只管执行并产生事件,决策层只管根据事件决策。职责清晰,可独立测试。
2.3事件流 + reducer模式(进一步统一)
事件驱动模式的一个变体是事件流 + reducer模式——不仅决策用事件驱动,流式输出的消费也用事件驱动。核心循环产生类型化的事件流,经过reducer折叠成状态,消费端根据状态渲染。错误事件、正常事件、状态变化事件都在同一个事件流中流动。
这个模式让”错误流”和”正常流”在同一个框架下处理——错误不是”特殊路径”,只是事件流中的一种事件类型。reducer处理它,消费端消费它。
三、三种错误处理与流式输出路线对比
3.1方案A:内联处理
这条路线的核心哲学是”简单直接“——错误处理逻辑直接写在agent loop里,try-catch包围LLM调用和工具执行,出错时在循环内重试或返回错误。
设计特点:
- 循环内处理:agent loop的单方法包含递归重试、错误恢复、状态修复等逻辑
- 弱类型事件:agent输出是字符串或非结构化对象,前端根据不同框架自行解析
- UI耦合:前端直接处理原始事件流,没有中间reducer层。如果有多端UI,每端各自实现事件处理逻辑
- 错误持久化:错误被写入session历史,方便调试但可能导致回放污染
适用场景: 快速原型、单端UI的项目、对错误处理要求不高的场景。
优势: 实现直观,代码都在一个地方,容易追踪。
代价: 循环膨胀严重(400+ 行的单方法),核心逻辑与错误处理耦合,新开发者很难区分”正常路径”和”异常路径”。多端UI时每个前端各写一套事件处理逻辑,容易不一致。
3.2方案B:事件emit + 上层监听
这条路线的核心哲学是”解耦“——核心循环通过事件广播机制(EventEmitter / EventBus)发出事件,上层监听并处理。
设计特点:
- 事件广播:核心循环在关键节点emit事件(
tool:start、tool:end、llm:error),上层监听 - 错误处理:上层通过监听error事件决定重试策略
- UI渲染:前端各自监听事件,自行维护状态
- 无类型约束:事件通常是字符串+payload的形式,无编译时类型校验
适用场景: 需要一定解耦但不想引入复杂事件流框架的项目。
优势: 比内联处理清晰,核心循环不再膨胀;事件机制简单易懂。
代价: 类型安全弱——事件名拼写错误只能运行时发现,payload结构变更缺少编译检查。多端UI仍需各自维护状态管理逻辑,可能重现同一bug在不同端表现不同的问题。
3.3方案C:类型化EventStream + 外置分层重试 + reducer(aptbot的选择)
这条路线的核心哲学是”结构化事件流“——所有事件都是类型化的联合类型,通过Generator/AsyncGenerator按序传递,经过纯函数reducer折叠成UI状态。
设计特点:
- 类型化事件:所有事件是
AgentEvent联合类型,TypeScript在编译时校验每个事件处理点 - 外置重试:重试策略不在核心循环内,由上层循环(session/harness)根据事件类型决策
- 共享reducer:纯函数
coreReducer被CLI和WebUI共享,差异只在渲染层 - 事件流即接口:事件流是agent内部对外部的统一接口——core产生事件,bus分发,channel转发,UI消费
- 错误不持久化:错误只在内存中通过事件流推送,不写入JSONL
适用场景: 需要生产级可靠性、多端UI、对类型安全有要求的项目。
优势: 类型安全最大化,UI一致性最大化,错误处理可审计可测试。
代价: 基础设施投入较大(需要定义完整的事件类型集、实现reducer和resync协议),对简单场景可能偏重。
3.4三种路线对比
| 维度 | 方案A(内联处理) | 方案B(事件emit) | 方案C(类型化事件流) |
|---|---|---|---|
| 核心哲学 | 简单直接 | 解耦 | 结构化事件流 |
| 事件类型 | 非结构化/字符串 | string + payload | 联合类型 |
| 类型安全 | 弱 | 弱 | 强(编译时校验) |
| 错误处理位置 | 循环内 | 事件监听层 | 外置分层(loop报告,上层决策) |
| 重试策略 | 内联try-catch | 监听层处理 | 三层分类(传输/业务/语义) |
| UI一致性 | 各端自维护 | 各端自维护 | 共享reducer |
| 错误持久化 | 是(写入历史) | 通常写入 | 否(只存内存) |
| 循环规模 | 400+ 行 | 200-300行 | ~150行 |
| 基础设施复杂度 | 低 | 中 | 较高 |
四、aptbot的错误处理与流式输出设计
aptbot选择方案C。下面逐一拆解每个设计点。
4.1外置分层重试哲学
最朴素的重试方式是”哪里出错就在哪里重试”——网络层出错网络层重试、业务层出错业务层重试。但这种方式有一个问题:低层不了解高层语义,重试决策可能错误。
举个例子:HTTP请求返回401。网络层看到”请求失败”,可能重试。但401是”认证失败”,重试100次还是401,纯粹浪费。重试决策应该在”知道401意味着什么”的层做出。
aptbot的外置分层重试哲学:loop报告,上层决策。具体执行层(如Provider调用)不自己决定是否重试,而是把错误分类后上报,由更上层的loop决策——是切换provider、是回滚、是询问用户、是放弃。
这让每一层保持纯粹——执行层只管”执行 + 报错”,决策层只管”如何处理错误”。决策逻辑集中、可审计、可调整;执行逻辑简单、可复用、可测试。
4.2三层重试:传输 + 业务 + 语义
aptbot的错误分为三层,每层处理自己能理解的错误:
传输层重试:网络层错误。ECONNRESET、ETIMEDOUT、socket hang up。这类错误重试有意义(可能是临时网络抖动),但要用退避避免雪上加霜。aptbot使用指数退避(1s→2s→4s)+ jitter。
业务层重试:HTTP状态码错误。401/403致命(不重试)、429/5xx可恢复(重试 + 切换provider)、400致命(参数错,重试无意义)。这一层根据HTTP语义决定——不是”看到错误就重试”,而是”根据错误类型判断是否值得重试”。
语义层重试:LLM输出错误。模型返回不合法JSON、工具参数schema校验失败、模型反复调用不存在的工具。这类”重试”不是重新发送HTTP请求,而是把错误反馈给LLM让它在下一轮纠正。这层由agent loop处理,不是provider层。
三层各自处理自己”懂”的错误,不越界。传输层不知道401意味着什么,不重试401。业务层不知道LLM输出错在哪里,不试图纠正JSON。语义层不重发HTTP请求,只在agent loop内反馈给LLM。
这个分层的关键价值:每一次重试都是有信息的决策,不是盲目尝试。传输层知道”网络抖动”所以重试;业务层知道”401是认证问题”所以不重试,而是通知用户检查配置;语义层知道”LLM输出不合法”所以把错误消息送回LLM,让它自己修正。
4.3错误不持久化原则(防止”400 poisoning”)
aptbot有一个反直觉的原则:错误不写入session历史。
为什么反直觉?直观上”记录错误以便复盘”是好的。但在实际中,这会导致”400 poisoning”——某个provider临时返回400(如模型参数错误),错误被写进session历史。下次session回放时,这个400又被发给LLM作为”上轮发生了什么”,LLM看到”上轮400错误”可能会困惑或重复触发同样的问题。
正确做法:错误只活在内存中——发生时通过事件流推送给客户端展示,但绝不写入JSONL。session历史只记录”成功完成的事”(user message、assistant message、tool call result),不记录”失败的尝试”。
这保证了session回放的一致性——任何时候回放,看到的都是”已完成的事”,不会有”半截错误”污染上下文。
4.4 AgentEvent联合类型
agent的所有输出(LLM token、工具调用、状态变化、错误)都是事件,统一用 AgentEvent 联合类型表示:
- token event:LLM流式输出的一个文本token
- tool_call_start event:工具调用开始(名称 + 参数)
- tool_call_end event:工具调用结束(结果)
- turn_end event:一个回合结束
- error event:错误(包含类型 + 消息)
- presence event:用户上线/离线
- session_changed event:session状态变化
联合类型让TypeScript在每个事件处理点强制校验类型,避免把token event当成tool_call_end处理这类错误。事件流是aptbot内部与外部世界的统一接口——agent core产生事件,bus分发事件,Channel转发事件,UI消费事件。
这是方案A和方案B做不到的层面:在方案A中,事件是字符串或非结构化对象,处理代码需要自己判断”这个事件是什么类型的”,写一堆 if (typeof x === 'string') 或 switch (x.type) 且没有编译时校验。方案C的联合类型让这层校验自动化。
4.5 EventStream → UI reducer模式
消费端不是直接处理事件流,而是通过reducer模式:
- EventStream:事件的有序序列,从agent流到消费端
- reducer:纯函数
(state, event) => newState,把事件序列”折叠”成状态 - 消费端:根据state渲染,state变化时自动更新

aptbot有两个消费端:CLI(使用Ink)和WebUI(使用Lit)。它们使用同一个 coreReducer——reducer是纯函数,与渲染框架无关。差异只在”如何把state渲染成像素”——Ink渲染成终端字符、Lit渲染成DOM元素。
reducer共享的好处:
- 一致性:CLI和WebUI显示同样的agent状态,不会出现”CLI看到工具调用但WebUI看不到”的情况
- 可测试:reducer是纯函数,单元测试不需要拉起渲染框架
- 可演化:未来添加新端(如mobile app),只需要复用reducer,只写渲染层
这是方案A和方案B做不到的:方案A每端各自处理事件、各自维护状态,很容易出现不一致(CLI显示”正在执行工具”但WebUI显示”正在等待LLM回复”)。方案B通过事件广播改善了一些,但状态管理仍然是每端自建。方案C的共享reducer从架构层面保证了一致性。
4.6流式渲染、回合中断、多端同步都是事件流的自然消费
reducer模式让三个看似复杂的流式行为变成了”事件流的自然消费”:
流式渲染:token event逐个到达,reducer把它们append到state的”当前assistant message”字段,消费端检测到state变化就渲染新token。不需要特殊的”流式逻辑”,就是reducer处理token event的自然结果。
回合中断:用户点击”停止”按钮,发送abort信号给agent loop,loop停止工具执行与LLM调用,发送turn_aborted event。reducer收到turn_aborted,把当前message标记为”已中断”,消费端显示中断标记。中断不是”特殊路径”,是事件流中的一个event。
多端同步:agent事件发送到bus,bus分发给所有绑定该session的channel。每个channel的消费端各自运行reducer,state各自演化但保持同步——因为它们消费的是同一份事件流。多端同步不需要”特殊的同步逻辑”,是事件流的天然结果。
这就是reducer模式的核心价值——把”复杂的流式行为”还原成”事件流 + 纯函数”,复杂度从消费层转移到事件设计层,消费层因此变薄。
4.7 resync协议
WebSocket断连重连后,客户端如何”补上”断连期间错过的事件?最朴素的做法是”重连后重新拉取整个session历史”,但这浪费带宽——大部分事件客户端已经接收过了。
aptbot的resync协议:
- 客户端记录最后收到的事件sequence number
- 重连时把sequence number发送给服务端
- 服务端从该sequence之后开始replay
这把”重连”的成本降到最低——只补充”漏接的事件”,不重发全部。resync协议在底层支撑了”无感重连”——用户网络抖动一下,UI闪一下,状态自动追上,不需要刷新页面。
resync依赖两个前提:事件流有单调递增的sequence number;reducer是纯函数(给定同样的初始state和同样的事件序列,得到同样的最终state)。第二点保证了replay的一致性——客户端重放事件序列后,状态与服务端完全一致。
4.8背压控制(防止慢消费端拖垮agent)
流式输出有一个常被忽视的问题:生产端(agent)和消费端(client)的速度不匹配。LLM每秒可能输出几十个token,但消费端可能因为网络慢、渲染慢、用户切到后台等原因处理不过来。如果没有背压控制,事件会在内存中堆积,最终OOM。
aptbot的背压控制策略:
- 有界缓冲区:channel为每个session维护一个有界的事件队列(而非无界队列)。队列满时,生产端被阻塞(generator的next()挂起),直到消费端消费了事件腾出空间
- 流式token的丢弃容忍:token event是”可丢弃”的——如果消费端跟不上,最新的token可以覆盖队列中尚未发送的旧token(因为用户只关心最终的完整文本,中间的逐token显示只是视觉效果)。但tool_call_start、error、turn_end等”关键事件”绝不丢弃,必须保证投递
- WebSocket的TCP背压:底层依赖TCP的流控——当client的TCP接收窗口收缩时,kernel的send buffer会满,write()会阻塞或返回EAGAIN,channel据此暂停从agent拉取事件
背压控制的核心原则是”宁可让agent慢下来,也不要让内存爆掉“。agent的流式输出不是”越快越好”,而是”匹配消费端速度”。一个被背压卡住的agent只是慢,一个OOM的agent是崩溃。
对比方案A:通常没有背压控制,假设消费端总能跟上。方案B:可能有简单的队列长度限制,但没有区分”可丢弃事件”和”关键事件”。aptbot的分级背压是更精细的设计——在保证关键事件不丢的前提下,最大化吞吐。
4.9节流缓冲(平衡突发与延迟)
LLM的token输出往往是突发的——模型”想清楚”后一口气吐出一大段,然后停下来”思考”。如果每个token到达就立即发送WebSocket帧,会导致:
- WebSocket帧风暴:几十个token在几毫秒内到达,每个都触发一个WS帧,网络开销巨大(每个帧都有header开销)
- 消费端渲染抖动:逐token渲染虽然看起来”流式”,但突发时的渲染抖动会让体验卡顿
aptbot的节流缓冲策略:
- 微批次(micro-batch):channel不是每个token立即发送,而是累积到一个短时间窗口(如16ms,对应60fps)或一个小批量(如8个token)后一次性发送
- flush on关键事件:遇到tool_call_start、error、turn_end等关键事件时,立即flush缓冲区——关键事件不能等,必须及时送达
- flush on空闲:如果token流停止了(LLM在”思考”),定时器超时后flush剩余缓冲,避免最后一个批次的token卡在缓冲区里
节流缓冲的核心权衡是延迟 vs 吞吐:完全不缓冲延迟最低但吞吐差,缓冲太大吞吐好但延迟高。16ms / 8 token的微批次是一个经验值——对人类感知来说16ms是不可察觉的,但能把WS帧数量降低一个数量级。
4.10序列顺序保证
流式输出的一个硬性要求:事件必须按产生顺序到达消费端。如果tool_call_start在tool_call_end之后到达,消费端的状态会错乱(reducer会尝试”结束一个还没开始的工具调用”)。
aptbot的序列顺序保证机制:
- 单session单流:一个session的事件流是单线的,不存在并发产生事件。agent loop是单线程的(即使工具并行执行,事件发射是串行的),从源头避免了事件乱序
- sequence number:每个事件携带单调递增的sequence number。消费端如果收到乱序事件(seq跳号),可以检测到并触发resync
- Channel的FIFO保证:WebSocket是有序的(TCP保证),一个连接上的消息按发送顺序到达。不依赖多个连接并行传输事件
为什么强调”单session单流”?因为有些agent系统为了并行化,会让多个工具调用的事件并行产生——这会导致事件流交错,消费端难以重建”哪个工具调用对应哪个结果”。aptbot的agent loop虽然允许工具并行执行,但事件发射是串行的——工具并行执行的结果在完成时按”工具调用发起的顺序”或”完成顺序”串行发射,不会交错。
对比方案A:通常没有sequence number,乱序只能靠”感觉”发现。方案B:可能有sequence但事件广播机制不一定保证FIFO。aptbot的”源头串行 + sequence校验 + TCP FIFO”三层保证是最严格的。
4.11分块传输策略(长内容可控流动)
工具结果可能很长——读一个10KB的文件、bash输出几MB的日志、web_fetch返回一整个HTML。如果一次性作为一个event发送,会造成:
- 单事件过大:WebSocket帧过大,消费端需要分配大buffer
- 阻塞事件流:一个大事件在传输时,后面的token event被阻塞(用户看到的”卡住了”)
aptbot的分块传输策略:
- 工具结果截断:bash输出超过100KB截断(见安全模型的OOM防护),read工具检查文件大小超过10MB拒绝读取。这从源头限制了单事件的大小
- 长文本分块emit:对于确实需要流式输出的长内容(如LLM生成的长回复),token event本身就是分块的——每个token是一个小event,自然流动
- 关键事件优先:如果缓冲区中有pending的token event和刚产生的error event,error event优先发送——错误信息不能被一堆token卡住
分块传输策略的核心是”不让任何一个事件阻塞整个流“。即使是1MB的工具结果(截断后),也是作为一个event发出,消费端可以快速接收并处理,不会阻塞后续事件。
4.12turn_busy队列反馈
agent正在执行turn时(比如正在运行bash工具,预期30秒完成),用户又发送了一条消息。这种情况怎么办?
aptbot用 turn_busy 反馈处理:
- agent正在执行turn时,新消息进入队列(而不是被丢弃或报错)
- agent给客户端发送turn_busy event,告诉消费端”我现在忙,你的消息已排队”
- 当前turn结束后,agent自动处理队列中的下一条消息
这让流式反馈更清晰——消费端知道”消息已被接收,但agent正在忙,稍后处理”,而不是”发了消息但agent没有反应”的迷茫。turn_busy是事件流中的又一个event,reducer处理它并显示”忙”状态。
4.13 SessionRef可变引用(运行中切换session不重启loop)
agent loop运行时,用户可能想切换到另一个session——比如当前session卡在一个长任务上,用户想切换到另一个session处理别的事情。
最朴素的实现是”停止当前loop,重启新loop切换到新session”,但代价较大——loop重启会丢失内存中的临时状态(如正在执行的工具、未完成的LLM调用)。
aptbot使用 SessionRef 可变引用解决:loop持有一个对当前session的引用(不是session本身),引用可以改变。切换session时只修改引用,loop不重启。当前正在执行的LLM调用完成后,下一轮自动使用新session。
这让”运行中切换session”的成本极低——只是修改一个引用,loop继续运行。代价是新session需要等待当前turn结束才能完全接管,但这是一个合理的代价(你不能让一个正在进行中的LLM调用”换大脑”)。
4.14与三种方案的核心差异
和方案A/B相比,aptbot最核心的差异不是”用了reducer”或”用了事件流”——而是把所有不確定性統一到同一个事件模型中处理。
方案A把错误当作例外(exception),正常流程和异常流程是两套代码。方案B把错误当作事件(event),但事件类型不完整、类型不安全。方案C把一切——正常的LLM token、工具调用、错误、状态变化、连接重连——都当作同一类东西:事件。
这意味着:
- 没有”特殊路径”:错误不是特殊路径,中断不是特殊路径,重连不是特殊路径。它们都是事件流中的事件
- 所有行为可回放:因为所有东西都是事件,任何时刻的状态都可以通过重放事件流来重建
- 新UI零成本接入:新UI只需要实现reducer + 渲染层,不需要理解agent的内部逻辑
五、发展方向
当前的事件流 + reducer模型已经覆盖了核心场景,未来可以在几个方向继续深化:
更精细的错误分类:当前的三层(传输/业务/语义)覆盖了大部分场景。未来可以进一步细分——比如区分”LLM输出格式错误”和”LLM输出内容不安全的错误”、区分”可重试的429”和”不可重试的429”。
reducer的分层合并:当前所有事件通过一个 coreReducer 处理,未来可以把reducer拆成子reducer(如 chatReducer、toolReducer、sessionReducer),用combine模式组合。这对事件类型增多后的可维护性有帮助。
resync协议的边界情况:当前resync机制假设sequence number不会回绕(即单调递增不回滚)。在极长的session中可能需要考虑sequence回绕的处理。
presence事件的深化:当前presence事件只覆盖”用户上线/离线”,未来可以扩展到”用户正在输入”等更丰富的协作场景。
离线事件队列:当前事件流依赖在线连接,未来可以支持客户端离线缓存事件、上线后批量同步。
这些方向都是L3路线的候选,但当前的核心架构(事件流 + reducer + resync)已经为这些扩展铺好了路。
小结
错误处理与流式输出在aptbot中被统一为同一个问题——“事件如何流动、出错时如何处理”。外置分层重试哲学让决策集中,传输/业务/语义三层重试各管各的,错误不持久化防止400 poisoning。AgentEvent联合类型 + EventStream + reducer让消费层变薄,流式渲染、回合中断、多端同步都是事件流的自然消费。背压控制防止慢消费端拖垮agent,节流缓冲平衡突发与延迟,序列顺序保证事件可靠投递,分块传输策略让长内容可控流动。resync协议支撑无感重连,turn_busy让队列反馈清晰,SessionRef让运行中切换session不需要重启loop。
对比方案A(内联处理)和方案B(事件emit + 上层监听),aptbot选择方案C(类型化EventStream + reducer)的原因不只是”更结构化的错误处理”——更是因为在多端消费的场景下,reducer共享提供了成本最低的一致性保证。而”错误即事件”的思维模型,让agent系统中所有的不确定性都在同一个框架下处理,开发者不需要区分”正常路径”和”异常路径”——一切都是事件流。
下一篇文章离开抽象层,看aptbot实际开发过程:从MVP到0.2.2的演进回顾。