AI Agent 流式输出怎么做:SSE 打字机效果、工具调用进度与断线续传

AI Agent 流式输出与 SSE 打字机效果、工具调用进度、断线续传示意图

很多人以为流式输出是个「体验优化」,加不加都行。真做过就知道,它其实是可用性底线:同一个回答,等了 12 秒一次性蹦出来,和第一秒就开始出字、12 秒出完,用户的判断完全不同。前者是「这系统卡了吧」,后者是「它在想,而且想得挺认真」。

但 Agent 场景的流式比普通聊天框麻烦得多。普通对话一路往下吐 token 就行;Agent 中间要调工具、要等外部接口返回、可能要跑几十秒没有一个字输出。这段「沉默期」如果前端不给个交代,用户照样会以为死机。这篇把从协议选型到断线续传的完整链路拆开讲。

先分清三种传输方式,别一上来就上 WebSocket

前端拿流式数据,主流三条路:

  • SSE(Server-Sent Events):基于普通 HTTP,服务端单向持续推文本事件。对 Agent 来说这是默认答案——因为 Agent 的输出本来就是单向的(服务端说、前端听),而且它是纯 HTTP,过网关、过 CDN、走公司代理都比 WebSocket 顺利得多,断线自动重连也是浏览器内建的。
  • WebSocket:双向长连接。只有在需要「用户边说边打断」「多人协同看同一个任务」这类真双向场景才值回票价。代价是运维复杂度上一个台阶:连接保活、负载均衡要粘性、很多企业网络直接拦。
  • 长轮询 / 分块读取:兼容性最好,但延迟和服务器连接数都吃亏。现在基本只作为降级兜底方案保留。

结论很直接:对话类 Agent 首选 SSE;只有确需双向实时才上 WebSocket。别为了「听起来更高级」去扛不必要的运维成本。

事件别只有一种,至少设计六类

最常见的偷懒做法是:所有流都用同一种「文本片段」事件往下推。结果是前端没法区分「模型在说话」和「工具在干活」,也没法做进度条和错误重试。一套够用的 Agent 流至少要有六类事件:

  1. 文本增量(delta):模型正在生成的正文片段。这是唯一直接进正文渲染的类型。
  2. 步骤切换(step):「正在检索知识库」「正在查询订单」这类中间态。它让沉默期有内容可展示。
  3. 工具开始(tool_call):带上工具名和脱敏后的参数摘要,让用户知道「要动什么数据」。
  4. 工具结果(tool_result):只回摘要和状态,不要把原始大 JSON 灌给前端。
  5. 结束(done):带最终状态:成功、被取消、还是失败。附上可续传的位置标记。
  6. 错误(error):要区分「可重试」和「不可重试」,并告诉前端该自动重连还是提示用户。

另外必须加一类不出现在正文里的东西:心跳。它不承担信息传递,只承担「连接还活着」这件事。工具正在跑、模型正在想的时候,每 5 到 15 秒发一个空事件或注释行,能让前端不误判超时,也能让中间的反向代理不掐连接。事件该怎么划分、返回体怎么组织,和站内 工具怎么设计 是同一套思路——对下游友好,永远是第一原则。

打字机效果:不是越慢越有感觉

把 delta 直接塞进 DOM 是最省事的做法,但会碰到三个具体问题:

  • Markdown 半截渲染。代码块、表格、加粗标记都是一个字符一个字符来的,中途会出现「**」这种裸露符号或者错乱的表格。稳妥做法是对未闭合的 Markdown 结构做抑制:检测到代码块没闭合就先按纯文本渲染,闭合后再整体切换成格式化的样子。
  • 节奏感。模型吐 token 的速度是不均匀的,有时一口气来二十个字,有时卡住半秒。直接渲染会出现一抽一抽的观感。常见做法是加一个输出队列,按固定节奏匀速播放,遇到句号、逗号做轻微停顿——这也是「打字机」这个叫法的由来。但节奏别调太慢,用户要的是信息,不是动画。
  • 自动滚动的争夺。新内容往下推的同时用户可能正在往上翻。正确逻辑是只在用户处于底部时才自动跟随,一旦检测到手动上滑就停止跟随并浮出「回到最新」按钮。

难点一:工具调用期间那几十秒怎么办

这是 Agent 流式和普通聊天最大的区别,也是最容易翻车的地方。用户看到的是:第一句话出来了,然后光标转了 40 秒,一个字没有。

