前言:为什么 AI 回复要”一个字一个字”往外蹦? 用过 ChatGPT 或 Claude 的人都注意过这个现象:当你发出一条消息,AI 的回复不是一次性弹出来的,而是一个字一个字、一行一行地流出来 ,就像有人在打字。
这不是为了”显得在思考”的视觉效果,而是有实实在在的技术原因:
LLM 生成一个完整回复可能需要 10-30 秒,如果等全部生成完再发给用户,体验极差
流式输出让用户立即看到响应开始 ,大幅降低感知延迟
某些场景(如实时翻译、对话、代码补全)里用户甚至不需要等完整答案——看到一半就知道对不对
这套技术背后的协议就叫 SSE(Server-Sent Events,服务器推送事件) 。本文从底层原理讲到 AI API 实战,帮你彻底搞懂它。
第一章:SSE 是什么? 1.1 几种实时通信方式对比
方式
方向
协议
复杂度
适用场景
短轮询
客户端→服务器
HTTP
⭐ 最简单
不需要实时、定时查一下就行
长轮询
客户端→服务器
HTTP
⭐⭐
模拟实时、兼容性好
SSE
服务器→客户端
HTTP
⭐⭐
单向推送(AI 回复、通知、日志)
WebSocket
双向
WS
⭐⭐⭐
聊天、游戏、协作编辑
1.2 SSE 的核心特点 ┌──────────┐ ┌──────────┐ │ 浏览器 │ ── HTTP GET ──────→ │ 服务器 │ │ (客户端) │ ←── text /event -stream │ (AI API) │ └──────────┘ 持续推送,不断开连接 └──────────┘
单向 :服务器 → 客户端。客户端只需发一次 HTTP 请求,服务器持续推送
基于 HTTP :不需要特殊协议,普通 HTTP 就行,天然支持代理、负载均衡、鉴权
自动重连 :浏览器原生 EventSource API 在连接断开时会自动重连
纯文本 :数据格式是纯文本,易于调试和理解
轻量 :比 WebSocket 简单得多,不需要握手升级协议
1.3 什么时候用 SSE,什么时候用 WebSocket?
场景
推荐
原因
AI 对话流式输出
SSE
单向推送,不需要客户端发消息
服务器日志实时查看
SSE
单向,简单
股票行情推送
SSE
单向数据流
通知推送
SSE
单向,自动重连
在线聊天
WebSocket
需要双向实时通信
多人协作编辑
WebSocket
双向高频通信
游戏
WebSocket
低延迟双向
第二章:SSE 协议细节 2.1 HTTP 层面的约定 客户端发起一个普通的 HTTP GET 请求:
GET /stream HTTP/1.1 Host : api.example.comAccept : text/event-streamCache-Control : no-cache
服务器返回一个特殊的响应:
HTTP/1.1 200 OKContent-Type : text/event-streamCache-Control : no-cacheConnection : keep-alive
关键在 Content-Type: text/event-stream——浏览器看到这个就知道这是一个 SSE 流,不会关闭连接。
2.2 数据格式 SSE 的数据体由若干字段 组成,每条消息以空行分隔。核心字段:
event: 事件类型(可选,默认 "message" )id: 消息 ID(可选,用于断线重连的 Last-Event -ID)data: 数据内容(可以有多个 data 行,会拼接)retry: 重连间隔(毫秒,可选)
简单示例:
data : 你好data : 这是第二条消息data : 这是第二条的续行event : errordata : 出错了id : 42 data : {"token ": "Hello "}
2.3 浏览器端:EventSource API 浏览器原生支持 SSE,几行代码就能用:
const source = new EventSource ('/api/stream' );source.onmessage = (event ) => { console .log ('收到数据:' , event.data ); console .log ('消息ID:' , event.lastEventId ); }; source.addEventListener ('error' , (event ) => { console .error ('流错误:' , event.data ); }); source.addEventListener ('open' , () => { console .log ('连接已建立' ); }); source.onerror = (event ) => { if (source.readyState === EventSource .CLOSED ) { console .log ('连接已关闭(不会重连)' ); } else { console .log ('连接出错,正在重连...' ); } }; source.close ();
2.4 Node.js 服务端实现 const http = require ('http' );http.createServer ((req, res ) => { if (req.url === '/stream' ) { res.writeHead (200 , { 'Content-Type' : 'text/event-stream' , 'Cache-Control' : 'no-cache' , 'Connection' : 'keep-alive' , 'Access-Control-Allow-Origin' : '*' , }); const keepAlive = setInterval (() => { res.write (': keepalive\n\n' ); }, 15000 ); let count = 0 ; const sendInterval = setInterval (() => { count++; res.write (`data: 这是第 ${count} 条消息\n` ); res.write (`id: ${count} \n\n` ); if (count >= 10 ) { clearInterval (sendInterval); clearInterval (keepAlive); res.end (); } }, 1000 ); req.on ('close' , () => { clearInterval (sendInterval); clearInterval (keepAlive); }); } }).listen (3000 );
💡 : keepalive 是 SSE 的注释语法(冒号开头),浏览器会忽略,但能防止代理服务器因长时间无数据而断开连接。
第三章:AI 中的 SSE 流式输出 3.1 为什么 LLM 天生适合 SSE? LLM 是逐 token 生成 的(自回归),天然就是流式的:
用户输入:"解释一下量子计算" │ ▼ ┌─────────────────────────────┐ │ LLM 逐 token 生成: │ │ 量 → 子 → 计 → 算 → 是 │ │ → 一 → 种 → 利 → 用 │ │ → 量 → 子 → 力 → 学 │ │ → ... │ └─────────────────────────────┘ │ 每个 token 生成后立即发送 ▼ ┌─────────────────────────────┐ │ SSE : │ │ data : 量 │ │ data : 子 │ │ data : 计 │ │ data : 算 │ │ ... │ └─────────────────────────────┘
相比”等全部生成完再一次性返回”:
⏱️ 首字延迟(TTFB)从 30 秒降到 0.5 秒
🧠 用户体验从”在等”变成”在看”
💰 可以在生成到一半时取消(省钱)
3.2 OpenAI API 的流式调用 OpenAI 的 Chat Completions API 设置 stream: true 即可启用 SSE:
import requestsimport jsonurl = "https://api.openai.com/v1/chat/completions" headers = { "Authorization" : "Bearer YOUR_API_KEY" , "Content-Type" : "application/json" , } data = { "model" : "gpt-4o" , "messages" : [{"role" : "user" , "content" : "用三句话介绍量子计算" }], "stream" : True , } response = requests.post(url, json=data, headers=headers, stream=True ) for line in response.iter_lines(): if not line: continue line = line.decode("utf-8" ) if line.startswith("data: " ): json_str = line[6 :] if json_str == "[DONE]" : print ("\n--- 完成 ---" ) break chunk = json.loads(json_str) content = chunk["choices" ][0 ]["delta" ].get("content" , "" ) if content: print (content, end="" , flush=True )
输出效果:
3.3 OpenAI SSE 的事件结构 OpenAI 的每个 SSE 事件是一个 JSON 块:
{ "id" : "chatcmpl-xxx" , "object" : "chat.completion.chunk" , "created" : 1714567890 , "model" : "gpt-4o" , "choices" : [ { "index" : 0 , "delta" : { "content" : "量" } , "finish_reason" : null } ] }
流结束时发送:
3.4 Anthropic Claude 的流式调用 Claude API 也支持 SSE,语法类似:
import anthropicclient = anthropic.Anthropic(api_key="YOUR_KEY" ) with client.messages.stream( model="claude-sonnet-4-20250514" , max_tokens=1024 , messages=[{"role" : "user" , "content" : "介绍一下机器学习" }], ) as stream: for text in stream.text_stream: print (text, end="" , flush=True )
Claude 的流式事件类型更丰富:
事件类型
含义
message_start
消息开始
content_block_start
内容块开始(包括 thinking 块)
content_block_delta
内容增量(实际的文本)
content_block_stop
内容块结束
message_delta
消息级增量(如 stop_reason)
message_stop
消息结束
第四章:前端实战 —— 打造 AI 聊天界面 4.1 用 EventSource 对接 OpenAI async function chatWithAI (prompt ) { const response = await fetch ('https://api.openai.com/v1/chat/completions' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' , 'Authorization' : `Bearer ${apiKey} ` , }, body : JSON .stringify ({ model : 'gpt-4o' , messages : [{ role : 'user' , content : prompt }], stream : true , }), }); const reader = response.body .getReader (); const decoder = new TextDecoder (); 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 () || '' ; for (const line of lines) { if (!line.startsWith ('data: ' )) continue ; const jsonStr = line.slice (6 ); if (jsonStr === '[DONE]' ) return ; try { const chunk = JSON .parse (jsonStr); const content = chunk.choices ?.[0 ]?.delta ?.content ; if (content) { appendToChat (content); } } catch (e) { } } } } await chatWithAI ('你好,请介绍一下你自己' );
4.2 完整的聊天 UI 实现 <!DOCTYPE html > <html > <head > <title > AI Chat</title > <style > body { font-family : system-ui; max-width : 700px ; margin : 40px auto; } #chat { border : 1px solid #ddd ; border-radius : 8px ; padding : 20px ; min-height : 400px ; margin-bottom : 20px ; } .msg { margin : 8px 0 ; padding : 10px 14px ; border-radius : 12px ; max-width : 80% ; } .user { background : #e3f2fd ; margin-left : auto; } .ai { background : #f5f5f5 ; } .cursor ::after { content : '▊' ; animation : blink 1s step-end infinite; } @keyframes blink { 50% { opacity : 0 ; } } #input-area { display : flex; gap : 10px ; } #input { flex : 1 ; padding : 12px ; border : 1px solid #ddd ; border-radius : 8px ; font-size : 16px ; } button { padding : 12px 24px ; border : none; border-radius : 8px ; background : #1976d2 ; color : white; cursor : pointer; font-size : 16px ; } button :hover { background : #1565c0 ; } button :disabled { opacity : 0.5 ; cursor : not-allowed; } </style > </head > <body > <div id ="chat" > </div > <div id ="input-area" > <input id ="input" type ="text" placeholder ="输入消息..." > <button id ="send" > 发送</button > </div > <script > const chat = document .getElementById ('chat' ); const input = document .getElementById ('input' ); const sendBtn = document .getElementById ('send' ); const API_KEY = 'YOUR_KEY' ; function addMsg (role, text ) { const div = document .createElement ('div' ); div.className = `msg ${role} ` ; div.textContent = text; chat.appendChild (div); chat.scrollTop = chat.scrollHeight ; return div; } async function send ( ) { const prompt = input.value .trim (); if (!prompt) return ; addMsg ('user' , prompt); input.value = '' ; sendBtn.disabled = true ; const aiDiv = addMsg ('ai' , '' ); aiDiv.classList .add ('cursor' ); try { const res = await fetch ('https://api.openai.com/v1/chat/completions' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' , 'Authorization' : `Bearer ${API_KEY} ` , }, body : JSON .stringify ({ model : 'gpt-4o' , messages : [{ role : 'user' , content : prompt }], stream : true , }), }); const reader = res.body .getReader (); const decoder = new TextDecoder (); 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 () || '' ; for (const line of lines) { if (!line.startsWith ('data: ' ) || line.includes ('[DONE]' )) continue ; try { const chunk = JSON .parse (line.slice (6 )); const content = chunk.choices ?.[0 ]?.delta ?.content ; if (content) aiDiv.textContent += content; } catch (e) {} } chat.scrollTop = chat.scrollHeight ; } } catch (e) { aiDiv.textContent = '请求失败:' + e.message ; } aiDiv.classList .remove ('cursor' ); sendBtn.disabled = false ; } sendBtn.addEventListener ('click' , send); input.addEventListener ('keydown' , (e ) => { if (e.key === 'Enter' && !e.shiftKey ) { e.preventDefault (); send (); } }); </script > </body > </html >
效果:实时流式打字、闪烁光标、自动滚动。保存为 .html 就可以直接用。
第五章:服务端中转 —— 保护 API Key 前端直接调 OpenAI API 会暴露 API Key,正确做法是加一个后端代理:
const express = require ('express' );const app = express ();app.use (express.json ()); app.use (express.static ('public' )); app.post ('/api/chat' , async (req, res) => { const { prompt } = req.body ; res.writeHead (200 , { 'Content-Type' : 'text/event-stream' , 'Cache-Control' : 'no-cache' , 'Connection' : 'keep-alive' , }); try { const aiResponse = await fetch ('https://api.openai.com/v1/chat/completions' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' , 'Authorization' : `Bearer ${process.env.OPENAI_API_KEY} ` , }, body : JSON .stringify ({ model : 'gpt-4o' , messages : [{ role : 'user' , content : prompt }], stream : true , }), }); const reader = aiResponse.body .getReader (); const decoder = new TextDecoder (); while (true ) { const { done, value } = await reader.read (); if (done) break ; const text = decoder.decode (value, { stream : true }); res.write (text); } res.end (); } catch (error) { res.write (`event: error\ndata: ${JSON .stringify({ error: error.message })} \n\n` ); res.end (); } }); app.listen (3000 , () => console .log ('Server on http://localhost:3000' ));
这样前端只需调 /api/chat,API Key 安全存放在服务端环境变量中。
第六章:高级话题 6.1 SSE vs WebSocket 的技术细节
维度
SSE
WebSocket
协议
HTTP/1.1 或 HTTP/2
独立协议(ws:// wss://)
浏览器 API
EventSource
WebSocket
二进制数据
不支持(仅文本)
支持
自动重连
原生支持
需手动实现
连接数限制
HTTP/1.1 每域名 6 个
无限制
HTTP/2 多路复用
完美支持
不适用
穿透代理
天然支持
可能被某些代理阻断
消息方向
单向(S→C)
双向
关键优势 :在 HTTP/2 下,多个 SSE 连接可以复用同一条 TCP 连接,不再有 6 连接限制。
6.2 流式思考(Streaming Thinking) Claude 等模型支持”思考过程”流式输出——在正式回答之前,先把推理过程实时展示给用户:
{ "type" : "content_block_start" , "content_block" : { "type" : "thinking" , "thinking" : "让我分析这个问题..." } } { "type" : "content_block_delta" , "delta" : { "type" : "thinking_delta" , "thinking" : "首先考虑时间复杂度..." } }
这给用户透明度和信任感——知道 AI 不是瞎猜的。
6.3 断线续传 EventSource 的 lastEventId 机制支持断线续传:
const lastId = req.headers ['last-event-id' ];if (lastId) { resumeFrom (lastId); }
在 AI 场景中,这意味着如果网络断了,可以恢复连接继续输出而不是从头开始。
6.4 多模态流式输出 最新的多模态模型支持流式输出混合内容(文本 + 图片 + 音频):
data: {"type" : "text" , "content" : "这是生成的文字..." }data: {"type" : "image" , "content" : "base64..." , "mime" : "image/png" }data: {"type" : "audio" , "content" : "base64..." , "mime" : "audio/wav" }
前端需要解析不同的 content type 并分别渲染。
第七章:调试与排错 常见问题 Q:EventSource 为什么连不上?
检查:
响应头 Content-Type: text/event-stream 是否正确
跨域问题 → 服务端加 Access-Control-Allow-Origin
EventSource 不支持自定义 Header → 用 fetch + ReadableStream 代替
Q:为什么中间会断?
可能原因:
代理服务器超时(nginx 默认 60s) → 加 : keepalive 注释或调大超时
服务端没处理 req.on('close') → 客户端断开后还在写数据
Q:数据乱了怎么办?
SSE 以空行分隔消息。如果数据中有空行会乱。处理时积累 buffer,只在遇到完整事件后才解析。
Q:浏览器标签页切到后台,SSE 会暂停吗?
不会停止接收,但 setTimeout/setInterval 会被降频。UI 更新尽量用 requestAnimationFrame。
总结
知识点
一句话
SSE 是什么
HTTP 长连接,服务器单向推送文本事件流
和 WebSocket 区别
SSE 单向(S→C)、基于 HTTP、更简单;WS 双向、独立协议
AI 为什么用 SSE
LLM 逐 token 生成,天然流式,首字延迟极低
EventSource
浏览器原生 API,但只支持 GET + 标准 Header
fetch + ReadableStream
手动解析 SSE,支持 POST + 自定义 Header
生产环境注意
服务端代理保护 Key、防超时、断线续传
SSE 不是什么新技术(2009 年就标准化了),但伴随着 LLM 的爆发,它成为了 AI 应用中最核心的通信协议之一。掌握它,你就能理解市面上几乎所有 AI 产品的”打字效果”是怎么实现的。