AI AGENT 工程师 · 站 01 PYTHON 速成 · 场景全景

一个 FastAPI 请求的一生

值班助手的第一个接口:一条 curl 进来,一个线程同时等 5 台机器的 GC 日志,1 秒后把汇总还回去。这张全景回答:这个请求在 Python 服务里走过哪 10 站,每一站对应这一站要学的哪个知识点。

所在:AI 地图 站 01 · Python 速成整条链路在本机真跑过 实测

场景:20:15,订单服务变慢了

谁
值班的你,手边是刚写好的值班助手(一个 FastAPI 小服务)。
什么时候
2026-11-08 20:15,订单服务 order 的 P99 从 80 ms 涨到 1.2 s,你怀疑是 GC。
做了什么
curl 'http://localhost:8000/gc/summary?service=order&minutes=10'
期望
1 秒左右拿到 5 台机器(order-01~05)最近 10 分钟的 GC 停顿:次数、最长、平均。哪台没拉到要写清楚,整个接口不能 500,也不能卡住。

实测 200,1.01 s。4 台各 60 次停顿,最长 240~256 ms,平均 31~36 ms;order-04 的日志代理卡住没回,标成 "timeout 1s",其余 4 台照常返回。

5 台机器是本机一个假日志代理按路径模拟的,GC 日志按 JDK 统一日志格式生成。

这个接口的代码

实测用的就是这段(省略了 import 和计时)。左边的号是这一行在第几站。

8HOSTS = [f"order-0{i}" for i in range(1, 6)]8PAUSE = re.compile(r"Pause .* (\d+\.\d+)ms$") 4class HostGc(BaseModel):    host: str    pauses: int = 0    max_ms: float | None = None    avg_ms: float | None = None    error: str | None = None 8def parse(host: str, text: str) -> HostGc:8    ms = [float(m.group(1)) for line in text.splitlines() if (m := PAUSE.search(line))]    return HostGc(host=host, pauses=len(ms), max_ms=max(ms),                  avg_ms=round(sum(ms) / len(ms), 1)) 6async def fetch(client, host: str, minutes: int) -> HostGc:    try:7        async with asyncio.timeout(1.0):7            r = await client.get(f"{AGENT}/{host}/gc.log", params={"minutes": minutes})    except TimeoutError:        return HostGc(host=host, error="timeout 1s")    return parse(host, r.text) 3@app.get("/gc/summary", response_model=GcSummary)4async def gc_summary(service: str, minutes: int = Query(10, ge=1, le=60)):5    async with httpx.AsyncClient() as client:6        hosts = await asyncio.gather(*(fetch(client, h, minutes) for h in HOSTS))9    return GcSummary(service=service, minutes=minutes, hosts=hosts, elapsed_ms=...)

全景图:十站

① uv run uvicorn main:app 按 uv.lock 核对 .venv(Python 3.13.18 + 29 个包),缺的装上,再在这个环境里起 uvicorn 进程 起进程 uvicorn 进程 · 1 个线程 · 1 个事件循环(uvloop) 日志代理 :9100 值班的你 curl order-01 order-02 order-03 order-04卡住不回 order-05 HTTP字节 ② uvicorn字节→scope ③ 路由找到端点函数 ④ 参数校验"10"→10,1~60 ⑤ 端点协程async def 开跑 ⑥ gather开出 5 个 Task scope 端点函数 str, int 协程 5×GET 5 个 Task 挂起 4 份日志 04:1 s 超时 ⑦ 事件循环 + epoll一个线程同时等 5 个 socket ⑧ 解析日志推导式 + 正则,算最长 ⑨ 响应模型GcSummary→JSON 日志文本 HostGc×5 200JSON 重走 ②~⑨ ⑩ pytest:同一条路,在测试里再走一遍 TestClient 不开端口,按 ASGI 直接调 app;monkeypatch 把 fetch 换成假的,让 order-04 固定“超时”
工程工具:uv、pytest Web 层:uvicorn、FastAPI 路由 类型和 Pydantic asyncio Python 语法 出事的地方
实线是往前送的东西,虚线是回来的东西。②到⑨全在一个进程、一个线程里;第⑥站把 5 个请求同时发出去,第⑦站让这一个线程同时等 5 个回复。点任意一站,跳到下面的分步讲解。
gather:5 台同时等 总耗时 ≈ 最慢的那台 order-01120 ms order-0280 ms order-03150 ms order-04卡住,1 s 到点被取消 order-0590 ms 实测 1009 ms for 循环里逐个 await:一台等完再等下一台 总耗时 ≈ 全部相加 order-01 order-02 order-03 order-041 s 超时 order-05 实测 1460 ms 0 250 500 750 1000 1250 1500 ms
时间几乎全花在第⑦站的“等”上。各台延迟是假日志代理里设定的(120 / 80 / 150 / 卡住 / 90 ms),两条竖线是本机实测的接口内计时(3 次 1009~1010 ms,串行 2 次 1460~1463 ms)。curl 量到的总耗时比接口内计时多约 1.5 ms,这就是第②③④⑨站加上本机网络的全部开销。

