FastAPI 推大模型 token 流,客户端一取消后端为啥还在跑?

大模型接口做成边生成边推之后,联调看着挺顺:用户一点停止,浏览器请求断了,GPU 或上游 HTTP 却还在吐 token,账单照扣。后端常见写法是 FastAPI 的 StreamingResponse 吃一个生成器,客户端用 fetch 读流或 EventSource 收 SSE。排查到最后,多半不是「框架坏了」,而是取消点没有落到 await 上,或者上游订阅没在断开时停掉。

文档怎么说取消

《Custom Response》里的 StreamingResponse 一节写得很清楚:StreamingResponse 接受 async generator 或普通 generator。异步任务只有碰到 await 才能被取消。生成器里如果只有同步 yield、中间没有任何 await,取消请求进来以后协程可能还接着跑。官方小例子本身不需要等待,于是加了 await anyio.sleep(0),专门让出事件循环,好处理取消。文中还说,大或无限流时这一点更重要。

同一段建议:与其亲手 return StreamingResponse(...),不如按《Stream Data》那套写法——在路径操作上声明 response_class=StreamingResponse,函数里直接 yield 分片。文档写这套从 FastAPI 0.134.0 起提供,并注明后台会帮你处理取消,用起来更省事。Stream Data 页也点了用途:可以直接推 LLM 输出的字符串,或按块读大文件/音视频,不必整份进内存。分片原样交给响应,框架不会再帮你做 JSON 序列化,编码成什么字节由生成器自己负责。

对接上游模型时,生成器里通常是:读一个 chunk → yield 给客户端 → 再读下一个。客户端断开之后,要在取消路径里停掉对上游的订阅或关闭上游响应体,否则只是本地生成器停了,远端还在算。上游 SDK 若提供 async 迭代,循环里的 await 本身就能成为取消检查点。若中间夹了大段纯同步 CPU 或阻塞 HTTP,取消照样会拖到下一次 await 才生效。需要关文件或释放客户端时,用 with / try/finally 包住生成器生命周期,避免连接泄漏。

推纯文本 token 时,也可以按 Stream Data 示例给自定义 StreamingResponse 子类设好 media_type,避免客户端猜内容类型。JSON Lines 有单独教程,和「一段段纯字符串 / 字节」不是同一条路,选型时先看上游吐的是什么。

和前端半截 JSON 怎么分工

站里另一篇讲的是前端拼半截 JSON、流式对象怎么收。这篇只盯后端:推流用哪种响应、取消怎样才断得干净。若走 SSE,响应 Content-Type 用 text/event-stream,字段形状另对照 MDN 的 Server-Sent Events 说明。FastAPI 较新的文档里还有 EventSourceResponse 一类专用响应,和手写 StreamingResponse 二选一时看你们框架版本,以及要不要带 event / id / retry 字段。

实践上可以自测一眼:客户端主动 abort 之后,看进程里上游连接或线程还在不在、GPU 利用率掉没掉下来。只看浏览器 Network 面板变红,说明不了服务端生成器已经停。日志里打上「generator exit / upstream closed」之类的点,比猜取消有没有生效管用。

另一种踩坑是把同步阻塞的上游调用塞进 async def 生成器,却几乎不 await:事件循环被占住时,取消信号同样难以及时生效。Stream Data 文档对「读文件这类偏阻塞的工作」建议改用普通 def 路径操作,让框架丢到线程池,避免堵死主循环。和 LLM 的 async 客户端搭配时,则应保持 async for / await,让每次取 chunk 都成为可取消点。两种模型别混着用却忘记检查点。

你们是直接 return StreamingResponse,还是已经改成 Stream Data 那种 yield?取消后上游有没有残留请求,欢迎贴框架版本和生成器结构(脱敏即可)。