错误处理与流式输出:分层重试 + 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:starttool:endllm: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共享的好处:

  1. 一致性:CLI和WebUI显示同样的agent状态,不会出现”CLI看到工具调用但WebUI看不到”的情况
  2. 可测试:reducer是纯函数,单元测试不需要拉起渲染框架
  3. 可演化:未来添加新端(如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(如 chatReducertoolReducersessionReducer),用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的演进回顾。