--- id: plan.week09 title: "第 9 周:阶段 2——五个重点 A~E 深挖 + 案例卡" type: week domain: plan stack: [] week: [9] est_hours: 12 order: 9 --- # 第 9 周:阶段 2——五个重点 A~E 深挖 + 案例卡 > 第三段的第 2 周。上周 `week08-inventory-architecture.md`,下周 `week10-agent-m1-tools-mcp.md`。 **本周目标**:把五个重点各自做一个小实验,再结合你在公司遇到的真实问题写成**案例卡**。案例卡是简历“项目经历”和面试“讲一个你解决过的难题”的直接素材,也是项目三的设计输入。 **每天的固定结构** 1. 解释这个重点是什么、为什么难; 2. 跑一个**可运行的最小实验**(Python 标准库就能跑,不依赖公司代码),亲眼看到问题和解决办法; 3. 对照公司的实际情况写案例卡(只写机制,私有细节放 `notes/private/`)。 **案例卡模板**(每张卡一个文件,放 `notes/cards/`) ```text 标题: 对应重点:A / B / C / D / E 现象: 根因(一层): 根因(系统层,可选): 当时怎么处理 / 未处理: 可迁移结论(面试一句): 若重做的设计: 关联代码/路径:(私有,只写在 notes/private/ 里,不进仓库) 日期: ``` **本周产出** ```text notes/cards/ ├── A-orchestration.md ├── B-source-of-truth.md ├── C-streaming.md ├── D-human-in-loop.md ├── E-integration.md └── README.md # 汇总“若重做的设计”,作为项目三的设计输入 notes/experiments/ # 本周的实验脚本 ``` --- ## 第 1 天:A 多 Agent 编排 ### 学习内容 - 说明:多 Agent 编排要回答四个问题: 1. **谁派活**:固定顺序(链式)、有向无环图(DAG),还是由一个“协调者”模型动态决定? 2. **什么时候结束**:达成共识?轮数上限?成本上限?**没有上限的循环一定会出事**(模型可能永远不说“同意”)。 3. **怎么防止重复执行**:用户连点两次“开始”、或者前端重连后又发一次请求,同一个任务不能跑两遍——需要“同一时刻只有一个执行者”的保证(锁 / 租约 / 唯一约束)。 4. **每个角色的输入输出**:结构化(JSON + 校验),而不是自由文本。 - 案例:协调者 + 两个角色的最小循环,包含轮数上限、共识判断、防重复执行。 ```python import threading from dataclasses import dataclass, field @dataclass class RunState: topic: str round: int = 0 transcript: list[str] = field(default_factory=list) status: str = "running" def proposer(state: RunState) -> str: return f"方案 v{state.round}:用 Redis 做缓存" def critic(state: RunState) -> str: # 模拟:第 3 轮开始同意 return "同意" if state.round >= 3 else "反对:没考虑缓存一致性" def coordinator(state: RunState, max_rounds: int) -> str: """返回下一步:proposer / critic / finish。""" if state.transcript and state.transcript[-1].endswith("同意"): return "finish" if state.round >= max_rounds: return "finish" # 轮数上限是必须的“保险丝” return "proposer" if len(state.transcript) % 2 == 0 else "critic" running: set[str] = set() lock = threading.Lock() def start_run(run_id: str, topic: str, max_rounds: int = 5) -> RunState | None: with lock: # 防重复执行:同一个 run_id 同一时刻只允许一个执行者 if run_id in running: print(f"{run_id} 已在执行,拒绝重复启动") return None running.add(run_id) try: state = RunState(topic) while (step := coordinator(state, max_rounds)) != "finish": if step == "proposer": state.round += 1 state.transcript.append(f"提议者:{proposer(state)}") else: state.transcript.append(f"评审者:{critic(state)}") state.status = "consensus" if state.transcript[-1].endswith("同意") else "max_rounds" return state finally: with lock: running.discard(run_id) s = start_run("run-1", "缓存方案") assert s is not None print("\n".join(s.transcript)) print("结束原因:", s.status, "轮数:", s.round) s2 = start_run("run-2", "缓存方案", max_rounds=2) assert s2 is not None and s2.status == "max_rounds" print("run-2 结束原因:", s2.status) running.add("run-3") # 模拟 run-3 正在执行 assert start_run("run-3", "x") is None ``` - 说明(接着讲):上面的 `running` 集合只在一个进程内有效。多个进程 / 多台机器时要换成数据库唯一约束或 Redis 锁(第 3 周学过 `SET NX PX`),并且要有**过期时间**,否则执行者崩溃后锁永远不释放——第 12 周会实现带租约(lease)的版本。 **易错点 / 面试常问(补充)** - 易错点:案例卡只写机制,不写公司的接口路径、表名、字段名;私有细节放 `notes/private/`。 - 面试常问:多 Agent 什么时候结束(共识条件 + 最大轮数兜底);怎么防止同一个任务被两个进程同时执行(租约 / 锁 + 带条件的更新)。 ### 要建的文件 - `notes/experiments/a_orchestration.py`(上面的代码)、`notes/cards/A-orchestration.md` ### 运行方式 ```bash python3 notes/experiments/a_orchestration.py ``` ### 完成标准 - 案例卡写清:公司产品里“谁派活、什么时候结束、有没有防重复”。示例(**按你的真实情况改写**): ```text 标题:研讨轮次由前端驱动,缺少服务端的结束条件和防重复保证 对应重点:A 现象:同一个研讨在两个标签页里被同时推进,出现重复发言;偶尔出现停不下来的讨论 根因(一层):下一轮由前端发起,服务端不知道“当前该谁、是否已在执行” 根因(系统层):编排状态没有服务端的权威记录 当时怎么处理:前端加按钮禁用和轮数上限 可迁移结论:编排必须有服务端的唯一执行者和明确的结束条件(共识 + 轮数上限 + 成本上限) 若重做的设计:服务端状态机 + 执行租约 + 结构化的协调者输出 日期: ``` --- ## 第 2 天:C 流式与长任务 ### 学习内容 - 说明: - SSE 让用户实时看到生成过程,但**连接是脆弱的**:关页面、切网络、手机锁屏、代理超时都会断。 - 如果“任务的执行”绑在“这条连接”上(在请求处理函数里循环调用模型),连接一断,任务就停了。 - 正确做法:**执行和推送分离**。任务在后台独立执行,把每一步的事件写进持久化的事件表;SSE 只是“订阅事件”,断开后用 `Last-Event-ID` 从断点补发(第 4 周 FastAPI 示例已经演示过)。 - 案例:对比两种写法在“客户端中途断开”时的表现。 ```python import asyncio events: list[str] = [] async def work_step(i: int) -> str: await asyncio.sleep(0.02) return f"第 {i} 步完成" async def coupled_handler(): """写法一:执行绑在连接上。""" for i in range(1, 6): yield await work_step(i) async def background_job(job_events: list[str]): """写法二:后台执行,事件写进“事件表”。""" for i in range(1, 6): job_events.append(await work_step(i)) async def subscriber(job_events: list[str], last_id: int = 0): """SSE 订阅:只读事件,从 last_id 之后补发。""" while True: while last_id < len(job_events): last_id += 1 yield last_id, job_events[last_id - 1] if len(job_events) == 5: return await asyncio.sleep(0.01) async def main(): # 写法一:客户端收到 2 条后断开 received = [] gen = coupled_handler() async for e in gen: received.append(e) if len(received) == 2: break await gen.aclose() # 连接断开 → 生成器被关闭 → 后面的步骤再也不会执行 print("写法一:执行到", received[-1], "后就停了") # 写法二:客户端收到 2 条后断开,1 秒后重连 job_events: list[str] = [] task = asyncio.create_task(background_job(job_events)) last = 0 async for eid, e in subscriber(job_events): last = eid if eid == 2: break # 断开 await task # 后台任务不受影响,继续执行 resumed = [e async for _, e in subscriber(job_events, last_id=last)] print("写法二:重连后补发", resumed) assert len(resumed) == 3 asyncio.run(main()) ``` **易错点 / 面试常问(补充)** - 面试常问:用户关掉页面后为什么流程停了(编排循环跑在浏览器里),怎么改(编排放到后台作业,前端只订阅进度,断线后按 `Last-Event-ID` 补齐)。 ### 要建的文件 - `notes/experiments/c_streaming.py`、`notes/cards/C-streaming.md` ### 运行方式 ```bash python3 notes/experiments/c_streaming.py ``` ### 完成标准 - 在公司开发环境做一次“断网 / 关页面”实验(只观察,不改代码),记录“单轮继续”和“整个循环停掉”的区别,写进卡片。 - 可迁移结论示例:“长任务的生命周期不能等于一条 HTTP 连接的生命周期;执行放后台,连接只负责订阅,断线按事件 ID 补发。” --- ## 第 3 天:B 状态与权威源 ### 学习内容 - 说明: - 同一个事实(比如“任务进行到哪一步”)如果存了多份(前端内存、草稿、作业表、业务任务表),就会出现**不一致**:一处更新了,另一处没更新。 - 原则:**每类状态只有一个权威来源**,其他地方要么从它读,要么由它的变化事件**派生**;需要同时改两张表时,用**同一个事务**。 - 跨服务无法用一个事务时,用 **outbox(发件箱)模式**:在同一个事务里“改业务数据 + 写一条待发送事件”,再由后台程序把事件可靠地发出去(至少一次,接收方要幂等)。 - 案例:重现“作业进行到第 3 步,任务状态还是未开始”,再用“同一事务 + outbox”修好。 ```python import sqlite3 db = sqlite3.connect(":memory:") db.executescript(""" CREATE TABLE tasks (id INTEGER PRIMARY KEY, status TEXT NOT NULL); CREATE TABLE jobs (id INTEGER PRIMARY KEY, task_id INT, step INT, status TEXT); CREATE TABLE outbox (id INTEGER PRIMARY KEY AUTOINCREMENT, topic TEXT, payload TEXT, sent INT DEFAULT 0); INSERT INTO tasks VALUES (1, 'todo'), (2, 'todo'); INSERT INTO jobs VALUES (1, 1, 0, 'running'), (2, 2, 0, 'running'); """) def advance_bad(job_id: int, step: int) -> None: # 只更新作业表,“顺便”更新任务的代码写在另一个地方,而且忘了调用 db.execute("UPDATE jobs SET step = ? WHERE id = ?", (step, job_id)) db.commit() def advance_good(job_id: int, step: int, total: int = 3) -> None: with db: # 同一个事务:作业进度、任务状态、待发事件,要么都成功,要么都不写 db.execute("UPDATE jobs SET step = ?, status = ? WHERE id = ?", (step, "done" if step == total else "running", job_id)) db.execute("UPDATE tasks SET status = ? WHERE id = (SELECT task_id FROM jobs WHERE id = ?)", ("done" if step == total else "doing", job_id)) db.execute("INSERT INTO outbox (topic, payload) VALUES ('job.progress', ?)", (f"{job_id}:{step}",)) for s in (1, 2, 3): advance_bad(1, s) advance_good(2, s) print("坏写法:", db.execute("SELECT j.step, t.status FROM jobs j JOIN tasks t ON t.id = j.task_id WHERE j.id = 1").fetchone()) print("好写法:", db.execute("SELECT j.step, t.status FROM jobs j JOIN tasks t ON t.id = j.task_id WHERE j.id = 2").fetchone()) # 后台发件程序:把未发送的事件发出去(这里只打印),发完标记 for oid, topic, payload in db.execute("SELECT id, topic, payload FROM outbox WHERE sent = 0").fetchall(): print("发送事件", topic, payload) db.execute("UPDATE outbox SET sent = 1 WHERE id = ?", (oid,)) db.commit() ``` **易错点 / 面试常问(补充)** - 易错点:“先写业务表再发消息”和“先发消息再写业务表”都可能在中间崩溃,导致两边不一致;发件箱模式把“要发的消息”和业务数据放在同一个事务里写入,再由后台程序发送。 - 面试常问:什么是权威数据源(出现分歧时以谁为准);草稿和正式记录为什么要分开。 ### 要建的文件 - `notes/experiments/b_source_of_truth.py`、`notes/cards/B-source-of-truth.md` ### 运行方式 ```bash python3 notes/experiments/b_source_of_truth.py ``` ### 完成标准 - 在公司环境对比草稿数据和任务 / 过程报告,找出哪些状态没有回写,写进卡片(字段名、表名只写在 `notes/private/`)。 - 能讲清 outbox 模式解决了什么问题,为什么接收方要幂等。 --- ## 第 4 天:D 人机协作 ### 学习内容 - 说明: - Agent 必须停下来问人的几种情况:**需求不清楚**(澄清)、**高风险操作**(删除数据、发邮件、付款前确认)、**连续失败**(换模型或人工接管)、**置信度低**。 - 关键是“停下来”之后的状态:流程要**持久化地挂起**(状态 = 等待用户),用户可能几小时后才回复,回复后从挂起点继续,而不是从头再来。 - 第 12 周会用 LangGraph 的 `interrupt()` + `Command(resume=...)` 实现;今天先用最朴素的方式理解。 - 换模型恢复:失败时记录“失败在哪一步、错误码”,换模型后**只重跑失败的那一步**。 - 案例: ```python import json import sqlite3 db = sqlite3.connect(":memory:") db.execute("CREATE TABLE runs (id TEXT PRIMARY KEY, state TEXT, data TEXT)") def save(run_id: str, state: str, data: dict) -> None: db.execute("INSERT OR REPLACE INTO runs VALUES (?, ?, ?)", (run_id, state, json.dumps(data, ensure_ascii=False))) db.commit() def load(run_id: str) -> tuple[str, dict]: state, data = db.execute("SELECT state, data FROM runs WHERE id = ?", (run_id,)).fetchone() return state, json.loads(data) def call_model(model: str, prompt: str) -> str: if model == "model-a": raise RuntimeError("QUOTA_EXCEEDED") # 模拟额度不足 return f"[{model}] 对“{prompt}”的分析" def step(run_id: str, user_reply: str | None = None, model: str = "model-a") -> None: state, data = load(run_id) if state == "new": if len(data["request"]) < 6: # 需求太短,需要澄清 save(run_id, "waiting_human", {**data, "question": "请补充:目标用户是谁?预算多少?"}) return state = "analyzing" if state == "waiting_human": if user_reply is None: return # 还没回复,保持挂起 data["request"] += f"(补充:{user_reply})" state = "analyzing" if state in ("analyzing", "failed"): try: data["result"] = call_model(model, data["request"]) save(run_id, "done", data) except RuntimeError as e: save(run_id, "failed", {**data, "error": str(e), "failed_step": "analyzing"}) save("r1", "new", {"request": "做个网站"}) step("r1") print(load("r1")) # 挂起,等待澄清 step("r1", user_reply="面向中小企业,预算 5 万") print(load("r1")[0], load("r1")[1].get("error")) # 额度不足,失败 step("r1", model="model-b") # 换模型,只重跑失败的一步 print(load("r1")) ``` ### 要建的文件 - `notes/experiments/d_human_in_loop.py`、`notes/cards/D-human-in-loop.md` ### 运行方式 ```bash python3 notes/experiments/d_human_in_loop.py ``` ### 完成标准 - 在公司环境走一遍“需要澄清”或“换模型失败 → 恢复”,记录当时的表现:挂起状态存在哪?换模型后是从头开始还是从失败点继续? --- ## 第 5 天:E 与业务系统集成 ### 学习内容 - 说明: - 模型的输出最终要变成**业务记录**(任务、报告、工单),这一步要求: 1. **结构化**:用 JSON Schema / pydantic 校验,不合格就重试或报错,不能把自由文本直接塞进业务表; 2. **幂等**:网络重试、消息重复投递会导致同一结果写两次——用“幂等键”(如 `run_id + step`)加唯一约束; 3. **可审计**:记录是哪个模型、哪个版本的提示词、哪次运行产生的; 4. **权限隔离**:Agent 只能写它有权限的业务对象(第 3 周的“查询都带 board_id”是同一个道理)。 - 案例: ```python import json import sqlite3 db = sqlite3.connect(":memory:") db.execute("""CREATE TABLE reports ( id INTEGER PRIMARY KEY, idem_key TEXT UNIQUE NOT NULL, title TEXT NOT NULL, risk TEXT NOT NULL CHECK (risk IN ('low','medium','high')), model TEXT NOT NULL, prompt_version TEXT NOT NULL, run_id TEXT NOT NULL)""") def parse_report(raw: str) -> dict: data = json.loads(raw) if not isinstance(data.get("title"), str) or data.get("risk") not in ("low", "medium", "high"): raise ValueError(f"模型输出不符合约定:{raw}") return data def save_report(run_id: str, step: str, raw: str, model: str, prompt_version: str) -> str: data = parse_report(raw) try: with db: db.execute( "INSERT INTO reports (idem_key, title, risk, model, prompt_version, run_id) VALUES (?, ?, ?, ?, ?, ?)", (f"{run_id}:{step}", data["title"], data["risk"], model, prompt_version, run_id), ) return "created" except sqlite3.IntegrityError: return "duplicate-ignored" # 重复投递:幂等键已存在,直接忽略 raw = '{"title": "缓存方案评审", "risk": "medium"}' print(save_report("run-1", "summary", raw, "model-b", "summary@v3")) print(save_report("run-1", "summary", raw, "model-b", "summary@v3")) # 重试 / 重复投递 try: save_report("run-2", "summary", '{"title": "x", "risk": "unknown"}', "model-b", "summary@v3") except ValueError as e: print("拒绝写入:", e) print(db.execute("SELECT idem_key, title, risk, model, prompt_version FROM reports").fetchall()) ``` ### 要建的文件 - `notes/experiments/e_integration.py`、`notes/cards/E-integration.md`(可以和 B 合并,但五个重点都要覆盖) ### 运行方式 ```bash python3 notes/experiments/e_integration.py ``` ### 完成标准 - 跟一次公司里的过程报告 / 任务状态生成:LLM 结果怎样变成业务记录?有没有校验、幂等、审计字段?写进卡片。 --- ## 第 6 天:补写和打磨案例卡 ### 学习内容 - 说明:好的案例卡要能在面试里用 1 分钟讲完,结构就是 **STAR**(情境、任务、行动、结果)的压缩版。检查每张卡: - “现象”是否具体(用户看到了什么)? - “根因”有没有分两层(直接原因 + 系统设计原因)? - “可迁移结论”是否一句话、离开公司背景也成立? - “若重做的设计”是否能在项目三里实现? - 有没有任何真实名称、路径、数据? - 优先写成卡的题目(来自你已经遇到过的问题): 1. 关掉浏览器后,多轮研讨就停了(编排跑在前端)——C / A 2. 分析已经进行到第三步,但任务状态还是“未开始”(权威源 / 回写缺失)——B / E 3. 模型额度或鉴权失败,以及“换模型后恢复”——D 4. 主分支升级后某张表缺字段导致接口 500(应用依赖和数据库迁移)——可以作为第 6 张“工程化”卡(迁移要随代码一起发布,第 2 周学过) ### 要建的文件 - 修改 5 张卡;如果写得动,补第 6 张迁移卡 ### 运行方式 - 每张卡对着手机讲一遍,计时 ≤ 1 分钟。 ### 完成标准 - 每张卡都能 1 分钟讲完;脱敏检查脚本通过。 --- ## 第 7 天:汇总设计输入 ### 学习内容 - 说明:把五张卡的“若重做的设计”汇总成一张表,这就是项目三(第 10~13 周)的**需求清单**,每一项都要在项目里有对应实现。 - 案例: ```markdown | 来源卡片 | 若重做的设计 | 项目三里的实现 | 周 | | --- | --- | --- | --- | | A | 服务端编排 + 结束条件(共识 / 轮数 / 成本) | LangGraph 条件边 + max_rounds | 11 | | A | 同一任务只有一个执行者 | runs 表 + 租约(lease) | 12 | | C | 执行与推送分离,断线补发 | 后台作业 + 事件表 + SSE Last-Event-ID | 12 | | B | 单一权威源 | 状态只在 checkpointer / runs 表,前端只读 | 12 | | D | 持久化挂起,回复后继续 | interrupt() + Command(resume=...) | 12 | | D | 换模型只重跑失败步骤 | 从检查点恢复 + 模型配置可切换 | 12 | | E | 结构化输出 + 幂等 + 审计 | pydantic 校验 + 幂等键 + 记录模型和提示词版本 | 11、13 | ``` ### 要建的文件 - `notes/cards/README.md`;在总纲进度表勾掉第 9 周 ### 完成标准 - [ ] A~E 每个重点至少一张案例卡 - [ ] 每张卡都写了“若重做,我会怎样设计” - [ ] 设计输入表完成,每一项都对应到第 10~13 周 --- ## 本周相关专题 > 本节由 `tools/gen_week_topics.py` 根据专题文件的 `week` 字段自动生成,请勿手改;想调整就修改对应专题文件的 frontmatter 后重新生成。 - **AI 编程练习(每周)**:本周至少一个功能用 AI 编程工具(Cursor / Claude Code / Codex 等)完成,按 [AI 编程:工作流](../topics/ai/ai-coding/01-workflow-rules-skills.md) 写清需求和规则,按 [验证与证据](../topics/ai/ai-coding/02-verify-evidence.md) 保存测试结果和会话记录(脱敏后),第 15 周整理成“AI 编程 + 验证方法”的应聘案例。 ## 本周自查清单 - [ ] 5 个实验脚本都跑过,能讲清每个实验演示了什么 - [ ] 5 张案例卡(脱敏),每张 1 分钟能讲完 - [ ] 设计输入表 - [ ] 能回答:“为什么编排不该只活在浏览器里?”“权威源是什么?”“outbox 模式解决什么问题?”“Agent 什么时候要停下来问人?”“LLM 结果怎么安全地写进业务系统?”