44 KiB
44 KiB
id, title, type, domain, stack, week, est_hours, order
| id | title | type | domain | stack | week | est_hours | order | |||
|---|---|---|---|---|---|---|---|---|---|---|
| plan.week10 | 第 10 周:项目三 M1——手写工具调用循环 + 手写多 Agent 循环 + MCP 服务 | week | plan |
|
|
12 | 10 |
第 10 周:项目三 M1——手写工具调用循环 + 手写多 Agent 循环 + MCP 服务
第三段的第 3 周,项目三
agent-mini(阶段 3 主交付) 的第 1 周。上周week09-case-cards.md,下周week11-agent-m2-langgraph.md。
项目三总览
做什么:把你在公司见过的多 Agent 产品“削”成一个没有业务外壳、可公开演示的内核,证明你“会造”,而不只是“会改”。
做完后(第 13 周)要能演示:
- 提一个问题 → 协调者派活给提议者、评审者 → 专家可以调用工具(包括通过 MCP 调用你的 RAG 知识库)→ 多轮讨论 → 达成共识或到达最大轮数后结束 → 生成可审计的报告。
- 中途
kill -9杀掉服务进程 → 重启 → 任务从最近的检查点接着跑完。 - 浏览器通过 SSE 看进度;关掉页面任务照跑;重新打开能补齐漏掉的事件。
- 信息不够时停下来问人(
interrupt),人回答后继续;模型失败后换模型从失败点重试。 - 记得用户的偏好(长期记忆),下次提问时用上。
- 有评测集,能用数字说明改动的效果;有可观测性(每次运行的耗时、调用了什么)。
节奏
| 周 | 里程碑 | 内容 |
|---|---|---|
| 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 周完成后)
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消息追加回去,再问模型,直到它给出最终回答。 - 为了测试,写一个脚本化假模型:按预先写好的顺序返回回答或工具调用。
- 消息有四种角色:
- 案例:
uv init --package agent-mini && cd agent-mini
uv add httpx pydantic pydantic-settings "mcp>=2"
uv add --dev pytest pytest-asyncio ruff pyright
# 文件:agent-mini/src/agent_mini/__init__.py
# 文件:agent-mini/src/agent_mini/handwritten/__init__.py
# 文件: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、.gitignoresrc/agent_mini/__init__.py、handwritten/__init__.py、handwritten/llm.py
运行方式
uv run python -c "import agent_mini.handwritten.llm as m; print(m.call('now', {}).to_dict())"
完成标准
- 能画出工具调用的消息往返:
user → assistant(tool_calls) → tool → assistant(最终回答)。
第 2 天:手写工具调用循环
学习内容
- 说明:
- 每个工具 = 名字 + 说明 + 参数模型 + 执行函数。说明写得好不好直接决定模型会不会用对——写清楚“什么时候用、参数什么意思”。
- 可靠性要点:
- 参数用 pydantic 校验;不合法时把错误返回给模型让它自己改,而不是整个流程崩溃。
- 工具执行出错同理,返回错误信息。
- 限制最大步数,防止模型来回调用停不下来。
- 计算器绝不能用
eval(会执行任意代码),用ast只允许数字和四则运算。 - 有副作用的工具(建待办、发邮件)要幂等、可审计(第 4 天)。
- 案例:
# 文件: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),
]
# 文件: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
# 文件: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
运行方式
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 文件(原子写入),进程中途退出后可以从文件继续——先体会“持久化 + 续跑”要解决什么问题,下两周再用检查点和数据库正式解决。
- 案例:
# 文件: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])
用一个“按角色回答”的假模型测试:
# 文件: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
运行方式
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,提供一个幂等的“建待办”工具、一个资源和一个提示词。
# 文件:agent-mini/mcp_servers/__init__.py
# 文件: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")
# 文件: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
运行方式
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 行桥接,原理更清楚。
- 案例:
# 文件: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
# 文件: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 官方文档为准;路径换成你自己的,不要提交含个人信息的路径):
{
"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 配置示例 + 截图)
运行方式
uv run pytest -v
完成标准
- 在 Cursor 对话里让它“查一下我的知识库里关于 X 的内容”,能看到它调用了
search_knowledge。 - 你自己的工具调用循环通过 MCP 用上了同一个工具。
第 6 天:真实模型试跑 + 对照文档(上半)
学习内容
- 说明:用真实模型跑手写版,观察模型的“不听话”:协调者 JSON 格式错、评审者忘写 VERDICT、乱调工具、重复调用同一个工具。每个现象都对应一条防护(校验重试、保守默认值、白名单、最大步数)。
- 案例:一个命令行入口(有密钥时用真实模型,没有时提示)。
# 文件: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:先写“手写版”这一半:循环、状态、持久化、工具调用、结束条件分别怎么做的,哪里最容易出错
运行方式
uv run python -m agent_mini.handwritten.demo "给读书 App 设计会员规则"
完成标准
- 跑 3 个题目,记录观察到的“不听话”现象和对应防护。
第 7 天:M1 验收
学习内容
- 说明:整理代码和测试;把本周的面试题用自己的话写进
docs/handwritten-vs-langgraph.md。
要建的文件
- README 初版:项目介绍、目录、怎么跑测试、怎么启动 MCP 服务
运行方式
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 周