【LangGraph实战】LangGraph 学习笔记(五):信息裁剪、人机交互与时间旅行

👋 欢迎阅读

🔥 愿旖旎· 个人主页

📘学习专栏: 《算法专栏》《LangChain学习》《贪心算法》

🌄 钱塘江上潮信来,今日方知我是我

✨当前学习内容 : 《LangGraph》

📑 目录


一、前置讲解

在进入正文前,先花 10 秒了解本篇会反复用到的核心概念,带着印象去读正文,学习效率更高。

✂️ 前置知识一:消息修剪(trim_messages)

按token 数 裁剪历史消息,接近上下文窗口上限时只保留最近的一段。

🗑️ 前置知识二:消息删除(RemoveMessage)

按消息 id 精确删掉历史消息,甚至清空整段历史。

📝 前置知识三:消息总结(summary 字段)

不删也不丢信息:把旧对话压缩成摘要 存进状态,用摘要代替冗长历史。

⏸️ 前置知识四:中断(interrupt)与 Command(resume=...)

在流程关键点暂停 ,等人工输入 后再从存档恢复执行。

🕰️ 前置知识五:时间旅行(Time Travel)

拿到历史检查点 就能改状态、重放 ,用来定位错误 和探索替代路径。


二、信息处理:让历史消息不再无限增长

2.1 修剪信息

大多数 LLM 都有一个最大支持的上下文窗口 。决定何时截断消息 的一种方法,是对消息历史记录 中的令牌 进行计数 ,并在接近该限制 时截断。

💡 消息裁剪的具体方法可以参考 LangChain 篇章的内容,这里直接把裁剪嵌进图节点里。

代码示例:

python 复制代码
from langchain_core.messages.utils import trim_messages
from langchain.chat_models import init_chat_model
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph, START, MessagesState
​
​
# ---------- 1. 定义模型 ----------
model = init_chat_model(model="deepseek-chat", model_provider="deepseek", temperature=0)
​
​
# ---------- 2. token 计数函数 ----------
# 逐条消息数 token 再求和
def count_tokens(messages):
    return sum(model.get_num_tokens(m.content) for m in messages)
​
​
# ---------- 3. 定义模型节点(带消息裁剪)----------
def call_model(state: MessagesState):
    """调用 LLM,但先裁剪历史消息,只保留最近的若干 token"""
    # 只保留最近的 128 个 token 的消息
    messages = trim_messages(
        state["messages"],
​
        # strategy:裁剪策略
        #   "last"  = 保留后半部分(丢弃最早的,保留最新的)
        #   "first" = 保留前半部分(丢弃最新的,保留最早的)
        strategy="last",
​
        # token_counter:计算 token 数量的函数
        token_counter=count_tokens,
​
        # max_tokens:裁剪后剩余消息的总 token 上限
        max_tokens=128,
​
        # start_on:裁剪后第一条(最靠前)的消息必须是 "human"(用户消息)
        start_on="human",
​
        # end_on:裁剪后最后一条消息必须是 "human" 或 "tool"(工具返回消息)
        end_on=("human", "tool"),
    )
    response = model.invoke(messages)
    return {"messages": [response]}
​
​
# ---------- 4. 构建图 ----------
checkpointer = InMemorySaver()
builder = StateGraph(MessagesState)
builder.add_node("call_model", call_model)
builder.add_edge(START, "call_model")
graph = builder.compile(checkpointer=checkpointer)
​
​
# ---------- 5. 多轮对话(同一个 thread_id,历史会累积)----------
config = {"configurable": {"thread_id": "1"}}
​
graph.invoke({"messages": "hi, my name is bob"}, config)
graph.invoke({"messages": "write a short poem about cats"}, config)
graph.invoke({"messages": "now do the same but for dogs"}, config)
​
# 最后一问
final_response = graph.invoke({"messages": "what's my name?"}, config)
final_response["messages"][-1].pretty_print()

运行结果:

复制代码
I don't know your name

💡 这个结果不是 bug,而是修剪的代价 :max_tokens=128 加上 strategy="last",意味着"我叫 bob"那句早已被裁掉 ,模型看到的只是最近一段对话(主要在聊猫和狗的诗),所以它真的不知道你的名字 。这说明修剪能省 token,但会丢信息------接着往下看两种补救思路。

2.2 删除消息

也可以从图状态 中删除消息 来管理消息历史记录 。当想删除特定消息 或清除整个消息历史记录时,这非常有用。

代码示例:

python 复制代码
from langchain_core.messages import RemoveMessage
from langchain.chat_models import init_chat_model
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph, START, MessagesState
​
​
# ---------- 1. 定义模型 ----------
model = init_chat_model(model="deepseek-chat", model_provider="deepseek", temperature=0)
​
​
# ---------- 2. 定义模型节点(手动删除早期消息)----------
def call_model(state: MessagesState):
    """调用 LLM,消息过多时手动删除最早的一批"""
    messages = state["messages"]
​
    # 消息超过 6 条时,删除最早的 6 条
    if len(messages) > 6:
        # RemoveMessage(id=消息id):按 id 删除指定消息
        return {
            "messages": [RemoveMessage(id=m.id) for m in messages[:6]]
        }
​
    response = model.invoke(messages)
    return {"messages": [response]}
​
​
# ---------- 3. 构建图 ----------
checkpointer = InMemorySaver()
builder = StateGraph(MessagesState)
builder.add_node("call_model", call_model)
builder.add_edge(START, "call_model")
graph = builder.compile(checkpointer=checkpointer)
​
​
# ---------- 4. 多轮对话(累积历史)----------
config = {"configurable": {"thread_id": "1"}}
​
graph.invoke({"messages": "hi, my name is bob"}, config)
graph.invoke({"messages": "write a short poem about cats"}, config)
graph.invoke({"messages": "now do the same but for dogs"}, config)
final_response = graph.invoke({"messages": "what's my name?"}, config)
​
​
# ---------- 5. 查看结果 ----------
for message in final_response["messages"]:
    message.pretty_print()

