Day 03 / 共 20 天 · 第 1 周 建立心智

LangGraph 零基础入门

框架最底层的 L1 就是 LangGraph。昨天我们把整栋楼通水通电、按了第一户门铃;今天钻进"一户人家内部",看它怎么一道工序接一道工序把活干完。今天全程读真源码:先读仓库 docs/learn-langgraph/ 的 4 个能跑 demo,最后读旗舰 sre-rca 的真实 state.py + builder.py,把 6 个抽象焊死在生产代码上。

📍 你在 20 天里的位置(第 1 周:建立心智)
D01 全景架构 D02 跑起来 D03 LangGraph D04 Supervisor D05 Agent 解剖
💡 用一个类比先兜住今天(延续「盖楼/装修」世界观) 一个 Agent 内部,就像装修一户房子的施工流水线LangGraph 图 = 施工蓝图;Node(节点) = 一道工序/工位(分诊、取证、验收…);Edge(边) = 工序之间"先做谁"的箭头;State(状态) = 工地正中的一块公告板,每个工种上去读进度、写成果;条件边 = 质检不合格就返工;Checkpointer(存档器) = 每完成一道工序就拍照存档,断了能接着干。记住"公告板 + 工序流水线",下面 6 个抽象就都好懂了。
L01

为什么用"图"来跑 Agent

一个稍复杂的 Agent 不是"调一次大模型就完事",它有很多步骤:先分诊、再并行取证、再综合、再质检、质检不过还要打回重做……这些步骤有先后、有分支、有循环。

LangGraph 的思路:把这些步骤画成一张"流程图"(graph)来跑。 图里每个方框是一个"节点(Node)"= 一个步骤;方框之间的箭头是"边(Edge)"= 执行顺序。所有节点共享一块叫 State 的黑板来传数据。

下面这张图会自己动起来——高亮会依次点亮每个节点,模拟一次真实执行(这正是 Day 14 要精读的 sre-rca Agent 的简化版;L08 你会看到它的真源码):

START triage分诊 取证并行专家 synthesizer综合 critic质检 writeback沉淀 END
为什么不直接写 if/else 一路调下来? 因为"图"这种结构天生支持并行(4 个专家同时跑)、循环(质检不过打回重做)、断点续跑(跑一半崩了能接着跑)、还能自动画出拓扑图。这些用裸 if/else 写会非常痛苦。L08 的真 builder 你会看到:这些复杂控制流全都变成"加一条边"的声明。
L02

六大核心抽象

LangGraph 你只要先掌握这 6 个概念,就够读懂本框架 90% 的图代码了:

📋

State 状态

全图共享的"黑板",通常是一个带类型的字典(TypedDict)。

Node 节点

一个处理步骤,本质是个函数:读 state,返回要写回 state 的部分。

➡️

Edge 边

节点间的箭头,决定执行顺序。有普通边和条件边两种。

🔀

Conditional Edge

条件边:根据 state 的值决定下一步走哪个节点(分支/循环)。

Reducer 归约器

多个节点同时写同一个字段时,"怎么合并"的规则(覆盖 or 累加)。

💾

Checkpointer

存档器:把每一步的 state 存下来,支持中断恢复、多会话隔离。

仓库里 docs/learn-langgraph/ 提供了 4 个能直接跑的最小 demo(默认用 MockLLM,不配 key 也能跑)。下面 L03–L07 就一个个读它们的真源码

L03

State:全图共享的黑板(读 demo1 真源码)

State 定义成一个 TypedDict。别看抽象的例子——直接看 demo1 里真实的 State 定义(它刻意和生产 sre_rca/state.py 同款 schema,方便你 L08 对照):

真源码 · docs/learn-langgraph/01_state_node_edge.py:25-37
class RcaState(TypedDict):
    # 输入
    trace_id: str
    user_question: str | None
    # 4 SP 各占独立字段(零冲突)—— 本 demo 只填 trace_finding
    trace_finding: dict | None
    metric_finding: dict | None
    deploy_finding: dict | None
    log_finding: dict | None
    # 累加 Reducer(解决并发,本 demo 还用不上,放着 demo 03 用)
    retry_count: Annotated[int, add]
    # 最终输出
    final_summary: str | None
  • trace_id / user_question:输入字段,进图时由调用方塞进去。
  • 4 个 *_finding 字段:注释点破了本框架最重要的设计——"4 SP 各占独立字段(零冲突)"。4 个专家并行时,各写各的字段,天生不打架(L06 展开)。
  • retry_count: Annotated[int, add]:这是唯一一个带 Reducer 的字段。Annotated[int, add] 意思是"类型是 int,但多方写入时用 operator.add(相加)合并"。demo1 用不上,故意先摆在这,demo3 才启用。

