Skip to content
Go back

AI 对话里的逐字输出,背后其实是一条不断吐数据的 HTTP 响应

用过 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]

这里有几个细节很重要:

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

原因很现实:

所以 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,还是其他兼容服务。

服务端怎么把模型流转发给浏览器

一个典型的服务端流式接口做三件事:

  1. 接收用户消息
  2. 请求模型服务,并开启 stream
  3. 把模型返回的增量内容继续写给浏览器

在 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',    },  })}

这里最关键的是:

如果中间代理把响应缓冲起来,流式体验就会消失。

这也是为什么有些人在本地开发时流式正常,部署到线上之后却变成“一次性出现”。问题不一定在前端,也可能是 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))}

这种方式最真实。模型吐多快,页面就显示多快。

但它也有问题:

第二种是缓冲后平滑播放

前端先把服务端收到的 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 产品会混合使用这两种策略:

这样既能让用户尽早看到内容,又能避免页面频繁重排和 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 的意义不是装饰性的。

它会影响:

比如一个接口明明返回的是 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":"模型服务暂时不可用"}

这样前端逻辑会清楚很多。

它不需要理解不同模型厂商的返回结构,只需要处理自己的协议:

这也是很多 AI 产品最终会走向的一层抽象:不要把厂商流格式直接暴露给前端。

因为今天你接的是 OpenAI-compatible,明天可能要接 Anthropic-compatible,后天还可能接本地模型或自定义 Agent Runtime。前端应该消费稳定的产品协议,而不是消费某个模型厂商的协议。

写在最后

AI 对话里的逐字输出,看起来是一个很小的交互细节,但它背后其实牵扯到整条链路:

如果只从 UI 看,它像是一个打字机效果。

但真正把它做稳之后会发现,它更像是一个小型传输协议设计问题。

Content-Type 在这里也不是一个无聊的 HTTP 头。它定义了客户端和服务端之间的基本契约:这次返回的到底是一份完整 JSON,还是一条可以被持续消费的事件流。

对 AI 产品来说,这个契约非常重要。

因为用户看到的“一个字一个字出现”,不是前端凭空变出来的动画,而是模型生成、服务端转发、HTTP 流式传输和浏览器渲染共同完成的一次协作。

把这条链路想清楚,AI 对话才不会只是“能跑”。

它才会变得可控、可调试,也更接近一个真正能长期维护的产品能力。


Share this post on: