面试官:AI 聊天的逐字回复,到底是怎么实现的?

面试官:AI 聊天的逐字回复,到底是怎么实现的?
SSE 是什么用过豆包、ChatGPT 这类 AI 产品的人对逐字输出的「打字机效果」一定不陌生。不少小伙伴可能会以为这是前端做的模拟打字动画或是通过 WebSocket 实现的实时推送。实际上这类流式输出的核心技术是 SSEServer-Sent Events服务端推送事件。目前大模型流式接口、Agent 任务进度推送等场景普遍采用这项技术。理解 SSE 最直观的方式不是先背协议条文而是打开浏览器抓包亲眼观察它的运行过程。以豆包为例随便发起一个提问等回复开始逐字输出时按下 F12 打开开发者工具切换到网络面板找到 /chat/completion 请求点进去再切到 EventStream 标签页。这时就能看到清晰的事件流界面。左侧是页面上正在逐字渲染的回答内容右侧则是浏览器自动解析后呈现的结构化 SSE 事件列表。图片EventStream 列表里每新增一条记录页面上就会每多渲染一些文字。这就是 SSE 的核心工作模式服务端生成一点就推送一点前端收到一点就渲染一点。把 SSE 和普通 HTTP 请求放在一起对比两者的表现差异十分明显现象 普通 HTTP SSE请求状态 很快变成 200 OKTimeline 里一条短竖线 长时间停在 PendingTimeline 拉得很长Response 面板 响应一次性完整显示 EventStream 里事件持续追加Content-Type application/json 等 text/event-stream连接生命周期 响应返回即结束 连接保持打开直到回答推完简单来说二者的交互模式有本质区别普通 HTTP 是「一问一答响应结束即断开」。客户端发起一次请求服务端返回完整响应后连接随即关闭页面拿到全部数据再统一渲染。SSE 是「一次请求连接保持分段推送」。客户端仅发起一次请求连接持续保持服务端生成一段数据就推送一段全部内容发送完成后才关闭连接。Chrome 开发者工具的 EventStream 标签会自动解析原始流式响应将其呈现为逐条排列的事件列表正是这种推送模式的直观体现。SSE 是一种基于 HTTP 的服务端到客户端单向推送协议。 连接建立后服务端按照标准文本格式持续推送数据浏览器可逐条接收事件并处理。它的核心特性可以归纳为三点基于 HTTP 协议复用标准 HTTP 长连接无需额外协议栈对防火墙、代理网关天然兼容。单向数据推送数据仅从服务端流向客户端通信方向固定实现逻辑简单。事件流形式传输数据以独立事件为单位分段传输消息边界清晰解析处理方便。二、为什么需要 SSE前面我们通过浏览器抓包直观看到了 SSE 的流式推送效果也对比了它和普通 HTTP 请求的表现差异。此时你可能会产生一个疑问HTTP 已经是通用的网络协议为什么还需要专门的 SSE 技术来做推送答案藏在 HTTP 协议的原生设计局限里。HTTP 协议从诞生起就遵循严格的请求 - 响应模型所有交互必须由客户端主动发起请求服务端被动处理后返回结果响应交付后本次交互即宣告结束。在未收到新请求的前提下服务端没有任何机制主动向客户端推送数据。图片即便 HTTP/1.1 引入了 Keep-Alive 长连接机制也只是让多个 HTTP 请求可以复用同一条 TCP 连接减少建连开销并没有改变「客户端主动请求、服务端被动响应」的核心逻辑。这套模型可以满足页面加载、数据查询等绝大多数常规场景但遇到「服务端数据持续产生、需要实时同步给客户端」的需求时就显得力不从心。比如大模型逐 token 生成回答、后台任务进度更新、实时日志输出这类场景服务端不可能等全部内容生成完再一次性返回用户也无法接受长时间的空白等待。为了突破这个局限业界在实践中有三类主流技术路线用轮询在 HTTP 上模拟推送、换 WebSocket 做全双工通信、基于 HTTP 标准实现单向推送能力。SSE 正是第三条路线的官方标准实现。轮询做过实时需求的同学第一个想到的方案大概率是轮询。既然服务端不会主动发那就让前端隔几秒就查一次有没有新数据。这种方案完全基于普通 HTTP 实现不需要任何额外协议支持开发成本极低。但它的缺陷也同样十分突出无效开销大网络面板里刷刷刷一堆请求大部分都是空结果带宽和服务端资源都浪费在无效查询上延迟和成本天然矛盾想更新快就得缩短轮询间隔间隔一短请求量就上去了想省资源就得拉长间隔实时性又下来了两头没法兼顾轮询本质是「用客户端的勤快弥补 HTTP 不能主动推送的短板」属于妥协方案仅适合对实时性要求不高的低频场景。WebSocket想做真正的主动推送另一个大家耳熟能详的方案是 WebSocket。它先通过一次 HTTP 请求完成握手升级服务端返回 101 Switching Protocols 之后就切换成了 WebSocket 协议变成一条双向通信通道。连接不断开的话客户端和服务端想什么时候发消息就什么时候发不受请求 - 响应的限制。这种双向对等的通信能力非常适合即时通讯、多人协同编辑、实时对战游戏这类双方都需要高频收发消息的场景。但对应的落地成本也显著更高协议栈独立WebSocket 是独立于 HTTP 的完整协议体系网关、负载均衡、防火墙、CDN 都需要单独做适配。运维复杂鉴权设计、运维监控、问题排查的复杂度也远高于普通 HTTP 服务。回到 AI 对话场景交互模式其实是典型的「单请求、长响应」用户只需要发送一次 Prompt后续全程都是服务端单向输出生成结果客户端几乎没有上行数据。这种单向流式场景使用 WebSocket 相当于用双向通信的重型工具去解决单向推送的轻型需求协议能力冗余运维成本偏高投入产出比并不理想。SSESSE 填补了轮询与 WebSocket 之间的空白它不更换底层协议完全在 HTTP 原生框架之内实现了标准化的服务端单向推送能力。客户端只需发起一次普通的 HTTP 请求连接建立后就持续保持打开状态服务端生成一段数据就推送一段直到全部内容发送完成再正常关闭连接。比起轮询它不用反复建连、反复发请求没有那么多无效开销比起 WebSocket它不用引入一套新的协议体系HTTP 现有的鉴权、网关、安全策略都能直接复用落地和维护成本低很多。这种轻量的单向流式特性和大模型的生成模式天然高度匹配。大模型逐 token 生成内容的过程本身就是持续的流式输出用户提交一次请求后服务端全程单向返回数据这也是 OpenAI、豆包等主流大模型厂商都选择 SSE 作为流式接口底层方案的核心原因。图片方案对比我们可以用一张表快速对比几种方案的核心差异技术方案 协议基础 通信模型 网络特征 典型适用场景 落地与运维成本普通 HTTP HTTP 请求 - 响应无主动推送 请求快速完成响应一次性返回 静态资源加载、常规数据查询 最低轮询 HTTP 客户端定时拉取伪推送 大量重复短请求多数为空响应 低频非实时状态刷新 低WebSocket 独立 WebSocket 协议 全双工双向通信 ws/wss 协议双向数据帧传输 即时通讯、协同编辑、实时游戏 高SSE HTTP 服务端单向真实推送 单条 HTTP 长连接响应体持续增长 AI 流式输出、任务进度推送 低基于以上对比可以做出以下选型逻辑常规的数据查询、状态刷新对实时性没那么高要求的普通 HTTP 就够了别为了「看起来高级」平白增加技术复杂度核心需求就是服务端单向持续输出比如流式对话、日志实时查看、进度条推送优先选 SSE实现简单、生态成熟踩坑也少只有当业务真的需要两边高频互发消息的时候再考虑上 WebSocket。放到 AI 应用这个场景里结论就更直接了绝大多数流式需求都是服务端单向吐数据SSE 基本是当前最均衡、最划算的选择。三、SSE 协议搞懂了 SSE 的定位和适用场景再看它的协议设计会发现格外简洁。它没有复杂的二进制封包全程基于 UTF-8 纯文本仅靠换行和空行做消息边界理解和实现的门槛都很低。第一节我们在 EventStream 面板里看到的那些结构化数据拆解到底就是一行行约定好格式的响应文本。报文格式SSE 本质是一种特殊格式的 HTTP 响应。服务端首先要设置正确的响应头浏览器才会将其识别为事件流Content-Type: text/event-stream浏览器识别 SSE 响应的核心标识是必填字段。Cache-Control: no-cache禁止代理、CDN 等中间节点缓存响应避免流式内容被缓冲导致无法实时推送。Connection: keep-alive维持 HTTP 长连接保证数据流可以持续传输。SSE 的报文格式非常简单核心只有两条每行一条字段格式为 字段名: 字段值以换行符\n结尾连续两个换行\n\n即一个空行代表一条完整的消息结束浏览器会立即触发对应的事件回调。标准一共定义了 5 种字段覆盖了正文、事件类型、断线续传、重连配置、心跳保活的需求字段 说明 示例data: 消息正文。单条消息可包含多行 data:最终会拼接成完整内容 data: Helloevent: 自定义事件类型。前端可按事件名分别监听不写则默认触发 message 事件 event: progressid: 消息唯一 ID。断线重连时浏览器会通过 Last-Event-ID 请求头带给服务端 id: 4102retry: 重连间隔。告知浏览器在连接断开后等待多少毫秒再进行重连尝试。 retry: 3000: 注释/心跳。以冒号开头的行会被浏览器忽略。常用于发送心跳包防止网关超时断连。 : ping报文示例 1一条标准的消息event: messageid: 1data: {“role”: “assistant”, “content”: “你好”}浏览器解析后会触发一个名为 message的事件event.data的值为 {“role”: “assistant”, “content”: “你好”}。报文示例 2多行 Data 拼接如果内容较长可以拆分为多行 data: 提升可读性。浏览器会自动将所有行拼接行与行之间自动插入一个换行符data: {data: “role”: “assistant”,data: “content”: “Hello World”data: }拼接后的完整内容为{ “role”: “assistant”, “content”: “Hello World” }开发者只需进行一次 JSON.parse即可。客户端对接方式浏览器消费 SSE 流主要有两种方案原生 EventSource API以及手动解析流式响应。1原生 EventSource API浏览器提供了原生的 EventSource 接口内置了报文解析、自动重连、事件分发能力无需手动逐行拆包基础场景使用成本极低。// 建立 SSE 连接const es new EventSource(“/stream”);// 连接建立成功es.onopen () {console.log(“SSE 连接已建立”);};// 监听默认 message 事件es.onmessage (event) {console.log(“收到消息:”, event.data);};// 监听自定义事件对应服务端的 event: progresses.addEventListener(“progress”, (event) {console.log(“任务进度:”, event.data);});// 连接异常时触发es.onerror () {console.log(“连接异常浏览器会自动尝试重连”);};但原生 API 存在两个明确的限制也是绝大多数生产环境不直接使用它的核心原因只支持 GET 请求无法使用 POST 发送请求体复杂参数只能拼接在 URL 中对于需要传递大量参数的大模型接口并不友好。无法自定义请求头不支持添加 Authorization 之类的自定义请求头难以实现标准的 Token 鉴权。2手动解析为了突破原生 API 的限制现代前端项目包括豆包、OpenAI 的官方 Web SDK普遍采用 fetch 配合 ReadableStream 手动解析 SSE 格式。核心思路是通过 fetch 获取响应的可读流分块读取二进制数据并解码为文本按换行符拆分逐行识别字段遇到空行时将累积的字段组装为完整事件抛出。以下是一个简化版的实现示例仅处理核心的 data 字段async function connectSSE() {const response await fetch(‘/api/chat’, {method: ‘POST’,headers: {‘Content-Type’: ‘application/json’,‘Authorization’: ‘Bearer your_token’ // 支持自定义Header},body: JSON.stringify({ prompt: ‘Hello’ }) // 支持携带请求体});const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; // 将新的二进制块解码为文本追加到缓冲区 buffer decoder.decode(value, { stream: true }); // 按换行拆分所有完整行 const lines buffer.split(\n); // 最后一行可能不完整放回缓冲区等待后续内容 buffer lines.pop(); lines.forEach(line { if (line.startsWith(data: )) { const data line.substring(6); if (data [DONE]) { console.log(Stream finished); return; } // 解析并处理业务数据 const payload JSON.parse(data); console.log(收到数据:, payload); } }); }}