一、核心背景
本文基于 FastAPI/Starlette 流式响应源码,结合实际业务接口(`/run` 接口),详细解析流式响应的工作机制、前端断开连接的感知逻辑,以及 ASGI 协议新旧版本的差异,帮助开发者从底层理解流式响应的实现原理。
核心业务场景:后端通过 `StreamingResponse` 向前端推送 AI 推理结果、实时事件(SSE 协议),需解决「前端断开连接后,后端及时停止推流、释放资源」的问题。
二、核心类解析:StreamingResponse
2.1 类的核心作用
`StreamingResponse` 继承自 FastAPI/Starlette 的 `Response` 基类,专门用于异步流式输出,核心优势:
-
不一次性将所有数据加载到内存,边生成边推送,降低内存占用;
-
自动兼容同步/异步迭代器(生成器);
-
遵循 ASGI 协议,支持所有 ASGI 服务器(如 Uvicorn)。
2.2 核心属性与方法
2.2.1 核心属性
`body_iterator: AsyncContentStream`:存储异步迭代器,所有要推送的数据流均从该迭代器读取,是流式响应的核心。
2.2.2 关键方法
-
init(初始化方法):接收用户传入的迭代器(同步/异步),自动将同步迭代器通过 `iterate_in_threadpool` 包装成异步迭代器,避免阻塞事件循环;同时初始化状态码、响应头、媒体类型、后台任务。
-
listen_for_disconnect:监听客户端断开事件,通过循环读取 `receive()` 消息,当收到 `http.disconnect` 时退出循环,用于旧版 ASGI 协议的断开感知。
-
stream_response:流式发送数据的核心方法,分三步执行:
-
发送响应头(告知客户端响应开始);
-
异步循环读取 `body_iterator` 中的数据块,统一转为 bytes 后推送(`more_body=True` 表示还有后续数据);
-
发送空数据块(`more_body=False`),告知客户端传输完成。
-
-
call(ASGI 入口方法):ASGI 服务器(如 Uvicorn)会将 `StreamingResponse` 实例当作函数直接调用,是整个流式响应的入口,负责根据 ASGI 版本选择对应的处理逻辑。
三、关键方法详解:call
3.1 核心作用
`call` 是 Python 魔法方法,实现该方法后,类的实例可以像函数一样被调用(`对象()` 等价于调用 `call`)。
在 FastAPI 中,`call` 是 ASGI 应用的标准入口:Uvicorn 等 ASGI 服务器会直接调用 `StreamingResponse` 实例,传入 `scope`(请求上下文)、`receive`(接收客户端消息)、`send`(向客户端发送消息)三个参数,驱动流式响应的执行。
3.2 核心逻辑
`call` 方法的核心是根据 ASGI 协议版本,选择不同的流式处理逻辑:
-
ASGI ≥ 2.4:简化处理,直接调用 `stream_response` 推流,通过捕获 `OSError` 感知客户端断开;
-
ASGI < 2.4:通过任务组并发运行「推流」和「监听断开」两个协程,实现双任务互锁,避免资源浪费。
四、旧版 ASGI(<2.4):双任务并发逻辑解析
4.1 核心代码
with collapse_excgroups():
async with anyio.create_task_group() as task_group:
async def wrap(func: typing.Callable[[], typing.Awaitable[None]]) -> None:
await func()
task_group.cancel_scope.cancel() # 完成后取消其他任务
# 任务1:流式发送数据(后台运行)
task_group.start_soon(wrap, partial(self.stream_response, send))
# 任务2:监听客户端断开(阻塞等待)
await wrap(partial(self.listen_for_disconnect, receive))
4.2 核心原理
旧版 ASGI 协议的缺陷:客户端断开连接后,`send()` 方法不会报错,后端无法主动感知,会继续无效推流,因此需要通过「双任务并发」解决该问题。
-
任务组(anyio.create_task_group):用于管理多个异步任务的生命周期,一个任务结束/报错,所有任务会被自动取消。
-
wrap 包装函数:灵魂逻辑,接收一个异步函数,执行该函数后,立即调用 `task_group.cancel_scope.cancel()`,取消整个任务组的所有任务------实现「任一任务结束,立即终止另一任务」。
-
双任务分工:
-
任务1(`stream_response`):后台异步推流,死循环读取数据并发送;
-
任务2(`listen_for_disconnect`):阻塞等待客户端断开,死循环读取 `receive()` 消息,收到 `http.disconnect` 后结束。
-
4.3 两种运行场景
-
正常结束:推流完成(任务1结束)→ 触发 `wrap` 取消任务组 → 任务2(监听)被终止;
-
前端断开:任务2(监听)收到断开消息并结束 → 触发 `wrap` 取消任务组 → 任务1(推流)被终止,避免无效推流。
五、新版 ASGI(≥2.4):简化逻辑解析
5.1 核心改进(关键!)
ASGI 2.4 协议新增核心规则:当客户端连接已关闭时,调用 `send()` 方法必须抛出 `OSError`(或其子类)。
这一改进彻底解决了旧版的缺陷:无需手动监听断开,`send()` 会自动报错,后端通过捕获异常即可感知客户端断开,因此可以删除复杂的双任务并发逻辑。
5.2 核心代码
if spec_version >= (2, 4):
try:
await self.stream_response(send) # 单协程推流
except OSError:
raise ClientDisconnect() # 捕获断开异常,抛出统一异常
5.3 前端断开的完整链路
-
前端关闭页面/取消请求 → TCP 连接断开;
-
后端继续执行 `stream_response` 中的 `await send(...)`;
-
ASGI 服务器(Uvicorn)发现连接已断开,主动抛出 `OSError`(如 Broken pipe);
-
异常被 `try/except` 捕获,抛出 `ClientDisconnect` 异常;
-
`stream_response` 方法终止,`async for` 推流循环立即停止;
-
异常向上冒泡,被业务代码(`event_generator`)捕获,感知到前端断开。
六、业务代码中的前端断开感知
6.1 核心结论
路由函数(如 `run_agent`)本身无法直接感知前端断开,但内部的流式生成器(`event_generator`)可以通过捕获 `OSError` 或 `ClientDisconnect` 异常,精准识别前端断开事件。
6.2 落地代码示例
async def event_generator():
nonlocal content, run_id
client_disconnected = False # 断开标记
try:
# AI 推理流式输出,前端断开会触发异常,中断该循环
async for event in run(agent, run_input):
# 业务逻辑:处理事件、格式化输出
yield format_sse_event(event)
# 捕获前端断开异常
except (OSError, ClientDisconnect) as e:
print(f"🚫 客户端主动断开连接: {e}")
client_disconnected = True
finally:
# 根据断开标记,控制后续逻辑(如是否保存数据库、生成推荐)
if client_disconnected:
print("ℹ️ 客户端断开,不执行后续收尾操作")
else:
print("ℹ️ 正常结束,执行推荐生成和数据库保存")
# 原有收尾逻辑(推荐问题、保存对话)
6.3 关键注意点
-
前端断开后,`async for event in run(...)` 会立即停止,AI 推理也会同步终止,避免资源浪费;
-
`finally` 块依然会执行,可通过「断开标记」控制是否执行收尾操作(如保存数据库、生成推荐);
-
`OSError` 由 Uvicorn 主动抛出,是新版 ASGI 感知断开的核心机制,100% 可靠。
七、新旧 ASGI 模式对比
| 对比维度 | 旧版 ASGI(<2.4) | 新版 ASGI(≥2.4) |
|---|---|---|
| 断开感知方式 | 主动监听 `receive()` 消息 | `send()` 自动抛出 `OSError` |
| 实现复杂度 | 高(任务组+双协程+包装函数) | 低(单协程+try/except) |
| 核心依赖 | 任务组、取消作用域 | ASGI 服务器(Uvicorn)异常抛出 |
| 资源占用 | 较高(双协程并发) | 较低(单协程线性执行) |