请解释 Python 生成器的底层帧挂起原理,以及如何用异步生成器实现 LLM 流式输出的 FastAPI SSE 接口?
问这个,一般是看你有没有把 Python 的协程机制和 Web 流式输出打通。我之前在智能客服项目里,就是用异步生成器把 LLM 的 token 流转成 SSE 推给前端。下面先扒一下生成器挂起的底层,再串到 FastAPI 实现。
🔹 生成器的底层:帧挂起,不是魔法
Python 的生成器能把一个函数执行到一半“冻”住,靠的是帧对象(frame)的现场保存。
当你调用一个生成器函数时,CPython 做了这几件事:
- 创建生成器对象(PyGenObject) 这个 C 结构体里有两个关键字段:
gi_frame:指向当前执行帧(PyFrameObject),包含代码对象、局部变量、指令指针(f_lasti)、栈等。-
gi_code:编译后的字节码对象。 -
执行到
yield时 解释器正常执行字节码,遇到YIELD_VALUE指令: - 把栈顶的值作为
yield的返回值传给调用方。 - 把当前帧的指令指针、栈深度等信息完整保留在
gi_frame里,然后把gi_frame字段保存,生成器状态变为Gi_SUSPENDED。 -
注意:此时帧并没有被销毁,只是从解释器的调用栈上摘下来,就地冻结。
-
下一次
next()或send()时 解释器找到生成器的gi_frame,重新压入调用栈,从f_lasti记录的字节码偏移后一条指令继续执行,局部变量、栈内容原封不动恢复——就像电影暂停后继续播放。
这样,生成器函数就实现了无状态回调的“有状态暂停”。从 Python 层面看,就是一个对象,但它的背后挂着一整个活着的执行上下文。
📦 用个图说话(逻辑示意)
生成器对象 (gen)
├─ gi_frame ──► 帧对象 (Frame)
│ ├─ f_lasti: 12 (下一条指令偏移)
│ ├─ f_locals: {'i': 3, 'data': [...]}
│ ├─ f_stack: [值1, 值2]
│ └─ f_code: Code Object
└─ gi_code ────► 同样的 Code Object
gi_frame在生成器耗尽(StopIteration)后会变成None,意味着帧被释放。
⚙️ 异步生成器:挂起还能等 I/O
异步生成器(async def + yield)底层更复杂,用的是 asyncgenobject,帧挂起原理一样,但 yield 点必须与事件循环协作。
当执行到 yield 时,它把当前协程的帧保存,然后将控制权通过 await 链返回给事件循环,这样循环可以去调度其他任务。
等到下一轮 async for 调用 anext 时,再次唤醒这个帧继续往后跑。
这就让它天然适合一边生成数据一边等待异步操作的场景,比如 LLM 的 token 流。
🔸 用异步生成器实现 LLM 流式输出的 FastAPI SSE
SSE(Server-Sent Events)是单向的流式协议,数据格式是 data: <内容>\n\n。
FastAPI 支持用 StreamingResponse 包裹一个异步生成器,并设置 media_type="text/event-stream"。
我们线上最简化的实现是:
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import asyncio
app = FastAPI()
async def llm_token_generator(prompt: str):
"""模拟 LLM 异步生成 token"""
# 这里实际会调用 openai.AsyncOpenAI 等
for token in ["今天", "的", "天气", "很", "好", "。"]:
await asyncio.sleep(0.1) # 模拟生成延迟
# SSE 格式
yield f"data: {token}\n\n"
yield "data: [DONE]\n\n"
@app.get("/stream")
async def stream(prompt: str):
return StreamingResponse(
llm_token_generator(prompt),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # 关掉 Nginx 缓冲
}
)
📌 关键点:
-
生成器函数必须是
async def,内部用yield而不是return。 -
StreamingResponse会迭代异步生成器,每拿到一个yield的值,就写一次 TCP 响应。 -
如果中途客户端断开连接,FastAPI 会抛出
asyncio.CancelledError,我们需要在生成器里处理清理。
🛡️ 必须处理的三个工程问题
1️⃣ 客户端断开时停止 LLM 调用
否则会浪费昂贵的 token。我们用 asyncio.CancelledError 捕获:
async def safe_stream(prompt: str):
try:
async for token in llm_client.generate(prompt):
yield f"data: {token}\n\n"
except asyncio.CancelledError:
# 客户端断开,手动停掉下游 LLM 请求
await llm_client.cancel()
raise # 必须重新抛出,让 FastAPI 知道终止
2️⃣ 背压控制:生产太快,消费太慢
如果 LLM 产 token 很快,但网络发不出去,生成器可能会积压。我们加了个 asyncio.Queue 做缓冲区,一旦队列满就暂停 LLM 获取,实现非对称速率适配。不过简单场景下,FastAPI 的 TCP 写缓冲也能抗一小阵。
3️⃣ 断线重连和事件 ID
SSE 规范支持 id 字段,客户端断开重连时,会带上 Last-Event-ID,我们可以从这个点继续。对于 LLM,这通常很难实现,因为我们没法从一个随机点恢复生成,但可以在前端存已接收的 token,重连时只请求剩余部分,后端重新生成完整回答——涉及状态同步,稍复杂。