节点函数的约定极简——读整个 state,只返回"我改了哪些字段"的 dict,剩下的 LangGraph 帮你合并。看 demo1 第一个节点:

真源码 · 01_state_node_edge.py:58-64 fetch_trace_node
async def fetch_trace_node(state: RcaState) -> dict:
    """节点 A:根据 trace_id 拉 trace 数据(这里 mock 一份)。"""
    trace_id = state["trace_id"]
    # 真实项目中这里会 call SkyWalking GraphQL
    raw = {"trace_id": trace_id, "spans": 42, "max_p99_ms": 8200}
    return {"trace_finding": {"raw": raw}}   # ← 只返回自己负责的字段
💡 本质:State 就是工地那块「公告板」每个工种(节点)不用互相打电话交接——统一上公告板读、写。节点只写自己改的那几个字段(像在板上补一行),LangGraph 负责把这行贴回公告板。节点之间彻底解耦:谁都不用知道别人是谁,只认公告板。
📝 用真实值走一次合并(demo1 的 initial state) 初始(01:113-122):{"trace_id":"abc123", "trace_finding":None, ...}
fetch_trace_node 读到 trace_id="abc123",返回局部字典 {"trace_finding":{"raw":{"spans":42,"max_p99_ms":8200}}}
→ LangGraph 合并回公告板 → trace_finding 从 None 变成那个 dict,而 trace_id、其它 finding 原样保留。
注意:节点没返回 trace_id,但它仍在——合并是"补写",不是"整块替换"。
🚧 边界/易错点:节点若返回"整块 state"会盖掉别人的字段 约定是"只返回你改的字段"。如果某个节点图省事,把读进来的整个 state 改改又原样 return state,在默认覆盖语义下没问题;但一旦涉及并行 + Reducer,返回多余字段就可能触发意外合并、或覆盖别的节点刚写的值。守住"只回写增量"这条纪律,并行才安全。
L04

Node 与 Edge — demo1:串一条线

有了 State 和节点函数,就用 StateGraph 把它们摆好、连起来。看 demo1 的建图函数(真源码,不是伪代码):

真源码 · 01_state_node_edge.py:91-104 build_graph
def build_graph():
    builder = StateGraph(RcaState)                 # 1. 以 State 类型建图

    builder.add_node("fetch_trace", fetch_trace_node)   # 2. 注册 3 个节点
    builder.add_node("analyze", analyze_node)
    builder.add_node("summarize", summarize_node)

    builder.add_edge(START, "fetch_trace")         # 3. 连边:START→A→B→C→END
    builder.add_edge("fetch_trace", "analyze")
    builder.add_edge("analyze", "summarize")
    builder.add_edge("summarize", END)

    return builder.compile()                       # 4. 编译成可执行图
  • StateGraph(RcaState):以 L03 那个 State 类型开一张空图。图知道"公告板长什么样"。
  • add_node("名字", 函数):把函数登记成一个工位,起个名字。
  • add_edge(A, B):连普通边"A 跑完一定走 B"。START / END 是内置的入口/出口特殊节点。
  • compile():把"蓝图"编译成能跑的图对象。之后 await graph.ainvoke(初始state) 就从 START 一路跑到 END,返回最终 state(demo1 用的是 ainvoke,异步版,本框架全异步)。
START fetch_trace analyze summarize END

把它当调试器单步走一遍——传入 {"trace_id":"abc123"},看公告板每步变成什么:

步骤此刻发生什么公告板 State 关键字段
START塞入初始 statetrace_finding: None
fetch_trace读 trace_id,回写 {trace_finding:{raw}}trace_finding.raw = {spans:42}
analyze读 raw 喂 LLM,回写 trace_finding(加 analysis)trace_finding.analysis = "..."
summarize综合,回写 final_summaryfinal_summary = "连接池打满..."
END停,返回整块 state← 这就是 ainvoke() 的返回值
🍼 一句话复述建图 = 摆好工位(add_node)+连好箭头(add_edge);跑图 = 从 START 沿箭头挨个工位干活,每个工位往公告板补一行,走到 END 把整块公告板端出来。
L05

