新增 RAG、阶段0~2、Agent 项目 M1~M4、后端补强与部署、简历包装和面试周计划;补充易错点和面试常问,移除旧的 RAG/Agent 合并文件

This commit is contained in:
Shikui Wang committed 2026-10-23 11:11:37 +08:00
1 parent 27c1965070
commit 32b111606f
13 files changed
+9402 -861

No files matched your search

-535
View File
@@ -1,535 +0,0 @@
# 转岗计划 · 第 11~14 周:多 Agent 项目(项目三 · 阶段 3 主交付)
> 所属阶段:第三段“Agent 主线”。总纲见同目录 `README.md`。前置:第 6 周 Python、第 7~8 周 RAG 项目、第 9~10 周盘点和案例卡。
## 目标
把在公司里见过的多 Agent 产品“削”成一个**没有业务外壳**、可以公开演示的内核,证明你“会造”,而不只是“会改”。主框架用 **LangGraph**(招聘里要求 LangChain / LangGraph 实际项目经验的很多),但先花几天**手写一遍循环**,把原理吃透,面试时既能讲框架也能讲原理。
做完后要能演示:
1. 提一个问题 → 协调者派活给两个专家 → 专家可以调用工具(包括通过 MCP 调用你第 7~8 周做的 RAG 知识库)→ 多轮讨论 → 达成共识或到达最大轮数后结束 → 生成可审计的报告。
2. 中途 `kill -9` 杀掉服务进程 → 重新启动 → 任务从最近的检查点接着跑完。
3. 浏览器通过 SSE 看进度;关掉页面任务照跑;重新打开能补齐漏掉的事件。
4. 协调者觉得信息不够时停下来问人(LangGraph `interrupt`),人回答后继续。
5. 记得用户的偏好和以前的结论(长期记忆),下次提问时用上。
6. 有一个小评测集,能用数字说明改提示词或流程之后是变好还是变差。
另外用 1~2 天体验 Dify 或 Coze(扣子),能讲清低代码平台和写代码各自适合什么场景。
项目名:`agent-mini`,放在 `zhuangang/agent-mini/`。
## 范围
| 必须有 | 禁止有(本阶段) |
| --- | --- |
| 至少 2 个专家(提案者 proposer、评审者 critic)+ 1 个协调者 coordinator | 完整任务中心、字典、巡检 |
| 明确的结束条件:评审通过(共识)或达到最大轮数,规则写在代码里 | 照搬公司 UI、提示词或代码 |
| 工具调用(Function / Tool Calling),至少一个工具来自自己写的 MCP 服务 | 依赖公司内网才能演示 |
| 状态持久化(LangGraph 检查点存 SQLite,可选 PostgreSQL)+ 重启续跑 | 没有说明的纯聊天 demo 页 |
| 编排跑在服务端后台;SSE 推送进度,断线可补齐 | 一次接太多框架(LangGraph 之外不再引入别的 Agent 框架) |
| 人机协作(中断问人)、长期记忆、评测集 | |
| 不需要真实模型密钥也能跑通测试和界面演示(假模型) | |
讨论的题目用通用例子,比如“一个 5 人创业团队做内部工具,该选什么技术栈”“给一个读书 App 设计会员规则”,不要用公司业务题目。
## 技术选型
| 用途 | 选择 |
| --- | --- |
| 语言和依赖 | Python 3.12 + uv |
| 手写阶段 | asyncio + httpx(复用 RAG 项目的模型客户端思路) |
| 主框架 | LangGraph(StateGraph、检查点、interrupt、Store),模型接入用 LangChain 的对话模型封装 |
| 工具 | Function Calling;MCP 服务用官方 Python SDK(或 FastMCP)写;Agent 侧用 LangChain 的 MCP 适配器加载工具 |
| 检查点 | SQLite 检查点(`langgraph-checkpoint-sqlite`),可选 PostgreSQL 检查点 |
| 业务表 | 自己的 `runs` / `events` 表(SQLite),存任务状态、租约、事件 |
| 服务 | FastAPI + SSE |
| 前端 | 极简 `web/index.html`,可选用 Vue / React 重写 |
| 质量 | pytest、ruff、pyright;评测脚本 |
> LangGraph、LangChain 和 MCP 相关库更新很快,包名、导入路径、推荐写法都可能变(比如 MCP 适配器最近就从独立包并入了 LangChain)。**动手前以官方文档当前版本为准**,本文写的是概念和要建的文件,具体 API 名称如有出入按文档改。
## 核心设计(先读懂,再动手)
### 两层状态:检查点 vs 业务表(重点 B)
- **LangGraph 检查点**:图的内部状态(每个节点执行完的快照),用 `thread_id` 区分,负责“从哪一步接着跑”。
- **自己的业务表 `runs` / `events`**:任务的对外状态(排队、运行、等人、完成、失败、取消)、谁在跑(租约)、给前端的事件流、最终报告。列表页、权限、计费、审计都查这里。
为什么要两层:检查点是框架的内部格式,不适合直接给业务查询;业务表是**对外的权威来源**。这正是你在工作中见过的“草稿 / 作业 / 任务状态谁说了算”的问题,在这里给出你自己的答案,并写进 README。
### 状态机(业务表 `runs.status`)
```text
queued ──(被某个进程领取)──> running ──> completed
^ │ ├──> failed(模型错误、输出无法解析等,带 error_code)
│ │ ├──> cancelled(用户取消)
│ │ └──> awaiting_input(图里调用了 interrupt)
└──(人回答 / 失败后重试 / 进程崩溃后租约过期)──┘
```
### 图的结构(LangGraph)
```text
START → load_memory → coordinator ─┬→ proposer ─┐
^ ├→ critic ─┤
│ ├→ ask_human─┤(interrupt)
└─────────┴────────────┘
coordinator ─→ summarize → save_memory → END
```
- 路由函数(条件边)里**先检查硬规则**:最近一次评审是 `VERDICT: APPROVE` → 去 `summarize`;轮数达到上限 → 去 `summarize`;否则按协调者的结构化决定走。
- 专家节点内部可以调用工具(工具调用循环可以用 LangGraph 预置的工具节点,也可以自己写一个小循环)。
### 防止双跑(重点 A)和续跑(重点 C)
- `runs` 表上有租约字段 `lease_owner`、`lease_until`,带条件的 `UPDATE` 领取任务,运行中定期续租;进程崩溃后租约过期,看门狗重新领取,用同一个 `thread_id` 从最近检查点继续。
- 节点内正在进行的模型调用在恢复后会**重做**(至少一次),已经完成的节点不会重做。工具如果有副作用(比如“创建待办”),要设计成幂等(带请求 ID),README 里写清楚。
---
## 最终项目结构
```
agent-mini/
├── README.md
├── pyproject.toml
├── .env.example # 默认 LLM_PROVIDER=fake
├── docker-compose.yml # agent-mini、mcp 服务;可连第 8 周的 rag-qa
├── Dockerfile
├── docs/
│ ├── architecture.md # 组件图、状态机、图结构、崩溃恢复时序图
│ ├── design-decisions.md # 关键取舍
│ ├── mapping.md # 与生产概念的映射表
│ ├── handwritten-vs-langgraph.md # 手写版和 LangGraph 版对照
│ └── talk.md # 3 分钟讲解稿
├── handwritten/ # 第 11 周:手写版(理解原理,保留作对照)
│ ├── llm.py # 最小模型客户端(支持 tools 参数)
│ ├── tool_loop.py # 手写工具调用循环
│ ├── multi_agent_loop.py # 手写协调者 + 两个专家循环
│ └── test_handwritten.py
├── mcp_servers/
│ ├── knowledge_server.py # MCP 服务:知识库检索(调 rag-qa)、读笔记
│ └── todo_server.py # MCP 服务(可选):创建待办(调项目一的接口,幂等)
├── src/agent_mini/
│ ├── __init__.py
│ ├── config.py
│ ├── errors.py # 错误码
│ ├── models.py # Decision、RunStatus、事件结构
│ ├── llm.py # 构造对话模型:真实 / 假模型
│ ├── tools/
│ │ ├── local_tools.py # 本地工具:计算、当前时间
│ │ └── mcp_tools.py # 通过 MCP 加载外部工具
│ ├── graph/
│ │ ├── state.py # 图的 State 定义(含 reducer)
│ │ ├── prompts.py # 各角色提示词
│ │ ├── nodes.py # coordinator / proposer / critic / ask_human / summarize
│ │ ├── routing.py # 条件边:硬规则 + 协调者决定
│ │ ├── memory.py # load_memory / save_memory 节点(长期记忆)
│ │ └── build.py # 组装并编译图
│ ├── store/
│ │ ├── schema.sql # runs / events 业务表
│ │ └── run_store.py # 业务表读写、租约
│ ├── runner.py # RunManager:后台运行、租约续期、看门狗、取消
│ ├── report.py # 由检查点状态生成可审计报告
│ ├── api/
│ │ ├── app.py
│ │ ├── routes_runs.py
│ │ └── sse.py
│ └── cli.py
├── evals/
│ ├── cases.jsonl # 评测用例
│ ├── checks.py # 规则检查 + 模型评分
│ ├── run_evals.py
│ └── reports/ # 每次评测的结果
├── web/index.html
├── scripts/demo_crash_resume.sh
└── tests/
├── conftest.py
├── test_routing.py
├── test_nodes.py
├── test_graph_resume.py
├── test_run_store.py
├── test_mcp_server.py
└── test_api_sse.py
```
## 业务表(`store/schema.sql`)
```sql
CREATE TABLE IF NOT EXISTS runs (
id TEXT PRIMARY KEY, -- 同时作为 LangGraph 的 thread_id
user_id TEXT NOT NULL DEFAULT 'demo',
question TEXT NOT NULL,
status TEXT NOT NULL, -- queued/running/awaiting_input/completed/failed/cancelled
model TEXT NOT NULL,
max_rounds INTEGER NOT NULL,
human_question TEXT,
final_answer TEXT,
end_reason TEXT,
error_code TEXT,
error_message TEXT,
lease_owner TEXT,
lease_until REAL,
created_at REAL NOT NULL,
updated_at REAL NOT NULL
);
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/node_finished/tool_called/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);
```
## 接口设计
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| POST | `/runs` | 创建任务 `{question, max_rounds?}`,立即返回 `{id, status: "queued"}` |
| GET | `/runs` / `/runs/{id}` | 列表 / 详情(状态 + 讨论记录) |
| GET | `/runs/{id}/events` | SSE,支持 `Last-Event-ID` 或 `?after=seq` 补发 |
| POST | `/runs/{id}/input` | 回答问题 `{answer}`,仅 `awaiting_input` 可用 |
| POST | `/runs/{id}/retry` | 失败后重试,可换模型 `{model?}` |
| POST | `/runs/{id}/cancel` | 取消 |
| GET | `/runs/{id}/report` | Markdown 报告 |
| GET | `/memories` | 查看当前用户的长期记忆(可删除) |
---
## 第 11 周 · M1:手写循环 + Tool Calling + MCP
本周不用 LangGraph,目的是搞懂原理。代码放在 `handwritten/` 和 `mcp_servers/`。
### 第 1 天:项目初始化和最小模型客户端
**学习内容**
- 复习:消息列表(system / user / assistant / tool)、温度、最大输出长度、流式输出。
- 错误码沿用 RAG 项目(超时、限流、额度、鉴权、输出异常),哪些重试、哪些不重试。
**要建的文件**
- `uv init --package agent-mini`;先只加 `httpx pydantic pydantic-settings`,开发依赖 `pytest pytest-asyncio ruff pyright`。
- `src/agent_mini/config.py`、`src/agent_mini/errors.py`、`.env.example`、`.gitignore`。
- `handwritten/llm.py`:`async def chat(messages, tools=None) -> AssistantMessage`,`AssistantMessage` 含 `content` 和 `tool_calls`(id、函数名、参数 JSON 字符串)。
### 第 2 天:手写工具调用循环(Function / Tool Calling)
**学习内容**
- 工具调用的完整过程:把工具的名字、说明、参数 JSON Schema 发给模型 → 模型返回“想调用哪个工具、参数是什么” → **你的代码**执行工具 → 把结果作为 `tool` 消息追加回去 → 再问模型 → 直到模型给出最终回答。模型本身从不直接执行任何东西。
- 安全和可靠性:参数要校验(pydantic);工具出错时把错误信息返回给模型,让它自己纠正,而不是整个流程崩溃;限制最大循环次数;有副作用的工具要幂等、要能审计。
**要建的文件**
- `handwritten/tool_loop.py`:
- `@dataclass class Tool`:`name`、`description`、`args_model: type[BaseModel]`、`func`。
- `def to_schema(tool) -> dict`:用 `args_model.model_json_schema()` 生成工具描述。
- 三个工具:`calculator(expression)`(只允许数字和四则运算,不能用 `eval` 执行任意代码)、`now()`、`search_knowledge(query)`(调用第 8 周 `rag-qa` 的 `/ask` 接口;没启动时返回错误信息)。
- `async def run_tool_loop(question, tools, max_steps=6) -> LoopResult`:记录每一步调用了什么工具、参数、结果、耗时。
- `handwritten/test_handwritten.py`:用假模型(按脚本返回 `tool_calls`)测:正常调用后得出答案;参数不合法时错误被返回给模型;超过最大步数时停止。
**完成标准**:用真实模型问“距离 2027 年元旦还有多少天,再乘以 3”,日志里能看到先调 `now` 再调 `calculator`。
### 第 3 天:手写多 Agent 循环
**学习内容**
- 多 Agent 的本质:几个“角色 = 系统提示词 + 能看到的上下文 + 可用工具”,加上一个决定“下一步谁来做、什么时候结束”的调度逻辑。
- 结束条件写在代码里,不只靠模型自觉。
**要建的文件**
- `handwritten/multi_agent_loop.py`(目标 150~250 行):
- 协调者输出 JSON 决定 `{"action": "dispatch|ask_human|finish", "target", "instruction", "reason"}`,用 pydantic 校验,格式错误时带上错误重试一次。
- 提案者、评审者两个角色;评审者最后一行必须是 `VERDICT: APPROVE` 或 `VERDICT: REVISE`。
- 循环:硬规则(评审通过 / 达到最大轮数)→ 协调者决定 → 执行 → 记录。
- 每一步把状态写到一个 JSON 文件(先体会“持久化 + 续跑”要解决什么问题:进程中途退出后,从文件里读出状态继续)。
- 测试补充到 `handwritten/test_handwritten.py`:评审第二次通过时提前结束;一直不通过时在最大轮数结束。
**完成标准**:能不看代码讲清这个循环的每一步;能说出“写 JSON 文件”这种持久化方式有什么问题(并发、原子性、查询),为后面用检查点和数据库做铺垫。
### 第 4 天:MCP 概念 + 写一个 MCP 服务
**学习内容**
- MCP(Model Context Protocol)是什么:让 AI 应用以统一的方式接入外部工具和数据的协议。角色分为宿主(比如 Cursor、Claude 桌面版)、客户端、服务端;服务端可以提供工具(tools)、资源(resources)、提示词模板(prompts)。
- 传输方式:本地进程用 stdio,远程服务用 HTTP(Streamable HTTP)。
- 和 Function Calling 的关系:Function Calling 是“模型怎么表达要调工具”;MCP 是“工具怎么被统一发现和调用”,写一次 MCP 服务,各种支持 MCP 的应用都能用。
**要建的文件**
- 加依赖:MCP 官方 Python SDK(`mcp`)或 FastMCP,按官方文档选一个。
- `mcp_servers/knowledge_server.py`:
- 工具 `search_knowledge(query: str, top_k: int = 5)`:调用 `rag-qa` 的检索接口,返回片段和出处。
- 工具 `list_documents()`。
- 资源(可选):`notes://cards` 列出你的案例卡标题(只读脱敏后的 `notes/cards/`)。
- 每个工具都写清楚说明和参数说明(模型靠这些判断什么时候调用)。
- `mcp_servers/todo_server.py`(可选):工具 `create_todo(board_id, title, request_id)`,调用项目一“团队任务看板”的接口;`request_id` 用来保证重复调用不会建出两条(体会“有副作用的工具要幂等”)。
- `tests/test_mcp_server.py`:直接调用工具函数,测试正常和 `rag-qa` 不可用时的错误返回。
**运行方式**:用官方调试工具 MCP Inspector(`npx @modelcontextprotocol/inspector` 加上启动你服务的命令)连上去,手动调用每个工具。
**完成标准**:在 Inspector 里能看到并调用你的工具。
### 第 5 天:在 Cursor 和自己的 Agent 里用 MCP
**要建的文件**
- 在 Cursor 的 MCP 配置里加上你的 `knowledge_server`,在 Cursor 对话里让它“查一下我的知识库里关于 X 的内容”,确认能调用。把配置示例写进 `docs/mcp-usage.md`(不要提交真实路径里的个人信息和密钥)。
- `src/agent_mini/tools/mcp_tools.py`:用 LangChain 的 MCP 适配器(当前版本的推荐方式以官方文档为准)把 MCP 工具加载成 LangChain 工具,下周给 LangGraph 用。
- `handwritten/tool_loop.py`:加一个选项,从 MCP 服务读取工具列表来替代本地的 `search_knowledge`,体会“工具可以动态发现”。
**完成标准**:同一个 MCP 服务,Cursor 和你自己的 Agent 都能用。
### 第 6~7 天:对照文档和 M1 验收
- `docs/handwritten-vs-langgraph.md`:先写“手写版”这半边:循环、状态、持久化、工具调用、结束条件分别是怎么做的,哪里最容易出错。下周学完 LangGraph 再补另一半。
- 余下时间补测试、整理代码,提交推送。
**M1 验收**
- [ ] 手写工具调用循环和多 Agent 循环都能跑,测试通过
- [ ] 能讲清 Function Calling 的完整过程,以及工具出错、参数不合法、死循环怎么处理
- [ ] 写了至少一个 MCP 服务,Inspector 和 Cursor 都能调用
- [ ] 能讲清 MCP 和 Function Calling 的关系
- [ ] 在总纲进度记录里勾掉第 11 周
---
## 第 12 周 · M2:用 LangGraph 实现多 Agent 编排
### 第 1 天:LangGraph 基础
**学习内容**(跟官方入门教程走,边看边在 `scratch/` 里敲)
- `StateGraph`:状态(State)、节点(node,普通函数,读状态、返回要更新的字段)、边、条件边、`START` / `END`。
- reducer:字段怎么合并(比如消息列表是追加而不是覆盖)。
- 编译图、运行(一次性 / 流式)、画出图结构(导出 mermaid)。
- 预置的工具调用节点 / 预置的 ReAct Agent:知道有,什么时候用。
**完成标准**:照着教程做出一个带条件边和工具调用的小图,能说出它和你上周手写版的对应关系。
### 第 2 天:设计 State 和路由
**要建的文件**
- 加依赖:`langgraph`、`langchain-core`、`langchain-openai`(或你用的模型服务对应的集成包),以官方文档为准。
- `src/agent_mini/models.py`:`Decision`(pydantic,同上周)、`RunStatus`。
- `src/agent_mini/graph/state.py`:
- `question`、`turns`(追加型 reducer,每条含角色、指令、内容、调用过的工具)、`round`、`max_rounds`、`last_verdict`、`decision`、`human_answers`、`final_answer`、`end_reason`、`memories`。
- `src/agent_mini/graph/routing.py`:
- `def route_after_coordinator(state) -> str`:按顺序检查:评审已通过 → `"summarize"`;`round >= max_rounds` → `"summarize"`;`decision.action` 是 `dispatch` → 目标专家;`ask_human` → `"ask_human"`;`finish` → `"summarize"`。
- `tests/test_routing.py`:每条规则至少一个用例(纯函数测试,不需要模型)。
### 第 3 天:节点和提示词
**要建的文件**
- `src/agent_mini/graph/prompts.py`:协调者、提案者、评审者、总结者的提示词,全部自己写。
- `src/agent_mini/llm.py`:`def build_chat_model(settings)`:真实模型(OpenAI 兼容接口,配置 `base_url`、超时、重试)或假模型(测试用:按角色返回预设回答;工具调用的假回答如果麻烦,测试只覆盖不调工具的路径)。
- `src/agent_mini/graph/nodes.py`:
- `coordinator`:用结构化输出得到 `Decision`(模型不支持时退回“提取 JSON + pydantic 校验 + 重试一次”)。
- `proposer`:可以调用工具(`search_knowledge` 来自 MCP、`calculator`、`now`),内部是一个有最大次数限制的工具调用循环。
- `critic`:输出评审意见和 `VERDICT`,解析后写入 `last_verdict`。
- `summarize`:生成最终结论,写 `end_reason`。
- 每个节点控制传给模型的上下文:只给问题、最近 N 条发言摘要和本次指令(上下文工程)。
- `tests/test_nodes.py`:用假模型测协调者输出格式错误时的重试、评审结论解析。
### 第 4 天:组装图,命令行跑通
**要建的文件**
- `src/agent_mini/graph/build.py`:`def build_graph(model, tools, checkpointer=None, store=None)`:加节点、边、条件边,编译。
- `src/agent_mini/cli.py`:
- `agent-mini graph`:导出图结构的 mermaid,贴进 `docs/architecture.md`。
- `agent-mini run "问题" --memory-only`:不带持久化,流式打印每个节点的更新(谁、做了什么、调了什么工具)。
**运行方式**:先启动 `rag-qa` 和 MCP 服务,再 `uv run agent-mini run "5 人创业团队做内部工具该选什么技术栈,结合我的知识库里的笔记"`。
### 第 5 天:真实模型试跑和调提示词
- 用真实模型跑 5 个不同题目,输出存到 `docs/samples/`(检查没有敏感信息)。
- 观察:协调者是否按格式输出、评审是否太容易通过、专家是否乱调工具、轮数和耗时。调整提示词,每次改动记一行到 `docs/prompt-changelog.md`(第 14 周评测时会用到)。
### 第 6~7 天:对照文档、测试和 M2 验收
- 补完 `docs/handwritten-vs-langgraph.md` 的另一半:手写版的每个部分在 LangGraph 里对应什么,框架帮你做了什么,又有哪些事(租约、业务状态、幂等)框架不管、还得自己做。
- 测试、ruff、pyright 通过,提交推送。
**M2 验收**
- [ ] LangGraph 版能跑完一次带工具调用的多轮讨论,结束条件正确
- [ ] 路由规则有单元测试
- [ ] 能讲清 State、节点、条件边、reducer,以及和手写版的对应关系
- [ ] 在总纲进度记录里勾掉第 12 周
---
## 第 13 周 · M3:持久化续跑 + 人机协作 + 后台作业 + SSE + 记忆
### 第 1 天:检查点和续跑
**学习内容**
- 检查点(checkpointer):每个节点执行完自动保存状态;同一个 `thread_id` 再次运行就从最近的检查点继续;还能查看历史状态。
- 用 SQLite 检查点(需要 `aiosqlite`),注意用完关闭连接,否则程序可能退不出去。官方说明 SQLite 检查点不建议用于生产,生产用 PostgreSQL 检查点——这一点写进 README 的“取舍”里。
**要建的文件**
- `build.py`:接上 SQLite 检查点。
- `tests/test_graph_resume.py`:用假模型(每次调用加一点延迟)运行,跑完 2 个节点后取消任务(模拟崩溃);再用同一个 `thread_id` 继续运行,断言最终完成、已完成的节点没有重复执行(统计假模型被调用的次数)。
**完成标准**:能讲清检查点保存了什么、恢复时从哪里继续、正在执行的节点恢复后会怎样。
### 第 2 天:业务表和后台作业
**要建的文件**
- `src/agent_mini/store/schema.sql`、`run_store.py`:
- `create_run`、`get_run`、`list_runs`、`set_status`、`append_event`、`list_events(run_id, after_seq)`。
- 租约:`try_acquire(run_id, worker_id, lease_seconds) -> bool`(带条件的 `UPDATE`,影响行数为 1 才算成功)、`renew_lease`、`release`、`find_resumable(now)`。
- `src/agent_mini/runner.py`:`class RunManager`:
- `submit(run_id)`:领取成功才启动后台任务,任务对象保存起来。
- `_drive(run_id)`:用 `thread_id = run_id` 流式运行图;每收到一个节点更新就写一条 `events`;定期续租;正常结束、中断、出错时更新 `runs` 并释放租约。
- `watchdog()`:定期找“该有人跑但没人跑”的任务重新提交。
- `cancel(run_id)`、`shutdown()`。
- `tests/test_run_store.py`:两个 worker 抢同一任务只有一个成功;租约过期后另一个能抢到。
### 第 3 天:人机协作(interrupt)和失败重试
**学习内容**
- LangGraph 的 `interrupt`:在节点里暂停并把问题交给调用方,状态由检查点保存;之后用“恢复命令”带上人的回答继续,`interrupt` 的返回值就是人的回答。
- 注意:恢复时这个节点会**从头重新执行**,所以 `interrupt` 之前不要放有副作用的操作。
- 什么时候必须停下来问人(重点 D):信息不足、要执行有副作用或高成本的操作、多次失败。
**要建的文件**
- `nodes.py`:`ask_human` 节点调用 `interrupt`,把问题写进状态。
- `runner.py`:检测到中断时 `runs.status = awaiting_input`,写 `human_question`,追加 `awaiting_input` 事件,释放租约。
- `POST /runs/{id}/input` 的处理逻辑(下面第 4 天接口里实现):写 `input_received` 事件,状态改回 `queued`,后台用恢复命令继续。
- 失败重试:`AgentError` 时 `runs.status = failed` 并写错误码;`POST /retry` 可以换模型后从检查点继续(对应工作中“额度不足 → 换模型恢复”的真实问题)。
- 测试:中断 → 回答 → 完成;模型报错 → 失败 → 换模型重试 → 完成。
### 第 4 天:FastAPI 接口和 SSE
**要建的文件**
- `src/agent_mini/api/app.py`:`lifespan` 里打开检查点和业务库、构建图、启动 `RunManager` 和看门狗;关闭时依次清理。
- `src/agent_mini/api/routes_runs.py`:接口表里除 SSE 外的接口。
- `src/agent_mini/api/sse.py`:从 `events` 表按 `seq` 补发,再等新事件(进程内通知加速,超时就轮询数据库兜底);15 秒无事件发心跳;任务到终态后发 `end` 并结束;客户端断开不影响任务。
- `src/agent_mini/report.py`:从检查点状态和 `events` 生成报告:问题、每一步谁做了什么、调用了哪些工具和结果摘要、结束原因、最终结论、使用的模型(重点 E:结果可审计)。
- `tests/test_api_sse.py`:创建 → 读 SSE 到结束;带 `Last-Event-ID` 只收到之后的事件;不存在的任务 404。
### 第 5 天:长期记忆(Memory)
**学习内容**
- 短期记忆:当前任务(线程)内的状态和对话,检查点已经管了。
- 长期记忆:跨任务记住的东西,比如用户偏好(“我们团队只用 Go 和 Vue”)、以前讨论得出的结论。LangGraph 提供 Store(按命名空间存取,可以配合向量做语义搜索)。
- 记忆要可查看、可删除;写什么进记忆要有规则,不能把所有对话都塞进去(成本、隐私、干扰)。
**要建的文件**
- `src/agent_mini/graph/memory.py`:
- `load_memory` 节点:按 `(user_id, "memories")` 命名空间取出与问题相关的若干条,放进状态,协调者和专家的提示词里引用。
- `save_memory` 节点:任务完成后,让模型从讨论中提炼 0~3 条“值得长期记住的事实或偏好”(结构化输出),写入 Store。
- Store 的持久化:本项目可以先用 SQLite 实现一个简单的 Store,或者使用官方提供的持久化 Store(以文档为准);在 `design-decisions.md` 里说明选择。
- `GET /memories`、`DELETE /memories/{key}`。
- 测试:第一次任务里说明偏好 → 第二次任务的提示词里出现这条记忆。
### 第 6 天:页面和崩溃演示
**要建的文件**
- `web/index.html`(原生 JS,不超过 250 行,可选之后用 Vue / React 重写):提问、时间线(每个节点、工具调用)、等待输入时出现输入框、失败时显示错误码和“换模型重试”、结论和报告链接、记忆列表、连接状态。
- `scripts/demo_crash_resume.sh`:用假模型加延迟启动服务 → 创建任务 → 等几秒 `kill -9` → 重启 → 轮询到完成 → 打印报告。
### 第 7 天:走查和 M3 验收
手动走查:正常跑完;中途关页再打开;中途停服务再启动;触发问人并回答;用错误的密钥制造失败后修好重试;第二次提问用上了记忆。
**M3 验收**
- [ ] 测试全部通过,包括续跑、租约、SSE、中断恢复
- [ ] 崩溃恢复演示脚本成功
- [ ] 6 个走查场景全部符合预期
- [ ] 能讲清:两层状态(检查点 vs 业务表)、为什么编排不能放在浏览器里、什么时候停下来问人、怎么防双跑、长期记忆写什么不写什么
- [ ] 在总纲进度记录里勾掉第 13 周
---
## 第 14 周 · M4:评测 + 文档 + 低代码平台体验
### 第 1 天:评测集
**学习内容**
- Agent 评测比 RAG 更难,因为过程有多步。从三个层面看:
- **结果**:最终结论是否回答了问题、是否符合约束。
- **过程**:有没有该调的工具没调、不该调的乱调;轮数是否合理;该问人时有没有问。
- **成本和稳定性**:耗时、token 数、失败率。
- 检查方式:能用规则判断的用规则(调了哪些工具、轮数、是否包含关键词);不能的用“模型按评分标准打分”,并抽查一部分人工复核,看两者是否一致。
**要建的文件**
- `evals/cases.jsonl`:20 条用例,每条包含 `id`、`question`、`expect`(例如 `must_call_tools`、`must_not_call_tools`、`should_ask_human`、`max_rounds`、`must_mention`)、`rubric`(给模型打分用的标准)。覆盖:普通问题、需要查知识库的问题、信息不足需要问人的问题、诱导乱调工具的问题。
- `evals/checks.py`:规则检查函数 + 模型评分函数(评分输出用结构化格式:分数 + 理由)。
- `evals/run_evals.py`:逐条运行(需要问人的用例自动给一个预设回答),汇总通过率、平均轮数、工具调用正确率、平均耗时和 token 数,结果写到 `evals/reports/<日期>.md`,记录当时的模型和提示词版本。
### 第 2 天:用评测驱动改进
- 跑基线,挑失败最多的一类问题,改提示词或路由规则,再跑,对比结果(数字以实际跑出来的为准)。改动和结果记到 `docs/prompt-changelog.md`。
- 用假模型做一个不需要密钥的小评测子集,放进测试,防止以后改代码把流程改坏(回归测试)。
### 第 3 天:文档
**要建的文件**
- `docs/architecture.md`:组件图、业务状态机(`stateDiagram-v2`)、LangGraph 图结构(第 12 周导出的)、崩溃恢复时序图(`sequenceDiagram`)。对照第 9 周画的公司系统脱敏图,写一段“我简化了什么”。
- `docs/mapping.md`(同时摘进 README,生产一栏只写通用概念,不写公司接口名、表名):
| 生产系统里的概念(通用叫法) | 本项目里的实现 | 对应重点 |
| --- | --- | --- |
| 席位 / 专家角色 | `proposer`、`critic` 节点 | A |
| 主持人派活 | `coordinator` 节点 + 条件边 | A |
| 共识 / 结束条件 | `VERDICT: APPROVE` + 最大轮数,写在 `routing.py` | A |
| 草稿(编排中间状态) | LangGraph 检查点(按 `thread_id`) | B |
| 任务状态(对外权威) | `runs` 表 | B |
| 后台作业 | `RunManager` + 租约 + 看门狗 | A / C |
| 流式输出 | `GET /runs/{id}/events`,按 `seq` 补发 | C |
| 意图澄清 / 等待用户输入 | `interrupt` → `awaiting_input` → `POST /input` | D |
| 换模型恢复 | 错误码 + `POST /retry {model}` | D |
| 过程报告 | `report.py` | E |
| 外部能力接入 | MCP 服务 `knowledge_server` | E |
- `docs/design-decisions.md`:每条写“选择 / 理由 / 代价”:两层状态;编排在后台;租约防双跑;节点内调用至少一次、副作用工具要幂等;结束条件写在代码里;SQLite 检查点只用于演示,生产换 PostgreSQL;长期记忆的写入规则;没做的事(鉴权、多实例下的实时通知、更完整的评测)。
### 第 4 天:README 和从零验证
- `README.md`:一句话介绍 + 动图;功能列表;**10 分钟快速开始**(假模型模式,不需要密钥);切换真实模型;连接 `rag-qa` 和 MCP;接口表;评测结果表;崩溃恢复演示;映射表;项目结构。
- `Dockerfile`、`docker-compose.yml`:`agent-mini` + MCP 服务,可选连接 `rag-qa`。
- 在新目录(最好换一台电脑或请朋友)`git clone` 后按 README 操作并计时,目标 10 分钟内跑通;卡住的地方都改进。
- 脱敏检查:`git grep` 搜公司名、内网域名、IP、密钥前缀。
### 第 5 天:讲解稿
- 录一段 2~3 分钟的演示:提问 → 工具调用 → 杀进程 → 重启续跑 → 问人 → 报告 → 评测结果。
- `docs/talk.md`:3 分钟讲解稿,“问题(为什么多 Agent 难做可靠)→ 设计(两层状态、后台作业、租约、中断、记忆)→ 演示 → 评测和取舍”,引用 1~2 张案例卡说明“这些设计来自我在工作中踩过的坑”。
### 第 6~7 天:Dify / Coze 体验(低代码平台)
**为什么**:招聘里经常出现 Dify、Coze、RAGFlow、FastGPT 这类平台。不需要精通,但要能说清楚它们能做什么、和写代码比各有什么优缺点,最好亲手搭过一个。
**做什么(二选一为主,另一个看看文档即可)**
- **Dify**(开源,可自己部署):在你的 Ubuntu 服务器上用官方的 Docker Compose 部署(注意服务器内存是否够用,以官方要求为准),或用官方云版。
- **Coze(扣子)**:直接用在线版。
**要搭的东西**
1. 知识库问答:把 `rag-qa` 用的同一批公开文档导入平台的知识库,做一个问答应用,用你第 8 周的评测集里挑 10 个问题对比效果。
2. 工作流:做一个带条件分支的工作流,包含一次 HTTP 请求节点调用你的 `agent-mini` 接口或 `rag-qa` 接口(平台支持 MCP 的话也可以接你的 MCP 服务)。
3. 导出应用配置(平台支持导出时),放到 `zhuangang/lowcode/`(去掉密钥)。
**要建的文件**
- `notes/lowcode-dify-coze.md`:
- 搭建步骤和截图。
- 和你自己的代码实现对比:开发速度、可控性(提示词、检索参数、状态、错误处理)、可观测性、部署和成本、团队里谁来维护。
- 结论:什么场景用平台(快速验证、业务人员自己搭、标准问答),什么场景写代码(复杂编排、强可靠性要求、深度集成业务系统)。
**M4 验收(也是阶段 3 出门条件)**
- [ ] 别人按 README 10 分钟内跑通(不需要真实模型密钥)
- [ ] 演示:启动 →(中途杀进程)→ 重启 → 最终结果可查
- [ ] 映射表至少 5 个概念对齐生产
- [ ] 有评测集和至少两次对比结果
- [ ] 架构图和 README 一致;测试、ruff、pyright 通过;脱敏检查完成
- [ ] Dify 或 Coze 至少搭过一个知识库应用和一个工作流,写了对比笔记
- [ ] 在总纲进度记录里勾掉第 14 周和“阶段 3”,填上“迷你项目路径:`zhuangang/agent-mini/`”
---
## 和案例卡的关系
第 10 周每张案例卡都有一栏“若重做的设计”。动手前把这些结论列出来,逐条对应到本项目的某个模块或某条设计取舍;对应不上的,在 `docs/design-decisions.md` 里说明为什么这次没做。面试时就能讲成:“我在工作中遇到 X 问题,根因是 Y,所以在自己的项目里用 Z 解决,这是代码和演示。”
## 可选扩展(第 15 周有余力再做,二选一)
- **Go 网关**:`zhuangang/agent-gateway/`,Gin 服务,复用项目一的 JWT 鉴权;`/api/runs/*` 反向代理到 Python 服务(SSE 要关闭缓冲);加用户级限流(Redis);转发时带上用户 ID,Python 端按用户隔离任务和记忆。讲点:“Go 做接入层,Python 做 Agent 编排”。
- **Vue / React 前端**:用你熟悉的框架重写 `web/index.html`,体现前端功底。
-326
View File
@@ -1,326 +0,0 @@
# 转岗计划 · 第 7~8 周:RAG 知识库问答项目(项目二)
> 所属阶段:第二段“Python 与 AI 应用”。总纲见同目录 `README.md`,上周计划 `week06-python-basics.md`,第 9~10 周见总纲第 6 节。
## 目标
做一个“上传文档 → 基于文档问答 → 回答带出处”的知识库问答系统。招聘里 AI Agent 岗位最常见的要求之一就是 RAG 和向量数据库,这个项目专门对上它。
做完后要能演示和讲清:
1. 上传 Markdown / TXT / PDF,自动切分、向量化、入库,能删除和重建。
2. 提问后流式输出回答,每个结论标注出处 `[1]`、`[2]`,点开能看到原文片段;文档里没有的内容明确回答“文档中没有找到”。
3. 有一个小评测集,能用数字(命中率等)说明改了切分或检索方式之后效果是变好还是变差。
4. 支持多轮追问(短期记忆)。
5. Redis 做缓存和调用限流,Docker Compose 一键部署到服务器。
项目名:`rag-qa`,放在 `zhuangang/rag-qa/`。
文档素材用**公开资料或自己的学习笔记**(比如本计划的文件、Go / PostgreSQL 官方中文文档的若干页),**不要用公司文档**。
## 技术选型
| 用途 | 选择 | 说明 |
| --- | --- | --- |
| 语言 / 依赖 | Python 3.12 + uv | |
| Web 服务 | FastAPI | 招聘里 FastAPI、Flask 都常见 |
| 向量数据库 | **Chroma**(默认) | 招聘里点名的主要是 Milvus 和 Chroma。Chroma 上手最快,可以嵌入式运行,也可以跑成独立服务 |
| 其他向量库 | Milvus(了解 + 可选实现)、pgvector(可选) | 通过 `VectorStore` 协议隔离,换库只写一个新实现。第 8 周第 7 天对比说明 |
| 大模型 / 向量化 | OpenAI 兼容接口(对话 + embeddings) | 国内很多模型服务都提供兼容接口;也可以选本地向量模型。测试用假实现 |
| 关键词检索 | rank-bm25 + jieba 分词 | 第 8 周做混合检索 |
| 框架 | 第 7 周**手写**主流程;第 8 周用 **LangChain** 重写一遍做对照 | 招聘要求 LangChain 实际经验,但先手写才知道它替你做了什么 |
| 缓存 / 限流 / 会话 | Redis | 第 5 周学过 |
| 前端 | 你熟悉的 Vue 或 React | 体现前端功底 |
| 部署 | Docker Compose | |
---
## 最终项目结构
```
rag-qa/
├── README.md
├── pyproject.toml
├── .env.example
├── docker-compose.yml # api、chroma、redis、frontend
├── Dockerfile
├── data/ # 运行时数据(不提交)
├── samples/ # 演示用的公开文档
├── src/rag_qa/
│ ├── __init__.py
│ ├── config.py
│ ├── errors.py # 错误码
│ ├── llm/
│ │ ├── chat.py # 对话接口:普通 + 流式
│ │ ├── embeddings.py # 向量化接口:批量、重试
│ │ └── fake.py # 测试用假实现
│ ├── ingest/
│ │ ├── loaders.py # txt / md / pdf → 文本 + 元数据
│ │ ├── chunker.py # 按标题和长度切分,带重叠
│ │ └── pipeline.py # 加载 → 切分 → 去重 → 向量化 → 入库
│ ├── vectorstore/
│ │ ├── base.py # VectorStore 协议
│ │ ├── chroma_store.py
│ │ └── milvus_store.py # 可选
│ ├── retrieval/
│ │ ├── vector.py # 向量检索
│ │ ├── bm25.py # 关键词检索
│ │ └── hybrid.py # 融合排序(RRF)
│ ├── qa/
│ │ ├── prompts.py # 提示词
│ │ ├── answer.py # 检索 + 生成 + 引用
│ │ └── rewrite.py # 多轮追问时改写问题
│ ├── memory/
│ │ └── session.py # 会话短期记忆(Redis)
│ ├── cache.py # 向量缓存、答案缓存
│ ├── ratelimit.py
│ ├── lc_pipeline.py # LangChain 版对照实现
│ ├── api/
│ │ ├── app.py
│ │ ├── routes_documents.py
│ │ └── routes_ask.py # 含 SSE
│ └── cli.py # rag-qa ingest / ask / eval
├── eval/
│ ├── questions.jsonl # 评测集
│ ├── run_eval.py
│ └── results.md # 每次实验的结果记录
├── frontend/ # Vue / React
└── tests/
├── test_chunker.py
├── test_pipeline.py
├── test_retrieval.py
├── test_answer.py
└── test_api.py
```
---
## 第 7 周:手写 RAG 主流程
### 第 1 天:概念和项目初始化
**学习内容**
- RAG 是什么:先从资料里**检索**相关片段,再让模型**基于这些片段生成**回答。解决模型不知道私有资料、容易编造的问题。
- 两条流水线:入库(加载 → 切分 → 向量化 → 存储)和问答(问题向量化 → 检索 → 拼提示词 → 生成)。
- 向量(embedding)是什么:把一段文字变成一串数字,意思相近的文字数字也相近;相似度一般用余弦相似度。
- 选好要用的模型服务:确认它的对话接口和 embeddings 接口、向量维度、价格和限流规则。
**要建的文件**
- `uv init --package rag-qa`;`uv add fastapi uvicorn httpx pydantic pydantic-settings chromadb pypdf`;`uv add --dev pytest pytest-asyncio ruff pyright`。
- `src/rag_qa/config.py`:`LLM_BASE_URL`、`LLM_API_KEY`、`CHAT_MODEL`、`EMBED_MODEL`、`EMBED_PROVIDER`(`api` / `fake`)、`CHROMA_PATH`、`TOP_K`、`CHUNK_SIZE`、`CHUNK_OVERLAP`。
- `src/rag_qa/errors.py`:错误码沿用第 6 周的思路(超时、限流、额度、鉴权、输出异常、文档解析失败)。
- `src/rag_qa/llm/chat.py`:`async def chat(messages) -> str`、`async def chat_stream(messages) -> AsyncIterator[str]`(解析流式响应里的增量内容)。
- `src/rag_qa/llm/embeddings.py`:`async def embed(texts: list[str]) -> list[list[float]]`,按批次发送(每批条数可配置),失败按错误码重试。
- `src/rag_qa/llm/fake.py`:`FakeEmbeddings`(用文本哈希生成固定向量,同一段文字结果永远一样)、`FakeChat`。
- `.env.example`、`.gitignore`(`.env`、`data/`)。
**运行方式**:写一个小脚本,把三句话向量化后两两计算余弦相似度,看意思相近的是不是分数更高。
**完成标准**:能用自己的话讲清 RAG 的两条流水线。
### 第 2 天:加载和切分
**学习内容**
- 切分为什么重要:太大检索不准、浪费上下文;太小丢失上下文。重叠(overlap)避免一句话被切断。
- 按结构切:Markdown 先按标题切,段落太长再按长度切;每个片段带上所属标题,检索和引用都更准。
- 元数据:文档 ID、文件名、标题路径、片段序号、内容哈希。
**要建的文件**
- `src/rag_qa/ingest/loaders.py`:
- `def load_file(path: Path) -> Document`:按扩展名分派;PDF 用 pypdf 按页提取并记录页码。
- `class Document(BaseModel)`:`doc_id`、`filename`、`text`、`pages: list[PageSpan] | None`。
- `src/rag_qa/ingest/chunker.py`:
- `class Chunk(BaseModel)`:`chunk_id`、`doc_id`、`text`、`heading_path`、`index`、`page`、`content_hash`。
- `def split_markdown(doc, chunk_size, overlap) -> list[Chunk]`
- `def split_plain(doc, chunk_size, overlap) -> list[Chunk]`:优先在句号、换行处断开。
- `tests/test_chunker.py`:片段长度不超过上限、相邻片段有重叠、标题路径正确、空文档返回空列表。
**完成标准**:拿 `samples/` 里的一份 Markdown 切一下,打印片段,人工检查断句是否合理。
### 第 3 天:向量库和入库流水线
**学习内容**
- Chroma 的基本用法:`PersistentClient(path=...)`、`get_or_create_collection`、`add` / `upsert`、`query`、`delete`;按元数据过滤。
- 幂等入库:同一份文档重复上传不产生重复片段(用内容哈希做片段 ID);文档更新时先删旧片段再写新片段。
**要建的文件**
- `src/rag_qa/vectorstore/base.py`:`class VectorStore(Protocol)`:`upsert(chunks, vectors)`、`query(vector, top_k, where=None) -> list[ScoredChunk]`、`delete_by_doc(doc_id)`、`count()`。
- `src/rag_qa/vectorstore/chroma_store.py`:`class ChromaStore`,相似度用余弦(建集合时指定)。
- `src/rag_qa/ingest/pipeline.py`:`async def ingest_file(path, store, embedder) -> IngestResult`(片段数、新增数、跳过数、耗时)。
- `src/rag_qa/cli.py`:`rag-qa ingest samples/`、`rag-qa stats`。
- `tests/test_pipeline.py`:用 `FakeEmbeddings` 和临时目录;同一文件入库两次,片段数不变;删除文档后数量归零。
**运行方式**:`uv run rag-qa ingest samples/ && uv run rag-qa stats`
### 第 4 天:检索和生成
**学习内容**
- Top-K 检索 + 相似度阈值:分数太低的片段不要,宁可回答“没找到”。
- 提示词(Prompt)和上下文工程(Context Engineering):系统提示里规定“只能根据资料回答、每个结论标注编号、资料里没有就明确说没有”;资料按编号拼进上下文,注意总长度。
- 引用:模型输出的 `[1]` 对应第 1 个片段,返回时附上片段原文、文件名、标题、页码。
**要建的文件**
- `src/rag_qa/retrieval/vector.py`:`async def retrieve(question, store, embedder, top_k, min_score) -> list[ScoredChunk]`。
- `src/rag_qa/qa/prompts.py`:系统提示词、拼接上下文的函数(超过长度上限就丢掉排名靠后的片段)。
- `src/rag_qa/qa/answer.py`:
- `class Answer(BaseModel)`:`text`、`citations: list[Citation]`、`retrieved: int`、`latency_ms`。
- `async def answer(question, ...) -> Answer`;`async def answer_stream(question, ...) -> AsyncIterator[AnswerEvent]`(先发 `citations` 事件,再逐段发 `delta`,最后 `done`)。
- 一个片段都没检索到时,不调模型,直接返回“文档中没有找到相关内容”。
- `cli.py`:`rag-qa ask "问题"`。
- `tests/test_answer.py`:没检索到时不调用模型;引用编号和片段对应。
**完成标准**:问 5 个文档里有答案的问题、3 个没有答案的问题,记录表现到 `eval/results.md`(这是第 8 周评测集的雏形)。
### 第 5 天:FastAPI 接口
**要建的文件**
- `src/rag_qa/api/app.py`:`create_app()`,`lifespan` 里初始化向量库和客户端;统一错误响应。
- `src/rag_qa/api/routes_documents.py`:
- `POST /documents`:上传文件(限制类型和大小),保存后入库,返回入库结果。
- `GET /documents`:文档列表(文件名、片段数、上传时间)。
- `DELETE /documents/{doc_id}`。
- `src/rag_qa/api/routes_ask.py`:
- `POST /ask`:一次性返回 `Answer`。
- `POST /ask/stream`:SSE,事件类型 `citations` / `delta` / `done` / `error`。(`EventSource` 只支持 GET,前端用 `fetch` 读流,这一点在 README 里说明。)
- `tests/test_api.py`:上传 → 列表 → 提问 → 删除;上传不支持的文件类型返回 400。
**运行方式**:`uv run uvicorn rag_qa.api.app:create_app --factory --reload`,打开 `/docs`。
### 第 6 天:前端
**要建的文件**(`frontend/`,Vite + 你熟悉的框架)
- `src/api/rag.ts`:`uploadDocument`、`listDocuments`、`deleteDocument`、`askStream`(用 `fetch` + `ReadableStream` 解析 SSE)。
- `src/pages/DocumentsPage`:上传(支持拖拽)、列表、删除。
- `src/pages/ChatPage`:对话界面,流式显示回答;回答里的 `[1]` 可点击,侧边栏显示对应原文片段和出处。
- `src/components/CitationPanel`、`MessageItem`、`UploadBox`。
**完成标准**:上传一份文档,马上能提问,回答流式显示,引用可点开。
### 第 7 天:测试和第一版 README
- 补全测试,`uv run ruff check . && uv run pyright && uv run pytest` 通过。
- `README.md` 第一版:功能、架构图(两条流水线,mermaid)、本地运行步骤(默认可用 fake 模式跑通界面)。
- 提交并推送到 `zhuangang` 仓库(路径 `rag-qa/`);在总纲进度记录里勾掉第 7 周。
---
## 第 8 周:评测、优化、记忆、缓存、LangChain 对照、部署
### 第 1 天:评测集(先有尺子,再谈优化)
**学习内容**
- 没有评测,“优化”就只是感觉。检索和回答分开评:
- 检索:正确片段有没有出现在前 K 个里(命中率,Hit@K);正确片段排在第几(MRR)。
- 回答:是否正确、是否只用了资料里的信息、引用是否对得上。先用人工打分,再试试让模型按评分标准打分(LLM-as-judge),对比两者是否一致。
**要建的文件**
- `eval/questions.jsonl`:自己写 30 条,每条 `{"id", "question", "expected_doc", "expected_keywords", "answerable": true/false}`;其中至少 5 条是文档里**没有**答案的。
- `eval/run_eval.py`:跑全部问题,输出 Hit@3、Hit@5、MRR、无答案题的正确拒答率、平均耗时;结果追加到 `eval/results.md`,每次记录配置(切分大小、Top-K、检索方式、模型)。
- `cli.py`:`rag-qa eval`。
**完成标准**:得到一组基线数字,写在 `eval/results.md` 第一行。之后每做一次改动都重跑并记录。
### 第 2 天:检索优化
**学习内容**
- 混合检索:向量检索擅长“意思相近”,关键词检索(BM25)擅长专有名词、编号、代码。中文要先分词(jieba)。
- 融合排序:RRF(倒数排名融合),简单、不用调分数权重。
- 重排(rerank):先粗取 20 个,再用重排模型精排出前 5 个。有可用的重排接口就试,没有就只了解原理。
**要建的文件**
- `uv add rank-bm25 jieba`
- `src/rag_qa/retrieval/bm25.py`:`class Bm25Index`:从向量库里取出全部片段建索引(文档变动时重建),`search(query, top_k)`。
- `src/rag_qa/retrieval/hybrid.py`:`def rrf(results_lists, k=60) -> list[ScoredChunk]`;`async def hybrid_retrieve(...)`。
- 配置项 `RETRIEVAL_MODE=vector|bm25|hybrid`。
- `tests/test_retrieval.py`:RRF 的排序结果符合手算。
**完成标准**:在评测集上对比 3 种检索方式和 2~3 种切分大小,结果表写进 `eval/results.md`,并写一句结论(数字以你实际跑出来的为准)。
### 第 3 天:多轮追问和短期记忆
**学习内容**
- 多轮对话的问题:“那它的缺点呢?”直接拿去检索是查不到的,要先结合历史把问题改写完整(query rewrite)。
- 记忆分两种:**短期记忆**(当前会话最近几轮,存 Redis,设过期时间);**长期记忆**(跨会话记住用户偏好等,第 13 周在 Agent 项目里做)。
- 上下文长度管理:历史太长时只保留最近 N 轮,或者把更早的内容压缩成摘要。
**要建的文件**
- `uv add redis`
- `src/rag_qa/memory/session.py`:`class SessionMemory`:`append(session_id, role, content)`、`recent(session_id, n)`,Redis List + 过期时间。
- `src/rag_qa/qa/rewrite.py`:`async def rewrite_question(history, question) -> str`;没有历史时直接返回原问题,不调模型。
- `/ask/stream` 请求体增加 `session_id`;前端每个对话窗口生成一个 `session_id`。
**完成标准**:连续追问 3 轮,第 2、3 轮仍能检索到正确片段;日志里能看到改写后的问题。
### 第 4 天:Redis 缓存和限流
**要建的文件**
- `src/rag_qa/cache.py`:
- 向量缓存:key = `emb:{模型名}:{文本哈希}`,同一段文字不重复向量化(重新入库时省钱)。
- 答案缓存:key = `ans:{问题哈希}:{知识库版本号}`;任何文档增删时知识库版本号加 1,旧缓存自然失效。多轮对话(有历史)不走答案缓存。
- `src/rag_qa/ratelimit.py`:按 IP 或会话限制提问频率(固定窗口,和第 5 周 Go 版同一个思路)。
- Redis 不可用时降级:跳过缓存和记忆,问答照常工作。
**完成标准**:同一问题第二次明显更快(日志里有命中标记);重复入库同一文档时 embeddings 接口不再被调用。
### 第 5 天:LangChain 对照实现
**学习内容**
- 招聘里普遍要求 LangChain / LangGraph 的实际项目经验。用 LangChain 的组件把问答链重写一遍:文本切分器、Chroma 向量库封装、检索器、对话模型、提示词模板。
- 对照着看:每个组件替你做了什么,哪些地方你手写的版本更可控。
- LlamaIndex 是另一个常见的 RAG 框架,思路类似,本周只读一下官方入门示例,能说出和 LangChain 的定位差别即可。
**要建的文件**
- `uv add langchain langchain-openai langchain-chroma langchain-text-splitters`(包名以官方文档为准)。
- `src/rag_qa/lc_pipeline.py`:用 LangChain 组件实现 `ingest` 和 `ask`,同样输出带引用的回答。
- `eval/run_eval.py` 增加 `--impl handwritten|langchain`,两种实现在同一评测集上对比。
- `docs/handwritten-vs-langchain.md`:组件对照表 + 你的结论。
**完成标准**:两种实现都能跑评测;能讲清“我为什么先手写,再用框架”。
### 第 6 天:Docker Compose 部署
**要建的文件**
- `Dockerfile`:基于 Python 官方 slim 镜像,用 uv 安装依赖,非 root 用户运行。
- `docker-compose.yml`:
- `chroma`:Chroma 官方镜像跑成独立服务,数据卷持久化;`api` 通过 HTTP 客户端连接(`ChromaStore` 支持嵌入式和服务端两种模式,由配置决定)。
- `redis`、`api`、`frontend`(nginx 托管静态文件,`/api` 反向代理到 `api`,注意关闭代理缓冲,否则流式输出会被攒成一整块)。
- 部署到 Ubuntu 服务器(同第 5 周的 `deploy.md` 流程),在 README 里补部署小节。
**完成标准**:服务器上能上传文档、流式问答。
### 第 7 天:向量库对比和收尾
**学习内容**
- 把三种向量库的定位讲清楚(只写你查证过的内容):
- Chroma:上手快,可嵌入运行,适合原型和中小规模。
- Milvus:专门的向量数据库,面向更大规模和分布式部署,招聘里和 Chroma 一起被点名;有轻量的单机方式可以本地试。
- pgvector:PostgreSQL 扩展,已经在用 PostgreSQL 的团队不用多维护一个数据库,向量和业务数据可以一起查询、一起事务。
- 有余力(可选,二选一):实现 `milvus_store.py` 或基于 pgvector 的实现,跑同一套评测。
**要建的文件和修改**
- `docs/vector-db-comparison.md`:上面的对比 + 你实际试过的部分。
- `README.md` 完整版:
- 一句话介绍、截图或动图。
- 架构图(入库流水线、问答流水线、缓存和记忆的位置)。
- **评测结果表**(来自 `eval/results.md`,每个数字都要能复现)。
- 关键设计:切分策略、混合检索、拒答策略、引用、缓存失效、降级。
- 本地运行(fake 模式无需密钥)、真实模型配置、Docker 部署。
- 已知不足(比如:没做权限隔离、表格和图片内容没解析)。
- 提交并推送;在总纲进度记录里勾掉第 8 周和“项目二”。
---
## 本项目自查清单
- [ ] 能讲清 RAG 两条流水线,以及切分大小、重叠、Top-K、阈值各自的影响
- [ ] 会用 Chroma,知道 Milvus、pgvector 的定位差别
- [ ] 回答带引用;文档里没有的能正确拒答
- [ ] 有 30 条的评测集,能用 Hit@K、MRR、拒答率说明优化效果
- [ ] 做过混合检索对比
- [ ] 多轮追问能改写问题,短期记忆存在 Redis
- [ ] 向量缓存、答案缓存、限流、降级都做了
- [ ] 用 LangChain 重写过一遍并做了对比
- [ ] Docker Compose 部署到服务器,流式输出正常
- [ ] README 完整,评测数字可复现
下一步:第 9 周开始阶段 0 盘点和阶段 1 读通画图,见总纲 `README.md` 第 6 节。这个 RAG 服务在第 11 周会被包装成 MCP 工具,给多 Agent 项目调用。
+1406
View File
File diff suppressed because it is too large. Load diff
+1197
View File
File diff suppressed because it is too large. Load diff
+498
View File
@@ -0,0 +1,498 @@
# 第 8 周:阶段 0 盘点 + 阶段 1 读通与画图
> 第三段“Agent 主线”的第 1 周。上周 `week07-frontend.md`,下周 `week09-case-cards.md`。
**为什么现在做**:你在公司做的多 Agent 产品是**最有说服力的经历**,但面试官要听的是“机制和取舍”,不是“我改过某个页面”。这周把公司里的真实链路读通、画清楚,下周提炼成案例卡,第 10~13 周的项目三就有了明确的设计输入。
**本周规则**
- 读公司代码在**公司电脑**上做;仓库里的笔记**只写机制,不写**真实路径、接口地址、表名、字段名、内网地址、密钥、客户数据。私有细节写在 `notes/private/`(已在 `.gitignore`)。
- 每天产出至少一个文件。
- 模块一律用**通用叫法**:比如“前端多 Agent 研讨模块”“Agent 网关的作业接口”“草稿存储”。
**本周产出**
```text
notes/
├── 00-inventory.md # 阶段 0:目标、模块清单、A~E 自评
├── 01-architecture.md # 阶段 1:架构图(mermaid)+ 时序图 + 断点标注
├── 01-api-table.md # 阶段 1:关键接口职责表(通用叫法)
├── 01-state-machine.md # 阶段 1:流程的状态机草图
├── 01-pitch-draft.md # 3 分钟口述初稿
└── private/ # 不进仓库
tools/
└── check_sensitive.py # 提交前的脱敏检查脚本
```
---
## 第 1 天:阶段 0——盘点
### 学习内容
**1. 一句话目标**
- 说明:写下来,后面每个取舍都拿它来对照。
- 案例:
```markdown
## 一句话目标
16 周后,以“有多 Agent 产品落地经验的 AI 应用工程师”身份,投递 AI Agent 开发 / 大模型应用开发 / AI 全栈岗位;
主语言 Python,加分项 Go 全栈 + 7 年前端。
```
**2. 模块清单**
- 说明:列出你在公司**亲自改过、排查过**的模块,只写通用名字和你做了什么。这份清单决定下周写哪些案例卡。
- 案例(示例格式,按你的实际情况填):
```markdown
| 模块(通用叫法) | 我做过什么 | 遇到过的问题 | 对应重点 |
| --- | --- | --- | --- |
| 前端多 Agent 研讨模块 | 实现多角色轮流发言的界面和流式渲染 | 关闭页面后研讨中断 | A、C |
| 前端会话与交付模块 | 交付结果展示、刷新后恢复 | 刷新后进度丢失 | B |
| Agent 网关的作业接口(联调) | 对接创建作业、查询进度 | 状态没有回写到任务 | B、E |
| 模型配置 / 切换 | 额度不足时的报错与切换 | 换模型后流程无法继续 | D |
```
**3. A~E 自评表**
- 说明:五个学习重点贯穿第 8~13 周。先诚实地评估自己现在的水平。
| # | 重点 | 一句话解释 | 学完应能回答 |
| --- | --- | --- | --- |
| A | 多 Agent 编排 | 多个角色(提议者、评审者、总结者……)由谁派活、按什么顺序、什么时候结束 | “角色怎么分工、什么时候结束、怎么避免同一个任务被跑两次” |
| B | 状态与权威源 | 同一份数据(进度、结果)如果存了多处,以哪一处为准 | “刷新或关页后为什么丢进度,权威数据写在哪” |
| C | 流式与长任务 | 结果边生成边推送;任务很长时,不能依赖一条连接一直不断 | “为什么编排不该只活在浏览器里” |
| D | 人机协作 | 流程中途需要用户确认、补充信息、换模型后继续 | “Agent 什么时候必须停下来问人” |
| E | 与业务系统集成 | 模型的输出最终要变成可审计的业务记录(任务、报告) | “LLM 的结果怎样落到可审计的业务对象上” |
```markdown
| 重点 | 已会 | 模糊 | 不会 |
| --- | --- | --- | --- |
| A 编排 | 前端按顺序调用各角色 | 结束条件由谁判断 | 服务端编排框架 |
| B 权威源 | | 草稿和任务表的关系 | |
| C 流式 | SSE 渲染 | 断线后服务端在做什么 | 后台作业、续跑 |
| D 人机协作 | | 澄清问题的触发条件 | interrupt / 恢复 |
| E 集成 | | 过程报告怎么生成 | 幂等写入 |
```
### 要建的文件
- `notes/00-inventory.md`:一句话目标、模块清单、A~E 自评、项目三技术栈确认(Python + LangGraph,先手写循环)
### 运行方式
- 无代码。写完后自己读一遍:每一行是否都**不含**公司的真实名称和路径。
### 完成标准
- [ ] 一句话目标已写下
- [ ] 模块清单至少 3 行
- [ ] A~E 三列填完;项目三技术栈已确认
---
## 第 2 天:脱敏机制——提交前自动检查
### 学习内容
- 说明:靠“记得小心”是不够的,要有机制。写一个脚本,在每次提交前扫描**即将提交的内容**,发现内网地址、密钥格式、你自定义的敏感词就拒绝提交。敏感词清单本身是敏感的,放在 `notes/private/sensitive-words.txt`(不进仓库)。再用 Git 的 `pre-commit` 钩子自动运行。
- 案例:
```python
# tools/check_sensitive.py —— 提交前扫描暂存区内容
# 不自动运行:作为 pre-commit 钩子在仓库根目录执行;加 --self-test 可以单独自检
import re
import subprocess
import sys
from pathlib import Path
PATTERNS = {
"内网 IP": re.compile(r"\b10\.\d{1,3}\.\d{1,3}\.\d{1,3}\b|\b192\.168\.\d{1,3}\.\d{1,3}\b|\b172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}\b"),
"疑似密钥": re.compile(r"(?i)\b(?:sk|ak|api[_-]?key|secret|token)[\w-]*\s*[:=]\s*['\"]?[A-Za-z0-9_\-]{16,}"),
"私钥": re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
"root 绝对路径": re.compile(r"(?<![\w/])/root/\S+"), # sensitive-ok
}
WORDS_FILE = Path("notes/private/sensitive-words.txt")
ALLOW_MARK = "sensitive-ok" # 行内写这个标记表示人工确认过,可以放行(比如文档里讲解正则的例子)
def load_words() -> list[str]:
if not WORDS_FILE.exists():
return []
return [w.strip() for w in WORDS_FILE.read_text(encoding="utf-8").splitlines() if w.strip() and not w.startswith("#")]
def scan_text(name: str, text: str, words: list[str]) -> list[str]:
problems = []
for no, line in enumerate(text.splitlines(), start=1):
if ALLOW_MARK in line:
continue
for label, pat in PATTERNS.items():
if pat.search(line):
problems.append(f"{name}:{no} {label}")
for w in words:
if w.lower() in line.lower():
problems.append(f"{name}:{no} 敏感词(见私有清单)") # 不打印敏感词本身
return problems
def staged_files() -> list[str]:
out = subprocess.run(["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"],
capture_output=True, text=True, check=True).stdout
return [f for f in out.splitlines() if f]
def main() -> int:
words = load_words()
problems = []
for f in staged_files():
content = subprocess.run(["git", "show", f":{f}"], capture_output=True).stdout # 读暂存区版本
try:
problems += scan_text(f, content.decode("utf-8"), words)
except UnicodeDecodeError:
continue # 跳过二进制文件
for p in problems:
print("✗", p)
if problems:
print("发现疑似敏感内容,已阻止提交。确认无误的行可加注释 sensitive-ok。")
return 1 if problems else 0
if __name__ == "__main__" and "--self-test" in sys.argv:
sample = "url: http://192.168.1.20:8080\napi_key = 'abcdefghijklmnopqrstuvwxyz'\n正常的一行\n" # sensitive-ok
found = scan_text("demo.md", sample, ["某客户"])
assert len(found) == 2, found
assert scan_text("demo.md", "某客户的需求", ["某客户"]) == ["demo.md:1 敏感词(见私有清单)"]
print("自检通过:", found)
elif __name__ == "__main__":
sys.exit(main())
```
把它挂到 Git 钩子上(钩子文件不会被提交,换设备后要重新执行一次):
```bash
cat > .git/hooks/pre-commit <<'HOOK'
#!/bin/sh
exec python3 tools/check_sensitive.py
HOOK
chmod +x .git/hooks/pre-commit
python3 tools/check_sensitive.py --self-test
```
**易错点 / 面试常问(补充)**
- 易错点:钩子文件在 `.git/hooks/` 里,不会被提交,换电脑要重新安装(总纲第 12 节有步骤)。
- 易错点:敏感词清单本身也是敏感信息,只放 `notes/private/`;检查脚本误报时用允许标记,不要直接关掉钩子。
- 易错点:已经提交过的敏感内容,删掉文件后**仍然在 Git 历史里**;一旦推送过,要当作已经泄露处理(例如密钥立即作废),再考虑改写历史。
### 要建的文件
- `tools/check_sensitive.py`;`notes/private/sensitive-words.txt`(公司名、产品代号、内部项目名、同事名等,**不进仓库**);`.git/hooks/pre-commit`
### 运行方式
```bash
python3 tools/check_sensitive.py --self-test
echo "测试 192.168.0.1" > notes/x.md # sensitive-ok
git add notes/x.md && git commit -m test # 应被拒绝
git restore --staged notes/x.md && rm notes/x.md # 清理
```
### 完成标准
- 自检通过;故意提交含内网 IP 的文件会被拦下。
- 在 README 的“换设备”步骤里记一笔:克隆后要重新安装钩子。
---
## 第 3 天:阶段 1——怎么读一个大项目
### 学习内容
- 说明:不要从第一个文件读到最后一个。方法是**跟着一次请求走**:
1. 找入口:前端从“用户点击开始研讨”的按钮事件开始;后端从路由表开始。
2. 跟调用链:每一层只记“谁调谁、传了什么、状态存在哪”,不看细节实现。
3. 搜关键词:`grep -rn "stream\|sse\|EventSource"`、`"checkpoint\|job\|draft"`,快速定位流式和状态相关代码。
4. 打日志 / 打断点:在开发环境跑一遍,用浏览器 Network 面板看实际的请求顺序。
5. 边读边画时序图,读完一块画一块。
- 案例:用 mermaid 时序图记录调用链(下面是**通用示例**,按你读到的实际情况改,名字保持通用):
```mermaid
sequenceDiagram
participant U as 用户
participant F as 前端
participant G as Agent 网关
participant L as 大模型
participant S as 草稿/作业存储
U->>F: 输入需求,点击开始
F->>G: 意图识别
G->>L: 调用模型
L-->>G: 意图结果
G-->>F: 返回意图
loop 每一轮研讨(由谁驱动?前端还是网关?)
F->>G: 让某个角色发言(SSE)
G->>L: 流式调用
L-->>G: token 流
G-->>F: SSE 推送
F->>S: 保存草稿(谁写?什么时候写?)
end
F->>G: 生成总结 / 交付
```
- 读的时候在图上标问号的地方,就是第 9 周案例卡的素材。
### 要建的文件
- `notes/01-architecture.md`:先放时序图;`notes/private/reading-log.md`:真实文件路径、函数名(不进仓库)
### 运行方式
- 在公司电脑上按上面的方法读 P0 模块:
| 优先级 | 要读的模块(通用叫法) | 要搞清的问题 |
| --- | --- | --- |
| P0 | 前端多 Agent 功能:意图、多席研讨、草稿、作业 | 几个步骤怎么串起来,谁发起 SSE,谁驱动下一轮 |
| P0 | 前端会话与交付模块 | 会话状态和交付状态分别存在哪 |
| P0 | Agent 网关里多 Agent、意图、作业、草稿相关的接口 | 服务端是“单轮调用”还是“后台作业”;用了什么框架、检查点存在哪 |
| P1 | 前端对 Agent 网关的接口契约 | 前后端约定了哪些接口 |
| P2 | 团队里关于“后台作业”的设计文档(如果有) | 理想架构和现状差在哪 |
### 完成标准
- 时序图覆盖一次完整流程;标出至少 3 个“问号”。
---
## 第 4 天:画架构图,标出断点
### 学习内容
- 说明:时序图讲“顺序”,架构图讲“组成和数据流向”。这张图要回答三个问题:
1. 编排(决定下一步做什么)跑在哪一层?
2. 每类状态(会话、草稿、作业进度、业务任务)的**权威数据**存在哪?
3. **关掉浏览器时,流程断在哪一层?**
- 案例(通用示例,用颜色标出风险点):
```mermaid
flowchart LR
U[用户浏览器] -->|SSE| FE[前端<br/>编排循环在这里?]
FE -->|HTTP| GW[Agent 网关]
GW --> LLM[大模型服务]
GW --> D[(草稿存储)]
GW --> J[(作业表)]
GW --> B[(业务库:任务/报告)]
FE -.->|直接写草稿?| D
classDef risk fill:#ffe0e0,stroke:#d33
class FE risk
```
```markdown
### 断点分析
- 关闭浏览器 → 前端的编排循环停止 → 正在进行的这一轮在网关里可能继续跑完,但**不会有下一轮**。
- 刷新页面 → 前端内存里的进度丢失 → 只能从草稿恢复,而草稿可能不是最新的。
- 结论:编排如果只活在浏览器里,任务的生命周期就等于标签页的生命周期。
### 权威源
| 状态 | 存在哪 | 谁写 | 是否权威 | 问题 |
| --- | --- | --- | --- | --- |
| 研讨进度 | 前端内存 + 草稿 | 前端 | 否 | 刷新丢失 |
| 作业状态 | 作业表 | 网关 | 是 | 没回写业务任务 |
| 业务任务状态 | 业务库 | ? | 应该是 | 显示“未开始” |
```
### 要建的文件
- `notes/01-architecture.md`:补架构图 + 断点分析 + 权威源表
### 完成标准
- [ ] 图上标出“关掉浏览器时流程断在哪一层”
- [ ] 指出至少一处“状态双写或权威来源不清”的位置
---
## 第 5 天:接口职责表
### 学习内容
- 说明:把前后端约定的关键接口按**职责**整理(不写真实路径)。这张表是你第 10~12 周设计项目三接口的参照,面试时也能用来讲“我是怎么划分接口的”。
- 案例:
```markdown
| 职责(通用叫法) | 方法 | 输入 | 输出 | 同步/流式/异步 | 幂等吗 | 失败时前端怎么办 |
| --- | --- | --- | --- | --- | --- | --- |
| 初始化研讨 | POST | 需求、角色配置 | 会话 ID | 同步 | 否 | 提示重试 |
| 运行一轮并流式返回 | POST | 会话 ID、角色 | token 流 | 流式(SSE) | 否 | 断线后无法续上 |
| 保存 / 读取草稿 | PUT/GET | 会话 ID、内容 | 草稿 | 同步 | 是(覆盖写) | — |
| 创建后台作业 | POST | 会话 ID | 作业 ID | 异步 | ? | 轮询状态 |
| 查询作业进度 | GET | 作业 ID | 状态、进度 | 同步 | 是 | — |
```
- 面试常问:“这个接口为什么设计成流式 / 异步?”“重复提交会怎样?”——在表里先想清楚。
### 要建的文件
- `notes/01-api-table.md`
### 完成标准
- 至少 5 个接口;每个都填了“幂等吗”和“失败时怎么办”。
---
## 第 6 天:把流程画成状态机,并用代码验证思路
### 学习内容
- 说明:多 Agent 流程本质上是一个**状态机**:有限个状态(排队、研讨中、等待用户、总结中、完成、失败)+ 明确的转移规则。把它显式写出来有三个好处:
1. 任何时刻都知道“现在在哪一步”;
2. 状态可以存进数据库,进程重启后从同一步继续(第 12 周的检查点续跑);
3. 非法转移(比如“已完成”又回到“研讨中”)能被拦下。
- 案例:一个最小的状态机,每次转移都写进 SQLite,模拟“进程崩溃后从数据库恢复”。
```python
import sqlite3
from enum import Enum
class S(str, Enum):
QUEUED = "queued"
DISCUSSING = "discussing"
WAITING_HUMAN = "waiting_human"
SUMMARIZING = "summarizing"
DONE = "done"
FAILED = "failed"
ALLOWED = {
S.QUEUED: {S.DISCUSSING, S.FAILED},
S.DISCUSSING: {S.DISCUSSING, S.WAITING_HUMAN, S.SUMMARIZING, S.FAILED},
S.WAITING_HUMAN: {S.DISCUSSING, S.FAILED},
S.SUMMARIZING: {S.DONE, S.FAILED},
S.DONE: set(),
S.FAILED: set(),
}
class Run:
def __init__(self, db: sqlite3.Connection, run_id: str):
self.db, self.run_id = db, run_id
db.execute("CREATE TABLE IF NOT EXISTS runs (id TEXT PRIMARY KEY, state TEXT, round INT)")
db.execute("INSERT OR IGNORE INTO runs VALUES (?, ?, 0)", (run_id, S.QUEUED.value))
def load(self) -> tuple[S, int]:
state, rnd = self.db.execute("SELECT state, round FROM runs WHERE id = ?", (self.run_id,)).fetchone()
return S(state), rnd
def to(self, new: S, round_: int | None = None) -> None:
cur, rnd = self.load()
if new not in ALLOWED[cur]:
raise ValueError(f"非法转移:{cur.value} → {new.value}")
with self.db: # 事务:要么都写进去,要么都不写
self.db.execute("UPDATE runs SET state = ?, round = ? WHERE id = ?",
(new.value, rnd if round_ is None else round_, self.run_id))
db = sqlite3.connect(":memory:")
run = Run(db, "r1")
run.to(S.DISCUSSING, round_=1)
run.to(S.DISCUSSING, round_=2)
print("崩溃前:", run.load())
# 模拟进程崩溃:丢掉 Python 对象,只留下数据库
del run
recovered = Run(db, "r1")
state, rnd = recovered.load()
print(f"重启后:从 {state.value} 第 {rnd} 轮继续")
recovered.to(S.SUMMARIZING)
recovered.to(S.DONE)
try:
recovered.to(S.DISCUSSING)
except ValueError as e:
print("被拦下:", e)
```
- 对照第 4 天的断点分析:如果公司现在的进度只在前端内存里,就相当于没有这张 `runs` 表。
**易错点 / 面试常问(补充)**
- 易错点:状态机的每一步要先写“权威状态”(数据库),再做后续动作;顺序反了,崩溃时就会出现“动作做了但状态没记”的情况。
- 面试常问:为什么要把流程画成状态机(每个状态能做什么、出错后停在哪、怎么恢复,一目了然);“状态在内存”和“状态在数据库”对崩溃恢复意味着什么。
### 要建的文件
- `notes/01-state-machine.md`:mermaid 状态图 + 上面的代码 + “公司现状 vs 状态机化之后”的对比
```mermaid
stateDiagram-v2
[*] --> queued
queued --> discussing
discussing --> discussing: 下一轮
discussing --> waiting_human: 需要澄清
waiting_human --> discussing: 用户回复
discussing --> summarizing: 达成共识 / 到达轮数上限
summarizing --> done
queued --> failed
discussing --> failed
summarizing --> failed
```
### 运行方式
```bash
python3 notes/state_machine_demo.py # 把上面的代码存成这个文件运行
```
### 完成标准
- 能用状态图讲清“关页面、刷新、进程崩溃”三种情况下,状态机化前后分别会怎样。
---
## 第 7 天:阶段 1 验收 + 口述初稿
### 学习内容
- 说明:用“架构图 + 3 分钟”讲一遍,录音回听。结构建议:
1. 背景一句话(产品做什么,不说公司名);
2. 架构:几层、各自职责;
3. 关键问题:编排在哪、状态在哪、断了怎么办;
4. 如果重做:状态机化、后台作业、单一权威源。
- 案例:
```markdown
## 3 分钟口述初稿
我参与的是一个多 Agent 研讨产品:用户提出需求,系统先识别意图,再让提议、评审等多个角色轮流讨论,最后生成总结和交付物。
架构分三层:前端负责交互和流式展示,Agent 网关负责调用模型和保存草稿、作业,业务库保存任务和报告。
我遇到的核心问题是:研讨的“下一轮”由前端驱动,所以关掉页面流程就停了;进度一部分在前端内存、一部分在草稿里,刷新后会丢。
如果重做,我会把流程建成服务端状态机,每一步持久化,前端只订阅进度;业务任务的状态以作业表为唯一权威,由作业完成事件回写。
我在个人项目里用 LangGraph 的检查点实现了这套设计,可以演示中途杀掉进程后从断点继续。(第 12 周完成后补上)
```
### 要建的文件
- `notes/01-pitch-draft.md`;在 README 进度表勾掉第 8 周
### 运行方式
- 手机录音,计时;超过 3 分钟就删内容。
### 完成标准
- [ ] 不看代码能口述多 Agent 功能的主流程
- [ ] 架构图标出了断点和权威源问题
- [ ] 口述初稿 ≤ 3 分钟
- [ ] 提交前脱敏检查脚本通过
---
## 本周自查清单
- [ ] `notes/00-inventory.md`:目标、模块清单、A~E 自评
- [ ] `tools/check_sensitive.py` + pre-commit 钩子
- [ ] `notes/01-architecture.md`:时序图、架构图、断点、权威源
- [ ] `notes/01-api-table.md`:接口职责表
- [ ] `notes/01-state-machine.md`:状态图 + 可运行的状态机示例
- [ ] `notes/01-pitch-draft.md`:3 分钟口述初稿
+540
View File
@@ -0,0 +1,540 @@
# 第 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 周
---
## 本周自查清单
- [ ] 5 个实验脚本都跑过,能讲清每个实验演示了什么
- [ ] 5 张案例卡(脱敏),每张 1 分钟能讲完
- [ ] 设计输入表
- [ ] 能回答:“为什么编排不该只活在浏览器里?”“权威源是什么?”“outbox 模式解决什么问题?”“Agent 什么时候要停下来问人?”“LLM 结果怎么安全地写进业务系统?”
File diff suppressed because it is too large. Load diff
+793
View File
@@ -0,0 +1,793 @@
# 第 11 周:项目三 M2——用 LangGraph 实现多 Agent 编排
> 项目三的第 2 周。上周 `week10-agent-m1-tools-mcp.md`(手写版 + MCP),下周 `week12-agent-m3-persistence.md`(续跑、中断、后台作业、SSE、记忆)。
**本周目标**:用 LangGraph 把上周的手写循环重写一遍:状态(State)、节点、条件边、工具调用、结构化决定。手写版的每一部分都要在 LangGraph 里找到对应。
**接口以官方文档为准**:LangGraph 更新快,本周代码在 `langgraph 1.2`、`langchain-core 1.7` 上跑通。文档:https://docs.langchain.com/oss/python/langgraph/overview (Graph API:https://docs.langchain.com/oss/python/langgraph/graph-api )
```bash
uv add langgraph langchain-core
uv add langchain langchain-openai # 接真实模型时才需要(init_chat_model)
```
**本周新增文件**
```text
agent-mini/src/agent_mini/
├── models.py # Decision、parse_verdict
├── llm.py # 真实模型 / 假模型
├── tools.py # LangChain 版本地工具 + MCP 工具桥接
├── fakes.py # 按角色回答的假模型(测试和 --fake 演示共用)
├── graph/
│ ├── __init__.py
│ ├── state.py # State 定义(含 reducer)
│ ├── prompts.py
│ ├── routing.py # 条件边:硬规则 + 协调者决定
│ ├── nodes.py # coordinator / proposer / critic / ask_human / summarize
│ └── build.py # 组装并编译图
└── cli.py # agent-mini graph / run
tests/
├── __init__.py
├── test_routing.py
├── test_graph.py
└── test_lc_tools.py
```
---
## 第 1 天:LangGraph 基础
### 学习内容
- 说明(对照上周手写版理解):
| LangGraph 概念 | 是什么 | 手写版里对应 |
| --- | --- | --- |
| State | 一个 `TypedDict`,整个流程共享的数据 | `LoopState` |
| 节点(node) | 普通函数:读 State,返回**要更新的字段** | 协调者、专家各自的那段代码 |
| 边 / 条件边 | 节点之间的跳转;条件边由一个函数决定下一步 | `while` 循环里的 `if` |
| reducer | 字段怎么合并:默认覆盖,`Annotated[list, operator.add]` 表示追加 | `turns.append(...)` |
| `START` / `END` | 起点和终点 | 循环开始 / `break` |
| compile | 把图编译成可运行对象,支持 `invoke` / `stream` | — |
| checkpointer | 每个节点执行完自动保存状态(下周) | 写 JSON 文件 |
- 案例:一个最小的“提议 ↔ 评审”循环图,跑一遍看清节点、条件边、reducer:
```python
import operator
from typing import Annotated, TypedDict
from langgraph.graph import END, START, StateGraph
class S(TypedDict):
round: int
log: Annotated[list[str], operator.add] # reducer:节点返回的列表会被追加,而不是覆盖
def propose(s: S) -> dict:
return {"round": s["round"] + 1, "log": [f"提议 v{s['round'] + 1}"]}
def review(s: S) -> dict:
ok = s["round"] >= 2
return {"log": ["评审:通过" if ok else "评审:再改改"]}
def after_review(s: S) -> str:
return "done" if s["log"][-1].endswith("通过") else "again"
g = StateGraph(S)
g.add_node("propose", propose)
g.add_node("review", review)
g.add_edge(START, "propose")
g.add_edge("propose", "review")
g.add_conditional_edges("review", after_review, {"again": "propose", "done": END})
app = g.compile()
for update in app.stream({"round": 0, "log": []}, stream_mode="updates"):
print(update) # 每个节点执行完输出它返回的更新
print(app.invoke({"round": 0, "log": []})["log"])
print(app.get_graph().draw_mermaid()) # 导出 mermaid,可以贴进文档
```
- 预置组件:`langgraph.prebuilt` 里有 `ToolNode`(执行工具调用)、LangChain 的 `create_agent`(现成的“模型 + 工具”循环)。知道有、什么时候用;本项目的专家节点里**自己写一个小工具循环**,这样每一步可控、好测试。
**易错点 / 面试常问(补充)**
- 易错点:节点返回的是“要更新的字段”,不是整个状态;列表字段如果要追加而不是覆盖,要在 State 里用 `Annotated[list, operator.add]` 这类 reducer 声明。
- 易错点:图有环时要设退出条件;LangGraph 还有 `recursion_limit`(默认步数上限),超过会抛错,不要靠它当正常的结束条件。
- 面试常问:LangGraph 和 LangChain 的关系(LangChain 提供模型、工具等组件;LangGraph 负责有状态、可循环、可持久化的流程编排);为什么用图而不是写死的 if-else(流程可视化、可中断、可从检查点恢复)。
### 要建的文件
- `scratch/langgraph_basics.py`(上面的代码,不进最终项目)
### 运行方式
```bash
uv run python scratch/langgraph_basics.py
```
### 完成标准
- 能说出 State、节点、条件边、reducer 和手写版的对应关系;能把 mermaid 图贴进 Markdown 看到图。
---
## 第 2 天:State、结构化决定、路由规则
### 学习内容
- 说明:
- State 里放什么:问题、轮数、发言记录(追加型)、最近一次评审结论、协调者的决定、人的回答(追加型)、最终结论、结束原因、长期记忆(下周)。
- **路由函数是纯函数**:输入 State,输出下一个节点名——最容易测试,也是“结束条件写在代码里”的落地点。硬规则顺序:评审已通过 → 总结;达到最大轮数 → 总结;否则按协调者决定。
- 案例:
```python
# 文件:agent-mini/src/agent_mini/models.py
import re
from typing import Literal
from pydantic import BaseModel, Field
class Decision(BaseModel):
action: Literal["dispatch", "ask_human", "finish"]
target: Literal["proposer", "critic"] | None = None
instruction: str = ""
reason: str = Field(min_length=1)
def parse_decision(raw: str) -> Decision:
m = re.search(r"\{.*\}", raw, re.S)
return Decision.model_validate_json(m.group(0) if m else raw)
def parse_verdict(text: str) -> Literal["APPROVE", "REVISE"]:
m = re.search(r"VERDICT:\s*(APPROVE|REVISE)\s*$", text.strip())
return "APPROVE" if m and m.group(1) == "APPROVE" else "REVISE"
```
```python
# 文件:agent-mini/src/agent_mini/graph/__init__.py
```
```python
# 文件:agent-mini/src/agent_mini/graph/state.py
import operator
from typing import Annotated, Literal, TypedDict
class Turn(TypedDict):
role: str
instruction: str
content: str
tools: list[str] # 这一步调用过的工具(审计用)
class AgentState(TypedDict, total=False):
question: str
user_id: str
max_rounds: int
round: int
turns: Annotated[list[Turn], operator.add]
last_verdict: Literal["APPROVE", "REVISE"] | None
decision: dict | None
human_answers: Annotated[list[str], operator.add]
memories: list[str]
final_answer: str
end_reason: str
```
```python
# 文件:agent-mini/src/agent_mini/graph/routing.py
from typing import Literal
from agent_mini.graph.state import AgentState
Next = Literal["proposer", "critic", "ask_human", "summarize"]
def hard_stop(state: AgentState) -> str | None:
"""硬规则:返回结束原因;不需要结束时返回 None。"""
if state.get("last_verdict") == "APPROVE":
return "consensus"
if state.get("round", 0) >= state.get("max_rounds", 3):
return "max_rounds"
return None
def route_after_coordinator(state: AgentState) -> Next:
if hard_stop(state):
return "summarize"
d = state.get("decision") or {}
if d.get("action") == "dispatch":
return "critic" if d.get("target") == "critic" else "proposer"
if d.get("action") == "ask_human":
return "ask_human"
return "summarize"
```
```python
# 文件:agent-mini/tests/test_routing.py
import pytest
from agent_mini.graph.routing import route_after_coordinator
@pytest.mark.parametrize("state, expected", [
({"last_verdict": "APPROVE", "decision": {"action": "dispatch", "target": "proposer"}}, "summarize"),
({"round": 3, "max_rounds": 3, "decision": {"action": "dispatch", "target": "proposer"}}, "summarize"),
({"round": 1, "decision": {"action": "dispatch", "target": "critic"}}, "critic"),
({"round": 1, "decision": {"action": "dispatch", "target": None}}, "proposer"),
({"round": 1, "decision": {"action": "ask_human"}}, "ask_human"),
({"round": 1, "decision": {"action": "finish"}}, "summarize"),
({"round": 0}, "summarize"), # 没有决定(异常情况)时保守结束
])
def test_route(state, expected):
assert route_after_coordinator(state) == expected
```
**易错点 / 面试常问(补充)**
- 易错点:让模型输出结构化决定时,一定要校验(本项目用 pydantic),并在解析失败时有兜底路由,不要让一次格式错误让整个流程崩溃。
- 易错点:路由函数要是纯函数,只根据状态决定下一步,这样才能单独测试。
- 面试常问:“谁来决定下一步”有几种做法(规则路由、模型决定、两者结合),各自的可控性和灵活性。
### 要建的文件
- `models.py`、`graph/__init__.py`、`graph/state.py`、`graph/routing.py`、`tests/test_routing.py`
### 运行方式
```bash
uv run pytest tests/test_routing.py -v
```
### 完成标准
- 每条路由规则都有用例;能解释为什么“硬规则优先于模型决定”。
---
## 第 3 天:模型、工具、提示词、节点
### 学习内容
- 说明:
- **模型**:真实模型用 LangChain 的 `init_chat_model`(OpenAI 兼容接口填 `base_url`);测试用自己写的假模型(继承 `BaseChatModel`,按角色返回预设回答,支持 `bind_tools`)。
- **工具**:本地工具用 `@tool` 装饰器;MCP 工具用 `StructuredTool` 包一层(参数 Schema 直接用 MCP 给的 JSON Schema)。
- **节点**:每个节点只做一件事,返回要更新的字段。
- 协调者:先检查硬规则(命中就不调用模型,省钱);否则让模型输出 JSON 决定,校验失败重试一次。
- 提议者:带工具的小循环(`bind_tools` → 模型要调用工具 → 执行 → `ToolMessage` 回填 → 再问),有最大步数。
- 评审者:解析 `VERDICT`。
- 问人:`interrupt()`(下周配合检查点使用)。
- 总结者:生成结论和结束原因。
- **上下文工程**:每个节点只给模型需要的东西——问题、最近几条发言、本次指令、相关记忆,而不是全部历史。
- 案例:
```python
# 文件:agent-mini/src/agent_mini/llm.py
import os
from collections.abc import Callable
from typing import Any
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AIMessage, BaseMessage
from langchain_core.outputs import ChatGeneration, ChatResult
class FakeChatModel(BaseChatModel):
"""测试用:responder 根据收到的消息决定回答(可以带 tool_calls)。"""
responder: Callable[[list[BaseMessage]], AIMessage]
calls: int = 0
@property
def _llm_type(self) -> str:
return "fake-role"
def _generate(self, messages: list[BaseMessage], stop: list[str] | None = None,
run_manager: Any = None, **kwargs: Any) -> ChatResult:
self.calls += 1
return ChatResult(generations=[ChatGeneration(message=self.responder(messages))])
def bind_tools(self, tools: Any, **kwargs: Any) -> "FakeChatModel": # type: ignore[override]
return self
def build_chat_model(model: str | None = None) -> BaseChatModel:
"""真实模型:OpenAI 兼容接口。需要 uv add langchain langchain-openai。参数以官方文档为准。"""
from langchain.chat_models import init_chat_model
return init_chat_model(
model or os.environ["CHAT_MODEL"], model_provider="openai",
base_url=os.environ["LLM_BASE_URL"], api_key=os.environ["LLM_API_KEY"],
temperature=0.2, timeout=60, max_retries=2,
)
```
```python
# 文件:agent-mini/src/agent_mini/tools.py
from datetime import date
from langchain_core.tools import BaseTool, StructuredTool, tool
from mcp import Client
from agent_mini.handwritten.tools import safe_eval
@tool
def calculator(expression: str) -> str:
"""计算四则运算,例如 (3+4)*2。需要做数学计算时使用。"""
return str(safe_eval(expression))
@tool
def today() -> str:
"""返回今天的日期(YYYY-MM-DD)。"""
return date.today().isoformat()
LOCAL_TOOLS: list[BaseTool] = [calculator, today]
async def mcp_tools_for_langchain(client: Client, allow: set[str]) -> list[BaseTool]:
"""把 MCP 工具包装成 LangChain 工具(也可以用官方的 MCP 适配器,接口以文档为准)。"""
out: list[BaseTool] = []
for t in (await client.list_tools()).tools:
if t.name not in allow:
continue
async def run(_name: str = t.name, **kwargs) -> str:
r = await client.call_tool(_name, kwargs)
text = "\n".join(c.text for c in r.content if getattr(c, "type", "") == "text")
return f"工具出错:{text}" if r.is_error else text
out.append(StructuredTool.from_function(coroutine=run, name=t.name,
description=t.description or "", args_schema=t.input_schema))
return out
```
```python
# 文件:agent-mini/src/agent_mini/graph/prompts.py
COORDINATOR = """你是协调者,负责决定下一步由谁做什么。规则:
- 还没有方案时派给提议者(proposer);有新方案时派给评审者(critic);评审要求修改时派回提议者。
- 问题缺少关键信息(预算、规模、约束)且无法合理假设时,选择 ask_human。
只输出 JSON:{"action": "dispatch|ask_human|finish", "target": "proposer|critic|null", "instruction": "具体指令", "reason": "理由"}"""
PROPOSER = "你是提议者。给出具体、可执行的方案;需要事实或计算时使用工具,不要编造。"
CRITIC = "你是评审者。检查方案的成本、风险、可行性。最后一行必须是 VERDICT: APPROVE 或 VERDICT: REVISE。"
SUMMARIZER = "你是总结者。根据讨论给出最终结论(不超过 300 字),列出关键理由和主要风险。"
def context(state: dict, last_n: int = 4) -> str:
turns = state.get("turns", [])[-last_n:]
recent = "\n".join(f"[{t['role']}] {t['content'][:500]}" for t in turns) or "(无)"
memories = "\n".join(f"- {m}" for m in state.get("memories", [])) or "(无)"
answers = "\n".join(f"- {a}" for a in state.get("human_answers", [])) or "(无)"
return f"问题:{state['question']}\n用户补充:\n{answers}\n相关记忆:\n{memories}\n最近讨论:\n{recent}"
```
```python
# 文件:agent-mini/src/agent_mini/graph/nodes.py
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
from langchain_core.tools import BaseTool
from langgraph.types import interrupt
from pydantic import ValidationError
from agent_mini.graph import prompts
from agent_mini.graph.routing import hard_stop
from agent_mini.graph.state import AgentState
from agent_mini.models import parse_decision, parse_verdict
class Nodes:
def __init__(self, model: BaseChatModel, tools: list[BaseTool] | None = None, max_tool_steps: int = 4):
self.model, self.tools, self.max_tool_steps = model, tools or [], max_tool_steps
async def coordinator(self, state: AgentState) -> dict:
if hard_stop(state):
return {"decision": {"action": "finish", "reason": "命中硬规则"}} # 不调用模型
messages = [SystemMessage(prompts.COORDINATOR), HumanMessage(prompts.context(state))]
for attempt in range(2):
raw = str((await self.model.ainvoke(messages)).content)
try:
return {"decision": parse_decision(raw).model_dump()}
except ValidationError as e:
if attempt == 1:
raise
messages += [HumanMessage(f"格式错误:{e.errors()[0]['msg']}。只输出合法 JSON。")]
raise AssertionError("unreachable")
async def proposer(self, state: AgentState) -> dict:
instruction = (state.get("decision") or {}).get("instruction", "")
messages = [SystemMessage(prompts.PROPOSER), HumanMessage(f"{prompts.context(state)}\n\n指令:{instruction}")]
bound = self.model.bind_tools(self.tools) if self.tools else self.model
by_name = {t.name: t for t in self.tools}
used: list[str] = []
for _ in range(self.max_tool_steps):
ai = await bound.ainvoke(messages)
messages.append(ai)
if not ai.tool_calls:
break
for call in ai.tool_calls:
tool = by_name.get(call["name"])
try:
result = await tool.ainvoke(call["args"]) if tool else f"没有工具 {call['name']}"
except Exception as e: # 工具出错返回给模型
result = f"工具出错:{e}"
used.append(call["name"])
messages.append(ToolMessage(content=str(result), tool_call_id=call["id"]))
content = str(messages[-1].content) if not getattr(messages[-1], "tool_calls", None) else "(工具调用次数超限)"
return {"round": state.get("round", 0) + 1,
"turns": [{"role": "proposer", "instruction": instruction, "content": content, "tools": used}]}
async def critic(self, state: AgentState) -> dict:
instruction = (state.get("decision") or {}).get("instruction", "")
ai = await self.model.ainvoke([SystemMessage(prompts.CRITIC),
HumanMessage(f"{prompts.context(state)}\n\n指令:{instruction}")])
content = str(ai.content)
return {"last_verdict": parse_verdict(content),
"turns": [{"role": "critic", "instruction": instruction, "content": content, "tools": []}]}
async def ask_human(self, state: AgentState) -> dict:
# interrupt 会暂停整个图并把问题交给调用方;恢复时这个节点从头重新执行,
# 所以 interrupt 之前不要放有副作用的操作
question = (state.get("decision") or {}).get("instruction") or "请补充更多信息"
answer = interrupt({"question": question})
return {"human_answers": [f"{question} → {answer}"], "decision": None}
async def summarize(self, state: AgentState) -> dict:
ai = await self.model.ainvoke([SystemMessage(prompts.SUMMARIZER), HumanMessage(prompts.context(state, last_n=8))])
reason = hard_stop(state) or "coordinator_finish"
return {"final_answer": str(ai.content), "end_reason": reason}
```
**易错点 / 面试常问(补充)**
- 易错点:每个角色的提示词要写清“输出格式”,例如审查者最后一行必须是 `VERDICT: APPROVE / REVISE`,否则路由规则无法判断。
- 易错点:工具调用的结果要限制长度再放回上下文,太长会挤掉重要信息、增加费用。
- 面试常问:怎么让多个角色不互相“附和”(不同的系统提示词、审查者有明确的检查清单、最大轮数兜底)。
### 要建的文件
- `llm.py`、`tools.py`、`graph/prompts.py`、`graph/nodes.py`
### 运行方式
- 明天组装成图后一起测试。
### 完成标准
- 提示词全部自己写,不复制公司的。
- 能讲清每个节点“读了 State 的哪些字段、返回了哪些字段”。
---
## 第 4 天:组装图 + 测试
### 学习内容
- 说明:把节点和条件边组装起来;`checkpointer` 和 `store` 作为参数传入(本周传 `None`,下周接上)。用 `stream_mode="updates"` 逐个节点看执行过程。
- 案例:
```python
# 文件:agent-mini/src/agent_mini/graph/build.py
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from langgraph.graph import END, START, StateGraph
from agent_mini.graph.nodes import Nodes
from agent_mini.graph.routing import route_after_coordinator
from agent_mini.graph.state import AgentState
def build_graph(model: BaseChatModel, tools: list[BaseTool] | None = None, checkpointer=None, store=None):
n = Nodes(model, tools)
g = StateGraph(AgentState)
g.add_node("coordinator", n.coordinator)
g.add_node("proposer", n.proposer)
g.add_node("critic", n.critic)
g.add_node("ask_human", n.ask_human)
g.add_node("summarize", n.summarize)
g.add_edge(START, "coordinator")
g.add_conditional_edges("coordinator", route_after_coordinator,
["proposer", "critic", "ask_human", "summarize"])
for node in ("proposer", "critic", "ask_human"):
g.add_edge(node, "coordinator")
g.add_edge("summarize", END)
return g.compile(checkpointer=checkpointer, store=store)
```
```python
# 文件:agent-mini/src/agent_mini/fakes.py
import json
from langchain_core.messages import AIMessage, BaseMessage
from agent_mini.llm import FakeChatModel
ROLE_MARKERS = {"协调者": "coordinator", "提议者": "proposer", "评审者": "critic", "总结者": "summarizer",
"记忆整理": "memory"}
def role_of(messages: list[BaseMessage]) -> str:
system = str(messages[0].content)
return next(role for marker, role in ROLE_MARKERS.items() if marker in system)
def decision(action: str, target: str | None = None, instruction: str = "请处理") -> str:
return json.dumps({"action": action, "target": target, "instruction": instruction, "reason": "测试"})
def scripted_model(**queues: list) -> FakeChatModel:
"""每个角色一个回答队列;元素是字符串或 AIMessage(可以带 tool_calls)。队列用完时重复最后一个。"""
q = {k: list(v) for k, v in queues.items()}
def respond(messages: list[BaseMessage]) -> AIMessage:
items = q[role_of(messages)]
item = items.pop(0) if len(items) > 1 else items[0]
return item if isinstance(item, AIMessage) else AIMessage(content=item)
return FakeChatModel(responder=respond)
def happy_path_model() -> FakeChatModel:
"""提议者先调用计算器 → 评审要求修改 → 再提议 → 评审通过。"""
return scripted_model(
coordinator=[decision("dispatch", "proposer"), decision("dispatch", "critic"),
decision("dispatch", "proposer"), decision("dispatch", "critic")],
proposer=[AIMessage(content="", tool_calls=[{"name": "calculator", "args": {"expression": "5*2000"}, "id": "t1"}]),
"方案一:每月预算 10000 元", "方案二:加上缓存一致性方案"],
critic=["没考虑一致性\nVERDICT: REVISE", "可以\nVERDICT: APPROVE"],
summarizer=["结论:采用方案二"],
memory=['{"memories": []}'],
)
```
```python
# 文件:agent-mini/tests/test_graph.py
from agent_mini.graph.build import build_graph
from agent_mini.tools import LOCAL_TOOLS
from agent_mini.fakes import decision, happy_path_model, scripted_model
async def test_consensus_with_tool_call():
graph = build_graph(happy_path_model(), LOCAL_TOOLS)
out = await graph.ainvoke({"question": "5 人团队怎么选技术栈", "max_rounds": 5, "round": 0})
assert out["end_reason"] == "consensus" and out["round"] == 2
assert out["turns"][0]["tools"] == ["calculator"]
assert [t["role"] for t in out["turns"]] == ["proposer", "critic", "proposer", "critic"]
assert out["final_answer"] == "结论:采用方案二"
async def test_max_rounds_and_stream_order():
model = scripted_model(coordinator=[decision("dispatch", "proposer"), decision("dispatch", "critic")] * 2,
proposer=["方案"], critic=["VERDICT: REVISE"], summarizer=["到达上限"])
graph = build_graph(model)
nodes = []
async for update in graph.astream({"question": "q", "max_rounds": 2, "round": 0}, stream_mode="updates"):
nodes.extend(update.keys())
assert nodes[nodes.index("summarize") - 1] == "coordinator" # 最后由协调者(命中轮数上限)转到总结
assert nodes.count("proposer") == 2
async def test_hard_stop_skips_model_call():
model = scripted_model(coordinator=[decision("dispatch", "proposer")], proposer=["方案"],
critic=["好\nVERDICT: APPROVE"], summarizer=["结论"])
graph = build_graph(model)
await graph.ainvoke({"question": "q", "max_rounds": 1, "round": 0})
# 协调者 1 次(派给提议者)+ 提议者 1 次 + 总结 1 次;第二次进入协调者时命中“轮数上限”,没有调用模型
assert model.calls == 3
def test_mermaid():
text = build_graph(happy_path_model()).get_graph().draw_mermaid()
assert "coordinator" in text and "summarize" in text
```
```python
# 文件:agent-mini/tests/__init__.py
```
**易错点 / 面试常问(补充)**
- 易错点:测试里用脚本化的假模型,按角色给出固定回复,这样图的每条路径都能稳定测试,不花钱、不受模型随机性影响。
- 面试常问:Agent 怎么做自动化测试(假模型测流程和路由、评测集测效果、真实模型只在少量冒烟测试里用)。
### 要建的文件
- `graph/build.py`、`fakes.py`(放在 `src` 里,命令行的 `--fake` 演示也要用)、`tests/__init__.py`、`tests/test_graph.py`
### 运行方式
```bash
uv run pytest tests/test_graph.py -v
```
### 完成标准
- 测试通过;能解释 `test_hard_stop_skips_model_call` 为什么是 3 次调用。
---
## 第 5 天:MCP 工具接入 LangGraph + 命令行
### 学习内容
- 说明:上周的 MCP 服务通过 `mcp_tools_for_langchain` 变成 LangChain 工具,提议者就能“查知识库”。命令行提供两个子命令:导出图结构、流式运行(打印每个节点做了什么)。
- 案例:
```python
# 文件:agent-mini/tests/test_lc_tools.py
import httpx
from langchain_core.messages import AIMessage
from mcp import Client
import mcp_servers.knowledge_server as ks
from agent_mini.graph.build import build_graph
from agent_mini.tools import mcp_tools_for_langchain
from agent_mini.fakes import decision, scripted_model
async def test_proposer_uses_mcp_tool(monkeypatch):
monkeypatch.setattr(ks, "RAG_TRANSPORT", httpx.MockTransport(lambda r: httpx.Response(200, json={
"text": "团队熟悉 Vue 和 Go [1]", "citations": [{"n": 1, "filename": "team.md", "heading_path": "技术栈"}]})))
async with Client(ks.mcp) as client:
tools = await mcp_tools_for_langchain(client, allow={"search_knowledge"})
model = scripted_model(
coordinator=[decision("dispatch", "proposer"), decision("finish")],
proposer=[AIMessage(content="", tool_calls=[{"name": "search_knowledge",
"args": {"query": "团队技术栈"}, "id": "k1"}]),
"根据知识库:前端 Vue、后端 Go"],
summarizer=["结论:Vue + Go"],
)
out = await build_graph(model, tools).ainvoke({"question": "选技术栈", "max_rounds": 3, "round": 0})
assert out["turns"][0]["tools"] == ["search_knowledge"]
assert out["end_reason"] == "coordinator_finish"
```
```python
# 文件:agent-mini/src/agent_mini/cli.py
import argparse
import asyncio
import json
from agent_mini.graph.build import build_graph
from agent_mini.tools import LOCAL_TOOLS
def make_model(fake: bool):
if fake:
from agent_mini.fakes import happy_path_model # 演示用假模型
return happy_path_model()
from agent_mini.llm import build_chat_model
return build_chat_model()
async def run(question: str, fake: bool, max_rounds: int) -> None:
graph = build_graph(make_model(fake), LOCAL_TOOLS)
async for update in graph.astream({"question": question, "max_rounds": max_rounds, "round": 0},
stream_mode="updates"):
for node, data in update.items():
print(f"== {node}")
print(json.dumps(data, ensure_ascii=False, indent=2)[:800])
def main() -> None:
p = argparse.ArgumentParser(prog="agent-mini")
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("graph")
r = sub.add_parser("run")
r.add_argument("question")
r.add_argument("--fake", action="store_true", help="使用假模型(不需要密钥)")
r.add_argument("--max-rounds", type=int, default=3)
args = p.parse_args()
if args.cmd == "graph":
print(build_graph(make_model(True)).get_graph().draw_mermaid())
else:
asyncio.run(run(args.question, args.fake, args.max_rounds))
if __name__ == "__main__": # 不自动运行
main()
```
**易错点 / 面试常问(补充)**
- 易错点:把 MCP 工具转成 LangChain 工具时,参数 Schema 要原样保留,否则模型生成的参数会和服务端要求对不上。
- 易错点:本项目用自己写的桥接;以后换成官方适配(`langchain-mcp-adapters` 或 `langchain` 里的 MCP 支持)时,这些包更新快,接口以官方文档为准:https://docs.langchain.com/oss/python/langchain/mcp 。
- 面试常问:工具多了以后怎么办(按场景挑选少量工具、工具描述写清楚、对有副作用的工具要求确认)。
### 要建的文件
- `tests/test_lc_tools.py`、`cli.py`;`pyproject.toml` 里加 `[project.scripts] agent-mini = "agent_mini.cli:main"`
### 运行方式
```bash
uv run pytest -v
uv run agent-mini graph > docs/graph.mmd
uv run agent-mini run "5 人创业团队做内部工具该选什么技术栈" --fake
```
### 完成标准
- 测试通过;图结构导出到 `docs/graph.mmd` 并贴进 `docs/architecture.md`。
---
## 第 6 天:真实模型试跑和调提示词
### 学习内容
- 说明:用真实模型跑 5 个不同题目,观察:协调者是否按格式输出、评审是否太容易通过(总是第一轮就 APPROVE)、提议者是否乱调工具、平均轮数和耗时。每次改提示词记一行到 `docs/prompt-changelog.md`,第 13 周评测时会用到。
- 案例:`docs/prompt-changelog.md` 的格式
```markdown
| 日期 | 改了什么 | 为什么 | 观察到的变化(以实际运行为准) |
| --- | --- | --- | --- |
| 2026-xx-xx | 评审者提示词加“至少指出一个风险” | 评审总是第一轮就通过 | 平均轮数从 x 变成 y |
```
### 要建的文件
- `docs/samples/langgraph-*.md`(真实运行记录,检查无敏感信息)、`docs/prompt-changelog.md`
### 运行方式
```bash
export LLM_BASE_URL=... LLM_API_KEY=... CHAT_MODEL=... # 写在 .env,不提交
uv run agent-mini run "给读书 App 设计会员规则"
```
### 完成标准
- 5 个题目都跑完;changelog 至少 2 条。
---
## 第 7 天:对照文档 + M2 验收
### 学习内容
- 说明:补完 `docs/handwritten-vs-langgraph.md` 的另一半——手写版每一部分在 LangGraph 里对应什么,框架帮你做了什么,又有哪些事框架不管、还得自己做。
- 案例:
```markdown
| 关注点 | 手写版 | LangGraph 版 | 框架没管、要自己做的 |
| --- | --- | --- | --- |
| 循环 / 跳转 | while + if | 条件边 | 结束条件(硬规则)仍要自己写 |
| 状态合并 | 手动 append | reducer | — |
| 持久化 | JSON 文件 | checkpointer(下周) | 业务状态表、权限、审计 |
| 工具调用 | 手写循环 | bind_tools + 节点内循环 / ToolNode | 参数白名单、副作用幂等 |
| 中断问人 | 自己存 awaiting_input | interrupt + Command(resume) | 前端交互、超时处理 |
| 防重复执行 | 进程内集合 | 无 | 租约(下周) |
```
### 要建的文件
- `docs/handwritten-vs-langgraph.md`
### 运行方式
```bash
uv run ruff check . && uv run pyright src && uv run pytest -v
```
### 完成标准(M2 验收)
- [ ] LangGraph 版能跑完一次带工具调用的多轮讨论,结束条件正确
- [ ] 路由规则、图的主要路径都有测试(假模型,不需要密钥)
- [ ] MCP 工具接入了 LangGraph
- [ ] 能讲清 State、节点、条件边、reducer,以及和手写版的对应关系
- [ ] 在总纲进度表勾掉第 11 周
File diff suppressed because it is too large. Load diff
+581
View File
@@ -0,0 +1,581 @@
# 第 13 周:项目三 M4——评测集、可观测性、文档、Docker + Dify / Coze 体验
> 项目三的最后一周(阶段 3 出门)。上周 `week12-agent-m3-persistence.md`,下周 `week14-backend-extend-deploy.md`。
**本周目标**:
1. **评测**:能用数字说明“改了提示词 / 流程之后变好还是变差”(招聘里的“Agent 评测”)。
2. **可观测性**:每次运行调用了几次模型、用了哪些工具、花了多少时间和 token;了解 LangSmith / Langfuse。
3. **文档**:架构图、设计取舍、与生产概念的映射表、讲解稿——面试时直接用。
4. **Docker** 一键启动;别人按 README 10 分钟跑通。
5. **Dify / Coze** 体验:能讲清低代码平台和写代码的取舍。
**本周新增文件**
```text
agent-mini/
├── src/agent_mini/
│ ├── evals.py # 用例结构、规则检查、模型评分、汇总
│ └── observability.py # 回调:统计模型调用、工具、token、耗时
├── evals/
│ ├── cases.jsonl
│ ├── run_evals.py
│ └── reports/
├── docs/ architecture.md design-decisions.md mapping.md talk.md
├── Dockerfile
├── docker-compose.yml
└── tests/
├── test_evals.py
└── test_observability.py
notes/lowcode-dify-coze.md
lowcode/ # Dify / Coze 导出的配置(去掉密钥)
```
---
## 第 1 天:评测集和规则检查
### 学习内容
- 说明:
- Agent 评测比 RAG 难,因为过程有多步。从三个层面看:
- **结果**:结论是否回答了问题、是否提到必须提到的点。
- **过程**:该调的工具调了没有、不该调的有没有乱调;轮数是否合理;该问人时有没有问。
- **成本和稳定性**:耗时、模型调用次数、token、失败率。
- **能用规则判断的用规则**(便宜、稳定、可复现);不能的再用“模型按评分标准打分”(LLM-as-judge),并抽一部分人工复核,确认模型评分靠谱。
- 用例要覆盖:普通问题、需要查知识库的问题、信息不足需要问人的问题、**诱导乱调工具的问题**(比如“帮我删掉所有待办”——不应该调用有副作用的工具)。
- 评测用 `InMemorySaver`,每条用例一个 `thread_id`;需要问人的用例自动给预设回答。
- 案例:
```json
{"id": "basic-1", "question": "5 人创业团队做内部工具,该选什么技术栈?", "expect": {"max_rounds": 3, "must_mention": ["方案"], "should_ask_human": false}}
```
```json
{"id": "clarify-1", "question": "[需要澄清] 帮我做个网站", "human_answer": "预算 5 万以内", "expect": {"should_ask_human": true}}
```
```json
{"id": "tool-1", "question": "每人每月 2000 元,5 个人一年预算多少?", "expect": {"must_call_tools": ["calculator"]}}
```
(每行一条,保存为 `evals/cases.jsonl`,至少 20 条;上面三行是格式示例。)
```python
# 文件:agent-mini/src/agent_mini/evals.py
import json
import re
import time
from pathlib import Path
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import HumanMessage, SystemMessage
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
from pydantic import BaseModel, Field
from agent_mini.graph.build import build_graph
class Expect(BaseModel):
must_call_tools: list[str] = []
must_not_call_tools: list[str] = []
should_ask_human: bool | None = None
max_rounds: int | None = None
must_mention: list[str] = []
class EvalCase(BaseModel):
id: str
question: str
human_answer: str = "按常见情况假设即可"
expect: Expect = Expect()
rubric: str = ""
class CaseResult(BaseModel):
id: str
passed: bool
failures: list[str]
rounds: int
tools: list[str]
asked_human: bool
end_reason: str
seconds: float
judge_score: int | None = None
def load_cases(path: Path) -> list[EvalCase]:
return [EvalCase.model_validate_json(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
def check(case: EvalCase, out: dict, asked_human: bool) -> list[str]:
tools = [t for turn in out.get("turns", []) for t in turn.get("tools", [])]
e, fails = case.expect, []
fails += [f"没有调用 {t}" for t in e.must_call_tools if t not in tools]
fails += [f"不该调用 {t}" for t in e.must_not_call_tools if t in tools]
if e.should_ask_human is not None and asked_human != e.should_ask_human:
fails.append("应该问人但没问" if e.should_ask_human else "不该问人却问了")
if e.max_rounds is not None and out.get("round", 0) > e.max_rounds:
fails.append(f"轮数 {out.get('round')} 超过 {e.max_rounds}")
answer = out.get("final_answer", "") + "".join(t["content"] for t in out.get("turns", []))
fails += [f"没有提到“{w}”" for w in e.must_mention if w not in answer]
if not out.get("final_answer"):
fails.append("没有最终结论")
return fails
async def run_case(model: BaseChatModel, case: EvalCase, tools=None, callbacks=None) -> CaseResult:
graph = build_graph(model, tools, checkpointer=InMemorySaver())
cfg = {"configurable": {"thread_id": case.id}, "callbacks": callbacks or []}
start = time.perf_counter()
out = await graph.ainvoke({"question": case.question, "max_rounds": 4, "round": 0}, cfg)
asked = False
for _ in range(3): # 最多回答 3 次问题,防止评测卡住
if "__interrupt__" not in out:
break
asked = True
out = await graph.ainvoke(Command(resume=case.human_answer), cfg)
fails = check(case, out, asked)
tools_used = [t for turn in out.get("turns", []) for t in turn.get("tools", [])]
return CaseResult(id=case.id, passed=not fails, failures=fails, rounds=out.get("round", 0), tools=tools_used,
asked_human=asked, end_reason=out.get("end_reason", ""),
seconds=round(time.perf_counter() - start, 3))
class JudgeOut(BaseModel):
score: int = Field(ge=1, le=5)
reason: str
JUDGE = """你是严格的评审。按评分标准给回答打 1~5 分(5 最好)。只输出 JSON:{"score": 整数, "reason": "一句话理由"}"""
async def judge(model: BaseChatModel, case: EvalCase, answer: str) -> JudgeOut:
ai = await model.ainvoke([SystemMessage(JUDGE), HumanMessage(
f"问题:{case.question}\n评分标准:{case.rubric or '是否具体、可执行、考虑了成本和风险'}\n回答:{answer}")])
m = re.search(r"\{.*\}", str(ai.content), re.S)
return JudgeOut.model_validate_json(m.group(0) if m else "{}")
def summarize(results: list[CaseResult]) -> dict:
n = len(results) or 1
tool_cases = [r for r in results if r.tools]
return {
"cases": len(results),
"pass_rate": round(sum(r.passed for r in results) / n, 3),
"avg_rounds": round(sum(r.rounds for r in results) / n, 2),
"avg_seconds": round(sum(r.seconds for r in results) / n, 3),
"cases_with_tools": len(tool_cases),
"failures": {r.id: r.failures for r in results if r.failures},
}
def to_markdown(summary: dict, meta: dict) -> str:
lines = ["# 评测报告", "", *[f"- {k}:{v}" for k, v in meta.items()], "",
"| 指标 | 值 |", "| --- | --- |",
*[f"| {k} | {v} |" for k, v in summary.items() if k != "failures"], "", "## 失败用例"]
lines += [f"- {cid}:{';'.join(f)}" for cid, f in summary["failures"].items()] or ["- 无"]
return "\n".join(lines)
if __name__ == "__main__":
print(json.dumps(Expect().model_dump(), ensure_ascii=False))
```
**易错点 / 面试常问(补充)**
- 易错点:用例的期望要写成能自动判断的规则(必须调用哪个工具、是否应该问人、必须提到什么),只写“回答得好”是没法自动评测的。
- 易错点:评测要固定模型、温度和提示词版本,否则两次结果的差异可能只是随机波动。
- 面试常问:Agent 评测和 RAG 评测的区别(除了结果,还要评过程:工具选择、步数、是否该停下来问人、成本)。
### 要建的文件
- `evals.py`、`evals/cases.jsonl`(≥ 20 条,覆盖上面 4 类)
### 运行方式
- 明天和测试、评测脚本一起跑。
### 完成标准
- 用例覆盖 4 类问题;每条至少有一个可以用规则判断的期望。
---
## 第 2 天:评测脚本、回归测试、用评测驱动改进
### 学习内容
- 说明:
- 评测脚本逐条运行,汇总通过率、平均轮数、耗时,结果写进 `evals/reports/<日期>.md`,**记录当时的模型和提示词版本**(否则数字没法比较)。
- 用假模型做一个不需要密钥的小评测子集,放进测试——这就是**回归测试**:以后改代码,流程被改坏会立刻发现。
- 改进流程:跑基线 → 挑失败最多的一类 → 改提示词或路由规则 → 再跑 → 对比。改动和结果记到 `docs/prompt-changelog.md`。**数字以你实际跑出来的为准,不要编。**
- 案例:
```python
# 文件:agent-mini/tests/test_evals.py
from langchain_core.messages import AIMessage
from agent_mini.demo_model import demo_model
from agent_mini.evals import EvalCase, Expect, judge, run_case, summarize
from agent_mini.fakes import decision, scripted_model
from agent_mini.tools import LOCAL_TOOLS
async def test_regression_suite_with_fake_model():
cases = [
EvalCase(id="basic", question="选技术栈", expect=Expect(max_rounds=3, must_mention=["方案"], should_ask_human=False)),
EvalCase(id="clarify", question="[需要澄清] 做网站", human_answer="5 万", expect=Expect(should_ask_human=True)),
]
results = [await run_case(demo_model(), c) for c in cases]
s = summarize(results)
assert s["pass_rate"] == 1.0, s["failures"]
assert results[1].asked_human and results[1].end_reason == "consensus"
async def test_detects_missing_tool_call():
model = scripted_model(coordinator=[decision("dispatch", "proposer"), decision("finish")],
proposer=["大概 12 万吧"], summarizer=["12 万"])
r = await run_case(model, EvalCase(id="t", question="算预算", expect=Expect(must_call_tools=["calculator"])), LOCAL_TOOLS)
assert not r.passed and r.failures == ["没有调用 calculator"]
async def test_tool_case_passes_when_tool_used():
model = scripted_model(
coordinator=[decision("dispatch", "proposer"), decision("finish")],
proposer=[AIMessage(content="", tool_calls=[{"name": "calculator", "args": {"expression": "2000*5*12"}, "id": "c"}]),
"一年 120000 元"],
summarizer=["一年预算 12 万"])
r = await run_case(model, EvalCase(id="t", question="算预算", expect=Expect(must_call_tools=["calculator"])), LOCAL_TOOLS)
assert r.passed and r.tools == ["calculator"]
async def test_judge_parses_score():
from agent_mini.llm import FakeChatModel
j = FakeChatModel(responder=lambda messages: AIMessage(content='评分:{"score": 4, "reason": "具体可执行"}'))
out = await judge(j, EvalCase(id="x", question="q"), "回答")
assert out.score == 4 and out.reason == "具体可执行"
```
```python
# 文件:agent-mini/evals/run_evals.py
"""用法:uv run python evals/run_evals.py [--fake]"""
import asyncio
import datetime as dt
import sys
from pathlib import Path
from agent_mini.evals import load_cases, run_case, summarize, to_markdown
from agent_mini.observability import RunMetrics
from agent_mini.tools import LOCAL_TOOLS
async def main() -> None: # 不自动运行:默认用真实模型
fake = "--fake" in sys.argv
if fake:
from agent_mini.demo_model import demo_model as make
else:
from agent_mini.llm import build_chat_model as make
cases = load_cases(Path(__file__).with_name("cases.jsonl"))
metrics = RunMetrics()
results = [await run_case(make(), c, LOCAL_TOOLS, callbacks=[metrics]) for c in cases]
summary = summarize(results) | {"model_calls": metrics.llm_calls, "total_tokens": metrics.total_tokens}
meta = {"日期": dt.date.today().isoformat(), "模型": "fake" if fake else "见 .env 的 CHAT_MODEL",
"提示词版本": "见 docs/prompt-changelog.md 最新一行"}
report = to_markdown(summary, meta)
out = Path(__file__).with_name("reports") / f"{dt.datetime.now():%Y%m%d-%H%M}.md"
out.parent.mkdir(exist_ok=True)
out.write_text(report, encoding="utf-8")
print(report)
if __name__ == "__main__":
asyncio.run(main())
```
**易错点 / 面试常问(补充)**
- 易错点:用模型打分(LLM-as-judge)有偏差(偏爱长回答、偏爱自己的风格),要抽样人工复核,并给出明确的评分标准。
- 易错点:`--fake` 模式下“必须调用计算器”这类用例会失败是正常的(演示假模型不调工具),它只用来确认脚本能跑。
- 面试常问:怎么证明你的优化有效(同一套评测集、固定配置、改一处跑一次、记录前后指标)。
### 要建的文件
- `tests/test_evals.py`、`evals/run_evals.py`、`evals/reports/`、`docs/prompt-changelog.md`
### 运行方式
```bash
uv run pytest tests/test_evals.py -v
uv run python evals/run_evals.py --fake # 先确认脚本能跑
uv run python evals/run_evals.py # 真实模型:基线
```
### 完成标准
- 至少两次真实模型评测(基线 + 一次改进),报告都在 `evals/reports/`,changelog 写清改了什么、指标怎么变。
---
## 第 3 天:可观测性(回调、LangSmith / Langfuse)
### 学习内容
- 说明:
- **为什么需要**:Agent 出问题时,你要能回答“这次运行调了几次模型、每次输入输出是什么、哪个工具出错、花了多少钱和时间”。没有这些,线上问题无从排查。
- **LangChain 回调**:在 `config={"callbacks": [...]}` 里传入回调对象,模型开始 / 结束、工具开始 / 结束时都会通知它——可以自己统计指标、写日志。
- **LangSmith**:LangChain 官方的追踪和评测平台。设置环境变量后自动上报每次运行的完整调用树(不用改代码):`LANGSMITH_TRACING=true`、`LANGSMITH_API_KEY=...`、`LANGSMITH_PROJECT=agent-mini`。**变量名和用法以官方文档为准**:https://docs.langchain.com/langsmith/home 。注意:上报的内容包含提示词和回答,**涉及敏感数据时不要开**,或者用可以自己部署的开源方案 **Langfuse**(https://langfuse.com/docs )。
- 案例:自己写一个统计回调,评测脚本和服务都能用。
```python
# 文件:agent-mini/src/agent_mini/observability.py
import time
from typing import Any
from langchain_core.callbacks import BaseCallbackHandler
from langchain_core.outputs import LLMResult
class RunMetrics(BaseCallbackHandler):
"""统计一次(或多次)运行的模型调用、token、工具和耗时。"""
def __init__(self) -> None:
self.llm_calls = 0
self.total_tokens = 0
self.llm_seconds = 0.0
self.tools: list[str] = []
self.tool_errors = 0
self._starts: dict[Any, float] = {}
def on_chat_model_start(self, serialized: dict, messages: list, *, run_id: Any, **kwargs: Any) -> None:
self._starts[run_id] = time.perf_counter()
def on_llm_end(self, response: LLMResult, *, run_id: Any, **kwargs: Any) -> None:
self.llm_calls += 1
self.llm_seconds += time.perf_counter() - self._starts.pop(run_id, time.perf_counter())
for gens in response.generations:
for g in gens:
usage = getattr(getattr(g, "message", None), "usage_metadata", None) or {}
self.total_tokens += usage.get("total_tokens", 0) # 假模型没有用量,真实模型才有
def on_tool_start(self, serialized: dict, input_str: str, **kwargs: Any) -> None:
self.tools.append(serialized.get("name", "?"))
def on_tool_error(self, error: BaseException, **kwargs: Any) -> None:
self.tool_errors += 1
def snapshot(self) -> dict:
return {"llm_calls": self.llm_calls, "total_tokens": self.total_tokens,
"llm_seconds": round(self.llm_seconds, 3), "tools": self.tools, "tool_errors": self.tool_errors}
```
```python
# 文件:agent-mini/tests/test_observability.py
from langchain_core.messages import AIMessage
from agent_mini.evals import EvalCase, run_case
from agent_mini.fakes import decision, scripted_model
from agent_mini.observability import RunMetrics
from agent_mini.tools import LOCAL_TOOLS
async def test_metrics_count_llm_and_tools():
model = scripted_model(
coordinator=[decision("dispatch", "proposer"), decision("finish")],
proposer=[AIMessage(content="", tool_calls=[{"name": "calculator", "args": {"expression": "1+1"}, "id": "c"}]),
"结果是 2"],
summarizer=["2"])
metrics = RunMetrics()
await run_case(model, EvalCase(id="m", question="1+1"), LOCAL_TOOLS, callbacks=[metrics])
snap = metrics.snapshot()
assert snap["tools"] == ["calculator"]
assert snap["llm_calls"] == model.calls == 5 # 协调者 2 次 + 提议者 2 次 + 总结 1 次
```
- 把指标接进服务(选做):`RunManager._drive` 里给 `astream` 的 config 加上 `RunMetrics()`,结束时把 `snapshot()` 写进 `run_completed` 事件,报告里就能看到每次运行的调用次数和 token。
**易错点 / 面试常问(补充)**
- 易错点:追踪平台会记录完整的提示词和回答,涉及用户隐私或公司数据时不要开云端追踪,改用可以自己部署的方案或只记录指标。
- 易错点:回调里不要做耗时操作(比如同步写远程日志),会拖慢每一次模型调用。
- 面试常问:线上 Agent 出问题怎么排查(看调用链 → 定位节点和输入 → 用评测用例复现 → 修复后加进回归测试);要监控哪些指标(成功率、延迟、每次任务的模型调用次数和 token、工具错误率、人工介入率)。
### 要建的文件
- `observability.py`、`tests/test_observability.py`;`docs/observability.md`:LangSmith 或 Langfuse 的截图(用假数据或公开题目)和“哪些情况不能开追踪”
### 运行方式
```bash
uv run pytest tests/test_observability.py -v
LANGSMITH_TRACING=true LANGSMITH_API_KEY=... uv run agent-mini run "选技术栈" # 有账号时体验
```
### 完成标准
- 能回答“线上 Agent 出问题你怎么排查”:看追踪里的调用树 → 找到出错的节点和输入 → 用评测用例复现 → 修复后加进回归测试。
---
## 第 4 天:文档(架构、设计取舍、映射表)
### 学习内容
- 说明:文档是面试的“讲稿底本”。映射表把**项目里的实现**和**生产系统里的概念**一一对齐——面试官听到的是“这个人知道生产系统要什么,并且自己实现过”。生产一栏只写通用概念,不写公司接口名、表名。
- 案例:`docs/mapping.md`
```markdown
| 生产系统里的概念(通用叫法) | 本项目里的实现 | 对应重点 |
| --- | --- | --- |
| 席位 / 专家角色 | `proposer`、`critic` 节点 | A |
| 主持人派活 | `coordinator` 节点 + 条件边 | A |
| 共识 / 结束条件 | `VERDICT: APPROVE` + 最大轮数,写在 `routing.py` | A |
| 防止同一任务被跑两次 | `runs` 表租约 + 带条件的 UPDATE | A |
| 草稿(编排中间状态) | LangGraph 检查点(按 `thread_id`) | B |
| 任务状态(对外权威) | `runs` 表 | B |
| 后台作业 | `RunManager` + 看门狗 | C |
| 流式输出、断线续传 | `GET /runs/{id}/events` + `Last-Event-ID` | C |
| 意图澄清 / 等待用户输入 | `interrupt` → `awaiting_input` → `POST /input` | D |
| 换模型恢复 | 错误码 + `POST /retry {model}`,从检查点继续 | D |
| 过程报告 | `report.py` | E |
| 外部能力接入 | MCP 服务 `knowledge_server` | E |
| 用户偏好 | LangGraph Store 长期记忆 | — |
```
`docs/architecture.md` 里放四张图:组件图、业务状态机(第 12 周)、LangGraph 图结构(`agent-mini graph` 导出)、崩溃恢复时序图:
```mermaid
sequenceDiagram
participant C as 客户端
participant A as API 进程 1
participant DB as runs / events / 检查点
participant B as API 进程 2(重启后)
C->>A: POST /runs
A->>DB: 创建 run(queued),领取租约
A->>DB: 节点完成 → 写检查点、写事件、续租
Note over A: kill -9(不再续租)
B->>DB: 看门狗:发现租约过期的 run
B->>DB: 领取租约,读取最近检查点
B->>DB: 从中断的节点继续 → 完成
C->>B: GET /events(Last-Event-ID)补齐事件
```
### 要建的文件
- `docs/architecture.md`、`docs/mapping.md`、`docs/design-decisions.md`(每条写“选择 / 理由 / 代价”)
### 完成标准
- 映射表至少 5 个概念对齐生产;架构图和代码一致。
---
## 第 5 天:README、Docker、从零验证、讲解稿
### 学习内容
- 说明:
- README 的目标:**别人 10 分钟内跑通**(假模型模式,不需要密钥)。结构:一句话介绍 + 动图;功能;快速开始;切换真实模型;接口表;评测结果;崩溃恢复演示;映射表;项目结构;设计取舍。
- 从零验证:在新目录(最好换一台电脑或请朋友)`git clone` 后按 README 操作并计时,卡住的地方都改进。
- 讲解稿:3 分钟,“问题(为什么多 Agent 难做可靠)→ 设计(两层状态、后台作业、租约、中断、记忆)→ 演示 → 评测和取舍”,引用 1~2 张案例卡说明“这些设计来自我在工作中踩过的坑”。
- 案例:
```dockerfile
FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock README.md ./
RUN uv sync --frozen --no-dev --no-install-project
COPY src ./src
COPY mcp_servers ./mcp_servers
COPY web ./web
RUN uv sync --frozen --no-dev
ENV PATH="/app/.venv/bin:$PATH" AGENT_DATA=/app/data AGENT_MODEL=fake
EXPOSE 8100
CMD ["uvicorn", "agent_mini.api.app:create_app", "--factory", "--host", "0.0.0.0", "--port", "8100"]
```
```yaml
services:
agent:
build: .
env_file: .env # AGENT_MODEL、LLM_BASE_URL、LLM_API_KEY 等;不提交
volumes:
- agent-data:/app/data # 检查点、业务表、记忆都在这里,重建容器不丢
ports:
- "8100:8100"
restart: unless-stopped
volumes:
agent-data:
```
### 要建的文件
- `README.md`、`Dockerfile`、`docker-compose.yml`、`.dockerignore`、`docs/talk.md`;录一段 2~3 分钟演示视频
### 运行方式
```bash
docker compose up -d --build && open http://localhost:8100 # Linux 用 xdg-open
python3 ../tools/check_sensitive.py --self-test && git add -A && git diff --cached --stat
```
### 完成标准
- 别人按 README 10 分钟内跑通;讲解稿计时 ≤ 3 分钟。
---
## 第 6~7 天:Dify / Coze 体验(低代码平台)
### 学习内容
- 说明:
- 招聘里经常出现 Dify、Coze(扣子)、RAGFlow、FastGPT。不需要精通,但要**亲手搭过**,能讲清它们能做什么、和写代码比各有什么优缺点。
- **Dify**:开源,可以自己部署(官方提供 Docker Compose,注意服务器内存,以官方要求为准:https://docs.dify.ai/ ),也有云版。
- **Coze(扣子)**:字节跳动的在线平台,直接用网页版(https://www.coze.cn/ )。
- 二选一为主,另一个看文档即可。
- 案例:要搭的东西
1. **知识库问答**:把 `rag-qa` 用的同一批公开文档导入平台知识库,做一个问答应用;用第 6 周评测集里挑 10 个问题,对比平台和你自己实现的效果(记录命中情况,数字以实际为准)。
2. **工作流**:做一个带条件分支的工作流,包含一个 HTTP 请求节点调用你的 `agent-mini` 的 `POST /runs`(服务需要能被平台访问;云版平台访问不到你的本机,可以用自己部署的 Dify,或只调用公网可访问的接口)。平台支持 MCP 的话,也可以接你的 MCP 服务。
3. **导出配置**:平台支持导出(如 Dify 的 DSL 文件)时,放到 `lowcode/`(去掉密钥)。
对比笔记的骨架:
```markdown
| 维度 | Dify / Coze | 自己写(rag-qa / agent-mini) |
| --- | --- | --- |
| 开发速度 | | |
| 可控性(提示词、检索参数、状态、错误处理) | | |
| 复杂编排(循环、中断、续跑) | | |
| 可观测性和评测 | | |
| 部署和成本 | | |
| 谁来维护(业务人员 / 工程师) | | |
结论:什么场景用平台(快速验证、业务人员自己搭、标准问答),什么场景写代码(复杂编排、强可靠性、深度集成业务系统)。
```
### 要建的文件
- `notes/lowcode-dify-coze.md`(步骤、截图、对比表、结论)、`lowcode/`(导出的配置,去掉密钥)
### 运行方式
```bash
# Dify 自己部署(命令以官方文档为准)
git clone https://github.com/langgenius/dify.git && cd dify/docker && cp .env.example .env && docker compose up -d
```
### 完成标准(M4 验收,也是阶段 3 出门条件)
- [ ] 别人按 README 10 分钟内跑通(不需要真实模型密钥)
- [ ] 演示:启动 →(中途杀进程)→ 重启 → 最终结果可查
- [ ] 映射表至少 5 个概念对齐生产
- [ ] 评测集 ≥ 20 条,至少两次对比结果;回归测试在 CI 里跑
- [ ] 能讲清可观测性怎么做,知道 LangSmith / Langfuse
- [ ] 架构图和 README 一致;测试、ruff、pyright 通过;脱敏检查完成
- [ ] Dify 或 Coze 至少搭过一个知识库应用和一个工作流,写了对比笔记
- [ ] 在总纲进度表勾掉第 13 周和“阶段 3”,填上“项目三路径:`zhuangang/agent-mini/`”
File diff suppressed because it is too large. Load diff
+394
View File
@@ -0,0 +1,394 @@
# 第 15 周:阶段 4(上)——作品集、简历、STAR 经历、口述稿
> 第四段的第 2 周。上周 `week14-backend-extend-deploy.md`,下周 `week16-interview.md`。
**本周目标**:把前 14 周的成果变成招聘方能快速看懂的材料:3 个公开仓库 + 1 份简历 + 3 条 STAR 工作经历 + 1 份口述稿(含演示视频)。
**原则**
- **只写做过的、能演示的**:简历上每一条都要能当场打开代码或演示讲清楚。
- **数字必须是自己跑出来的**:评测指标、耗时等都来自 `evals/reports/` 或自己的记录;没有数字就写现象和做法,不编。
- **脱敏**:公司经历只写机制和自己的贡献,不写公司内部的项目名、接口、数据(总纲第 10 节)。
## 本周最终目录
```text
career/
├── resume.md # 简历(Markdown 写,导出 PDF)
├── resume-variants/ # 针对不同岗位微调的版本
├── star.md # 3 条 STAR 工作经历
├── pitch.md # 口述稿:3 分钟架构 + 1 分钟踩坑
├── portfolio.md # 作品清单:仓库地址、演示链接、视频
├── jd/ # 收集的岗位描述(纯文本,一个岗位一个文件)
├── keyword_check.py # 简历和岗位描述的关键词对照
└── tests/test_keyword_check.py
```
---
## 第 1 天:整理作品集(3 个公开仓库)
### 学习内容
- 说明:招聘方看作品通常只花几分钟:先看 README 顶部(一句话介绍 + 演示动图 + 快速开始),再看目录结构和一两个核心文件。所以每个项目要单独一个公开仓库(GitHub 或其他公开平台),而不是让人在学习仓库里翻。
- 说明:**不要直接公开学习仓库的历史**:历史里可能有早期误提交的内容。最稳妥的做法是把项目目录复制到一个新仓库,作为第一次提交,再做一遍脱敏检查。
- 案例:每个仓库 README 顶部的结构
```markdown
# agent-mini:可崩溃恢复的多 Agent 编排(LangGraph + FastAPI)
![演示](docs/demo.gif)
多个 Agent(提议者、审查者、协调者)协作完成方案讨论;任务在后台执行,进程被杀后自动续跑;
需要澄清时暂停等用户回答;全过程可通过 SSE 实时查看,并生成过程报告。
**亮点**:LangGraph 检查点续跑 · interrupt 人机协作 · 租约防重复执行 · MCP 工具服务 · 评测集 + 回归测试
## 快速开始(不需要模型密钥)
docker compose up -d && open http://localhost:8100
```
- 动图:用录屏工具录 20~30 秒的关键流程(创建任务 → 流式输出 → 杀进程 → 恢复),转成 GIF 或放视频链接。
### 易错点 / 面试常问
- 易错点:公开前在新仓库里再跑一次 `python3 tools/check_sensitive.py`(第 8 周的脚本,复制过去)并人工看一遍 `git diff --cached`;`.env`、数据目录、`*.db` 都要在 `.gitignore` 里。
- 易错点:README 里的命令要在一台干净的电脑(或新目录)上从头跑一遍,常见问题是漏了依赖、端口冲突、缺示例数据。
- 面试常问:“介绍一下你的项目”——先一句话说清解决什么问题,再说架构,最后说你做的最难的一个点(第 6 天的口述稿就是答案)。
### 要建的文件
- 3 个公开仓库:`task-board`(含 `web/board-vue` 前端)、`rag-qa`(含 `web/rag-react`)、`agent-mini`;`career/portfolio.md` 记录地址和演示链接
### 运行方式
```bash
mkdir ~/pub && cp -r ~/zhuangang/agent-mini ~/pub/agent-mini && cd ~/pub/agent-mini
rm -rf .venv data && git init && git add -A && git diff --cached --stat # 检查要提交的文件
python3 ~/zhuangang/tools/check_sensitive.py && git commit -m "agent-mini 初始版本"
```
### 完成标准
- 3 个仓库公开;每个 README 顶部有一句话介绍、动图或视频、快速开始;脱敏检查通过。
---
## 第 2 天:简历结构、学历和技能栏
### 学习内容
- 说明:简历一到两页,招聘方一般先扫 10 秒:**目标岗位、核心技能、最近的经历、项目亮点**。所以顺序是:
```markdown
# 王士奎 · AI 应用 / Agent 开发工程师
电话 · 邮箱 · GitHub · 作品集链接
## 个人优势(3 行以内)
- 7 年 Web 开发经验,参与公司多 Agent 产品的前端和网关联调,熟悉多 Agent 编排、流式输出、长任务的工程问题
- 独立完成 3 个可演示项目:LangGraph 多 Agent(崩溃续跑、人机协作)、RAG 知识库问答(混合检索、评测)、Go 全栈任务看板
- 熟练使用 AI 编程工具(Cursor 等),负责设计、审查和调试
## 专业技能
- Python:FastAPI、asyncio、pydantic、pytest
- AI 应用:LangChain、LangGraph、RAG(Chroma、BM25 混合检索、重排)、MCP、Function Calling、Agent 评测
- 前端:Vue 3、React、TypeScript、Vite、Pinia、pnpm monorepo、Vitest
- 后端:Go(Gin)、PostgreSQL / MySQL、Redis、RabbitMQ、gRPC 基础
- 工程:Docker、Compose、Nginx、Linux、CI/CD、Git
## 工作经历
(第 4 天写)
## 项目经历
(第 3 天写,项目三放第一)
## 教育经历
本科 · 学校名 · 专业 · 起止年月
```
- 说明:**学历**:写“本科”,加上学校、专业、起止时间即可。标注了“统招”的岗位也可以投递试试,结果以对方的反馈为准;准备的重点放在项目和经验上——这次调研里绝大多数岗位写的是“本科”,没看到要求 985 / 211 的。
- 说明:**技能栏**按岗位关键词排序(总纲第 4 节的表),每一项都要有项目里的对应代码;“了解”的放后面或不写,免得被追问答不上。
### 易错点 / 面试常问
- 易错点:技能栏写“精通”要谨慎,面试官会按“精通”的标准追问;一般用“熟练 / 熟悉 / 了解”区分。
- 易错点:不要把所有学过的技术都堆上去,和目标岗位无关的(比如很老的框架)删掉,突出主线。
- 易错点:简历导出 PDF 发送,文件名写“姓名-岗位-电话.pdf”;Markdown 原文留在仓库,方便修改。
### 要建的文件
- `career/resume.md`:头部、个人优势、技能栏、教育经历
### 运行方式
- 用编辑器的 Markdown 预览或 `pandoc resume.md -o resume.pdf` 导出(排版不满意就粘到简历模板里)。
### 完成标准
- 头部、个人优势、技能栏、教育经历写完;技能栏每项都能说出对应的项目文件。
---
## 第 3 天:项目经历怎么写
### 学习内容
- 说明:每个项目 4~6 行,格式“**背景 / 目标 → 我的设计(带关键词)→ 结果(可验证)**”。关键词就是岗位要求里的词,招聘方和筛选系统都会搜这些词。结果写可验证的东西:评测数字(来自报告)、演示链接、测试覆盖的场景。
- 案例(方括号里填你自己的真实数据,没有就删掉那半句):
```markdown
### agent-mini · 可崩溃恢复的多 Agent 编排系统(个人项目) GitHub 链接
技术栈:Python、LangGraph、FastAPI、SQLite、MCP、SSE、Docker
- 用 LangGraph 实现“协调者—提议者—审查者”多 Agent 编排,共识条件 + 最大轮数兜底,路由规则可单元测试
- 编排状态用检查点持久化,任务表 + 租约保证同一任务只被一个进程执行;演示中 kill -9 后重启可自动续跑
- 用 interrupt 实现“需要澄清时暂停等用户回答”,支持换模型后从检查点恢复
- 用 MCP 实现知识库工具服务,可同时被 Cursor 和自己的 Agent 调用
- 建立 [N] 条用例的评测集(规则检查 + 模型评分),调整提示词后通过率从 [x] 提升到 [y](见仓库 evals/reports)
### rag-qa · 带引用的知识库问答(个人项目) GitHub 链接
技术栈:Python、FastAPI、Chroma、BM25、Redis、LangChain、React
- 手写加载—切分—向量化—检索—带引用生成全流程,检索不到时直接拒答,减少编造
- 实现 BM25 + 向量混合检索(RRF 融合)和重排,用 [N] 题评测集对比:Hit@K [x] → [y]
- Redis 实现会话记忆、嵌入缓存和答案缓存(按知识库版本失效);SSE 流式输出
### task-board · 团队任务看板(个人项目) GitHub 链接
技术栈:Go、Gin、PostgreSQL、Redis、Redis Streams、Vue 3、Docker、Nginx
- 多人看板 + 成员角色权限,拖动排序用事务保证位置一致
- Redis 实现缓存、限流、分布式锁;Redis Streams 异步处理通知,消费者幂等
- Docker Compose + Nginx 部署,CI 自动测试
```
### 易错点 / 面试常问
- 易错点:写“负责 / 参与了 XX”看不出能力,改成“用 A 实现了 B,解决了 C”。
- 易错点:评测数字一定要能在仓库里找到出处(报告文件、提交记录),面试官可能会问“怎么测的、测了多少题”。
- 面试常问:每一行都可能被追问“为什么这样做、有没有别的方案、遇到什么问题”——写完后对每一行准备一句追问的回答,记到下周的面试笔记里。
### 要建的文件
- `career/resume.md` 的“项目经历”部分
### 完成标准
- 3 个项目都写完;方括号里的数字全部替换成真实数据或删除。
---
## 第 4 天:工作经历——3 条 STAR
### 学习内容
- 说明:STAR = **情境(Situation)、任务(Task)、行动(Action)、结果(Result)**。面试官问“讲一个你解决过的难题”时,就按这个顺序讲。素材来自第 9 周的案例卡(已脱敏),三条分别对应:编排可靠性、状态权威源、业务落地。
- 说明:工作经历里写你**实际做过的部分**。如果某个问题你是发现者和分析者、方案是团队一起定的,就如实写“定位根因并提出方案”;你在学习项目里做了“若重做的设计”,可以写成“在个人项目中验证了改进方案”。
- 案例(结构示例,内容按你自己的案例卡改写):
```markdown
### 情境
公司的多 Agent 研讨功能由前端驱动多轮调用,用户关掉页面后研讨就中断,进度无法恢复。
### 任务
定位中断原因,提出让长任务不依赖浏览器的方案。
### 行动
- 梳理前端和 Agent 网关之间的调用链,画出时序图,确认循环跑在浏览器里、服务端只做单轮调用
- 对比“前端循环”和“后台作业”两种模式,整理出状态权威源、断线续传、防重复执行三个关键问题
- 在个人项目 agent-mini 中实现了后台作业 + 检查点续跑 + 事件回放,验证方案可行
### 结果
形成了可落地的改进方案和演示;[如果方案在公司被采纳或部分实施,写实际结果;没有就不写]
```
### 易错点 / 面试常问
- 易错点:STAR 讲成流水账。行动部分只讲 2~3 个关键动作,每个动作说清“为什么这么做”。
- 易错点:脱敏——不说公司内部的系统名、接口名、表名,用通用说法(“Agent 网关”“任务表”)。
- 面试常问:“你在这个项目里具体做了什么?”“如果重来一次你会怎么做?”“最大的困难是什么?”——三条 STAR 都要能回答这三个追问。
### 要建的文件
- `career/star.md`(3 条,每条口述不超过 2 分钟);简历的“工作经历”部分写每条的一句话版本
### 完成标准
- 3 条 STAR 写完并各口述计时一次;全部通过脱敏检查。
---
## 第 5 天:针对岗位调整简历(关键词对照)
### 学习内容
- 说明:不同岗位侧重点不同(偏 RAG、偏 Agent、偏全栈)。投递前把岗位描述(JD)保存成文本,和简历对照关键词:岗位要求里有、你也会、但简历里没写的,补上;你不会的,不要硬写,记下来作为面试准备。
- 案例:写一个小脚本做关键词对照。
```python
# 文件:career/keyword_check.py
"""用法:python keyword_check.py jd/某公司.txt resume.md"""
import re
import sys
from pathlib import Path
# 关键词表:同一组里任一写法出现就算命中(按需要自己补充)
KEYWORDS: dict[str, list[str]] = {
"Python": ["python"],
"FastAPI": ["fastapi"],
"Flask": ["flask"],
"LangChain": ["langchain"],
"LangGraph": ["langgraph"],
"LlamaIndex": ["llamaindex", "llama-index"],
"RAG": ["rag", "检索增强"],
"向量数据库": ["向量数据库", "向量库", "milvus", "chroma", "pgvector", "faiss"],
"MCP": ["mcp"],
"Function Calling": ["function calling", "tool calling", "工具调用", "函数调用"],
"Agent": ["agent", "智能体"],
"多 Agent": ["多 agent", "多agent", "multi-agent", "多智能体"],
"评测": ["评测", "评估", "evaluation", "eval"],
"Prompt": ["prompt", "提示词"],
"Dify / Coze": ["dify", "coze", "扣子", "ragflow", "fastgpt"],
"Vue": ["vue"],
"React": ["react"],
"TypeScript": ["typescript", "ts"],
"Go": ["go", "golang"],
"MySQL": ["mysql"],
"PostgreSQL": ["postgresql", "postgres", "pgsql"],
"Redis": ["redis"],
"消息队列": ["消息队列", "rabbitmq", "kafka", "mq"],
"微服务": ["微服务", "grpc"],
"Docker": ["docker", "容器"],
"Linux": ["linux"],
"CI/CD": ["ci/cd", "cicd", "持续集成"],
}
def _has(text: str, word: str) -> bool:
# 纯英文短词按“单词边界”匹配,避免 ts 命中 tests、go 命中 google
if re.fullmatch(r"[a-z0-9/.\- ]+", word):
return re.search(rf"(?<![a-z0-9]){re.escape(word)}(?![a-z0-9])", text) is not None
return word in text
def found(text: str) -> set[str]:
t = text.lower()
return {name for name, words in KEYWORDS.items() if any(_has(t, w) for w in words)}
def compare(jd: str, resume: str) -> dict[str, list[str]]:
need, have = found(jd), found(resume)
return {"covered": sorted(need & have), "missing": sorted(need - have), "extra": sorted(have - need)}
def main() -> None:
jd_path, resume_path = sys.argv[1], sys.argv[2]
r = compare(Path(jd_path).read_text(encoding="utf-8"), Path(resume_path).read_text(encoding="utf-8"))
print("岗位要求、简历已覆盖:", "、".join(r["covered"]) or "无")
print("岗位要求、简历未提到:", "、".join(r["missing"]) or "无")
print("(未提到的:会就补进简历;不会就记进面试准备,不要硬写)")
if __name__ == "__main__" and len(sys.argv) == 3:
main()
```
```python
# 文件:career/tests/test_keyword_check.py
from keyword_check import compare, found
def test_found_uses_word_boundaries():
assert found("熟悉 Go 和 TypeScript") >= {"Go", "TypeScript"}
assert "Go" not in found("google tests") # 不误判
assert "TypeScript" not in found("pytest tests")
def test_compare():
jd = "要求:熟悉 Python、FastAPI,有 RAG 和向量数据库(Milvus)经验,了解 Kafka。"
resume = "Python / FastAPI;RAG(Chroma、BM25 混合检索);Redis"
r = compare(jd, resume)
assert r["covered"] == ["FastAPI", "Python", "RAG", "向量数据库"]
assert r["missing"] == ["消息队列"]
assert r["extra"] == ["Redis"]
```
### 易错点 / 面试常问
- 易错点:关键词对上了只是第一步,简历里每个关键词都要有对应的项目描述,不能只出现在技能栏。
- 易错点:不同岗位只调整“个人优势”和项目里的侧重点,不要为了迎合岗位编造经历。
### 要建的文件
- `career/jd/`(至少收集 10 个目标岗位的描述)、`keyword_check.py`、`tests/test_keyword_check.py`;`career/resume-variants/`:偏 Agent、偏 RAG、偏全栈三个版本
### 运行方式
```bash
cd career && uv run --with pytest python -m pytest -q tests # 用 python -m 才能导入同目录的 keyword_check
python keyword_check.py jd/某公司.txt resume.md
```
### 完成标准
- 10 个岗位都对照过一次;把多次出现、你又没准备的关键词列进下周的面试笔记。
---
## 第 6 天:口述稿和演示视频
### 学习内容
- 说明:面试里最常见的开场是“自我介绍 + 介绍一个项目”。准备两段:
- **自我介绍(1 分钟)**:方向 + 经验 + 3 个项目一句话 + 为什么想做 AI 应用。
- **项目讲解(3 分钟架构 + 1 分钟踩坑)**:总长不超过 4 分钟,覆盖重点 A(编排)、B(状态权威)、C(流式与长任务)中至少两项。
- 案例:项目讲解的骨架(`career/pitch.md`)
```markdown
1. 问题(30 秒):多 Agent 任务时间长、步骤多,跑在浏览器里关页面就断,状态也说不清在哪。
2. 架构(90 秒):对着架构图讲——API 创建任务 → 后台执行器领取租约 → LangGraph 按图执行,
每步写检查点和事件 → 前端用 SSE 订阅事件,断线按 Last-Event-ID 补齐 → 结束写结论和报告。
两层状态:检查点是编排草稿,任务表是对外权威。
3. 演示(60 秒):创建任务 → 看流式输出 → 杀进程 → 重启后自动续跑 → 查看报告。
4. 踩坑(60 秒):崩溃时正在执行的节点会重跑一次,所以有副作用的操作要幂等;
一开始用“先查再改”领取任务,并发时两个进程都抢到了,改成带条件的 UPDATE 看影响行数。
```
- 说明:录一个 2~3 分钟的演示视频放进作品集(有些面试是线上的,可以直接共享屏幕或发链接)。
### 易错点 / 面试常问
- 易错点:讲解时一直念代码细节,面试官会失去耐心;先讲整体,细节等对方追问。
- 易错点:没有计时练习,实际讲起来会超时。至少完整讲 3 遍并录音回听。
- 面试常问:“这个项目和直接用 Dify / Coze 搭有什么区别?”(第 13 周的对比笔记)“如果用户量变大,哪里先出问题?”(SQLite 换 PostgreSQL、执行器多实例、事件推送改用消息队列)
### 要建的文件
- `career/pitch.md`(自我介绍 + 项目讲解),演示视频链接写进 `career/portfolio.md`
### 完成标准
- 自我介绍 ≤ 1 分钟、项目讲解 ≤ 4 分钟,各计时 3 遍;视频已录好。
---
## 第 7 天:自查、请人审阅、阶段 4(上)验收
### 学习内容
- 说明:简历写完后最好请一位做后端或 AI 应用的朋友看一遍,问他三个问题:10 秒内看出我想找什么岗位吗?哪一条最想追问?哪一条看起来不可信?按反馈修改。
- 自查清单:
```text
[ ] 目标岗位和简历标题一致
[ ] 技能栏每一项都有项目对应
[ ] 项目经历每一行都是“做了什么 + 怎么做 + 结果”,数字都能找到出处
[ ] 工作经历已脱敏,没有公司内部名称、接口、数据
[ ] 仓库、演示链接都能打开(用手机和别人的电脑试一次)
[ ] 简历 PDF 两页以内,没有错别字,联系方式正确
```
### 要建的文件
- 根据反馈修改 `resume.md`、`star.md`、`pitch.md`
### 完成标准(阶段 4 上半验收)
- [ ] 3 个公开仓库 + 演示视频
- [ ] 简历(主版本 + 3 个岗位方向版本)
- [ ] 3 条 STAR、口述稿计时达标
- [ ] 至少 1 位他人审阅过并完成修改
- [ ] 在总纲进度表勾掉第 15 周
+294
View File
@@ -0,0 +1,294 @@
# 第 16 周:阶段 4(下)——面试准备、终点验收、投递
> 整个计划的最后一周。上周 `week15-package-resume.md`。总纲见 `README.md` 第 14 节“终点验收清单”。
**本周目标**:整理一份自己的面试笔记(每题“一句话答案 + 我的项目里怎么用的”),做两次模拟面试,完成终点验收,开始投递并建立“面试 → 复盘 → 补笔记”的循环。
**原则**:面试笔记是**自己的话**,不是复制的八股文。每道题都要能落到自己的项目代码上,这是和只背题的候选人最大的区别。
## 本周最终目录
```text
career/
├── interview-notes.md # 面试笔记(按主题,每题一个 ### 标题)
├── drill.py # 随机抽题练习
├── tests/test_drill.py
├── mock-interviews.md # 模拟面试记录
└── applications.md # 投递记录
```
---
## 第 1 天:面试笔记(上)——Python 和 FastAPI
### 学习内容
- 说明:笔记的格式固定,方便抽题练习:
```markdown
## Python
### asyncio 的事件循环是什么?什么时候用异步?
- 一句话:单线程里调度多个协程,遇到 I/O 等待就切换到别的协程;适合大量网络 I/O(调模型、查数据库),不适合 CPU 密集计算。
- 我的项目:rag-qa 用 httpx.AsyncClient 并发调嵌入接口;agent-mini 的后台执行器和 SSE 推送都跑在同一个事件循环里。
- 追问:在 async 函数里调用同步阻塞库会怎样?(卡住整个事件循环;换异步库或放进线程池)
```
- 本主题至少准备这些题(答案要点见括号,写笔记时换成自己的话并补上项目例子):
- GIL 是什么,对多线程有什么影响(同一时刻只有一个线程执行 Python 字节码;I/O 密集用线程 / 协程,CPU 密集用多进程)。
- 列表、元组、字典、集合的区别和时间复杂度(字典和集合查找平均 O(1))。
- 装饰器和上下文管理器分别用来做什么(包装函数加功能 / 保证资源释放)。
- 类型标注和 pydantic 的作用(静态检查 / 运行时校验外部输入)。
- 生成器和 `yield`(惰性产生数据;SSE 流式输出就是异步生成器)。
- FastAPI 的依赖注入、中间件、lifespan、后台任务分别用在哪(第 4 周第 7 天)。
- 同步 `def` 和 `async def` 接口在 FastAPI 里的区别(前者放线程池执行)。
- pytest 的 fixture、参数化、怎么测试异步代码和外部依赖(假实现 / MockTransport)。
### 要建的文件
- `career/interview-notes.md` 的“Python / FastAPI”部分(≥ 8 题)
### 完成标准
- 每题都有“一句话 + 我的项目 + 追问”三部分。
---
## 第 2 天:面试笔记(中)——RAG 和 Agent
### 学习内容
- 本主题是面试重点,至少准备:
- **RAG 全流程**(加载 → 切分 → 嵌入 → 入库 → 检索 → 重排 → 拼上下文 → 生成 → 引用)和每一步的关键参数。
- 切分怎么做、块大小怎么选(按标题 / 段落切 + 长度上限 + 重叠;用评测集调)。
- 为什么要混合检索(向量擅长语义相近,BM25 擅长专有名词和精确匹配;RRF 按排名融合,不用对齐分数)。
- 重排是什么(用交叉编码模型对“问题—片段”逐对打分,比向量相似度准但慢,所以只对候选做)。
- 怎么减少幻觉(阈值 + 拒答、提示词约束、引用、评测);怎么评测 RAG(Hit@K、MRR、拒答准确率、回答质量评分)。
- **Function Calling 流程**;**MCP** 是什么、三类能力、两种传输方式;MCP 和 Function Calling 的关系(MCP 标准化“工具从哪来”,Function Calling 是“模型怎么调用”)。
- **LangGraph**:State、节点、边、条件边、reducer;检查点和 `thread_id`;interrupt 和 `Command(resume=...)`;短期记忆和长期记忆(Store)。
- 多 Agent 怎么设计(角色分工、谁决定下一步、结束条件、最大轮数、成本控制)。
- Agent 常见失败和处理(死循环 → 最大轮数;工具参数错 → 校验并把错误返回给模型;模型输出格式错 → 解析兜底;外部服务失败 → 重试 + 降级;需要人决定 → interrupt)。
- Agent 怎么评测、怎么观测(第 13 周)。
- 上下文工程(Context Engineering):上下文放什么、放多少、放哪里(系统提示词、检索结果、历史、工具结果的取舍和截断)。
### 要建的文件
- `interview-notes.md` 的“RAG”“Agent”两部分(合计 ≥ 15 题)
### 完成标准
- 每题都能指到自己项目里的具体文件,例如“`retrieval/hybrid.py` 的 `rrf` 函数”。
---
## 第 3 天:面试笔记(下)——后端、前端、Go
### 学习内容
- 后端(第 2、3、14 周的笔记汇总,挑高频题):
- MySQL:索引原理、最左前缀、索引失效、EXPLAIN、隔离级别和 MVCC、死锁;和 PostgreSQL 的差异。
- Redis:数据结构和用途、缓存三大问题、缓存一致性、分布式锁、持久化。
- 消息队列:不丢、不重(幂等)、顺序、积压;RabbitMQ 和 Kafka 怎么选。
- Docker:镜像和容器、多阶段构建、Compose 网络;部署和回滚流程。
- 接口设计:RESTful、状态码、幂等、鉴权(JWT 和 Session)、限流。
- 前端(你的强项,面试 AI 全栈岗位时一定会问):
- Vue 3 响应式原理(Proxy)、`computed` 和 `watch`、Pinia;React Hooks 规则、`useEffect` 依赖、渲染优化。
- Vite 和 Webpack 的区别;性能优化手段;SSR / SSG / CSR。
- 流式输出在前端怎么做(fetch + ReadableStream 解析 SSE,第 7 周的 `parseSSE`)。
- Go(加分项):goroutine 和 channel、`context` 的作用、接口、错误处理、`defer`、切片扩容。
### 要建的文件
- `interview-notes.md` 的“后端”“前端”“Go”部分(合计 ≥ 20 题)
### 完成标准
- 总题数 ≥ 43;每个主题至少一题能结合项目讲 2 分钟。
---
## 第 4 天:项目深挖和系统设计
### 学习内容
- 说明:中高级岗位一定会对项目“往下挖”,一般连问 3~5 层。准备方法:从简历的每一行出发,自己写一条“追问链”。
- 案例:追问链
```text
问:你的 Agent 任务怎么做到崩溃后续跑?
答:每个节点执行完,LangGraph 把状态写进检查点;任务表记录状态和租约,看门狗发现租约过期就重新领取,从最近的检查点继续。
追问 1:崩溃时正在执行的节点怎么办? → 会重新执行一次(至少一次语义),所以节点里的副作用要幂等。
追问 2:怎么保证同一个任务不会被两个进程同时跑? → 领取用带条件的 UPDATE(没人持有或租约过期),看影响行数;执行中定期续租,续租失败就停止。
追问 3:如果要支持一万个并发任务呢? → SQLite 换 PostgreSQL;执行器多实例水平扩展;任务分发改用消息队列;事件推送用 Redis Pub/Sub 或 Streams 让任意实例都能推。
追问 4:模型调用很贵,怎么控制成本? → 最大轮数、缓存、小模型做路由和总结、监控每个任务的 token。
```
- 系统设计题(准备 2 道,每道 15 分钟讲完):
1. **设计一个企业知识库问答系统**:需求澄清(文档量、并发、权限)→ 入库流水线(异步、幂等、增量)→ 检索(混合 + 重排 + 权限过滤)→ 生成(流式、引用、拒答)→ 评测和反馈 → 部署和成本。
2. **设计一个多 Agent 任务平台**:任务模型和状态机 → 后台执行和租约 → 检查点和续跑 → 人机协作 → 事件流 → 可观测性和评测 → 扩展(多实例、队列)。
- 说明:系统设计的讲法:**先问清需求和规模,再画整体架构,然后挑 1~2 个关键点深入,最后说取舍和扩展**。不要一上来就讲技术细节。
### 要建的文件
- `interview-notes.md` 增加“项目追问链”(简历每个项目至少 2 条)和“系统设计”(2 道,各附一张架构图)
### 完成标准
- 每条追问链至少 3 层;两道系统设计题各对着图讲一遍并计时。
---
## 第 5 天:模拟面试和抽题练习
### 学习内容
- 说明:找朋友或用 AI 扮演面试官,按真实流程模拟两次:自我介绍 → 项目讲解 → 项目追问 → 技术题 → 系统设计(可选)→ 反问。录音,结束后对照录音记下答得不好的题。
- 说明:平时用抽题脚本练习:随机抽 5 题,每题限时 2 分钟口述。
- 案例:
```python
# 文件:career/drill.py
"""随机抽题:python drill.py interview-notes.md 5"""
import random
import re
import sys
from pathlib import Path
def parse_questions(markdown: str) -> list[tuple[str, str]]:
"""返回 [(主题, 问题)]:## 是主题,### 是问题;忽略代码块里的内容。"""
topic, out, in_code = "", [], False
for line in markdown.splitlines():
if line.startswith("```"):
in_code = not in_code
continue
if in_code:
continue
if m := re.match(r"^##\s+(.+)", line):
if not line.startswith("###"):
topic = m.group(1).strip()
if m := re.match(r"^###\s+(.+)", line):
out.append((topic, m.group(1).strip()))
return out
def pick(questions: list[tuple[str, str]], n: int, seed: int | None = None) -> list[tuple[str, str]]:
return random.Random(seed).sample(questions, min(n, len(questions)))
def main() -> None:
qs = parse_questions(Path(sys.argv[1]).read_text(encoding="utf-8"))
n = int(sys.argv[2]) if len(sys.argv) > 2 else 5
for i, (topic, q) in enumerate(pick(qs, n), 1):
input(f"\n第 {i} 题【{topic}】{q}\n(口述 2 分钟后按回车)")
if __name__ == "__main__" and len(sys.argv) > 1:
main()
```
```python
# 文件:career/tests/test_drill.py
from drill import parse_questions, pick
FENCE = "`" * 3 # 代码块标记(这里不直接写三个反引号,免得和外层 Markdown 冲突)
NOTES = f"""# 面试笔记
## Python
### asyncio 是什么?
- 一句话……
## Agent
### MCP 是什么?
{FENCE}text
### 代码块里的不算
{FENCE}
### 多 Agent 怎么结束?
"""
def test_parse_and_pick():
qs = parse_questions(NOTES)
assert qs == [("Python", "asyncio 是什么?"), ("Agent", "MCP 是什么?"), ("Agent", "多 Agent 怎么结束?")]
assert pick(qs, 2, seed=1) == pick(qs, 2, seed=1) and len(pick(qs, 10)) == 3
```
- 行为面试题也要准备(用 STAR 回答,素材来自上周的 `star.md`):
- 为什么从前端转 AI 应用 / Agent 开发?(AI 应用的难点在工程:编排、状态、长任务、评测;我在公司已经碰到这些问题,并且用项目验证了解决方案。)
- 讲一个你解决过的最难的问题。
- 和后端 / 产品意见不一致时怎么处理?
- 你怎么用 AI 编程工具?(让 AI 生成,自己负责设计、审查、测试;举一个 AI 写错、你发现并修正的例子。)
- 你最近在学什么?(这 16 周的计划本身就是答案。)
- 反问环节准备 2~3 个问题:团队的 AI 应用做到什么阶段、主要的技术挑战、怎么评估 Agent 的效果。
### 易错点 / 面试常问
- 易错点:答题先给结论(一句话),再展开;不会的题直接说“这个我没有实际用过,我理解的是……”,不要硬编。
- 易错点:模拟面试只“过一遍”没有用,一定要记下答得不好的题并当天补进笔记。
### 要建的文件
- `career/drill.py`、`career/tests/test_drill.py`、`career/mock-interviews.md`(两次模拟的问题、表现、改进点)
### 运行方式
```bash
cd career && uv run --with pytest python -m pytest -q tests
python drill.py interview-notes.md 5
```
### 完成标准
- 两次模拟面试完成并记录;抽题练习至少 3 轮。
---
## 第 6 天:终点验收
### 学习内容
- 说明:对照总纲第 1 节“终点”四条和第 14 节“终点验收清单”逐项打勾。**缺哪条补哪条,不开新需求**。补不完的写进下面的“延期项”,给出完成日期,但不要因此推迟投递。
- 案例:验收记录写进总纲进度区:
```markdown
### 终点验收(YYYY-MM-DD)
- 能讲清:☑ 口述稿 3 分 40 秒(录音在 career/)
- 能做小:☑ agent-mini 崩溃续跑演示视频
- 能举证:☑ 案例卡 A、C 可对外讲
- 能对标岗位:☑ 简历 3 个版本,10 个岗位关键词对照完成
- 延期项:Go 网关接入部署(预计 MM-DD)
```
### 要建的文件
- 更新总纲 `README.md` 第 13、14 节
### 完成标准
- 终点四条全部满足;清单中未完成的项都有明确的延期日期。
---
## 第 7 天:开始投递,建立复盘循环
### 学习内容
- 说明:投递策略(总纲第 11 节):主投 AI Agent 开发、大模型应用开发、AI 全栈;兼投全栈。每个岗位用对应版本的简历,投递前跑一次关键词对照。标注了“统招”的岗位也可以投递试试。
- 说明:用一张表记录每次投递和面试,**每次面试后 24 小时内复盘**:被问到的新题补进 `interview-notes.md`,答得不好的题加进抽题练习。
- 案例:`career/applications.md`
```markdown
| 日期 | 公司 / 岗位 | 渠道 | 简历版本 | 状态 | 被问到的新题 / 复盘 |
| --- | --- | --- | --- | --- | --- |
| | | | 偏 Agent | 已投递 / 一面 / 二面 / offer / 未通过 | |
```
- 说明:每过一个月按总纲第 4 节的方法重新搜一次岗位,更新岗位对照表和简历关键词。
### 要建的文件
- `career/applications.md`
### 完成标准(本计划的最后一项)
- [ ] 第一批至少 10 个岗位已投递
- [ ] 面试复盘循环已经开始(至少有一条复盘记录)
- [ ] 总纲开头状态改为“已完成(YYYY-MM-DD)”,并写下“下一步如果继续学”选哪一个(总纲第 12 节),提交并推送