37 KiB
转岗计划 · 第 9~11 周:Python 迷你多 Agent 项目(阶段 3 主交付)
所属阶段:第 3 个月,阶段 3。总纲见同目录
README.md,前置补课见week08-python-basics.md。
目标
把在公司里见过的多 Agent 产品“削”成一个没有业务外壳、可以公开演示的内核,证明你“会造”,而不只是“会改”。
做完后要能演示:
- 提一个问题 → 协调者派活给两个专家 → 多轮讨论 → 达成共识或到达最大轮数后结束 → 生成可审计的报告。
- 中途
kill -9杀掉服务进程 → 重新启动 → 任务从上一步接着跑完,已完成的步骤不重复。 - 浏览器通过 SSE 看进度;关掉页面任务照跑;重新打开能补齐漏掉的事件。
- 协调者觉得信息不够时停下来问人,人回答后继续。
- 模型调用失败(超时、限流、额度、鉴权)时,任务进入失败状态并带上可读的错误码,可以换模型后继续。
项目名:agent-mini,放在 zhuangang/agent-mini/。
范围
| 必须有 | 禁止有(本阶段) |
|---|---|
| 至少 2 个专家(提案者 proposer、评审者 critic)+ 1 个协调者 coordinator | 完整任务中心、字典、巡检 |
| 明确的结束条件:评审通过(共识)或达到最大轮数 | 照搬公司 UI、提示词或代码 |
| 状态持久化:默认 SQLite(可选 PostgreSQL) | 依赖公司内网才能演示 |
| 编排跑在服务端后台,进程重启后能续跑 | 没有说明的纯聊天 demo 页 |
| SSE 推送进度,断线重连能补齐 | 同时再学 LangGraph(二选一,已选自写) |
| 不需要真实模型密钥也能演示(FakeLLM) |
讨论的题目用通用例子,比如“一个 5 人创业团队做内部工具,该选什么技术栈”“给一个读书 App 设计会员规则”,不要用公司业务题目。
技术选型
| 用途 | 选择 |
|---|---|
| 语言和依赖 | Python 3.12 + uv |
| 编排 | 自写 asyncio 循环 |
| 模型调用 | httpx 调 OpenAI 兼容的 /chat/completions 接口;FakeLLM 用于测试和离线演示 |
| 数据校验 | pydantic v2、pydantic-settings |
| 存储 | SQLite(aiosqlite),通过 RunStore 协议隔离,可另写 PostgreSQL 实现 |
| 服务 | FastAPI + uvicorn,SSE 用 StreamingResponse 手写 |
| 前端 | 一个静态 web/index.html(原生 JS + EventSource),可选用你熟悉的 Vue / React 重写 |
| 质量 | pytest + pytest-asyncio、ruff、pyright |
核心设计(先读懂,再动手)
状态机
queued ──(被某个进程领取)──> running ──> completed
^ │ │
│ │ ├──> failed(模型错误、输出无法解析等,带 error_code)
│ │ ├──> cancelled(用户取消)
│ │ └──> awaiting_input(协调者要求问人)
│ │
└──(人回答 / 失败后重试 / 进程崩溃后租约过期)──┘
一“步”是什么
一步 = 协调者做一次决定 + 被派到的专家回答一次。每一步完成后,在同一个数据库事务里:
- 插入这一步的发言记录
turns(主键run_id + step,重复插入会被忽略,保证不会写出两条); - 更新
runs.next_step、runs.round、runs.status; - 追加事件
events(给 SSE 用)。
进程在一步中间崩溃,这一步的模型调用会在恢复后重做一次(模型调用“至少一次”),但写进数据库的步骤“恰好一次”。这个取舍要写进 README。
结束条件写在代码里,不只靠模型自觉
- 评审者每次回答最后一行必须是
VERDICT: APPROVE或VERDICT: REVISE。最近一次评审是APPROVE时,代码直接让协调者收尾。 - 轮数达到
max_rounds时,不再问协调者,直接收尾,并在报告里注明“未达成共识,因轮数上限结束”。 - 协调者输出格式错误:带上错误提示重试 1 次,还错就以
LLM_BAD_OUTPUT失败。
防止同一个任务被跑两次(重点 A)
runs 表上有租约字段 lease_owner、lease_until。进程领取任务时用一条带条件的 UPDATE(只有租约为空、已过期或本来就是自己的才能领到),影响行数为 1 才算领取成功。运行中每步续租;进程崩溃后租约过期,看门狗会把任务重新领走。
权威数据在数据库(重点 B)
SSE 推送、内存里的通知都只是“加速”,任何时候以数据库里的 runs / turns / events 为准。SSE 断线重连就是按 events.seq 从数据库补发。
最终项目结构
agent-mini/
├── README.md # 作品说明(M4)
├── pyproject.toml # 依赖、脚本入口 agent-mini、pytest/ruff 配置
├── .python-version
├── .env.example # 所有配置项和说明,默认 LLM_PROVIDER=fake
├── .gitignore # .env、data/、.venv/
├── docs/
│ ├── architecture.md # 组件图、状态机、崩溃恢复时序图(mermaid)
│ ├── design-decisions.md # 关键取舍:为什么后台跑、租约、至少一次
│ └── mapping.md # 与生产概念的映射表(也摘进 README)
├── scripts/
│ └── demo_crash_resume.sh # 一键演示:启动 → 杀进程 → 重启 → 查看结果
├── web/
│ └── index.html # 极简页面:提问、看进度、回答问题
├── src/agent_mini/
│ ├── __init__.py
│ ├── config.py # Settings(pydantic-settings)
│ ├── errors.py # ErrorCode、AgentError
│ ├── models.py # Run、RunStatus、Turn、Decision、Event
│ ├── llm/
│ │ ├── __init__.py # build_llm(settings) 工厂函数
│ │ ├── base.py # ChatMessage、LLMResult、LLMClient 协议
│ │ ├── openai_compat.py # 真实调用:超时、重试、错误码映射
│ │ └── fake.py # 假模型:按脚本回复,可设延迟和故障
│ ├── roles/
│ │ ├── __init__.py
│ │ ├── base.py # Specialist 基类、构造提示词的公共函数
│ │ ├── proposer.py # 提案者
│ │ ├── critic.py # 评审者(输出 VERDICT)
│ │ └── coordinator.py # 协调者:decide()、summarize()
│ ├── parsing.py # 从模型输出里提取 JSON / VERDICT
│ ├── orchestrator.py # 单步执行 + 循环,结束条件
│ ├── store/
│ │ ├── __init__.py
│ │ ├── base.py # RunStore 协议
│ │ ├── schema.sql # 建表语句
│ │ └── sqlite_store.py # SQLite 实现(事务、租约、事件)
│ ├── events.py # EventNotifier:进程内通知,加速 SSE
│ ├── runner.py # RunManager:后台任务、租约续期、看门狗恢复、取消
│ ├── report.py # 由 turns 生成可审计的 Markdown 报告
│ ├── api/
│ │ ├── __init__.py
│ │ ├── app.py # create_app()、lifespan(启动看门狗)
│ │ ├── routes_runs.py # 任务相关接口
│ │ └── sse.py # GET /runs/{id}/events
│ └── cli.py # agent-mini ask / run / show / list / serve
└── tests/
├── conftest.py # 临时数据库、FakeLLM、测试设置
├── test_llm_openai_compat.py
├── test_parsing.py
├── test_orchestrator.py
├── test_sqlite_store.py
├── test_runner_resume.py
└── test_api_sse.py
数据库表(store/schema.sql)
CREATE TABLE IF NOT EXISTS runs (
id TEXT PRIMARY KEY, -- uuid
question TEXT NOT NULL,
status TEXT NOT NULL, -- queued/running/awaiting_input/completed/failed/cancelled
model TEXT NOT NULL, -- 本任务用的模型,可在重试时更换
round INTEGER NOT NULL DEFAULT 0,
max_rounds INTEGER NOT NULL,
next_step INTEGER NOT NULL DEFAULT 1,
human_question TEXT, -- awaiting_input 时要问人的问题
final_answer TEXT,
error_code TEXT,
error_message TEXT,
lease_owner TEXT,
lease_until REAL, -- Unix 时间戳
created_at REAL NOT NULL,
updated_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS turns (
run_id TEXT NOT NULL REFERENCES runs(id),
step INTEGER NOT NULL,
role TEXT NOT NULL, -- proposer/critic/human/coordinator
instruction TEXT NOT NULL DEFAULT '', -- 协调者派活时说的话
content TEXT NOT NULL,
decision_json TEXT, -- 协调者这一步的决定原文(审计用)
model TEXT,
latency_ms INTEGER,
created_at REAL NOT NULL,
PRIMARY KEY (run_id, step)
);
CREATE TABLE IF NOT EXISTS events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
run_id TEXT NOT NULL REFERENCES runs(id),
type TEXT NOT NULL, -- run_created/decision/turn/awaiting_input/input_received/run_completed/run_failed/run_cancelled
data TEXT NOT NULL, -- JSON
created_at REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_events_run_seq ON events(run_id, seq);
CREATE INDEX IF NOT EXISTS idx_runs_status_lease ON runs(status, lease_until);
接口设计
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /health |
健康检查 |
| POST | /runs |
创建任务 {question, max_rounds?},立即返回 {id, status: "queued"},后台开始跑 |
| GET | /runs |
最近的任务列表 |
| GET | /runs/{id} |
任务状态 + 所有 turns |
| GET | /runs/{id}/events |
SSE,支持 Last-Event-ID 请求头或 ?after=seq 补发 |
| POST | /runs/{id}/input |
回答协调者的问题 {answer},仅 awaiting_input 状态可用 |
| POST | /runs/{id}/retry |
失败后重试,可换模型 {model?},仅 failed 状态可用 |
| POST | /runs/{id}/cancel |
取消 |
| GET | /runs/{id}/report |
Markdown 报告:问题、每一步谁说了什么、结束原因、最终结论 |
| GET | / |
返回 web/index.html |
错误响应统一:{"error": {"code": "RUN_NOT_FOUND", "message": "..."}}。
第 9 周 · M1:单轮 LLM 调用封装(第 1~3 天)
第 1 天:项目初始化、配置和错误码
学习内容
src/布局的 Python 项目,pyproject.toml里配置脚本入口。- 配置集中管理,所有可调参数都能通过环境变量改。
- 为什么要有统一的错误码:日志、接口、前端提示都能按错误码处理,而不是靠解析错误文字。
要建的文件
uv init --package agent-mini,uv python pin 3.12;uv add httpx pydantic pydantic-settings;uv add --dev pytest pytest-asyncio ruff pyright。pyproject.toml:[project.scripts] agent-mini = "agent_mini.cli:main";[tool.pytest.ini_options] asyncio_mode = "auto"。.env.example:LLM_PROVIDER=fake、LLM_BASE_URL=、LLM_API_KEY=、LLM_MODEL=、LLM_TIMEOUT_S=60、LLM_MAX_RETRIES=2、DB_PATH=data/agent_mini.db、MAX_ROUNDS=6、LEASE_SECONDS=15、FAKE_DELAY_S=0,每项上面一行注释。.gitignore:.env、data/、.venv/、__pycache__/。src/agent_mini/config.py:class Settings(BaseSettings),字段同上;llm_provider: Literal["fake", "openai"];def get_settings() -> Settings。src/agent_mini/errors.py:class ErrorCode(StrEnum):LLM_TIMEOUT、LLM_RATE_LIMITED、LLM_QUOTA_EXCEEDED、LLM_AUTH、LLM_BAD_REQUEST、LLM_UNAVAILABLE、LLM_BAD_OUTPUT、RUN_NOT_FOUND、RUN_INVALID_STATE、RUN_LEASE_LOST。class AgentError(Exception):code: ErrorCode、message: str、retryable: bool、detail: dict | None。- 每个错误码配一句中文说明
ERROR_HINTS: dict[ErrorCode, str],比如LLM_QUOTA_EXCEEDED: "模型额度不足,请更换模型或充值后重试"。
运行方式:uv run python -c "from agent_mini.config import get_settings; print(get_settings())"
完成标准:改环境变量能改变配置;uv run ruff check . 通过。
第 2 天:OpenAI 兼容客户端
学习内容
/chat/completions请求和响应的结构(messages、choices[0].message.content、usage)。- 超时分两种:连不上和等太久;整体超时用
httpx.Timeout。 - 错误分类:哪些重试(超时、429 限流、5xx、连接失败),哪些不重试(401/403 鉴权、400 参数、额度不足)。
- 指数退避:第 n 次重试前等
base * 2**n秒,加一点随机抖动。
要建的文件
src/agent_mini/llm/base.py:class ChatMessage(BaseModel):role: Literal["system", "user", "assistant"]、content: str。class LLMResult(BaseModel):content、model、prompt_tokens: int | None、completion_tokens: int | None、latency_ms: int。class LLMClient(Protocol):async def chat(self, messages: list[ChatMessage], *, model: str | None = None, temperature: float = 0.3) -> LLMResult;async def aclose(self) -> None。
src/agent_mini/llm/openai_compat.py:class OpenAICompatClient:构造时创建httpx.AsyncClient(base_url, timeout, headers),可注入transport方便测试。async def chat(...):发请求、计时、解析;按上面的分类转成AgentError。429 时看响应体里有没有额度相关的错误类型(不同服务商字段不同,先按你实际用的服务商处理,并在注释里说明),有就是LLM_QUOTA_EXCEEDED(不重试),否则LLM_RATE_LIMITED(重试)。响应结构不对时LLM_BAD_OUTPUT。def _map_error(resp: httpx.Response) -> AgentError:单独一个函数,方便测试。- 日志里记录模型名、耗时、重试次数,不记录 API Key 和完整提示词。
完成标准:能说清楚每种错误为什么重试或不重试。
第 3 天:FakeLLM、测试和 ask 命令
学习内容
- 为什么需要假模型:测试要快、要稳定、不花钱;演示不依赖密钥和网络;可以故意制造故障。
要建的文件
src/agent_mini/llm/fake.py:class FakeLLM:实现LLMClient协议。- 根据 system 消息里的角色标记(比如
[ROLE:critic])决定回复:协调者按“提案 → 评审 → 修改 → 评审”的顺序输出 JSON 决定;评审者第一次VERDICT: REVISE、第二次VERDICT: APPROVE。 - 参数
delay_s(每次调用等待多久,演示崩溃恢复用)、fail_plan: dict[int, ErrorCode](第几次调用抛什么错误)、calls: list记录每次调用,方便测试断言。
src/agent_mini/llm/__init__.py:def build_llm(settings) -> LLMClient。tests/test_llm_openai_compat.py,用httpx.MockTransport:- 200 正常解析出
content和usage。 - 401 →
LLM_AUTH,只请求了 1 次。 - 429 额度不足 →
LLM_QUOTA_EXCEEDED,只请求了 1 次。 - 429 限流两次后 200 → 成功,请求了 3 次。
- 一直超时 →
LLM_TIMEOUT,请求了1 + max_retries次。 - 响应里没有
choices→LLM_BAD_OUTPUT。 - 测试里把退避时间设为 0,别让测试变慢。
- 200 正常解析出
src/agent_mini/cli.py:先实现agent-mini ask "问题":调一次模型,打印回复、耗时、token 数;出错时打印错误码和中文提示,退出码非 0。
运行方式:uv run pytest -v;uv run agent-mini ask "你好"(fake);配置好 .env 后用真实模型再试一次。
M1 验收
- 6 个客户端测试全部通过
- 真实模型调用一次成功;故意填错 Key,看到
LLM_AUTH和中文提示 - 能说清楚错误码设计如何对应你在工作中遇到的“额度 / 鉴权失败”问题(对应案例卡 D 或 E)
第 9 周 · M2:多轮编排循环(第 4~7 天)
第 4 天:数据模型和输出解析
要建的文件
src/agent_mini/models.py:class RunStatus(StrEnum):6 个状态;TERMINAL = {completed, failed, cancelled}。class Decision(BaseModel):action: Literal["dispatch", "ask_human", "finish"]、target: Literal["proposer", "critic"] | None、instruction: str = ""、question_for_human: str | None = None、reason: str;用model_validator保证dispatch必须有target、ask_human必须有question_for_human。class Turn(BaseModel):step、role、instruction、content、decision_json、model、latency_ms。class Run(BaseModel):对应runs表的字段。class Event(BaseModel):seq、run_id、type、data: dict。
src/agent_mini/parsing.py:def extract_json(text: str) -> str:处理代码块包裹、前后多余文字(第 8 周第 5 天写过类似的)。def parse_decision(text: str) -> Decision:失败抛AgentError(LLM_BAD_OUTPUT)。def parse_verdict(text: str) -> Literal["APPROVE", "REVISE"] | None:取最后一个VERDICT:行。
tests/test_parsing.py:参数化测试,至少 8 种输入。
第 5 天:角色和提示词
学习内容
- 角色 = system 提示词 + 能看到的上下文 + 输出格式要求。
- 上下文控制:专家只看到问题、之前的发言摘要和这次的指令;历史太长时只保留最近几步(先写死保留最近 6 步,在 README 里说明这是简化)。
- 结构化输出:协调者必须只输出 JSON,提示词里给出格式和例子。
要建的文件
src/agent_mini/roles/base.py:def format_history(turns: list[Turn], limit: int = 6) -> strclass Specialist:name、system_prompt;async def act(self, llm, question, turns, instruction, model) -> Turn。
src/agent_mini/roles/proposer.py:提案者,给出或修改方案,要求分点、具体。src/agent_mini/roles/critic.py:评审者,指出风险和遗漏,最后一行必须是VERDICT: APPROVE或VERDICT: REVISE。src/agent_mini/roles/coordinator.py:async def decide(self, llm, question, turns, round, max_rounds, model) -> Decision:解析失败时把错误信息附在消息末尾重试 1 次。async def summarize(self, llm, question, turns, end_reason, model) -> str:输出最终结论。
- 所有提示词放在各自文件顶部的常量里,带
[ROLE:xxx]标记(FakeLLM 靠它识别角色)。提示词全部自己写,不复制公司的。
第 6 天:编排循环(内存版)
学习内容
- 把“做一步”和“循环”分开:
step()是纯逻辑,方便测试;循环只负责反复调用step()并检查结束条件。下周加持久化时只改循环,不改step()。
要建的文件
src/agent_mini/orchestrator.py:class StepOutcome(BaseModel):decision: Decision、turn: Turn | None、new_status: RunStatus、end_reason: str | None、final_answer: str | None。class Orchestrator:持有llm和三个角色。async def step(self, run: Run, turns: list[Turn]) -> StepOutcome,规则按顺序:- 最近一次评审是
APPROVE→ 直接summarize,completed,结束原因“评审通过”。 run.round >= run.max_rounds→ 直接summarize,completed,结束原因“达到最大轮数”。- 否则调
coordinator.decide:dispatch→ 调对应专家,round + 1;ask_human→awaiting_input;finish→summarize,completed。
- 最近一次评审是
async def run_in_memory(self, question: str, max_rounds: int, on_event) -> Run:内存里循环调用step(),每步调用on_event回调。
cli.py:新增agent-mini run "问题" --max-rounds 6 --memory,每步打印“第几步 / 谁 / 指令 / 回答前 200 字”,最后打印结论。
运行方式:uv run agent-mini run "5 人创业团队做内部工具该选什么技术栈" --memory
第 7 天:编排测试和真实模型试跑
要建的文件
tests/test_orchestrator.py(全部用 FakeLLM):- 评审第二次通过 → 正常结束,步骤顺序是 proposer、critic、proposer、critic。
- 评审一直不通过 → 在
max_rounds结束,结束原因是“达到最大轮数”。 - 协调者第一次输出乱码、第二次正常 → 成功;两次都乱码 →
LLM_BAD_OUTPUT。 - 协调者要求问人 → 状态
awaiting_input,question_for_human有值。 - 协调者派给不存在的角色 → 解析阶段就报错(
Literal校验)。
- 用真实模型跑 3 个不同题目,把输出存到
docs/samples/(检查没有敏感信息),观察协调者有没有不按格式输出、评审是否太容易通过,调整提示词。
M2 验收
uv run pytest全部通过- 命令行能用 fake 和真实模型各跑完一次多轮讨论
- 能说清楚“结束条件为什么要写在代码里”(对应重点 A)
- 提交并推送;在总纲进度记录里勾掉第 9 周
第 10 周 · M3:持久化 + 续跑 + 后台作业 + SSE
第 1 天:SQLite 存储层
学习内容
- aiosqlite 用法;SQLite 开启 WAL 模式(
PRAGMA journal_mode=WAL)让读写不互相卡住;PRAGMA foreign_keys=ON。 - 事务:
BEGIN IMMEDIATE ... COMMIT。 - 幂等写入:
INSERT ... ON CONFLICT(run_id, step) DO NOTHING(第 3 周学过 PostgreSQL 的写法,SQLite 语法几乎一样)。
要建的文件
uv add aiosqlitesrc/agent_mini/store/schema.sql:见上面“数据库表”。src/agent_mini/store/base.py:class RunStore(Protocol),方法:create_run(question, max_rounds, model) -> Runget_run(run_id) -> Run(找不到抛RUN_NOT_FOUND)、list_runs(limit) -> list[Run]、list_turns(run_id) -> list[Turn]commit_step(run_id, worker_id, outcome: StepOutcome, events: list[tuple[str, dict]]) -> None:一个事务里写 turn、更新 run、追加事件;先检查租约还是自己的,不是就抛RUN_LEASE_LOST,什么都不写。try_acquire(run_id, worker_id, lease_seconds) -> bool、renew_lease(...) -> bool、release(run_id, worker_id)find_resumable(now) -> list[str]:状态是queued或running且租约为空或已过期的任务。set_status(run_id, status, **fields)、append_event(run_id, type, data) -> int、list_events(run_id, after_seq) -> list[Event]
src/agent_mini/store/sqlite_store.py:class SqliteRunStore,async def open(path)时执行建表和 PRAGMA。tests/test_sqlite_store.py(用tmp_path下的临时数据库):- 同一步
commit_step两次,turns只有一行。 - 两个 worker 抢同一个任务,只有一个
try_acquire成功;租约过期后另一个能抢到。 - 租约被别人抢走后,原 worker 的
commit_step抛RUN_LEASE_LOST,数据库没有变化。 list_events(after_seq)只返回之后的事件,seq递增。
- 同一步
第 2 天:带持久化的编排循环
要建的文件
src/agent_mini/orchestrator.py新增:async def drive(self, store, run_id, worker_id, should_stop) -> RunStatus:- 从数据库读
run和turns(每次恢复都从数据库重建状态,不依赖内存)。 - 循环:检查
should_stop()和数据库里的状态(被取消就退出)→step()→commit_step()→ 续租。 AgentError时把任务置为failed,写error_code、error_message,追加run_failed事件。- 结束、等人、失败时释放租约。
- 从数据库读
src/agent_mini/report.py:def build_report(run, turns) -> str:Markdown,包含问题、每一步(角色、指令、回答、模型、耗时)、结束原因、最终结论、生成时间。报告完全由数据库数据拼出,不再调模型,这样结果可追溯(对应重点 E)。cli.py:agent-mini run "问题"(不带--memory时写数据库,打印 run_id)、agent-mini show <run_id>(打印报告)、agent-mini list。
运行方式:uv run agent-mini run "...",然后 uv run agent-mini show <run_id>。
第 3 天:RunManager 后台作业和看门狗
学习内容
- 后台任务和 HTTP 请求分开:请求只负责创建任务并返回,真正的编排由后台任务执行。
- 看门狗:定期扫描“该有人跑但没人跑”的任务(服务刚启动、别的进程崩溃后租约过期)。
- 优雅关机:停止看门狗、取消后台任务、释放租约。
要建的文件
src/agent_mini/events.py:class EventNotifier:每个 run 一个asyncio.Event,notify(run_id)唤醒等待的 SSE 连接,wait(run_id, timeout)等待。只是加速,没有它 SSE 也能靠轮询数据库工作。src/agent_mini/runner.py:class RunManager:worker_id:进程启动时生成(主机名 + 进程号 + 随机串)。async def submit(run_id):try_acquire成功才create_task(self._drive(run_id)),任务对象存到self._tasks字典,完成后自动移除。async def _drive(run_id):调用orchestrator.drive,捕获所有异常并记录日志,保证不会让后台任务静默死掉。async def watchdog():每lease_seconds / 3秒执行一次find_resumable→submit。async def cancel(run_id):数据库置为cancelled+ 取消本进程里对应的任务。async def shutdown()。
第 4 天:崩溃恢复测试和演示脚本
要建的文件
tests/test_runner_resume.py:- 用 FakeLLM(
delay_s=0.2)和短租约(1 秒)启动 manager A 跑任务;跑完 2 步后直接取消 A 的后台任务且不释放租约(模拟进程被杀)。 - 启动 manager B(新的
worker_id),等待租约过期,看门狗把任务领走并跑完。 - 断言:最终
completed;turns的step从 1 开始连续、没有重复;事件里出现过两个不同的 worker。 - 再测一个:A 还活着时 B 不能抢走任务。
- 用 FakeLLM(
scripts/demo_crash_resume.sh:- 用
FAKE_DELAY_S=2后台启动服务,记下进程号; curl -X POST /runs创建任务;- 等 5 秒,
kill -9杀掉服务; - 打印此时数据库里的步骤数;
- 重新启动服务,轮询
/runs/{id}直到completed; - 打印报告,显示步骤连续不重复。
- 用
运行方式:uv run pytest tests/test_runner_resume.py -v;bash scripts/demo_crash_resume.sh
第 5 天:FastAPI 接口
要建的文件
uv add fastapi uvicornsrc/agent_mini/api/app.py:def create_app(settings=None, llm=None) -> FastAPI:可以注入 FakeLLM 方便测试。lifespan:打开存储 → 创建RunManager→ 启动看门狗 →yield→shutdown()→ 关闭存储和 LLM 客户端。- 全局异常处理:
AgentError转成统一错误响应(RUN_NOT_FOUND→ 404,RUN_INVALID_STATE→ 409)。
src/agent_mini/api/routes_runs.py:接口表里除 SSE 外的所有接口。POST /runs/{id}/input:状态必须是awaiting_input;把回答写成一条role=human的 turn,状态改回queued,追加input_received事件,再submit。POST /runs/{id}/retry:状态必须是failed;可选更换model;清掉错误字段,状态改为queued,submit。
cli.py:agent-mini serve --port 8000(内部调用 uvicorn)。
运行方式:uv run agent-mini serve,打开 http://localhost:8000/docs 用自动生成的接口文档测试。
第 6 天:SSE
学习内容
- SSE 每条消息的
id:用events.seq;浏览器重连时自动带Last-Event-ID,服务端从这个序号之后补发。 - 心跳:长时间没有事件时发
: ping,避免中间的代理断开空闲连接。 - 连接断开不影响任务:SSE 只是“读”,编排在 RunManager 里。
要建的文件
src/agent_mini/api/sse.py:GET /runs/{id}/events:- 起点:
Last-Event-ID请求头 >?after=参数 > 0。 - 循环:从数据库取
seq > last的事件逐条发出 → 任务已是终态且没有新事件,发event: end后结束 → 否则notifier.wait(run_id, timeout=15),超时就发: ping。 - 每轮检查
request.is_disconnected(),断开就退出生成器。 - 响应头:
Cache-Control: no-cache、X-Accel-Buffering: no(经过 Nginx 时不缓冲)。
- 起点:
tests/test_api_sse.py(用with TestClient(create_app(llm=FakeLLM())) as client:触发 lifespan):- 创建任务 → 读 SSE 直到
end,事件顺序符合预期,最后一个业务事件是run_completed。 - 带
Last-Event-ID: 3再读一次,只收到seq > 3的事件。 - 不存在的 run 返回 404。
ask_human场景:状态变成awaiting_input后调/input,任务能继续跑完。
- 创建任务 → 读 SSE 直到
第 7 天:极简页面和端到端走查
要建的文件
web/index.html(原生 JS,不超过 200 行;你熟悉 Vue / React 的话之后可以重写,但本周先用最简单的):- 输入问题 →
POST /runs→ 用EventSource订阅。 - 时间线:每一步显示角色、指令、回答;结束后显示结论和“查看报告”链接。
awaiting_input时出现输入框,提交到/input。run_failed时显示错误码、中文提示和“重试”按钮(可填新模型名)。- 显示连接状态(已连接 / 重连中)。
- 输入问题 →
api/app.py:GET /返回这个页面。
手动走查:
- 正常跑完一次;
- 跑到一半关掉标签页,过一会儿打开同一任务,能看到完整时间线;
- 跑到一半
Ctrl+C停服务再启动,页面自动重连后继续收到事件; - 用错误的 Key 制造
LLM_AUTH失败,修好配置后点“重试”成功。
M3 验收
uv run pytest全部通过,包括崩溃恢复和 SSE 测试demo_crash_resume.sh演示成功- 走查 4 个场景全部符合预期
- 能说清楚:权威数据在哪(B)、为什么编排不能放在浏览器里(C)、什么时候停下来问人(D)、结果如何变成可审计的报告(E)、怎么防止双跑(A)
- 提交并推送;在总纲进度记录里勾掉第 10 周
第 11 周 · M4:文档、架构图、映射表
第 1 天:架构图
要建的文件
docs/architecture.md,三张 mermaid 图:- 组件图:浏览器 → FastAPI(routes / SSE)→ RunManager → Orchestrator → 角色 → LLMClient;存储层和 EventNotifier 的位置。
- 状态图(
stateDiagram-v2):6 个状态和所有转换条件。 - 时序图(
sequenceDiagram):创建任务 → 跑两步 → 进程崩溃 → 新进程看门狗领取 → 续跑 → SSE 重连补发。
- 回头对照第 5 周画的
notes/01-architecture.md(公司系统的脱敏图),在architecture.md末尾写一段“和生产系统相比,我简化了什么”。
第 2 天:README 主体
要建的文件
README.md:- 一句话介绍 + 动图或截图(页面时间线、崩溃恢复演示)。
- 功能列表(对应上面“目标”的 5 条)。
- 10 分钟快速开始:安装 uv →
git clone→uv sync→cp .env.example .env→uv run agent-mini serve→ 打开页面。默认用 FakeLLM,不需要任何密钥;再写怎么切换到真实模型。 - 配置项表。
- 接口表。
- 运行测试:
uv run pytest。 - 崩溃恢复演示:
bash scripts/demo_crash_resume.sh。 - 项目结构(简版)。
第 3 天:映射表和设计取舍
要建的文件
docs/mapping.md,同时摘进 README(至少 5 行,生产一栏只写通用概念,不写公司接口名、表名):
| 生产系统里的概念(通用叫法) | 本项目里的实现 | 对应重点 |
|---|---|---|
| 席位 / 专家角色 | roles/proposer.py、roles/critic.py |
A |
| 主持人派活 | Coordinator.decide() 返回 Decision(action="dispatch") |
A |
| 共识 / 结束条件 | VERDICT: APPROVE + 最大轮数,规则写在 Orchestrator.step() |
A |
| 草稿(编排中间状态) | runs + turns 表,每步一个事务提交 |
B |
| 后台作业 | RunManager 后台任务 + 租约 + 看门狗 |
A / C |
| 流式输出 | GET /runs/{id}/events,按 seq 断点补发 |
C |
| 意图澄清 / 等待用户输入 | ask_human → awaiting_input → POST /input |
D |
| 换模型恢复 | 错误码 + POST /retry {model} |
D |
| 过程报告 | report.py 从数据库拼出的 Markdown |
E |
docs/design-decisions.md,每条写“选择 / 理由 / 代价”:- 编排放在服务端后台,而不是浏览器循环(关页、刷新、断网都不影响)。
- 每步一个事务 +
(run_id, step)主键:写入恰好一次,模型调用至少一次。 - 租约而不是进程内锁:多进程、多机器也不会双跑;代价是崩溃后要等租约过期才恢复。
- 数据库是权威,SSE 只是视图:断线重连靠
seq补发。 - 结束条件写在代码里,不只靠模型判断。
- SQLite 而不是 PostgreSQL:方便别人跑;存储层有协议隔离,换 PostgreSQL 只需新写一个实现(可选练习:用第 3 周学的 PostgreSQL 写
pg_store.py)。 - 没做的事(诚实写):token 级流式输出、多实例下的事件通知(目前靠轮询兜底)、鉴权、评测。
第 4 天:从零验证
- 在一个新目录(最好是另一台电脑,或者请朋友)
git clone下来,严格按 README 操作并计时,目标 10 分钟内跑通。 - 遇到的每个卡点都改进 README 或代码。
uv run ruff check . && uv run ruff format --check . && uv run pyright && uv run pytest全部通过。- 最后检查一遍脱敏:
git grep搜公司名、内网域名、IP、密钥前缀,确认没有。
第 5 天:演示和讲解准备
- 录一段 2~3 分钟的演示(屏幕录制即可):提问 → 看时间线 → 杀进程 → 重启续跑 → 查看报告。
- 写
docs/talk.md:3 分钟讲解稿,结构是“问题(为什么多 Agent 难做可靠)→ 设计(状态机、权威源、后台作业、租约)→ 演示 → 取舍”,并引用 1~2 张案例卡说明“这些设计来自我在工作中踩过的坑”。这份稿子第 12 周会再压缩成面试口述稿。
第 6~7 天:缓冲 / 可选扩展
先确认 M1~M4 的验收全部完成。没完成就用这两天补,不做下面的扩展。
可选 M5(二选一,做一个就够,复用第 1 个月的 Go 技能):
- Go 网关:新建
zhuangang/agent-gateway/,Gin 服务:- 复用
task-board的auth包做登录和 JWT 校验; /api/runs/*用httputil.ReverseProxy转发到 Python 服务,设置FlushInterval = -1让 SSE 不被缓冲;- 转发时加上
X-User-ID请求头,Python 端runs表加owner_id,只能看自己的任务。 - 讲点:“Go 做接入层(鉴权、限流、转发),Python 做 Agent 编排”。
- 复用
- Vue / React 前端:用你最熟悉的框架重写
web/index.html,加上任务列表、报告页、错误提示,体现前端功底。
M4 验收(也是阶段 3 出门条件)
- 别人按 README 10 分钟内跑通(不需要真实模型密钥)
- 演示:启动 →(中途杀进程)→ 重启 → 最终结果可查
- 映射表至少 5 个概念对齐生产
- 架构图和 README 一致,并归档到总纲要求的位置
- 测试、ruff、pyright 全部通过,脱敏检查完成
- 提交并推送;在总纲进度记录里勾掉第 11 周和“阶段 3”,填上“迷你项目路径:
zhuangang/agent-mini/”
和案例卡的关系
第 6~7 周每张案例卡都有一栏“若重做的设计”。动手前把这些结论列出来,逐条对应到本项目的某个模块或某条设计取舍;对应不上的,在 docs/design-decisions.md 里说明为什么这次没做。面试时就能讲成:“我在工作中遇到 X 问题,根因是 Y,所以在自己的项目里用 Z 解决,这是代码和演示。”
附:如果阶段 0 改选了 LangGraph
只在第 5 周盘点时明确改选 LangGraph 才看这一节,不要两个都做。概念大致对应如下(具体 API 以 LangGraph 官方文档为准):
| 自写版 | LangGraph 里的对应 |
|---|---|
Orchestrator.step() 的规则 |
图的节点 + 条件边 |
Run / turns 状态 |
图的 State(TypedDict 或 pydantic 模型) |
commit_step 每步持久化 |
Checkpointer(有 SQLite / PostgreSQL 实现) |
| 崩溃后续跑 | 用同一个 thread_id 从最近的检查点继续 |
ask_human / awaiting_input |
interrupt() 暂停,之后带上人的回答恢复 |
| SSE 事件 | 图的流式输出(astream)转成 SSE |
| 租约防双跑、看门狗 | 框架不直接管,仍要自己在任务表上实现 |
M1(LLM 封装和错误码)、M3 的接口和 SSE、M4 的文档和映射表都照做。