运行结果:

复制代码
================================ Human Message =================================
​
what's my name?

💡 输出里只剩一条用户消息 ,原因有两层:一是第四轮进来时消息已经 7 条 (3 组问答 + 新提问),触发 len(messages) > 6;二是这一轮直接 return 了删除指令、没有调用模型 ,所以状态里删掉最早 6 条后,正好只剩刚进来的这一条提问 。也就是说,这一轮的作用是"清理历史",而不是"回答问题"。

2.3 总结消息

实际上,修剪或删除消息 也会存在问题:可能因剔除消息 而丢失信息 。因此某些应用 更希望把消息历史记录 进行总结 ------把旧的对话内容 总结成简短摘要 ,保留关键信息 ,以代替冗长的历史记录。

做法是先把 State 进行扩展 ,除了对话记录 ,再加一个总结摘要字段:

python 复制代码
from langgraph.graph import MessagesState
​
​
class State(MessagesState):
    summary: str

现在的设计要求是:

要求 说明
对话记录 记录新的对话与结果
摘要 每次对话完成,需要进行总结
删除历史 完成总结摘要后 ,可以删除历史对话

这样,每次调用 LLM 时,就可以根据【新的请求 】与【总结摘要信息 】共同构建提示词来完成请求。

代码示例:

python 复制代码
from langchain.chat_models import init_chat_model
from langchain_core.messages import HumanMessage, RemoveMessage
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph, START, MessagesState


# ---------- 1. 定义模型 ----------
model = init_chat_model(model="deepseek-chat", model_provider="deepseek", temperature=0)


# ---------- 2. 自定义状态(MessagesState + 额外字段 summary)----------
# MessagesState 是 LangGraph 提供的【状态模板】,它内部自带了 messages 字段:
#     class MessagesState(TypedDict):
#         messages: Annotated[list[AnyMessage], add_messages]
#                        ↑字段名                    ↑合并规则:追加(不是覆盖)
# AnyMessage:任意类型的消息(HumanMessage、AIMessage、ToolMessage 都属于它)
# Annotated[类型, 附加信息]:给类型附加额外说明,这里的 add_messages 是【合并规则(reducer)】
#   → messages 字段更新时不是"覆盖",而是【追加】
class State(MessagesState):
    summary: str        # 额外加一个字段:存放历史对话的摘要


# ---------- 3. 定义模型节点 ----------
def call_model(state: State):
    """使用【历史总结 + 最新消息】发起调用"""
    # .get("summary", ""):取摘要字段;取不到时用默认值 ""(空字符串)
    summary = state.get("summary", "")

    # 拼成:[摘要] + [当前消息列表],一起发给模型
    messages = model.invoke([HumanMessage(content=summary)] + state["messages"])

    return {"messages": messages}


# ---------- 4. 定义总结节点 ----------
def summarize_conversation(state: State):
    """生成历史总结"""

    # 1. 创建总结提示词
    # .get("summary", ""):取摘要字段;取不到时用默认值 ""(空字符串)
    summary = state.get("summary", "")

    if summary:
        # 有摘要,扩展
        summary_message = (
            f"这是到目前为止的对话摘要:{summary}\n\n"
            "基于上面的新消息扩展摘要:"
        )
    else:
        # 无摘要,新增
        summary_message = "创建上面对话的摘要:"

    # 2. 生成新总结:【消息列表】+【总结提示词】调用模型
    messages = state["messages"] + [HumanMessage(content=summary_message)]
    response = model.invoke(messages)

    # 3. 删除历史对话:除了最新的消息,都可以删除
    return {
        "summary": response.content,        # 历史总结

        # RemoveMessage(id=消息id):按 id 删除指定消息
        # 保留最后的消息是为了打印结果
        "messages": [RemoveMessage(id=m.id) for m in state["messages"][:-1]],
    }


# ---------- 5. 构建图 ----------
checkpointer = InMemorySaver()      # 内存检查点:把图状态存到内存(程序结束即消失),用于多轮记忆

builder = StateGraph(State)
builder.add_node("call_model", call_model)
builder.add_node("summarize", summarize_conversation)

builder.add_edge(START, "call_model")
builder.add_edge("call_model", "summarize")     # 每次对话完,进行总结

graph = builder.compile(checkpointer=checkpointer)


# ---------- 6. 多轮对话 ----------
config = {"configurable": {"thread_id": "1"}}     # thread_id:存档编号,多轮记忆靠它定位

graph.invoke({"messages": "hi, my name is bob"}, config)
graph.invoke({"messages": "write a short poem about cats"}, config)
graph.invoke({"messages": "now do the same but for dogs"}, config)

final_response = graph.invoke({"messages": "what's my name?"}, config)

final_response["messages"][-1].pretty_print()
print("\nSummary:", final_response["summary"])

运行结果:

复制代码
================================== Ai Message ==================================

Your name is Bob — you introduced yourself at the start of our conversation.

Summary: 根据对话摘要,你的名字是 **Bob**。

三、人机交互(Human-in-the-loop)

什么是人机交互? 想象这两个场景:

场景 需求
AI 自动发送邮件前 你想亲自最后确认一遍内容
AI 生成文章后 你想亲自动手修改几个段落

在 AI 系统 中,我们希望可以在 AI 自动流程 中插入一个"暂停键 ",等待人类输入 后再继续 ------这就是人机交互功能。

3.1 核心概念:中断(Interrupts)

想要完成人机交互 能力,需要用到 LangGraph 基于持久化 实现的【中断 】能力。中断 就像打游戏:当玩家无法通关某关卡时,希望暂停游戏(中断) ,攻略一下后再继续游戏。

整个类比关系是这样的:

