--- id: plan.week11 title: "第 11 周:项目三 M2——用 LangGraph 实现多 Agent 编排" type: week domain: plan stack: [langchain, langgraph, mcp, prompt-engineering] week: [11] est_hours: 12 order: 11 --- # 第 11 周:项目三 M2——用 LangGraph 实现多 Agent 编排 > 项目三的第 2 周。上周 `week10-agent-m1-tools-mcp.md`(手写版 + MCP),下周 `week12-agent-m3-persistence.md`(续跑、中断、后台作业、SSE、记忆)。 **本周目标**:用 LangGraph 把上周的手写循环重写一遍:状态(State)、节点、条件边、工具调用、结构化决定。手写版的每一部分都要在 LangGraph 里找到对应。 **接口以官方文档为准**:LangGraph 更新快,本周代码在 `langgraph 1.2`、`langchain-core 1.7` 上跑通。文档:https://docs.langchain.com/oss/python/langgraph/overview (Graph API:https://docs.langchain.com/oss/python/langgraph/graph-api ) ```bash uv add langgraph langchain-core uv add langchain langchain-openai # 接真实模型时才需要(init_chat_model) ``` **本周新增文件** ```text agent-mini/src/agent_mini/ ├── models.py # Decision、parse_verdict ├── llm.py # 真实模型 / 假模型 ├── tools.py # LangChain 版本地工具 + MCP 工具桥接 ├── fakes.py # 按角色回答的假模型(测试和 --fake 演示共用) ├── graph/ │ ├── __init__.py │ ├── state.py # State 定义(含 reducer) │ ├── prompts.py │ ├── routing.py # 条件边:硬规则 + 协调者决定 │ ├── nodes.py # coordinator / proposer / critic / ask_human / summarize │ └── build.py # 组装并编译图 └── cli.py # agent-mini graph / run tests/ ├── __init__.py ├── test_routing.py ├── test_graph.py └── test_lc_tools.py ``` --- ## 第 1 天:LangGraph 基础 ### 学习内容 - 说明(对照上周手写版理解): | LangGraph 概念 | 是什么 | 手写版里对应 | | --- | --- | --- | | State | 一个 `TypedDict`,整个流程共享的数据 | `LoopState` | | 节点(node) | 普通函数:读 State,返回**要更新的字段** | 协调者、专家各自的那段代码 | | 边 / 条件边 | 节点之间的跳转;条件边由一个函数决定下一步 | `while` 循环里的 `if` | | reducer | 字段怎么合并:默认覆盖,`Annotated[list, operator.add]` 表示追加 | `turns.append(...)` | | `START` / `END` | 起点和终点 | 循环开始 / `break` | | compile | 把图编译成可运行对象,支持 `invoke` / `stream` | — | | checkpointer | 每个节点执行完自动保存状态(下周) | 写 JSON 文件 | - 案例:一个最小的“提议 ↔ 评审”循环图,跑一遍看清节点、条件边、reducer: ```python import operator from typing import Annotated, TypedDict from langgraph.graph import END, START, StateGraph class S(TypedDict): round: int log: Annotated[list[str], operator.add] # reducer:节点返回的列表会被追加,而不是覆盖 def propose(s: S) -> dict: return {"round": s["round"] + 1, "log": [f"提议 v{s['round'] + 1}"]} def review(s: S) -> dict: ok = s["round"] >= 2 return {"log": ["评审:通过" if ok else "评审:再改改"]} def after_review(s: S) -> str: return "done" if s["log"][-1].endswith("通过") else "again" g = StateGraph(S) g.add_node("propose", propose) g.add_node("review", review) g.add_edge(START, "propose") g.add_edge("propose", "review") g.add_conditional_edges("review", after_review, {"again": "propose", "done": END}) app = g.compile() for update in app.stream({"round": 0, "log": []}, stream_mode="updates"): print(update) # 每个节点执行完输出它返回的更新 print(app.invoke({"round": 0, "log": []})["log"]) print(app.get_graph().draw_mermaid()) # 导出 mermaid,可以贴进文档 ``` - 预置组件:`langgraph.prebuilt` 里有 `ToolNode`(执行工具调用)、LangChain 的 `create_agent`(现成的“模型 + 工具”循环)。知道有、什么时候用;本项目的专家节点里**自己写一个小工具循环**,这样每一步可控、好测试。 **易错点 / 面试常问(补充)** - 易错点:节点返回的是“要更新的字段”,不是整个状态;列表字段如果要追加而不是覆盖,要在 State 里用 `Annotated[list, operator.add]` 这类 reducer 声明。 - 易错点:图有环时要设退出条件;LangGraph 还有 `recursion_limit`(默认步数上限),超过会抛错,不要靠它当正常的结束条件。 - 面试常问:LangGraph 和 LangChain 的关系(LangChain 提供模型、工具等组件;LangGraph 负责有状态、可循环、可持久化的流程编排);为什么用图而不是写死的 if-else(流程可视化、可中断、可从检查点恢复)。 ### 要建的文件 - `scratch/langgraph_basics.py`(上面的代码,不进最终项目) ### 运行方式 ```bash uv run python scratch/langgraph_basics.py ``` ### 完成标准 - 能说出 State、节点、条件边、reducer 和手写版的对应关系;能把 mermaid 图贴进 Markdown 看到图。 --- ## 第 2 天:State、结构化决定、路由规则 ### 学习内容 - 说明: - State 里放什么:问题、轮数、发言记录(追加型)、最近一次评审结论、协调者的决定、人的回答(追加型)、最终结论、结束原因、长期记忆(下周)。 - **路由函数是纯函数**:输入 State,输出下一个节点名——最容易测试,也是“结束条件写在代码里”的落地点。硬规则顺序:评审已通过 → 总结;达到最大轮数 → 总结;否则按协调者决定。 - 案例: ```python # 文件:agent-mini/src/agent_mini/models.py import re from typing import Literal from pydantic import BaseModel, Field class Decision(BaseModel): action: Literal["dispatch", "ask_human", "finish"] target: Literal["proposer", "critic"] | None = None instruction: str = "" reason: str = Field(min_length=1) def parse_decision(raw: str) -> Decision: m = re.search(r"\{.*\}", raw, re.S) return Decision.model_validate_json(m.group(0) if m else raw) def parse_verdict(text: str) -> Literal["APPROVE", "REVISE"]: m = re.search(r"VERDICT:\s*(APPROVE|REVISE)\s*$", text.strip()) return "APPROVE" if m and m.group(1) == "APPROVE" else "REVISE" ``` ```python # 文件:agent-mini/src/agent_mini/graph/__init__.py ``` ```python # 文件:agent-mini/src/agent_mini/graph/state.py import operator from typing import Annotated, Literal, TypedDict class Turn(TypedDict): role: str instruction: str content: str tools: list[str] # 这一步调用过的工具(审计用) class AgentState(TypedDict, total=False): question: str user_id: str max_rounds: int round: int turns: Annotated[list[Turn], operator.add] last_verdict: Literal["APPROVE", "REVISE"] | None decision: dict | None human_answers: Annotated[list[str], operator.add] memories: list[str] final_answer: str end_reason: str ``` ```python # 文件:agent-mini/src/agent_mini/graph/routing.py from typing import Literal from agent_mini.graph.state import AgentState Next = Literal["proposer", "critic", "ask_human", "summarize"] def hard_stop(state: AgentState) -> str | None: """硬规则:返回结束原因;不需要结束时返回 None。""" if state.get("last_verdict") == "APPROVE": return "consensus" if state.get("round", 0) >= state.get("max_rounds", 3): return "max_rounds" return None def route_after_coordinator(state: AgentState) -> Next: if hard_stop(state): return "summarize" d = state.get("decision") or {} if d.get("action") == "dispatch": return "critic" if d.get("target") == "critic" else "proposer" if d.get("action") == "ask_human": return "ask_human" return "summarize" ``` ```python # 文件:agent-mini/tests/test_routing.py import pytest from agent_mini.graph.routing import route_after_coordinator @pytest.mark.parametrize("state, expected", [ ({"last_verdict": "APPROVE", "decision": {"action": "dispatch", "target": "proposer"}}, "summarize"), ({"round": 3, "max_rounds": 3, "decision": {"action": "dispatch", "target": "proposer"}}, "summarize"), ({"round": 1, "decision": {"action": "dispatch", "target": "critic"}}, "critic"), ({"round": 1, "decision": {"action": "dispatch", "target": None}}, "proposer"), ({"round": 1, "decision": {"action": "ask_human"}}, "ask_human"), ({"round": 1, "decision": {"action": "finish"}}, "summarize"), ({"round": 0}, "summarize"), # 没有决定(异常情况)时保守结束 ]) def test_route(state, expected): assert route_after_coordinator(state) == expected ``` **易错点 / 面试常问(补充)** - 易错点:让模型输出结构化决定时,一定要校验(本项目用 pydantic),并在解析失败时有兜底路由,不要让一次格式错误让整个流程崩溃。 - 易错点:路由函数要是纯函数,只根据状态决定下一步,这样才能单独测试。 - 面试常问:“谁来决定下一步”有几种做法(规则路由、模型决定、两者结合),各自的可控性和灵活性。 ### 要建的文件 - `models.py`、`graph/__init__.py`、`graph/state.py`、`graph/routing.py`、`tests/test_routing.py` ### 运行方式 ```bash uv run pytest tests/test_routing.py -v ``` ### 完成标准 - 每条路由规则都有用例;能解释为什么“硬规则优先于模型决定”。 --- ## 第 3 天:模型、工具、提示词、节点 ### 学习内容 - 说明: - **模型**:真实模型用 LangChain 的 `init_chat_model`(OpenAI 兼容接口填 `base_url`);测试用自己写的假模型(继承 `BaseChatModel`,按角色返回预设回答,支持 `bind_tools`)。 - **工具**:本地工具用 `@tool` 装饰器;MCP 工具用 `StructuredTool` 包一层(参数 Schema 直接用 MCP 给的 JSON Schema)。 - **节点**:每个节点只做一件事,返回要更新的字段。 - 协调者:先检查硬规则(命中就不调用模型,省钱);否则让模型输出 JSON 决定,校验失败重试一次。 - 提议者:带工具的小循环(`bind_tools` → 模型要调用工具 → 执行 → `ToolMessage` 回填 → 再问),有最大步数。 - 评审者:解析 `VERDICT`。 - 问人:`interrupt()`(下周配合检查点使用)。 - 总结者:生成结论和结束原因。 - **上下文工程**:每个节点只给模型需要的东西——问题、最近几条发言、本次指令、相关记忆,而不是全部历史。 - 案例: ```python # 文件:agent-mini/src/agent_mini/llm.py import os from collections.abc import Callable from typing import Any from langchain_core.language_models import BaseChatModel from langchain_core.messages import AIMessage, BaseMessage from langchain_core.outputs import ChatGeneration, ChatResult class FakeChatModel(BaseChatModel): """测试用:responder 根据收到的消息决定回答(可以带 tool_calls)。""" responder: Callable[[list[BaseMessage]], AIMessage] calls: int = 0 @property def _llm_type(self) -> str: return "fake-role" def _generate(self, messages: list[BaseMessage], stop: list[str] | None = None, run_manager: Any = None, **kwargs: Any) -> ChatResult: self.calls += 1 return ChatResult(generations=[ChatGeneration(message=self.responder(messages))]) def bind_tools(self, tools: Any, **kwargs: Any) -> "FakeChatModel": # type: ignore[override] return self def build_chat_model(model: str | None = None) -> BaseChatModel: """真实模型:OpenAI 兼容接口。需要 uv add langchain langchain-openai。参数以官方文档为准。""" from langchain.chat_models import init_chat_model return init_chat_model( model or os.environ["CHAT_MODEL"], model_provider="openai", base_url=os.environ["LLM_BASE_URL"], api_key=os.environ["LLM_API_KEY"], temperature=0.2, timeout=60, max_retries=2, ) ``` ```python # 文件:agent-mini/src/agent_mini/tools.py from datetime import date from langchain_core.tools import BaseTool, StructuredTool, tool from mcp import Client from agent_mini.handwritten.tools import safe_eval @tool def calculator(expression: str) -> str: """计算四则运算,例如 (3+4)*2。需要做数学计算时使用。""" return str(safe_eval(expression)) @tool def today() -> str: """返回今天的日期(YYYY-MM-DD)。""" return date.today().isoformat() LOCAL_TOOLS: list[BaseTool] = [calculator, today] async def mcp_tools_for_langchain(client: Client, allow: set[str]) -> list[BaseTool]: """把 MCP 工具包装成 LangChain 工具(也可以用官方的 MCP 适配器,接口以文档为准)。""" out: list[BaseTool] = [] for t in (await client.list_tools()).tools: if t.name not in allow: continue async def run(_name: str = t.name, **kwargs) -> str: r = await client.call_tool(_name, kwargs) text = "\n".join(c.text for c in r.content if getattr(c, "type", "") == "text") return f"工具出错:{text}" if r.is_error else text out.append(StructuredTool.from_function(coroutine=run, name=t.name, description=t.description or "", args_schema=t.input_schema)) return out ``` ```python # 文件:agent-mini/src/agent_mini/graph/prompts.py COORDINATOR = """你是协调者,负责决定下一步由谁做什么。规则: - 还没有方案时派给提议者(proposer);有新方案时派给评审者(critic);评审要求修改时派回提议者。 - 问题缺少关键信息(预算、规模、约束)且无法合理假设时,选择 ask_human。 只输出 JSON:{"action": "dispatch|ask_human|finish", "target": "proposer|critic|null", "instruction": "具体指令", "reason": "理由"}""" PROPOSER = "你是提议者。给出具体、可执行的方案;需要事实或计算时使用工具,不要编造。" CRITIC = "你是评审者。检查方案的成本、风险、可行性。最后一行必须是 VERDICT: APPROVE 或 VERDICT: REVISE。" SUMMARIZER = "你是总结者。根据讨论给出最终结论(不超过 300 字),列出关键理由和主要风险。" def context(state: dict, last_n: int = 4) -> str: turns = state.get("turns", [])[-last_n:] recent = "\n".join(f"[{t['role']}] {t['content'][:500]}" for t in turns) or "(无)" memories = "\n".join(f"- {m}" for m in state.get("memories", [])) or "(无)" answers = "\n".join(f"- {a}" for a in state.get("human_answers", [])) or "(无)" return f"问题:{state['question']}\n用户补充:\n{answers}\n相关记忆:\n{memories}\n最近讨论:\n{recent}" ``` ```python # 文件:agent-mini/src/agent_mini/graph/nodes.py from langchain_core.language_models import BaseChatModel from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage from langchain_core.tools import BaseTool from langgraph.types import interrupt from pydantic import ValidationError from agent_mini.graph import prompts from agent_mini.graph.routing import hard_stop from agent_mini.graph.state import AgentState from agent_mini.models import parse_decision, parse_verdict class Nodes: def __init__(self, model: BaseChatModel, tools: list[BaseTool] | None = None, max_tool_steps: int = 4): self.model, self.tools, self.max_tool_steps = model, tools or [], max_tool_steps async def coordinator(self, state: AgentState) -> dict: if hard_stop(state): return {"decision": {"action": "finish", "reason": "命中硬规则"}} # 不调用模型 messages = [SystemMessage(prompts.COORDINATOR), HumanMessage(prompts.context(state))] for attempt in range(2): raw = str((await self.model.ainvoke(messages)).content) try: return {"decision": parse_decision(raw).model_dump()} except ValidationError as e: if attempt == 1: raise messages += [HumanMessage(f"格式错误:{e.errors()[0]['msg']}。只输出合法 JSON。")] raise AssertionError("unreachable") async def proposer(self, state: AgentState) -> dict: instruction = (state.get("decision") or {}).get("instruction", "") messages = [SystemMessage(prompts.PROPOSER), HumanMessage(f"{prompts.context(state)}\n\n指令:{instruction}")] bound = self.model.bind_tools(self.tools) if self.tools else self.model by_name = {t.name: t for t in self.tools} used: list[str] = [] for _ in range(self.max_tool_steps): ai = await bound.ainvoke(messages) messages.append(ai) if not ai.tool_calls: break for call in ai.tool_calls: tool = by_name.get(call["name"]) try: result = await tool.ainvoke(call["args"]) if tool else f"没有工具 {call['name']}" except Exception as e: # 工具出错返回给模型 result = f"工具出错:{e}" used.append(call["name"]) messages.append(ToolMessage(content=str(result), tool_call_id=call["id"])) content = str(messages[-1].content) if not getattr(messages[-1], "tool_calls", None) else "(工具调用次数超限)" return {"round": state.get("round", 0) + 1, "turns": [{"role": "proposer", "instruction": instruction, "content": content, "tools": used}]} async def critic(self, state: AgentState) -> dict: instruction = (state.get("decision") or {}).get("instruction", "") ai = await self.model.ainvoke([SystemMessage(prompts.CRITIC), HumanMessage(f"{prompts.context(state)}\n\n指令:{instruction}")]) content = str(ai.content) return {"last_verdict": parse_verdict(content), "turns": [{"role": "critic", "instruction": instruction, "content": content, "tools": []}]} async def ask_human(self, state: AgentState) -> dict: # interrupt 会暂停整个图并把问题交给调用方;恢复时这个节点从头重新执行, # 所以 interrupt 之前不要放有副作用的操作 question = (state.get("decision") or {}).get("instruction") or "请补充更多信息" answer = interrupt({"question": question}) return {"human_answers": [f"{question} → {answer}"], "decision": None} async def summarize(self, state: AgentState) -> dict: ai = await self.model.ainvoke([SystemMessage(prompts.SUMMARIZER), HumanMessage(prompts.context(state, last_n=8))]) reason = hard_stop(state) or "coordinator_finish" return {"final_answer": str(ai.content), "end_reason": reason} ``` **易错点 / 面试常问(补充)** - 易错点:每个角色的提示词要写清“输出格式”,例如审查者最后一行必须是 `VERDICT: APPROVE / REVISE`,否则路由规则无法判断。 - 易错点:工具调用的结果要限制长度再放回上下文,太长会挤掉重要信息、增加费用。 - 面试常问:怎么让多个角色不互相“附和”(不同的系统提示词、审查者有明确的检查清单、最大轮数兜底)。 ### 要建的文件 - `llm.py`、`tools.py`、`graph/prompts.py`、`graph/nodes.py` ### 运行方式 - 明天组装成图后一起测试。 ### 完成标准 - 提示词全部自己写,不复制公司的。 - 能讲清每个节点“读了 State 的哪些字段、返回了哪些字段”。 --- ## 第 4 天:组装图 + 测试 ### 学习内容 - 说明:把节点和条件边组装起来;`checkpointer` 和 `store` 作为参数传入(本周传 `None`,下周接上)。用 `stream_mode="updates"` 逐个节点看执行过程。 - 案例: ```python # 文件:agent-mini/src/agent_mini/graph/build.py from langchain_core.language_models import BaseChatModel from langchain_core.tools import BaseTool from langgraph.graph import END, START, StateGraph from agent_mini.graph.nodes import Nodes from agent_mini.graph.routing import route_after_coordinator from agent_mini.graph.state import AgentState def build_graph(model: BaseChatModel, tools: list[BaseTool] | None = None, checkpointer=None, store=None): n = Nodes(model, tools) g = StateGraph(AgentState) g.add_node("coordinator", n.coordinator) g.add_node("proposer", n.proposer) g.add_node("critic", n.critic) g.add_node("ask_human", n.ask_human) g.add_node("summarize", n.summarize) g.add_edge(START, "coordinator") g.add_conditional_edges("coordinator", route_after_coordinator, ["proposer", "critic", "ask_human", "summarize"]) for node in ("proposer", "critic", "ask_human"): g.add_edge(node, "coordinator") g.add_edge("summarize", END) return g.compile(checkpointer=checkpointer, store=store) ``` ```python # 文件:agent-mini/src/agent_mini/fakes.py import json from langchain_core.messages import AIMessage, BaseMessage from agent_mini.llm import FakeChatModel ROLE_MARKERS = {"协调者": "coordinator", "提议者": "proposer", "评审者": "critic", "总结者": "summarizer", "记忆整理": "memory"} def role_of(messages: list[BaseMessage]) -> str: system = str(messages[0].content) return next(role for marker, role in ROLE_MARKERS.items() if marker in system) def decision(action: str, target: str | None = None, instruction: str = "请处理") -> str: return json.dumps({"action": action, "target": target, "instruction": instruction, "reason": "测试"}) def scripted_model(**queues: list) -> FakeChatModel: """每个角色一个回答队列;元素是字符串或 AIMessage(可以带 tool_calls)。队列用完时重复最后一个。""" q = {k: list(v) for k, v in queues.items()} def respond(messages: list[BaseMessage]) -> AIMessage: items = q[role_of(messages)] item = items.pop(0) if len(items) > 1 else items[0] return item if isinstance(item, AIMessage) else AIMessage(content=item) return FakeChatModel(responder=respond) def happy_path_model() -> FakeChatModel: """提议者先调用计算器 → 评审要求修改 → 再提议 → 评审通过。""" return scripted_model( coordinator=[decision("dispatch", "proposer"), decision("dispatch", "critic"), decision("dispatch", "proposer"), decision("dispatch", "critic")], proposer=[AIMessage(content="", tool_calls=[{"name": "calculator", "args": {"expression": "5*2000"}, "id": "t1"}]), "方案一:每月预算 10000 元", "方案二:加上缓存一致性方案"], critic=["没考虑一致性\nVERDICT: REVISE", "可以\nVERDICT: APPROVE"], summarizer=["结论:采用方案二"], memory=['{"memories": []}'], ) ``` ```python # 文件:agent-mini/tests/test_graph.py from agent_mini.graph.build import build_graph from agent_mini.tools import LOCAL_TOOLS from agent_mini.fakes import decision, happy_path_model, scripted_model async def test_consensus_with_tool_call(): graph = build_graph(happy_path_model(), LOCAL_TOOLS) out = await graph.ainvoke({"question": "5 人团队怎么选技术栈", "max_rounds": 5, "round": 0}) assert out["end_reason"] == "consensus" and out["round"] == 2 assert out["turns"][0]["tools"] == ["calculator"] assert [t["role"] for t in out["turns"]] == ["proposer", "critic", "proposer", "critic"] assert out["final_answer"] == "结论:采用方案二" async def test_max_rounds_and_stream_order(): model = scripted_model(coordinator=[decision("dispatch", "proposer"), decision("dispatch", "critic")] * 2, proposer=["方案"], critic=["VERDICT: REVISE"], summarizer=["到达上限"]) graph = build_graph(model) nodes = [] async for update in graph.astream({"question": "q", "max_rounds": 2, "round": 0}, stream_mode="updates"): nodes.extend(update.keys()) assert nodes[nodes.index("summarize") - 1] == "coordinator" # 最后由协调者(命中轮数上限)转到总结 assert nodes.count("proposer") == 2 async def test_hard_stop_skips_model_call(): model = scripted_model(coordinator=[decision("dispatch", "proposer")], proposer=["方案"], critic=["好\nVERDICT: APPROVE"], summarizer=["结论"]) graph = build_graph(model) await graph.ainvoke({"question": "q", "max_rounds": 1, "round": 0}) # 协调者 1 次(派给提议者)+ 提议者 1 次 + 总结 1 次;第二次进入协调者时命中“轮数上限”,没有调用模型 assert model.calls == 3 def test_mermaid(): text = build_graph(happy_path_model()).get_graph().draw_mermaid() assert "coordinator" in text and "summarize" in text ``` ```python # 文件:agent-mini/tests/__init__.py ``` **易错点 / 面试常问(补充)** - 易错点:测试里用脚本化的假模型,按角色给出固定回复,这样图的每条路径都能稳定测试,不花钱、不受模型随机性影响。 - 面试常问:Agent 怎么做自动化测试(假模型测流程和路由、评测集测效果、真实模型只在少量冒烟测试里用)。 ### 要建的文件 - `graph/build.py`、`fakes.py`(放在 `src` 里,命令行的 `--fake` 演示也要用)、`tests/__init__.py`、`tests/test_graph.py` ### 运行方式 ```bash uv run pytest tests/test_graph.py -v ``` ### 完成标准 - 测试通过;能解释 `test_hard_stop_skips_model_call` 为什么是 3 次调用。 --- ## 第 5 天:MCP 工具接入 LangGraph + 命令行 ### 学习内容 - 说明:上周的 MCP 服务通过 `mcp_tools_for_langchain` 变成 LangChain 工具,提议者就能“查知识库”。命令行提供两个子命令:导出图结构、流式运行(打印每个节点做了什么)。 - 案例: ```python # 文件:agent-mini/tests/test_lc_tools.py import httpx from langchain_core.messages import AIMessage from mcp import Client import mcp_servers.knowledge_server as ks from agent_mini.graph.build import build_graph from agent_mini.tools import mcp_tools_for_langchain from agent_mini.fakes import decision, scripted_model async def test_proposer_uses_mcp_tool(monkeypatch): monkeypatch.setattr(ks, "RAG_TRANSPORT", httpx.MockTransport(lambda r: httpx.Response(200, json={ "text": "团队熟悉 Vue 和 Go [1]", "citations": [{"n": 1, "filename": "team.md", "heading_path": "技术栈"}]}))) async with Client(ks.mcp) as client: tools = await mcp_tools_for_langchain(client, allow={"search_knowledge"}) model = scripted_model( coordinator=[decision("dispatch", "proposer"), decision("finish")], proposer=[AIMessage(content="", tool_calls=[{"name": "search_knowledge", "args": {"query": "团队技术栈"}, "id": "k1"}]), "根据知识库:前端 Vue、后端 Go"], summarizer=["结论:Vue + Go"], ) out = await build_graph(model, tools).ainvoke({"question": "选技术栈", "max_rounds": 3, "round": 0}) assert out["turns"][0]["tools"] == ["search_knowledge"] assert out["end_reason"] == "coordinator_finish" ``` ```python # 文件:agent-mini/src/agent_mini/cli.py import argparse import asyncio import json from agent_mini.graph.build import build_graph from agent_mini.tools import LOCAL_TOOLS def make_model(fake: bool): if fake: from agent_mini.fakes import happy_path_model # 演示用假模型 return happy_path_model() from agent_mini.llm import build_chat_model return build_chat_model() async def run(question: str, fake: bool, max_rounds: int) -> None: graph = build_graph(make_model(fake), LOCAL_TOOLS) async for update in graph.astream({"question": question, "max_rounds": max_rounds, "round": 0}, stream_mode="updates"): for node, data in update.items(): print(f"== {node}") print(json.dumps(data, ensure_ascii=False, indent=2)[:800]) def main() -> None: p = argparse.ArgumentParser(prog="agent-mini") sub = p.add_subparsers(dest="cmd", required=True) sub.add_parser("graph") r = sub.add_parser("run") r.add_argument("question") r.add_argument("--fake", action="store_true", help="使用假模型(不需要密钥)") r.add_argument("--max-rounds", type=int, default=3) args = p.parse_args() if args.cmd == "graph": print(build_graph(make_model(True)).get_graph().draw_mermaid()) else: asyncio.run(run(args.question, args.fake, args.max_rounds)) if __name__ == "__main__": # 不自动运行 main() ``` **易错点 / 面试常问(补充)** - 易错点:把 MCP 工具转成 LangChain 工具时,参数 Schema 要原样保留,否则模型生成的参数会和服务端要求对不上。 - 易错点:本项目用自己写的桥接;以后换成官方适配(`langchain-mcp-adapters` 或 `langchain` 里的 MCP 支持)时,这些包更新快,接口以官方文档为准:https://docs.langchain.com/oss/python/langchain/mcp 。 - 面试常问:工具多了以后怎么办(按场景挑选少量工具、工具描述写清楚、对有副作用的工具要求确认)。 ### 要建的文件 - `tests/test_lc_tools.py`、`cli.py`;`pyproject.toml` 里加 `[project.scripts] agent-mini = "agent_mini.cli:main"` ### 运行方式 ```bash uv run pytest -v uv run agent-mini graph > docs/graph.mmd uv run agent-mini run "5 人创业团队做内部工具该选什么技术栈" --fake ``` ### 完成标准 - 测试通过;图结构导出到 `docs/graph.mmd` 并贴进 `docs/architecture.md`。 --- ## 第 6 天:真实模型试跑和调提示词 ### 学习内容 - 说明:用真实模型跑 5 个不同题目,观察:协调者是否按格式输出、评审是否太容易通过(总是第一轮就 APPROVE)、提议者是否乱调工具、平均轮数和耗时。每次改提示词记一行到 `docs/prompt-changelog.md`,第 13 周评测时会用到。 - 案例:`docs/prompt-changelog.md` 的格式 ```markdown | 日期 | 改了什么 | 为什么 | 观察到的变化(以实际运行为准) | | --- | --- | --- | --- | | 2026-xx-xx | 评审者提示词加“至少指出一个风险” | 评审总是第一轮就通过 | 平均轮数从 x 变成 y | ``` ### 要建的文件 - `docs/samples/langgraph-*.md`(真实运行记录,检查无敏感信息)、`docs/prompt-changelog.md` ### 运行方式 ```bash export LLM_BASE_URL=... LLM_API_KEY=... CHAT_MODEL=... # 写在 .env,不提交 uv run agent-mini run "给读书 App 设计会员规则" ``` ### 完成标准 - 5 个题目都跑完;changelog 至少 2 条。 --- ## 第 7 天:对照文档 + M2 验收 ### 学习内容 - 说明:补完 `docs/handwritten-vs-langgraph.md` 的另一半——手写版每一部分在 LangGraph 里对应什么,框架帮你做了什么,又有哪些事框架不管、还得自己做。 - 案例: ```markdown | 关注点 | 手写版 | LangGraph 版 | 框架没管、要自己做的 | | --- | --- | --- | --- | | 循环 / 跳转 | while + if | 条件边 | 结束条件(硬规则)仍要自己写 | | 状态合并 | 手动 append | reducer | — | | 持久化 | JSON 文件 | checkpointer(下周) | 业务状态表、权限、审计 | | 工具调用 | 手写循环 | bind_tools + 节点内循环 / ToolNode | 参数白名单、副作用幂等 | | 中断问人 | 自己存 awaiting_input | interrupt + Command(resume) | 前端交互、超时处理 | | 防重复执行 | 进程内集合 | 无 | 租约(下周) | ``` ### 要建的文件 - `docs/handwritten-vs-langgraph.md` ### 运行方式 ```bash uv run ruff check . && uv run pyright src && uv run pytest -v ``` ### 完成标准(M2 验收) - [ ] LangGraph 版能跑完一次带工具调用的多轮讨论,结束条件正确 - [ ] 路由规则、图的主要路径都有测试(假模型,不需要密钥) - [ ] MCP 工具接入了 LangGraph - [ ] 能讲清 State、节点、条件边、reducer,以及和手写版的对应关系 - [ ] 在总纲进度表勾掉第 11 周