从 ASGI 到推理服务:FastAPI、Starlette、Uvicorn 在 mini-infer 里如何协作
很多人第一次接触推理服务时,会把 FastAPI 当成全部答案。会写几个路由,能把模型跑起来,似乎就已经“做完了服务层”。但只要开始认真做 LLM serving,你很快就会发现,真正重要的不是“起了一个 Web 框架”,而是你有没有把下面这些边界讲清楚:
- HTTP 请求是怎么进入服务的;
- 服务层和推理引擎之间怎么解耦;
- 为什么流式输出几乎天然要求异步接口;
- 为什么每个请求都自己
generate()一次,系统就做不出连续批处理。
这篇文章不打算停留在“FastAPI 教程”层面,而是把 FastAPI、Starlette、Uvicorn、ASGI 放回一条完整的推理服务链路里看,并结合 mini_infer/serving/server.py、mini_infer/runtime/async_engine.py 和 mini_infer/cli/serve.py 讲清楚它们各自到底负责什么。
如果你想先建立一个总图,再进入正文,可以先看这张对照表:
| 层 | 解决的问题 | mini-infer 对应位置 |
|---|---|---|
Uvicorn |
谁来监听端口、接住 HTTP 请求 | mini_infer/cli/serve.py 里的 uvicorn.run(...) |
ASGI |
服务器和应用之间按什么协议协作 | FastAPI app 被 Uvicorn 调用时的底层约定 |
Starlette |
请求/响应、路由、流式响应这些底层能力由谁提供 | StreamingResponse、FastAPI 的底座 |
FastAPI |
如何把 endpoint、请求模型、生命周期组织起来 | mini_infer/serving/server.py |
AsyncEngine |
HTTP 请求怎样进入共享的后台 step loop | mini_infer/runtime/async_engine.py |
先把四个角色拆开
如果只记一句话,我建议记这句:
FastAPI是接口层,Starlette是底座,ASGI是服务端和应用之间的调用协议,Uvicorn是真正把应用跑起来的服务器。
这四个名字经常一起出现,但它们不是一回事。
Web 服务先解决的是什么问题
推理服务本质上就是一个长期监听端口的程序。客户端发来 HTTP 请求,服务端接收请求、解析参数、调用推理引擎、返回响应。只是到了大模型场景,这个过程会出现几个和普通 CRUD 后端很不一样的约束:
- 请求处理时间更长;
- 很多请求要流式返回;
- 后台存在共享 GPU 资源;
- 多个请求最好合并进同一批次,而不是各跑各的;
- 服务启动和退出时,往往要显式初始化和释放模型、KV cache、后台线程。
所以推理服务不是“网页后端的一个特例”,而更像“一个对外暴露 HTTP 接口的推理调度系统”。
ASGI 到底是什么
ASGI 全称是 Asynchronous Server Gateway Interface。它不是框架,而是一套约定:服务器如何把请求交给应用,应用如何把响应再交回服务器。
它的核心接口可以粗略理解成三部分:
scope:这次连接/请求的上下文信息,比如路径、方法、请求头;receive:应用如何异步接收来自客户端的数据;send:应用如何异步把响应数据发回去。
如果你只做同步、短请求、一次性返回,很多时候不会特别在意这层协议。但 LLM serving 常见的流式输出、长连接等待、后台异步协调,本质上都要求“应用”和“服务器”之间有一个明确的异步通信约定。ASGI 的价值就在这里。
如果把它压缩成一个最小心智模型,可以想成这样:
1 | async def app(scope, receive, send): |
这段伪代码当然不是你平时会直接写的服务代码,但它能帮你抓住本质:ASGI 关心的是“应用如何接收请求并发回响应”,FastAPI 只是把这套底层协议包装成了更容易维护的业务接口。
FastAPI、Starlette、Uvicorn 分别在干什么
三者的分工其实很清楚:
FastAPI负责把“路径、请求模型、响应模型、生命周期”这些业务层概念组织起来;Starlette提供底层 ASGI 应用能力,比如请求/响应对象、路由分发、StreamingResponse等;Uvicorn负责监听端口、接受 HTTP 连接、驱动事件循环,并把请求按照 ASGI 协议交给应用。
换句话说:
- 没有
Uvicorn,应用不会真正对外提供网络服务; - 没有
ASGI,服务器和应用之间没有统一的异步调用契约; - 没有
Starlette/FastAPI,你就得直接在更底层的协议抽象上手写大量服务逻辑。
为什么 LLM serving 比普通接口更依赖这套栈
很多教程会告诉你,FastAPI 的优点是自动文档、Pydantic 校验、类型提示友好。这些当然都对,但放到推理服务里,真正关键的不是这些“写起来舒服”的点,而是下面三件事:
第一,服务层不能阻塞后台调度
LLM 推理不是一个纯 I/O 任务。真正的瓶颈在 GPU,真正昂贵的状态在模型权重、KV cache 和调度器。服务层如果按“一个 HTTP 请求对应一次完整推理”的方式组织,就会天然和 continuous batching 冲突。
mini-infer 的设计非常明确:所有请求共享一个 AsyncEngine,由它背后的后台 step loop 统一驱动 LLMEngine.step()。HTTP 只是把请求送进去,再把结果取出来;HTTP 本身不拥有推理主循环。
第二,流式输出天然要求异步边界
LLM 输出是增量生成的。服务层不应该等整段文本都生成完再一次性返回,而是应该:
- 请求进入;
- 后台引擎逐步产出 token;
- 服务层边拿边发;
- 结束时显式发送结束信号。
这类“请求生命周期明显长于一次函数调用”的场景,本来就是 ASGI 更擅长的场景。
第三,推理服务非常依赖生命周期管理
模型什么时候初始化,后台线程什么时候启动,服务退出时如何清理资源,这些都不是小事。普通脚本里你可以把初始化写在全局,但服务一旦长期运行、需要健康检查和可控启动,就必须把生命周期讲清楚。
mini-infer 是怎么把推理引擎接成服务的
如果顺着 mini-infer 的代码看,服务栈的组织方式非常清楚。
第一步:CLI 负责收集配置并启动服务器
在 mini_infer/cli/serve.py 里,CLI 会解析启动参数,构造 EngineConfig,然后把配置挂到 app.state.engine_config 上,最后通过 uvicorn.run(app, ...) 启动服务。
这一步的意义不是“把参数解析一下”这么简单,而是把两个边界彻底分开了:
- CLI 负责进程启动和配置注入;
- FastAPI app 负责处理 HTTP 请求;
- 引擎配置通过
app.state进入应用,而不是散落在全局变量里。
这类分层在工程上很重要。否则以后你想从命令行启动切到容器环境变量启动,或者在测试里注入不同配置,都会变得很难看。
第二步:lifespan 负责启动和关闭引擎
mini_infer/serving/server.py 里最关键的不是路由,而是 lifespan:
1 |
|
这段代码把服务生命周期和引擎生命周期绑定了起来:
- 服务启动时创建并启动
AsyncEngine; - 服务运行期间所有请求共享这一个引擎;
- 服务关闭时统一停止后台线程并清理状态。
很多人知道 lifespan 这个名词,但真正要理解的是:它不是一个“框架技巧”,而是推理服务里资源管理的主入口。
从工程角度看,这里其实同时解决了三个问题:
- 避免每个请求都重复初始化模型;
- 保证所有请求共享同一个后台引擎实例;
- 保证服务退出时后台线程、队列和引擎状态能被统一回收。
第三步:路由只做协议层工作
mini-infer 的路由很少,只有几个核心接口:
GET /healthzGET /v1/modelsPOST /v1/chat/completions
这里最值得注意的是,路由层并不直接操心底层调度细节。它主要负责三件事:
- 校验请求是否合法;
- 把请求转换成引擎可消费的输入;
- 决定走 streaming 还是 non-streaming 响应路径。
这是一个很好的服务层边界。HTTP 层管协议,引擎层管执行,异步层管请求和 token 的交接。
如果把 server.py 的服务职责再压缩一下,可以概括成下面这张表:
| 函数/入口 | 它做的事 | 不做的事 |
|---|---|---|
healthz() |
暴露服务健康状态 | 不参与推理 |
list_models() |
暴露当前模型列表 | 不管理模型加载 |
chat_completions() |
路由分流、请求校验、选择 stream / non-stream | 不自己调度 batch |
_non_stream() |
组装完整 JSON 响应 | 不维护后台 step loop |
_stream_generator() |
把事件翻译成 SSE chunk | 不直接执行模型前向 |
一条请求是怎么穿过整条栈的
以 POST /v1/chat/completions 为例,mini-infer 的处理路径可以概括成下面这条链:
1 | Client |
如果把细节展开,大致是这样:
1. Uvicorn 接住 HTTP 请求
Uvicorn 监听端口并接受请求,然后按 ASGI 协议把请求交给 FastAPI 应用。这一步解决的是“网络连接和应用调用之间的桥接”。
2. FastAPI 完成协议层解析
路由函数接收 ChatCompletionRequest 这样的 Pydantic 模型。这里完成的是:
- 请求体反序列化;
- 字段类型和范围校验;
- 路由匹配;
- 错误转换成标准 HTTP 响应。
这也是为什么 FastAPI 特别适合做推理服务的入口层。它不是帮你把推理变快,而是帮你把协议层写得更稳。
3. 服务层把 messages 转成模型 prompt
在 chat_completions() 里,mini-infer 会先调用 engine.format_prompt(request.messages)。这一步很关键,它说明 OpenAI 风格的 messages 只是协议层表示,不是模型真正直接吃进去的格式。
如果底层 tokenizer 支持 apply_chat_template(),这里会生成模型真正需要的 prompt;如果是 dry_run,则退化成简单拼接。
4. 路由层决定响应模式
如果 request.stream 为真,就返回:
1 | StreamingResponse( |
否则就走 _non_stream(),等完整文本生成完以后一次性返回 JSON。
这就是服务栈里一个很典型的职责分配:同一个业务接口,对外暴露两种协议行为,但两者底层都复用同一个引擎。
真正关键的点:为什么不能每个请求自己 generate()
如果只是做一个本地 demo,最容易想到的写法是:
- HTTP 请求进来;
- 直接调用一次
model.generate(); - 等结果出来以后返回。
这在单请求场景下当然能跑,但它会直接失去 continuous batching 的核心收益。因为每个请求都变成一次独立推理,GPU 的调度单位就被锁死在“单请求”上,后来的请求只能排队。
mini-infer 的 AsyncEngine 正是在解决这个问题。它做的不是“把同步代码改成 async”这么简单,而是建立了一个统一的异步前门:
- 每个请求注册一个自己的
asyncio.Queue; - 后台线程持续调用
LLMEngine.step(); - 同一步里可以合并多个请求进入同一个 decode batch;
- 再把产出的 token 分发回各自的 queue。
这条链路如果用一句更工程化的话来总结,就是:
HTTP 请求不是直接驱动 GPU,而是先进入一个共享的异步前门;真正驱动 GPU 的,是后台统一运行的 step loop。
这也是为什么 README 里那组数据有意义:在 Qwen2.5-7B 上,并发 1 到 8 个客户端时,核心 serving 路径吞吐从 55.7 tok/s 提升到 219.1 tok/s,本质原因不是 HTTP 更快了,而是 HTTP 服务层没有破坏引擎的批处理能力。
FastAPI、Starlette、Uvicorn 在这条链里各自值在哪里
回到最开始那三个最容易混在一起的名字,现在就更容易看清了。
FastAPI:把协议层组织成可维护的服务
FastAPI 最重要的价值,不是“能自动生成文档”,而是它把以下概念做成了天然的一等公民:
- 路由;
- 请求模型;
- 响应模型;
- 生命周期;
- 流式响应。
这些恰好都是推理服务入口层最需要的东西。
Starlette:提供真正能落地的 ASGI 原语
你在 mini-infer 里直接感知到的 StreamingResponse,底层就来自 Starlette。很多人写 FastAPI 的时候感觉不到 Starlette 的存在,但一旦进入流式输出、生命周期管理、请求/响应对象这些位置,实际上都是在消费 Starlette 的能力。
Uvicorn:把“应用对象”变成“对外服务”
没有 uvicorn.run(app, ...),你的 app 只是一个 Python 对象。Uvicorn 的角色就是把它接到网络上,并驱动事件循环去执行那套 ASGI 协议。
所以你可以把 Uvicorn 理解成“真正跑应用的人”,而把 FastAPI 理解成“定义应用长什么样的人”。
从这篇文章里应该真正带走什么
如果你是从“想会用 FastAPI”出发读这篇文章,最后真正该掌握的不是几个 API 名字,而是下面这些判断:
1. 推理服务不是普通 Web 接口
它面对的是共享 GPU、长生命周期请求、流式输出和调度协同问题,所以天然更依赖清晰的异步边界与生命周期管理。
2. ASGI 不是一个抽象名词
它解决的是“服务器如何与应用异步协作”的问题。你一旦做 streaming、SSE、长连接等待,这层协议就不再是背景板。
3. FastAPI 的真正价值是协议层组织能力
推理引擎的性能来自调度器、KV cache、kernel;服务层的价值,是把这些能力以稳定、标准、可观测的方式暴露出去。
4. AsyncEngine 才是 mini-infer 服务层设计的关键
如果没有它,HTTP 请求就只是把同步推理包了一层壳;有了它,HTTP 才真正和 continuous batching、SSE streaming、共享后台 step loop 结合起来。
建议和另外两篇一起看
这篇主要解决“服务栈为什么要这样分层”。如果你准备顺着往下读,建议接着看:
- 把推理引擎接成标准接口:结合 mini-infer 讲清 OpenAI-Compatible HTTP API:看协议层到底暴露了什么契约。
- 为什么大模型服务需要流式返回:结合 mini-infer 讲清 SSE 的协议、实现与断连处理:看流式路径和断连清理是怎么落地的。
写在最后
很多“会写推理服务”的表述,其实只是“会把模型挂到一个接口上”。而 mini-infer 这套实现更有价值的地方在于,它把服务层和引擎层的边界切得很清楚:
- CLI 负责启动;
- FastAPI 负责协议;
lifespan负责资源生命周期;AsyncEngine负责异步前门;LLMEngine负责真正的执行与调度。
当你把这几层讲顺之后,再去看 OpenAI-compatible API、SSE、continuous batching,就不会觉得它们是三件零散的事。它们其实是同一件事的三个侧面:如何把一个推理引擎,稳定地做成一个真正可用的在线服务。