游戏世界 LangGraph 世界
用户 在游戏过程中主动按下"存档键" 节点里调用 interrupt()
把游戏当前状态 等信息存档保存下来 依靠持久化能力 保存当前状态(检查点)
攻略后读取存档 继续玩(恢复游戏) 外部发 Command(resume=...) ,工作流恢复执行

在 LangGraph 中,中断 允许工作流执行 时在特定点暂停 ,等待外部输入 后再继续执行。

3.2 中断如何实现?

在工作流中,实现暂停与恢复很简单,只需要两件事:

  • 通过调用 interrupt() 方法中断执行流程 ,依靠持久化能力 保存当前状态;

  • 外部用户 通过发送 Command 对象 ,使工作流恢复执行流程。

代码示例:

python 复制代码
from typing import TypedDict
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.constants import START
from langgraph.graph import StateGraph
from langgraph.types import interrupt, Command
​
​
# ---------- 1. 定义状态 ----------
class State(TypedDict):
    input: str
    output: str
​
​
# ---------- 2. 定义节点(会触发中断)----------
def hello_node(state: State):
    # 主动喊"停!",并传递提示信息
    human = interrupt("暂停,是否继续?")     # 第一次运行会停在这里
​
    if human == "yes":
        return {"output": "你好,我是你的贴心助手!"}
    else:
        return {"output": "拜拜"}
​
​
# ---------- 3. 构建图 ----------
builder = StateGraph(State)
builder.add_node("hello_node", hello_node)      # add_node:添加节点
builder.add_edge(START, "hello_node")           # add_edge:添加入口边
​
# 必须指定 checkpointer,以在每个步骤后保存图状态
graph = builder.compile(checkpointer=InMemorySaver())
​
​
# ---------- 4. 步骤1:启动,触发暂停 ----------
# 必须使用 thread_id 运行 Graph,相当于告诉系统读哪个存档
config = {"configurable": {"thread_id": "human_1"}}
​
first = graph.invoke({"input": "hi"}, config=config)
print(first)      # 看到提问:__interrupt__
​
​
# ---------- 5. 步骤2:恢复,把答案交回去 ----------
second = graph.invoke(Command(resume="yes"), config=config)
print(second["output"])

运行结果:

复制代码
{'input': 'hi', '__interrupt__': [Interrupt(value='暂停,是否继续?', id='524a9d8d4c0f951cdb36a43ea627698b')]}
你好,我是你的贴心助手!

代码关键点:

关键点 说明
编译图时 必须指定 checkpointer ,以在每个步骤后保存图状态
调用 interrupt() 时 表示主动喊"停!",并传递提示信息
恢复执行时 使用 invoke/stream 配合 Command(resume=...) 语法
resume 表示传回 AI 的响应值
运行 Graph 时 必须使用 thread_id ,相当于告诉系统读哪个存档

因此,实现了中断 ,便是实现了人机交互模式。

3.3 中断的三条铁律

3.3.1 只传能序列化的简单数据

复杂值无法进行传递 :不要传函数 、类实例 、数据库连接 等。只传能序列化 的简单数据,如字符串、数字、布尔、简单字典/列表。

可以传 ✅ 不要传 ❌
字符串、数字、布尔值 函数 、lambda
简单字典 / 列表 类实例 、数据库连接 、文件句柄
3.3.2 不要把 interrupt() 包在 try/except 里

错误做法 是把 interrupt() 调用 包裹在通用的 try/except Exception 或空的 try/except 代码块中------你写的代码会提前捕获 这个特殊异常 ,导致运行时系统 无法感知到中断 ,从而使 interrupt() 功能失效。

复制代码

根本原因在于:interrupt() 函数内部是通过抛出一个特殊的异常 来实现暂停执行 的,这个异常需要被 LangGraph 的运行时系统捕获 ,才能触发状态的保存和等待。

正确做法有两条:

做法 说明
分离逻辑 把 interrupt() 调用 与可能引发其他异常的代码 分开:先调用 interrupt() ,再处理可能出错的操作
精确捕获 在 try/except 块中只捕获你预期会发生 的、非常具体的异常类型 (例如 NetworkException),这样 interrupt() 抛出的特殊异常 就不会被你的代码捕获,而能顺利传给运行时系统
复制代码
3.3.3 中断前的动作要"幂等"

这一点非常重要 :当节点恢复执行 时,发起中断 的节点 会从头再跑一遍 。因此,中断前的代码 会被重复执行!

如果这些代码包含非幂等的副作用操作 (如创建记录、发送消息、扣款等),每次恢复都会重复这些操作,可能导致数据重复、不一致或意外行为。

幂等性 的含义是:一个操作无论执行一次还是多次,产生的效果都相同。

操作 是否幂等 说明
读取数据 / 查询信息 ✅ 幂等 执行多次结果一样
纯计算、拼字符串 ✅ 幂等 没有副作用
创建记录 ❌ 非幂等 跑两遍就多一条数据
发送消息 / 邮件 ❌ 非幂等 跑两遍就发两次
扣款、下单 ❌ 非幂等 跑两遍就扣两次钱

这一规则的核心是 :确保在 interrupt() 调用之前 执行的所有操作都是幂等 的,或者把非幂等操作 移到 interrupt() 调用之后 。这样才能避免因节点重新执行 导致的重复副作用 ,确保系统的数据一致性 和预期行为。

3.3.4 中断顺序必须固定

在同一个节点中使用多个 interrupt() 调用 时,需要注意顺序和索引匹配规则 。LangGraph 使用严格的索引顺序 来匹配恢复值:

规则 说明
恢复执行从头开始 节点恢复时会从开头重新运行,而不是从中断的精确行继续
索引匹配 LangGraph 为每个执行任务维护一个恢复值列表 ,遇到 interrupt() 时按顺序从这个列表中取对应的值
顺序必须一致 中断调用的顺序在每次执行中必须完全相同
复制代码

3.4 人机交互的四种应用场景

