← 返回文章列表
李奕锦的个人网站所属专题:架构之道

让AI开口说话:从"等一屏字"到"边打边看"的流式输出进化史

更新于 2025-12-28年份:2025字数:2,200阅读时长:6 分钟

流式输出的本质,是用"边算边送、边送边显"把等待感转移给后端和网络,让用户在前端几乎感觉不到等待。本文分享在 AI 客服产品中,如何用 SSE 选型、AbortController 请求控制、数据解析与 UI 更新三条主线,把"等一屏字"变成"边打边看",以及 input、db、menu 等不同业务类型的差异化策略。

你有没有这样的体验:在对话框里敲下一行字,然后盯着屏幕,等待AI的回复像一篇文章一样"啪"地整段出现?那种空白期间的焦虑,就像看着网页加载的转圈圈,一秒像一年。

但如果换一种方式:AI像一个打字员,一边思考一边把答案一个字一个字敲给你看,你的感受会完全不同——你不再干等,而是跟着它的思路走,甚至能提前捕捉到它的语气和倾向。这就是流式输出的魔力。

今天,我想和你聊聊我们在AI客服产品中,如何用技术把"等一屏字"变成"边打边看",以及背后那些让你感觉"又快又稳"的小心机。

一、感知速度:比快更快的心理学

先说一个反常识的事实:同样一段话,逐字出现比一次性出现,用户会觉得更快

为什么?因为用户的等待心理被"填充"了。一次性加载时,用户看到的是从0到100%的跃迁,中间是未知的空白;而逐字出现时,用户始终有东西可看,大脑在持续处理信息,等待感被稀释了。

所以,流式输出的核心目标不是真的把延迟降到0(这不可能),而是用连续反馈掩盖延迟,让用户感觉系统"正在努力思考",而不是"死机了"。

二、选型:为什么是SSE,而不是WebSocket?

很多同学第一反应:实时推送?上WebSocket啊!但我们在项目里选了Server-Sent Events(SSE)。

原因很简单:客服场景是"一问一答"的半双工通信,不是实时游戏那种全双工

SSE天生适合:它基于HTTP,服务端单向推送,客户端只用监听。对现有基础设施(代理、负载均衡、CDN)友好,不用维护额外长连接。
WebSocket有点重:双向通信意味着更复杂的协议、心跳、重连逻辑。用在这就像用大炮打蚊子,杀鸡用牛刀。

具体实现上,我们用了微软的 `@microsoft/fetch-event-source` 库。它支持用POST发起请求,建立单向下行连接,完美适配我们"发送问题->接收流式回复"的场景。

而且,不同的业务(普通问答、翻译、菜单OCR、推荐)虽然接口不同,但都复用了同一套流式接收逻辑——一次封装,到处复用,这是代码洁癖者的福音。

三、三条主线:连接、解析、渲染

流式输出的实现可以拆成三个核心环节,每个环节都有坑,也有巧思。

1. 连接与请求控制:别让多条消息打架

想象一下:用户问了一个问题,AI正在逐字回复,结果用户不耐烦又发了一条。这时候如果同时存在两个流,UI就会乱套——A的回复和B的回复混在一起,神仙都分不清。

我们的解决方案很简单:每次发送新消息前,先中止上一次的请求

利用浏览器的 `AbortController`,在发起新请求时调用 `abort()`,保证同一时刻只有一条有效连接。这样既避免竞态,也防止重复订阅导致的性能浪费。

// 伪代码示意
let abortController = null;

async function sendMessage(text) {
  if (abortController) abortController.abort();
  abortController = new AbortController();
  
  await fetchEventSource('/api/chat', {
    signal: abortController.signal,
    onmessage: handleMessage
  });
}

2. 数据解析:从字节流到有意义的片段

服务端推送的是SSE格式的数据,我们需要在 `onmessage` 里解析。我们约定每个消息体是一个JSON,包含 `event` 和 `data` 字段:

`event`:标记消息类型,比如 `message`(增量内容)、`message_end`(结束标志)。
`data`:具体内容,包含 `output` 对象,里面有 `text` 和 `finishReason`。

