从 0 搭一个 AI Agent(三):SSE 流式输出,让 AI 边想边说

22 阅读7分钟

前两篇讲了 Agent 的循环和 LangGraph 编排。这一篇聊一个直接影响用户体验的东西——流式输出。没有它,你的 Agent 在用户眼里就是「转圈 5 秒,然后蹦一大段字」的半成品。

这篇的目标是把第一版流式跑通,并且跑通的版本不会在你看不见的地方出丑。重连、续传、重试上限这些边界,下一篇专门讲,这篇只留接口。

一、为什么必须流式

大模型生成是逐 token 的,一次完整回复可能要 3–10 秒。如果等全生成完再返回:

  • 用户盯着转圈圈,以为卡了;
  • 长文容易触发网关超时;
  • 首字延迟(TTFT)极高,体验像拨号上网。

流式输出让第一个字在几百毫秒内就出现,后面边生成边显示,体验天差地别。

二、三种实时方案怎么选

方案适用复杂度
轮询简单、低频低,但浪费请求
WebSocket双向、长连接游戏高,要维护连接
SSE服务端单向推流(AI 最典型)中,原生 HTTP,好上手

AI 输出是「服务端 → 客户端」单向流,SSE 正好对口,还不用引入 WebSocket 那套连接管理。

但「SSE」在客户端有两条路,选错会在下一篇还债:

  • 原生 EventSource:自动重连、自动带 Last-Event-ID、自动解析 wire format。代价是只支持 GET,不能设自定义头——没法传 Authorization,也没法 POST 一大段 prompt。
  • fetch + ReadableStream:支持 POST、自定义头、AbortController。代价是帧解析、重连、退避、Last-Event-ID 全得自己写。

这篇走 fetch 流式,因为 AI 对话基本都要 POST + 鉴权。你放弃了 EventSource 的免费重连,这件事下一篇再补;这篇先把「发得对、收得对、断了能停」做对。

三、后端怎么发(FastAPI)

先看一个「能跑但会出丑」的版本,这是绝大多数教程里的写法:

async def event_stream(prompt):
    async for token in llm.stream(prompt):
        yield f"data: {token}\n\n"

它有两个问题,demo 里看不出来,真实 LLM 输出里必现。

问题一:token 含换行就断帧。  模型输出代码块或多行文本时,token 里带 \n。SSE 规范要求每一行都以 data: 开头,否则客户端只收到第一行。yield f"data: {token}\n\n" 在 token 是 "line1\nline2" 时,客户端拿到的 data 只有 line1,line2 被丢掉。

问题二:客户端断开,服务端不知道。  前端加 AbortController 只是停止读取,服务端 async for token in llm.stream(prompt) 会继续跑完整个生成,每个 token 往一个已经死掉的连接里写。对按 token 计费的 LLM,这是直接烧钱。

修正后的最小实现:

from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse

app = FastAPI()

def sse_frame(data: str, event: str | None = None, event_id: str | None = None) -> str:
    """按 SSE wire format 拼一帧;data 内每一行都单独加 data: 前缀。"""
    lines = []
    if event_id is not None:
        lines.append(f"id: {event_id}")
    if event is not None:
        lines.append(f"event: {event}")
    for line in data.splitlines() or [""]:
        lines.append(f"data: {line}")
    return "\n".join(lines) + "\n\n"

async def event_stream(prompt: str, request: Request):
    try:
        async for token in llm.stream(prompt):
            if await request.is_disconnected():
                return                       # 断开就停,不再消费 LLM
            yield sse_frame(str(token), event="token")
        yield sse_frame("[DONE]", event="done")
    finally:
        await llm.aclose()                   # 无论正常结束还是被取消,都释放上游

@app.post("/chat")
async def chat(req: dict, request: Request):
    return StreamingResponse(
        event_stream(req["prompt"], request),
        media_type="text/event-stream",
        headers={"X-Accel-Buffering": "no"},   # 让 Nginx 不要缓冲
    )

三个关键点,都是「看起来不重要、漏了会出事」的:

  1. sse_frame 把多行 data 拆开,代码块和多行文本不会丢行。
  2. request.is_disconnected() 主动检查断开。AbortController 只解决前端停止读取,服务端不查这个,LLM 会继续跑到 max_tokens 用完。
  3. finally 里 llm.aclose() 。正常结束会走到这里,客户端断开、任务被取消也会走到这里,清理不依赖某一条 except 分支。

不用 EventSourceResponse 也能跑,原生 StreamingResponse 对这篇的篇幅更友好;需要心跳和 send_timeout 时再换 sse-starlette,那是下一篇的事。

四、前端怎么接(重点)

