LangChain LangGraph
Module 1 Module 2 Module 3 Module 4 Module 5 Module 6
Module 3 · Human in the Loop
1 3-1 Streaming
2 3-2 Breakpoints
3 3-3 Edit State
4 3-4 Dyn Breakpts
5 3-5 Time Travel
SUM Module Summary
HomeLangGraphModule 3 · Human in the LoopModule Summary
LANGGRAPH MODULE 3 · SUMMARY

Human in the Loop
模块总结

从流式输出到时间旅行,Module 3 构建了一套完整的
人机协作与可控 AI 执行体系。

5子课程
3核心机制
2断点类型
可回溯检查点
模块学习路径
流式输出
L1 · stream()
updates / values / events
静态断点
L2 · interrupt_before
get_state · invoke(None)
状态编辑
L3 · update_state
as_node · 人工注入
动态中断
L4 · interrupt()
条件触发 · 运行时判断
时间旅行
L5 · get_state_history
Replay · Fork
三大知识域
流式与可观测性
stream_mode="updates":每个节点执行后输出增量
stream_mode="values":每步输出完整 State 快照
astream_events:Token 级别细粒度输出
messages 模式:专为聊天 UI 设计的流式格式
invoke 与 stream:阻塞 vs 实时的两种调用方式
人机协作控制
interrupt_before / after:编译期固定的静态断点
graph.get_state():查看断点处当前 State
graph.invoke(None):从断点处恢复执行
update_state() + as_node:直接修改 State 字段
interrupt():运行时条件性动态中断
时间旅行调试
Checkpointer:每步自动保存 State 快照
get_state_history():遍历历史所有检查点
checkpoint_id:唯一标识每个历史时刻
Replay:从历史检查点重新执行相同路径
Fork:修改历史状态后开启全新执行分支
5 个子课程详解
1

Streaming & Interruption

stream() · astream_events · interrupt_before/after

默认的 invoke() 是阻塞式的——图跑完才返回。流式输出让你在图执行的每一步都能实时获取数据,是 Human-in-the-loop 的感知基础。

  • stream_mode="updates":每个节点执行完后,立即吐出该节点对 State 的增量更新,适合监控节点进度
  • stream_mode="values":每步输出图的完整 State,适合需要全貌的调试场景
  • astream_events:Token 粒度流式,LLM 每生成一个 token 就立刻推送,实现"打字机"效果
  • interrupt_before/after:在编译时声明,让图在指定节点前/后自动暂停,等待外部指令
核心洞察:流式输出不只是"好看",它是实现 Human-in-the-loop 的前提——你必须能实时观察图在做什么,才能在合适的时机介入。
深度讲解 →
2

Breakpoints

compile(interrupt_before) · get_state · invoke(None)

断点(Breakpoint)是在编译期固定下来的暂停位置,每次执行到该节点就无条件中断,等待外部决策后再继续——这是最直接的人机协作方式。

  • compile(interrupt_before=["node"]):在节点执行之前暂停,适合"执行前审批"场景
  • compile(interrupt_after=["node"]):在节点执行之后暂停,适合"查看结果再决定"场景
  • graph.get_state(config):断点触发后,查询当前 State 的快照,了解图"停在了哪里"
  • graph.invoke(None, config):传入 None 作为输入,从断点处恢复执行
  • MemorySaver 必要条件:断点必须配合 Checkpointer 使用,否则无法保存暂停状态
核心洞察:断点让图变成了一个"等待指令的状态机"。invoke(None) 中的 None 意思是"不新增输入,直接用保存的状态继续往下走"。
深度讲解 →
3

Edit State & Human Feedback

update_state · as_node · human_feedback 节点

断点只能"批准或拒绝",而 update_state() 让你能在暂停期间直接修改图内部 State——纠正错误数据、注入人工输入、然后带着新状态继续执行。

  • graph.update_state(config, values):向当前 State 写入新值,类似节点的返回字典
  • as_node 参数:指定此次更新"伪装成"哪个节点的输出,影响 Reducer 的应用方式(尤其对 add_messages 至关重要)
  • 消息覆盖机制:对消息字段使用 update_state 时,传入相同 ID 的消息会覆盖原消息,而非追加
  • human_feedback 节点:专门的节点函数,在图流程中"占位"等待人工输入,恢复时注入人工数据
核心洞察:as_node 解决了一个微妙问题:LangGraph 需要知道"是哪个节点执行了这次 State 更新",才能正确计算下一步应该走哪条边。
深度讲解 →
4

