234 lines
14 KiB
Markdown
234 lines
14 KiB
Markdown
# 转岗计划 · 第 8 周学习计划(Python 速成:给 JS 开发者)
|
||
|
||
> 所属阶段:第 2 个月最后一周,为第 9~11 周的 Python 迷你多 Agent 项目做准备。总纲见同目录 `README.md`,项目计划见 `agent-mini-project.md`。
|
||
|
||
目标:一周内把写 Agent 服务需要的 Python 补齐:基础语法、类型标注、asyncio、pydantic、httpx、pytest、FastAPI 和 SSE。不追求全面,只学项目里马上会用到的。
|
||
|
||
学法:你有 7 年 JS 和一个月 Go 的底子,每个知识点都先想“JS / Go 里对应的是什么”,再看 Python 的写法。每天的练习文件第一段写一张小对照表。
|
||
|
||
本周只学 Python,不开 Agent 项目(戒律:同一周不同时开新框架和新功能)。
|
||
|
||
参考资料:
|
||
- Python 官方教程(中文):https://docs.python.org/zh-cn/3/tutorial/
|
||
- asyncio 官方文档(中文):https://docs.python.org/zh-cn/3/library/asyncio.html
|
||
- uv:https://docs.astral.sh/uv/
|
||
- pydantic:https://docs.pydantic.dev/
|
||
- FastAPI(中文):https://fastapi.tiangolo.com/zh/
|
||
- httpx:https://www.python-httpx.org/
|
||
|
||
---
|
||
|
||
## 最终项目结构
|
||
|
||
```
|
||
py-learning/
|
||
├── pyproject.toml # uv 管理依赖
|
||
├── .python-version
|
||
├── day1_basics.py
|
||
├── day2_typing.py
|
||
├── day3_errors_files.py
|
||
├── day4_async.py
|
||
├── day5_models.py
|
||
├── day6_http.py
|
||
├── fastapi_demo/
|
||
│ ├── __init__.py
|
||
│ ├── main.py # FastAPI 应用
|
||
│ └── static/
|
||
│ └── sse.html # 用 EventSource 订阅 SSE 的测试页
|
||
└── tests/
|
||
├── test_day2.py
|
||
├── test_day5.py
|
||
├── test_day6.py
|
||
└── test_fastapi_demo.py
|
||
```
|
||
|
||
---
|
||
|
||
## 第 1 天:环境和基础语法
|
||
|
||
**学习内容**
|
||
- 用 uv 管理 Python 版本和依赖(相当于 `nvm + npm` 合在一起):`curl -LsSf https://astral.sh/uv/install.sh | sh`。
|
||
- 缩进代替大括号;`None` / `True` / `False`;`list`、`dict`、`tuple`、`set`。
|
||
- f-string(相当于模板字符串)、切片 `a[1:3]`、解包 `a, *rest = items`。
|
||
- 列表推导式和字典推导式(相当于 `map` + `filter`)。
|
||
- 函数:默认参数、关键字参数、`*args`、`**kwargs`;默认参数不要用可变对象(`def f(x=[])` 是经典坑)。
|
||
- `if __name__ == "__main__":` 的作用。
|
||
|
||
**要建的文件**
|
||
- `py-learning/`:运行 `uv init py-learning && cd py-learning && uv python pin 3.12`,生成 `pyproject.toml` 和 `.python-version`。
|
||
- `day1_basics.py`:
|
||
- 文件开头用注释写一张 JS ↔ Python 对照表(至少 10 行:`const/let`、`===`、`null/undefined`、`arr.map`、`arr.filter`、对象展开、解构、模板字符串、`for...of`、箭头函数)。
|
||
- `def word_count(text: str) -> dict[str, int]`:和第 1 周 Go 的 `WordCount` 做同一件事,对比代码长短。
|
||
- `def unique(items: list[str]) -> list[str]`:去重并保持原顺序。
|
||
- 演示可变默认参数的坑和正确写法(`def f(x=None)`)。
|
||
|
||
**运行方式**:`uv run python day1_basics.py`
|
||
|
||
**完成标准**:能说清楚 Python 的 `list` 和 `tuple` 区别,以及为什么默认参数不能用 `[]`。
|
||
|
||
---
|
||
|
||
## 第 2 天:类型标注、dataclass 和 Protocol
|
||
|
||
**学习内容**
|
||
- 类型标注只是提示,运行时不检查(和 TS 一样),要靠检查工具:`uv add --dev pyright ruff pytest`。
|
||
- `list[str]`、`dict[str, int]`、`X | None`、`Literal["a", "b"]`、`TypedDict`。
|
||
- `@dataclass`:相当于带构造函数的结构体。
|
||
- `Protocol`:Python 的“隐式接口”,和 Go 的 `interface` 思路一样,Agent 项目里 LLM 客户端和存储层都会用它。
|
||
- 类、`self`、`@property`、`@classmethod`。
|
||
|
||
**要建的文件**
|
||
- `day2_typing.py`:
|
||
- `TaskStatus = Literal["todo", "doing", "done"]`
|
||
- `@dataclass class Task`:`id: int`、`title: str`、`status: TaskStatus = "todo"`、`tags: list[str] = field(default_factory=list)`。
|
||
- `class TaskStore(Protocol)`:`add(self, task: Task) -> None`、`list(self) -> list[Task]`。
|
||
- `class MemoryTaskStore`:不继承 `TaskStore`,但实现同样的方法(体会“隐式实现”)。
|
||
- `def count_by_status(tasks: list[Task]) -> dict[TaskStatus, int]`
|
||
- `tests/test_day2.py`:测试 `count_by_status`;用 `pytest.mark.parametrize` 写表格驱动测试(对应 Go 的表格驱动测试)。
|
||
|
||
**运行方式**:`uv run pytest -v`、`uv run pyright day2_typing.py`、`uv run ruff check .`
|
||
|
||
**完成标准**:故意把 `status` 写成 `"deleted"`,pyright 能报错;能说清楚 `Protocol` 和 Go 接口的相同点。
|
||
|
||
---
|
||
|
||
## 第 3 天:异常、上下文管理器和文件
|
||
|
||
**学习内容**
|
||
- `try / except / else / finally`,`raise ... from err`(相当于 Go 的 `%w` 包装错误)。
|
||
- 自定义异常层级:一个基类,下面分具体错误。
|
||
- `with` 语句(上下文管理器):自动关闭文件、连接,相当于 Go 的 `defer`。
|
||
- `pathlib.Path`、`json.loads` / `json.dumps`(注意 `ensure_ascii=False` 才能正常输出中文)。
|
||
- `logging` 基本用法。
|
||
|
||
**要建的文件**
|
||
- `day3_errors_files.py`:
|
||
- `class AppError(Exception)`,带 `code: str` 属性;子类 `NotFoundError`、`ValidationError`。
|
||
- `def load_tasks(path: Path) -> list[dict]`:文件不存在返回空列表;JSON 格式错误时 `raise ValidationError(...) from err`。
|
||
- `def save_tasks(path: Path, tasks: list[dict]) -> None`:先写临时文件再 `replace`,避免写到一半崩溃导致文件损坏(Agent 项目里“写状态要原子”就是这个道理)。
|
||
- 用 `contextlib.contextmanager` 写一个 `timer(name)`,在 `with timer("load"):` 结束时打印耗时。
|
||
|
||
**运行方式**:`uv run python day3_errors_files.py`
|
||
|
||
**完成标准**:能说清楚 `raise X from err` 保留了什么信息,以及“先写临时文件再改名”为什么更安全。
|
||
|
||
---
|
||
|
||
## 第 4 天:asyncio(本周最重要的一天)
|
||
|
||
**学习内容**
|
||
- `async def` / `await` 和 JS 很像,但要有事件循环:入口用 `asyncio.run(main())`。
|
||
- 和 JS 的区别:调用 `async` 函数只得到协程对象,**不会自动开始执行**,要 `await` 或 `asyncio.create_task`。
|
||
- `asyncio.gather`(相当于 `Promise.all`)、`asyncio.wait_for` / `asyncio.timeout`(超时)、`asyncio.Queue`(相当于 Go 的 channel)、`asyncio.Event`。
|
||
- 取消:`task.cancel()` 会在任务里抛出 `CancelledError`,要在 `finally` 里清理,**不要吞掉这个异常**。
|
||
- 异步生成器 `async def ... yield`,以及 `async for`:SSE 推送就是靠它。
|
||
- 不能在 `async` 函数里调用会阻塞的同步代码(比如 `time.sleep`、同步的数据库驱动),否则整个服务卡住;实在要用就 `await asyncio.to_thread(...)`。
|
||
|
||
**要建的文件**
|
||
- `day4_async.py`:
|
||
- `async def fake_llm(name: str, delay: float) -> str`:`await asyncio.sleep(delay)` 后返回结果,模拟一次模型调用。
|
||
- `async def run_parallel()`:用 `gather` 同时调 3 个 `fake_llm`,打印总耗时,和顺序调用对比。
|
||
- `async def run_with_timeout()`:给一个 3 秒的调用设 1 秒超时,捕获 `TimeoutError` 并打印。
|
||
- `async def producer_consumer()`:一个生产者往 `Queue` 放 5 个任务,两个消费者处理,全部处理完后退出(`queue.join()`)。
|
||
- `async def ticker(n: int)`:异步生成器,每 0.5 秒 `yield` 一个数字;用 `async for` 消费,消费到第 3 个时取消,观察 `finally` 是否执行。
|
||
- 文件开头写对照表:`Promise.all` ↔ `gather`、`setTimeout` ↔ `asyncio.sleep`、Go 的 `go f()` ↔ `create_task`、Go 的 channel ↔ `Queue`、Go 的 `context` 取消 ↔ `task.cancel()`。
|
||
|
||
**运行方式**:`uv run python day4_async.py`
|
||
|
||
**完成标准**:能说清楚“调用 async 函数但没 await”会发生什么;能解释为什么在 async 服务里用同步阻塞调用会拖垮所有请求。
|
||
|
||
---
|
||
|
||
## 第 5 天:pydantic 数据模型
|
||
|
||
**学习内容**
|
||
- pydantic 是“带运行时校验的类型”,作用类似前端的 zod:外部进来的数据(请求体、模型输出的 JSON)都要先过一遍校验。
|
||
- `BaseModel`、`Field(min_length=..., max_length=...)`、默认值、`model_validate`、`model_validate_json`、`model_dump`、`model_dump_json`。
|
||
- `Literal` 字段和“按某个字段区分类型”的联合类型(discriminated union)。
|
||
- `ValidationError` 里能拿到哪个字段、为什么错。
|
||
- `pydantic-settings`:从环境变量和 `.env` 读配置。
|
||
|
||
**要建的文件**
|
||
- `uv add pydantic pydantic-settings`
|
||
- `day5_models.py`:
|
||
- `class Decision(BaseModel)`:`action: Literal["dispatch", "finish", "ask_human"]`、`target: str | None = None`、`instruction: str = ""`、`reason: str`。(这就是第 9 周协调者输出的雏形)
|
||
- `def parse_decision(raw: str) -> Decision`:模型常常会在 JSON 外面包一层 ```` ```json ```` 代码块或多说几句话,先提取出第一个 `{...}` 再 `model_validate_json`;失败时抛出自定义的 `BadModelOutput`,错误信息里带上原始文本的前 200 个字。
|
||
- `class Settings(BaseSettings)`:`llm_base_url: str`、`llm_api_key: str = ""`、`llm_model: str`、`llm_timeout_s: float = 30`,从 `.env` 读取。
|
||
- `tests/test_day5.py`:参数化测试 `parse_decision`:正常 JSON、带代码块的 JSON、前后有废话的 JSON、`action` 拼错、缺少 `reason`、完全不是 JSON。
|
||
|
||
**运行方式**:`uv run pytest tests/test_day5.py -v`
|
||
|
||
**完成标准**:6 种输入都按预期通过或报错;能说清楚“为什么模型输出必须当成不可信的外部输入来校验”。
|
||
|
||
---
|
||
|
||
## 第 6 天:httpx 异步请求和测试
|
||
|
||
**学习内容**
|
||
- `httpx.AsyncClient`:设置 `base_url`、`timeout`、请求头;用 `async with` 管理生命周期,复用连接。
|
||
- 状态码和异常:`raise_for_status()`、`httpx.TimeoutException`、`httpx.ConnectError`。
|
||
- 测试时不真的发请求:`httpx.MockTransport` 可以按请求返回任意响应,用来模拟超时、429、500。
|
||
- pytest 跑异步测试:`uv add --dev pytest-asyncio`,在 `pyproject.toml` 里设置 `asyncio_mode = "auto"`。
|
||
|
||
**要建的文件**
|
||
- `uv add httpx`
|
||
- `day6_http.py`:
|
||
- `class HttpError(Exception)`,带 `code` 和 `retryable: bool`。
|
||
- `async def get_json(client: httpx.AsyncClient, url: str, retries: int = 2) -> dict`:超时和 5xx、429 时等待后重试(等待时间逐次翻倍),401 / 400 不重试,最后抛出带错误码的 `HttpError`。
|
||
- `main()` 里请求一个公开接口(例如 `https://httpbin.org/json`)打印结果。
|
||
- `tests/test_day6.py`,用 `MockTransport` 测试:
|
||
- 200 直接返回。
|
||
- 前两次 503、第三次 200,最终成功,并断言一共请求了 3 次。
|
||
- 401 不重试,直接抛 `code == "AUTH"`。
|
||
- 一直超时,最终抛 `code == "TIMEOUT"`。
|
||
|
||
**运行方式**:`uv run python day6_http.py`、`uv run pytest tests/test_day6.py -v`
|
||
|
||
**完成标准**:四个测试都通过;能说清楚哪些错误值得重试、哪些不值得。
|
||
|
||
---
|
||
|
||
## 第 7 天:FastAPI 基础和 SSE
|
||
|
||
**学习内容**
|
||
- FastAPI 路由、路径参数、查询参数,请求体直接用 pydantic 模型(对照第 2 周的 Gin)。
|
||
- 依赖注入 `Depends`。
|
||
- `lifespan`:服务启动和关闭时执行的代码(第 10 周用它在启动时恢复未完成的任务)。
|
||
- 后台任务:用 `asyncio.create_task` 启动,**要把任务对象保存起来**,否则可能被垃圾回收。
|
||
- SSE 格式:`id: 1\nevent: progress\ndata: {...}\n\n`;用 `StreamingResponse(生成器, media_type="text/event-stream")` 返回;浏览器用 `EventSource` 订阅,断线会自动重连并带上 `Last-Event-ID` 请求头。
|
||
- 检测客户端断开:`await request.is_disconnected()`。
|
||
|
||
**要建的文件**
|
||
- `uv add fastapi uvicorn`
|
||
- `fastapi_demo/main.py`:
|
||
- `GET /health`。
|
||
- `POST /jobs`:请求体 `{"n": 10}`,用 `create_task` 启动一个后台任务,每秒把进度追加到内存里的 `jobs[job_id]["events"]` 列表,返回 `{"job_id": ...}`。
|
||
- `GET /jobs/{job_id}`:返回当前状态和已产生的事件数。
|
||
- `GET /jobs/{job_id}/events`:SSE。先补发 `Last-Event-ID` 之后的事件,再等待新事件,任务完成后发 `event: done` 并结束;每 15 秒没有新事件就发一行 `: ping` 心跳。
|
||
- **关键实验**:浏览器关掉后,后台任务要继续跑完(因为任务和 SSE 连接是分开的)。
|
||
- `fastapi_demo/static/sse.html`:一个按钮启动任务,用 `EventSource` 显示进度。
|
||
- `tests/test_fastapi_demo.py`:用 `fastapi.testclient.TestClient` 测 `/health` 和创建任务。
|
||
|
||
**运行方式**:`uv run uvicorn fastapi_demo.main:app --reload`,浏览器打开测试页;或者 `curl -N localhost:8000/jobs/<id>/events`。
|
||
|
||
**完成标准**
|
||
- 启动任务后关掉页面,再打开、重新订阅,能看到任务已经继续往前跑,而且能补齐中间漏掉的事件。
|
||
- 能说清楚“编排为什么不能只活在浏览器里”——这就是重点 C,也是第 10 周要做的核心功能的缩小版。
|
||
- `uv run ruff check . && uv run pytest` 通过,提交并推送到 `zhuangang` 仓库(路径 `py-learning/`)。
|
||
- 在总纲 `README.md` 的进度记录里勾掉第 8 周。
|
||
|
||
---
|
||
|
||
## 本周自查清单
|
||
|
||
- [ ] 会用 uv 建项目、加依赖、跑脚本
|
||
- [ ] 熟悉列表推导式、解包、f-string,知道可变默认参数的坑
|
||
- [ ] 会写类型标注、dataclass、Protocol,会用 pyright 检查
|
||
- [ ] 会用自定义异常和 `raise ... from`
|
||
- [ ] 理解 asyncio:协程、`create_task`、`gather`、超时、取消、`Queue`、异步生成器
|
||
- [ ] 会用 pydantic 校验外部数据(包括模型输出)
|
||
- [ ] 会用 httpx 发异步请求、按错误类型决定是否重试,会用 `MockTransport` 测试
|
||
- [ ] 会写 FastAPI 接口和 SSE,理解后台任务和 SSE 连接是两回事
|
||
|
||
下周预告:第 9 周开始迷你多 Agent 项目,先做 M1(LLM 调用封装)和 M2(多轮编排循环),见 `agent-mini-project.md`。
|