SSE 不是普通 fetch 拿 JSON,得读流。先看一个常见的、会出错的最简版:

const reader = res.body!.getReader()
const decoder = new TextDecoder()
while (true) {
  const { value, done } = await reader.read()
  if (done) break
  const chunk = decoder.decode(value)
  const token = chunk.replace(/^data: /, '')   // ❌ 不能这么干
  appendToScreen(token)
}

问题在于 reader.read() 返回的是任意长度的字节块,不是「一帧」。一次 read() 可能给你半个事件、两个事件、或者一个事件的尾巴加下一个事件的 data: 前缀。直接 replace 和 append,在 demo 里「看起来能跑」,网络分片位置一不巧就渲染出脏字符。

正确做法是缓冲 + 按 \n\n 切帧 + 拼多行 data:

async function streamChat(prompt: string, signal: AbortSignal) {
  const res = await fetch('/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ prompt }),
    signal,
  })
  if (!res.body) throw new Error('no body')

  const reader = res.body.getReader()
  const decoder = new TextDecoder()
  let buffer = ""

  while (true) {
    const { value, done } = await reader.read()
    if (done) break
    buffer += decoder.decode(value, { stream: true })

    const frames = buffer.split("\n\n")
    buffer = frames.pop() ?? ""          // 最后一段可能不完整,留到下一轮

    for (const frame of frames) {
      let event = "message"
      const dataLines: string[] = []
      for (const line of frame.split("\n")) {
        if (line.startsWith("event:")) event = line.slice(6).trim()
        else if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""))
      }
      if (!dataLines.length) continue

      const data = dataLines.join("\n")   // 多行 data 拼回来
      if (event === "done") return
      appendToScreen(data)
    }
  }
}

调用侧配一个 AbortController,用户切走页面或点「停止」时中止:

const ctrl = new AbortController()
streamChat(prompt, ctrl.signal).catch(err => {
  if (err.name !== 'AbortError') showRetryHint()
})
// 需要时:ctrl.abort()

这段代码教会三件事,比 replace(/^data: /) 重要得多:

  1. 帧要缓冲,不能假设一次 read() 等于一帧;
  2. 多行 data 要拼,和后端 sse_frame 对称;
  3. done 是终止信号,收到就 return,不要等连接自己关。

decoder.decode(value, { stream: true }) 的 { stream: true } 也别忘了,它处理多字节 UTF-8 字符被切在两个 chunk 中间的情况。中文输出下这个细节很容易踩到。

五、这篇没做、下一篇接力的

这篇把「发得对、收得对、断了能停」做完了,但下面这些边界故意没展开,因为每一个都够单独写一节,塞进来会让这篇变成排查手册:

  • 断线后的自动重连和退避:fetch 流式没有 EventSource 的免费重连,要自己写 onclose → retryCount → backoff。注意 retryCount 不能在 onopen 里无条件重置,否则重试上限形同虚设。
  • Last-Event-ID 续传:服务端要给每个 token 带 id,done 也要带可解析的 id,重连时才能判断「已到末尾」而不是永远等不到结束信号。
  • done 的边界:缓存回放分支在 resume_from 到达末尾时,必须补发一个 done,不能静默 return。
  • GeneratorExit 与 finally:断连时 Starlette 可能向生成器注入 GeneratorExit,清理放 finally,不要在 finally 里 yield。
  • 优雅关闭:uvicorn 的 timeout_graceful_shutdown 默认是 None(无限等),SSE 场景下 Ctrl-C 会卡住,要在 lifespan 里主动通知活跃流结束,并把这个值调小。
  • 幂等键:缓存键用 userId:requestId,不要只用客户端给的 request_id,否则是跨用户重放,不是碰撞问题。

这些不是「以后再说」的客套,是这篇的代码明确留下接口的:sse_frame 已经支持 event_id,done 已经是一个独立事件,finally 已经管清理——下一篇往里填逻辑,不用推翻结构。

小结

流式输出是 AI 产品的「门面工程」——用户感知到的「快」和「聪明」,一大半来自这里。但「看起来跑起来了」和「跑得对」之间,隔着两件很小、很容易漏的事:后端每一行 data 都要加前缀、断开后要主动停;前端要缓冲切帧、拼多行、认 done。

把这两件事做对,你的第一版流式就不会在用户切走页面之后继续烧 token,也不会在代码块里丢行。剩下的重连和续传,下一篇用这篇留好的接口接着写。

前端同学在这块天然有手感——帧怎么切、流怎么读、UI 怎么增量更新,本来就是前端的主场,是切入 AI 产品的绝佳切入点。


在做前端 + AI / 全栈 AI 的朋友,欢迎交流。我目前在看相关方向的机会,玄镜是手上真实跑了一年的项目。