阶段3(上):Python/asyncio/FastAPI 专题的课程、企业场景、面试题和可运行示例

This commit is contained in:
Shikui Wang committed 2026-10-23 15:44:13 +08:00
1 parent b8010a74f3
commit 7f52513ffc
24 files changed
+2817 -33

No files matched your search

@@ -0,0 +1,116 @@
---
id: python.fastapi.routing-validation
title: 路由和参数校验:路径 / 查询 / 请求体、pydantic 模型、响应模型
type: lesson
domain: python
stack: [fastapi]
tags: [路由, pydantic, 参数校验, 响应模型, OpenAPI]
level: basic
prerequisites: [python.python.typing-errors-testing]
est_hours: 2
related: [python.fastapi.deps-auth-errors, backend.gin.routing-binding, python.fastapi.interview]
week: [4]
order: 1
---
# 路由和参数校验:路径 / 查询 / 请求体、pydantic 模型、响应模型
## 解释说明
- **参数来源由类型和位置决定**:路径里出现的参数 → 路径参数;简单类型(int、str)→ 查询参数;pydantic 模型 → JSON 请求体;`Header()`、`Cookie()` 显式声明。
- **校验**:`Field(ge=1, le=100, min_length=1, pattern=...)`、`Query(...)`、`Path(...)`;`Literal` 限定枚举值;校验失败自动返回 **422**,带出错字段和原因。
- **响应模型**:返回类型标注或 `response_model=`,会过滤掉模型之外的字段——防止把密码哈希等内部字段返回出去。
- **自动文档**:启动后访问 `/docs`(Swagger UI),前端联调直接用。
- **`async def` 还是 `def`**:路由里调用的都是异步库 → `async def`;调用同步阻塞库 → 普通 `def`(FastAPI 会放到线程池执行)。**不要在 `async def` 里调用阻塞函数。**
- **APIRouter**:按模块拆分路由,`app.include_router(router, prefix="/v1/docs")`。
和 Gin 对照:`c.Param` ↔ 路径参数,`c.Query` ↔ 查询参数,`ShouldBindJSON` + `binding` 标签 ↔ pydantic 模型 + `Field`。
## 案例
知识库的文档接口:创建文档(标题 1~200 字、标签最多 10 个、可见范围只能是 `private / team / public`)、按 ID 查询、分页列表(页码 ≥ 1,每页 1~100)。用户传了 `"visibility": "everyone"`,FastAPI 自动返回 422 和具体字段,不用手写校验代码。
## 代码示例
```python
from datetime import datetime, timezone
from typing import Annotated, Literal
from fastapi import APIRouter, FastAPI, HTTPException, Path, Query
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
class DocCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
content: str = Field(min_length=1)
tags: list[str] = Field(default_factory=list, max_length=10)
visibility: Literal["private", "team", "public"] = "team"
class DocOut(BaseModel): # 响应模型:只暴露这些字段
id: int
title: str
tags: list[str]
visibility: str
created_at: datetime
class Page(BaseModel):
items: list[DocOut]
total: int
router = APIRouter(prefix="/v1/docs", tags=["文档"])
DB: dict[int, dict] = {}
@router.post("", status_code=201)
async def create_doc(body: DocCreate) -> DocOut:
doc_id = len(DB) + 1
DB[doc_id] = {**body.model_dump(), "id": doc_id, "owner_secret": "内部字段",
"created_at": datetime.now(timezone.utc)}
return DocOut(**DB[doc_id]) # 只取模型里声明的字段
@router.get("/{doc_id}")
async def get_doc(doc_id: Annotated[int, Path(ge=1)]) -> DocOut:
if doc_id not in DB:
raise HTTPException(status_code=404, detail="文档不存在")
return DocOut(**DB[doc_id])
@router.get("")
async def list_docs(page: Annotated[int, Query(ge=1)] = 1,
size: Annotated[int, Query(ge=1, le=100)] = 20,
tag: str | None = None) -> Page:
docs = [d for d in DB.values() if tag is None or tag in d["tags"]]
start = (page - 1) * size
return Page(items=[DocOut(**d) for d in docs[start:start + size]], total=len(docs))
app = FastAPI()
app.include_router(router)
c = TestClient(app)
r = c.post("/v1/docs", json={"title": "年假制度", "content": "……", "tags": ["hr"]})
print(r.status_code, r.json()["id"], "owner_secret" in r.json()) # 201 1 False
r = c.post("/v1/docs", json={"title": "", "content": "x", "visibility": "everyone"})
print(r.status_code, [e["loc"] for e in r.json()["detail"]]) # 422 [['body', 'title'], ['body', 'visibility']]
print(c.get("/v1/docs", params={"size": 500}).status_code) # 422
print(c.get("/v1/docs/99").json()) # {'detail': '文档不存在'}
print(c.get("/v1/docs", params={"tag": "hr"}).json()["total"]) # 1
```
## 易错点
- **在 `async def` 路由里调用同步数据库驱动或 `requests`**:阻塞事件循环,吞吐骤降。
- **直接返回 ORM 对象或字典而不声明响应模型**:内部字段(密码哈希、内部备注)泄露。
- **可变默认值**:`tags: list[str] = []` 在 pydantic 里是安全的(会复制),但养成 `default_factory` 的习惯更清晰。
- **路由顺序**:`/docs/{doc_id}` 写在 `/docs/stats` 之前,`stats` 会被当成 doc_id 去解析,返回 422。固定路径放在前面。
- **422 的错误格式直接给前端**:默认格式偏技术化,通常用异常处理器转换成团队统一的格式(见下一课)。
- **pydantic v1 和 v2 写法混用**:v2 用 `model_dump()`、`model_validate()`、`field_validator`;网上很多旧教程是 v1 写法(`dict()`、`validator`)。
## 面试常问
见 [FastAPI 面试题](interview.md):FastAPI 为什么快、参数校验原理、async def 和 def 的区别、响应模型的作用。
@@ -0,0 +1,134 @@
---
id: python.fastapi.deps-auth-errors
title: 依赖注入、鉴权、中间件和统一错误处理
type: lesson
domain: python
stack: [fastapi]
tags: [Depends, 鉴权, JWT, 中间件, 异常处理器, 多租户]
level: intermediate
prerequisites: [python.fastapi.routing-validation]
est_hours: 3
related: [python.fastapi.streaming-testing, backend.gin.middleware-auth, python.fastapi.interview]
week: [4, 5]
order: 2
---
# 依赖注入、鉴权、中间件和统一错误处理
## 解释说明
**依赖注入 `Depends`**:把“获取当前用户”“获取数据库会话”“获取模型客户端”写成函数,路由参数里声明 `Depends(函数)`,FastAPI 自动调用并把结果传进来。
- 依赖可以嵌套(获取当前用户 → 依赖解析 token → 依赖读取配置)。
- 带 `yield` 的依赖可以做资源清理(请求结束后关闭数据库会话)。
- 同一个请求里同一个依赖默认只执行一次(有缓存)。
- **测试时用 `app.dependency_overrides[原依赖] = 假实现`** 替换,这是 FastAPI 最好用的测试手段。
**鉴权**:常见两种——给服务调用方的 API Key(请求头 `X-API-Key`),给用户的 JWT(`Authorization: Bearer ...`)。写成依赖:校验失败抛 `HTTPException(401)`;权限不足抛 403。多租户系统里,依赖返回“当前租户”,后续查询都带上租户条件。
**中间件**:每个请求都要经过的逻辑——请求 ID、耗时日志、CORS。`@app.middleware("http")` 或添加 Starlette 中间件类。业务相关的校验(鉴权、权限)用依赖更合适,因为能精确控制作用于哪些路由。
**统一错误处理**:`@app.exception_handler(异常类型)`。
- `HTTPException` → 统一格式 `{"code", "message", "request_id"}`;
- `RequestValidationError` → 把 422 转成统一格式,列出字段和原因;
- 兜底 `Exception` → 记录完整日志,返回 500 和通用提示,**不返回内部细节**。
和 Gin 对照:`Depends` 类似在中间件里 `c.Set("user", ...)` 然后在 handler 里取,但它有类型、按路由声明、可以在测试中替换。
## 案例
AI 服务给多个业务系统调用:每个系统一个 API Key,对应一个租户;每个请求日志都带 `request_id` 和租户;参数错误返回统一格式让调用方能程序化处理;内部异常(数据库连接串、堆栈)只进日志。
## 代码示例
```python
from collections.abc import Iterator
from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
class Session: # 模拟数据库会话
def __init__(self) -> None:
self.closed = False
def query_docs(self, tenant: str) -> list[str]:
return [f"{tenant} 的文档 {i}" for i in range(2)]
opened: list[Session] = []
def get_session() -> Iterator[Session]: # 带 yield 的依赖:请求结束后清理
s = Session()
opened.append(s)
try:
yield s
finally:
s.closed = True
KEYS = {"key-a": "tenant-a", "key-b": "tenant-b"}
def current_tenant(x_api_key: Annotated[str | None, Header()] = None) -> str:
if not x_api_key or x_api_key not in KEYS:
raise HTTPException(status_code=401, detail="API Key 无效")
return KEYS[x_api_key]
def require_admin(tenant: Annotated[str, Depends(current_tenant)]) -> str: # 依赖嵌套
if tenant != "tenant-a":
raise HTTPException(status_code=403, detail="没有管理权限")
return tenant
app = FastAPI()
@app.exception_handler(HTTPException)
async def http_error(request: Request, exc: HTTPException):
return JSONResponse({"code": exc.status_code, "message": exc.detail}, status_code=exc.status_code)
@app.get("/documents")
def list_docs(tenant: Annotated[str, Depends(current_tenant)],
db: Annotated[Session, Depends(get_session)]) -> list[str]:
return db.query_docs(tenant) # 查询一律带租户条件
@app.delete("/cache")
def clear_cache(tenant: Annotated[str, Depends(require_admin)]) -> dict:
return {"cleared_by": tenant}
c = TestClient(app)
print(c.get("/documents").json()) # {'code': 401, 'message': 'API Key 无效'}
print(c.get("/documents", headers={"X-API-Key": "key-b"}).json()) # ['tenant-b 的文档 0', 'tenant-b 的文档 1']
print(opened[-1].closed) # True:请求结束后会话已关闭
print(c.delete("/cache", headers={"X-API-Key": "key-b"}).status_code) # 403
# 测试里替换依赖:不需要真实的 API Key 和数据库
app.dependency_overrides[current_tenant] = lambda: "tenant-test"
print(c.get("/documents").json()) # ['tenant-test 的文档 0', ...]
app.dependency_overrides.clear()
```
请求 ID 中间件、422 统一格式、500 兜底的完整写法见 [examples/chat-api/app.py](examples/chat-api/app.py)。
## 易错点
- **鉴权写在中间件里对所有路径生效**:健康检查、文档页面也被拦住;用依赖按路由声明更精确(或在路由器级别 `dependencies=[Depends(...)]`)。
- **多租户只在入口校验,查询时忘了带租户条件**:越权读到别的租户数据。把租户作为数据访问层的必传参数。
- **兜底异常处理器把 `str(exc)` 返回给调用方**:泄露数据库地址、内部路径。
- **测试后不清理 `dependency_overrides`**:影响其他测试。
- **API Key 明文存储、用 `==` 比较**:存哈希,用 `secrets.compare_digest` 比较以防时序攻击。
- **自定义路由用了 `/docs`、`/redoc`、`/openapi.json`**:和 FastAPI 自带的文档页面冲突(本课示例最初就踩了这个坑,`GET /docs` 返回的是 Swagger 页面)。换路径,或者 `FastAPI(docs_url=None)` 关闭/改名自带文档——生产环境通常也应该关闭或加鉴权。
- **在依赖里做很重的操作**(每个请求都查一次配置中心):考虑缓存或在应用启动时加载。
## 面试常问
见 [FastAPI 面试题](interview.md):Depends 的原理和用途、中间件和依赖的区别、怎么统一错误格式、怎么做多租户隔离。
@@ -0,0 +1,134 @@
---
id: python.fastapi.streaming-testing
title: 流式输出(SSE)、生命周期、测试和部署
type: lesson
domain: python
stack: [fastapi]
tags: [SSE, StreamingResponse, lifespan, TestClient, pytest, uvicorn]
level: intermediate
prerequisites: [python.fastapi.deps-auth-errors]
est_hours: 3
related: [python.fastapi.scenario.streaming-chat-api, devops.nginx.https-stream-limit, python.fastapi.interview]
week: [5, 12]
order: 3
---
# 流式输出(SSE)、生命周期、测试和部署
## 解释说明
**SSE 流式输出**
- 大模型逐字生成,用 `StreamingResponse(异步生成器, media_type="text/event-stream")` 边生成边发送。
- 每个事件的格式:`data: 内容\n\n`(以空行结束);可以加 `event: 名字` 区分事件类型(如 `error`)。内容里有换行时用 JSON 编码。
- **响应头一旦发出就不能再修改状态码**:生成中途出错,只能在流里发送一个错误事件,前端据此提示。
- **客户端断开**:用 `await request.is_disconnected()` 检查,断开就停止调用模型,节省费用。
- 经过 Nginx 时要关闭代理缓冲(响应头 `X-Accel-Buffering: no`),见 [Nginx 流式代理](../../devops/nginx/02-https-stream-limit.md)。
- 前端:`EventSource` 只支持 GET;POST 请求体(对话消息)通常用 `fetch` + `ReadableStream` 自己解析 SSE。
**生命周期 lifespan**:应用启动时创建共享资源(HTTP 客户端连接池、数据库连接池、加载模型配置),关闭时释放。不要每个请求新建 `httpx.AsyncClient`。
**测试**
- `TestClient(app)`:同步写法测试,内部运行事件循环,最常用;
- `httpx.AsyncClient(transport=ASGITransport(app=app))`:在异步测试中使用;
- `app.dependency_overrides`:替换模型客户端、数据库、鉴权;
- 测试里**永远不要调用真实的模型 API**——慢、花钱、结果不稳定。
**部署**:`uvicorn app:app --host 0.0.0.0 --port 8000 --workers 4`;或 gunicorn + uvicorn worker。每个 worker 是独立进程,内存里的状态(缓存、限流计数)不共享,需要共享的放 Redis。容器化见 [Docker 专题](../../devops/docker/index.md)。
## 案例
RAG 问答接口:lifespan 中创建共享的 `httpx.AsyncClient`(连接池、超时);问答接口先检索,然后流式输出答案,最后发一个 `event: sources` 事件携带引用来源;测试用假的检索器和模型客户端,验证事件顺序。
## 代码示例
```python
import asyncio
import json
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from fastapi.testclient import TestClient
@asynccontextmanager
async def lifespan(app: FastAPI):
# 启动:创建共享资源(连接池复用,设置超时)
app.state.http = httpx.AsyncClient(timeout=httpx.Timeout(30, connect=5))
yield
# 关闭:释放资源
await app.state.http.aclose()
app = FastAPI(lifespan=lifespan)
async def fake_answer(question: str) -> AsyncIterator[str]:
for word in ["根据", "制度", ",", "年假", "为", "5", "天", "。"]:
await asyncio.sleep(0.01)
yield word
def sse(data, event: str | None = None) -> str:
head = f"event: {event}\n" if event else ""
return f"{head}data: {json.dumps(data, ensure_ascii=False)}\n\n"
@app.get("/ask")
async def ask(q: str, request: Request):
async def events() -> AsyncIterator[str]:
async for token in fake_answer(q):
if await request.is_disconnected():
return # 客户端断开,停止生成
yield sse({"delta": token})
yield sse([{"title": "员工手册", "section": "3.2"}], event="sources")
yield "data: [DONE]\n\n"
return StreamingResponse(events(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
# 测试:with 语句会触发 lifespan 的启动和关闭
with TestClient(app) as client:
assert isinstance(app.state.http, httpx.AsyncClient)
with client.stream("GET", "/ask", params={"q": "年假几天"}) as r:
body = "".join(r.iter_text())
blocks = body.strip().split("\n\n")
answer = "".join(json.loads(b[6:])["delta"] for b in blocks if b.startswith("data: {"))
print(answer) # 根据制度,年假为5天。
print(blocks[-2].splitlines()[0], blocks[-1]) # event: sources data: [DONE]
print(app.state.http.is_closed) # True:关闭时已释放
```
pytest 中的异步测试写法(需要 `pytest-asyncio` 或 `anyio` 插件):
```python
import httpx
import pytest
@pytest.mark.anyio
async def test_health():
from app import app # 你的应用
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.get("/healthz")
assert r.status_code == 200
```
## 易错点
- **流式响应中途出错仍想返回 500**:做不到,响应头已发出。在流里发送错误事件。
- **不检查客户端断开**:用户关掉页面,后端仍在生成并计费。
- **每个请求创建新的 HTTP 客户端**:无法复用连接,延迟高,还可能耗尽文件描述符。
- **用 `TestClient(app)` 但不用 `with`**:lifespan 不会执行,`app.state` 上的资源不存在。
- **多 worker 部署后,内存缓存 / 限流 / 会话状态各进程不一致**:共享状态放 Redis。
- **测试调用真实模型**:CI 不稳定且产生费用。
## 面试常问
见 [FastAPI 面试题](interview.md):怎么实现流式输出、SSE 和 WebSocket 的区别、怎么测试依赖外部服务的接口、部署时 worker 数怎么定。
@@ -0,0 +1,152 @@
# requires: fastapi, httpx
"""流式对话 API:鉴权、参数校验、请求 ID、统一错误格式、SSE 流式输出、依赖注入替换模型客户端。
运行:uvicorn app:app --reload(或 python app.py 打印一次演示)
测试:python -m pytest -q test_app.py
模型客户端是假的(逐字返回固定文本),换成真实 SDK 时只需要实现同样的 stream 方法。
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
import uuid
from collections.abc import AsyncIterator
from typing import Annotated, Literal, Protocol
from fastapi import Depends, FastAPI, Header, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
log = logging.getLogger("chat-api")
# ---------- 模型 ----------
class Message(BaseModel):
role: Literal["system", "user", "assistant"]
content: str = Field(min_length=1, max_length=4000)
class ChatRequest(BaseModel):
messages: list[Message] = Field(min_length=1, max_length=50)
temperature: float = Field(default=0.3, ge=0, le=2)
stream: bool = True
class ChatResponse(BaseModel):
request_id: str
content: str
# ---------- 依赖 ----------
class LLMClient(Protocol):
def stream(self, messages: list[Message], temperature: float) -> AsyncIterator[str]: ...
class FakeLLM:
async def stream(self, messages: list[Message], temperature: float) -> AsyncIterator[str]:
answer = f"你问的是:{messages[-1].content}。这是一个演示回答。"
for ch in answer:
await asyncio.sleep(0.005)
yield ch
def get_llm() -> LLMClient:
return FakeLLM()
API_KEYS = {"demo-key": "tenant-a"} # 演示用;真实项目从数据库或密钥服务读取,并做哈希比对
def current_tenant(x_api_key: Annotated[str | None, Header()] = None) -> str:
tenant = API_KEYS.get(x_api_key or "")
if tenant is None:
raise HTTPException(status_code=401, detail="API Key 无效")
return tenant
# ---------- 应用 ----------
app = FastAPI(title="chat-api")
@app.middleware("http")
async def request_id_and_timing(request: Request, call_next):
rid = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:16]
request.state.request_id = rid
start = time.perf_counter()
response = await call_next(request)
response.headers["X-Request-ID"] = rid
log.info("%s %s %s %.1fms", request.method, request.url.path, response.status_code,
(time.perf_counter() - start) * 1000)
return response
def error_body(request: Request, code: str, message: str, details=None) -> dict:
body = {"code": code, "message": message, "request_id": getattr(request.state, "request_id", "")}
if details is not None:
body["details"] = details
return body
@app.exception_handler(HTTPException)
async def http_error(request: Request, exc: HTTPException):
return JSONResponse(error_body(request, f"HTTP_{exc.status_code}", str(exc.detail)), status_code=exc.status_code)
@app.exception_handler(RequestValidationError)
async def validation_error(request: Request, exc: RequestValidationError):
details = [{"field": ".".join(str(p) for p in e["loc"][1:]), "message": e["msg"]} for e in exc.errors()]
return JSONResponse(error_body(request, "INVALID_PARAMS", "参数校验失败", details), status_code=422)
@app.exception_handler(Exception)
async def unhandled_error(request: Request, exc: Exception):
log.exception("未处理的异常") # 详细信息只进日志,不返回给调用方
return JSONResponse(error_body(request, "INTERNAL", "服务器内部错误"), status_code=500)
@app.get("/healthz")
async def healthz() -> dict:
return {"status": "ok"}
@app.post("/v1/chat", response_model=None)
async def chat(req: ChatRequest, request: Request,
tenant: Annotated[str, Depends(current_tenant)],
llm: Annotated[LLMClient, Depends(get_llm)]):
rid = request.state.request_id
if not req.stream:
content = "".join([t async for t in llm.stream(req.messages, req.temperature)])
return ChatResponse(request_id=rid, content=content)
async def events() -> AsyncIterator[str]:
try:
async for token in llm.stream(req.messages, req.temperature):
if await request.is_disconnected(): # 客户端断开就停止生成,节省模型费用
log.info("客户端已断开,停止生成 request_id=%s", rid)
return
yield f"data: {json.dumps({'delta': token}, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
except Exception:
log.exception("生成失败 request_id=%s", rid)
# 响应头已经发出,不能再改状态码,只能在流里发送错误事件
yield f"event: error\ndata: {json.dumps({'message': '生成失败', 'request_id': rid}, ensure_ascii=False)}\n\n"
return StreamingResponse(events(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
@app.get("/boom")
async def boom():
raise RuntimeError("数据库连接串 postgres://secret@...") # 演示:内部细节不会泄露给调用方
if __name__ == "__main__":
from fastapi.testclient import TestClient
with TestClient(app) as c:
r = c.post("/v1/chat", headers={"X-API-Key": "demo-key"},
json={"messages": [{"role": "user", "content": "年假怎么算"}], "stream": False})
print(r.status_code, r.json())
@@ -0,0 +1,90 @@
# requires: fastapi, httpx
import json
from collections.abc import AsyncIterator
from fastapi.testclient import TestClient
from app import Message, app, get_llm
AUTH = {"X-API-Key": "demo-key"}
BODY = {"messages": [{"role": "user", "content": "你好"}]}
class ScriptedLLM:
"""测试替身:按脚本返回 token,可以模拟中途失败。"""
def __init__(self, tokens: list[str], fail_at: int | None = None) -> None:
self.tokens, self.fail_at = tokens, fail_at
self.calls: list[list[Message]] = []
async def stream(self, messages: list[Message], temperature: float) -> AsyncIterator[str]:
self.calls.append(messages)
for i, t in enumerate(self.tokens):
if i == self.fail_at:
raise RuntimeError("上游模型 500")
yield t
def client_with(llm) -> TestClient:
app.dependency_overrides[get_llm] = lambda: llm
return TestClient(app, raise_server_exceptions=False)
def parse_sse(text: str) -> list[tuple[str, str]]:
events = []
for block in text.strip().split("\n\n"):
name, data = "message", ""
for line in block.splitlines():
if line.startswith("event: "):
name = line[7:]
elif line.startswith("data: "):
data = line[6:]
events.append((name, data))
return events
def teardown_function():
app.dependency_overrides.clear()
def test_auth_required():
r = client_with(ScriptedLLM(["x"])).post("/v1/chat", json=BODY)
assert r.status_code == 401 and r.json()["code"] == "HTTP_401" and r.json()["request_id"]
def test_validation_error_format():
r = client_with(ScriptedLLM(["x"])).post("/v1/chat", headers=AUTH,
json={"messages": [{"role": "robot", "content": ""}], "temperature": 5})
assert r.status_code == 422
fields = {d["field"] for d in r.json()["details"]}
assert {"messages.0.role", "messages.0.content", "temperature"} <= fields
def test_non_stream():
llm = ScriptedLLM(["你", "好"])
r = client_with(llm).post("/v1/chat", headers={**AUTH, "X-Request-ID": "rid-1"}, json={**BODY, "stream": False})
assert r.status_code == 200
assert r.json() == {"request_id": "rid-1", "content": "你好"}
assert r.headers["X-Request-ID"] == "rid-1"
assert llm.calls[0][0].content == "你好"
def test_stream_sse():
r = client_with(ScriptedLLM(["A", "B", "C"])).post("/v1/chat", headers=AUTH, json=BODY)
assert r.status_code == 200 and r.headers["content-type"].startswith("text/event-stream")
events = parse_sse(r.text)
assert [json.loads(d)["delta"] for n, d in events[:-1]] == ["A", "B", "C"]
assert events[-1] == ("message", "[DONE]")
def test_stream_error_event():
r = client_with(ScriptedLLM(["A", "B"], fail_at=1)).post("/v1/chat", headers=AUTH, json=BODY)
events = parse_sse(r.text)
assert events[0][0] == "message" and events[-1][0] == "error"
assert json.loads(events[-1][1])["message"] == "生成失败"
def test_internal_error_hides_details():
r = client_with(ScriptedLLM([])).get("/boom")
assert r.status_code == 500
assert r.json()["message"] == "服务器内部错误" and "postgres" not in r.text
+30 -3
View File
@@ -4,12 +4,39 @@ title: FastAPI
type: index
domain: python
stack: [fastapi]
tags: []
tags: [FastAPI, Web 框架, pydantic, SSE]
week: [4, 5, 12]
---
# FastAPI
路由与校验、依赖注入、中间件与鉴权、流式输出、测试。
Python AI 服务最常用的 Web 框架:基于类型标注自动做参数校验和生成 OpenAPI 文档,原生支持 async 和流式响应。你熟悉的 Express / Koa、以及本计划里的 Gin,概念都能对应上:路由 ↔ 路由,中间件 ↔ 中间件,`Depends` ↔ 依赖注入(Gin 里通常手动传)。
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
## 学习路线
| 顺序 | 课程 | 级别 | 预计 |
| --- | --- | --- | --- |
| 1 | [路由和参数校验:路径 / 查询 / 请求体、pydantic 模型、响应模型](01-routing-validation.md) | 基础 | 2 小时 |
| 2 | [依赖注入、鉴权、中间件和统一错误处理](02-deps-auth-errors.md) | 进阶 | 3 小时 |
| 3 | [流式输出(SSE)、生命周期、测试和部署](03-streaming-testing.md) | 进阶 | 3 小时 |
## 企业场景
- [大模型对话 API:鉴权、统一错误、SSE 流式、客户端断开即停止](scenarios/streaming-chat-api.md)
## 面试题
- [FastAPI 面试题](interview.md)
## 示例代码
- [examples/chat-api](examples/chat-api/):完整的流式对话 API(API Key 鉴权、请求 ID、统一错误格式、SSE、依赖替换)+ 6 个测试;模型客户端是假实现,不需要 API Key
## 在 16 周计划中
- [第 4 周:FastAPI 基础](../../../plan/week04-python.md)、[第 5 周:RAG 服务](../../../plan/week05-rag-1.md)、[第 12 周:Agent 服务化](../../../plan/week12-agent-m3-persistence.md)
## 官方资料
- FastAPI 文档(有中文版):https://fastapi.tiangolo.com/zh/
- pydantic v2:https://docs.pydantic.dev/latest/
+59
View File
@@ -0,0 +1,59 @@
---
id: python.fastapi.interview
title: FastAPI 面试题
type: interview
domain: python
stack: [fastapi]
tags: [FastAPI, pydantic, 依赖注入, 面试]
level: intermediate
related: [python.fastapi.routing-validation, python.fastapi.deps-auth-errors, python.fastapi.streaming-testing]
week: [4, 5, 16]
---
# FastAPI 面试题
## 基础
### 【基础】FastAPI 有什么特点?为什么 AI 服务常用它?
**参考答案**:基于 Starlette(ASGI)和 pydantic,原生支持 async,适合 IO 密集的模型调用;通过类型标注自动完成参数解析和校验、生成 OpenAPI 文档;依赖注入方便组织鉴权、数据库会话和测试替换;支持流式响应和 WebSocket。AI 生态(模型 SDK、LangChain、向量库客户端)以 Python 为主,用 FastAPI 封装服务很自然。
### 【基础】FastAPI 怎么区分路径参数、查询参数和请求体?
**参考答案**:在路径模板中出现的参数是路径参数;简单类型(int、str、bool 等)的其他参数是查询参数;pydantic 模型类型的参数是请求体;请求头、Cookie 用 `Header()`、`Cookie()` 显式声明。也可以用 `Path()`、`Query()`、`Body()` 显式指定并添加校验规则。
### 【基础】路由函数用 `async def` 和 `def` 有什么区别?
**参考答案**:`async def` 在事件循环中直接执行,里面只能调用非阻塞的异步代码;普通 `def` 会被放到线程池中执行,适合调用同步阻塞库。在 `async def` 中调用阻塞函数会卡住整个事件循环,是最常见的性能问题。
### 【基础】响应模型有什么作用?
**参考答案**:声明接口返回的数据结构:自动过滤掉未声明的字段(防止泄露内部字段),校验和序列化返回数据,并体现在 OpenAPI 文档中,前端可以据此生成类型。
## 进阶
### 【进阶】`Depends` 的原理和常见用途?
**参考答案**:FastAPI 在处理请求时解析路由函数签名,递归解析依赖树,按顺序调用依赖函数并把返回值注入参数;同一请求内相同依赖默认只调用一次;带 `yield` 的依赖可以在请求结束后执行清理。用途:鉴权和获取当前用户 / 租户、数据库会话、分页参数、共享客户端;测试时通过 `dependency_overrides` 替换。
### 【进阶】中间件和依赖都能做鉴权,怎么选?
**参考答案**:中间件作用于所有请求,适合与业务无关的横切逻辑:请求 ID、日志、耗时、CORS。依赖按路由或路由器声明,能拿到类型化的结果(当前用户)并注入处理函数,能在测试中替换,适合鉴权、权限、租户。通常鉴权用依赖。
### 【进阶】怎么实现大模型的流式输出?要注意什么?
**参考答案**:用 `StreamingResponse` 包装异步生成器,`media_type="text/event-stream"`,每个事件 `data: ...\n\n`。注意:响应头发出后不能改状态码,错误要以事件形式发送;检查客户端断开及时停止生成;内容用 JSON 编码避免换行破坏格式;经过 Nginx 时关闭缓冲;前端 POST 请求需要用 fetch 读取流。
## 场景题
### 【场景题】压测发现 FastAPI 服务 QPS 很低,CPU 也不高,可能是什么原因?
**参考答案**:最常见的是在 `async def` 路由里调用了阻塞操作(同步数据库驱动、`requests`、`time.sleep`、同步 SDK),事件循环被阻塞。其他原因:每个请求新建 HTTP 客户端或数据库连接、连接池太小、下游(模型、数据库)本身慢、只启动了一个 worker。排查:看慢请求的耗时分布,用 py-spy 看事件循环线程在做什么。
### 【场景题】一个接口依赖大模型和向量数据库,怎么写单元测试?
**参考答案**:把模型客户端和检索器抽象成接口(Protocol),通过依赖注入获取;测试中用 `app.dependency_overrides` 替换为假实现,返回固定内容或模拟异常;用 `TestClient` 调用接口,断言状态码、响应格式、流式事件顺序、错误处理。真实模型的效果评估另外做(评测集),不放在单元测试里。
### 【场景题】给多个业务系统提供 AI 能力,怎么防止某个系统把额度用光?
**参考答案**:每个调用方一个 API Key,鉴权依赖解析出租户;在服务端按租户做限流(Redis 滑动窗口 / 令牌桶)和配额(按日 / 月的 token 数);记录每次调用的 token 用量写入数据库;超过配额返回 429 并给出提示;对异常增长告警。Nginx 层可以再加一层按 IP 的粗粒度限流。
@@ -0,0 +1,112 @@
---
id: python.fastapi.scenario.streaming-chat-api
title: 大模型对话 API:鉴权、统一错误、SSE 流式、客户端断开即停止
type: scenario
domain: python
stack: [fastapi, asyncio, nginx]
tags: [对话 API, SSE, 鉴权, 错误处理, 可测试性]
level: intermediate
prerequisites: [python.fastapi.streaming-testing]
est_hours: 2
related: [python.fastapi.interview, devops.nginx.scenario.ai-gateway-sse-canary]
week: [5, 12]
---
# 大模型对话 API:鉴权、统一错误、SSE 流式、客户端断开即停止
## 业务背景与问题
公司要把大模型能力封装成内部统一的对话服务,给客服系统、OA、知识库等多个业务调用,而不是每个业务各自直连模型服务商。要求:
- 各业务系统用自己的 API Key 调用,按租户统计用量;
- 支持流式(打字机效果)和非流式两种模式;
- 错误格式统一,调用方能程序化处理;
- 出了问题能用请求 ID 串起调用方和服务端的日志;
- 未来可以切换模型服务商而不影响调用方。
## 约束
- 第一版只有两三个人开发,要求简单可靠。
- 模型调用按 token 计费,用户关闭页面后不应继续生成。
- 模型 API Key 只保存在这个服务里,不下发给业务系统。
## 方案设计
```mermaid
sequenceDiagram
participant B as 业务系统
participant N as Nginx
participant A as 对话服务(FastAPI)
participant L as 模型服务商
B->>N: POST /v1/chat(X-API-Key, X-Request-ID)
N->>A: 转发(关闭缓冲)
A->>A: 依赖:校验 API Key → 租户;pydantic 校验参数
A->>L: 流式请求
loop 每个 token
L-->>A: delta
A-->>B: data: {"delta": ...}
A->>A: 客户端断开?→ 停止
end
A-->>B: data: [DONE](或 event: error)
```
1. **分层**:路由层(校验、鉴权、响应格式)→ 服务层(组装提示词、计量)→ 模型客户端(`LLMClient` 协议,可替换服务商)。
2. **鉴权**:`current_tenant` 依赖,API Key → 租户;后续计量、限流都按租户。
3. **统一错误**:`HTTPException`、`RequestValidationError`、兜底异常都转成 `{code, message, request_id}`;兜底异常只记日志不返回细节。
4. **请求 ID**:中间件读取或生成 `X-Request-ID`,写进响应头和日志。
5. **流式**:SSE;中途出错发送 `event: error`;客户端断开立即停止。
6. **可测试**:模型客户端通过依赖注入,测试中替换为脚本化的假实现。
## 关键代码
完整代码和测试见 [examples/chat-api](../examples/chat-api/):
```python
@app.post("/v1/chat", response_model=None)
async def chat(req: ChatRequest, request: Request,
tenant: Annotated[str, Depends(current_tenant)],
llm: Annotated[LLMClient, Depends(get_llm)]):
rid = request.state.request_id
if not req.stream:
content = "".join([t async for t in llm.stream(req.messages, req.temperature)])
return ChatResponse(request_id=rid, content=content)
async def events():
try:
async for token in llm.stream(req.messages, req.temperature):
if await request.is_disconnected(): # 用户走了,停止生成
return
yield f"data: {json.dumps({'delta': token}, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
except Exception:
log.exception("生成失败 request_id=%s", rid)
yield f"event: error\ndata: {json.dumps({'message': '生成失败', 'request_id': rid}, ensure_ascii=False)}\n\n"
return StreamingResponse(events(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
```
测试覆盖:未带 API Key 返回 401 统一格式;参数错误返回 422 并列出 `messages.0.role` 等字段;非流式返回完整内容并透传请求 ID;流式事件顺序正确并以 `[DONE]` 结束;模型中途失败时发送 `error` 事件;内部异常不泄露连接串。
## 取舍
| 选择 | 优点 | 缺点 |
| --- | --- | --- |
| **SSE(本方案)** | 基于 HTTP,简单,代理和网关支持好 | 单向;`EventSource` 不支持 POST,前端要用 fetch 解析 |
| WebSocket | 双向,适合语音、实时协作 | 连接管理和负载均衡更复杂 |
| 统一对话服务(本方案) | 密钥集中、可计量、可切换服务商 | 多一跳;需要保证高可用 |
| 业务直连模型服务商 | 少一跳 | 密钥分散、无法统一计量和治理 |
| 直接使用 LangChain 等框架封装 | 功能多 | 对一个转发 + 治理的服务来说偏重,版本变化快 |
## 易错点
- 流式中途异常想改状态码:响应头已发出,只能发错误事件。
- 不检查断开:页面关了模型还在生成。
- 统一错误处理器漏掉 `RequestValidationError`:422 的格式和其他错误不一致。
- 兜底异常返回 `str(e)`:可能包含连接串、内部地址。
- Nginx 没关缓冲:流式变整段(见 [AI 应用网关场景](../../../devops/nginx/scenarios/ai-gateway-sse-canary.md))。
- 只在内存里计量:多 worker / 多实例数据不准,要写数据库或 Redis。
## 面试怎么讲
> “我做了一个内部统一的对话服务,FastAPI 实现。鉴权用依赖注入把 API Key 映射到租户,便于计量和限流;所有错误转成统一的 code/message/request_id 格式,内部异常只记日志;流式用 SSE,中途出错发 error 事件,客户端断开就停止生成以节省 token;模型客户端抽象成协议,测试里用假实现替换,覆盖了鉴权、校验、流式和异常场景。”