---
title: 用 SSE 做流式 AI 输出：先出字，而不是先转圈
url: https://vabora.dev/posts/sse-streaming-ai
author: Vabora
published: 2026-09-12 10:00:00
updated: 2026-09-27 10:33:42
category: 技术分享
tags: AI, 架构设计, Vue 3
canonical: https://vabora.dev/posts/sse-streaming-ai
license: 转载与引用请注明出处并保留原文链接
---

# 用 SSE 做流式 AI 输出：先出字，而不是先转圈

> 后台的 AI 写作助手从"等 30 秒看结果"改成"逐字往外冒"，用的是 30 行 SSE 代码加几条必须配对的响应头。附 Nginx 缓冲导致"不流式"的排查方法。
## 背景

后台用 AI 生成一篇 1500 字的文章摘要与正文，模型要跑 20~40 秒。一次性返回的体验是：点一下按钮，转圈 30 秒，然后"哗"地一下出现一整屏文字——用户既不知道它在干活，也没法中途叫停。

流式输出把这件事变得**可感知**：第一个字约 1 秒出现，之后持续往外冒，想停随时停。

## 做法

### 1. 后端：一个循环 + 三条响应头

```php
header('Content-Type: text/event-stream; charset=utf-8');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');   // 关键：告诉 Nginx 不要缓冲这段响应

// 关掉 PHP 自身的输出缓冲，否则内容会攒到脚本结束才发
while (ob_get_level() > 0) {
    ob_end_flush();
}
ob_implicit_flush(true);

foreach ($chunks as $chunk) {
    echo 'data: ' . json_encode(['text' => $chunk], JSON_UNESCAPED_UNICODE) . "\n\n";
    flush();          // 推出去
}
echo "data: [DONE]\n\n";   // 显式结束，前端才知道"正常写完了"
```

三个细节都是踩过的：

- `X-Accel-Buffering: no` —— **没有它，Nginx 会先把整段响应攒起来**，你在本地 `php -S` 测试是流式的，上了服务器就变成"一次性蹦出来"。这是"明明写了流式却不流式"的头号原因。
- `ob_end_flush()` —— PHP-FPM 默认有输出缓冲，不清掉同样是攒完才发。
- 结尾发一个 `[DONE]` 标记 —— 否则前端只能靠连接关闭判断结束，无法区分"写完了"和"断了"。

### 2. 前端：用 fetch + reader，而不是 EventSource

`EventSource` 只能发 GET、不能带请求体、也不能自定义请求头，而生成请求需要 POST 一段很长的上下文。所以用 `fetch` 读流：

```js
const controller = new AbortController()

const response = await fetch('/admin/api/ai/generate', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(payload),
  signal: controller.signal,           // 中断用的
})

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 })

  // SSE 以空行分隔事件；残缺的一段留在 buffer 等下一块
  const parts = buffer.split('\n\n')
  buffer = parts.pop() ?? ''
  for (const part of parts) {
    const line = part.replace(/^data: /, '')
    if (line === '[DONE]') return
    appendText(JSON.parse(line).text)
  }
}
```

`buffer` 那三行是必须的：网络分块不保证按事件边界切，少写就会出现 `JSON.parse` 报错或偶发丢字。

### 3. 中断

`controller.abort()` 之后，浏览器会中断连接，后端那次 cURL 请求随之断开——**在上游也真的停掉**，不像轮询那样"用户走了，请求还在跑，账单还在涨"。

## 结果

- 首字延迟从 20~40 秒降到约 1 秒，长文生成的"等待焦虑"基本消失；
- 生成中可以随时停下，不满意就重来，不用等它写完；
- 上游 token 消耗与用户实际等待时间对齐，浪费明显减少。

## 取舍

| 方案 | 何时选它 |
|---|---|
| **SSE** | 服务端单向推、文本流、要复用现有 HTTP 鉴权与限流 —— 选它 |
| WebSocket | 真正的双向实时（协同编辑、聊天室）—— 本项目不需要，引入等于多一套连接管理 |
| 轮询 | 任务型异步（提交后拿任务 ID 查进度）—— 长任务且允许延迟时更简单可靠 |

流式的代价是**错误处理变复杂**：一旦开始输出，HTTP 状态码已经发出去了，中途出错无法再用 4xx/5xx 表达，只能在流里发一个错误事件让前端识别。所以上游模型调用要有超时与重试，且第一条内容发出前完成所有参数校验。
