很多人第一次接触推理服务时,会把 FastAPI 当成全部答案。会写几个路由,能把模型跑起来,似乎就已经“做完了服务层”。但只要开始认真做 LLM serving,你很快就会发现,真正重要的不是“起了一个 Web 框架”,而是你有没有把下面这些边界讲清楚:

  • HTTP 请求是怎么进入服务的;
  • 服务层和推理引擎之间怎么解耦;
  • 为什么流式输出几乎天然要求异步接口;
  • 为什么每个请求都自己 generate() 一次,系统就做不出连续批处理。

这篇文章不打算停留在“FastAPI 教程”层面,而是把 FastAPIStarletteUvicornASGI 放回一条完整的推理服务链路里看,并结合 mini_infer/serving/server.pymini_infer/runtime/async_engine.pymini_infer/cli/serve.py 讲清楚它们各自到底负责什么。

如果你想先建立一个总图,再进入正文,可以先看这张对照表:

解决的问题 mini-infer 对应位置
Uvicorn 谁来监听端口、接住 HTTP 请求 mini_infer/cli/serve.py 里的 uvicorn.run(...)
ASGI 服务器和应用之间按什么协议协作 FastAPI appUvicorn 调用时的底层约定
Starlette 请求/响应、路由、流式响应这些底层能力由谁提供 StreamingResponseFastAPI 的底座
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
2
3
4
5
6
7
8
9
10
11
async def app(scope, receive, send):
assert scope["type"] == "http"
await send({
"type": "http.response.start",
"status": 200,
"headers": [(b"content-type", b"text/plain")],
})
await send({
"type": "http.response.body",
"body": b"hello",
})

这段伪代码当然不是你平时会直接写的服务代码,但它能帮你抓住本质: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 输出是增量生成的。服务层不应该等整段文本都生成完再一次性返回,而是应该:

  1. 请求进入;
  2. 后台引擎逐步产出 token;
  3. 服务层边拿边发;
  4. 结束时显式发送结束信号。

这类“请求生命周期明显长于一次函数调用”的场景,本来就是 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
2
3
4
5
6
7
8
9
10
11
12
@asynccontextmanager
async def lifespan(app: FastAPI):
global _engine
config = getattr(app.state, "engine_config", None)
if config is None:
config = _default_engine_config()
app.state.engine_config = config
_engine = AsyncEngine(config)
await _engine.start()
yield
await _engine.stop()
_engine = None

这段代码把服务生命周期和引擎生命周期绑定了起来:

  • 服务启动时创建并启动 AsyncEngine
  • 服务运行期间所有请求共享这一个引擎;
  • 服务关闭时统一停止后台线程并清理状态。

很多人知道 lifespan 这个名词,但真正要理解的是:它不是一个“框架技巧”,而是推理服务里资源管理的主入口。

从工程角度看,这里其实同时解决了三个问题:

  • 避免每个请求都重复初始化模型;
  • 保证所有请求共享同一个后台引擎实例;
  • 保证服务退出时后台线程、队列和引擎状态能被统一回收。

第三步:路由只做协议层工作

mini-infer 的路由很少,只有几个核心接口:

  • GET /healthz
  • GET /v1/models
  • POST /v1/chat/completions

这里最值得注意的是,路由层并不直接操心底层调度细节。它主要负责三件事:

  1. 校验请求是否合法;
  2. 把请求转换成引擎可消费的输入;
  3. 决定走 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
2
3
4
5
6
7
8
Client
-> Uvicorn
-> FastAPI route
-> request schema / validation
-> AsyncEngine
-> LLMEngine.step() background loop
-> token events
-> StreamingResponse / JSON response

如果把细节展开,大致是这样:

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
2
3
4
StreamingResponse(
_stream_generator(engine, prompt, request),
media_type="text/event-stream",
)

否则就走 _non_stream(),等完整文本生成完以后一次性返回 JSON。

这就是服务栈里一个很典型的职责分配:同一个业务接口,对外暴露两种协议行为,但两者底层都复用同一个引擎。

真正关键的点:为什么不能每个请求自己 generate()

如果只是做一个本地 demo,最容易想到的写法是:

  • HTTP 请求进来;
  • 直接调用一次 model.generate()
  • 等结果出来以后返回。

这在单请求场景下当然能跑,但它会直接失去 continuous batching 的核心收益。因为每个请求都变成一次独立推理,GPU 的调度单位就被锁死在“单请求”上,后来的请求只能排队。

mini-inferAsyncEngine 正是在解决这个问题。它做的不是“把同步代码改成 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 这套实现更有价值的地方在于,它把服务层和引擎层的边界切得很清楚:

  • CLI 负责启动;
  • FastAPI 负责协议;
  • lifespan 负责资源生命周期;
  • AsyncEngine 负责异步前门;
  • LLMEngine 负责真正的执行与调度。

当你把这几层讲顺之后,再去看 OpenAI-compatible API、SSE、continuous batching,就不会觉得它们是三件零散的事。它们其实是同一件事的三个侧面:如何把一个推理引擎,稳定地做成一个真正可用的在线服务。