Dynamic Breakpoints

interrupt() · NodeInterrupt · 条件性中断

静态断点无条件暂停,而动态断点让节点内部代码根据运行时数据自行决定是否触发中断,并可携带自定义的中断原因信息传给外部。

  • from langgraph.types import interrupt:动态断点的核心函数,在节点内部调用
  • interrupt(value):触发中断并向外传递 value(字符串或字典),可用于说明中断原因
  • 条件触发:只有满足特定条件(如风险分数超阈值)才调用 interrupt(),否则节点正常执行
  • 恢复时的陷阱:直接 invoke(None)重新触发同一中断;正确做法是先 update_state 写入处理结果再恢复
核心洞察:动态断点把"是否需要人工干预"的判断权交给了数据。不是所有工具调用都需要审批——只有那些真正风险高的才需要。这才是生产级 Agent 的设计思路。
深度讲解 →
5

Time Travel

get_state_history · checkpoint_id · Replay · Fork

LangGraph 的 Checkpointer 在每个节点执行后自动保存快照。时间旅行让你能回溯到任意历史时刻——重放原路径或分叉走向全新分支。

  • graph.get_state_history(config):返回所有历史检查点的生成器,每项包含完整 State 和 checkpoint_id
  • Replay(重放):将过去某检查点的 config 传给 invoke(),按原来相同路径再走一遍
  • Fork(分叉):先 update_state(past_config, new_values) 修改历史状态,再 invoke() 开启新执行分支
  • 类比 Git:检查点 ≈ commit,重放 ≈ checkout 查看历史,分叉 ≈ 从历史 commit 建新分支
核心洞察:时间旅行把"调试"从被动变成主动——不再只能"看错误日志",而是能直接回到出错的那一刻,改变数据,然后观察结果是否符合预期。
深度讲解 →
核心概念速查
概念核心 API解决的问题典型场景
stream updatesgraph.stream(..., stream_mode="updates")节点粒度实时输出增量监控多步骤 Agent 的执行进度
stream valuesgraph.stream(..., stream_mode="values")每步输出完整 State 快照调试状态变化过程
astream_eventsgraph.astream_events(..., version="v2")Token 级别细粒度流式聊天界面"打字机"效果
静态断点compile(interrupt_before=["node"])节点前无条件暂停所有工具调用统一审批
get_stategraph.get_state(config)查询断点处的当前 State断点触发后检查数据再决策
恢复执行graph.invoke(None, config)从断点处继续执行人工审批通过后放行
update_stategraph.update_state(config, values)直接修改图内部 State纠正 AI 中间结果后继续
as_nodeupdate_state(..., as_node="node_name")声明更新来源节点消息字段使用 add_messages 时
动态断点from langgraph.types import interrupt运行时条件性触发中断金额超阈值才需审批
历史检查点graph.get_state_history(config)遍历所有历史执行快照回溯 Agent 决策链路
重放 Replayinvoke(None, past_checkpoint_config)从历史某点重走同一路径验证行为可重现性
分叉 Forkupdate_state(past_cfg) + invoke修改历史状态开新执行分支A/B 测试不同输入的结果
模块核心收获
📡

Streaming 是 HiL 的眼睛

不能实时观察图在做什么,就无法在合适的时机介入。stream_mode="updates" 是最轻量的方式,astream_events 是最细粒度的方式,按需选用。

🔒

静态断点是安全阀

对所有高风险操作(发邮件、写数据库、调用付费 API)设置 interrupt_before,是最简单的安全保障。配合 MemorySaver 才能正常工作。

🔧

update_state 是外科手术刀

精准修改单个字段,不影响其他状态。as_node 参数让 LangGraph 能正确计算更新后的下一步路由,别忘了它。

动态断点更精准

让数据决定是否暂停,而不是让代码决定。interrupt(value) 还能携带中断原因,让外部系统知道"为什么需要人工介入"。

检查点是时间机器

只要挂载了 Checkpointer,每个节点都是自动保存点。时间旅行让"回退重试"从不可能变成一行代码的事,这是 LangGraph 区别于普通 Agent 框架的核心优势之一。

🌿

Fork 是平行宇宙

分叉执行创造了独立的执行历史分支,原来的 thread 不受影响。这是实验不同策略、进行 A/B 测试、以及从错误中恢复的强力工具。

返回 Module 3 首页
综合实战项目
Project

智能合同审批助手
Smart Contract Approval Agent

一个集流式进度展示、人工审批、状态编辑、动态风险中断与历史回溯于一体的合同处理工作流。将 Module 3 全部 5 个核心知识点有机整合,模拟真实企业级审批场景。

