FastAPI 流式响应核心原理解析(含前端断开感知)

一、核心背景

本文基于 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 关键方法

  1. init(初始化方法):接收用户传入的迭代器(同步/异步),自动将同步迭代器通过 `iterate_in_threadpool` 包装成异步迭代器,避免阻塞事件循环;同时初始化状态码、响应头、媒体类型、后台任务。

  2. listen_for_disconnect:监听客户端断开事件,通过循环读取 `receive()` 消息,当收到 `http.disconnect` 时退出循环,用于旧版 ASGI 协议的断开感知。

  3. stream_response:流式发送数据的核心方法,分三步执行:

    1. 发送响应头(告知客户端响应开始);

    2. 异步循环读取 `body_iterator` 中的数据块,统一转为 bytes 后推送(`more_body=True` 表示还有后续数据);

    3. 发送空数据块(`more_body=False`),告知客户端传输完成。

  4. 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()` 方法不会报错,后端无法主动感知,会继续无效推流,因此需要通过「双任务并发」解决该问题。

  1. 任务组(anyio.create_task_group):用于管理多个异步任务的生命周期,一个任务结束/报错,所有任务会被自动取消。

  2. wrap 包装函数:灵魂逻辑,接收一个异步函数,执行该函数后,立即调用 `task_group.cancel_scope.cancel()`,取消整个任务组的所有任务------实现「任一任务结束,立即终止另一任务」。

  3. 双任务分工

    1. 任务1(`stream_response`):后台异步推流,死循环读取数据并发送;

    2. 任务2(`listen_for_disconnect`):阻塞等待客户端断开,死循环读取 `receive()` 消息,收到 `http.disconnect` 后结束。

4.3 两种运行场景

  1. 正常结束:推流完成(任务1结束)→ 触发 `wrap` 取消任务组 → 任务2(监听)被终止;

  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 前端断开的完整链路

  1. 前端关闭页面/取消请求 → TCP 连接断开;

  2. 后端继续执行 `stream_response` 中的 `await send(...)`;

  3. ASGI 服务器(Uvicorn)发现连接已断开,主动抛出 `OSError`(如 Broken pipe)

  4. 异常被 `try/except` 捕获,抛出 `ClientDisconnect` 异常;

  5. `stream_response` 方法终止,`async for` 推流循环立即停止;

  6. 异常向上冒泡,被业务代码(`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)异常抛出
资源占用 较高(双协程并发) 较低(单协程线性执行)
相关推荐
代码搬运媛8 小时前
Jest 测试框架详解与实现指南
前端
counterxing8 小时前
我把 Codex 里的 Skills 做成了一个 MCP,还支持分享
前端·agent·ai编程
wangqiaowq9 小时前
windows下nginx的安装
linux·服务器·前端
之歆9 小时前
DAY_12JavaScript DOM 完全指南(二):实战与性能篇
开发语言·前端·javascript·ecmascript
发现一只大呆瓜9 小时前
Vite凭什么这么快?3分钟带你彻底搞懂 Vite 热更新的幕后黑手
前端·面试·vite
Maimai108089 小时前
React如何用 @microsoft/fetch-event-source 落地 SSE:比原生 EventSource 更灵活的实时推送方案
前端·javascript·react.js·microsoft·前端框架·reactjs·webassembly
kyriewen11 小时前
产品经理把PRD写成“天书”,我用AI半小时重写了一遍,他当场愣住
前端·ai编程·cursor
humcomm12 小时前
元框架的工作原理详解
前端·前端框架
canonical_entropy12 小时前
Attractor Before Harness: AI 大规模开发的方法论
前端·aigc·ai编程
zhangxingchao12 小时前
多 Agent 架构到底怎么选?从 Claude Agent Teams、Cognition/Devin 到工程落地原则
前端·人工智能·后端