前两篇讲了 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 不要缓冲
)
三个关键点,都是「看起来不重要、漏了会出事」的:
sse_frame把多行data拆开,代码块和多行文本不会丢行。request.is_disconnected()主动检查断开。AbortController只解决前端停止读取,服务端不查这个,LLM 会继续跑到max_tokens用完。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: /) 重要得多:
- 帧要缓冲,不能假设一次
read()等于一帧; - 多行
data要拼,和后端sse_frame对称; 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 的朋友,欢迎交流。我目前在看相关方向的机会,玄镜是手上真实跑了一年的项目。