L1 Streaming L2 Breakpoints L3 update_state L4 interrupt() L5 Time Travel
项目特性
流式显示每个审批步骤进展
提交前强制等待人工确认
人工可修改合同条款后继续
高风险条款自动触发中断
支持回溯任意历史审批版本
工作流架构图
START → analyze_document 节点
LLM 解析合同结构 + 提取关键条款
risk_assessment 节点 · L4
计算风险分数 → 分数 > 0.7 时调用 interrupt() 动态中断
⏸ 静态断点 interrupt_before=["submit"] · L2
每次提交前暂停 → 等待人工确认或 update_state() 修改条款 · L3
submit_contract 节点 → END · L5 可回溯
每步自动保存 Checkpoint → 支持 Replay / Fork 时间旅行
全程 stream_mode="updates" 实时显示进度 · L1
代码块 1 / 5 State 定义与图结构 —— L1 + L2
# ── 依赖导入 ──────────────────────────────────────────────
from typing import Literal, Optional
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
from langgraph.graph import StateGraph, START, END, MessagesState
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import interrupt   # 【L4】动态断点函数

# ════════════════════════════════════════════════
# State:继承 MessagesState,扩展合同专用字段
# ════════════════════════════════════════════════
class ContractState(MessagesState):
    contract_text: str       # 合同全文
    risk_score:    float     # 风险分数 0.0-1.0
    key_clauses:   list[str] # 提取的关键条款
    decision:      str       # 审批决定:pending/approved/rejected

llm    = ChatOpenAI(model="gpt-4o-mini")
memory = MemorySaver()   # 【L2/L5】Checkpointer:断点+时间旅行的必要前提

# ── 节点函数 ──────────────────────────────────────────────
def analyze_document(state: ContractState):
    """解析合同,提取结构与关键条款"""
    response = llm.invoke([
        SystemMessage(content="你是一个专业的合同分析师。提取合同中的关键条款和风险点。"),
        HumanMessage(content=f"请分析以下合同:\n{state['contract_text']}"),
    ])
    clauses = response.content.split("\n")[:5]
    return {"key_clauses": clauses, "messages": [response]}
L1 · 流式的基础:State 字段

流式输出的每个 chunk 包含该节点对 State 的增量更新。ContractState 中每个字段的变化都会通过 stream_mode="updates" 实时推送,让外部能看到合同文本什么时候写入、风险分数何时计算完成。

L2 · MemorySaver 是断点的前提

断点触发后,图的执行状态必须被保存下来,等待恢复时才能从断点处继续。MemorySaver 就是这个状态存储——没有它,interrupt_before 会报错。同时它也是 L5 时间旅行的数据来源。

代码块 2 / 5 流式进度监控 —— L1
import asyncio

config = {"configurable": {"thread_id": "contract_review_001"}}

# ── 【L1】stream_mode="updates":节点粒度实时输出 ────────
# 每个节点执行完后,立即推送该节点对 State 的增量变化
# 不需要等整个图跑完,用户能实时看到每一步的进展
for chunk in graph.stream(
    {"contract_text": contract_text},
    config=config,
    stream_mode="updates",
):
    node_name = list(chunk.keys())[0]
    print(f"[{node_name}] 执行完成 → {list(chunk[node_name].keys())}")

# 输出示例:
# [analyze_document] 执行完成 → ['key_clauses', 'messages']
# ⏸ 图暂停(interrupt_before="submit_contract")

# ── 【L1】astream_events:Token 级别流式输出 ─────────────
# 捕获 LLM 每生成一个 token 就立刻推送的事件
# 适合聊天界面需要"打字机"效果的场景
async def stream_tokens():
    async for event in graph.astream_events(
        {"contract_text": contract_text},
        config=config,
        version="v2",
    ):
        if event["event"] == "on_chat_model_stream":
            token = event["data"]["chunk"].content
            print(token, end="", flush=True)  # 逐 token 打印
stream_mode 三种值的对比

"updates":增量字典,最小化数据量,生产首选"values":完整 State,调试时方便查看全貌。"messages":只推消息列表的增量,专为聊天场景优化。三者性能开销依次递增。

astream_events 的事件类型

事件名称遵循 on_<component>_<lifecycle> 格式。on_chat_model_stream 是 LLM 流式 token;on_tool_start/end 是工具调用;on_chain_start/end 是节点生命周期。用 event["name"] 过滤特定节点的事件。