一步一步走

1uv:备好环境,起进程

敲下 uv run uvicorn main:app --port 8000:uv 先按 uv.lock 核对项目里的 .venv(Python 3.13.18,fastapi、uvicorn、httpx 等 29 个包),缺什么装什么,再在这个环境里起 uvicorn。冷缓存下 uv add 装完全部依赖用了约 7 秒 实测。

为什么要它不同项目要不同版本的 Python 和包。没有隔离的环境,给这个项目装一个包,就可能弄坏另一个项目。

Java 里≈ Maven 管依赖,再加 SDKMAN 管 JDK 版本。uv.lock 把整棵依赖树的版本钉死,Maven 默认没有这个文件。

往下一层uv 管环境和依赖(对照 Maven)

常出的事用系统 Python 直接 pip install,换台机器版本就对不上;uv.lock 没提交,同事装出来的依赖和你不一样。

2uvicorn:把字节变成一次 ASGI 调用

事件循环(装了 uvloop 就用 uvloop)接下 TCP 连接,httptools 把 GET /gc/summary?service=order&minutes=10 HTTP/1.1 这些字节解析成一个 scope 字典,然后调用 app(scope, receive, send)。

为什么要它Web 框架只需要认一种调用约定(ASGI),服务器和框架就可以各自替换。

Java 里uvicorn ≈ Tomcat 的 Connector,ASGI ≈ Servlet 规范,app(scope, receive, send) ≈ service(req, resp)。差别在于 Tomcat 一个请求占一个线程,uvicorn 所有请求共用一个线程。

往下一层FastAPI 写接口(对照 Spring MVC) · 补给:从输入 URL 到页面展示(TCP 和 HTTP 字节那几段)

常出的事生产只起 1 个 uvicorn 进程:一个进程只有一个事件循环线程,只吃得满一个 CPU 核;要用多核得开多个 worker 进程。

3路由:找到被 @app.get 登记过的函数

请求先穿过中间件,再由 Starlette 的 Router 按路径和方法逐条匹配,命中 /gc/summary。这条路由是 import main.py 时登记的:@app.get(...) 这个装饰器把函数记进路由表,再把函数原样返回。

为什么要它路由表在启动时就建好,请求来了只需要查表。

Java 里≈ DispatcherServlet 加 HandlerMapping;@app.get 看着像 @GetMapping,但 Spring 的注解要靠启动时扫描才生效,Python 装饰器在 import 时就作为普通函数调用执行了。

往下一层函数是一等公民:闭包和装饰器 · 补给:Spring MVC 请求处理流程

常出的事路由按登记顺序匹配:/gc/{host} 写在 /gc/summary 前面,summary 会被当成主机名,请求被错的函数接走。

4参数校验:类型标注就是校验规则

FastAPI 读函数签名 service: str, minutes: int = Query(10, ge=1, le=60),交给 Pydantic:查询串里的字符串 "10" 转成整数 10,再检查是否在 1~60 之间。传 minutes=0 时 1 ms 内返回 422,消息是 Input should be greater than or equal to 1,端点函数根本没被调用 实测。

为什么要它Python 运行时本来不检查类型标注。FastAPI 借标注生成校验规则和接口文档,把坏参数挡在门外。

Java 里≈ @RequestParam 加 @Min(1) @Max(60) 加 @Validated;Pydantic 模型 ≈ DTO 加 Bean Validation。

往下一层类型标注和 Pydantic 数据模型

常出的事以为写了类型标注 Python 就会检查。普通函数 def f(x: int) 传进字符串照样能跑,只有经过 Pydantic 的地方才会校验。

5端点协程:async def 在事件循环上跑

FastAPI 调用 gc_summary(...)。它是 async def,调用后先得到一个协程对象,由事件循环推着往下执行,一直跑到第一个 await。如果写成普通 def,FastAPI 会把它放进线程池里跑,免得卡住事件循环(fastapi/routing.py 里的 run_in_threadpool)。

