跳到主要内容
我的站点
首页 归档 分类 标签 案例 服务 关于 聊聊需求

用 SSE 做流式 AI 输出:先出字,而不是先转圈

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

用 SSE 做流式 AI 输出:先出字,而不是先转圈 封面

#背景

后台用 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 表达,只能在流里发一个错误事件让前端识别。所以上游模型调用要有超时与重试,且第一条内容发出前完成所有参数校验。

最后更新: 本文采用 CC BY-NC-SA 4.0 许可,转载请注明出处与作者。
related

相关文章

评论

还没有评论,来说点什么吧。

发表评论

评论需站长审核后展示 · 最多 1000 字