用过 ChatGPT、Claude、Cursor 或其他 AI 对话工具的人,应该都很熟悉一种交互:你发出问题之后,回答不是等几十秒一次性出现,而是一个字、一个词、或者一小段一小段地冒出来。
这个体验看起来像“模型正在打字”。
但从工程角度看,它通常不是模型真的在前端打字,也不是浏览器里有一个神秘的动画在伪造内容。
更常见的实现是:服务端打开一条 HTTP 响应,然后在模型生成过程中不断往这条响应里写入数据;浏览器一边接收,一边解析,一边更新页面。
这类响应最常见的格式之一,就是:
Content-Type: text/event-stream
也就是我们常说的 SSE,Server-Sent Events。
先说结论:逐字输出不是一种 UI 技巧,而是一种传输形态
如果不用流式响应,一个 AI 对话请求大概是这样的:
用户发送问题 -> 服务端请求模型 -> 模型完整生成回答 -> 服务端一次性返回 JSON -> 前端一次性渲染整段文本
这种方式简单,但用户体验很差。
因为模型生成可能需要几秒、十几秒,甚至更久。在完整回答返回之前,浏览器什么也看不到。用户不知道系统是否还活着,也不知道回答已经生成到哪里。
流式响应把这个过程改成了:
用户发送问题 -> 服务端请求模型 -> 模型每生成一小段 token -> 服务端立刻把这一小段写给浏览器 -> 浏览器立刻追加到当前回答里
前端看到的“逐字出现”,本质上是“增量数据不断到达,然后增量渲染”。
这里有两个层次不要混在一起:
- 网络层:数据是不是分块返回
- 展示层:前端是不是把每个分块立刻显示出来
SSE 解决的是前者。打字机效果解决的是后者。
很多 AI 对话产品里的“一个字一个字出现”,其实是这两层叠在一起的结果。
SSE 是什么
SSE,全称 Server-Sent Events,是浏览器原生支持的一种服务端推送协议。
它建立在普通 HTTP 之上,不需要像 WebSocket 那样升级协议。客户端发起一个 GET 请求,服务端保持连接不关闭,并按照固定格式持续写入事件。
一个最简单的 SSE 响应长这样:
HTTP/1.1 200 OKContent-Type: text/event-streamCache-Control: no-cacheConnection: keep-alivedata: hellodata: worlddata: [DONE]
注意这里的格式。
每条消息通常以 data: 开头,并用一个空行结束。浏览器或者客户端流解析器读到空行,就知道一个事件结束了。
如果传的是 JSON,常见写法是:
data: {"delta":"你"}data: {"delta":"好"}data: {"delta":","}data: {"delta":"世界"}data: [DONE]
这里有几个细节很重要:
Content-Type是text/event-stream- 每个事件可以包含一行或多行
data: - 一个事件用空行分隔
- 服务端可以持续写入,连接可以保持很久
[DONE]不是 SSE 标准的一部分,只是很多 AI 接口约定的结束标记
SSE 还支持 event:、id:、retry: 等字段。
例如:
event: messageid: 42data: {"delta":"hello"}event: donedata: {}
但 AI 对话场景里,最常见的还是只用 data:,因为客户端只需要不断拿到增量文本。
text/event-stream 到底告诉浏览器什么
Content-Type 的作用,是告诉接收方:这段响应体应该按什么格式理解。
当服务端返回:
Content-Type: text/event-stream
它表达的是:这不是一个普通 JSON,也不是一个 HTML 页面,而是一条事件流。
浏览器如果使用原生 EventSource,会按照 SSE 规则处理它:
const source = new EventSource('/api/chat/stream')source.onmessage = event => { console.log(event.data)}
服务端只要持续写:
data: hellodata: world
前端的 onmessage 就会被触发两次。
不过很多 AI 对话产品并不会直接用 EventSource,而是用 fetch + ReadableStream。
原因很现实:
EventSource默认是 GET,不方便发送复杂请求体- AI 对话通常需要 POST,因为要提交 messages、model、temperature 等参数
fetch更容易控制 headers、body、鉴权和 abortReadableStream可以兼容 SSE,也可以处理非 SSE 的原始文本流
所以 AI 对话前端更常见的写法是:
const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ messages }),})const reader = response.body?.getReader()const decoder = new TextDecoder()while (reader) { const { done, value } = await reader.read() if (done) break const chunk = decoder.decode(value, { stream: true }) // 这里解析 chunk,然后更新 UI}
这里有一个容易混淆的点:
请求的 Content-Type 和响应的 Content-Type 不是一回事。
前端发请求时:
Content-Type: application/json
意思是“我发给服务端的请求体是 JSON”。
服务端回响应时:
Content-Type: text/event-stream
意思是“我返回给你的响应体是一条 SSE 事件流”。
同一个接口里,请求和响应可以使用完全不同的 Content-Type。
AI 接口里的流式格式通常长什么样
很多 AI 模型服务在流式输出时,会返回类似这样的 SSE:
data: {"choices":[{"delta":{"content":"你"}}]}data: {"choices":[{"delta":{"content":"好"}}]}data: {"choices":[{"delta":{"content":","}}]}data: {"choices":[{"delta":{"content":"我"}}]}data: {"choices":[{"delta":{"content":"是"}}]}data: {"choices":[{"delta":{"content":" AI"}}]}data: [DONE]
前端或者服务端中转层要做的事情,就是从这些事件里提取增量内容:
你 + 好 + , + 我 + 是 + AI
最后拼成完整回答:
你好,我是 AI
如果是 OpenAI-compatible 的接口,流里经常能看到 choices[0].delta.content。
如果是 Anthropic-compatible 的接口,事件类型会更细,比如 message start、content block delta、message stop 之类。
不同厂商的事件结构不完全一样,但核心思想接近:
模型生成一点 -> 接口吐一点 -> 客户端解析一点 -> UI 展示一点
所以做 AI 对话产品时,经常会在服务端做一层适配,把不同模型厂商的流式格式统一成自己的内部格式。
比如统一成:
data: {"type":"delta","text":"你"}data: {"type":"delta","text":"好"}data: {"type":"done"}
这样前端就不用关心底层到底接的是 OpenAI、Anthropic,还是其他兼容服务。
服务端怎么把模型流转发给浏览器
一个典型的服务端流式接口做三件事:
- 接收用户消息
- 请求模型服务,并开启 stream
- 把模型返回的增量内容继续写给浏览器
在 Node.js 或 Next.js 里,常见写法是返回一个 ReadableStream:
export async function POST(req: Request) { const { messages } = await req.json() const stream = new ReadableStream({ async start(controller) { const encoder = new TextEncoder() for await (const delta of callModelStream(messages)) { controller.enqueue( encoder.encode(`data: ${JSON.stringify({ text: delta })}\n\n`) ) } controller.enqueue(encoder.encode('data: [DONE]\n\n')) controller.close() }, }) return new Response(stream, { headers: { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', }, })}
这里最关键的是:
controller.enqueue()每调用一次,就往响应体里写一块数据\n\n用来结束一个 SSE 事件controller.close()表示响应结束Cache-Control: no-transform尽量阻止中间层改写或缓冲响应X-Accel-Buffering: no常用于告诉 Nginx 不要缓冲
如果中间代理把响应缓冲起来,流式体验就会消失。
这也是为什么有些人在本地开发时流式正常,部署到线上之后却变成“一次性出现”。问题不一定在前端,也可能是 Nginx、CDN、Serverless 平台或反向代理在缓冲响应。
前端如何实现一个字一个字出现
前端收到流之后,一般有两种展示方式。
第一种是真实增量渲染。
每收到一个 token,就立刻 append 到当前消息:
let answer = ''for await (const event of parseSSE(response.body)) { if (event === '[DONE]') break const payload = JSON.parse(event) answer += payload.text setMessages(messages => updateLastAssistantMessage(messages, answer))}
这种方式最真实。模型吐多快,页面就显示多快。
但它也有问题:
- 高频 setState 可能导致 React 渲染压力变大
- token 到达节奏不稳定,视觉上可能忽快忽慢
- 中文和英文 token 粒度不同,有时不是严格一个字
- Markdown 还没闭合时,渲染结果可能抖动
第二种是缓冲后平滑播放。
前端先把服务端收到的 token 存进一个 buffer,然后用定时器按固定节奏消费:
const buffer: string[] = []let visibleText = ''function onDelta(text: string) { buffer.push(text)}setInterval(() => { const next = buffer.shift() if (!next) return visibleText += next render(visibleText)}, 16)
这种方式更像“打字机效果”。
它不完全等同于真实网络到达速度,而是用一个前端播放层把模型输出变得更平滑。
很多成熟 AI 产品会混合使用这两种策略:
- 网络层真实流式接收
- 前端层稍微缓冲
- UI 层按帧或按小批次更新
- 回答结束后再做一次完整 Markdown 渲染
这样既能让用户尽早看到内容,又能避免页面频繁重排和 Markdown 抖动。
为什么有时不是“一个字”,而是一段一段
用户看到的粒度,不一定等于模型生成的粒度。
这里至少有四层会影响最终效果:
第一层是模型 token。
模型生成的基本单位通常是 token。一个 token 可能是一个中文字,也可能是一个英文单词的一部分,也可能是一段空格或标点。
第二层是模型服务商的 flush 策略。
服务商不一定每生成一个 token 就立刻发给你。它可能攒几个 token 再发一个 chunk。
第三层是你的服务端中转。
如果你在服务端做了 JSON 解析、格式转换、日志记录、内容安全检查,可能会改变输出节奏。
第四层是浏览器和前端渲染。
浏览器读流、JS 事件循环、React 批处理、Markdown 渲染都会影响最终看到的刷新节奏。
所以“一个字一个字出现”不是协议保证的结果,而是模型、服务端、网络和前端共同塑造出来的体验。
SSE 只保证服务端可以持续发送事件,不保证每个事件一定是一个字。
Content-Type 不只是告诉浏览器格式
聊到这里,可以顺便把 Content-Type 讲清楚。
它在 HTTP 里非常基础,但做 AI 对话时很容易被忽略。
常见的几类是:
Content-Type: application/json
表示响应体是 JSON。传统 API 最常见。
Content-Type: text/event-stream
表示响应体是 SSE 事件流。AI 流式对话常见。
Content-Type: text/plain; charset=utf-8
表示响应体是纯文本。也可以用于原始文本流,但没有 SSE 的事件格式。
Content-Type: text/html; charset=utf-8
表示响应体是 HTML 页面。
Content-Type: application/octet-stream
表示响应体是任意二进制流,下载文件时常见。
Content-Type 的意义不是装饰性的。
它会影响:
- 浏览器如何解析响应
- 客户端库如何处理 body
- 中间代理是否可能做压缩或转换
- 服务端框架是否自动序列化
- 安全策略如何判断资源类型
比如一个接口明明返回的是 SSE,却写成:
Content-Type: application/json
这就很容易误导客户端和中间层。某些框架可能会尝试按 JSON 完整解析,某些代理可能认为这是普通短响应,前端代码也会更难维护。
反过来,如果响应是普通 JSON,却写成 text/event-stream,浏览器也不会把它当成一次普通 JSON API。
所以 Content-Type 最重要的作用是建立契约:这条响应体到底应该按什么协议消费。
SSE、WebSocket 和普通流有什么区别
AI 对话流式输出不一定只能用 SSE。
常见方案大概有三种。
普通 JSON
服务端等模型完整生成后,一次性返回:
Content-Type: application/json
{ "message": "完整回答"}
优点是简单、稳定、好调试。
缺点是首字延迟高,用户体验差,不适合长回答。
SSE
服务端通过 text/event-stream 持续推送:
data: {"text":"你"}data: {"text":"好"}
优点是协议简单、浏览器支持好、非常适合服务端到客户端的单向流。
缺点是主要适合单向推送;如果要客户端和服务端频繁双向通信,就不如 WebSocket 自然。
WebSocket
客户端和服务端建立双向连接,双方都可以随时发消息。
它适合多人协作、实时游戏、终端会话、语音通信这类强双向场景。
但对普通 AI 对话来说,WebSocket 不一定是首选。
因为一次 AI 对话通常是:
客户端发一次问题服务端持续返回回答
这个模型天然适合 HTTP streaming 或 SSE。
WebSocket 当然也能做,但它会带来额外的连接管理、心跳、重连、鉴权和部署复杂度。除非产品确实需要强实时双向通信,否则 SSE 通常已经够用。
为什么很多 AI 工具看起来都像“逐字输出”
从产品角度看,逐字输出有几个明显好处。
第一,它降低等待焦虑。
用户发出问题后,只要很快看到第一个字,就会感觉系统已经开始工作。哪怕完整回答还要等很久,体验也比空白等待好。
第二,它让用户可以提前判断方向。
如果模型开头就答偏了,用户可以立刻停止生成、修改问题,而不用等完整回答出来。
第三,它符合“生成中”的心理模型。
AI 回答本来就是逐步生成的,流式输出把这个过程展示出来,用户会更容易理解系统正在推理和组织语言。
第四,它能掩盖一部分长尾延迟。
完整回答可能需要十几秒,但首 token 如果 1 秒内出来,用户主观体验会好很多。
所以很多 AI 工具并不是为了炫技才做逐字输出,而是因为它直接改变了等待体验。
不过这里也有边界。
如果前端为了“像打字”刻意播放得太慢,反而会浪费用户时间。好的流式体验应该是:尽快出现、稳定推进、必要时允许用户停止,而不是机械地模拟人类打字。
实现流式 AI 对话时容易踩的坑
第一,忘记用正确的响应头。
至少应该明确:
Content-Type: text/event-stream; charset=utf-8Cache-Control: no-cache, no-transform
如果经过 Nginx,还经常需要:
X-Accel-Buffering: no
第二,中间层缓冲。
本地正常,线上不流式,优先检查代理、平台和 CDN 有没有缓冲响应。
第三,SSE 分包解析写错。
网络 chunk 和 SSE event 不是同一个概念。一次 reader.read() 可能读到半个事件,也可能读到多个事件。
所以不能简单假设每个 chunk 都是一条完整消息。
更稳妥的做法是维护一个文本 buffer,按 \n\n 切分事件:
let buffer = ''function feed(chunk: string) { buffer += chunk const parts = buffer.split('\n\n') buffer = parts.pop() ?? '' for (const part of parts) { handleEvent(part) }}
第四,Markdown 增量渲染抖动。
模型可能先输出:
```tsconst a =
代码块还没闭合时,Markdown 渲染器可能不断改变结构。
一些产品会在流式过程中使用较轻的文本渲染,结束后再做完整 Markdown 渲染。
第五,取消生成没有打通。
用户点“停止生成”时,前端应该 abort fetch,服务端也应该取消模型请求,而不是只停止 UI 更新。
否则模型调用仍然在后台继续消耗资源。
第六,错误事件和完成事件没有统一。
流式接口最好明确几类事件:
deltaerrordonemetadata
否则前端只能靠连接断开来猜测是否完成,调试会很痛苦。
我更推荐的内部事件格式
如果是自己做 AI 对话产品,我会倾向于把外部模型的流转换成内部统一事件。
比如:
data: {"type":"start","messageId":"msg_123"}data: {"type":"delta","text":"你"}data: {"type":"delta","text":"好"}data: {"type":"metadata","usage":{"inputTokens":1200}}data: {"type":"done"}
遇到错误:
data: {"type":"error","message":"模型服务暂时不可用"}
这样前端逻辑会清楚很多。
它不需要理解不同模型厂商的返回结构,只需要处理自己的协议:
start:创建消息占位delta:追加文本metadata:更新 token、trace、引用来源等信息error:展示错误状态done:结束 loading
这也是很多 AI 产品最终会走向的一层抽象:不要把厂商流格式直接暴露给前端。
因为今天你接的是 OpenAI-compatible,明天可能要接 Anthropic-compatible,后天还可能接本地模型或自定义 Agent Runtime。前端应该消费稳定的产品协议,而不是消费某个模型厂商的协议。
写在最后
AI 对话里的逐字输出,看起来是一个很小的交互细节,但它背后其实牵扯到整条链路:
- 模型是否支持 streaming
- 服务端是否能边读边写
- 响应头是否声明为
text/event-stream - 中间代理是否禁用了缓冲
- 前端是否正确解析 SSE
- UI 是否做了增量渲染和平滑播放
- 错误、取消、完成事件是否有明确协议
如果只从 UI 看,它像是一个打字机效果。
但真正把它做稳之后会发现,它更像是一个小型传输协议设计问题。
Content-Type 在这里也不是一个无聊的 HTTP 头。它定义了客户端和服务端之间的基本契约:这次返回的到底是一份完整 JSON,还是一条可以被持续消费的事件流。
对 AI 产品来说,这个契约非常重要。
因为用户看到的“一个字一个字出现”,不是前端凭空变出来的动画,而是模型生成、服务端转发、HTTP 流式传输和浏览器渲染共同完成的一次协作。
把这条链路想清楚,AI 对话才不会只是“能跑”。
它才会变得可控、可调试,也更接近一个真正能长期维护的产品能力。