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

44 KiB
Raw Blame History

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
mcp
function-calling
10
12 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 实际跑通。动手前先看官方文档当前版本,导入路径或参数有出入时按文档改:

最终项目结构(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、.gitignore
  • src/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 天:手写工具调用循环

学习内容

  • 说明:
    • 每个工具 = 名字 + 说明 + 参数模型 + 执行函数。说明写得好不好直接决定模型会不会用对——写清楚“什么时候用、参数什么意思”。
    • 可靠性要点:
      1. 参数用 pydantic 校验;不合法时把错误返回给模型让它自己改,而不是整个流程崩溃。
      2. 工具执行出错同理,返回错误信息。
      3. 限制最大步数,防止模型来回调用停不下来。
      4. 计算器绝不能用 eval(会执行任意代码),用 ast 只允许数字和四则运算。
      5. 有副作用的工具(建待办、发邮件)要幂等、可审计(第 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 周