34 KiB
34 KiB
id, title, type, domain, stack, week, est_hours, order
| id | title | type | domain | stack | week | est_hours | order | |||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| plan.week11 | 第 11 周:项目三 M2——用 LangGraph 实现多 Agent 编排 | week | plan |
|
|
12 | 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 )
uv add langgraph langchain-core
uv add langchain langchain-openai # 接真实模型时才需要(init_chat_model)
本周新增文件
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:
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(上面的代码,不进最终项目)
运行方式
uv run python scratch/langgraph_basics.py
完成标准
- 能说出 State、节点、条件边、reducer 和手写版的对应关系;能把 mermaid 图贴进 Markdown 看到图。
第 2 天:State、结构化决定、路由规则
学习内容
- 说明:
- State 里放什么:问题、轮数、发言记录(追加型)、最近一次评审结论、协调者的决定、人的回答(追加型)、最终结论、结束原因、长期记忆(下周)。
- 路由函数是纯函数:输入 State,输出下一个节点名——最容易测试,也是“结束条件写在代码里”的落地点。硬规则顺序:评审已通过 → 总结;达到最大轮数 → 总结;否则按协调者决定。
- 案例:
# 文件: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"
# 文件:agent-mini/src/agent_mini/graph/__init__.py
# 文件: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
# 文件: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"
# 文件: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
运行方式
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()(下周配合检查点使用)。 - 总结者:生成结论和结束原因。
- 上下文工程:每个节点只给模型需要的东西——问题、最近几条发言、本次指令、相关记忆,而不是全部历史。
- 模型:真实模型用 LangChain 的
- 案例:
# 文件: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,
)
# 文件: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
# 文件: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}"
# 文件: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"逐个节点看执行过程。 - 案例:
# 文件: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)
# 文件: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": []}'],
)
# 文件: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
# 文件:agent-mini/tests/__init__.py
易错点 / 面试常问(补充)
- 易错点:测试里用脚本化的假模型,按角色给出固定回复,这样图的每条路径都能稳定测试,不花钱、不受模型随机性影响。
- 面试常问:Agent 怎么做自动化测试(假模型测流程和路由、评测集测效果、真实模型只在少量冒烟测试里用)。
要建的文件
graph/build.py、fakes.py(放在src里,命令行的--fake演示也要用)、tests/__init__.py、tests/test_graph.py
运行方式
uv run pytest tests/test_graph.py -v
完成标准
- 测试通过;能解释
test_hard_stop_skips_model_call为什么是 3 次调用。
第 5 天:MCP 工具接入 LangGraph + 命令行
学习内容
- 说明:上周的 MCP 服务通过
mcp_tools_for_langchain变成 LangChain 工具,提议者就能“查知识库”。命令行提供两个子命令:导出图结构、流式运行(打印每个节点做了什么)。 - 案例:
# 文件: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"
# 文件: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"
运行方式
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的格式
| 日期 | 改了什么 | 为什么 | 观察到的变化(以实际运行为准) |
| --- | --- | --- | --- |
| 2026-xx-xx | 评审者提示词加“至少指出一个风险” | 评审总是第一轮就通过 | 平均轮数从 x 变成 y |
要建的文件
docs/samples/langgraph-*.md(真实运行记录,检查无敏感信息)、docs/prompt-changelog.md
运行方式
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 里对应什么,框架帮你做了什么,又有哪些事框架不管、还得自己做。 - 案例:
| 关注点 | 手写版 | LangGraph 版 | 框架没管、要自己做的 |
| --- | --- | --- | --- |
| 循环 / 跳转 | while + if | 条件边 | 结束条件(硬规则)仍要自己写 |
| 状态合并 | 手动 append | reducer | — |
| 持久化 | JSON 文件 | checkpointer(下周) | 业务状态表、权限、审计 |
| 工具调用 | 手写循环 | bind_tools + 节点内循环 / ToolNode | 参数白名单、副作用幂等 |
| 中断问人 | 自己存 awaiting_input | interrupt + Command(resume) | 前端交互、超时处理 |
| 防重复执行 | 进程内集合 | 无 | 租约(下周) |
要建的文件
docs/handwritten-vs-langgraph.md
运行方式
uv run ruff check . && uv run pyright src && uv run pytest -v
完成标准(M2 验收)
- LangGraph 版能跑完一次带工具调用的多轮讨论,结束条件正确
- 路由规则、图的主要路径都有测试(假模型,不需要密钥)
- MCP 工具接入了 LangGraph
- 能讲清 State、节点、条件边、reducer,以及和手写版的对应关系
- 在总纲进度表勾掉第 11 周
本周相关专题
本节由
tools/gen_week_topics.py根据专题文件的week字段自动生成,请勿手改;想调整就修改对应专题文件的 frontmatter 后重新生成。
- AI 编程练习(每周):本周至少一个功能用 AI 编程工具(Cursor / Claude Code / Codex 等)完成,按 AI 编程:工作流 写清需求和规则,按 验证与证据 保存测试结果和会话记录(脱敏后),第 15 周整理成“AI 编程 + 验证方法”的应聘案例。
| 技术栈 | 课程 / 场景 / 面试题 |
|---|---|
| Agent 设计 | 课程:Agent 推理模式;课程:上下文工程、Skills、多 Agent 和 A2A;场景:运营报表 Agent;面试题 |
| LangChain | 课程:LangChain 1.x;场景:工单自动分类服务;面试题 |
| LangGraph | 课程:LangGraph 状态图;面试题 |
| MCP 模型上下文协议 | 课程:MCP 协议和服务开发;场景:企业内部 MCP 服务;面试题 |
| 提示词与上下文工程 | 课程:提示词注入和防护 |
跨技术栈场景:审批流 + 人工介入的 Agent、工单系统