Files
zhuangang/plan/week10-agent-m1-tools-mcp.md

1062 lines
46 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
id: plan.week10
title: "第 10 周:项目三 M1——手写工具调用循环 + 手写多 Agent 循环 + MCP 服务"
type: week
domain: plan
stack: [mcp, function-calling]
week: [10]
est_hours: 12
order: 10
---
# 第 10 周:项目三 M1——手写工具调用循环 + 手写多 Agent 循环 + MCP 服务
> 第三段的第 3 周,**项目三 `agent-mini`(阶段 3 主交付)** 的第 1 周。上周 `week09-case-cards.md`,下周 `week11-agent-m2-langgraph.md`。
## 项目三总览
**做什么**:把你在公司见过的多 Agent 产品“削”成一个**没有业务外壳**、可公开演示的内核,证明你“会造”,而不只是“会改”。
做完后(第 13 周)要能演示:
1. 提一个问题 → 协调者派活给提议者、评审者 → 专家可以调用工具(包括通过 **MCP** 调用你的 RAG 知识库)→ 多轮讨论 → 达成共识或到达最大轮数后结束 → 生成可审计的报告。
2. 中途 `kill -9` 杀掉服务进程 → 重启 → 任务从最近的检查点接着跑完。
3. 浏览器通过 SSE 看进度;关掉页面任务照跑;重新打开能补齐漏掉的事件。
4. 信息不够时停下来问人(`interrupt`),人回答后继续;模型失败后换模型从失败点重试。
5. 记得用户的偏好(长期记忆),下次提问时用上。
6. 有评测集,能用数字说明改动的效果;有可观测性(每次运行的耗时、调用了什么)。
**节奏**
| 周 | 里程碑 | 内容 |
| --- | --- | --- |
| 10 | M1 | **手写**工具调用循环、手写多 Agent 循环(理解原理)、写 MCP 服务 |
| 11 | M2 | 用 **LangGraph** 重写编排 |
| 12 | M3 | 检查点续跑、`interrupt`、业务表 + 租约、后台作业、SSE、长期记忆 |
| 13 | M4 | 评测集、可观测性(LangSmith)、文档和映射表、Dify / Coze 体验 |
**范围**
| 必须有 | 禁止有(本阶段) |
| --- | --- |
| 协调者 + 提议者 + 评审者;结束条件写在代码里 | 照搬公司 UI、提示词或代码 |
| 工具调用,至少一个工具来自自己写的 MCP 服务 | 依赖公司内网才能演示 |
| 检查点持久化 + 重启续跑;编排在服务端后台 | LangGraph 之外再引入别的 Agent 框架 |
| SSE 断线补发;人机协作;长期记忆;评测集 | |
| **不需要真实模型密钥也能跑通测试和演示**(假模型) | |
讨论题目用通用例子(“5 人创业团队做内部工具该选什么技术栈”“给读书 App 设计会员规则”),不要用公司业务题目。
**技术选型**:Python 3.12 + uv、httpx、pydantic;MCP 官方 Python SDK(2.x);LangGraph + langchain-core;FastAPI + SSE;SQLite(检查点、业务表,生产换 PostgreSQL);pytest。
> **版本提醒**:LangGraph、LangChain、MCP SDK 更新很快。本计划的代码在 2026-10 用 `mcp 2.3`、`langgraph 1.2`、`langchain-core 1.7` 实际跑通。**动手前先看官方文档当前版本**,导入路径或参数有出入时按文档改:
> - MCP Python SDK:https://py.sdk.modelcontextprotocol.io/ (v1 → v2 迁移说明:https://py.sdk.modelcontextprotocol.io/v2/migration/ )
> - MCP 协议:https://modelcontextprotocol.io/
> - LangGraph:https://docs.langchain.com/oss/python/langgraph/overview
### 最终项目结构(4 周完成后)
```text
agent-mini/
├── README.md
├── pyproject.toml
├── .env.example # 默认 LLM_PROVIDER=fake
├── Dockerfile / docker-compose.yml # 第 13 周
├── docs/ # 架构、设计取舍、映射表、对照文档、讲解稿
├── mcp_servers/
│ ├── __init__.py
│ └── knowledge_server.py # 第 10 周:MCP 服务(查知识库、建待办、资源、提示词)
├── src/agent_mini/
│ ├── __init__.py
│ ├── handwritten/ # 第 10 周:手写版(保留作对照)
│ │ ├── __init__.py
│ │ ├── llm.py # 最小模型客户端(支持 tools)+ 脚本化假模型
│ │ ├── tools.py # 工具定义、安全计算器、日期工具
│ │ ├── tool_loop.py # 手写工具调用循环
│ │ ├── multi_agent.py # 手写协调者 + 两个专家循环(JSON 文件持久化)
│ │ └── mcp_bridge.py # 把 MCP 工具接进手写循环
│ ├── models.py # 第 11 周:Decision 等结构
│ ├── llm.py # 第 11 周:真实 / 假对话模型
│ ├── graph/ # 第 11~12 周:LangGraph 版
│ ├── store/ # 第 12 周:runs / events 业务表、租约
│ ├── runner.py # 第 12 周:后台运行、看门狗
│ ├── api/ # 第 12 周:FastAPI + SSE
│ └── observability.py # 第 13 周:运行指标
├── evals/ # 第 13 周
├── web/index.html # 第 12 周:极简页面
└── tests/
```
---
## 第 1 天:初始化 + 支持工具调用的最小模型客户端
### 学习内容
- 说明:
- 消息有四种角色:`system`(规则)、`user`(用户)、`assistant`(模型)、`tool`(工具执行结果)。
- **工具调用(Function / Tool Calling)**的本质:你把“有哪些工具、每个工具的参数格式(JSON Schema)”发给模型;模型**不执行任何东西**,只返回“我想调用哪个工具、参数是什么”(`tool_calls`);**你的代码**执行工具,把结果作为 `tool` 消息追加回去,再问模型,直到它给出最终回答。
- 为了测试,写一个**脚本化假模型**:按预先写好的顺序返回回答或工具调用。
- 案例:
```bash
uv init --package agent-mini && cd agent-mini
uv add httpx pydantic pydantic-settings "mcp>=2"
uv add --dev pytest pytest-asyncio ruff pyright
```
```python
# 文件:agent-mini/src/agent_mini/__init__.py
```
```python
# 文件:agent-mini/src/agent_mini/handwritten/__init__.py
```
```python
# 文件:agent-mini/src/agent_mini/handwritten/llm.py
import json
from dataclasses import dataclass, field
from typing import Protocol
import httpx
@dataclass
class ToolCall:
id: str
name: str
arguments: str # 模型给的是 JSON 字符串,可能不合法,要自己校验
@dataclass
class AssistantMessage:
content: str | None = None
tool_calls: list[ToolCall] = field(default_factory=list)
def to_dict(self) -> dict:
"""转回 OpenAI 格式,追加到消息列表里。"""
d: dict = {"role": "assistant", "content": self.content}
if self.tool_calls:
d["tool_calls"] = [
{"id": c.id, "type": "function", "function": {"name": c.name, "arguments": c.arguments}}
for c in self.tool_calls
]
return d
class ChatLLM(Protocol):
async def chat(self, messages: list[dict], tools: list[dict] | None = None) -> AssistantMessage: ...
class OpenAICompatLLM:
def __init__(self, client: httpx.AsyncClient, model: str):
self.client, self.model = client, model
async def chat(self, messages: list[dict], tools: list[dict] | None = None) -> AssistantMessage:
body: dict = {"model": self.model, "messages": messages, "temperature": 0.2}
if tools:
body["tools"] = tools
resp = await self.client.post("/chat/completions", json=body)
resp.raise_for_status()
msg = resp.json()["choices"][0]["message"]
calls = [ToolCall(c["id"], c["function"]["name"], c["function"]["arguments"]) for c in msg.get("tool_calls") or []]
return AssistantMessage(msg.get("content"), calls)
class ScriptedLLM:
"""按顺序返回预设的回答;记录每次收到的消息,方便断言。"""
def __init__(self, script: list[AssistantMessage]):
self.script = list(script)
self.calls: list[list[dict]] = []
async def chat(self, messages: list[dict], tools: list[dict] | None = None) -> AssistantMessage:
self.calls.append(json.loads(json.dumps(messages))) # 深拷贝,防止后续修改影响断言
if not self.script:
return AssistantMessage(content="(脚本已用完)")
return self.script.pop(0)
def call(name: str, args: dict | str, id: str = "c1") -> AssistantMessage:
"""测试辅助:构造一条“想调用工具”的模型回复。"""
return AssistantMessage(tool_calls=[ToolCall(id, name, args if isinstance(args, str) else json.dumps(args))])
```
**易错点 / 面试常问(补充)**
- 易错点:OpenAI 兼容接口里,模型要调工具时返回的 `tool_calls[].function.arguments` 是 **JSON 字符串**,不是对象,要自己 `json.loads`,并处理解析失败。
- 易错点:把工具结果发回模型时,消息角色是 `tool`,并且要带上对应的 `tool_call_id`;漏了会报错或让模型对不上号。
- 面试常问:Function Calling 的完整流程(带工具定义请求 → 模型返回要调用的工具和参数 → 程序执行 → 把结果发回 → 模型生成最终回答);模型自己会执行工具吗(不会,执行的永远是你的程序)。
### 要建的文件
- `pyproject.toml`(加 `[tool.pytest.ini_options] asyncio_mode = "auto"`)、`.env.example`、`.gitignore`
- `src/agent_mini/__init__.py`、`handwritten/__init__.py`、`handwritten/llm.py`
### 运行方式
```bash
uv run python -c "import agent_mini.handwritten.llm as m; print(m.call('now', {}).to_dict())"
```
### 完成标准
- 能画出工具调用的消息往返:`user → assistant(tool_calls) → tool → assistant(最终回答)`。
---
## 第 2 天:手写工具调用循环
### 学习内容
- 说明:
- 每个工具 = 名字 + 说明 + 参数模型 + 执行函数。**说明写得好不好直接决定模型会不会用对**——写清楚“什么时候用、参数什么意思”。
- 可靠性要点:
1. 参数用 pydantic 校验;不合法时**把错误返回给模型**让它自己改,而不是整个流程崩溃。
2. 工具执行出错同理,返回错误信息。
3. **限制最大步数**,防止模型来回调用停不下来。
4. 计算器**绝不能用 `eval`**(会执行任意代码),用 `ast` 只允许数字和四则运算。
5. 有副作用的工具(建待办、发邮件)要幂等、可审计(第 4 天)。
- 案例:
```python
# 文件:agent-mini/src/agent_mini/handwritten/tools.py
import ast
import inspect
import operator
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import date
from typing import Any
from pydantic import BaseModel, Field
@dataclass
class Tool:
name: str
description: str
parameters: dict # JSON Schema
func: Callable[[dict], Any | Awaitable[Any]]
args_model: type[BaseModel] | None = None # 本地工具用 pydantic 校验;MCP 工具由服务端校验
def schema(self) -> dict:
return {"type": "function", "function": {"name": self.name, "description": self.description,
"parameters": self.parameters}}
async def run(self, args: dict) -> str:
if self.args_model is not None:
args = self.args_model.model_validate(args).model_dump() # 不合法会抛 ValidationError
result = self.func(args)
if inspect.isawaitable(result):
result = await result
return str(result)
def local_tool(name: str, description: str, args_model: type[BaseModel], func: Callable[[dict], Any]) -> Tool:
return Tool(name, description, args_model.model_json_schema(), func, args_model)
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.USub: operator.neg}
def safe_eval(expr: str) -> float:
def ev(node: ast.AST) -> float:
if isinstance(node, ast.Expression):
return ev(node.body)
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](ev(node.left), ev(node.right))
if isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](ev(node.operand))
raise ValueError("只允许数字和 + - * /")
if len(expr) > 100:
raise ValueError("表达式太长")
return ev(ast.parse(expr, mode="eval"))
class CalcArgs(BaseModel):
expression: str = Field(description="只含数字和 + - * / 括号的算式,例如 (3+4)*2")
class DaysArgs(BaseModel):
start: date = Field(description="开始日期,YYYY-MM-DD")
end: date = Field(description="结束日期,YYYY-MM-DD")
class NoArgs(BaseModel):
pass
def default_tools(today: Callable[[], date] = date.today) -> list[Tool]:
return [
local_tool("calculator", "计算四则运算。需要做数学计算时使用,不要自己心算。", CalcArgs,
lambda a: safe_eval(a["expression"])),
local_tool("today", "返回今天的日期(YYYY-MM-DD)。问题涉及“今天”“现在”时先调用它。", NoArgs,
lambda a: today().isoformat()),
local_tool("days_between", "计算两个日期之间相差的天数。", DaysArgs,
lambda a: (a["end"] - a["start"]).days),
]
```
```python
# 文件:agent-mini/src/agent_mini/handwritten/tool_loop.py
import json
import time
from dataclasses import dataclass, field
from pydantic import ValidationError
from agent_mini.handwritten.llm import ChatLLM
from agent_mini.handwritten.tools import Tool
@dataclass
class StepLog:
tool: str
arguments: str
result: str
ok: bool
ms: int
@dataclass
class LoopResult:
answer: str
steps: list[StepLog] = field(default_factory=list)
stop_reason: str = "answered" # answered / max_steps
async def run_tool_loop(llm: ChatLLM, question: str, tools: list[Tool], max_steps: int = 6,
system: str = "你是一个会使用工具的助手。需要时调用工具,得到结果后用中文简洁回答。") -> LoopResult:
by_name = {t.name: t for t in tools}
messages: list[dict] = [{"role": "system", "content": system}, {"role": "user", "content": question}]
result = LoopResult(answer="")
for _ in range(max_steps):
msg = await llm.chat(messages, [t.schema() for t in tools])
messages.append(msg.to_dict())
if not msg.tool_calls:
result.answer = msg.content or ""
return result
for c in msg.tool_calls: # 模型可能一次要求调用多个工具
start = time.perf_counter()
ok = True
try:
tool = by_name.get(c.name)
if tool is None:
raise LookupError(f"没有名为 {c.name} 的工具,可用:{', '.join(by_name)}")
output = await tool.run(json.loads(c.arguments or "{}"))
except (json.JSONDecodeError, ValidationError) as e:
ok, output = False, f"参数错误:{e}。请修正参数后重试。"
except Exception as e: # 工具内部错误也返回给模型,而不是让整个循环崩溃
ok, output = False, f"工具执行失败:{e}"
result.steps.append(StepLog(c.name, c.arguments, output, ok, int((time.perf_counter() - start) * 1000)))
messages.append({"role": "tool", "tool_call_id": c.id, "content": output})
result.answer, result.stop_reason = "超过最大步数,已停止。", "max_steps"
return result
```
```python
# 文件:agent-mini/tests/test_tool_loop.py
from datetime import date
import pytest
from agent_mini.handwritten.llm import AssistantMessage, ScriptedLLM, call
from agent_mini.handwritten.tool_loop import run_tool_loop
from agent_mini.handwritten.tools import default_tools, safe_eval
TOOLS = default_tools(today=lambda: date(2026, 10, 23))
async def test_calls_tools_then_answers():
llm = ScriptedLLM([
call("today", {}, "c1"),
call("days_between", {"start": "2026-10-23", "end": "2027-01-01"}, "c2"),
call("calculator", {"expression": "70*3"}, "c3"),
AssistantMessage(content="还有 70 天,乘以 3 是 210。"),
])
r = await run_tool_loop(llm, "距离 2027 年元旦还有多少天,再乘以 3", TOOLS)
assert [s.tool for s in r.steps] == ["today", "days_between", "calculator"]
assert [s.result for s in r.steps] == ["2026-10-23", "70", "210"]
assert r.answer.endswith("210。")
last_messages = llm.calls[-1]
assert last_messages[-1] == {"role": "tool", "tool_call_id": "c3", "content": "210"}
async def test_bad_arguments_are_returned_to_model():
llm = ScriptedLLM([call("days_between", {"start": "明天"}), AssistantMessage(content="抱歉,请给出具体日期")])
r = await run_tool_loop(llm, "x", TOOLS)
assert r.steps[0].ok is False and "参数错误" in r.steps[0].result
assert "参数错误" in llm.calls[1][-1]["content"] # 模型下一轮能看到错误
async def test_unknown_tool_and_max_steps():
llm = ScriptedLLM([call("rm_rf", {})] * 10)
r = await run_tool_loop(llm, "x", TOOLS, max_steps=3)
assert r.stop_reason == "max_steps" and len(r.steps) == 3
assert "没有名为 rm_rf 的工具" in r.steps[0].result
@pytest.mark.parametrize("expr", ["__import__('os').system('ls')", "2**999999", "a+1"])
def test_safe_eval_rejects_code(expr):
with pytest.raises(ValueError):
safe_eval(expr)
```
### 要建的文件
- `handwritten/tools.py`、`handwritten/tool_loop.py`、`tests/test_tool_loop.py`
### 运行方式
```bash
uv run pytest tests/test_tool_loop.py -v
```
### 完成标准
- 测试通过;有密钥的话,用真实模型问“距离 2027 年元旦还有多少天,再乘以 3”,日志里能看到先调 `today`、再调 `days_between`、再调 `calculator`。
- 面试常问:工具调用的完整流程?模型参数给错了怎么办?怎么防止死循环?为什么不能用 `eval`?
---
## 第 3 天:手写多 Agent 循环
### 学习内容
- 说明:
- 多 Agent 的本质:几个“角色 = 系统提示词 + 能看到的上下文 + 可用工具”,加上一个“下一步谁来做、什么时候结束”的调度逻辑。
- **协调者**输出结构化决定(JSON,用 pydantic 校验,格式错误时把错误告诉它重试一次)。
- **评审者**最后一行必须是 `VERDICT: APPROVE` 或 `VERDICT: REVISE`,用代码解析。
- **硬规则优先于模型**:评审通过 → 结束;达到最大轮数 → 结束。不能只靠模型自觉。
- 每一步把状态写进 JSON 文件(原子写入),进程中途退出后可以从文件继续——先体会“持久化 + 续跑”要解决什么问题,下两周再用检查点和数据库正式解决。
- 案例:
```python
# 文件:agent-mini/src/agent_mini/handwritten/multi_agent.py
import json
import os
import re
import tempfile
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, Field, ValidationError
from agent_mini.handwritten.llm import ChatLLM
ROLE_PROMPTS = {
"coordinator": """你是协调者,负责决定下一步。只输出 JSON:
{"action": "dispatch" | "ask_human" | "finish", "target": "proposer" | "critic" | null,
"instruction": "给对方的具体指令", "reason": "理由"}""",
"proposer": "你是提议者。根据问题和评审意见给出具体、可执行的方案。",
"critic": "你是评审者。指出方案的问题。最后一行必须是 VERDICT: APPROVE 或 VERDICT: REVISE。",
"summarizer": "你是总结者。根据讨论给出最终结论,列出关键理由。",
}
class Decision(BaseModel):
action: Literal["dispatch", "ask_human", "finish"]
target: Literal["proposer", "critic"] | None = None
instruction: str = ""
reason: str = Field(min_length=1)
class Turn(BaseModel):
role: str
instruction: str
content: str
class LoopState(BaseModel):
question: str
max_rounds: int = 3
round: int = 0
turns: list[Turn] = []
last_verdict: Literal["APPROVE", "REVISE"] | None = None
status: Literal["running", "awaiting_input", "done"] = "running"
human_question: str | None = None
final_answer: str | None = None
end_reason: str | None = None
def parse_verdict(text: str) -> Literal["APPROVE", "REVISE"]:
m = re.search(r"VERDICT:\s*(APPROVE|REVISE)\s*$", text.strip())
return m.group(1) if m else "REVISE" # 格式不对按“不通过”处理,偏保守 # type: ignore[return-value]
def save_atomic(path: Path, state: LoopState) -> None:
fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(state.model_dump_json(indent=2))
os.replace(tmp, path)
def context_for(state: LoopState, last_n: int = 4) -> str:
"""上下文工程:只给问题 + 最近几条发言,而不是全部历史。"""
recent = "\n".join(f"[{t.role}] {t.content[:400]}" for t in state.turns[-last_n:])
return f"问题:{state.question}\n最近的讨论:\n{recent or '(无)'}"
async def decide(llm: ChatLLM, state: LoopState) -> Decision:
messages = [{"role": "system", "content": ROLE_PROMPTS["coordinator"]},
{"role": "user", "content": context_for(state)}]
for attempt in range(2):
raw = (await llm.chat(messages)).content or ""
try:
m = re.search(r"\{.*\}", raw, re.S)
return Decision.model_validate_json(m.group(0) if m else raw)
except ValidationError as e:
if attempt == 1:
raise
messages += [{"role": "assistant", "content": raw},
{"role": "user", "content": f"格式错误:{e.errors()[0]['msg']}。只输出合法 JSON。"}]
raise AssertionError("unreachable")
async def run_multi_agent(llm: ChatLLM, state: LoopState, path: Path, human_answer: str | None = None) -> LoopState:
if state.status == "awaiting_input":
if human_answer is None:
return state
state.turns.append(Turn(role="human", instruction=state.human_question or "", content=human_answer))
state.status, state.human_question = "running", None
while state.status == "running":
# 1. 硬规则
if state.last_verdict == "APPROVE":
state.end_reason = "consensus"
elif state.round >= state.max_rounds:
state.end_reason = "max_rounds"
if state.end_reason:
break
# 2. 协调者决定
d = await decide(llm, state)
if d.action == "finish":
state.end_reason = "coordinator_finish"
break
if d.action == "ask_human":
state.status, state.human_question = "awaiting_input", d.instruction
save_atomic(path, state)
return state
# 3. 执行专家
target = d.target or "proposer"
reply = await llm.chat([{"role": "system", "content": ROLE_PROMPTS[target]},
{"role": "user", "content": f"{context_for(state)}\n\n指令:{d.instruction}"}])
content = reply.content or ""
state.turns.append(Turn(role=target, instruction=d.instruction, content=content))
if target == "proposer":
state.round += 1
else:
state.last_verdict = parse_verdict(content)
save_atomic(path, state) # 每一步都落盘
summary = await llm.chat([{"role": "system", "content": ROLE_PROMPTS["summarizer"]},
{"role": "user", "content": context_for(state, last_n=8)}])
state.final_answer, state.status = summary.content, "done"
save_atomic(path, state)
return state
def load_state(path: Path) -> LoopState:
return LoopState.model_validate_json(path.read_text(encoding="utf-8"))
if __name__ == "__main__":
print(json.dumps(Decision.model_json_schema(), ensure_ascii=False)[:200])
```
用一个“按角色回答”的假模型测试:
```python
# 文件:agent-mini/tests/test_multi_agent.py
import json
import pytest
from pydantic import ValidationError
from agent_mini.handwritten.llm import AssistantMessage
from agent_mini.handwritten.multi_agent import LoopState, load_state, parse_verdict, run_multi_agent
class RoleLLM:
"""根据系统提示词判断当前角色,从对应队列里取回答。"""
def __init__(self, **queues: list[str]):
self.queues = {k: list(v) for k, v in queues.items()}
self.count = {k: 0 for k in queues}
async def chat(self, messages, tools=None):
system = messages[0]["content"]
role = next(r for r in ("coordinator", "proposer", "critic", "summarizer")
if {"coordinator": "协调者", "proposer": "提议者", "critic": "评审者", "summarizer": "总结者"}[r] in system)
self.count[role] = self.count.get(role, 0) + 1
return AssistantMessage(content=self.queues[role].pop(0))
def d(action, target=None, instruction="请处理", reason="需要"):
return json.dumps({"action": action, "target": target, "instruction": instruction, "reason": reason})
async def test_consensus_ends_early(tmp_path):
llm = RoleLLM(
coordinator=[d("dispatch", "proposer"), d("dispatch", "critic"), d("dispatch", "proposer"), d("dispatch", "critic")],
proposer=["方案一", "方案二"],
critic=["不够具体\nVERDICT: REVISE", "可以\nVERDICT: APPROVE"],
summarizer=["结论:采用方案二"],
)
s = await run_multi_agent(llm, LoopState(question="选技术栈", max_rounds=5), tmp_path / "s.json")
assert (s.end_reason, s.round, s.final_answer) == ("consensus", 2, "结论:采用方案二")
assert load_state(tmp_path / "s.json").status == "done"
async def test_max_rounds(tmp_path):
llm = RoleLLM(coordinator=[d("dispatch", "proposer"), d("dispatch", "critic")] * 2,
proposer=["a", "b"], critic=["VERDICT: REVISE"] * 2, summarizer=["到达上限"])
s = await run_multi_agent(llm, LoopState(question="q", max_rounds=2), tmp_path / "s.json")
assert s.end_reason == "max_rounds" and s.round == 2
async def test_bad_json_retried_once(tmp_path):
llm = RoleLLM(coordinator=["好的我来派活", d("finish")], summarizer=["结束"])
s = await run_multi_agent(llm, LoopState(question="q"), tmp_path / "s.json")
assert s.end_reason == "coordinator_finish" and llm.count["coordinator"] == 2
llm2 = RoleLLM(coordinator=["坏", "还是坏"])
with pytest.raises(ValidationError):
await run_multi_agent(llm2, LoopState(question="q"), tmp_path / "s2.json")
async def test_ask_human_then_resume_from_file(tmp_path):
path = tmp_path / "s.json"
llm = RoleLLM(coordinator=[d("ask_human", instruction="预算是多少?"), d("finish")], summarizer=["按 5 万预算推荐"])
s = await run_multi_agent(llm, LoopState(question="做网站"), path)
assert s.status == "awaiting_input" and s.human_question == "预算是多少?"
restored = load_state(path) # 模拟进程重启:只从文件恢复
s = await run_multi_agent(llm, restored, path, human_answer="5 万")
assert s.status == "done" and s.turns[-1].role == "human"
def test_parse_verdict():
assert parse_verdict("好\nVERDICT: APPROVE") == "APPROVE"
assert parse_verdict("没写结论") == "REVISE"
```
**易错点 / 面试常问(补充)**
- 易错点:多 Agent 循环一定要有最大轮数,否则两个角色可能一直互相“再改改”。
- 易错点:状态要在每一步之后保存(本周存 JSON 文件),保存要用“写临时文件再改名”,避免崩溃时写坏文件。
- 面试常问:多 Agent 和单 Agent + 多工具的区别和代价(角色分工更清楚、可以互相审查,但调用次数和费用成倍增加,调试更难)。
### 要建的文件
- `handwritten/multi_agent.py`(150~250 行)、`tests/test_multi_agent.py`
### 运行方式
```bash
uv run pytest tests/test_multi_agent.py -v
```
### 完成标准
- 测试通过;能不看代码讲清循环的每一步。
- 写下“用 JSON 文件持久化”的问题(为下两周铺垫):多个进程同时写会互相覆盖;查询“所有等待输入的任务”要扫所有文件;正在调用模型时崩溃,这一步会重做。
---
## 第 4 天:MCP 概念 + 写一个 MCP 服务
### 学习内容
- 说明:
- **MCP(Model Context Protocol)**:让 AI 应用用统一方式接入外部工具和数据的协议。角色:**宿主**(Cursor、Claude 桌面版、你自己的 Agent)、**客户端**(宿主里负责连接的部分)、**服务端**(你写的,提供能力)。
- 服务端能提供三类东西:
- **tools**:模型可以调用的函数(有副作用也行),比如“查知识库”“建待办”;
- **resources**:只读数据,用 URI 标识,比如 `notes://cards`;
- **prompts**:可复用的提示词模板,比如“评审某个方案”。
- **传输方式**:本地子进程用 **stdio**(宿主启动你的程序,通过标准输入输出通信);远程服务用 **Streamable HTTP**(老的 HTTP+SSE 传输已被它取代)。
- **和 Function Calling 的关系**:Function Calling 是“模型怎么表达要调用工具”;MCP 是“工具怎么被统一发现和调用”。写一次 MCP 服务,所有支持 MCP 的宿主都能用。
- 返回值写成 pydantic 模型,SDK 会自动生成**输出 Schema**,客户端拿到结构化结果;想把错误信息告诉调用方要抛 `ToolError`,其他异常在 2.x 里只会显示“执行出错”(防止泄露内部细节)。
- SDK 版本:MCP Python SDK 2.x 里服务端类是 `MCPServer`(`from mcp.server.mcpserver import MCPServer`);1.x 里叫 `FastMCP`(`from mcp.server.fastmcp import FastMCP`)。网上很多教程还是 1.x 写法,**以官方文档为准**:https://py.sdk.modelcontextprotocol.io/
- 案例:知识库 MCP 服务——查询第 5~6 周的 `rag-qa`,提供一个幂等的“建待办”工具、一个资源和一个提示词。
```python
# 文件:agent-mini/mcp_servers/__init__.py
```
```python
# 文件:agent-mini/mcp_servers/knowledge_server.py
"""知识库 MCP 服务。运行:uv run python mcp_servers/knowledge_server.py(stdio)"""
import os
from pathlib import Path
import httpx
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from pydantic import BaseModel
RAG_QA_URL = os.getenv("RAG_QA_URL", "http://localhost:8000")
NOTES_DIR = Path(os.getenv("NOTES_DIR", "notes/cards"))
RAG_TRANSPORT: httpx.AsyncBaseTransport | None = None # 测试时替换成 MockTransport
mcp = MCPServer("knowledge", instructions="查询个人知识库(rag-qa)和学习笔记。")
_todos: dict[str, dict] = {} # 演示用;真实实现调用项目一的接口并带上幂等键
# 返回值用 pydantic 模型:SDK 会自动生成输出 Schema,客户端拿到结构化结果(structured_content)
class Source(BaseModel):
n: int
file: str
heading: str
class SearchResult(BaseModel):
answer: str
sources: list[Source]
class DocItem(BaseModel):
filename: str
chunks: int
class TodoResult(BaseModel):
id: int
title: str
duplicate: bool
def _client() -> httpx.AsyncClient:
return httpx.AsyncClient(base_url=RAG_QA_URL, timeout=30, transport=RAG_TRANSPORT)
@mcp.tool()
async def search_knowledge(query: str, top_k: int = 4) -> SearchResult:
"""在个人知识库中检索并回答问题,返回回答和出处。
当问题涉及“我的笔记 / 知识库 / 学过的内容”时使用。query 用完整的中文问题。"""
async with _client() as c:
try:
r = await c.post("/ask", json={"question": query})
except httpx.HTTPError as e:
# ToolError 的信息会原样返回给客户端;其他异常只会显示“执行出错”,避免泄露内部细节
raise ToolError(f"知识库服务不可用({type(e).__name__}),请稍后再试") from e
if r.status_code != 200:
raise ToolError(f"知识库返回 {r.status_code}")
data = r.json()
return SearchResult(answer=data["text"], sources=[
Source(n=c["n"], file=c["filename"], heading=c["heading_path"]) for c in data["citations"][:top_k]])
@mcp.tool()
async def list_documents() -> list[DocItem]:
"""列出知识库里的文档(文件名和片段数)。"""
async with _client() as c:
r = await c.get("/documents")
if r.status_code != 200:
raise ToolError(f"知识库返回 {r.status_code}")
return [DocItem(filename=d["filename"], chunks=d["chunks"]) for d in r.json()]
@mcp.tool()
def create_todo(title: str, request_id: str) -> TodoResult:
"""创建一条待办。request_id 由调用方生成;同一个 request_id 重复调用只会创建一次(幂等)。"""
if request_id in _todos:
return TodoResult(**_todos[request_id], duplicate=True)
_todos[request_id] = {"id": len(_todos) + 1, "title": title}
return TodoResult(**_todos[request_id], duplicate=False)
@mcp.resource("notes://cards")
def card_titles() -> str:
"""案例卡标题列表(只读脱敏后的 notes/cards)。"""
if not NOTES_DIR.is_dir():
return "(没有找到案例卡目录)"
return "\n".join(p.read_text(encoding="utf-8").splitlines()[0] for p in sorted(NOTES_DIR.glob("*.md")))
@mcp.prompt()
def review_plan(plan: str) -> str:
"""让模型按固定维度评审一个方案。"""
return f"请从成本、风险、可维护性三个方面评审下面的方案,最后给出 VERDICT: APPROVE 或 VERDICT: REVISE。\n\n{plan}"
if __name__ == "__main__": # 不自动运行
mcp.run() # 默认 stdio;远程部署用 mcp.run(transport="streamable-http")
```
```python
# 文件:agent-mini/tests/test_mcp_server.py
import httpx
from mcp import Client
import mcp_servers.knowledge_server as ks
def fake_rag(request: httpx.Request) -> httpx.Response:
if request.url.path == "/ask":
return httpx.Response(200, json={"text": "用 channel 通信 [1]", "citations": [
{"n": 1, "filename": "go.md", "heading_path": "Go > 并发"}]})
return httpx.Response(200, json=[{"filename": "go.md", "chunks": 3}])
async def test_tools_resources_prompts(monkeypatch, tmp_path):
monkeypatch.setattr(ks, "RAG_TRANSPORT", httpx.MockTransport(fake_rag))
(tmp_path / "A.md").write_text("标题:编排停在浏览器里\n...", encoding="utf-8")
monkeypatch.setattr(ks, "NOTES_DIR", tmp_path)
async with Client(ks.mcp) as client: # 内存连接:不起子进程,测试最方便
names = {t.name for t in (await client.list_tools()).tools}
assert names == {"search_knowledge", "list_documents", "create_todo"}
r = await client.call_tool("search_knowledge", {"query": "goroutine 怎么通信"})
assert not r.is_error and r.structured_content["sources"][0]["file"] == "go.md"
first = await client.call_tool("create_todo", {"title": "复习", "request_id": "req-1"})
again = await client.call_tool("create_todo", {"title": "复习", "request_id": "req-1"})
assert first.structured_content["duplicate"] is False and again.structured_content["duplicate"] is True
res = await client.read_resource("notes://cards")
assert "编排停在浏览器里" in res.contents[0].text
p = await client.get_prompt("review_plan", {"plan": "用 Redis 缓存"})
assert "VERDICT" in p.messages[0].content.text
async def test_rag_down_returns_error(monkeypatch):
def down(request):
raise httpx.ConnectError("refused", request=request)
monkeypatch.setattr(ks, "RAG_TRANSPORT", httpx.MockTransport(down))
async with Client(ks.mcp) as client:
r = await client.call_tool("search_knowledge", {"query": "x"})
assert r.is_error and "不可用" in r.content[0].text
```
### 要建的文件
- `mcp_servers/__init__.py`、`mcp_servers/knowledge_server.py`、`tests/test_mcp_server.py`
### 运行方式
```bash
uv run pytest tests/test_mcp_server.py -v
# 官方调试工具 MCP Inspector:浏览器里查看并手动调用工具
npx @modelcontextprotocol/inspector uv run python mcp_servers/knowledge_server.py
```
### 完成标准
- 测试通过;在 Inspector 里能看到 3 个工具、1 个资源、1 个提示词并手动调用(先启动 `rag-qa`)。
- 面试常问:MCP 是什么?tools / resources / prompts 的区别?stdio 和 Streamable HTTP 怎么选?有副作用的工具怎么保证安全(幂等键、权限、人工确认)?
---
## 第 5 天:在 Cursor 和自己的 Agent 里用 MCP
### 学习内容
- 说明:
- 同一个 MCP 服务,**Cursor 能用,你自己的 Agent 也能用**——这就是协议的价值。
- 在自己的 Agent 里:用 MCP 客户端连接服务 → `list_tools` 拿到工具名、说明、参数 Schema → 转成第 2 天的 `Tool` 对象 → 放进工具调用循环。工具是**运行时动态发现**的,不用写死。
- 连接方式:测试里用内存连接 `Client(server)`;真实使用时用 `Client(StdioServerParameters(command=..., args=[...]))` 启动子进程,或连远程 HTTP 地址。
- LangChain 也提供了 MCP 适配器(独立包 `langchain-mcp-adapters` 的 `MultiServerMCPClient`;较新的 LangChain 把它并进了 `langchain` 包,处于测试阶段)。**以官方文档为准**:https://docs.langchain.com/oss/python/langchain/mcp 。下周我们用自己写的 20 行桥接,原理更清楚。
- 案例:
```python
# 文件:agent-mini/src/agent_mini/handwritten/mcp_bridge.py
from mcp import Client
from agent_mini.handwritten.tools import Tool
class ToolExecError(RuntimeError):
pass
async def load_mcp_tools(client: Client, allow: set[str] | None = None) -> list[Tool]:
"""把 MCP 服务上的工具转成手写循环用的 Tool。allow 是白名单:不要把所有工具都暴露给模型。"""
tools = []
for t in (await client.list_tools()).tools:
if allow is not None and t.name not in allow:
continue
async def run(args: dict, _name: str = t.name) -> str:
r = await client.call_tool(_name, args)
text = "\n".join(c.text for c in r.content if getattr(c, "type", "") == "text")
if r.is_error:
raise ToolExecError(text)
return text
tools.append(Tool(name=t.name, description=t.description or "", parameters=t.input_schema, func=run))
return tools
```
```python
# 文件:agent-mini/tests/test_mcp_bridge.py
import httpx
from mcp import Client
import mcp_servers.knowledge_server as ks
from agent_mini.handwritten.llm import AssistantMessage, ScriptedLLM, call
from agent_mini.handwritten.mcp_bridge import load_mcp_tools
from agent_mini.handwritten.tool_loop import run_tool_loop
from agent_mini.handwritten.tools import default_tools
async def test_agent_uses_mcp_tool(monkeypatch):
monkeypatch.setattr(ks, "RAG_TRANSPORT", httpx.MockTransport(
lambda r: httpx.Response(200, json={"text": "Pod 是最小调度单位 [1]",
"citations": [{"n": 1, "filename": "k8s.md", "heading_path": "K8s"}]})))
async with Client(ks.mcp) as client:
mcp_tools = await load_mcp_tools(client, allow={"search_knowledge"})
assert [t.name for t in mcp_tools] == ["search_knowledge"]
llm = ScriptedLLM([call("search_knowledge", {"query": "Pod 是什么"}),
AssistantMessage(content="根据你的笔记,Pod 是最小调度单位。")])
r = await run_tool_loop(llm, "我的笔记里 Pod 是什么", default_tools() + mcp_tools)
assert r.steps[0].ok and "k8s.md" in r.steps[0].result
assert "根据你的笔记" in r.answer
```
Cursor 里配置 MCP(项目根目录 `.cursor/mcp.json`,**格式以 Cursor 官方文档为准**;路径换成你自己的,不要提交含个人信息的路径):
```json
{
"mcpServers": {
"knowledge": {
"command": "uv",
"args": ["run", "--directory", "/path/to/agent-mini", "python", "mcp_servers/knowledge_server.py"],
"env": { "RAG_QA_URL": "http://localhost:8000" }
}
}
}
```
**易错点 / 面试常问(补充)**
- 易错点:stdio 方式的 MCP 服务**不能往标准输出打印日志**,标准输出是协议通道,乱打印会让客户端解析失败;日志写到标准错误。
- 易错点:Cursor 的 `mcp.json` 里命令和路径要写对(建议用绝对路径或 `uv --directory`),改完配置要重新加载。
- 易错点:MCP Python SDK 2.x 和 1.x 的导入路径、类名不同(本计划按 2.x 写),网上的例子多数是 1.x,以官方文档为准:https://py.sdk.modelcontextprotocol.io/ 。
- 面试常问:MCP 解决什么问题(工具接入的统一协议:一个服务可以被各种客户端复用);tools、resources、prompts 的区别;stdio 和 Streamable HTTP 两种传输方式各适合什么场景(本机进程 / 远程服务)。
### 要建的文件
- `handwritten/mcp_bridge.py`、`tests/test_mcp_bridge.py`、`docs/mcp-usage.md`(Cursor 配置示例 + 截图)
### 运行方式
```bash
uv run pytest -v
```
### 完成标准
- 在 Cursor 对话里让它“查一下我的知识库里关于 X 的内容”,能看到它调用了 `search_knowledge`。
- 你自己的工具调用循环通过 MCP 用上了同一个工具。
---
## 第 6 天:真实模型试跑 + 对照文档(上半)
### 学习内容
- 说明:用真实模型跑手写版,观察模型的“不听话”:协调者 JSON 格式错、评审者忘写 VERDICT、乱调工具、重复调用同一个工具。每个现象都对应一条防护(校验重试、保守默认值、白名单、最大步数)。
- 案例:一个命令行入口(有密钥时用真实模型,没有时提示)。
```python
# 文件:agent-mini/src/agent_mini/handwritten/demo.py
import asyncio
import os
import sys
import tempfile
from pathlib import Path
import httpx
from agent_mini.handwritten.llm import OpenAICompatLLM
from agent_mini.handwritten.multi_agent import LoopState, run_multi_agent
async def main(question: str) -> None: # 不自动运行:需要真实模型密钥
if not os.getenv("LLM_API_KEY"):
sys.exit("请先在环境变量里设置 LLM_BASE_URL、LLM_API_KEY、CHAT_MODEL")
async with httpx.AsyncClient(base_url=os.environ["LLM_BASE_URL"], timeout=60,
headers={"Authorization": f"Bearer {os.environ['LLM_API_KEY']}"}) as c:
llm = OpenAICompatLLM(c, os.environ["CHAT_MODEL"])
path = Path(tempfile.gettempdir()) / "agent_mini_state.json"
state = await run_multi_agent(llm, LoopState(question=question, max_rounds=3), path)
for t in state.turns:
print(f"\n## {t.role}(指令:{t.instruction})\n{t.content}")
print(f"\n== 结束原因:{state.end_reason}\n{state.final_answer}")
if __name__ == "__main__":
asyncio.run(main(" ".join(sys.argv[1:]) or "5 人创业团队做内部工具,该选什么技术栈?"))
```
### 要建的文件
- `handwritten/demo.py`;`docs/samples/handwritten-*.md`(真实运行记录,检查无敏感信息)
- `docs/handwritten-vs-langgraph.md`:先写“手写版”这一半:循环、状态、持久化、工具调用、结束条件分别怎么做的,哪里最容易出错
### 运行方式
```bash
uv run python -m agent_mini.handwritten.demo "给读书 App 设计会员规则"
```
### 完成标准
- 跑 3 个题目,记录观察到的“不听话”现象和对应防护。
---
## 第 7 天:M1 验收
### 学习内容
- 说明:整理代码和测试;把本周的面试题用自己的话写进 `docs/handwritten-vs-langgraph.md`。
### 要建的文件
- README 初版:项目介绍、目录、怎么跑测试、怎么启动 MCP 服务
### 运行方式
```bash
uv run ruff check . && uv run pyright src && uv run pytest -v
```
### 完成标准(M1 验收)
- [ ] 手写工具调用循环和多 Agent 循环都能跑,测试通过
- [ ] 能讲清 Function Calling 的完整过程,以及参数不合法、工具出错、死循环怎么处理
- [ ] 写了 MCP 服务(tools / resources / prompts),Inspector 和 Cursor 都能调用
- [ ] 自己的 Agent 能动态加载 MCP 工具
- [ ] 能讲清 MCP 和 Function Calling 的关系
- [ ] 在总纲进度表勾掉第 10 周
<!-- week-topics:start -->
## 本周相关专题
> 本节由 `tools/gen_week_topics.py` 根据专题文件的 `week` 字段自动生成,请勿手改;想调整就修改对应专题文件的 frontmatter 后重新生成。
- **AI 编程练习(每周)**:本周至少一个功能用 AI 编程工具(Cursor / Claude Code / Codex 等)完成,按 [AI 编程:工作流](../topics/ai/ai-coding/01-workflow-rules-skills.md) 写清需求和规则,按 [验证与证据](../topics/ai/ai-coding/02-verify-evidence.md) 保存测试结果和会话记录(脱敏后),第 15 周整理成“AI 编程 + 验证方法”的应聘案例。
| 技术栈 | 课程 / 场景 / 面试题 |
| --- | --- |
| [Agent 设计](../topics/ai/agent-design/index.md) | 课程:[Agent 推理模式](../topics/ai/agent-design/01-react-planning.md) |
| [AI 编程工具与验证](../topics/ai/ai-coding/index.md) | 课程:[AI 编程工具工作流](../topics/ai/ai-coding/01-workflow-rules-skills.md) |
| [Function Calling 工具调用](../topics/ai/function-calling/index.md) | 课程:[工具调用原理](../topics/ai/function-calling/01-tool-loop.md);场景:[让 Agent 安全地调用内部业务接口](../topics/ai/function-calling/scenarios/safe-business-tools.md);[面试题](../topics/ai/function-calling/interview.md) |
| [模型侧基础(了解级)](../topics/ai/llm-serving/index.md) | 课程:[模型侧基础](../topics/ai/llm-serving/01-routing-vllm-lora.md) |
| [MCP 模型上下文协议](../topics/ai/mcp/index.md) | 课程:[MCP 协议和服务开发](../topics/ai/mcp/01-protocol-server.md);场景:[企业内部 MCP 服务](../topics/ai/mcp/scenarios/internal-tools-gateway.md);[面试题](../topics/ai/mcp/interview.md) |
**跨技术栈场景**:[报表生成 Agent](../scenarios/report-agent.md)
<!-- week-topics:end -->