阶段3(上):Python/asyncio/FastAPI 专题的课程、企业场景、面试题和可运行示例
This commit is contained in:
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
|
||||
@@ -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/
|
||||
@@ -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;模型客户端抽象成协议,测试里用假实现替换,覆盖了鉴权、校验、流式和异常场景。”
|
||||
Reference in new issue
Block a user