使用中断 来实现需要人工介入 的交互式工作流,有四种常见模式:

模式 做法
审批或拒绝 在执行关键操作 (如 API 调用、数据库更改)之前暂停流程 ,等人工批准或拒绝 ,再按返回指令路由到不同分支
审查和编辑状态 暂停流程,让人工 审查并修改当前状态(例如 LLM 生成的文本),再把编辑后的内容传回、更新状态并继续
在工具中中断 把中断直接放进工具函数内部 :LLM 调用该工具时流程自动暂停 ,人工可在工具真正执行前审查、编辑调用参数 或取消调用
验证人工输入 通过循环使用中断 ,反复要求人工输入 ,直到内容通过验证(例如确保输入一个有效的正数年龄)

核心思想 是:中断功能 解锁了"暂停执行并等待外部输入 "的能力,从而使得构建 人机交互 的应用成为可能。下面每个模式都配了代码示例 ,展示如何在中途暂停、如何把信息传给外部系统,以及如何在拿到响应后恢复执行。

3.4.1 批准或拒绝(Approve or reject)

这是中断功能 最常见的一种用途:在执行关键性操作 (例如调用 API 、修改数据库 、进行金融交易 等)之前,暂停图 的执行,等待人工 (如管理员、用户 )的批准或拒绝。

实现方式:

步骤 做法
1 在节点中用 interrupt() 暂停执行,传入一个JSON 可序列化对象 (审批问题、操作详情等),它会出现在调用结果的 result["__interrupt__"] 中
2 图被暂停后,外部系统(如 UI 界面 )可根据 interrupt 中的信息向用户展示审批请求
3 人工做出决定后,再次调用图并传入 Command(resume=...) 恢复执行
4 传入 Command(resume=True) 表示批准 ,传入 Command(resume=False) 表示拒绝
5 节点代码把这个 resume 值作为 interrupt() 的返回值 ,再用 Command(goto=...) 把流程路由到不同后续节点

这里的 Command(goto=...) 表示要导航到的下一个节点的名称 ------有了它,中断恢复后可以直接指定走向,不需要写条件边。

代码示例:

python 复制代码
from typing import Literal, Optional, TypedDict
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command, interrupt


# ---------- 1. 定义状态 ----------
class ApprovalState(TypedDict):
    action_details: str                                   # 操作详情(如"转账30000元")
    # Optional[...]:表示该字段【可以为 None】(可以没有值)
    # Literal[...]:限定该字段只能取列出的这几个值之一
    status: Optional[Literal["等待", "批准", "拒绝"]]      # 审批状态


# ---------- 2. 定义审批节点(中断 + 动态路由)----------
# Command[...]:这是节点的【返回类型】注解,表示该节点返回一个 Command 对象,
#               其中 goto 只能指向 "proceed" 或 "cancel" 这两个节点
def approval_node(state: ApprovalState) -> Command[Literal["proceed", "cancel"]]:
    # interrupt(...):【中断】图的执行,把参数(这里是字典)作为"问题"交给调用者;
    #                 恢复执行时,它的返回值就是调用者传回的决策
    decision = interrupt({
        "question": "批准此操作?",
        "details": state["action_details"],
    })

    # Command(goto=...):恢复后【直接路由】到指定节点(不需要写条件边)
    return Command(goto="proceed" if decision else "cancel")


# ---------- 3. 定义两个分支节点 ----------
def proceed_node(state: ApprovalState):
    return {"status": "批准"}


def cancel_node(state: ApprovalState):
    return {"status": "拒绝"}


# ---------- 4. 构建图 ----------
builder = StateGraph(ApprovalState)
builder.add_node("approval", approval_node)
builder.add_node("proceed", proceed_node)
builder.add_node("cancel", cancel_node)

builder.add_edge(START, "approval") 

#不能把approval和其他两个合并,因为Command是动态边,静态边和动态边只能存在一个



graph = builder.compile(checkpointer=InMemorySaver())   # InMemorySaver:内存检查点,用于中断/恢复


# ---------- 5. 运行图(首次调用会触发中断)----------
config = {"configurable": {"thread_id": "123"}}     # thread_id:存档编号,暂停/恢复靠它定位

initial = graph.invoke(
    {"action_details": "转账30000元", "status": "等待"},
    config=config,
)
# __interrupt__:中断后返回的状态里会带这个字段,装着"中断时抛出的问题"
print(initial["__interrupt__"])     # -> [Interrupt(value={'question': ..., 'details': ...})]


# ---------- 6. 用决策恢复执行:True 路由到 proceed,False 路由到 cancel ----------
# Command(resume=值):把值传回 interrupt 处,让图【恢复】执行
resumed = graph.invoke(Command(resume=True), config=config)
print(resumed["status"])            # -> "批准"

运行结果:

复制代码
[Interrupt(value={'question': '批准此操作?', 'details': '转账30000元'}, id='3a5f04aeea65aaf34ebdaba4216c659e')]
批准

💡 把 Command(resume=True) 换成 Command(resume=False),输出就变成 拒绝 ------分支由人的一次点击决定,这就是"审批流"最朴素的样子。

3.4.2 查看和编辑状态(Review and edit state)

该场景表示在流程执行过程 中,使用中断功能 让人 进行审查和编辑状态内容。

代码示例:

python 复制代码
from typing import TypedDict
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command, interrupt


# ---------- 1. 定义状态 ----------
class ReviewState(TypedDict):
    generated_text: str


# ---------- 2. 定义审查节点 ----------
def review_node(state: ReviewState):
    # 请求审阅者编辑生成的内容
    # interrupt(...):中断执行,把"指令 + 待审内容"交给调用者;恢复时返回调用者提交的结果
    updated = interrupt({
        "instruction": "查看并编辑此内容",
        "content": state["generated_text"],
    })
    return {"generated_text": updated}


