AI 对话里的 SSE
聊聊 AI 对话里的 SSE
打开任何一个 AI 对话产品,你都会看到那个熟悉的「打字机效果」——文字一个字一个字地蹦出来。这背后几乎清一色是同一个技术:SSE(Server-Sent Events)。
为什么是 SSE 而不是 WebSocket?浏览器原生的 SSE 为什么生产环境不用?断线了怎么续上?工具调用又是怎么塞进流里的?这篇把这些一次讲清楚。
读完这篇你能收获什么
- ✅ 说清楚 AI 对话为什么选 SSE,以及它和短轮询 / 长轮询 / WebSocket 的取舍
- ✅ 看懂 SSE 协议的真身,知道
data/event/id/ 空行分隔各是干嘛的- ✅ 明白原生
EventSource为什么上不了生产,以及生产为什么改用fetch + ReadableStream- ✅ 会自己解析 SSE 流,避开粘包拆包、中文乱码这几个必踩的坑
- ✅ 掌握异常分类、断线续传、心跳保活,让对话通道扛得住真实网络
- ✅ 理解流式工具调用(Tool Calling)和 Agent 循环,让对话从「会聊天」进化到「会干活」
一、为什么 AI 对话偏偏选了 SSE
先看 AI 对话的本质需求。大模型是逐 token 生成的,一段回答算完要好几秒甚至几十秒。如果等全部算完再返回,用户就得盯着空白屏幕干等——体验灾难。
所以核心诉求就八个字:服务端边生成、客户端边渲染。
抓住两个关键词:单向(数据主要是服务端往客户端推)+ 流式(一次请求、持续返回片段)。带着这两点看候选方案:
1 | |
WebSocket 为什么被淘汰?它是全双工的,而 AI 对话里客户端只在开头发一次问题,之后几乎不往上推——全双工是浪费。更麻烦的是它走独立协议(ws://),需要单独握手升级,CDN、鉴权网关、Gzip、监控这些 HTTP 基础设施对它支持不好,断线重连、心跳都得自己造轮子。它适合聊天室、协同编辑这种真·双向高频场景。
而 SSE 就是为「服务端→客户端单向流」而生的,它走标准 HTTP,整套 HTTP 生态无缝复用,协议还极简。
| 维度 | 短轮询 | 长轮询 | WebSocket | SSE |
|---|---|---|---|---|
| 数据方向 | 拉 | 拉 | 全双工 | 服务端→客户端 |
| 底层协议 | HTTP | HTTP | 独立协议 | HTTP |
| 实时性 | 差 | 中 | 高 | 高 |
| 基础设施兼容 | 好 | 好 | 差 | 好 |
| 实现复杂度 | 低 | 中 | 高 | 低 |
| 适配 AI 对话 | 差 | 凑合 | 过重 | 最佳 |
这就是 OpenAI、Anthropic 以及国内各家大模型 API 全都用 SSE 的原因。
二、SSE 协议长什么样
在写代码前,先看它在网络上的真身。响应头必须声明:
1 | |
响应体是一行行纯文本,以空行 \n\n 分隔一个个事件:
1 | |
字段就这几个,记住即可:
data:—— 数据内容event:—— 事件名,客户端可按名分发id:—— 事件 ID,断线续传的关键retry:—— 断线后重连间隔(毫秒):开头 —— 注释行,常用来做心跳保活- 空行
\n\n—— 事件结束的分隔符,解析全靠它
三、原生 EventSource:能跑,但上不了生产
浏览器自带了 SSE 客户端 EventSource,代码少到感人:
1 | |
浏览器帮你做了解析、自动重连、Last-Event-ID 回传。看起来很美——但一到真实 AI 产品就全是坑:
❌ 只能 GET,不能 POST。 AI 对话要发一大坨 JSON(几千 token 的上下文、model、temperature),GET 只能塞 query string,根本装不下。
❌ 不能自定义请求头。 现代鉴权靠
Authorization: Bearer <token>,EventSource不支持加 header,你只能把 token 塞进 URL——不安全。❌ 重连不可控。 浏览器断线后会无脑用原始 URL 重发 GET。对一个生成到一半的对话,重发只会得到一个全新的回答,而不是「接着刚才继续」。这时候自动重连反而是 bug。
❌ 错误信息为零。
onerror里拿不到 HTTP 状态码,不知道是 401 还是 500 还是断网,没法精细处理。
一句话:EventSource 是「只读一个公开 GET 接口」的玩具。AI 对话需要带鉴权的 POST + 大 body + 可控错误处理,它一个都满足不了。
四、生产标准姿势:fetch + ReadableStream
既然原生客户端不够用,那就自己当 SSE 客户端:用 fetch 发请求(想 POST 就 POST、想加 header 就加),再手动读取响应的可读流,自己解析 SSE。
1 | |
对比原生 EventSource,用「自己解析协议」这一点代价,换回了全部控制权:
| 能力 | 原生 EventSource | fetch + ReadableStream |
|---|---|---|
| 请求方法 | 只能 GET | 任意(POST) |
| 自定义 Header | ❌ | ✅ 可带鉴权 |
| 请求体 body | ❌ | ✅ 大 JSON |
| HTTP 状态码 | ❌ | ✅ |
| 重连策略 | 浏览器托管、不可控 | 完全自己掌控 |
| 取消请求 | .close() |
AbortController |
Vercel 的 ai SDK、@microsoft/fetch-event-source、LangChain 前端,底层全是这个套路。
⚠️ 解析时的三个必踩坑
自己解析 SSE,有三个几乎人人会栽的坑:
1. 一定要用 buffer 处理「粘包 / 拆包」。 TCP 是字节流,read() 返回的一块数据边界完全不可靠——可能是半个事件,也可能是两个半事件。必须累积后按 \n\n 切分,别指望「一个 chunk 就是一个事件」。
1 | |
2. TextDecoder 必须开 stream: true。 一个 UTF-8 中文字符可能横跨两个 chunk,不加它会解出乱码「�」。
3. data: 冒号后约定有一个空格,解析时要去掉。
五、生产级容错:异常、断连重续、心跳
真实网络是不可靠的:wifi 切 4G、隧道、服务端超时、token 过期……这些都得兜住。
异常要分类,别一把 catch 了事
1 | |
有个特别关键的点:「正常结束」的唯一标志是收到约定的结束事件(如 [DONE]),而不是 done === true。 连接断了 done 也会是 true。必须用哨兵标记区分「正常收完」和「半路断了」,否则断线会被当成正常结束,用户就看到一句没说完的话。
断线续传:别让用户重问一遍
天真的重连 = 重发请求 = 模型从头再答一遍,用户看着回答「闪一下重来」,还白烧一次 token。正确做法是接着断掉的地方继续,两种思路:
① SSE 原生的 Last-Event-ID(连接级续传)
服务端给每个事件带 id:,客户端重连时把最后收到的 id 通过 Last-Event-ID 头回传,服务端从该 id 之后继续推。用 fetch 需要手动维护:
1 | |
② 业务层续传(更常用)
很多大模型 API 不支持连接级续传(一次生成是原子的)。这时在业务层做:把「已生成的部分」落库或缓存,重连时从库里补齐缺失部分,或作为上下文让模型「继续之前未完成的回答」。
配合指数退避 + 抖动,避免断网时的重连风暴:
1 | |
心跳保活:防止「假死」连接
模型「思考」时可能十几秒不吐字,中间的代理/网关会把这条「空闲」连接判定为死连接掐掉。解法是服务端空闲时定期发注释行心跳,客户端解析时自动忽略:
1 | |
六、进阶:让流式对话「会干活」——工具调用
现代 AI 不只是吐文字,还要调用工具(查天气、搜索、执行代码)。难点在于:工具调用的参数本身也是流式、一点点吐出来的。
模型不会一次给你完整的调用,而是分片吐 tool_calls 的 delta:
1 | |
函数名一次给全,但 arguments(一个 JSON 字符串)是逐字符拼出来的。所以要按 index 累积,直到 finish_reason: "tool_calls" 才 JSON.parse:
1 | |
而工具调用不是终点,它是一个多轮循环——这也是 Agent 的核心:
1 | |
1 | |
几个实战要点:tool_calls 数组可能一次有多个(并行调用),要并发执行并按 tool_call_id 对齐结果;工具执行失败也要作为 tool 消息回传,让模型自己决定重试还是换方案,别直接抛给用户。
写在最后
一条生产级的 AI 对话通道,本质就是三层:
- 选对协议:AI 对话是「单向 + 流式」,SSE 用最小复杂度完美匹配,还白嫖整套 HTTP 生态。
- 可控的客户端:弃用玩具级的
EventSource,改用fetch + ReadableStream,付出「自己解析协议」的代价换回完全控制权——记得用 buffer 处理粘包拆包。 - 鲁棒的连接管理 + Agent 循环:异常分类、断线续传、心跳保活兜住不稳定网络;再套一层工具调用循环,就从「会聊天」进化成「会干活」。
看懂这三层,你就看懂了市面上所有 AI 对话产品的流式底座。