阶段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

+616 -24
View File
@@ -425,42 +425,48 @@
"domain": "python",
"title": "asyncio 异步编程",
"index": "topics/python/asyncio/index.md",
"lessons": 0,
"scenarios": 0,
"lessons": 2,
"scenarios": 1,
"interview_questions": {
"基础": 0,
"进阶": 0,
"场景题": 0
"基础": 4,
"进阶": 3,
"场景题": 3
},
"examples": []
"examples": [
"topics/python/asyncio/examples/fanout"
]
},
{
"stack": "fastapi",
"domain": "python",
"title": "FastAPI",
"index": "topics/python/fastapi/index.md",
"lessons": 0,
"scenarios": 0,
"lessons": 3,
"scenarios": 1,
"interview_questions": {
"基础": 0,
"进阶": 0,
"场景题": 0
"基础": 4,
"进阶": 3,
"场景题": 3
},
"examples": []
"examples": [
"topics/python/fastapi/examples/chat-api"
]
},
{
"stack": "python",
"domain": "python",
"title": "Python 语言",
"index": "topics/python/python/index.md",
"lessons": 0,
"scenarios": 0,
"lessons": 3,
"scenarios": 1,
"interview_questions": {
"基础": 0,
"进阶": 0,
"场景题": 0
"基础": 5,
"进阶": 4,
"场景题": 3
},
"examples": []
"examples": [
"topics/python/python/examples/reconcile"
]
}
],
"items": [
@@ -3777,6 +3783,84 @@
"第 7 天:开始投递,建立复盘循环"
]
},
{
"id": "python.asyncio.concurrency-control",
"title": "并发控制:gather / TaskGroup、Semaphore、超时、取消、阻塞调用",
"type": "lesson",
"domain": "python",
"stack": [
"asyncio"
],
"tags": [
"TaskGroup",
"Semaphore",
"timeout",
"取消",
"to_thread",
"背压"
],
"level": "intermediate",
"prerequisites": [
"python.asyncio.coroutines-tasks"
],
"related": [
"python.asyncio.scenario.concurrent-llm-calls",
"python.asyncio.interview",
"backend.go.errors-concurrency"
],
"est_hours": 3,
"week": [
4,
5,
12
],
"order": 2,
"path": "topics/python/asyncio/02-concurrency-control.md",
"sections": [
"解释说明",
"案例",
"代码示例",
"易错点",
"面试常问"
]
},
{
"id": "python.asyncio.coroutines-tasks",
"title": "协程、任务和事件循环:和 JS Promise 对照",
"type": "lesson",
"domain": "python",
"stack": [
"asyncio"
],
"tags": [
"协程",
"事件循环",
"Task",
"await",
"async for"
],
"level": "basic",
"prerequisites": [
"python.python.syntax-for-js"
],
"related": [
"python.asyncio.concurrency-control",
"python.asyncio.interview"
],
"est_hours": 2,
"week": [
4
],
"order": 1,
"path": "topics/python/asyncio/01-coroutines-tasks.md",
"sections": [
"解释说明",
"案例",
"代码示例",
"易错点",
"面试常问"
]
},
{
"id": "python.asyncio.index",
"title": "asyncio 异步编程",
@@ -3785,7 +3869,12 @@
"stack": [
"asyncio"
],
"tags": [],
"tags": [
"asyncio",
"协程",
"并发",
"超时"
],
"level": null,
"prerequisites": [],
"related": [],
@@ -3797,7 +3886,134 @@
],
"order": null,
"path": "topics/python/asyncio/index.md",
"sections": []
"sections": [
"学习路线",
"企业场景",
"面试题",
"示例代码",
"在 16 周计划中",
"官方资料"
]
},
{
"id": "python.asyncio.interview",
"title": "asyncio 面试题",
"type": "interview",
"domain": "python",
"stack": [
"asyncio"
],
"tags": [
"asyncio",
"协程",
"并发",
"面试"
],
"level": "intermediate",
"prerequisites": [],
"related": [
"python.asyncio.coroutines-tasks",
"python.asyncio.concurrency-control"
],
"est_hours": null,
"week": [
4,
16
],
"order": null,
"path": "topics/python/asyncio/interview.md",
"sections": [
"基础",
"进阶",
"场景题"
],
"questions": {
"基础": 4,
"进阶": 3,
"场景题": 3
}
},
{
"id": "python.asyncio.scenario.concurrent-llm-calls",
"title": "并发调用多个模型和外部接口:限并发、超时、重试、部分失败",
"type": "scenario",
"domain": "python",
"stack": [
"asyncio",
"fastapi"
],
"tags": [
"并发",
"大模型调用",
"超时",
"重试",
"限流",
"部分失败"
],
"level": "intermediate",
"prerequisites": [
"python.asyncio.concurrency-control"
],
"related": [
"python.asyncio.interview",
"backend.go.scenario.batch-third-party-calls"
],
"est_hours": 2,
"week": [
5,
12
],
"order": null,
"path": "topics/python/asyncio/scenarios/concurrent-llm-calls.md",
"sections": [
"业务背景与问题",
"约束",
"方案设计",
"关键代码",
"取舍",
"易错点",
"面试怎么讲"
]
},
{
"id": "python.fastapi.deps-auth-errors",
"title": "依赖注入、鉴权、中间件和统一错误处理",
"type": "lesson",
"domain": "python",
"stack": [
"fastapi"
],
"tags": [
"Depends",
"鉴权",
"JWT",
"中间件",
"异常处理器",
"多租户"
],
"level": "intermediate",
"prerequisites": [
"python.fastapi.routing-validation"
],
"related": [
"python.fastapi.streaming-testing",
"backend.gin.middleware-auth",
"python.fastapi.interview"
],
"est_hours": 3,
"week": [
4,
5
],
"order": 2,
"path": "topics/python/fastapi/02-deps-auth-errors.md",
"sections": [
"解释说明",
"案例",
"代码示例",
"易错点",
"面试常问"
]
},
{
"id": "python.fastapi.index",
@@ -3807,7 +4023,12 @@
"stack": [
"fastapi"
],
"tags": [],
"tags": [
"FastAPI",
"Web 框架",
"pydantic",
"SSE"
],
"level": null,
"prerequisites": [],
"related": [],
@@ -3819,7 +4040,174 @@
],
"order": null,
"path": "topics/python/fastapi/index.md",
"sections": []
"sections": [
"学习路线",
"企业场景",
"面试题",
"示例代码",
"在 16 周计划中",
"官方资料"
]
},
{
"id": "python.fastapi.interview",
"title": "FastAPI 面试题",
"type": "interview",
"domain": "python",
"stack": [
"fastapi"
],
"tags": [
"FastAPI",
"pydantic",
"依赖注入",
"面试"
],
"level": "intermediate",
"prerequisites": [],
"related": [
"python.fastapi.routing-validation",
"python.fastapi.deps-auth-errors",
"python.fastapi.streaming-testing"
],
"est_hours": null,
"week": [
4,
5,
16
],
"order": null,
"path": "topics/python/fastapi/interview.md",
"sections": [
"基础",
"进阶",
"场景题"
],
"questions": {
"基础": 4,
"进阶": 3,
"场景题": 3
}
},
{
"id": "python.fastapi.routing-validation",
"title": "路由和参数校验:路径 / 查询 / 请求体、pydantic 模型、响应模型",
"type": "lesson",
"domain": "python",
"stack": [
"fastapi"
],
"tags": [
"路由",
"pydantic",
"参数校验",
"响应模型",
"OpenAPI"
],
"level": "basic",
"prerequisites": [
"python.python.typing-errors-testing"
],
"related": [
"python.fastapi.deps-auth-errors",
"backend.gin.routing-binding",
"python.fastapi.interview"
],
"est_hours": 2,
"week": [
4
],
"order": 1,
"path": "topics/python/fastapi/01-routing-validation.md",
"sections": [
"解释说明",
"案例",
"代码示例",
"易错点",
"面试常问"
]
},
{
"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"
],
"related": [
"python.fastapi.interview",
"devops.nginx.scenario.ai-gateway-sse-canary"
],
"est_hours": 2,
"week": [
5,
12
],
"order": null,
"path": "topics/python/fastapi/scenarios/streaming-chat-api.md",
"sections": [
"业务背景与问题",
"约束",
"方案设计",
"关键代码",
"取舍",
"易错点",
"面试怎么讲"
]
},
{
"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"
],
"related": [
"python.fastapi.scenario.streaming-chat-api",
"devops.nginx.https-stream-limit",
"python.fastapi.interview"
],
"est_hours": 3,
"week": [
5,
12
],
"order": 3,
"path": "topics/python/fastapi/03-streaming-testing.md",
"sections": [
"解释说明",
"案例",
"代码示例",
"易错点",
"面试常问"
]
},
{
"id": "python.python.index",
@@ -3829,7 +4217,12 @@
"stack": [
"python"
],
"tags": [],
"tags": [
"Python",
"语法",
"类型标注",
"pytest"
],
"level": null,
"prerequisites": [],
"related": [],
@@ -3839,7 +4232,206 @@
],
"order": null,
"path": "topics/python/python/index.md",
"sections": []
"sections": [
"学习路线",
"企业场景",
"面试题",
"示例代码",
"在 16 周计划中",
"官方资料"
]
},
{
"id": "python.python.interview",
"title": "Python 面试题",
"type": "interview",
"domain": "python",
"stack": [
"python"
],
"tags": [
"Python",
"基础",
"面试"
],
"level": "basic",
"prerequisites": [],
"related": [
"python.python.syntax-for-js",
"python.python.typing-errors-testing",
"python.python.project-packaging"
],
"est_hours": null,
"week": [
4,
16
],
"order": null,
"path": "topics/python/python/interview.md",
"sections": [
"基础",
"进阶",
"场景题"
],
"questions": {
"基础": 5,
"进阶": 4,
"场景题": 3
}
},
{
"id": "python.python.project-packaging",
"title": "工程化:虚拟环境、uv / pip、项目结构、导入、日志和配置",
"type": "lesson",
"domain": "python",
"stack": [
"python"
],
"tags": [
"虚拟环境",
"uv",
"pip",
"pyproject.toml",
"导入",
"logging",
"配置"
],
"level": "basic",
"prerequisites": [
"python.python.syntax-for-js"
],
"related": [
"python.fastapi.routing-validation",
"python.python.interview"
],
"est_hours": 2,
"week": [
4
],
"order": 3,
"path": "topics/python/python/03-project-packaging.md",
"sections": [
"解释说明",
"案例",
"代码示例",
"易错点",
"面试常问"
]
},
{
"id": "python.python.scenario.order-reconciliation",
"title": "订单对账脚本:从“能跑的脚本”到可测试、可维护的工具",
"type": "scenario",
"domain": "python",
"stack": [
"python"
],
"tags": [
"对账",
"CSV",
"Decimal",
"数据处理",
"测试"
],
"level": "basic",
"prerequisites": [
"python.python.typing-errors-testing"
],
"related": [
"python.python.interview",
"backend.mq.reliability"
],
"est_hours": 2,
"week": [
4
],
"order": null,
"path": "topics/python/python/scenarios/order-reconciliation.md",
"sections": [
"业务背景与问题",
"约束",
"方案设计",
"关键代码",
"取舍",
"易错点",
"面试怎么讲"
]
},
{
"id": "python.python.syntax-for-js",
"title": "给 JS 开发者的 Python:语法、数据结构、推导式、函数、类",
"type": "lesson",
"domain": "python",
"stack": [
"python"
],
"tags": [
"语法",
"list",
"dict",
"推导式",
"解包",
"类"
],
"level": "basic",
"prerequisites": [],
"related": [
"python.python.typing-errors-testing",
"python.python.interview"
],
"est_hours": 3,
"week": [
4
],
"order": 1,
"path": "topics/python/python/01-syntax-for-js.md",
"sections": [
"解释说明",
"案例",
"代码示例",
"易错点",
"面试常问"
]
},
{
"id": "python.python.typing-errors-testing",
"title": "类型标注、dataclass、异常和上下文管理器、pytest",
"type": "lesson",
"domain": "python",
"stack": [
"python"
],
"tags": [
"类型标注",
"dataclass",
"Protocol",
"异常",
"with",
"生成器",
"pytest"
],
"level": "basic",
"prerequisites": [
"python.python.syntax-for-js"
],
"related": [
"python.python.scenario.order-reconciliation",
"python.asyncio.coroutines-tasks",
"python.python.interview"
],
"est_hours": 3,
"week": [
4
],
"order": 2,
"path": "topics/python/python/02-typing-errors-testing.md",
"sections": [
"解释说明",
"案例",
"代码示例",
"易错点",
"面试常问"
]
},
{
"id": "scenarios.index",
@@ -0,0 +1,112 @@
---
id: python.asyncio.coroutines-tasks
title: 协程、任务和事件循环:和 JS Promise 对照
type: lesson
domain: python
stack: [asyncio]
tags: [协程, 事件循环, Task, await, async for]
level: basic
prerequisites: [python.python.syntax-for-js]
est_hours: 2
related: [python.asyncio.concurrency-control, python.asyncio.interview]
week: [4]
order: 1
---
# 协程、任务和事件循环:和 JS Promise 对照
## 解释说明
| JavaScript | Python asyncio | 区别 |
| --- | --- | --- |
| 事件循环由运行时自动提供 | `asyncio.run(main())` 显式启动 | Python 脚本默认是同步的 |
| `async function f()` | `async def f():` | |
| 调用 async 函数**立即开始执行**,返回 Promise | 调用 `f()` 只得到**协程对象,不会执行** | 必须 `await` 或包装成任务 |
| `Promise.all([...])` | `await asyncio.gather(...)` 或 `TaskGroup` | |
| `Promise.race` | `asyncio.wait(..., return_when=FIRST_COMPLETED)` | |
| `setTimeout` 包装的延时 | `await asyncio.sleep(1)` | **不要用 `time.sleep`**,会阻塞整个循环 |
| 后台执行不等待 | `asyncio.create_task(coro)` | 要保存任务引用,否则可能被垃圾回收 |
| `for await (const x of stream)` | `async for x in stream:` | 流式读取模型输出常用 |
| `AbortController` | `task.cancel()`、`asyncio.timeout()` | |
**核心概念**
- **协程(coroutine)**:`async def` 定义的函数调用后得到的对象,在 `await` 处可以暂停,把控制权交还给事件循环。
- **任务(Task)**:被事件循环调度执行的协程。`create_task` 之后,它会在当前协程下一次 `await` 时开始运行。
- **事件循环**:单线程,不断地挑选“已经可以继续执行的任务”来运行。所以**任何一段不 await 的长时间计算或阻塞调用,都会让所有任务停下来**。
**适用场景**:IO 密集(网络请求、数据库、文件)。CPU 密集计算不会因为 asyncio 变快。
## 案例
RAG 接口需要同时做三件事:向量检索、关键词检索、查询用户权限。顺序执行约 300ms + 200ms + 100ms = 600ms;用 `gather` 并发执行,总耗时约等于最慢的那个,300ms。
## 代码示例
```python
import asyncio
import time
async def vector_search(q: str) -> list[str]:
await asyncio.sleep(0.3) # 模拟网络 IO
return [f"向量结果:{q}"]
async def keyword_search(q: str) -> list[str]:
await asyncio.sleep(0.2)
return [f"关键词结果:{q}"]
async def user_scopes(user_id: int) -> set[str]:
await asyncio.sleep(0.1)
return {"hr", "public"}
async def main() -> None:
# 1. 顺序执行:约 0.6 秒
start = time.perf_counter()
await vector_search("年假")
await keyword_search("年假")
await user_scopes(1)
print(f"顺序:{time.perf_counter() - start:.2f}s")
# 2. 并发执行:约 0.3 秒;结果顺序和参数顺序一致
start = time.perf_counter()
vec, kw, scopes = await asyncio.gather(vector_search("年假"), keyword_search("年假"), user_scopes(1))
print(f"并发:{time.perf_counter() - start:.2f}s", vec, kw, scopes)
# 3. 只调用不 await:协程不会执行,并且会有 "coroutine was never awaited" 警告
coro = vector_search("x")
print(type(coro).__name__) # coroutine
coro.close() # 演示用:手动关闭,避免警告
# 4. 后台任务:先启动,做别的事,再取结果
task = asyncio.create_task(vector_search("后台"))
await asyncio.sleep(0) # 让出一次控制权,任务开始执行
print("先做别的事")
print(await task)
# 5. 异步迭代:模拟模型流式输出
async def stream_tokens(text: str):
for ch in text:
await asyncio.sleep(0.01)
yield ch
print("".join([t async for t in stream_tokens("流式输出")]))
asyncio.run(main())
```
## 易错点
- **忘记 `await`**:拿到的是协程对象,函数根本没执行,常见表现是“结果是 `<coroutine object ...>`”。
- **在 async 函数里用 `time.sleep`、`requests.get`、同步数据库驱动**:整个服务卡住。用 `asyncio.sleep`、`httpx.AsyncClient`、异步驱动,或者 `asyncio.to_thread` 包装。
- **`create_task` 后不保存引用**:任务可能被垃圾回收而中途消失;也看不到它抛出的异常。
- **在已经运行的事件循环里再调用 `asyncio.run`**(比如在 Jupyter、FastAPI 的 async 路由里):会报错,直接 `await` 即可。
- **以为 asyncio 是多线程**:它是单线程的协作式调度,共享变量在两个 `await` 之间是安全的,但跨 `await` 仍可能被别的任务修改。
## 面试常问
见 [asyncio 面试题](interview.md):协程和线程的区别、事件循环原理、asyncio 适合什么场景、为什么不能在协程里调用阻塞函数。
@@ -0,0 +1,138 @@
---
id: python.asyncio.concurrency-control
title: 并发控制:gather / TaskGroup、Semaphore、超时、取消、阻塞调用
type: lesson
domain: python
stack: [asyncio]
tags: [TaskGroup, Semaphore, timeout, 取消, to_thread, 背压]
level: intermediate
prerequisites: [python.asyncio.coroutines-tasks]
est_hours: 3
related: [python.asyncio.scenario.concurrent-llm-calls, python.asyncio.interview, backend.go.errors-concurrency]
week: [4, 5, 12]
order: 2
---
# 并发控制:gather / TaskGroup、Semaphore、超时、取消、阻塞调用
## 解释说明
**等待一组任务的三种方式**
| 写法 | 一个失败时 | 适合 |
| --- | --- | --- |
| `gather(*coros)` | 第一个异常立即抛给调用方,**其他任务继续运行**(不会被取消) | 简单的并发 |
| `gather(*coros, return_exceptions=True)` | 异常作为结果返回,不抛出 | 允许部分失败 |
| `async with asyncio.TaskGroup() as tg:`(3.11+) | **取消其余任务**,异常合并为 `ExceptionGroup` 抛出 | 要么全部成功、要么全部取消 |
**限制并发**:`asyncio.Semaphore(n)`,`async with sem:` 包住真正的调用。没有限制时,一次性并发 1000 个请求会触发下游限流、耗尽连接池。(对照 Go:带缓冲的 channel 当信号量,或 `errgroup.SetLimit`。)
**超时**
- `async with asyncio.timeout(秒):`(3.11+)或 `await asyncio.wait_for(coro, 秒)`;超时抛 `TimeoutError`,内部的任务会被取消。
- 分清**单次调用超时**和**整体截止时间**(例如接口必须 10 秒内返回,内部每次模型调用最多 5 秒)。
**取消**
- `task.cancel()` 会在任务下一个 `await` 点抛出 `CancelledError`。
- 捕获 `CancelledError` 做清理后**必须重新抛出**,否则取消被吞掉,调用方以为任务还在正常运行。
- `except Exception` 不会捕获 `CancelledError`(它继承自 `BaseException`),这是有意设计的。
**阻塞调用**:必须用同步库(某些 SDK、文件解析、CPU 计算)时,`await asyncio.to_thread(func, 参数)` 放到线程池执行,不阻塞事件循环。CPU 密集且耗时长的用进程池。
**背压**:生产者比消费者快时,用有界的 `asyncio.Queue(maxsize=n)`,队列满时 `put` 会等待。
## 案例
批量为 2000 篇文档生成摘要:用 Semaphore 限制最多 8 个并发请求(符合模型服务商的速率限制);每次调用 30 秒超时;限流错误(429)指数退避重试;单篇失败记录下来,最后汇总重跑,而不是让整个批次失败。
## 代码示例
```python
import asyncio
async def fetch(i: int) -> int:
await asyncio.sleep(0.05 * (i % 3))
if i == 4:
raise ValueError(f"任务 {i} 失败")
return i * 10
async def main() -> None:
# 1. gather + return_exceptions:部分失败也拿到所有结果
results = await asyncio.gather(*(fetch(i) for i in range(6)), return_exceptions=True)
print([r if not isinstance(r, Exception) else f"错误:{r}" for r in results])
# 2. TaskGroup:一个失败,其余被取消,异常以 ExceptionGroup 抛出
try:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(fetch(i)) for i in range(6)]
except* ValueError as eg: # except* 处理 ExceptionGroup(3.11+)
print("TaskGroup 失败:", [str(e) for e in eg.exceptions])
print("被取消的任务数:", sum(t.cancelled() for t in tasks))
# 3. Semaphore 限制并发
sem = asyncio.Semaphore(2)
running = 0
async def limited(i: int) -> None:
nonlocal running
async with sem:
running += 1
assert running <= 2
await asyncio.sleep(0.01)
running -= 1
await asyncio.gather(*(limited(i) for i in range(10)))
print("Semaphore:同时运行的任务从未超过 2 个")
# 4. 超时
try:
async with asyncio.timeout(0.1):
await asyncio.sleep(1)
except TimeoutError:
print("超时")
# 5. 取消:清理后重新抛出
async def worker() -> None:
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
print("收到取消,释放资源")
raise # 必须重新抛出
t = asyncio.create_task(worker())
await asyncio.sleep(0.01)
t.cancel()
try:
await t
except asyncio.CancelledError:
print("任务已取消:", t.cancelled())
# 6. 同步阻塞函数放到线程里执行
def parse_pdf_sync(path: str) -> str:
import time
time.sleep(0.1) # 模拟同步库的阻塞调用
return f"{path} 的文本"
print(await asyncio.to_thread(parse_pdf_sync, "manual.pdf"))
asyncio.run(main())
```
把这些组合起来(并发上限 + 单次超时 + 重试 + 部分失败 + 整体截止时间)的完整实现和测试见 [examples/fanout](examples/fanout/)。
## 易错点
- **用 `gather` 以为失败时会取消其他任务**:不会,其他任务继续跑(可能继续消耗模型额度)。需要“一个失败全部取消”用 TaskGroup。
- **吞掉 `CancelledError`**:任务无法被取消,服务关闭时卡住。
- **没有并发上限**:一次性创建成千上万个请求,下游限流、连接池耗尽、内存上涨。
- **只设整体超时不设单次超时**(或反过来):一个慢请求占满全部时间,或者总时长失控。
- **重试不区分错误类型**:参数错误(400)重试多少次都一样失败,只重试超时、429、5xx。
- **在 `to_thread` 里跑 CPU 密集任务**:受 GIL 限制,没有并行效果,用进程池。
## 面试常问
见 [asyncio 面试题](interview.md):gather 和 TaskGroup 的区别、怎么限制并发、超时和取消如何实现、怎么在异步代码中调用同步库。
@@ -0,0 +1,100 @@
"""并发调用多个下游(例如大模型 / 检索 / 第三方 API):并发上限、单次超时、有限重试、部分失败、整体截止时间。
只用标准库。运行:python fanout.py;测试:python -m pytest -q test_fanout.py
"""
from __future__ import annotations
import asyncio
import random
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Generic, TypeVar
T = TypeVar("T")
class RetryableError(Exception):
"""可以重试的错误(限流 429、服务暂时不可用 503 等)。"""
@dataclass
class Result(Generic[T]):
key: str
value: T | None = None
error: str | None = None
attempts: int = 0
async def call_with_retry(fn: Callable[[], Awaitable[T]], *, timeout: float, retries: int,
base_delay: float = 0.05) -> tuple[T, int]:
"""单次调用带超时;遇到超时或可重试错误时指数退避重试。返回 (结果, 尝试次数)。"""
for attempt in range(1, retries + 2):
try:
async with asyncio.timeout(timeout): # Python 3.11+
return await fn(), attempt
except (TimeoutError, RetryableError):
if attempt > retries:
raise
await asyncio.sleep(base_delay * 2 ** (attempt - 1) * (1 + random.random() / 2))
raise AssertionError("不会执行到这里")
async def fan_out(jobs: dict[str, Callable[[], Awaitable[T]]], *, concurrency: int, timeout: float,
retries: int = 1, deadline: float | None = None) -> list[Result[T]]:
"""并发执行所有任务,最多 concurrency 个同时进行;单个失败不影响其他任务。
deadline:整体截止时间(秒),到时未完成的任务被取消并标记为超时。"""
sem = asyncio.Semaphore(concurrency)
results = {k: Result[T](key=k) for k in jobs}
async def run(key: str, fn: Callable[[], Awaitable[T]]) -> None:
async def counted() -> T: # 记录尝试次数(失败时也能看到试了几次)
results[key].attempts += 1
return await fn()
async with sem:
try:
results[key].value, _ = await call_with_retry(counted, timeout=timeout, retries=retries)
except asyncio.CancelledError:
results[key].error = "整体超时,已取消"
raise # 取消必须继续向上传播
except Exception as e: # 单个任务失败只记录,不影响其他任务
results[key].error = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
tasks = [asyncio.create_task(run(k, fn)) for k, fn in jobs.items()]
done, pending = await asyncio.wait(tasks, timeout=deadline)
for t in pending:
t.cancel()
await asyncio.gather(*pending, return_exceptions=True) # 等待取消完成,避免任务泄漏
return [results[k] for k in jobs]
# ---------------- 演示 ----------------
def fake_llm(name: str, delay: float, fail_times: int = 0) -> Callable[[], Awaitable[str]]:
state = {"left": fail_times}
async def call() -> str:
await asyncio.sleep(delay)
if state["left"] > 0:
state["left"] -= 1
raise RetryableError("429 Too Many Requests")
return f"{name} 的回答"
return call
async def demo() -> None:
jobs = {
"摘要": fake_llm("摘要", 0.10),
"分类": fake_llm("分类", 0.10, fail_times=1), # 第一次被限流,重试后成功
"翻译": fake_llm("翻译", 2.00), # 太慢,单次超时
"关键词": fake_llm("关键词", 0.05),
}
start = time.perf_counter()
for r in await fan_out(jobs, concurrency=2, timeout=0.5, retries=1):
print(f"{r.key}: {r.value or r.error}(尝试 {r.attempts} 次)")
print(f"总耗时 {time.perf_counter() - start:.2f}s")
if __name__ == "__main__":
asyncio.run(demo())
@@ -0,0 +1,60 @@
import asyncio
import time
from fanout import RetryableError, fake_llm, fan_out
def run(coro):
return asyncio.run(coro)
def test_concurrency_limit():
active, peak = 0, 0
def job():
async def call():
nonlocal active, peak
active += 1
peak = max(peak, active)
await asyncio.sleep(0.05)
active -= 1
return "ok"
return call
res = run(fan_out({str(i): job() for i in range(10)}, concurrency=3, timeout=1))
assert peak == 3 and all(r.value == "ok" for r in res)
def test_retry_then_success():
[r] = run(fan_out({"a": fake_llm("a", 0.01, fail_times=1)}, concurrency=1, timeout=1, retries=1))
assert r.value == "a 的回答" and r.attempts == 2
def test_partial_failure():
async def boom():
raise ValueError("参数错误") # 不可重试的错误:只尝试一次
res = run(fan_out({"ok": fake_llm("ok", 0.01), "bad": boom, "slow": fake_llm("slow", 1)},
concurrency=3, timeout=0.1, retries=1))
by = {r.key: r for r in res}
assert by["ok"].value == "ok 的回答"
assert by["bad"].error == "ValueError: 参数错误"
assert by["slow"].error == "TimeoutError"
def test_retry_exhausted():
[r] = run(fan_out({"x": fake_llm("x", 0.01, fail_times=5)}, concurrency=1, timeout=1, retries=2))
assert r.value is None and "RetryableError" in r.error
def test_overall_deadline_cancels():
start = time.perf_counter()
res = run(fan_out({"fast": fake_llm("fast", 0.01), "slow": fake_llm("slow", 5)},
concurrency=2, timeout=10, deadline=0.2))
assert time.perf_counter() - start < 1
by = {r.key: r for r in res}
assert by["fast"].value and by["slow"].error == "整体超时,已取消"
def test_retryable_error_is_exception():
assert issubclass(RetryableError, Exception)
+29 -3
View File
@@ -4,12 +4,38 @@ title: asyncio 异步编程
type: index
domain: python
stack: [asyncio]
tags: []
tags: [asyncio, 协程, 并发, 超时]
week: [4, 5, 12]
---
# asyncio 异步编程
协程、任务、并发控制、超时与取消。
AI 应用的大部分时间在“等”:等模型生成、等检索、等数据库。asyncio 让一个进程同时等很多件事。你熟悉 JS 的 `async/await` 和 Promise,概念几乎可以直接迁移;不同点在于 Python **需要显式运行事件循环**、**同步阻塞调用会卡住整个循环**、以及任务取消和超时的写法。
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
## 学习路线
| 顺序 | 课程 | 级别 | 预计 |
| --- | --- | --- | --- |
| 1 | [协程、任务和事件循环:和 JS Promise 对照](01-coroutines-tasks.md) | 基础 | 2 小时 |
| 2 | [并发控制:gather / TaskGroup、Semaphore、超时、取消、阻塞调用](02-concurrency-control.md) | 进阶 | 3 小时 |
## 企业场景
- [并发调用多个模型和外部接口:限并发、超时、重试、部分失败](scenarios/concurrent-llm-calls.md)
## 面试题
- [asyncio 面试题](interview.md)
## 示例代码
- [examples/fanout](examples/fanout/):并发上限、单次超时、指数退避重试、部分失败、整体截止时间取消,附 6 个测试,只用标准库
## 在 16 周计划中
- [第 4 周:Python 速成(asyncio 是重点)](../../../plan/week04-python.md)、[第 5 周:RAG 服务](../../../plan/week05-rag-1.md)、[第 12 周:Agent 持久化](../../../plan/week12-agent-m3-persistence.md)
## 官方资料
- asyncio 文档(中文):https://docs.python.org/zh-cn/3/library/asyncio.html
- 协程与任务:https://docs.python.org/zh-cn/3/library/asyncio-task.html
+59
View File
@@ -0,0 +1,59 @@
---
id: python.asyncio.interview
title: asyncio 面试题
type: interview
domain: python
stack: [asyncio]
tags: [asyncio, 协程, 并发, 面试]
level: intermediate
related: [python.asyncio.coroutines-tasks, python.asyncio.concurrency-control]
week: [4, 16]
---
# asyncio 面试题
## 基础
### 【基础】协程、线程、进程有什么区别?
**参考答案**:进程有独立的内存空间,切换和通信开销大,能利用多核;线程共享进程内存,由操作系统抢占式调度,在 CPython 中受 GIL 限制不能并行执行 Python 代码;协程是用户态的、协作式调度的执行单元,在 `await` 处主动让出控制权,切换开销极小,单线程即可同时处理大量 IO 等待。
### 【基础】asyncio 适合什么场景?不适合什么场景?
**参考答案**:适合 IO 密集、并发连接多的场景:调用大模型 API、HTTP 请求、数据库查询、WebSocket / SSE 长连接。不适合 CPU 密集计算(单线程,无法加速),这类任务用多进程或交给 C 扩展库。
### 【基础】调用 `async def` 函数但没有 `await` 会怎样?
**参考答案**:只会创建协程对象,函数体不会执行,Python 会给出 “coroutine was never awaited” 警告。这一点和 JS 不同——JS 的 async 函数调用后立即开始执行。
### 【基础】为什么在协程里不能使用 `time.sleep` 或 `requests`?
**参考答案**:事件循环是单线程的,阻塞调用会让整个线程停住,所有其他协程都无法运行,服务的并发能力退化为 1。应该用 `asyncio.sleep`、`httpx.AsyncClient` 等异步库;只能用同步库时用 `asyncio.to_thread` 放到线程池执行。
## 进阶
### 【进阶】`asyncio.gather` 和 `TaskGroup` 有什么区别?
**参考答案**:`gather` 在某个任务抛异常时立即把异常抛给调用方,但其余任务继续运行;加 `return_exceptions=True` 则把异常作为结果返回。`TaskGroup`(3.11+)是结构化并发:一个任务失败时取消组内其他任务,等它们全部结束后,以 `ExceptionGroup` 抛出所有异常,不会留下“孤儿任务”。
### 【进阶】怎么限制并发数?
**参考答案**:用 `asyncio.Semaphore(n)`,在每个任务的实际调用外层 `async with sem:`;或者用固定数量的 worker 从 `asyncio.Queue` 取任务。注意多个请求要共享同一个 Semaphore;多进程 / 多实例时需要 Redis 等全局限流。
### 【进阶】asyncio 的取消机制是怎样的?
**参考答案**:`task.cancel()` 请求取消,任务在下一个 `await` 点收到 `CancelledError`。任务可以捕获它做清理(关闭连接、记录状态),但应该重新抛出,否则取消失效。`asyncio.timeout` / `wait_for` 超时也是通过取消内部任务实现的。`CancelledError` 继承自 `BaseException`,所以 `except Exception` 不会误吞它。
## 场景题
### 【场景题】需要对 1 万条文本调用大模型生成摘要,服务商限制每分钟 600 次请求,怎么设计?
**参考答案**:用 Semaphore 或 worker 池限制并发,再加速率控制(令牌桶,每秒约 10 次);每次调用设超时;对 429、超时、5xx 做带抖动的指数退避重试,429 响应如果带 `Retry-After` 就按它等待;单条失败记录下来,最后汇总重跑;记录进度(已完成的 ID)以便中断后续跑;结果写入数据库而不是全放内存。规模更大时用消息队列分发给多个 worker。
### 【场景题】FastAPI 接口里调用了一个同步的 SDK,发现服务在压测时吞吐很低,为什么?怎么改?
**参考答案**:在 `async def` 路由里直接调用同步 SDK,阻塞了事件循环,所有请求排队。改法:换成 SDK 的异步版本;或者 `await asyncio.to_thread(sdk.call, ...)`;或者把路由定义为普通 `def`,FastAPI 会把它放到线程池执行。然后重新压测确认。
### 【场景题】一个异步任务在服务关闭时总是卡住,可能是什么原因?
**参考答案**:任务里捕获了 `CancelledError` 却没有重新抛出(或者用了裸 `except:`),导致取消无效;或者任务在执行不响应取消的阻塞调用(同步 IO、`to_thread` 中的线程无法被强制停止);或者有后台任务没有被追踪和取消。修复:正确传播取消、给阻塞调用设超时、在应用的生命周期关闭阶段统一取消并等待后台任务。
@@ -0,0 +1,111 @@
---
id: python.asyncio.scenario.concurrent-llm-calls
title: 并发调用多个模型和外部接口:限并发、超时、重试、部分失败
type: scenario
domain: python
stack: [asyncio, fastapi]
tags: [并发, 大模型调用, 超时, 重试, 限流, 部分失败]
level: intermediate
prerequisites: [python.asyncio.concurrency-control]
est_hours: 2
related: [python.asyncio.interview, backend.go.scenario.batch-third-party-calls]
week: [5, 12]
---
# 并发调用多个模型和外部接口:限并发、超时、重试、部分失败
## 业务背景与问题
客服工单系统里,每张新工单需要并行做四件事:摘要(大模型)、分类(大模型)、情绪识别(小模型服务)、查客户等级(CRM 接口)。第一版是顺序调用,单张工单处理要 8~15 秒;改成直接 `gather` 之后变快了,但遇到新问题:
- 早高峰一次进来几百张工单,同时发出上千个请求,模型服务商返回大量 429(限流);
- 某一个接口卡住时,整张工单一直没有结果;
- 任何一个调用报错,整个 `gather` 抛异常,其余已经成功的结果也丢了。
## 约束
- 模型服务商有速率限制(每分钟请求数和 token 数)。
- 工单处理结果允许部分缺失(例如情绪识别失败,不影响分配客服),但必须标明缺失项。
- 每张工单的处理总时长不超过 20 秒,超时的部分以后台任务补齐。
## 方案设计
```mermaid
flowchart LR
T[新工单] --> F[fan_out]
F -->|Semaphore 限并发| S[摘要 LLM]
F --> C[分类 LLM]
F --> E[情绪服务]
F --> R[CRM 接口]
S & C & E & R --> M[合并结果:成功的字段 + 失败原因]
M --> D[分配客服]
M -->|有失败项| Q[补偿队列:稍后重试]
```
1. **全局并发上限**:所有模型调用共享一个 `Semaphore`(按服务商限额设置),而不是每张工单各自限制。多进程 / 多实例部署时,进一步用 Redis 做全局限流。
2. **单次超时 + 整体截止时间**:每次调用 5 秒超时;每张工单整体 20 秒,到时取消未完成的调用。
3. **有选择地重试**:只对超时、429、5xx 做指数退避重试(加随机抖动),最多 1~2 次;参数错误不重试。
4. **部分失败**:每个子任务的结果单独记录成功值或错误原因,不让一个失败影响全部;失败项进入补偿队列。
5. **可观测**:记录每个子任务的耗时、重试次数、失败原因,用于调整并发和超时参数。
## 关键代码
完整实现和测试见 [examples/fanout](../examples/fanout/):
```python
async def fan_out(jobs, *, concurrency, timeout, retries=1, deadline=None):
sem = asyncio.Semaphore(concurrency)
results = {k: Result(key=k) for k in jobs}
async def run(key, fn):
async with sem:
try:
results[key].value, _ = await call_with_retry(fn, timeout=timeout, retries=retries)
except asyncio.CancelledError:
results[key].error = "整体超时,已取消"
raise # 取消要继续传播
except Exception as e: # 单个失败只记录
results[key].error = f"{type(e).__name__}: {e}"
tasks = [asyncio.create_task(run(k, fn)) for k, fn in jobs.items()]
done, pending = await asyncio.wait(tasks, timeout=deadline)
for t in pending:
t.cancel()
await asyncio.gather(*pending, return_exceptions=True)
return [results[k] for k in jobs]
```
演示输出(并发 2、单次超时 0.5 秒、重试 1 次):
```text
摘要: 摘要 的回答(尝试 1 次)
分类: 分类 的回答(尝试 2 次) ← 第一次 429,重试成功
翻译: TimeoutError(尝试 2 次) ← 太慢,两次都超时
关键词: 关键词 的回答(尝试 1 次)
```
## 取舍
| 方案 | 优点 | 缺点 |
| --- | --- | --- |
| 顺序调用 | 简单 | 慢 |
| 裸 `gather` | 快、代码少 | 无并发上限、一个失败全部失败 |
| **Semaphore + 超时 + 重试 + 部分失败(本方案)** | 可控、稳定 | 代码多一些,参数要调 |
| 消息队列 + 独立 worker | 削峰、可水平扩展、失败可重放 | 架构更复杂,结果是异步的 |
请求量继续增大时,把“工单 → 子任务”拆成队列消息,由 worker 池消费,Semaphore 换成 Redis 全局限流。
## 易错点
- 每个请求各自创建一个 Semaphore:等于没有限制。
- 重试不加抖动:所有任务在同一时刻重试,再次触发限流。
- `except Exception` 之后不区分错误类型就重试:参数错误被重试多次,浪费额度。
- 捕获 `CancelledError` 后不重新抛出:整体截止时间失效。
- 截止时间到了只是“不等了”,没有取消任务:后台还在消耗模型额度。
- 只记录“失败”不记录原因:无法判断是限流、超时还是参数问题。
## 面试怎么讲
> “工单要同时调用几个模型和接口。我用 asyncio 并发,但加了全局 Semaphore 控制并发数以符合服务商的限流;每次调用有超时,只对超时和 429 做带抖动的指数退避重试;每个子任务单独记录结果和错误,单个失败不影响其他,失败项进补偿队列;整体有截止时间,到时取消剩余调用。处理时间从顺序调用的 8 到 15 秒降到了约等于最慢的那个调用。”
注意:面试中提到的耗时数据要来自你自己项目的实际测量,不要编造。和 Go 版本的同类场景对照:[批量调用第三方接口(Go)](../../../backend/go/scenarios/batch-third-party-calls.md)。
@@ -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;模型客户端抽象成协议,测试里用假实现替换,覆盖了鉴权、校验、流式和异常场景。”
+132
View File
@@ -0,0 +1,132 @@
---
id: python.python.syntax-for-js
title: 给 JS 开发者的 Python:语法、数据结构、推导式、函数、类
type: lesson
domain: python
stack: [python]
tags: [语法, list, dict, 推导式, 解包, 类]
level: basic
est_hours: 3
related: [python.python.typing-errors-testing, python.python.interview]
week: [4]
order: 1
---
# 给 JS 开发者的 Python:语法、数据结构、推导式、函数、类
## 解释说明
| JavaScript / TypeScript | Python | 备注 |
| --- | --- | --- |
| `const` / `let` | 直接赋值 `x = 1` | 没有声明关键字;常量靠全大写约定 |
| `{}` 代码块 | 缩进(4 个空格) | 缩进就是语法 |
| `null` / `undefined` | `None` | 只有一个“空” |
| `true` / `false` | `True` / `False` | 首字母大写 |
| `&&` `||` `!` | `and` `or` `not` | |
| `===` | `==`(值相等)、`is`(同一对象) | 判断 None 用 `is None` |
| 模板字符串 `` `a${b}` `` | f-string `f"a{b}"` | |
| Array | `list` | `xs.append(1)`、`len(xs)`、切片 `xs[1:3]`、`xs[-1]` 取最后一个 |
| Object / Map | `dict` | `d["k"]`(不存在会抛 KeyError)、`d.get("k", 默认值)` |
| Set | `set` | `a & b` 交集、`a | b` 并集 |
| 不可变数组 | `tuple` | `(1, 2)`,常用于返回多个值 |
| `arr.map / filter` | 推导式 | `[x * 2 for x in xs if x > 0]` |
| 解构 `const [a, b] = arr` | 解包 `a, b = arr` | `first, *rest = xs` |
| 展开 `{...a, ...b}` | `{**a, **b}` 或 `a | b` | |
| 箭头函数 | `lambda x: x * 2` | 只能写一个表达式,复杂逻辑用 `def` |
| `try / catch` | `try / except` | |
| `for (const x of xs)` | `for x in xs:` | `for i, x in enumerate(xs)` 带下标 |
| `class` + `constructor` | `class` + `__init__` | 方法第一个参数是 `self` |
**真值判断**:空字符串、`0`、`None`、空列表 / 字典 / 集合都是假,和 JS 类似;但 Python 里 `[]` 是假,JS 里 `[]` 是真。
**函数参数**:位置参数、关键字参数 `f(name="a")`、默认值、`*args`(剩余位置参数)、`**kwargs`(剩余关键字参数)、`*` 之后的参数只能用关键字传。
## 案例
把 JS 里一段常见的数据处理“翻译”成 Python:按用户聚合订单金额并取前三。
```js
// JS
const totals = {};
for (const o of orders) totals[o.user] = (totals[o.user] ?? 0) + o.amount;
const top3 = Object.entries(totals).sort((a, b) => b[1] - a[1]).slice(0, 3);
```
## 代码示例
```python
from collections import Counter, defaultdict
orders = [
{"user": "alice", "amount": 30, "status": "paid"},
{"user": "bob", "amount": 50, "status": "paid"},
{"user": "alice", "amount": 20, "status": "refunded"},
{"user": "carol", "amount": 70, "status": "paid"},
{"user": "alice", "amount": 40, "status": "paid"},
]
# 推导式 = map + filter
paid = [o for o in orders if o["status"] == "paid"]
amounts = [o["amount"] for o in paid]
print(sum(amounts), max(amounts)) # 190 70
# defaultdict:不存在的 key 自动给默认值(类似 ?? 0)
totals: defaultdict[str, int] = defaultdict(int)
for o in paid:
totals[o["user"]] += o["amount"]
top3 = sorted(totals.items(), key=lambda kv: kv[1], reverse=True)[:3]
print(top3) # [('alice', 70), ('carol', 70), ('bob', 50)]
# Counter:计数专用
print(Counter(o["status"] for o in orders).most_common()) # [('paid', 4), ('refunded', 1)]
# 字典推导式、解包、合并
by_user = {u: t for u, t in totals.items() if t >= 60}
first, *rest = sorted(totals)
defaults = {"page": 1, "size": 20}
params = defaults | {"size": 50} # Python 3.9+,相当于 {...defaults, size: 50}
print(by_user, first, rest, params)
# 函数:默认值、关键字参数、只能用关键字传的参数
def paginate(items: list, *, page: int = 1, size: int = 20) -> list:
start = (page - 1) * size
return items[start:start + size]
print(paginate(list(range(100)), page=3, size=10)) # [20, ..., 29]
# 类
class Cart:
def __init__(self, owner: str) -> None:
self.owner = owner
self.items: list[tuple[str, int]] = []
def add(self, sku: str, qty: int = 1) -> "Cart":
self.items.append((sku, qty))
return self
def __len__(self) -> int: # 让 len(cart) 可用
return sum(q for _, q in self.items)
def __repr__(self) -> str: # 打印时的样子,类似 toString
return f"Cart({self.owner!r}, {len(self)} 件)"
print(Cart("alice").add("pen", 2).add("book")) # Cart('alice', 3 件)
```
## 易错点
- **可变默认参数**:`def f(xs=[])` 的默认列表只创建一次,多次调用共享。写成 `def f(xs=None): xs = xs or []`(或 `if xs is None`)。
- **`dict["k"]` 不存在会抛异常**,不像 JS 返回 `undefined`。用 `.get()`。
- **整数除法**:`7 / 2 == 3.5`,`7 // 2 == 3`。
- **浮点数算金额**:`0.1 + 0.2 != 0.3`,金额用 `Decimal` 或整数“分”。
- **`is` 和 `==` 混用**:比较值用 `==`,只有 `None`、`True`、`False` 用 `is`。
- **循环里修改正在遍历的列表**:结果出乎意料,遍历副本或生成新列表。
- **缩进混用 Tab 和空格**:直接报错。编辑器统一 4 个空格。
## 面试常问
见 [Python 面试题](interview.md):可变与不可变类型、可变默认参数、`is` 和 `==`、深拷贝和浅拷贝、推导式和生成器。
@@ -0,0 +1,147 @@
---
id: python.python.typing-errors-testing
title: 类型标注、dataclass、异常和上下文管理器、pytest
type: lesson
domain: python
stack: [python]
tags: [类型标注, dataclass, Protocol, 异常, with, 生成器, pytest]
level: basic
prerequisites: [python.python.syntax-for-js]
est_hours: 3
related: [python.python.scenario.order-reconciliation, python.asyncio.coroutines-tasks, python.python.interview]
week: [4]
order: 2
---
# 类型标注、dataclass、异常和上下文管理器、pytest
## 解释说明
**类型标注**:和 TypeScript 不同,Python 的类型标注**运行时不检查**,只给编辑器和类型检查器(mypy、pyright)用;但 pydantic、FastAPI 会在运行时读取标注做校验。
- 基本写法:`def f(name: str, tags: list[str] | None = None) -> dict[str, int]:`
- `Protocol`:结构化类型(鸭子类型),类似 TS 的 interface——只要有对应的方法就算实现了,不需要继承。
- `TypedDict`:给字典描述结构;`Literal["a", "b"]`:字面量联合类型。
**dataclass**:自动生成 `__init__`、`__repr__`、`__eq__` 的数据类,`frozen=True` 不可变,`slots=True` 省内存。需要运行时校验(接口入参、配置)时用 pydantic。
**异常**:`try / except 具体异常 / else(没出错时执行)/ finally(总会执行)`;自定义异常继承 `Exception`;`raise 新异常 from 原异常` 保留原因链。**不要裸写 `except:`**,至少写 `except Exception`,并且要记录或重新抛出。
**上下文管理器**:`with open(...) as f:` 离开代码块时自动关闭,类似 JS 的 `try/finally`。自己写用 `@contextmanager`。
**生成器**:函数里用 `yield`,按需逐个产生值,不一次性把所有数据读进内存——处理大文件、分页拉取数据时很有用。
**pytest**:文件名 `test_*.py`,函数名 `test_*`,直接用 `assert`;`pytest.raises` 断言异常;`@pytest.mark.parametrize` 表驱动;fixture(`tmp_path`、`monkeypatch`、`caplog` 等)提供测试依赖。
## 案例
对账脚本里:记录用 `@dataclass(frozen=True)`;数据来源(本地 CSV、渠道 API)定义成 `Protocol`,测试时传假实现;每一行解析错误抛自定义异常并带上行号;读取大文件用生成器逐行处理。完整代码见[订单对账场景](scenarios/order-reconciliation.md)。
## 代码示例
```python
import logging
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Protocol
import time
log = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class Order:
id: str
amount: Decimal
tags: tuple[str, ...] = () # 不可变默认值;可变的要用 field(default_factory=list)
@dataclass
class Batch:
name: str
orders: list[Order] = field(default_factory=list)
class OrderSource(Protocol): # 任何有 fetch 方法的类都满足这个协议
def fetch(self) -> Iterator[Order]: ...
class FakeSource:
def fetch(self) -> Iterator[Order]:
yield Order("A1", Decimal("9.90"))
yield Order("A2", Decimal("20.00"), ("vip",))
class ImportError_(Exception): # 自定义异常(避免和内置 ImportError 重名)
def __init__(self, line: int, reason: str) -> None:
super().__init__(f"第 {line} 行:{reason}")
self.line = line
def parse_amount(line: int, raw: str) -> Decimal:
try:
return Decimal(raw)
except ArithmeticError as e: # decimal.InvalidOperation 是 ArithmeticError 的子类
raise ImportError_(line, f"金额格式错误 {raw!r}") from e
@contextmanager
def timer(name: str) -> Iterator[None]:
start = time.perf_counter()
try:
yield
finally:
log.info("%s 耗时 %.1f ms", name, (time.perf_counter() - start) * 1000)
def total(source: OrderSource) -> Decimal:
with timer("汇总"):
return sum((o.amount for o in source.fetch()), Decimal("0"))
logging.basicConfig(level=logging.INFO)
print(total(FakeSource())) # 29.90
try:
parse_amount(3, "12,00")
except ImportError_ as e:
print(e, "| 原因:", type(e.__cause__).__name__)
```
对应的 pytest 测试:
```python
# 文件:test_orders.py(和上面的代码放在 orders.py 中)
from decimal import Decimal
import pytest
from orders import FakeSource, ImportError_, parse_amount, total
def test_total():
assert total(FakeSource()) == Decimal("29.90")
@pytest.mark.parametrize("raw, ok", [("1.5", True), ("abc", False), ("", False)])
def test_parse_amount(raw, ok):
if ok:
assert parse_amount(1, raw) == Decimal(raw)
else:
with pytest.raises(ImportError_, match="第 1 行"):
parse_amount(1, raw)
```
## 易错点
- **以为类型标注会在运行时报错**:`def f(x: int)` 传字符串照样运行。需要校验用 pydantic。
- **dataclass 用可变默认值**:`items: list = []` 会直接报错,用 `field(default_factory=list)`。
- **`except Exception: pass`**:错误被悄悄吞掉,排查时毫无线索。
- **`raise` 新异常时不加 `from`**:丢失原始原因(虽然 Python 会显示“During handling...”,但语义不清楚)。
- **测试依赖真实网络、真实时间**:不稳定。用 Protocol + 假实现、`monkeypatch` 替换。
- **生成器只能遍历一次**:第二次遍历是空的,需要多次使用就转成 list。
## 面试常问
见 [Python 面试题](interview.md):类型标注的作用、dataclass 和 pydantic 的区别、生成器和迭代器、上下文管理器原理、异常处理规范。
@@ -0,0 +1,114 @@
---
id: python.python.project-packaging
title: 工程化:虚拟环境、uv / pip、项目结构、导入、日志和配置
type: lesson
domain: python
stack: [python]
tags: [虚拟环境, uv, pip, pyproject.toml, 导入, logging, 配置]
level: basic
prerequisites: [python.python.syntax-for-js]
est_hours: 2
related: [python.fastapi.routing-validation, python.python.interview]
week: [4]
order: 3
---
# 工程化:虚拟环境、uv / pip、项目结构、导入、日志和配置
## 解释说明
| npm 生态 | Python 生态 |
| --- | --- |
| `package.json` | `pyproject.toml`(项目元数据和依赖) |
| `package-lock.json` | `uv.lock` / `poetry.lock`;老项目用 `requirements.txt` 锁版本 |
| `node_modules`(每个项目一份) | **虚拟环境** `.venv`(每个项目一份,需要“激活”或用工具代为执行) |
| `npm install` | `uv sync` / `pip install -r requirements.txt` |
| `npx` | `uv run` / `python -m 模块` |
| nvm | `uv python install 3.12` / pyenv |
**为什么一定要用虚拟环境**:不同项目依赖的版本不同,全局安装会互相覆盖,系统 Python 也可能被破坏。
**uv**:近年流行的包管理工具,速度快,集成了虚拟环境、依赖锁定和 Python 版本管理。团队已有 pip / poetry 也完全可以,原理相同。命令以官方文档为准:https://docs.astral.sh/uv/
**项目结构**(推荐 `src` 布局或简单的包目录):
```text
ai-service/
├── pyproject.toml
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI 入口
│ ├── config.py # 配置
│ └── rag/
│ ├── __init__.py
│ └── retriever.py
└── tests/
└── test_retriever.py
```
**导入**:用包的绝对导入 `from app.rag.retriever import Retriever`;从项目根目录用 `python -m app.main` 或 `pytest` 运行,避免“直接运行子目录里的文件导致找不到包”。
**日志**:用 `logging` 而不是 `print`;每个模块 `log = logging.getLogger(__name__)`;在入口统一配置格式和级别;生产环境输出 JSON 便于收集。
**配置**:从环境变量读取(十二要素原则),用 pydantic-settings 做类型校验和默认值;`.env` 只在本地使用、不提交。
## 案例
AI 服务项目:`uv init` 创建项目,`uv add fastapi uvicorn httpx pydantic-settings`,`uv add --dev pytest ruff`;CI 里 `uv sync --frozen` 严格按锁文件安装;配置类读取 `LLM_API_KEY`、`DATABASE_URL`,缺失时启动即失败,而不是运行到一半才报错。
## 代码示例
```bash
# 用 uv(推荐)
uv init ai-service && cd ai-service
uv add fastapi "uvicorn[standard]" httpx pydantic-settings
uv add --dev pytest ruff
uv run pytest -q
uv run ruff check .
# 用标准库 venv + pip(同样有效)
python3 -m venv .venv
source .venv/bin/activate # Windows:.venv\Scripts\activate
pip install fastapi uvicorn httpx pydantic-settings
pip freeze > requirements.txt
```
配置和日志(`app/config.py`):
```python
import logging
from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_")
database_url: str = "postgresql://app:app@localhost:5432/app"
llm_api_key: SecretStr = Field(default=SecretStr("")) # 打印时显示为 **********
llm_timeout_seconds: float = 30.0
log_level: str = "INFO"
def setup_logging(level: str) -> None:
logging.basicConfig(level=level, format="%(asctime)s %(levelname)s %(name)s %(message)s")
settings = Settings() # 读取环境变量 APP_DATABASE_URL 等
setup_logging(settings.log_level)
logging.getLogger(__name__).info("配置加载完成:%s", settings.model_dump(exclude={"llm_api_key"}))
```
## 易错点
- **不用虚拟环境,`sudo pip install`**:破坏系统 Python(很多 Linux 发行版已经禁止这样做)。
- **`requirements.txt` 不锁版本**:今天能装好,下个月依赖升级后跑不起来。
- **在子目录里直接 `python retriever.py`**:`from app...` 找不到包。从项目根目录用 `-m` 运行。
- **文件名和标准库或第三方库同名**(如 `random.py`、`fastapi.py`):导入的是你的文件,报奇怪的错误。
- **到处 `print` 调试并留在代码里**:用 logging 并按级别控制。
- **把密钥写进代码或日志**:用 `SecretStr`,日志里排除敏感字段。
## 面试常问
见 [Python 面试题](interview.md):虚拟环境的作用、依赖如何锁定、GIL 是什么。
@@ -0,0 +1,120 @@
"""订单对账:比较“我方订单”和“支付渠道账单”两份 CSV,输出差异。
运行:python reconcile.py(使用内置示例数据)或 python reconcile.py 我方.csv 渠道.csv
测试:python -m pytest -q test_reconcile.py
"""
from __future__ import annotations
import csv
import io
import logging
import sys
from collections.abc import Iterable, Iterator
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from enum import Enum
from pathlib import Path
log = logging.getLogger("reconcile")
class DiffKind(str, Enum):
MISSING_IN_CHANNEL = "渠道缺失" # 我方有、渠道没有:可能没支付成功或渠道漏单
MISSING_IN_OURS = "我方缺失" # 渠道有、我方没有:掉单(钱收了订单没更新)
AMOUNT_MISMATCH = "金额不一致"
@dataclass(frozen=True, slots=True)
class Record:
order_id: str
amount: Decimal # 金额一律用 Decimal,不用 float
@dataclass(frozen=True, slots=True)
class Diff:
kind: DiffKind
order_id: str
ours: Decimal | None
channel: Decimal | None
class BadRow(ValueError):
pass
def parse_rows(rows: Iterable[dict[str, str]], id_col: str, amount_col: str, cents: bool = False) -> Iterator[Record]:
for line_no, row in enumerate(rows, start=2): # 第 1 行是表头
try:
oid = row[id_col].strip()
raw = row[amount_col].strip()
amount = Decimal(raw) / 100 if cents else Decimal(raw)
except (KeyError, InvalidOperation) as e:
raise BadRow(f"第 {line_no} 行格式错误:{row}") from e
if not oid:
raise BadRow(f"第 {line_no} 行缺少订单号")
yield Record(oid, amount.quantize(Decimal("0.01")))
def to_map(records: Iterable[Record]) -> dict[str, Record]:
result: dict[str, Record] = {}
for r in records:
if r.order_id in result:
log.warning("订单号重复:%s,后出现的记录将被忽略", r.order_id)
continue
result[r.order_id] = r
return result
def reconcile(ours: Iterable[Record], channel: Iterable[Record]) -> list[Diff]:
a, b = to_map(ours), to_map(channel)
diffs: list[Diff] = []
for oid in sorted(a.keys() | b.keys()): # 集合并集,排序后输出稳定
x, y = a.get(oid), b.get(oid)
if y is None:
diffs.append(Diff(DiffKind.MISSING_IN_CHANNEL, oid, x.amount, None))
elif x is None:
diffs.append(Diff(DiffKind.MISSING_IN_OURS, oid, None, y.amount))
elif x.amount != y.amount:
diffs.append(Diff(DiffKind.AMOUNT_MISMATCH, oid, x.amount, y.amount))
return diffs
def write_report(diffs: list[Diff], out) -> None:
w = csv.writer(out)
w.writerow(["差异类型", "订单号", "我方金额", "渠道金额"])
for d in diffs:
w.writerow([d.kind.value, d.order_id, d.ours if d.ours is not None else "", d.channel if d.channel is not None else ""])
SAMPLE_OURS = """order_id,amount
A001,99.00
A002,19.90
A003,250.00
A004,10.00
"""
SAMPLE_CHANNEL = """out_trade_no,total_fee
A001,9900
A002,1999
A004,1000
A005,500
"""
def main(argv: list[str]) -> int:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
if len(argv) == 3:
ours_text = Path(argv[1]).read_text(encoding="utf-8-sig") # utf-8-sig:兼容 Excel 导出的 BOM
channel_text = Path(argv[2]).read_text(encoding="utf-8-sig")
else:
ours_text, channel_text = SAMPLE_OURS, SAMPLE_CHANNEL
ours = parse_rows(csv.DictReader(io.StringIO(ours_text)), "order_id", "amount")
channel = parse_rows(csv.DictReader(io.StringIO(channel_text)), "out_trade_no", "total_fee", cents=True)
diffs = reconcile(ours, channel)
write_report(diffs, sys.stdout)
log.info("共发现 %d 条差异", len(diffs))
return 1 if diffs else 0
if __name__ == "__main__":
code = main(sys.argv)
sys.exit(0 if len(sys.argv) != 3 else code) # 示例数据本来就有差异,演示模式下返回 0
@@ -0,0 +1,40 @@
import csv
import io
from decimal import Decimal
import pytest
from reconcile import SAMPLE_CHANNEL, SAMPLE_OURS, BadRow, DiffKind, Record, parse_rows, reconcile
def load(text: str, id_col: str, amount_col: str, cents: bool = False) -> list[Record]:
return list(parse_rows(csv.DictReader(io.StringIO(text)), id_col, amount_col, cents))
def test_sample():
diffs = reconcile(load(SAMPLE_OURS, "order_id", "amount"),
load(SAMPLE_CHANNEL, "out_trade_no", "total_fee", cents=True))
assert [(d.kind, d.order_id) for d in diffs] == [
(DiffKind.AMOUNT_MISMATCH, "A002"),
(DiffKind.MISSING_IN_CHANNEL, "A003"),
(DiffKind.MISSING_IN_OURS, "A005"),
]
assert diffs[0].ours == Decimal("19.90") and diffs[0].channel == Decimal("19.99")
def test_decimal_not_float():
# 0.1 + 0.2 用 float 不等于 0.3,用 Decimal 才精确
assert 0.1 + 0.2 != 0.3
assert Decimal("0.1") + Decimal("0.2") == Decimal("0.3")
@pytest.mark.parametrize("text", ["order_id,amount\nA1,abc\n", "order_id,amount\n,1.00\n", "id,amount\nA1,1\n"])
def test_bad_rows(text):
with pytest.raises(BadRow):
load(text, "order_id", "amount")
def test_duplicate_ids_logged(caplog):
recs = [Record("A1", Decimal("1.00")), Record("A1", Decimal("2.00"))]
assert reconcile(recs, [Record("A1", Decimal("1.00"))]) == []
assert "订单号重复" in caplog.text
+32 -3
View File
@@ -4,12 +4,41 @@ title: Python 语言
type: index
domain: python
stack: [python]
tags: []
tags: [Python, 语法, 类型标注, pytest]
week: [4]
---
# Python 语言
给 JS 开发者的 Python:语法、类型、异常、包管理、测试。
AI 应用 / Agent 岗位的主力语言。你已经会 JavaScript / TypeScript,学 Python 的重点不是从零学编程,而是**对照差异**:语法、数据结构、类型标注、异常、包管理和测试的“Python 写法”,以及一些和 JS 直觉相反的坑(可变默认参数、浮点金额、导入路径)。
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
## 学习路线
| 顺序 | 课程 | 级别 | 预计 |
| --- | --- | --- | --- |
| 1 | [给 JS 开发者的 Python:语法、数据结构、推导式、函数、类](01-syntax-for-js.md) | 基础 | 3 小时 |
| 2 | [类型标注、dataclass、异常和上下文管理器、pytest](02-typing-errors-testing.md) | 基础 | 3 小时 |
| 3 | [工程化:虚拟环境、uv / pip、项目结构、导入、日志和配置](03-project-packaging.md) | 基础 | 2 小时 |
## 企业场景
- [订单对账脚本:从“能跑的脚本”到可测试、可维护的工具](scenarios/order-reconciliation.md)
## 面试题
- [Python 面试题](interview.md)
## 示例代码
- [examples/reconcile](examples/reconcile/):CSV 对账(Decimal 金额、dataclass、生成器、日志、异常)+ pytest 测试,只用标准库
## 在 16 周计划中
- [第 4 周:Python 速成 + FastAPI 基础](../../../plan/week04-python.md)
## 官方资料
- Python 官方教程(中文):https://docs.python.org/zh-cn/3/tutorial/
- typing 模块:https://docs.python.org/zh-cn/3/library/typing.html
- pytest:https://docs.pytest.org/
- uv:https://docs.astral.sh/uv/
+93
View File
@@ -0,0 +1,93 @@
---
id: python.python.interview
title: Python 面试题
type: interview
domain: python
stack: [python]
tags: [Python, 基础, 面试]
level: basic
related: [python.python.syntax-for-js, python.python.typing-errors-testing, python.python.project-packaging]
week: [4, 16]
---
# Python 面试题
## 基础
### 【基础】Python 中哪些类型是可变的,哪些是不可变的?有什么影响?
**参考答案**:不可变:int、float、str、tuple、frozenset、bytes;可变:list、dict、set、自定义对象(默认)。影响:函数参数传的是对象引用,函数里修改可变对象会影响调用方;只有不可变(可哈希)的对象才能做 dict 的 key 和 set 的元素。
### 【基础】为什么不要用可变对象作为函数默认参数?
**参考答案**:默认值在函数定义时只创建一次,之后每次调用共享同一个对象。`def f(xs=[])` 多次调用会在同一个列表上累加。改为默认 `None`,在函数内部创建新列表。
### 【基础】`is` 和 `==` 的区别?
**参考答案**:`==` 比较值是否相等(调用 `__eq__`),`is` 比较是否是同一个对象(身份)。判断 `None` 用 `is None`;比较值永远用 `==`,不要依赖小整数缓存之类的实现细节。
### 【基础】浅拷贝和深拷贝的区别?
**参考答案**:浅拷贝(`list(x)`、`x.copy()`、`copy.copy`)只复制最外层容器,内部元素仍是同一引用;深拷贝(`copy.deepcopy`)递归复制所有层级。嵌套结构修改内层元素时,浅拷贝会影响原对象。
### 【基础】列表推导式和生成器表达式有什么区别?
**参考答案**:`[x for x in xs]` 立即生成完整列表,占用内存;`(x for x in xs)` 生成器表达式按需逐个产生,内存占用恒定,但只能遍历一次。传给 `sum`、`max` 等函数时用生成器表达式即可。
## 进阶
### 【进阶】什么是 GIL?对 AI 应用开发有什么影响?
**参考答案**:GIL(全局解释器锁)使 CPython 同一时刻只有一个线程执行 Python 字节码,所以多线程无法利用多核做 CPU 密集计算;但 IO 等待时会释放 GIL,所以多线程 / asyncio 对 IO 密集任务有效。AI 应用大多是 IO 密集(调用模型 API、查数据库、检索),用 asyncio 很合适;CPU 密集(本地向量计算、文本解析)用多进程,或者依赖 numpy 等在 C 层释放 GIL 的库。Python 3.13 起提供了实验性的无 GIL 构建,以官方文档为准。
### 【进阶】装饰器是什么?写一个统计耗时的装饰器。
**参考答案**:装饰器是接收函数并返回新函数的函数,用来在不修改原函数的情况下增加功能(日志、重试、缓存、鉴权)。
```python
import functools
import time
def timed(fn):
@functools.wraps(fn) # 保留原函数的名字和文档
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return fn(*args, **kwargs)
finally:
print(f"{fn.__name__} 耗时 {(time.perf_counter() - start) * 1000:.1f} ms")
return wrapper
@timed
def work(n: int) -> int:
return sum(range(n))
work(1_000_000)
```
**追问**:异步函数怎么写装饰器?→ wrapper 也要定义成 `async def` 并 `await fn(...)`。
### 【进阶】dataclass、pydantic、TypedDict 怎么选?
**参考答案**:内部数据结构用 dataclass(轻量、无运行时校验);外部输入(接口请求体、配置、大模型返回的 JSON)用 pydantic,它会在运行时校验和转换类型并给出清晰的错误;只是给已有的字典加类型提示(例如 JSON 数据直接传来传去)用 TypedDict。
### 【进阶】上下文管理器的原理是什么?
**参考答案**:实现了 `__enter__` 和 `__exit__` 方法的对象。`with` 进入时调用 `__enter__`,离开代码块(无论正常还是异常)时调用 `__exit__`,保证资源释放;`__exit__` 返回 True 会吞掉异常。也可以用 `contextlib.contextmanager` 装饰生成器函数实现,异步版本是 `async with` 和 `__aenter__` / `__aexit__`。
## 场景题
### 【场景题】要处理一个 10GB 的日志文件,统计每个接口的请求数,怎么写?
**参考答案**:不能一次读入内存。用 `with open(...)` 逐行迭代(文件对象本身就是惰性的),解析出接口路径后用 `Counter` 计数;正则预编译;如果需要更快,可以按字节范围切分文件用多进程并行统计后合并。输出前 N 个用 `most_common`。
### 【场景题】一段 Python 代码在本机能跑,在服务器上报 `ModuleNotFoundError`,怎么排查?
**参考答案**:确认服务器上是否激活了正确的虚拟环境(`which python`、`python -c "import sys; print(sys.executable)"`);依赖是否按锁文件安装;运行目录和方式是否不同(直接运行子目录的文件 vs 从根目录 `python -m`);是否有本地文件和包同名遮蔽了真正的包;Python 版本是否一致。
### 【场景题】你从前端转 Python,怎么保证写出来的 Python 代码质量?
**参考答案**:用 ruff 做 lint 和格式化,用 mypy / pyright 做类型检查,pytest 写测试并在 CI 中运行;遵循 PEP 8 和项目约定;数据边界用 pydantic 校验;对照 JS 的经验注意 Python 特有的坑(可变默认参数、浮点金额、导入路径)。可以举一个自己写过的、带测试的小工具为例。
@@ -0,0 +1,87 @@
---
id: python.python.scenario.order-reconciliation
title: 订单对账脚本:从“能跑的脚本”到可测试、可维护的工具
type: scenario
domain: python
stack: [python]
tags: [对账, CSV, Decimal, 数据处理, 测试]
level: basic
prerequisites: [python.python.typing-errors-testing]
est_hours: 2
related: [python.python.interview, backend.mq.reliability]
week: [4]
---
# 订单对账脚本:从“能跑的脚本”到可测试、可维护的工具
## 业务背景与问题
财务每天要核对“系统里的已支付订单”和“支付渠道的账单”。原来由一位同事手工在 Excel 里 VLOOKUP,经常漏看;后来有人写了一个 50 行的 Python 脚本,但:金额用 float 比较,出现“19.9 和 19.90 不一致”的误报;渠道账单金额单位是“分”,脚本没处理;文件里有一行格式错误整个脚本崩溃,不知道是哪一行;没人敢改。
## 约束
- 输入是两份 CSV(渠道账单可能是 Excel 导出的,带 BOM)。
- 输出差异清单给财务:渠道缺失(我方有渠道无)、我方缺失(掉单)、金额不一致。
- 每天运行一次,后续可能接入定时任务和告警。
- 先不引入 pandas 等重型依赖(服务器上只有标准 Python)。
## 方案设计
1. **数据模型**:`Record(order_id, amount: Decimal)`,用 `dataclass(frozen=True)`;金额统一转成两位小数的 `Decimal`,渠道的“分”在解析时除以 100。
2. **解析和比较分离**:`parse_rows`(生成器,逐行解析,错误带行号)→ `to_map`(处理重复订单号并告警)→ `reconcile`(纯函数,集合运算找差异)→ `write_report`(输出 CSV)。纯函数便于测试。
3. **输入兼容**:`utf-8-sig` 读取,兼容 Excel 的 BOM;列名作为参数传入,不同渠道只是配置不同。
4. **可观测**:logging 输出统计;有差异时返回非零退出码,方便定时任务据此告警。
5. **测试**:示例数据、错误格式、重复订单号、Decimal 精度都有用例。
## 关键代码
完整代码和测试见 [examples/reconcile](../examples/reconcile/):
```python
def reconcile(ours: Iterable[Record], channel: Iterable[Record]) -> list[Diff]:
a, b = to_map(ours), to_map(channel)
diffs: list[Diff] = []
for oid in sorted(a.keys() | b.keys()): # 两边订单号的并集,排序后输出稳定
x, y = a.get(oid), b.get(oid)
if y is None:
diffs.append(Diff(DiffKind.MISSING_IN_CHANNEL, oid, x.amount, None))
elif x is None:
diffs.append(Diff(DiffKind.MISSING_IN_OURS, oid, None, y.amount))
elif x.amount != y.amount:
diffs.append(Diff(DiffKind.AMOUNT_MISMATCH, oid, x.amount, y.amount))
return diffs
```
运行示例数据的输出:
```text
差异类型,订单号,我方金额,渠道金额
金额不一致,A002,19.90,19.99
渠道缺失,A003,250.00,
我方缺失,A005,,5.00
```
## 取舍
| 方案 | 优点 | 缺点 |
| --- | --- | --- |
| **标准库 csv + dict(本方案)** | 无依赖、逻辑清晰、易测试 | 数据量到几千万行时内存吃紧 |
| pandas | 一两行完成合并对比,适合分析 | 依赖重;类型和精度要小心(默认 float) |
| 数据库里对账(导入后 SQL FULL OUTER JOIN) | 数据量大也能处理 | 需要导入流程 |
| 实时对账(支付回调 + 定时查询渠道) | 发现问题更早 | 复杂度高 |
数据量变大后,可以把两份数据导入 PostgreSQL,用 `FULL OUTER JOIN` 找差异——思路完全一样。
## 易错点
- 用 float 比较金额:`19.9 != 19.90` 的问题其实来自字符串比较,`0.1 + 0.2 != 0.3` 来自 float,两者都要避免——统一转成 `Decimal` 并量化到两位小数。
- 忘了单位换算(元 / 分)。
- 一行坏数据导致整个任务失败且不知道是哪一行:错误信息带行号;也可以选择“记录坏行、继续处理、最后汇总”。
- 订单号重复时静默覆盖:要告警。
- 文件编码:Excel 导出的 CSV 可能带 BOM 或是 GBK 编码。
## 面试怎么讲
> “我把一个手工对账流程改成了 Python 工具:金额统一用 Decimal 并处理元和分的换算;解析用生成器逐行处理,错误带行号;比较逻辑是纯函数,用集合运算找出渠道缺失、我方缺失和金额不一致三类差异;有差异时返回非零退出码,接入定时任务告警。写了 pytest 覆盖精度、坏数据和重复订单号。”
前端转岗时,这类“数据处理 + 工程化”的小工具是证明 Python 能力的好素材,比只写语法练习更有说服力。