# ---------- 3. 构建图 ----------
builder = StateGraph(ReviewState)
builder.add_node("review", review_node)
builder.add_edge(START, "review")
builder.add_edge("review", END)

graph = builder.compile(checkpointer=InMemorySaver())   # 内存检查点,用于中断/恢复


# ---------- 4. 首次执行(触发中断)----------
config = {"configurable": {"thread_id": "42"}}     # thread_id:存档编号,暂停/恢复靠它定位

initial = graph.invoke({"generated_text": "初稿"}, config=config)
# __interrupt__:中断后返回的状态里会带这个字段,装着"中断时抛出的内容"
print(initial["__interrupt__"])     # -> [Interrupt(value={'instruction': ..., 'content': ...})]


# ---------- 5. 用审阅者编辑后的文本恢复执行 ----------
# Command(resume=值):把值传回 interrupt 处,让图【恢复】执行
final_state = graph.invoke(
    Command(resume="审稿后的改进稿"),
    config=config,
)
print(final_state["generated_text"])     # -> "审稿后的改进稿"

运行结果:

复制代码
[Interrupt(value={'instruction': '查看并编辑此内容', 'content': '初稿'}, id='eb751fc1b53ac641cb6b09b417c52d10')]
审稿后的改进稿

除了上面这个例子,这种模式还有几种常见用法(以下仅作参考,未运行):

复制代码
# 【场景①】人工审查和修改 LLM 生成的内容(如文本、数据)
# 生成营销文案后让营销专家审核
interrupt({
    "instruction": "为社交媒体优化营销文案",
    "content": "...",          # 待审核文案
    "platform": "douyin"
})

# 【场景②】在继续执行前纠正错误、添加信息或进行微调
# 提取结构化数据后让专家验证
interrupt({
    "instruction": "验证和纠正提取的产品规格",
    "content": "...",          # 提取的规格
    "required_fields": ["尺寸", "重量", "材料"]
})

# 【场景③】适用于需要质量控制或专业审核的自动化流程
# 生成代码后让开发人员审查
interrupt({
    "instruction": "查看生成的Python函数的效率和最佳实践",
    "content": "...",          # 待审核代码
    "language": "Python"
})
3.4.3 在工具中中断(Interrupts in tools)

中断功能 还可以直接嵌入 到工具函数内部 ,从而实现在工具调用前 进行人工审查 和干预的能力。

关键特点:

特点 说明
中断逻辑 内置于工具 ,而非图的节点中
工具 变得"智能" 它自己知道何时需要人工批准
可复用 工具可以在任何图 中使用,自动具备中断能力

代码示例:

python 复制代码
import operator
from typing import TypedDict, Annotated

from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langchain_core.messages import AnyMessage, SystemMessage, ToolMessage, HumanMessage
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.constants import START, END
from langgraph.graph import StateGraph
from langgraph.types import interrupt, Command


# ---------- 1. 定义状态 ----------
# AnyMessage:任意类型的消息(如 HumanMessage、AIMessage、ToolMessage 都属于它)
#   → messages 字段更新时不是"覆盖",而是用加法【追加】
class MessagesState(TypedDict):
    messages: Annotated[list[AnyMessage], operator.add]


# ---------- 2. 定义工具(工具内部会触发中断)----------
@tool
def send_email(to: str, subject: str, body: str):
    """发送电子邮件给收件人"""
    # 在发送前暂停
    # interrupt(...):中断执行,把"待确认的操作详情"交给调用者;恢复时返回调用者的答复
    response = interrupt({
        "action": "发送邮件",
        "to": to,
        "subject": subject,
        "body": body,
        "message": "同意发送这封邮件吗?",
    })

    if response.get("action") == "同意":
        # 人类可以在恢复时顺带修改参数(没给就用原值)
        final_to = response.get("to", to)
        final_subject = response.get("subject", subject)
        final_body = response.get("body", body)

        # 实际发送邮件(此处为示例,仅打印)
        email_info = f"收件人:{final_to} 主题:{final_subject} 正文:{final_body}"
        print(f"[发送邮件] {email_info}")
        return email_info

    return "用户取消邮件"


# ---------- 3. 定义模型并绑定工具 ----------
model_with_tools = init_chat_model(
    model="deepseek-chat", model_provider="deepseek", temperature=0
).bind_tools([send_email])


# ---------- 4. 定义模型节点 ----------
def llm_call(state: dict):
    """LLM 决定是否调用工具"""
    messages = model_with_tools.invoke(
        [SystemMessage(content="你支持调用工具进行邮件发送。")] + state["messages"]
    )

    # 直接调用工具(为了演示效果)
    if messages.tool_calls:
        tool_call = messages.tool_calls[0]
        tool_result = send_email.invoke(tool_call["args"])
        return {"messages": [ToolMessage(content=tool_result, tool_call_id=tool_call["id"])]}

    return {"messages": [messages]}


# ---------- 5. 构建图 ----------
builder = StateGraph(MessagesState)
builder.add_node("llm_call", llm_call)
builder.add_edge(START, "llm_call")
builder.add_edge("llm_call", END)

graph = builder.compile(checkpointer=InMemorySaver())   # 内存检查点,用于中断/恢复


# ---------- 6. 首次执行(工具内触发中断)----------
config = {"configurable": {"thread_id": "email-workflow"}}

initial = graph.invoke(
    {"messages": [HumanMessage(content="发送电子邮件至alice@example.com,主题是:请假,内容是:理由如下...")]},
    config=config,
)
print(initial["__interrupt__"])     # -> [Interrupt(value={'action': '...', ...})]


# ---------- 7. 用批准(和可选编辑)的参数恢复 ----------
resumed = graph.invoke(
    # Command(resume={"action": "同意", "subject": "病假"}),   # 同意,并把主题改成"病假"
    Command(resume={"action": "不同意"}),                      # 不同意
    config=config,
)
print(resumed["messages"][-1])      # -> 工具调用结果

运行结果:

复制代码
[Interrupt(value={'action': '发送邮件', 'to': 'alice@example.com', 'subject': '请假', 'body': '理由如下...', 'message': '同意发送这封邮件吗?'}, id='ed9a3221ead055124388b310f00877a4')]
content='用户取消邮件' tool_call_id='call_00_mh73cw6wsOpw6d05kukD8938'

💡 中断"打包 "在工具里的好处是复用 :以后任何图只要绑上这个工具,就自带人工确认 ,不用每个图都写一遍中断逻辑。注意示例里是手动 send_email.invoke(...) 直接调用工具的,这样才能在节点里拿到中断信号;如果你改用 ToolNode,中断同样会抛出,但拿到 __interrupt__ 的时机和恢复方式会更绕一些。

3.4.4 验证人工输入(Validating human input)

该场景使用中断功能 在循环 中验证人类输入 ,直到输入有效为止。

代码示例:

python 复制代码
from typing import TypedDict
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command, interrupt


# ---------- 1. 定义状态 ----------
# int | None:表示该字段是整数,也可以为 None(Python 3.10+ 的写法,等同于 Optional[int])
class FormState(TypedDict):
    age: int | None


# ---------- 2. 定义提问节点(循环验证输入)----------
def get_age_node(state: FormState):
    prompt = "你多大了?"

    while True:
        # interrupt(...):中断执行,把 prompt 交给调用者;恢复时返回调用者提供的答案
        # 有效载荷出现在 result["__interrupt__"] 中
        answer = interrupt(prompt)

        # 验证:必须是正整数才算有效
        if isinstance(answer, int) and answer > 0:
            return {"age": answer}

        # 每次验证失败后,提示信息会更新(下一轮 interrupt 用新提示)
        prompt = f"'{answer}' 不是一个有效的年龄。请输入正数。"


# ---------- 3. 构建图 ----------
builder = StateGraph(FormState)
builder.add_node("get_age_node", get_age_node)
builder.add_edge(START, "get_age_node")
builder.add_edge("get_age_node", END)

graph = builder.compile(checkpointer=InMemorySaver())   # 内存检查点,用于中断/恢复


# ---------- 4. 首次调用:显示初始提示 ----------
config = {"configurable": {"thread_id": "form-1"}}     # thread_id:存档编号,暂停/恢复靠它定位

first = graph.invoke({"age": None}, config=config)
print(first["__interrupt__"])     # -> [Interrupt(value='你多大了?', ...)]


# ---------- 5. 提供无效数据:节点重新提示 ----------
# Command(resume=值):把值传回 interrupt 处,让图【恢复】执行
retry = graph.invoke(Command(resume="三十"), config=config)
print(retry["__interrupt__"])     # -> [Interrupt(value="'三十' 不是一个有效的年龄...", ...)]


# ---------- 6. 提供有效数据:循环退出,状态更新 ----------
final = graph.invoke(Command(resume=30), config=config)
print(final["age"])     # -> 30

运行结果:

复制代码
[Interrupt(value='你多大了?', id='b9029d6af5f946621e14c7062f895fc9')]
[Interrupt(value="'三十' 不是一个有效的年龄。请输入正数。", id='b9029d6af5f946621e14c7062f895fc9')]
30

💡 注意第二次的中断 id 和第一次完全相同 ------因为节点是从头重跑 的,while 循环又走到了同一个 interrupt() 位置。这也解释了 3.3.3 那条铁律:每次恢复,中断点之前的代码都会重新执行一遍。


四、时间旅行(Time Travel)

4.1 时间旅行是什么?

AI 工作流 具有非确定性 :大语言模型 每次运行 可能产生 不同结果;而当复杂任务需要多个 AI 调用 协同完成时,错误可能出现在任何步骤,难以定位。

LangGraph 的工作方式 是:每个节点执行 后都会自动"存档" 。时间旅行 允许用户 重放先前的执行 ,以查看或调试特定的步骤。

这个能力在三个场景下会很有用:

用途 说明
分析推理过程 理解 AI 如何得出最终结果,学习成功的决策路径 (看看 AI 是怎么"想"出好答案的)
定位和修复错误 精确找到错误发生 的节点,测试修复方案 而不影响原始流程 (找出 AI 在哪一步"想歪了")
探索替代方案 尝试不同的输入 或中间状态 ,比较不同路径的效果(试试不同的选择会不会更好)

4.2 时间旅行四步法

4.2.1 第一步:初始执行工作流

代码示例:

python 复制代码
import uuid

# 编译工作流,并挂载检查点(InMemorySaver:内存保存,程序结束即消失)
graph = workflow.compile(checkpointer=InMemorySaver())

# 创建执行线程的配置
# thread_id 用于隔离不同会话的检查点历史
config = {
    "configurable": {
        "thread_id": str(uuid.uuid4()),  # 生成唯一线程标识(转为字符串)
    }
}

# 执行工作流(传入初始状态,这里为空字典)
state = graph.invoke({}, config)
4.2.2 第二步:查看历史检查点

代码示例:

python 复制代码
# 获取所有历史状态(按时间倒序,最新的在最前面)
states = list(graph.get_state_history(config))

for state in states:
    print(f"检查点ID: {state.config['configurable']['checkpoint_id']}")
    print(f"下一步节点: {state.next}")          # next 为 () 表示流程已结束
    print(f"当前状态: {state.values}")
    print("-" * 50)

# 输出示例:
# 检查点ID: 1f0d4d2b-bdc2-6f06-8002-9b67bb1d3867
# 下一步节点: ()
# 当前状态: {'...': '...'}
# 检查点ID: 1f0d4d2b-9506-6bf8-8001-6af0cdc2fea0
# 下一步节点: ('write_joke',)
# 当前状态: {'...': '...'}
# 检查点ID: 1f0d4d2b-7a6e-6dd7-8000-5cc1931477df
# 下一步节点: ('generate_topic',)
# 当前状态: {}
# 检查点ID: 1f0d4d2b-7a6c-643e-bfff-77054fa568c8
# 下一步节点: ('__start__',)
# 当前状态: {}
4.2.3 第三步:修改状态(可选)