代码块 3 / 5 静态断点与状态编辑 —— L2 + L3
# ════════════════════════════════════════════════
# 【L2】静态断点:编译期固定,每次提交前无条件暂停
#   · interrupt_before=["submit_contract"]
#   · 必须挂载 checkpointer,否则断点无法保存状态
# ════════════════════════════════════════════════
graph = builder.compile(
    checkpointer=memory,
    interrupt_before=["submit_contract"],  # 在 submit 节点执行前暂停
)

# ── 第一次运行:触发断点后图自动暂停 ──
graph.invoke(
    {"contract_text": contract_text},
    config=config,
)

# ── 【L2】断点触发后:检查当前 State ──
snapshot = graph.get_state(config)
print("当前 State:", snapshot.values)
print("下一步节点:", snapshot.next)   # ('submit_contract',)

# ── 【L3】用 update_state 修改合同条款(人工审核后纠错)──
# 相当于人类审核员直接在 State 上"划改"了一条条款
# as_node 告诉 LangGraph 这次更新来自哪个节点(影响路由)
graph.update_state(
    config,
    {"contract_text": "[已修正] 合同总金额:100万元(原:150万元)..."},
    as_node="analyze_document",   # 声明此更新来源于 analyze_document 节点
)

# ── 【L2】恢复执行:传入 None 作为新输入 ──
# None 意味着"不增加新输入,使用已保存的 State 继续"
# 图会从断点处(submit_contract 节点)继续往下执行
final_state = graph.invoke(None, config)
L2 · invoke(None) 的语义

graph.invoke(None, config)None 表示"没有新的用户输入"。LangGraph 看到这个信号,会去 Checkpointer 里加载最新快照,从断点处的下一个节点继续执行。这是恢复执行的标准姿势。

L3 · as_node 为什么重要

LangGraph 用节点名来决定执行完后走哪条边。当你 update_state 时,必须告诉图"这次更新等价于哪个节点刚执行完",否则图不知道下一步去哪。对消息字段使用 add_messages Reducer 时,as_node 还影响消息的追加 vs 覆盖行为。

代码块 4 / 5 动态中断节点 —— L4
# ════════════════════════════════════════════════
# 【L4】动态断点:运行时根据条件决定是否触发中断
#   · 使用 interrupt() 函数,在节点内部条件性调用
#   · 可携带任意信息作为中断原因传给外部审核人员
# ════════════════════════════════════════════════
def risk_assessment(state: ContractState):
    """计算风险分数,高风险时动态触发中断"""
    # 简化的风险评分(生产中用专门的风险模型)
    text  = state["contract_text"]
    score = 0.9 if "担保" in text or "不可抗力" in text else 0.3

    # ── 【L4】只有高风险(score > 0.7)才触发中断 ──
    # interrupt() 调用后,节点执行立即挂起
    # 传入的字典会被存入检查点,外部可通过 get_state() 读取
    if score > 0.7:
        human_decision = interrupt({
            "reason":           "检测到高风险条款,需要法律顾问审核",
            "risk_score":        score,
            "flagged_keywords":  ["担保", "不可抗力"],
            "contract_preview":  text[:300],
        })
        # interrupt() 返回值是外部通过 update_state 注入的人工决定
        return {"risk_score": score, "decision": human_decision}

    # 低风险:直接通过,不打扰审批人员
    return {"risk_score": score, "decision": "auto_approved"}


# ── 外部代码处理动态中断 ──────────────────────────────────
graph.invoke({"contract_text": risky_contract}, config=config)

# 读取中断原因
snapshot = graph.get_state(config)
interrupt_info = snapshot.tasks[0].interrupts[0].value
print(f"中断原因:{interrupt_info['reason']}")
print(f"风险分数:{interrupt_info['risk_score']}")

# ── 【L4】正确的恢复方式:先写入审批结果,再 invoke ──
# 直接 invoke(None) 会重新执行 risk_assessment 节点,再次触发 interrupt
# 正确做法:先用 update_state 向 State 写入人工决定,再恢复
graph.update_state(config, {"decision": "approved_with_amendments"})
graph.invoke(None, config)  # 现在可以正常继续
L4 · 动态 vs 静态断点的核心区别

静态断点(L2):每次经过该节点都暂停,无论数据内容。动态断点(L4):只有满足运行时条件才暂停,低风险合同直接通过,完全不打扰审批人员。这让 Agent 系统更智能、审批效率更高。

L4 · 恢复动态断点的陷阱

