#背景
后台用 AI 生成一篇 1500 字的文章摘要与正文,模型要跑 20~40 秒。一次性返回的体验是:点一下按钮,转圈 30 秒,然后"哗"地一下出现一整屏文字——用户既不知道它在干活,也没法中途叫停。
流式输出把这件事变得可感知:第一个字约 1 秒出现,之后持续往外冒,想停随时停。
#做法
#1. 后端:一个循环 + 三条响应头
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 读流:
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 表达,只能在流里发一个错误事件让前端识别。所以上游模型调用要有超时与重试,且第一条内容发出前完成所有参数校验。
评论
还没有评论,来说点什么吧。