Chapter 03 · 上手实操
上手实操:从「能讲」到「能跑」
上一章讲了三轴的取舍与代价——这章把一个多 agent 系统从「能讲」做成「能跑」。LangGraph 1.0 起步,三阶推进:worked(读完整代码)→ partial(补关键决策)→ open(自己组装)。
本章你要建立的心智模型
- 把「StateGraph + 节点即 agent + 条件边」翻译成可运行代码——供学习用,未本机执行
- 一个 supervisor 派两个并行 worker、再汇总的最小调研助手,每行映射回 01 章三轴取值
- 三个关键决策点亲手补:worker 间走 shared-state 还是消息、worker 要不要隔离上下文、终止条件怎么定
- 把 supervisor 拓扑改成 swarm-handoff 去中心拓扑——同一需求换一种控制权流向
这一章全是代码。读得很顺不等于会写——读代码调用的是识别,写代码调用的是生成,二者不是一回事。每个 worked example 读完,合上页面,凭记忆把 graph 的节点和边重画一遍;画不出来,就是只「看懂」了没「学会」。partial 的留白请先想 30 秒再展开 <details>——直接看答案等于把决策点当成又一段要读的文字。
3.1环境准备
整章用 LangGraph 搭多 agent,主 LLM 用 Claude。最小依赖三个:langgraph(编排核心)、langchain-anthropic(Claude 接入)、langchain-core(@tool 等基础件)。版本锁到 1.0 系列,避免 0.x 的旧 API 混进来。
# Python 3.11+ ;演示用,未本机执行
python -m venv .venv && source .venv/bin/activate
pip install \
"langgraph>=1.0,<1.1" \
"langchain-anthropic>=0.3" \
"langchain-core>=0.3"
# LLM provider 凭证(换 OpenAI 则设 OPENAI_API_KEY 并装 langchain-openai)
export ANTHROPIC_API_KEY="sk-ant-..."
# 自检:导入不报错 = 装对了
python -c "import langgraph, langchain_anthropic; print('ok')"
装到 0.x 旧版是头号失败源:from langgraph.graph import StateGraph 在 0.x 和 1.0 行为有差异,混装会报莫名其妙的导入错。永远用上面带版本上界的写法(<1.1),别裸装 pip install langgraph。第二个常见失败:忘了 export ANTHROPIC_API_KEY,invoke 时才在深处抛 401——先跑那行 python -c 自检,导入通过再写业务。第三个:在系统 Python 里全局装,污染别的项目;务必先建 venv。
3.2Worked · supervisor + 两个并行 worker
需求:做一个最小调研助手。用户给一个主题,supervisor 同时派两个 worker——search_worker 去找资料、summarize_worker 把主题拆成提纲,两者并行跑互不等待;都返回后,supervisor 把两份结果汇总成一段回答。这是 01 章 supervisor 拓扑(§1.2)+ orchestrator 协调(§1.3)+ shared-state 通信(§1.4)的最小落地。
Send 同时派出,真并行不是串行两次;它们各写共享 state 的不同字段(search_result / outline),所以不冲突,汇总节点等两边都到齐再读。# 演示用,未本机执行 —— LangGraph 1.0 / langchain-anthropic 0.3
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.types import Send
from langchain_anthropic import ChatAnthropic
from langchain_core.tools import tool
llm = ChatAnthropic(model="claude-sonnet-4-5", temperature=0)
# ── 共享 state:worker 各写不同字段,互不覆盖 ──
class ResearchState(TypedDict):
topic: str # 用户输入
search_result: str # search_worker 写
outline: str # summarize_worker 写
answer: str # 汇总节点写
@tool
def web_search(query: str) -> str:
"""查资料(演示桩;生产接 Tavily / Bing 等)。"""
return f"[资料] 关于 '{query}' 的三条要点 ..."
# ── worker 1:search_worker(节点即 agent)──
def search_worker(state: ResearchState) -> dict:
hits = web_search.invoke({"query": state["topic"]})
note = llm.invoke(
f"用三句话提炼这份资料的关键事实:\n{hits}"
).content
return {"search_result": note} # 只写自己那格
# ── worker 2:summarize_worker(与上一个并行)──
def summarize_worker(state: ResearchState) -> dict:
outline = llm.invoke(
f"为主题「{state['topic']}」列一个 3 点提纲,不要展开。"
).content
return {"outline": outline} # 写另一格,不碰 search_result
# ── supervisor 的派发:一次性 fan-out 两个 worker(并行)──
def dispatch(state: ResearchState):
return [
Send("search_worker", state),
Send("summarize_worker", state),
]
# ── 汇总节点:两个 worker 都到齐后才跑,读两格、合成回答 ──
def aggregate(state: ResearchState) -> dict:
merged = llm.invoke(
"把以下资料与提纲合成一段连贯回答:\n\n"
f"资料:{state['search_result']}\n\n提纲:{state['outline']}"
).content
return {"answer": merged}
# ── 装配 graph ──
g = StateGraph(ResearchState)
g.add_node("search_worker", search_worker)
g.add_node("summarize_worker", summarize_worker)
g.add_node("aggregate", aggregate)
g.add_conditional_edges(START, dispatch, ["search_worker", "summarize_worker"])
g.add_edge("search_worker", "aggregate") # 两个 worker 都连向汇总
g.add_edge("summarize_worker", "aggregate") # aggregate 等两边都到齐
g.add_edge("aggregate", END)
app = g.compile()
result = app.invoke({"topic": "MCP 协议是什么"})
print(result["answer"])
ResearchState 对应 01 章通信 = shared-state(§1.4):两个 worker 不直接对话,而是各写共享 state 的不同字段(search_result / outline)。写不同格 = 天然无冲突,不需要 reducer。
dispatch + Send 对应拓扑 = supervisor(§1.2)+ 协调 = orchestrator 派发(§1.3)。Send 一次返回两个目标 = fan-out 并行;换成两条普通 add_edge 串起来就成了串行,慢一倍。
aggregate 的两条入边对应终止 = 结构汇合:LangGraph 的 super-step 语义保证 aggregate 等两个上游 worker 都完成才触发——不需要手写「等两个都回来了吗」的轮询逻辑。
每个 worker 函数体是一个独立 agent:它只看自己拿到的 state 切片、调自己的 LLM、写自己的字段。这就是 02 章上下文隔离(§2.4)的最朴素形态——worker 之间不共享中间推理。
这里之所以不冲突,是因为两个 worker 写的是不同字段。一旦两个并行 worker 写同一个字段,LangGraph 默认抛 INVALID_CONCURRENT_GRAPH_UPDATE——这正是 3.4 要用 Annotated[list, operator.add] reducer 解决的问题。04 章会展开这个失败模式与三种修法。
调研助手里 search_worker 和 summarize_worker 全程不通信,各干各的、最后汇总。这不是偷懒——是因为两个子任务互相独立(查资料不依赖提纲、提纲不依赖资料)。独立子任务做并行 fan-out 最省事;只有当后一步真的要读前一步的产出时,才需要让它们串起来或共享状态。这条「独立才并行」的边界,是 04 章「fan-out 适合 read 不适合 write」失败模式的根。
3.3Partial · 补三个关键决策点
把 3.2 的调研助手改成可配置版——三处真正影响行为的决策抠成 TODO。每个 TODO 先合上页面想 30 秒该怎么选、代价是什么,再展开对照。三个决策分别落在通信、上下文、终止三根轴上。
# 演示用,未本机执行 —— 在 3.2 基础上抠掉 3 个决策
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
from langgraph.types import Send
from langchain_anthropic import ChatAnthropic
llm = ChatAnthropic(model="claude-sonnet-4-5", temperature=0)
# ─────────────────────────────────────────────
# 决策 (a) · worker 间走 shared-state 还是消息传递?
# 现在两个 worker 写 state 不同字段。若改成「summarize 要先读
# search 的结果再出提纲」,state 该怎么改?字段怎么聚合?
# ─────────────────────────────────────────────
class ResearchState(TypedDict):
topic: str
# findings: Annotated[list[str], ???] # 多 worker 写同一字段时填什么 reducer?
answer: str
# ─────────────────────────────────────────────
# 决策 (b) · worker 要不要隔离上下文?
# 现在每个 worker 只拿到 state、各调各的 LLM(隔离)。
# 若让两个 worker 共享同一段对话历史,会省 token 还是更贵?质量呢?
# ─────────────────────────────────────────────
def search_worker(state: ResearchState) -> dict:
# context_passed_in = ??? # 只传 topic,还是把别的 worker 的中间推理也塞进来?
note = llm.invoke(f"提炼关于「{state['topic']}」的三条关键事实").content
return {"findings": [note]} # 配合 (a) 的 reducer
def summarize_worker(state: ResearchState) -> dict:
outline = llm.invoke(f"为「{state['topic']}」列 3 点提纲").content
return {"findings": [outline]}
def dispatch(state: ResearchState):
return [Send("search_worker", state), Send("summarize_worker", state)]
# ─────────────────────────────────────────────
# 决策 (c) · 终止条件怎么定?
# 两个 worker 都写完才汇总——「都写完」用什么判定?
# 靠 graph 结构汇合(结构信号),还是数 findings 长度(内容信号)?
# ─────────────────────────────────────────────
def aggregate(state: ResearchState) -> dict:
# if ???: # 收齐了吗?
merged = llm.invoke(
"把以下片段合成一段连贯回答:\n" + "\n\n".join(state["findings"])
).content
return {"answer": merged}
g = StateGraph(ResearchState)
g.add_node("search_worker", search_worker)
g.add_node("summarize_worker", summarize_worker)
g.add_node("aggregate", aggregate)
g.add_conditional_edges(START, dispatch, ["search_worker", "summarize_worker"])
g.add_edge("search_worker", "aggregate")
g.add_edge("summarize_worker", "aggregate")
g.add_edge("aggregate", END)
app = g.compile()
决策 (a) 答案 · 通信:shared-state 还是消息
看子任务是否独立,不是非此即彼:
- 子任务独立(如 3.2 的查资料 vs 列提纲):用 shared-state,且各写不同字段,零冲突、零 reducer,最简单。
- 多 worker 写同一字段(如本 partial 的
findings):必须给 reducer,否则并行写抛INVALID_CONCURRENT_GRAPH_UPDATE。最常用的是Annotated[list[str], operator.add]——告诉 graph「多个写合并成 list 拼接」。 - 后一步依赖前一步产出(如 summarize 要先读 search):这就不是并行了。改成串行边
search_worker → summarize_worker,或让 supervisor 分两轮派发。消息传递(一个 worker 的输出当下一个的输入)本质就是这种串行依赖。
填空:findings: Annotated[list[str], operator.add]。锚 01 章通信轴(§1.4)+ 02 章拓扑权衡(§2.2)。
class ResearchState(TypedDict):
topic: str
findings: Annotated[list[str], operator.add] # 并行写同字段 → 拼接
answer: str
决策 (b) 答案 · 上下文要不要隔离
默认隔离,每个 worker 只拿到它需要的 state 切片,不把别的 worker 的中间推理塞进来。理由:
- 隔离(推荐):worker 的上下文只装自己的子任务。token 账最省(02 章§2.1已算过多 agent 约 15× token,省的就是这部分),且互不污染——一个 worker 跑偏不会带歪另一个。
- 共享同一段对话历史:表面省事,实则更贵——每个 worker 都要吞下别人的全部上下文,token 随 worker 数平方级涨;还容易互相干扰。只在 worker 真的要协同推理时才用。
填空:context_passed_in = {"topic": state["topic"]}——只传子任务必需的,不传别人的推理。锚 02 章上下文隔离(§2.4)。
决策 (c) 答案 · 终止:结构信号还是内容信号
优先用 graph 结构汇合(结构信号),别去数 findings 长度:
- 结构信号(推荐):
aggregate的两条入边(search_worker → aggregate、summarize_worker → aggregate)让 LangGraph 的 super-step 语义保证两个上游都完成才触发汇总。不写一行「收齐了吗」的判断,框架替你管。 - 内容信号(
len(findings) == 2):脆。worker 数一变就要改这个魔数;某个 worker 异常没写就永远凑不齐、死等。内容计数适合「不定数量 worker」的少数场景,且必须配 max_round 兜底。
填空:删掉 if ???,直接信 graph 结构——aggregate 被触发时 findings 必然齐。锚 01 章协调机制(§1.3)。
决策 (c) 若选了内容信号(数 findings 长度),又有 worker 因异常没写——汇总条件永远不满足,整个 graph 卡死。这是 04 章「无兜底终止」失败模式的一种。结构信号天然免疫这点,但若业务上必须用内容信号,务必叠加 max_round / 超时兜底。
app.get_graph().draw_mermaid() 打印 graph 结构,确认 START 同时连向两个 worker(而不是串成一条线)。再在每个 worker 里打一行带时间戳的日志,两条日志时间几乎重合 = 真并行;一前一后差出整个 LLM 调用耗时 = 退化成串行了。
3.4Open · 改成 swarm-handoff 去中心拓扑
前面是 supervisor 居中派发(中心化)。开放练习换一种拓扑:swarm-handoff——没有固定的中心 supervisor,agent 之间直接把控制权交给对方(handoff)。需求:一个客服流,triage agent 先判类型,账单问题 handoff 给 billing、技术问题 handoff 给 tech;被交接的 agent 处理完直接出最终答复,不退回 triage。
这用到 01 章拓扑 = 去中心 swarm(§1.2)+ 协调 = handoff(§1.3),以及 02 章拓扑权衡(§2.2)里「中心化 vs 去中心」那一格。先自己写完,再对照参考实现。
参考实现 · swarm-handoff(写完自己的版本再展开)
LangGraph 里 handoff 用 Command(goto=...) 表达:节点返回一个 Command,goto 指定把控制权交给哪个节点。跨子图交接时用 graph=Command.PARENT。
# 演示用,未本机执行 —— swarm-handoff with Command(goto=...)
from typing import TypedDict, Literal
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command
from langchain_anthropic import ChatAnthropic
llm = ChatAnthropic(model="claude-sonnet-4-5", temperature=0)
class CSState(TypedDict):
question: str
answer: str
# triage:判类型,handoff 给对应 agent(控制权转移,不等返回)
def triage(state: CSState) -> Command[Literal["billing", "tech"]]:
kind = llm.invoke(
f"这条客服问题属于 billing 还是 tech,只回一个词:\n{state['question']}"
).content.strip().lower()
target = "billing" if "billing" in kind else "tech"
return Command(goto=target) # ← handoff:交出控制权
def billing(state: CSState) -> Command[Literal["__end__"]]:
reply = llm.invoke(f"作为账单客服,回答:{state['question']}").content
return Command(goto=END, update={"answer": reply}) # 自己收尾
def tech(state: CSState) -> Command[Literal["__end__"]]:
reply = llm.invoke(f"作为技术客服,回答:{state['question']}").content
return Command(goto=END, update={"answer": reply})
g = StateGraph(CSState)
g.add_node("triage", triage)
g.add_node("billing", billing)
g.add_node("tech", tech)
g.add_edge(START, "triage")
# 注意:billing / tech 不需要 add_edge 回 triage —— handoff 是单向转移
app = g.compile()
print(app.invoke({"question": "我的 Pro 订阅没刷出 API 配额"})["answer"])
关键决策说明:
- 为什么用
Command(goto=...)而不是 conditional_edges:handoff 的语义是「当前 agent 自己决定把控制权交给谁」,决策和转移在同一个节点内完成;conditional_edges 是「图的路由逻辑」决定下一步,决策在节点外。swarm 强调 agent 自治,所以放节点里更贴语义。Command还能同时updatestate,一步到位。 - 为什么 billing/tech 不连回 triage:这正是 handoff 与 supervisor delegation 的本质区别(锚 01 章§1.5)。delegation 是「派活后等结果,派发者继续在场」;handoff 是「控制权完全交出,原 agent 退出」。被交接者直接走 END。
- 去中心的代价(锚 02 章§2.2):没有中心 supervisor 全局把关,handoff 链一长就难追踪「现在到底谁在处理、为什么走到这」。observability 变差、循环 handoff 风险上升。换来的是每个 agent 自治、加新类型只需多一个 handoff 目标,不用改中心路由。
去中心 swarm 最危险的失败模式:A handoff 给 B、B 觉得不归自己又 handoff 回 A,两个 agent 互踢皮球无限循环。supervisor 拓扑因为有中心仲裁不易出这问题,swarm 必须自己设跳数上限或循环检测。04 章会给具体兜底写法。
§本章 self-check
先合上代码,把答案写下来,再展开对照。直接展开等于把这一节当又读了一遍。
- 3.2 的两个 worker 为什么能并行写 state 不冲突?什么情况下并行写会抛
INVALID_CONCURRENT_GRAPH_UPDATE,怎么修? - 3.2 用
Send派发两个 worker。如果改成两条普通add_edge把它们串起来,行为会有什么变化?锚 01 章哪根轴? - 3.3 决策 (c) 里,「结构信号」终止比「内容信号」(数 findings 长度)好在哪?内容信号在什么情况下会让 graph 卡死?
- 3.4 的 swarm-handoff 里,
billing处理完为什么不连回triage?这体现了 handoff 与 supervisor delegation 的什么本质区别? - 同一个客服需求,supervisor 拓扑和 swarm-handoff 拓扑各自的主要代价是什么?要做 observability(追踪每一步谁在处理)时该选哪个?
答案(先做完再展开)
- 因为两个 worker 写的是 state 的不同字段(
search_result/outline),没有写写冲突。一旦两个并行 worker 写同一字段,LangGraph 默认假设单写者,会抛INVALID_CONCURRENT_GRAPH_UPDATE。修法:给该字段加 reducer,最常用Annotated[list[str], operator.add],告诉 graph 把多个写合并成 list 拼接。 Send一次派出两个目标 = 并行(super-step 内同时跑);两条普通add_edge串起来 = 串行,总耗时翻倍且第二个 worker 还会看到第一个的写入。锚 01 章拓扑 / 协调轴(§1.2、§1.3)——并行 fan-out 是 supervisor 派发独立子任务的标准形态。- 结构信号靠 graph 的边和 super-step 语义保证两个上游都完成才触发汇总,不写一行判断、worker 数变了也不用改代码。内容信号(
len(findings)==2)写死了数量,且某个 worker 异常没写时永远凑不齐 → 死等卡死。内容信号只适合不定数量 worker,且必须配 max_round / 超时兜底。 - 因为 handoff 是控制权完全转移——triage 把控制权交给 billing 后就退出,由 billing 自己收尾到 END;连回 triage 反而制造循环。这区别于 supervisor 的 delegation:supervisor 派活后继续在场等 worker 返回、再决定下一步。锚 01 章单 vs 多分界(§1.5)。
- supervisor 拓扑的代价:中心节点是瓶颈与单点,所有决策过中心。swarm-handoff 的代价:没有全局视图,handoff 链一长难追踪、易循环 handoff。要做 observability 选 supervisor——中心节点天然是「现在谁在处理」的单一观测点;swarm 的控制权散在各 agent 间,追踪成本高。锚 02 章拓扑权衡(§2.2)。
给 3.2 的调研助手加一个 evaluator agent 复核
现在调研助手是 supervisor → 两个并行 worker → 汇总。加一个 evaluator agent:在 aggregate 出答案后,让 evaluator 判断答案是否够好;不够好就让两个 worker 带着改进意见重跑一轮。要求:
- evaluator 看
answer,返回approve或revise(含改进意见) revise时跳回 dispatch 重派两个 worker(带上改进意见)- 设
max_round <= 3防止 evaluator 和 worker 互相挑刺无限循环 - evaluator 该看两个 worker 的完整中间推理,还是只看
answer?为什么?
提示(卡住再展开)
结构:加 evaluator 节点 + state 加 critique: str 和 round: int。aggregate → evaluator,再 add_conditional_edges("evaluator", route),route 看 evaluator 决定 → 回 dispatch 或 END。worker 函数读 state.get("critique") 决定是否带意见重跑。route 里先判 state["round"] >= 3 直接 END 兜底(防 reflection 无限循环——这是 04 章会讲的失败模式)。evaluator 只看 answer 就够:它评的是结果质量不是过程,看完整中间推理会让上下文膨胀、token 暴涨(锚 02 章上下文隔离 §2.4 与 token 账 §2.1)。