告别 startup/shutdown:FastAPI lifespan 生命周期管理,从原理到 AI 工程实战
一、为什么 startup/shutdown 被干掉了
先看老写法:
python
# 旧版,已废弃
@app.on_event("startup")
async def startup():
app.state.model = load_model()
@app.on_event("shutdown")
async def shutdown():
app.state.model.release()
看起来没毛病,用的人也不少。但 Starlette 团队废弃它是有道理的:
核心问题:startup 和 shutdown 是两个独立函数,但它们要共享的资源和状态只能挂在全局或 app.state 上。
这带来三个工程痛点:
- 资源生命周期割裂------加载在 startup,释放要等 shutdown,中间靠全局变量传递。一旦 startup 抛异常,shutdown 不一定执行,资源泄漏
- 无法利用上下文管理器 ------
async with天然保证了进退对称(获取/释放),但旧版事件回调没法用这层保护 - ASGI 协议对不齐 ------ASGI 规范定义的是
lifespan协议消息,startup/shutdown 是 FastAPI 自己加的事件钩子,属于框架层 hack
| 对比 | startup/shutdown(旧) | lifespan(新) |
|---|---|---|
| 模型 | 两个独立事件回调 | 一个 async 上下文管理器 |
| 资源传递 | 靠全局变量 / app.state | yield 前后天然共享作用域 |
| 异常安全 | startup 失败后 shutdown 不保证执行 | async with 保证 yield 后的清理一定执行 |
| ASGI 对齐 | 框架层 hack | 原生 ASGI lifespan 协议 |
| 测试 | 需要手动触发事件 | with TestClient(app) 自动走完生命周期 |
一句话:lifespan 把"启动→运行→关闭"收敛进一个上下文管理器,资源的安全释放由 Python 语义保证,不靠开发者记着写。
二、lifespan 的核心:yield 一刀切
lifespan 的本质就一个东西:@asynccontextmanager + yield。
yield 之前 → 启动阶段(加载模型、连数据库、初始化客户端)
yield → 应用运行(接收请求,正常服务)
yield 之后 → 关闭阶段(释放连接、清理资源)
执行流:
ASGI Server 发送 lifespan.startup
→ 进入 lifespan 函数
→ 执行 yield 之前的代码(资源初始化)
→ yield,交还控制权给 ASGI Server
→ 应用开始接收请求
→ ... 服务运行中 ...
→ ASGI Server 发送 lifespan.shutdown
→ 执行 yield 之后的代码(资源清理)
→ 退出 lifespan 函数
最小示例:
python
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
# ---- 启动阶段 ----
print("加载资源...")
app.state.model = "my_llm_model"
yield # ← 这一刀,把启动和关闭分开
# ---- 关闭阶段 ----
print("释放资源...")
del app.state.model
app = FastAPI(lifespan=lifespan)
必须记住的三条铁律:
yield不能少------没有 yield 就不是 async generator,ASGI Server 会直接报错- 函数必须是
async def------同步def+@asynccontextmanager不行 - yield 之前如果抛异常,yield 之后的代码不执行------和正常
try/finally语义一致
三、AI 工程场景:一个 lifespan 管全部资源
做 LLM 服务,启动时要加载的东西不少:Embedding 模型、向量库连接、LLM 客户端、MCP Server 客户端......以前每个都写一对 startup/shutdown,现在一个 lifespan 搞定。
3.1 完整封装
python
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
# ============ 启动:初始化所有资源 ============
# 1. 加载 Embedding 模型
from sentence_transformers import SentenceTransformer
app.state.embedder = SentenceTransformer("BAAI/bge-large-zh-v1.5")
# 2. 连接向量数据库
from qdrant_client import QdrantClient
app.state.qdrant = QdrantClient(host="localhost", port=6333)
# 3. 初始化 LLM 客户端
from openai import AsyncOpenAI
app.state.llm = AsyncOpenAI(api_key="sk-xxx")
print("所有资源就绪,开始服务")
yield # ← 应用运行期间,所有资源挂在 app.state 上
# ============ 关闭:逆序释放所有资源 ============
await app.state.llm.close() # 先关 LLM
app.state.qdrant.close() # 再关向量库
# Embedding 模型无显式 close,GC 回收
print("所有资源已释放")
app = FastAPI(lifespan=lifespan)
关闭顺序要注意:逆序释放。 后加载的资源先关,先加载的资源后关------和栈一样,后进先出。这和数据库连接池、网络客户端的依赖关系一致。
3.2 路由中取用资源
资源挂在 app.state 上,路由里通过 Request 取:
python
from fastapi import Request
@app.post("/search")
async def search(query: str, request: Request):
# 直接取 lifespan 初始化好的资源
embedder = request.app.state.embedder
qdrant = request.app.state.qdrant
llm = request.app.state.llm
# 1. query → embedding
query_vec = embedder.encode(query)
# 2. 向量检索
results = qdrant.search(
collection_name="kb",
query_vector=query_vec.tolist(),
limit=3,
)
# 3. 拼上下文 → LLM 生成
context = "\n".join([hit.payload["text"] for hit in results])
resp = await llm.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": f"基于以下知识回答:\n{context}"},
{"role": "user", "content": query},
],
)
return {"answer": resp.choices[0].message.content}
整个过程的数据流:
请求进来
→ Request.app.state 取 embedder / qdrant / llm
→ embedder.encode(query) 生成查询向量
→ qdrant.search() 召回 top-k 文档
→ llm.chat.completions 生成回答
→ 返回
不需要在路由函数里 new 客户端、建连接------那些重资源在应用启动时就初始化好了,请求里直接拿来用。这也是性能关键:Embedding 模型加载要几秒,如果每个请求都 new 一遍,服务直接没法用。
四、FastMCP 中的 lifespan:lifespan_context
FastMCP(MCP 协议的 Python 实现)也用了 lifespan,但取资源的路径和 FastAPI 不太一样------它通过 Context.lifespan_context 传递。
python
from contextlib import asynccontextmanager
from mcp.server.fastmcp import FastMCP, Context
@asynccontextmanager
async def app_lifespan(mcp: FastMCP):
"""FastMCP 的 lifespan,返回值会注入到每个工具的 Context 中"""
# 启动时初始化
shared_state = {
"qdrant": QdrantClient(host="localhost", port=6333),
"embedder": SentenceTransformer("BAAI/bge-large-zh-v1.5"),
}
yield shared_state # ← 这个返回值 = Context.lifespan_context
# 关闭时清理
shared_state["qdrant"].close()
mcp = FastMCP("my-mcp-server", lifespan=app_lifespan)
@mcp.tool()
async def search_kb(query: str, ctx: Context) -> str:
# 从 lifespan_context 取资源
qdrant = ctx.lifespan_context["qdrant"]
embedder = ctx.lifespan_context["embedder"]
query_vec = embedder.encode(query)
results = qdrant.search(
collection_name="kb",
query_vector=query_vec.tolist(),
limit=3,
)
return "\n".join([hit.payload["text"] for hit in results])
和 FastAPI 的区别:
| 对比点 | FastAPI | FastMCP |
|---|---|---|
| 资源挂载 | app.state.xxx |
yield 的返回值 |
| 路由取用 | request.app.state.xxx |
ctx.lifespan_context["xxx"] |
| 生命周期范围 | 整个应用 | 整个 MCP Server |
| yield 返回值 | 不用(None) | 必须返回(注入 Context) |
FastMCP 的设计更干净------lifespan yield 出来的对象直接成为每个工具的上下文,不需要通过 Request 中转。
五、异常安全:lifespan 比 startup/shutdown 强在哪
这是 lifespan 最被低估的优势。
旧版 startup 抛异常时:
python
@app.on_event("startup")
async def startup():
app.state.db = await connect_db() # 这里炸了
app.state.cache = await connect_redis() # 不执行
@app.on_event("shutdown")
async def shutdown():
await app.state.db.close() # db 可能没建成功,又来关它 → 二次异常
await app.state.cache.close() # cache 不存在 → AttributeError
startup 失败后,shutdown 是否执行、执行顺序是否正确,全靠运气。
lifespan 用 try/finally 保证清理:
python
@asynccontextmanager
async def lifespan(app: FastAPI):
db = None
try:
db = await connect_db()
app.state.db = db
app.state.cache = await connect_redis() # 这里炸了
yield
finally:
# 无论上面是否异常,这里一定执行
if db is not None:
await db.close()
async with 的语义保证:只要进了 yield,后面的清理代码一定跑;即使 yield 前面炸了,finally 块也照跑。 这是上下文管理器自带的保障,不需要开发者自己记着补逻辑。
六、测试:with TestClient 自动走完生命周期
lifespan 在测试中特别方便------with TestClient(app) 进来自动触发启动,出去自动触发关闭:
python
from fastapi.testclient import TestClient
def test_search():
with TestClient(app) as client:
# 进入 with → lifespan 的 yield 之前执行 → 资源就绪
response = client.post("/search", json={"query": "什么是 RAG"})
assert response.status_code == 200
# 退出 with → lifespan 的 yield 之后执行 → 资源清理
不用手动 startup() / shutdown(),with 语句自动管。这也意味着测试之间资源不会互相污染------每个 with 都是一个完整的生命周期。
七、几条避坑经验
1. yield 之前别做太重的事。 启动超时 ASGI Server 会认为服务挂了。模型加载确实慢,但可以通过分片加载或 lazy init 缓解,别让用户等 30 秒才 healthz 通过。
2. app.state 是个 SimpleNamespace,可以任意挂属性。 但别挂太多,命名要清晰,不然后面维护时不知道 app.state.xxx 是谁建的。建议统一加前缀,或者封装成一个 AppState 类。
3. 同步资源和异步资源的混用。 lifespan 函数是 async def,但有些库(如 SentenceTransformer)是同步的。直接调用没问题,但要注意它会阻塞事件循环。批量推理时考虑用 run_in_executor 丢到线程池。
4. 多 worker 下的资源隔离。 Uvicorn --workers 4 时,每个 worker 进程独立跑一遍 lifespan------4 个进程 = 4 份资源。Embedding 模型占内存大时,注意总内存 = 单进程内存 × worker 数。
5. 不要在 lifespan 里做请求级别的初始化。 lifespan 管的是应用级资源(整个进程生命周期内复用的),不是每个请求都要 new 的东西。请求级别的资源走 Depends。
小结
lifespan 不是 FastAPI 的一个新特性那么简单,它是一次设计哲学的切换------从"事件回调"到"上下文管理"。
旧版 startup/shutdown 让你手动管理两个函数之间的状态传递和异常边界,lifespan 把这些交给 Python 的 async with 语义去保证。你只要把资源初始化写在 yield 前,清理写在 yield 后,剩下的交给 ASGI 协议。
做 AI 工程的,服务启动时加载模型、连向量库、建 LLM 客户端------这些重资源必须在应用级别初始化一次、全局复用。lifespan 就是干这个的,没有更合适的方式了。