Files
zhuangang/plan/agent-mini-project.md
T

37 KiB
Raw Blame History

转岗计划 · 第 9~11 周:Python 迷你多 Agent 项目(阶段 3 主交付)

所属阶段:第 3 个月,阶段 3。总纲见同目录 README.md,前置补课见 week08-python-basics.md。

目标

把在公司里见过的多 Agent 产品“削”成一个没有业务外壳、可以公开演示的内核,证明你“会造”,而不只是“会改”。

做完后要能演示:

  1. 提一个问题 → 协调者派活给两个专家 → 多轮讨论 → 达成共识或到达最大轮数后结束 → 生成可审计的报告。
  2. 中途 kill -9 杀掉服务进程 → 重新启动 → 任务从上一步接着跑完,已完成的步骤不重复。
  3. 浏览器通过 SSE 看进度;关掉页面任务照跑;重新打开能补齐漏掉的事件。
  4. 协调者觉得信息不够时停下来问人,人回答后继续。
  5. 模型调用失败(超时、限流、额度、鉴权)时,任务进入失败状态并带上可读的错误码,可以换模型后继续。

项目名: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(协调者要求问人)
   │                           │
   └──(人回答 / 失败后重试 / 进程崩溃后租约过期)──┘

一“步”是什么

一步 = 协调者做一次决定 + 被派到的专家回答一次。每一步完成后,在同一个数据库事务里:

  1. 插入这一步的发言记录 turns(主键 run_id + step,重复插入会被忽略,保证不会写出两条);
  2. 更新 runs.next_step、runs.round、runs.status;
  3. 追加事件 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,别让测试变慢。
  • 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) -> str
    • class 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,规则按顺序:
      1. 最近一次评审是 APPROVE → 直接 summarize,completed,结束原因“评审通过”。
      2. run.round >= run.max_rounds → 直接 summarize,completed,结束原因“达到最大轮数”。
      3. 否则调 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 aiosqlite
  • src/agent_mini/store/schema.sql:见上面“数据库表”。
  • src/agent_mini/store/base.py:class RunStore(Protocol),方法:
    • create_run(question, max_rounds, model) -> Run
    • get_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:
      1. 从数据库读 run 和 turns(每次恢复都从数据库重建状态,不依赖内存)。
      2. 循环:检查 should_stop() 和数据库里的状态(被取消就退出)→ step() → commit_step() → 续租。
      3. AgentError 时把任务置为 failed,写 error_code、error_message,追加 run_failed 事件。
      4. 结束、等人、失败时释放租约。
  • 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 不能抢走任务。
  • scripts/demo_crash_resume.sh:
    1. 用 FAKE_DELAY_S=2 后台启动服务,记下进程号;
    2. curl -X POST /runs 创建任务;
    3. 等 5 秒,kill -9 杀掉服务;
    4. 打印此时数据库里的步骤数;
    5. 重新启动服务,轮询 /runs/{id} 直到 completed;
    6. 打印报告,显示步骤连续不重复。

运行方式:uv run pytest tests/test_runner_resume.py -v;bash scripts/demo_crash_resume.sh

第 5 天:FastAPI 接口

要建的文件

  • uv add fastapi uvicorn
  • src/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,任务能继续跑完。

第 7 天:极简页面和端到端走查

要建的文件

  • web/index.html(原生 JS,不超过 200 行;你熟悉 Vue / React 的话之后可以重写,但本周先用最简单的):
    • 输入问题 → POST /runs → 用 EventSource 订阅。
    • 时间线:每一步显示角色、指令、回答;结束后显示结论和“查看报告”链接。
    • awaiting_input 时出现输入框,提交到 /input。
    • run_failed 时显示错误码、中文提示和“重试”按钮(可填新模型名)。
    • 显示连接状态(已连接 / 重连中)。
  • api/app.py:GET / 返回这个页面。

手动走查:

  1. 正常跑完一次;
  2. 跑到一半关掉标签页,过一会儿打开同一任务,能看到完整时间线;
  3. 跑到一半 Ctrl+C 停服务再启动,页面自动重连后继续收到事件;
  4. 用错误的 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 图:
    1. 组件图:浏览器 → FastAPI(routes / SSE)→ RunManager → Orchestrator → 角色 → LLMClient;存储层和 EventNotifier 的位置。
    2. 状态图(stateDiagram-v2):6 个状态和所有转换条件。
    3. 时序图(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,每条写“选择 / 理由 / 代价”:
    1. 编排放在服务端后台,而不是浏览器循环(关页、刷新、断网都不影响)。
    2. 每步一个事务 + (run_id, step) 主键:写入恰好一次,模型调用至少一次。
    3. 租约而不是进程内锁:多进程、多机器也不会双跑;代价是崩溃后要等租约过期才恢复。
    4. 数据库是权威,SSE 只是视图:断线重连靠 seq 补发。
    5. 结束条件写在代码里,不只靠模型判断。
    6. SQLite 而不是 PostgreSQL:方便别人跑;存储层有协议隔离,换 PostgreSQL 只需新写一个实现(可选练习:用第 3 周学的 PostgreSQL 写 pg_store.py)。
    7. 没做的事(诚实写):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 的文档和映射表都照做。