为什么要它只有协程能在等待时把线程让出来,一个线程同时服务很多请求就靠这一点。

Java 里协程有点像虚拟线程:都能在等待时暂停、之后从原地接着跑。不同的是虚拟线程在阻塞调用处自动暂停,协程只在你写了 await 的地方暂停。补给:JDK 8、17、21、25(虚拟线程)

往下一层asyncio:事件循环、async/await、并发请求

常出的事在 async def 里调用阻塞函数,比如 time.sleep、requests.get、同步数据库驱动。一个请求里 time.sleep(1),同时来的 /healthz 从 1 ms 变成 0.8 s,整个进程都被卡住 实测。

6gather:一次开出 5 个 Task

asyncio.gather(*(fetch(client, h, 10) for h in HOSTS)) 把 5 个协程包成 5 个 Task 交给事件循环。每个 Task 跑到 await client.get(...),把请求发出去就挂起。5 个请求几乎同时出门。

为什么要它5 台机器各要等几十到几百毫秒。一台一台等,时间是相加的;同时等,只取决于最慢的那台。

Java 里≈ 往线程池提交 5 个任务,再 CompletableFuture.allOf(...).join()。区别是这里没有 5 个线程,只有 5 个挂起的协程。补给:ThreadPoolExecutor.execute 源码

往下一层asyncio:事件循环、async/await、并发请求

常出的事写成 [await fetch(...) for h in HOSTS]:看着是异步,实际还是一台一台等。串行 1.46 s,gather 1.01 s 实测。

7事件循环:一个线程同时等 5 个 socket

5 个 Task 都挂起后,事件循环把 5 个 socket 交给 epoll,自己睡下。80 ms 时 order-02 先回来,循环唤醒对应的 Task 接着往下跑;01、03、05 陆续回来。order-04 一直不回,1 s 到点,asyncio.timeout(1.0) 取消那个 Task,fetch 捕获 TimeoutError,记成 "timeout 1s"。

为什么要它等网络时不占线程,一个线程就能同时等成千上万个连接。

Java 里和 Redis、Netty 的单线程事件循环是同一个模型:epoll 告诉你哪个 socket 好了,就处理哪个。补给:网络 IO 模型、Redis 线程模型与 IO 多路复用

往下一层asyncio:事件循环、async/await、并发请求

常出的事只要有一个 Task 抛出异常,gather 就把这个异常抛给你,另外 4 台的结果也拿不到。所以要像这里一样在 fetch 里自己兜住,或者给 gather 传 return_exceptions=True。

8解析日志:推导式加正则,算最长和平均

每台的日志有 120 行,正则从中挑出 60 行 Pause … 23.456ms,列表推导式取出毫秒数,再用 max() 和 sum() / len() 算出最长和平均。order-01 是 60 次停顿,最长 256.2 ms,平均 33.3 ms。

为什么要它原始日志人看不过来。值班时要的是 5 台机器放在一起、一眼能比的几个数。

Java 里列表推导式 ≈ stream().filter().map().collect(),但一行就写完。练习素材:G1 和 ZGC 怎么回收里的停顿日志。

往下一层语法对照:列表、字典、推导式、切片

常出的事这一步是纯 CPU 计算,跑在事件循环那唯一的线程上。日志有几百 MB 时,解析期间其他请求全部排队,和第 5 站是同一个雷。另外,日志为空时 max([]) 直接抛 ValueError,接口返回 500。

9响应模型:Pydantic 对象变成 JSON 字节

端点返回一个 GcSummary(里面是 5 个 HostGc),FastAPI 按 response_model 校验、过滤字段,序列化成 JSON,uvicorn 写回 socket。接口内计时 1009 ms,curl 量到 1010.5 ms 实测。

为什么要它返回的结构有明确定义,多一个字段、少一个字段都在这里拦住;同一个模型还会生成接口文档。

Java 里≈ @ResponseBody 加 Jackson 序列化 DTO。

往下一层类型标注和 Pydantic 数据模型 · FastAPI 写接口(对照 Spring MVC)

常出的事不写 response_model、直接返回字典或数据库对象,内部字段(密码、内网 IP)可能原样漏出去。

10pytest:同一条路,在测试里再走一遍

TestClient(main.app) 不开端口,直接按 ASGI 调用 app,走的是第 2~9 站同样的路由、校验和序列化。monkeypatch.setattr(main, "fetch", fake_fetch) 把真实的拉日志换成假的,让 order-04 固定“超时”。2 个用例 0.39 s 跑完 实测。

