面试官:AI 聊天的逐字回复,到底是怎么实现的?
2026/7/19 5:17:43 网站建设 项目流程

SSE 是什么
用过豆包、ChatGPT 这类 AI 产品的人,对逐字输出的「打字机效果」一定不陌生。不少小伙伴可能会以为这是前端做的模拟打字动画,或是通过 WebSocket 实现的实时推送。

实际上,这类流式输出的核心技术是 SSE(Server-Sent Events,服务端推送事件)。目前大模型流式接口、Agent 任务进度推送等场景普遍采用这项技术。

理解 SSE 最直观的方式,不是先背协议条文,而是打开浏览器抓包,亲眼观察它的运行过程。

以豆包为例,随便发起一个提问,等回复开始逐字输出时,按下 F12 打开开发者工具,切换到网络面板,找到 /chat/completion 请求,点进去再切到 EventStream 标签页。

这时就能看到清晰的事件流界面。左侧是页面上正在逐字渲染的回答内容,右侧则是浏览器自动解析后呈现的结构化 SSE 事件列表。

图片

EventStream 列表里每新增一条记录,页面上就会每多渲染一些文字。这就是 SSE 的核心工作模式:服务端生成一点就推送一点,前端收到一点就渲染一点。

把 SSE 和普通 HTTP 请求放在一起对比,两者的表现差异十分明显:

现象 普通 HTTP SSE
请求状态 很快变成 200 OK,Timeline 里一条短竖线 长时间停在 Pending,Timeline 拉得很长
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 正是第三条路线的官方标准实现。

  1. 轮询
    做过实时需求的同学,第一个想到的方案大概率是轮询。既然服务端不会主动发,那就让前端隔几秒就查一次有没有新数据。

这种方案完全基于普通 HTTP 实现,不需要任何额外协议支持,开发成本极低。但它的缺陷也同样十分突出:

无效开销大:网络面板里刷刷刷一堆请求,大部分都是空结果,带宽和服务端资源都浪费在无效查询上;

延迟和成本天然矛盾:想更新快就得缩短轮询间隔,间隔一短请求量就上去了;想省资源就得拉长间隔,实时性又下来了,两头没法兼顾;

轮询本质是「用客户端的勤快弥补 HTTP 不能主动推送的短板」,属于妥协方案,仅适合对实时性要求不高的低频场景。

  1. WebSocket
    想做真正的主动推送,另一个大家耳熟能详的方案是 WebSocket。

它先通过一次 HTTP 请求完成握手升级,服务端返回 101 Switching Protocols 之后,就切换成了 WebSocket 协议,变成一条双向通信通道。连接不断开的话,客户端和服务端想什么时候发消息就什么时候发,不受请求 - 响应的限制。

这种双向对等的通信能力,非常适合即时通讯、多人协同编辑、实时对战游戏这类双方都需要高频收发消息的场景。但对应的落地成本也显著更高:

协议栈独立:WebSocket 是独立于 HTTP 的完整协议体系,网关、负载均衡、防火墙、CDN 都需要单独做适配。

运维复杂:鉴权设计、运维监控、问题排查的复杂度也远高于普通 HTTP 服务。

回到 AI 对话场景,交互模式其实是典型的「单请求、长响应」:用户只需要发送一次 Prompt,后续全程都是服务端单向输出生成结果,客户端几乎没有上行数据。

这种单向流式场景,使用 WebSocket 相当于用双向通信的重型工具去解决单向推送的轻型需求,协议能力冗余,运维成本偏高,投入产出比并不理想。

  1. SSE
    SSE 填补了轮询与 WebSocket 之间的空白:它不更换底层协议,完全在 HTTP 原生框架之内,实现了标准化的服务端单向推送能力。

客户端只需发起一次普通的 HTTP 请求,连接建立后就持续保持打开状态,服务端生成一段数据就推送一段,直到全部内容发送完成,再正常关闭连接。

比起轮询,它不用反复建连、反复发请求,没有那么多无效开销;比起 WebSocket,它不用引入一套新的协议体系,HTTP 现有的鉴权、网关、安全策略都能直接复用,落地和维护成本低很多。

这种轻量的单向流式特性,和大模型的生成模式天然高度匹配。大模型逐 token 生成内容的过程,本身就是持续的流式输出;用户提交一次请求后,服务端全程单向返回数据,这也是 OpenAI、豆包等主流大模型厂商,都选择 SSE 作为流式接口底层方案的核心原因。

图片

  1. 方案对比
    我们可以用一张表快速对比几种方案的核心差异:

技术方案 协议基础 通信模型 网络特征 典型适用场景 落地与运维成本
普通 HTTP HTTP 请求 - 响应,无主动推送 请求快速完成,响应一次性返回 静态资源加载、常规数据查询 最低
轮询 HTTP 客户端定时拉取(伪推送) 大量重复短请求,多数为空响应 低频非实时状态刷新 低
WebSocket 独立 WebSocket 协议 全双工双向通信 ws/wss 协议,双向数据帧传输 即时通讯、协同编辑、实时游戏 高
SSE HTTP 服务端单向真实推送 单条 HTTP 长连接,响应体持续增长 AI 流式输出、任务进度推送 低
基于以上对比,可以做出以下选型逻辑:

常规的数据查询、状态刷新,对实时性没那么高要求的,普通 HTTP 就够了,别为了「看起来高级」平白增加技术复杂度;

核心需求就是服务端单向持续输出,比如流式对话、日志实时查看、进度条推送,优先选 SSE,实现简单、生态成熟,踩坑也少;

只有当业务真的需要两边高频互发消息的时候,再考虑上 WebSocket。

放到 AI 应用这个场景里结论就更直接了:绝大多数流式需求都是服务端单向吐数据,SSE 基本是当前最均衡、最划算的选择。

三、SSE 协议
搞懂了 SSE 的定位和适用场景,再看它的协议设计会发现格外简洁。它没有复杂的二进制封包,全程基于 UTF-8 纯文本,仅靠换行和空行做消息边界,理解和实现的门槛都很低。

第一节我们在 EventStream 面板里看到的那些结构化数据,拆解到底就是一行行约定好格式的响应文本。

  1. 报文格式
    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: Hello
event: 自定义事件类型。前端可按事件名分别监听;不写则默认触发 message 事件 event: progress
id: 消息唯一 ID。断线重连时浏览器会通过 Last-Event-ID 请求头带给服务端 id: 4102
retry: 重连间隔。告知浏览器在连接断开后,等待多少毫秒再进行重连尝试。 retry: 3000
: 注释/心跳。以冒号开头的行会被浏览器忽略。常用于发送心跳包,防止网关超时断连。 : ping
报文示例 1:一条标准的消息

event: message
id: 1
data: {“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即可。

  1. 客户端对接方式
    浏览器消费 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: progress
es.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); } }); }

}

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询