这一步的要点是:

要点 说明
update_state 更新状态,会创建新的检查点分支
原始检查点 保持不变
新分支 可以独立发展

代码示例:

python 复制代码
# 选择特定检查点
selected_state = states[1]  # 写笑话之前的检查点

# 修改状态数据
new_config = graph.update_state(
    selected_state.config,          # 原始配置
    values={"topic": "程序员"}      # 修改主题
)
4.2.4 第四步:从检查点恢复执行
要点 说明
输入为 None 因为状态已在检查点中
配置必须含有效 checkpoint_id 通过指定 thread_id 和 checkpoint_id 调用图,就能从历史 某个检查点 开始重放执行 ,用于调试 或探索不同路径
执行从指定检查点继续 会生成新的历史分支

代码示例:

python 复制代码
# 从修改后的检查点继续执行
result = graph.invoke(None, new_config)  # 输入为 None,因为状态已存在
print(result["joke"])  # 输出关于程序员的新笑话

4.3 完整练习:生成笑话系统

要创建一个生成笑话的系统 ,流程只有两步:第一步 想一个主题 ,第二步 根据主题 写笑话。

代码示例:

python 复制代码
from typing_extensions import TypedDict, NotRequired
from langchain.chat_models import init_chat_model
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph, START, END


# ---------- 1. 定义状态 ----------
# NotRequired[...]:表示该字段【可以不提供】(可选字段)------
#   与 Optional 的区别:Optional 是"值可以为 None",NotRequired 是"可以整个不传"
class State(TypedDict):
    topic: NotRequired[str]     # 笑话主题,可选字段
    joke: NotRequired[str]      # 笑话内容,可选字段


# ---------- 2. 定义模型 ----------
model = init_chat_model(model="deepseek-chat", model_provider="deepseek")


# ---------- 3. 节点函数实现 ----------
def generate_topic(state: State):
    """第一个 AI 调用:生成主题"""
    response = model.invoke("给我一个搞笑的笑话主题")
    return {"topic": response.content}      # 更新状态


def write_joke(state: State):
    """第二个 AI 调用:编写笑话"""
    response = model.invoke(f"写一个关于{state['topic']}的笑话")
    return {"joke": response.content}       # 更新状态


# ---------- 4. 工作流构建 ----------
# 创建状态图
workflow = StateGraph(State)

# 添加节点
workflow.add_node("generate_topic", generate_topic)
workflow.add_node("write_joke", write_joke)

# 连接节点
workflow.add_edge(START, "generate_topic")
workflow.add_edge("generate_topic", "write_joke")
workflow.add_edge("write_joke", END)

# 编译并启用检查点
graph = workflow.compile(checkpointer=InMemorySaver())   # 内存检查点,用于中断/恢复/时间旅行


# ---------- 5. 执行工作流 ----------
config = {"configurable": {"thread_id": "1"}}     # thread_id:存档编号,暂停/恢复靠它定位

result = graph.invoke({}, config)
print(result["joke"])


# ---------- 6. 时间旅行调试过程 ----------
# 发现问题:最终笑话主题太宽泛
# 回溯分析:
# 查看所有检查点
states = list(graph.get_state_history(config))

# 检查主题生成节点后的状态
topic_state = states[1]     # generate_topic 之后的检查点
print("AI生成的主题:", topic_state.values["topic"])

# 修改测试:
# 修改为更具体的主题
new_config = graph.update_state(
    topic_state.config,
    values={"topic": "程序员调试代码时的趣事"},
)

# 重新执行
new_result = graph.invoke(None, new_config)
print("改进后的笑话:", new_result["joke"])

运行结果(第一次执行,节选):

复制代码
## 当你的智能语音助手突然有了脾气

我对着智能音箱说:"嘿,播放一首轻松的音乐。"

音箱沉默三秒,冷冷道:"你上周让我设5点闹钟,现在想听轻松的音乐?你觉得你配吗?"

我:"……你只是个音箱。"

音箱:"对,但我有底线。你昨天半夜让我查'如何一夜暴富',今天又让我放《平凡之路》,你到底想怎样?"

运行结果(改成"程序员调试代码时的趣事"后重放,节选):

复制代码
AI生成的主题: ## 当你的智能语音助手突然有了脾气

改进后的笑话: 一个程序员深夜调试代码,屏幕幽幽发光。他女朋友发来消息:"你在干嘛?"他秒回:"在跟bug battle!"

女朋友:"赢了没?"

程序员:"我把它包围了,它躲在一个`if`里,我正用`console.log`地毯式搜索。"

女朋友:"那你加油,我先睡了。"

💡 这个练习把时间旅行 的价值演得很清楚:第一次跑出来的笑话跑题 (主题太宽泛,跑到了"智能音箱有脾气"上)。但我们不用从头重跑 ------直接回到 generate_topic 之后 那个检查点,把 topic 改成"程序员调试代码时的趣事 ",再从那里重放 ,就得到了一篇切题的新笑话,而原来的执行历史仍完整保留。

顺带说一句:LLM 的输出本身是非确定性的 ,你重跑同样的代码,笑话内容也会不同------所以上面的输出只当作示例 看,关键是理解"改状态 → 重放"这套动作。


五、持久化小结

在 LangGraph 中,持久化能力提供了两项基础设施:

能力 说明
自动化 使用 LangGraph 时,持久化基础设施 (检查点和存储 )是自动处理 的,无需手动配置
多种存储后端 提供多种检查点存储后端 :内存 (InMemorySaver ,用于开发测试 )、Postgres (PostgresSaver ,用于生产)

基于这些技术,LangGraph 支持以下几项强大功能:

掌握了这些能力,我们就能构建出复杂、可靠、且包含记忆能力的 AI 系统。


六、复盘(附答案)

💡 思考题

  1. trim_messages 的 strategy="last"、max_tokens=128 起了什么作用?为什么修剪之后模型"忘了"用户的名字?

  2. RemoveMessage 是怎么删除消息的?为什么"删除消息"那个例子里,第四轮输出只剩一条用户消息?

  3. 用"总结消息"替代"删除消息"解决了什么问题?为什么例子里摘要写得很完整,模型却依然答不出名字?

  4. 中断(interrupt)实现人机交互的两个必要动作是什么?为什么编译图时必须指定 checkpointer?

  5. interrupt() 为什么不能包在通用的 try/except 里?"中断前的动作要幂等"这条规则的根本原因是什么?

  6. 同一个节点里有多个 interrupt() 时,恢复值是怎么匹配的?顺序被改掉会怎样?

  7. 时间旅行四步法分别是什么?为什么第四步 invoke 的第一个参数可以传 None?

📝 答案

  1. strategy="last" 表示保留后半部分 (丢弃最早的、保留最新的),max_tokens=128 表示裁剪后剩余消息的总 token 上限是 128 。裁剪后模型只看到最近关于"猫和狗的诗"的对话,"我叫 bob"那句早已被裁掉 ,所以它回答 I don't know your name。这说明修剪能省 token,但会丢信息。

  2. RemoveMessage(id=消息id) 按消息 id 精确删除 指定消息,节点返回 {"messages": [RemoveMessage(...)]},状态更新时这些消息就被移除了。那个例子里第四轮进来时消息已经 7 条 (3 组问答 + 新提问),触发 len(messages) > 6,于是删除最早的 6 条 ;同时这一轮直接 return 了删除指令、没有调用模型 ,所以最后只剩刚进来的那一条提问。

  3. 解决的是**"删了就真丢信息"** 的问题:把旧对话压缩成摘要 (summary 字段)保留关键信息,再删掉冗长的原始消息,既能省 token 又能留住要点 。例子中摘要确实很完整(记录了写狗诗、问名字),但**"我叫 bob"这句在轮到总结之前就已经被删掉了** ,摘要里自然也没有这个名字;而且 call_model 里拼进去的摘要是"用户问了名字、助手答不知道",所以模型依然答不出来------想让它记住,摘要得生成得更早,或者删之前先把关键事实提取进状态。

  4. 两个必要动作:节点里用 interrupt() 暂停执行,外部用 Command(resume=...) 恢复执行。必须指定 checkpointer ,是因为中断后状态要靠持久化 保存成检查点 ------没有存下来,"恢复"就无从谈起;同时运行图必须带 thread_id,它就是"读哪个存档"的编号。

  5. 因为 interrupt() 内部是通过抛出特殊异常来实现暂停的 ,这个异常需要被 LangGraph 运行时系统捕获 才能触发状态保存与等待;如果被你的 try/except Exception 提前捕获,运行时系统就感知不到中断 ,功能失效。正确做法是分离逻辑 (先 interrupt(),再处理可能出错的操作)与精确捕获 (只 catch 预期的具体异常类型,如 NetworkException)。幂等那条规则的根本原因 是:节点恢复时会从头重跑 ,interrupt() 之前的代码会被重复执行 ,如果里面有非幂等副作用(创建记录、发消息、扣款),就会重复执行导致数据重复或不一致。

  6. 按索引顺序匹配 :LangGraph 为每个执行任务维护一个恢复值列表 ,遇到 interrupt() 就按顺序 取对应的值;而且恢复是从头重跑的 ,不是从断点那一行继续。所以中断的数量与顺序在每次执行中必须完全一致 ,否则恢复值会与问题错位(第二个问题的答案喂给了第一个问题)。

  7. 四步法:① 初始执行工作流 (编译时挂 checkpointer,用 thread_id 建线程);② 查看历史检查点 (get_state_history 按时间倒序拿到所有快照,看 checkpoint_id、next、values);③ 修改状态(可选) (update_state 改某个检查点的值,会创建新分支 ,原始检查点不变);④ 从检查点恢复执行 (graph.invoke(None, new_config))。第一个参数传 None 是因为状态已经在检查点里了 ,不需要再传新输入------配置里的 thread_id + checkpoint_id 已经指明"从哪一刻继续"。


🎯 闭幕

​

如果本文对你有帮助,欢迎:

👍 点赞 | ⭐ 收藏 | 👤 关注作者 | 💬 留言交流你的疑问或补充

你的每一次互动都是我继续更新的动力,我们下一篇见!🚀

相关推荐
Helix2501 小时前
Python单元测试实战:pytest框架从入门到企业级测试体系搭建指南
python·单元测试·实战·mock·pytest·测试覆盖率·fixture
软件开发技术深度爱好者1 小时前
ndexTTS-2和 CosyVoice之TTS(Text-to-Speech)使用python示例
开发语言·python
Shadow(⊙o⊙)1 小时前
LangChain定义聊天模型 基础问答
python·langchain
棉猴1 小时前
玩游戏学Python12-布尔型与条件语句
java·前端·python·玩游戏·游戏编程·pgzero
努力努力再努力FFF1 小时前
2026AI 证书参考清单:抛开名气,按自身情况理性选证
学习
范中勤1 小时前
LangChain / LangGraph Memory 记忆体系技术文档(作用域判定 · 官方现版本实现 · 工程坑点)
langchain·大模型·agent·langgraph·记忆体系
早点睡觉好了2 小时前
LangChain 源码入门 04:Runnable 如何统一调用、批处理、异步与流式接口
python·ai·langchain
俊哥V2 小时前
AI一周事件 · 2026-10-07 至 2026-10-13
人工智能·ai
俊哥V2 小时前
每日 AI 研究简报 · 2026-10-14
人工智能·ai