动态断点被触发后,该节点处于"执行中途挂起"状态。如果直接 invoke(None),LangGraph 会重新执行该节点,再次遇到 interrupt(),死循环。正确做法:先用 update_state 写入一个值,让节点"感知到"人工决定已到达,再恢复。

代码块 5 / 5 时间旅行:回放与分叉 —— L5
# ════════════════════════════════════════════════
# 【L5】时间旅行:浏览 + 重放 + 分叉
#   · 每个节点执行后 Checkpointer 自动保存快照
#   · 所有快照构成一条时间线,每条有唯一 checkpoint_id
# ════════════════════════════════════════════════

# ── 浏览完整执行历史 ──────────────────────────────────────
history = list(graph.get_state_history(config))  # 最新在前
for i, checkpoint in enumerate(history):
    ckpt_id  = checkpoint.config["configurable"]["checkpoint_id"]
    decision = checkpoint.values.get("decision", "pending")
    risk     = checkpoint.values.get("risk_score", "N/A")
    print(f"[{i}] checkpoint_id={ckpt_id[:12]}... decision={decision} risk={risk}")

# ── Replay(重放):从某个历史检查点重新走相同路径 ─────────
# 取 analyze_document 节点刚执行完的那个快照
analyze_done_ckpt = history[-2]  # 倒数第2个 = 分析完成时的快照

# 传入历史快照的 config(包含 checkpoint_id)来重放
replayed = graph.invoke(None, analyze_done_ckpt.config)
# 结果与历史一致,因为 State 数据相同,图的决策路径也相同

# ── Fork(分叉):修改历史状态后开启新的执行分支 ───────────
# 场景:回到"分析完成"那个时刻,改变合同文本,看结果如何变化

# Step 1:找到目标历史检查点
target = analyze_done_ckpt

# Step 2:在历史快照上执行 update_state,修改合同内容
# 这会创建一个新检查点(分叉点),原来的历史时间线不受影响
forked_config = graph.update_state(
    target.config,
    {"contract_text": "[修改版] 已去除担保条款的低风险合同..."},
    as_node="analyze_document",
)

# Step 3:从分叉点继续执行,走向全新的执行分支
forked_result = graph.invoke(None, forked_config)
print(f"分叉后风险分数:{forked_result['risk_score']}")  # 预计 0.3(低风险)
print(f"分叉后决定:{forked_result['decision']}")         # auto_approved
L5 · Replay vs Fork 的本质区别

Replay:不修改历史 State,直接从某个检查点重新执行。结果与历史相同(确定性 Agent 下)。用于验证行为、调试偶发问题。
Fork:先修改历史 State,再执行。产生一条全新的执行分支,不影响原时间线。用于纠错和 A/B 测试。

L5 · update_state 在 Fork 中的角色

Fork 的关键在于 update_state(historical_config, ...) 返回一个新的 config(包含新的 checkpoint_id)。这个新 config 代表分叉点,后续对它调用 invoke(None) 会从分叉点继续,而不是从原来的历史检查点。Git 类比:这就是 git checkout <old-commit> + git commit,创建了新分支。

知识点对应关系总览
项目各部分与 5 个子课程的精确对应
项目组成部分对应课程核心 API解决的问题
stream_mode="updates" 进度监控 L1 · Streaming graph.stream(..., stream_mode="updates") 实时看到每个节点何时完成,审批人员了解进展
astream_events Token 流 L1 · Streaming graph.astream_events(..., version="v2") LLM 分析合同时逐 token 显示,减少等待感
interrupt_before=["submit"] L2 · Breakpoints compile(interrupt_before=[...]) 每次提交前无条件暂停,等待人工最终确认
graph.get_state + invoke(None) L2 · Breakpoints get_state(config) / invoke(None, config) 断点后检查数据,确认无误后放行继续执行
update_state 修改合同条款 L3 · Edit State graph.update_state(config, values, as_node=...) 审核发现错误,直接改 State 后继续,无需重新提交
risk_assessment 中的 interrupt() L4 · Dynamic Breakpoints from langgraph.types import interrupt 只有风险分数超阈值才暂停,低风险自动通过
get_state_history 历史浏览 L5 · Time Travel graph.get_state_history(config) 回溯完整的审批历史,查看每个节点的 State 快照
Replay 重放验证 L5 · Time Travel invoke(None, historical_config) 从历史某点重新执行,验证审批决策的可重现性
Fork 分叉测试 L5 · Time Travel update_state(historical_config) + invoke 修改历史合同内容,测试不同版本的审批结果
返回 Module 3 首页