此外,还要处理转义字符。比如某些接口返回 `\u4e2d\u6587` 这种Unicode,需要用 `decodeUnicode()` 转换成可读的中文。

这一步的核心是稳定地切分数据流,把乱糟糟的字节拼成一条条完整的消息。

3. 文本累积与UI更新:别让用户看到跳帧

收到一个chunk后,我们需要把内容追加到当前正在显示的AI消息上。这里有两个关键点:

如何识别是追加还是新建? 我们通过一个 `isFirst` 标志来判断:如果是当前对话的第一条AI回复,就新建一个消息对象;否则就在最后一条消息的 `content` 上追加。
如何保证UI流畅更新? 直接用 `streamingText += chunk`,然后触发React/Vue的响应式更新。简单粗暴,但有效。

为了让不同业务共用同一套UI,我们把"追加逻辑"抽象成 `addAIMessage(data, 'ai', isFirst)`,内部根据数据类型决定是替换还是追加。这样,无论后端是实时流还是一次性返回,前端都能正确渲染。

四、差异化策略:同一个"流",不同的"戏法"

虽然都叫流式输出,但不同业务后端的实现天差地别。我们做了针对性处理:

input类型(实时流):服务端真正流式返回,每收到一个chunk就追加内容。这是最理想的"真流式",首字延迟低,体验顺滑。
db类型(数据库检索):后端要查数据库,等查完才一次性返回完整答案,不支持流式。怎么办?我们耍了个"小聪明":前端模拟打字机效果——收到完整答案后,用 `setTimeout` 按字符间隔逐个显示。

但这有个坑:频繁的 `setTimeout` 会阻塞事件循环,如果答案很长,用户会感觉卡顿。更好的做法是用字符队列 + `requestAnimationFrame` 非阻塞渲染,这是我们后续优化的方向。

menu/ocr等:这些接口返回的结构化数据,我们按 `output.text` 增量追加,用 `finishReason == 'stop'` 判断结束。

你看,同样是"流",后端不给力时,前端就得自己演。但演也要演得专业,不能让用户察觉。

五、小结与可优化点

目前这套方案已经在线上平稳运行,优点明显:

首字延迟低:SSE建立连接后,第一个字节就能推过来。
支持中断:用户可以随时打断AI回复。
复用度高:一套代码处理多种聊天类型。

当然,还有改进空间:

db模式的非阻塞渲染:用 `requestAnimationFrame` 替代 `setTimeout`,避免长文本卡UI。
各chat类型分支解耦:目前是 `if-else` 堆砌,未来可以改成策略模式,方便扩展。
断线自动重连:网络闪断时,SSE连接会断开。我们需要监听 `onerror`,实现指数退避重连。

六、写在最后

流式输出的本质,是在网络和算力延迟的现实下,用"边算边送、边送边显"把等待感转移给后端和网络,让用户在前端几乎感觉不到等待

这不仅是技术实现,更是对用户体验的深刻理解。就像你和朋友聊天,如果对方总是等想好全部才开口,你会觉得他反应慢;但如果他一边想一边说,你会觉得他思维敏捷,甚至能参与他的思考过程。

我们做的,就是让AI学会"一边想一边说"。而这背后,是前端对网络、对渲染、对人性的把控。

希望这篇文章能给你一些启发。如果你也在做类似的功能,欢迎交流,我们一起踩坑,一起进步。

阅读时长:6 分钟


文档信息

版权声明:自由转载-非商用-非衍生-保持署名(CC BY-NC-ND 3.0)

原文链接:https://yijinlee.com/share-future/article-10

作者:李奕锦

商业用途或修改衍生请联系授权。


TL;DR

  • 流式输出的核心不是降低真实延迟,而是用连续反馈掩盖延迟,让用户感觉系统"正在努力思考"。
  • SSE 适配一问一答半双工,比 WebSocket 更轻量;AbortController 保证同一时刻只有一条流,避免 UI 混乱。
  • input 真流式、db 模拟打字机、menu/ocr 增量追加:同一套接收逻辑,差异化渲染策略。
Tags:SSEServer-Sent Events流式输出AbortControllerfetch-event-sourceReact用户体验

该专题下的阅读路径

系统设计原则 → 工程化实践 → 技术选型与重构