为什么要它不可能每次跑测试都有 5 台真机器在线。把网络 IO 换掉,剩下的逻辑才测得稳定。

Java 里≈ JUnit 加 MockMvc 加 Mockito。monkeypatch 能直接换掉模块里的函数,因为在 Python 里函数就是一个普通的值。

往下一层pytest 写测试 · 函数是一等公民:闭包和装饰器

常出的事monkeypatch 换的是 main.fetch。如果别的模块用 from main import fetch 早早拿到了原函数,那边调用的还是真函数,替换不生效。

知识点地图

知识点名照抄学习地图。下钻按依赖排:被别人用到的先学。

站知识点状态下钻顺序和理由
1uv 管环境和依赖(对照 Maven) 会用有页1后面每一页的实验都要先 uv run
8语法对照:列表、字典、推导式、切片 会用有页2先能读写代码,再谈机制
3、10函数是一等公民:闭包和装饰器 懂机制有页3看懂 @app.get 和 monkeypatch 都靠它
4、9类型标注和 Pydantic 数据模型 懂机制有页4进出两头的数据格式都由它定
5、6、7asyncio:事件循环、async/await、并发请求 懂设计有页5这一站最重的一个,时间几乎全花在这三站
2、3、4、9FastAPI 写接口(对照 Spring MVC) 会用有页6把前四个串成一个服务
10pytest 写测试 会用有页7接口写完再测
—用 AI 编程工具干活:Claude Code、Cursor 会用有页8从第 1 个起就用,站末总结

这张全景没走到的

  • 用 AI 编程工具干活:它是写这段代码的方式,不是请求经过的一站。从下钻第 1 个知识点起,实验代码都和 Claude Code 结对写,站末再做一页总结。

补给站:Java 板块里能直接借来的

回到全景

复述练习

  1. 合上页面,画出 10 站,按顺序写出部件名:uv、uvicorn、……、pytest。
  2. 每站用一句话讲它做了什么,说出流过箭头的是什么(字节、scope、协程、Task、日志文本、JSON)。
  3. 挑 3 站往下讲两层。比如第 7 站:事件循环怎么知道哪个 socket 好了?超时到点时,那个 Task 里发生了什么?

也可以直接讲给我听,说“回到全景”,我对照这张图帮你查漏。

动手:值班助手这一站

照着上面的代码写出 GC 日志接口,全程和 Claude Code 结对。写通以后,自己埋三颗雷,再用数字把它们找出来:

  1. 第 5 站:在端点里加一句 time.sleep(1),同时打 /healthz,看延迟变多少。
  2. 第 6 站:把 gather 换成 for 循环里的 await,看总耗时从多少变到多少。
  3. 第 7 站:去掉 asyncio.timeout,让 order-04 永远不回,看接口怎么死。

预测题

1. 把 minutes 传成 100,请求会走到第几站?用户看到什么?

只走到第 4 站。le=60 不通过,直接返回 422,端点函数不会被调用,5 台机器一个请求都不会收到。

从第 4 站推出:校验在调用端点之前。

2. 去掉 asyncio.timeout(1.0),order-04 永远不回。curl 多久返回?返回什么?

大约 5 秒后返回 500。httpx 默认超时是 5 秒,到点抛 httpx.ReadTimeout。它不是内置的 TimeoutError,fetch 里的 except TimeoutError 接不住它,gather 把异常抛出来,另外 4 台的结果也一起丢了。

从第 7 站推出:超时要自己设,异常要在每个 Task 里兜住。httpx 默认 5 秒和异常的继承关系是在本机 httpx 0.28.1 里查的。

3. 第 8 站换成解析 500 MB 日志,要算 3 秒。这 3 秒里别人调 /healthz 会怎样?

要等到解析完才返回,最多等 3 秒。解析是纯 CPU 计算,占着事件循环那唯一的线程,第 7 站没机会处理别的 socket。把解析放进 asyncio.to_thread,事件循环就不会被整段卡住;真要多核并行算几份大日志,得用进程池,因为有 GIL。

从第 5、7、8 站推出:一个线程,谁占着它谁说了算。

4. 大促时 50 个人同时调这个接口,uvicorn 会开 50 个线程吗?

不会。50 个请求 × 5 台 = 250 个 Task,全都挂在同一个线程上等 socket。这时先撑不住的是日志代理(同时收到 250 个请求)和第 8 站的 CPU,而不是线程数。

从第 2、6、7 站推出:并发数靠协程,不靠线程。

下一圈