处理思路是把「沉默」变成「可读的进度」:

  • 调用前先发步骤事件,用人话说明要做什么(「正在查最近 30 天的订单」),而不是暴露工具函数名。
  • 长任务要有阶段进度。「已扫描 1200 条,命中 37 条」比一个无声的转圈有用一百倍。像批量处理这类场景,进度推送和 长时任务的状态检查点 是配套设计,缺一个都会让人不放心。
  • 超时要有预期管理。「这一步预计还要 20 秒」比什么都不说更让人愿意等。
  • 超过阈值仍无进展,要能主动降级或暂停,思路见 熔断与暂停机制

难点二:断线了怎么接着传,而不是从头再来

手机切后台、地铁进隧道、公司 Wi-Fi 抽风——移动端流式断线是常态,不是异常。如果断线就意味着重新生成一次,那既费钱又费时间,用户还可能拿到一段逻辑对不上的新答案。

SSE 本身给了个可用的机制:每条事件带一个 id,浏览器重连时会自动在请求头里带上 Last-Event-ID。服务端要做的是三件事:

  1. 给每个事件编号(会话内自增即可),编号要能表达「顺序」。
  2. 在服务端保留一个有限窗口的事件缓冲,比如最近 5 分钟或最近 2000 个事件。重连时从指定位置开始补发。
  3. 窗口之外的请求,返回一个明确的「已过期」状态,让前端走「重新生成」或「展示已有部分」的分支,而不是静默失败。

这里有个设计取舍值得提前想清楚:续传的到底是「展示」还是「生成」?最省事的做法是只续传展示——后端任务继续跑,前端重新连上就恢复接收。这个方案对用户最友好,代价是服务端要能管理运行中的任务状态,状态丢了就只能重来。任务状态怎么落、怎么恢复,可参考 上下文管理 里的分层思路,别把所有状态都塞在内存里。

难点三:用户点「停止」,要真的停下来

「停止生成」按钮如果只是前端断开连接,后端往往还在跑——工具照样调、钱照样花,甚至还会继续产生副作用(发邮件、下单)。正确做法是:

  • 前端的取消动作通过一个显式接口通知服务端,而不是只关掉浏览器那一端。
  • 服务端要有一个可传递的取消信号,一路传到正在执行的工具调用里。
  • 已经产生副作用的步骤不可回滚,但必须记录清楚「停在哪一步、已经做了什么」,让用户知道现在的系统状态。留痕的做法可参考 证据包 的思路。
  • 取消后重新发起时,要带上「上一次停在哪」,避免重复执行已经完成的副作用动作。这就是幂等的问题,工具调用失败怎么排查 里讲重试时也绕不开同一件事。

难点四:看起来在流,其实被中间层缓存住了

这个坑排查起来最费时间,因为代码写得完全正确,但用户看到的就是「转半天然后一次性全出来」。常见原因有四类:

  • 反向代理缓冲。Nginx 默认会对响应做缓冲,攒够一定大小或等请求结束才转发。要在对应的 location 上关闭缓冲,并显式设置不缓存。
  • 压缩中间件。某些 gzip 配置会等缓冲区满了才输出,小流量的流式会被完全吃掉。
  • CDN 或安全网关。有些网关会对响应做整体检查再放行,流式天然被破坏。这类需要确认是否提供了流式穿透的开关。
  • 框架层的响应封装。一些 Web 框架默认会收集完整响应再发送,需要显式启用流式响应模式,并把超时时间调大。

排查手段很简单:用命令行直接请求接口,观察内容是「一行一行出现」还是「卡住之后一次性刷出」。这个动作能瞬间区分是后端没流、还是中间层没放。

五个常见错误

错误一:只流正文,不流状态。用户看不到工具在干什么,沉默期一律当成卡死。

错误二:没有心跳。长任务必然被判定超时,尤其在企业网络环境里。

错误三:把原始工具返回直接推给前端。既泄露内部结构,也让流量和渲染压力凭空翻倍。

错误四:断线一律重新生成。费钱、费时,而且用户可能拿到前后不一致的两段答案。

错误五:出问题只看前端。流式链路上有客户端、网关、代理、应用四层,只查一层往往查不出结论。分层排查的方法可以借用 日志排错 里的思路。

适合谁做,什么时候先别做

该做:回答普遍在 5 秒以上、有工具调用、用户在移动端使用的 Agent 产品。这三条占任意两条,流式就是必需项而不是加分项。

可以先不做:纯后台批处理、没有实时界面的场景(比如定时跑批出报表),流式带来的复杂度换不来任何收益。另外如果产品还没稳定,先把「任务能不能跑对」解决掉,再优化「看起来爽不爽」——顺序反了会是白干。

一句话总结:流式输出的本质,是把「机器在干什么」持续、诚实地告诉用户。它解决的表面上是等待体验,实际上解决的是信任——用户愿意等,前提是他知道你在干活,而不是不确定你到底有没有在干活。

发表评论

您的电子邮箱地址不会被公开,必填项已标注 *