条件边 — demo2:三态分支(读真 critic_router)

普通边是"死路"(A 之后一定走 B)。条件边让图能根据 state 的值"走不同的路"。这正是 Critic 质检的核心模式——PASS / FAIL 重试 / 触顶结束三态。看 demo2 的路由函数真源码:

真源码 · 02_conditional_edge.py:70-76 critic_router
def critic_router(state: RcaState) -> str:
    """与 app/graph/builder.py 的 critic_router 同款三态。"""
    if state.get("critique_passed"):
        return "writeback"                    # ① 过 → 去沉淀
    if state.get("retry_count", 0) >= 2:
        return "end"                          # ② 没过但触顶 → 结束(防死循环)
    return "synthesizer"                      # ③ 没过且没触顶 → 回去重做

路由函数只做一件事:看 state,返回一个字符串(下一步节点的名字)。再把它挂到图上:

真源码 · 02_conditional_edge.py:93-101 add_conditional_edges
builder.add_conditional_edges(
    "critic",                    # 从 critic 节点出发
    critic_router,               # 用这个函数决定去哪
    {
        "writeback": "writeback",
        "synthesizer": "synthesizer",   # ← 指回前面的节点 = 形成循环
        "end": END,
    },
)
critic
质检结果?
通过writeback(沉淀经验,结束)
不过 & 没超次数 → 回 synthesizer 重做
不过 & 超了 2 次END(诚实说"信息不足")
  • add_conditional_edges(源节点, 路由函数, 映射表):从 critic 出来后,先跑 critic_router 拿到一个字符串,再用第三个参数的映射表把字符串翻译成真正的目标节点。
  • "synthesizer": "synthesizer" 这条把边指回更早的节点 → 就形成了循环(retry loop):质检不过 → 回去重做 → 再质检。
⚖️ 设计取舍①:路由用确定性 Python 代码,绝不让 LLM 决定 demo2 开头的 docstring(02_conditional_edge.py:3-4)写死了这条硬约束:"路由是确定性 Python 代码,不让 LLM 决定 —— 这是 spec 的硬约束"。
朴素做法:问大模型"你觉得该重试还是结束?"→ 结果不可复现、可能死循环、还费 token。
本仓做法:路由永远是 if state[...] >= 2 这种纯代码判断。收益:行为 100% 可预测、可测试、零成本。"让 LLM 干判断力活、让代码干控制流"——这是全框架的价值观。
🚧 边界/易错点:循环必须有"触顶兜底",否则无限重试critic_router 的 ② 分支 retry_count >= 2 → end02:74)。如果没有这行,质检永远不过就会 synthesizer↔critic 死循环,把 token 烧穿。凡是回指的循环边,必配一个计数上限 + 触顶出口。而计数靠什么不出错?靠下一讲的 Reducer。
L06

Reducer + 并行 — demo3:扇出扇入(读真源码)

当多个节点从同一上游出发、又都指向同一下游时,LangGraph 自动并行执行它们。看 demo3 怎么用一个 for 循环把 4 个专家扇出、再扇入到 rag:

真源码 · 03_reducer_parallel.py:96-102 扇出扇入
    # 一对多扇出:START 同时触发 4 个 SP
    for sp in ("trace_sp", "metric_sp", "deploy_sp", "log_sp"):
        builder.add_edge(START, sp)
        # 多对一扇入:4 个 SP 都指向 rag → LangGraph 自动等 4 个都完成才执行 rag
        builder.add_edge(sp, "rag")

    builder.add_edge("rag", "synthesizer")
START
trace_spmetric_spdeploy_splog_sp
rag
汇合

问题:4 个专家同时往 state 写。如果写同一个字段就会打架。解决办法就是 Reducer(归约器)——看 demo3 真实的 State 定义:

真源码 · 03_reducer_parallel.py:23-31
class RcaState(TypedDict):
    trace_id: str
    # 4 SP 字段隔离(默认覆盖 Reducer,因每个字段只有 1 个写者)
    trace_finding: dict | None
    metric_finding: dict | None
    deploy_finding: dict | None
    log_finding: dict | None
    # ⑤ 累加 Reducer:解决并发计数
    retry_count: Annotated[int, add]
  • 4 个 *_finding 字段:每个字段只有一个写者(trace_sp 只写 trace_finding),天然不冲突,用默认的"覆盖"语义就行——不需要 Reducer。
  • retry_count: Annotated[int, add]:这是唯一需要 Reducer 的字段。add 就是 operator.add。多个节点各返回 {"retry_count": 1},Reducer 把它们相加而不是互相覆盖。
Reducer 到底是什么? 就是一个函数 (旧值, 新值) → 合并后的值。默认是"新值覆盖旧值";写 Annotated[int, add] 就是"两个值相加"。demo2 里 critic 节点返回 {"retry_count": 0 if passed else 1}02:57-58),失败才 +1,靠 Reducer 累加——节点不需要先读旧值再 +1
⚖️ 设计取舍②:为什么 4 个专家用"独立字段"而不是共享一个 findings 列表? 共享列表做法:所有专家都 append 到 state["findings"] → 就得给这个字段配一个"列表拼接 Reducer" + 处理并发追加的顺序问题,还要在下游区分"哪条是谁写的"。
本仓做法(字段隔离)trace_finding / metric_finding / ... 各占一格,每格单写者。并行写零冲突,连 Reducer 都不用配,用默认覆盖即可(demo3 注释原话)。代价是 State 多几个字段,但换来"并行安全 + 下游按名取用",非常值。这是"用数据结构消灭并发问题"的典范。
⚖️ 设计取舍③:retry_count 为什么用累加 Reducer,而不是节点里 state["retry_count"] += 1? demo2 注释(02:57)点破了原因:"Reducer 自动累加,不需要读旧值再 +1"。"读旧值 → +1 → 写回"这三步在并行下是经典的竞态(race condition):两个节点同时读到 0,各自写回 1,结果丢了一次计数。用 Annotated[int, add] 后,节点只声明"我贡献 +1",合并交给引擎原子完成,计数永不丢。
L07

Checkpointer — demo4:存档与恢复(读真源码)

Checkpointer(存档器)把每跑完一个节点的 state 存下来。看 demo4 怎么挂它、怎么用 thread_id 隔离会话:

真源码 · 04_checkpointer.py:52-65 + 72-88
from langgraph.checkpoint.memory import InMemorySaver

def build_graph(checkpointer):
    builder = StateGraph(RcaState)
    ...
    # 测试用 InMemorySaver;生产用 RedisSaver.from_conn_string(REDIS_URL)
    return builder.compile(checkpointer=checkpointer)   # ← 挂存档器

# 跑:
checkpointer = InMemorySaver()
graph = build_graph(checkpointer)
config1 = {"configurable": {"thread_id": "session-001"}}   # ← thread_id 决定"哪次会话"
final1 = await graph.ainvoke(initial, config=config1)
  • compile(checkpointer=...):编译时挂上存档器,图每跑完一个节点就把 state 落一次盘。
  • config={"configurable":{"thread_id": ...}}thread_id 就像"会话 ID"。同一 thread_id 的多次调用共享同一份存档;换个 thread_id 就是全新一份。
  • demo4 用两个 thread(session-001 / session-002)证明"两份 state 互不干扰",还用 graph.get_state(config1)04:105)取回会话 1 的存档。

带来两个能力:断点续跑(跑一半崩了,用同一 thread_id 能从断点接着跑)+ 多会话隔离(一个 agent 服务同时处理很多用户,各自 state 不串)。

⚖️ 设计取舍④:为什么 checkpointer 做成"可替换",本地内存 / 生产 Redis? demo4 注释(04:64):"测试用 InMemorySaver;生产用 RedisSaver"。本框架进一步把这个选择封成工厂 make_checkpointer_from_env()memory/checkpointer.py:24):本地/测试用内存版(pod 重启即丢,够用);线上设了 REDIS_URL 就自动切 Redis(跨进程/跨 pod 持久)。同一份图代码,靠环境变量切存储后端——正是 Day 02 那套"配置驱动"的思想。这就是 Day 09 要讲的"Working Memory(工作记忆)"层。
L08

从 demo 到生产:读 sre-rca 真实的 state.py + builder.py

4 个 demo 的抽象,在旗舰 Agent sre-rca 里长成什么样?先看它真实的 State——你会发现字段比 demo 多,但结构一模一样:

真源码 · apps/sre-rca-agent/sre_rca/state.py:15-53
class RcaState(TypedDict, total=False):
    # ── 输入 ──
    trace_id: str
    service_name: str | None
    user_question: str
    # ── Triage 输出 ──
    alert_type: str
    need_full_dag: bool
    # ── Recall 输出(历史 case)──
    similar_cases: list[dict[str, Any]]
    # ── 4 Specialist 输出(字段独立,并行)──
    trace_finding: dict[str, Any] | None
    metric_finding: dict[str, Any] | None
    deploy_finding: dict[str, Any] | None
    log_finding: dict[str, Any] | None
    # ── Synthesizer 输出 ──
    conclusion: dict[str, Any] | None
    # ── Critic 输出 ──
    critique_passed: bool
    critique_feedback: str
    critique_layer: int   # 1 = 代码层失败 / 2 = LLM 层失败 / 0 = 通过
    # ── Reducer:累加(防并发竞态)──
    retry_count: Annotated[int, add]
    # ── Writeback / 输出 ──
    summary_for_human: str
  • total=False:所有字段可选。因为每个节点只填自己产出的那几个字段,不用一次性填满。
  • 4 个 *_finding:和 demo3 完全一致的"字段隔离"设计,4 个专家并行写零冲突。
  • critique_layer: intstate.py:45):注释直接剧透了 Day 07 的 Critic 双层——1=代码层失败 / 2=LLM 层失败 / 0=通过。State 字段本身就是能力的说明书。
  • retry_count: Annotated[int, add]state.py:48):全 State 唯一带 Reducer 的字段,和 demo 一致——用累加防竞态。
RcaState 数据结构(按生产者分区) 输入trace_id... Triagealert_type Recallsimilar_cases 4 Specialist(并行·字段独立·默认覆盖) trace_finding · metric_finding · deploy_finding · log_finding Synthesizerconclusion Criticcritique_passed/layer retry_countAnnotated[int,add] Writebacksummary_for_human
图注(数据结构):State 字段按"谁写它"分区。绿框=4 专家独立字段(并行安全);粉框=唯一带 Reducer 的 retry_count。

再看它真实的建图 builder.py——这才是"读一个 Agent 的正确入口"。整张图的骨架就这几十行:

真源码 · apps/sre-rca-agent/sre_rca/builder.py:73-118(裁剪)
    builder = StateGraph(RcaState)
    # 节点注册(10 个)
    builder.add_node("triage", triage_node)
    builder.add_node("recall", build_recall_node(episodic))
    builder.add_node("trace_sp", trace_sp_node)
    builder.add_node("metric_sp", metric_sp_node)
    builder.add_node("deploy_sp", deploy_sp_node)
    builder.add_node("log_sp", log_sp_node)
    builder.add_node("rag", build_rag_node(semantic))
    builder.add_node("synthesizer", build_synthesizer_node())
    builder.add_node("critic", build_critic_for_rca())
    builder.add_node("writeback", build_writeback_node(episodic))

    # 边:线性入口
    builder.add_edge(START, "triage")
    builder.add_edge("triage", "recall")
    # 4 SP 并行扇出 + 扇入到 RAG(和 demo3 一模一样的写法)
    for sp in ("trace_sp", "metric_sp", "deploy_sp", "log_sp"):
        builder.add_edge("recall", sp)
        builder.add_edge(sp, "rag")
    builder.add_edge("rag", "synthesizer")
    builder.add_edge("synthesizer", "critic")

    # Critic 三态路由(用 toolkit 封好的,不手写)
    critic_router = build_critic_router(
        pass_dest="writeback", retry_dest="synthesizer",
        end_dest=END, max_retries=max_critic_retries,
    )
    builder.add_conditional_edges("critic", critic_router,
        {"writeback": "writeback", "synthesizer": "synthesizer", END: END})
    builder.add_edge("writeback", END)
    return builder.compile(checkpointer=checkpointer)
  • 10 个 add_node:triage 分诊 → recall 召回历史 → 4 个 SP 专家 → rag 汇合 → synthesizer 综合 → critic 质检 → writeback 沉淀。名字即职责。
  • for sp in (...) 那两行:和你在 demo3(03:96-100)读到的扇出扇入一字不差。demo 不是玩具,就是生产写法的最小复刻。
  • build_critic_router(...)builder.py:100-105):注意——生产没有手写 demo2 那个 critic_router,而是从 toolkit import build_critic_router,传 pass_dest / retry_dest / end_dest / max_retries 四个参数生成。
  • compile(checkpointer=checkpointer):挂上 L07 讲的存档器收尾。
triage recall trace_spmetric_sp deploy_splog_sp rag synthesizer critic writeback END pass 触顶 end retry:critic 不过 → 回 synthesizer 重做(循环边)
图注(控制流):sre-rca 真实 DAG。紫=普通边,绿=pass,红=触顶 END,橙虚线=retry 循环边。对照 demo1(串线)+demo2(三态)+demo3(并行) 三张 demo 拼起来就是它。
⚖️ 设计取舍⑤:生产为什么用 toolkit 的 build_critic_router,而不像 demo2 手写路由? demo2 里 critic_router 是手写的三态函数(教学用,看得见逻辑)。生产 builder.py:100from ai_trust_toolkit.failsafe import build_critic_router 直接调工厂。
这正是 Day 01「渐进抽象」的落地:三态路由 + 触顶兜底是每个带 Critic 的 Agent 都要的横切逻辑,第 2 个 Agent 复制它时就该下沉到 toolkit。手写版帮你理解机制,工厂版保证全平台一致、改一处全生效(比如统一调整 max_retries 行为)。读代码时看到 build_xxx 工厂,就该意识到"这块是被抽象过的公共能力"。
读代码技巧(记牢):以后打开任何一个 agent,先看 state.py(数据长什么样)再看 builder.py——从 StateGraph(...).compile() 之间那段,就是这个 Agent 的完整"流程图定义"。先把节点和边捋清楚,再逐个看节点函数干嘛。这是本框架最高效的读法,Day 14 精读 sre-rca 全流程就靠它。
L09

今日小结 + 动手

🧠 今天你应该能回答

  • 为什么用"图"跑 Agent?(支持并行/循环/断点续跑,比 if/else 清晰)
  • State 的约定?(TypedDict + total=False;节点只回写自己改的字段,引擎负责合并)
  • 条件边 + 循环怎么写?(路由函数返回字符串 + add_conditional_edges 映射;回指=循环,必配触顶上限)
  • Reducer 解决什么?为什么 4 专家不用它?(多写同字段的合并规则;专家字段隔离天生单写者,用默认覆盖)
  • 读一个 agent 从哪入手?(先 state.pybuilder.py;看 StateGraph→compile 那段)

✋ 动手:跑 4 个官方 demo + 读真 Agent(不配 key 也能跑)

# 这 4 个 demo 默认走 MockLLM,无需 API key
uv run python docs/learn-langgraph/01_state_node_edge.py
uv run python docs/learn-langgraph/02_conditional_edge.py
uv run python docs/learn-langgraph/03_reducer_parallel.py
uv run python docs/learn-langgraph/04_checkpointer.py

# 对照生产:先看数据结构,再看建图
sed -n '15,53p'  apps/sre-rca-agent/sre_rca/state.py
sed -n '73,118p' apps/sre-rca-agent/sre_rca/builder.py

# 想切真 Claude?(需要 key)
USE_REAL_LLM=True uv run python docs/learn-langgraph/01_state_node_edge.py

跑的时候留意:demo2 里条件边怎么触发不同分支;demo3 里 4 个并行节点的输出怎么被 reducer 合并;demo4 里换 thread_id 会不会看到之前的 state。然后回头看 sre-rca 的 builder.py——你会发现它就是这几个 demo 的拼装。

明天预告 · Day 04:单个 Agent 会读了,但"多个 Agent 怎么协作"?Day 04 讲 Supervisor(主管)模式——一个中央调度决定"这任务派给哪个 agent",以及 agent 之间的 handoff(交接)。你会读到本框架里 route / RouteDecision / HandoffSignal 的真源码。
← Day 02 环境搭建 Day 04 · Supervisor 模式与多 Agent 协作 →