LangGraph 记忆、人机交互、时间旅行和核心能力

文章目录

  • [1. 持久化实现的应用能力](#1. 持久化实现的应用能力)
    • [1.1 记忆(Memory)](#1.1 记忆(Memory))
      • [1.1.1 记忆概念](#1.1.1 记忆概念)
      • [4.1.2 管理短期记忆](#4.1.2 管理短期记忆)
        • [4.1.2.1 修剪消息](#4.1.2.1 修剪消息)
        • [4.1.2.2 删除消息](#4.1.2.2 删除消息)
        • [4.1.2.3 总结消息](#4.1.2.3 总结消息)
    • [1.2 人机交互(Human-in-the-Loop)](#1.2 人机交互(Human-in-the-Loop))
      • [1.2.1 中断(Interrupts)](#1.2.1 中断(Interrupts))
      • [1.2.2 中断如何实现?](#1.2.2 中断如何实现?)
      • [1.2.3 中断法则](#1.2.3 中断法则)
        • [1.2.3.1 只能传序列化的简单数据](#1.2.3.1 只能传序列化的简单数据)
        • [1.2.3.2 不应该将 interrupt() 调用包裹在 try/except 代码块中](#1.2.3.2 不应该将 interrupt() 调用包裹在 try/except 代码块中)
        • [1.2.3.3 中断前的动作要"幂等"](#1.2.3.3 中断前的动作要“幂等”)
        • [1.2.3.4 中断顺序固定](#1.2.3.4 中断顺序固定)
      • [1.2.4 人机交互的应用场景](#1.2.4 人机交互的应用场景)
        • [1.2.4.1 批准或拒绝(Approve or reject)](#1.2.4.1 批准或拒绝(Approve or reject))
        • [1.2.4.2 查看和编辑状态(Review and edit state)](#1.2.4.2 查看和编辑状态(Review and edit state))
        • [1.2.4.3 在工具中中断(Interrupts in tools)](#1.2.4.3 在工具中中断(Interrupts in tools))
        • [1.2.4.4 验证人工输入(Validating human input)](#1.2.4.4 验证人工输入(Validating human input))
    • [1.3 时间旅行(Time Travel)](#1.3 时间旅行(Time Travel))
      • [1.3.1 时间旅行是什么?](#1.3.1 时间旅行是什么?)
      • [1.3.2 时间旅行四步法](#1.3.2 时间旅行四步法)
        • [1.3.2.1 第一步:初始执行工作流](#1.3.2.1 第一步:初始执行工作流)
        • [1.3.2.2 第二步:查看历史检查点](#1.3.2.2 第二步:查看历史检查点)
        • [1.3.2.3 第三步:修改状态(可选)](#1.3.2.3 第三步:修改状态(可选))
        • [1.3.2.4 第四步:从检查点恢复执行](#1.3.2.4 第四步:从检查点恢复执行)
      • [1.3.3 完整示例](#1.3.3 完整示例)
        • [1.3.3.1 状态类型定义](#1.3.3.1 状态类型定义)
        • [1.3.3.2 节点函数实现](#1.3.3.2 节点函数实现)
        • [1.3.3.3 工作流构建](#1.3.3.3 工作流构建)
        • [4.3.3.4 时间旅行调试过程](#4.3.3.4 时间旅行调试过程)
      • [5. 持久化小结](#5. 持久化小结)
  • [2. LangGraph其他核心能力](#2. LangGraph其他核心能力)
    • [2.1 运行时上下文(Runtime context)](#2.1 运行时上下文(Runtime context))
      • [2.1.1 什么是运行时上下文?](#2.1.1 什么是运行时上下文?)
        • [2.1.1.1 上下文定义与分类](#2.1.1.1 上下文定义与分类)
        • [2.1.1.2 场景练习](#2.1.1.2 场景练习)
      • [2.1.2 配置运行时上下文](#2.1.2 配置运行时上下文)
        • [2.1.2.1 定义上下文模式](#2.1.2.1 定义上下文模式)
        • [2.1.2.2 在图中使用上下文模式](#2.1.2.2 在图中使用上下文模式)
        • [2.1.2.3 在节点中访问上下文](#2.1.2.3 在节点中访问上下文)
        • [2.1.2.4 在工具中访问上下文](#2.1.2.4 在工具中访问上下文)
    • [2.2 流(Streaming)](#2.2 流(Streaming))
      • [2.2.1 概念](#2.2.1 概念)
      • [2.2.2 五种流模式](#2.2.2 五种流模式)
      • [2.2.3 流式输出状态值](#2.2.3 流式输出状态值)
      • [2.2.4 流式传输自定义数据](#2.2.4 流式传输自定义数据)
        • [2.4.1 基本用法](#2.4.1 基本用法)
          • [2.4.1.1 从节点和工具中输出用户自定义数据](#2.4.1.1 从节点和工具中输出用户自定义数据)
          • [2.4.1.2 设置多种传输模式](#2.4.1.2 设置多种传输模式)
        • [2.4.2 应用场景](#2.4.2 应用场景)
          • [2.4.2.1 创建自定义监控面板](#2.4.2.1 创建自定义监控面板)
      • [2.2.5 流式传输 LLM tokens](#2.2.5 流式传输 LLM tokens)
        • [2.2.5.1 基本用法](#2.2.5.1 基本用法)
        • [2.2.5.2 高级功能](#2.2.5.2 高级功能)
          • [2.2.5.2.1 按 Tags 过滤 Tokens](#2.2.5.2.1 按 Tags 过滤 Tokens)
          • [2.2.5.2.2 按节点名称过滤](#2.2.5.2.2 按节点名称过滤)

1. 持久化实现的应用能力

1.1 记忆(Memory)

1.1.1 记忆概念

记忆,是一种能够记住之前互动信息的系统。对于人工智能代理来说,记忆至关重要,因为它使他们能够记住任务,这一能力对反馈中学习,并根据用户偏好进行调整。随着代理处理涉及大量用户交互的复杂任务之前的互动,从效率和用户满意度都变得至关重要。

区分记忆和持久化的概念:

  • 持久化为LangGraph底层能力,包含【线程级】持久化和【跨会话】持久化
  • 记忆为LangGraph能实现的应用层能力,包含【短期记忆】和【长期记忆】

在应用层,短期记忆就由线程级持久化实现,长期记忆由跨会话持久化实现。
#mermaid-svg-oHux9Fj8RT7dgDUq{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-oHux9Fj8RT7dgDUq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-oHux9Fj8RT7dgDUq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-oHux9Fj8RT7dgDUq .error-icon{fill:#552222;}#mermaid-svg-oHux9Fj8RT7dgDUq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-oHux9Fj8RT7dgDUq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-oHux9Fj8RT7dgDUq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-oHux9Fj8RT7dgDUq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-oHux9Fj8RT7dgDUq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-oHux9Fj8RT7dgDUq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-oHux9Fj8RT7dgDUq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-oHux9Fj8RT7dgDUq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-oHux9Fj8RT7dgDUq .marker.cross{stroke:#333333;}#mermaid-svg-oHux9Fj8RT7dgDUq svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-oHux9Fj8RT7dgDUq p{margin:0;}#mermaid-svg-oHux9Fj8RT7dgDUq .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-oHux9Fj8RT7dgDUq .cluster-label text{fill:#333;}#mermaid-svg-oHux9Fj8RT7dgDUq .cluster-label span{color:#333;}#mermaid-svg-oHux9Fj8RT7dgDUq .cluster-label span p{background-color:transparent;}#mermaid-svg-oHux9Fj8RT7dgDUq .label text,#mermaid-svg-oHux9Fj8RT7dgDUq span{fill:#333;color:#333;}#mermaid-svg-oHux9Fj8RT7dgDUq .node rect,#mermaid-svg-oHux9Fj8RT7dgDUq .node circle,#mermaid-svg-oHux9Fj8RT7dgDUq .node ellipse,#mermaid-svg-oHux9Fj8RT7dgDUq .node polygon,#mermaid-svg-oHux9Fj8RT7dgDUq .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-oHux9Fj8RT7dgDUq .rough-node .label text,#mermaid-svg-oHux9Fj8RT7dgDUq .node .label text,#mermaid-svg-oHux9Fj8RT7dgDUq .image-shape .label,#mermaid-svg-oHux9Fj8RT7dgDUq .icon-shape .label{text-anchor:middle;}#mermaid-svg-oHux9Fj8RT7dgDUq .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-oHux9Fj8RT7dgDUq .rough-node .label,#mermaid-svg-oHux9Fj8RT7dgDUq .node .label,#mermaid-svg-oHux9Fj8RT7dgDUq .image-shape .label,#mermaid-svg-oHux9Fj8RT7dgDUq .icon-shape .label{text-align:center;}#mermaid-svg-oHux9Fj8RT7dgDUq .node.clickable{cursor:pointer;}#mermaid-svg-oHux9Fj8RT7dgDUq .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-oHux9Fj8RT7dgDUq .arrowheadPath{fill:#333333;}#mermaid-svg-oHux9Fj8RT7dgDUq .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-oHux9Fj8RT7dgDUq .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-oHux9Fj8RT7dgDUq .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oHux9Fj8RT7dgDUq .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-oHux9Fj8RT7dgDUq .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oHux9Fj8RT7dgDUq .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-oHux9Fj8RT7dgDUq .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-oHux9Fj8RT7dgDUq .cluster text{fill:#333;}#mermaid-svg-oHux9Fj8RT7dgDUq .cluster span{color:#333;}#mermaid-svg-oHux9Fj8RT7dgDUq div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-oHux9Fj8RT7dgDUq .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-oHux9Fj8RT7dgDUq rect.text{fill:none;stroke-width:0;}#mermaid-svg-oHux9Fj8RT7dgDUq .icon-shape,#mermaid-svg-oHux9Fj8RT7dgDUq .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oHux9Fj8RT7dgDUq .icon-shape p,#mermaid-svg-oHux9Fj8RT7dgDUq .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-oHux9Fj8RT7dgDUq .icon-shape .label rect,#mermaid-svg-oHux9Fj8RT7dgDUq .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oHux9Fj8RT7dgDUq .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-oHux9Fj8RT7dgDUq .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-oHux9Fj8RT7dgDUq :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} LangGraph能力
AI应用能力
短期记忆
长期记忆
线程级持久化
跨会话持久化

  • 短期记忆:单次会话中保持的上下文信息
  • 长期记忆:跨会话保存的用户或应用数据

4.1.2 管理短期记忆

讲述具体场景时,如何对记忆进行管理。例如当消息记录过多,需要进行消息裁剪、总结消息、消息删除等操作。

4.1.2.1 修剪消息

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

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

model = init_chat_model("gpt-4o-mini", temperature=0)

def call_model(state: MessagesState):
    # 只保留最近的128个token的消息
    messages = trim_messages(
        state["messages"],
        strategy="last",        # 策略: 保留最后的部分
        token_counter=model,    # 计算token数量
        max_tokens=128,         # 最大token数
        start_on="human",  # 从用户消息开始
        end_on=("human","tool")  # 结束于用户消息或工具消息
    )
    response = model.invoke(messages)
    return {"messages": [response]}

checkpointer = InMemorySaver()
builder = StateGraph(MessagesState)
builder.add_node(call_model)
builder.add_edge(START, "call_model")
graph = builder.compile(checkpointer=checkpointer)

config = {"configurable": {"thread_id": "1"}}
graph.invoke({"messages": "hi, my name is bob"}, config)
graph.invoke({"messages": "write a short poem about dogs"}, config)
graph.invoke({"messages": "now do the same but for cats"}, config)
final_response = graph.invoke({"messages": "what's my name?"}, config)
final_response["messages"][-1].pretty_print()

运行结果如下:

plain 复制代码
================================== Ai Message ==================================
[{'type': 'text', 'text': "I don't know your name unless you tell me.", 'annotations': [], 'id': 'msg_08397679739155b4016a5cb37fcb38819992ec6859b3a988c9', 'phase': 'final_answer'}]
4.1.2.2 删除消息

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

python 复制代码
def call_model(state: MessagesState):
    messages = state["messages"]

    if len(messages) > 6:
        # 删除最早的6条消息
        return {
            "messages": [RemoveMessage(id=m.id) for m in messages[:6]]
        }

    response = model.invoke(messages)
    return {"messages": [response]}

# .....

# 测试: 可以发现只剩最后一条消息了
for message in final_response["messages"]:
    message.pretty_print()

删除所有消息:

python 复制代码
from langgraph.graph.message import REMOVE_ALL_MESSAGES

def call_model(state: MessagesState):
    return {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES)]}
4.1.2.3 总结消息

实际上,修剪或删除消息也会存在问题:可能会因剔除消息而丢失信息。因此,某些应用更希望将消息历史记录进行总结,把旧的对话内容总结成简短摘要,保留关键信息,以代替冗长的历史记录。
#mermaid-svg-40B8r51AF9M6mk5r{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-40B8r51AF9M6mk5r .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-40B8r51AF9M6mk5r .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-40B8r51AF9M6mk5r .error-icon{fill:#552222;}#mermaid-svg-40B8r51AF9M6mk5r .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-40B8r51AF9M6mk5r .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-40B8r51AF9M6mk5r .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-40B8r51AF9M6mk5r .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-40B8r51AF9M6mk5r .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-40B8r51AF9M6mk5r .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-40B8r51AF9M6mk5r .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-40B8r51AF9M6mk5r .marker{fill:#333333;stroke:#333333;}#mermaid-svg-40B8r51AF9M6mk5r .marker.cross{stroke:#333333;}#mermaid-svg-40B8r51AF9M6mk5r svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-40B8r51AF9M6mk5r p{margin:0;}#mermaid-svg-40B8r51AF9M6mk5r .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-40B8r51AF9M6mk5r .cluster-label text{fill:#333;}#mermaid-svg-40B8r51AF9M6mk5r .cluster-label span{color:#333;}#mermaid-svg-40B8r51AF9M6mk5r .cluster-label span p{background-color:transparent;}#mermaid-svg-40B8r51AF9M6mk5r .label text,#mermaid-svg-40B8r51AF9M6mk5r span{fill:#333;color:#333;}#mermaid-svg-40B8r51AF9M6mk5r .node rect,#mermaid-svg-40B8r51AF9M6mk5r .node circle,#mermaid-svg-40B8r51AF9M6mk5r .node ellipse,#mermaid-svg-40B8r51AF9M6mk5r .node polygon,#mermaid-svg-40B8r51AF9M6mk5r .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-40B8r51AF9M6mk5r .rough-node .label text,#mermaid-svg-40B8r51AF9M6mk5r .node .label text,#mermaid-svg-40B8r51AF9M6mk5r .image-shape .label,#mermaid-svg-40B8r51AF9M6mk5r .icon-shape .label{text-anchor:middle;}#mermaid-svg-40B8r51AF9M6mk5r .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-40B8r51AF9M6mk5r .rough-node .label,#mermaid-svg-40B8r51AF9M6mk5r .node .label,#mermaid-svg-40B8r51AF9M6mk5r .image-shape .label,#mermaid-svg-40B8r51AF9M6mk5r .icon-shape .label{text-align:center;}#mermaid-svg-40B8r51AF9M6mk5r .node.clickable{cursor:pointer;}#mermaid-svg-40B8r51AF9M6mk5r .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-40B8r51AF9M6mk5r .arrowheadPath{fill:#333333;}#mermaid-svg-40B8r51AF9M6mk5r .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-40B8r51AF9M6mk5r .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-40B8r51AF9M6mk5r .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-40B8r51AF9M6mk5r .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-40B8r51AF9M6mk5r .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-40B8r51AF9M6mk5r .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-40B8r51AF9M6mk5r .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-40B8r51AF9M6mk5r .cluster text{fill:#333;}#mermaid-svg-40B8r51AF9M6mk5r .cluster span{color:#333;}#mermaid-svg-40B8r51AF9M6mk5r div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-40B8r51AF9M6mk5r .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-40B8r51AF9M6mk5r rect.text{fill:none;stroke-width:0;}#mermaid-svg-40B8r51AF9M6mk5r .icon-shape,#mermaid-svg-40B8r51AF9M6mk5r .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-40B8r51AF9M6mk5r .icon-shape p,#mermaid-svg-40B8r51AF9M6mk5r .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-40B8r51AF9M6mk5r .icon-shape .label rect,#mermaid-svg-40B8r51AF9M6mk5r .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-40B8r51AF9M6mk5r .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-40B8r51AF9M6mk5r .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-40B8r51AF9M6mk5r :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Summarize
Filter
过滤后对话
Human message
AI message
Human message
原始对话
Human message
AI message
Human message
AI message
Human message
LLM
Summary

先将State进行扩展,除了对话记录,还包含一个总结摘要字段:

python 复制代码
from langgraph.graph import MessagesState

class State(MessagesState):
    summary: str

现在要求:

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

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

完整代码如下所示:

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

model = ChatOpenAI(
    model="gpt-5.4",
    api_key=os.getenv("PACKYAPI_API_KEY"),
    base_url="https://www.packyapi.com/v1/",
    use_responses_api=True,
    output_version="responses/v1",
)

# 每次聊天后自动总结,压缩上下文
class State(MessagesState):
    summary: str

def call_model(state: State):
    """调用模型,带上总结+用户问题生成AI回复"""
    # 使用历史总结+最新消息发起调用
    summary = state.get("summary", "")
    messages = [HumanMessage(content=summary)] + state["messages"]
    return {"messages": [model.invoke(messages)]}

def summarize_conversation(state: State):
    """根据旧summary+最新消息生成新的summary"""
    # 生成历史总结
    # 1. 创建总结提示词
    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. 删除历史对话: 除了最新的AI消息,都可以删除
    return {
        "summary": response.content,  # 历史总结
        "messages": [RemoveMessage(id=m.id) for m in state["messages"][:-1]]  # 保留最后的消息是为了打印结果
    }


builder = StateGraph(State)
builder.add_node(call_model)
builder.add_node("summarize", summarize_conversation)
builder.add_edge(START, "call_model")
builder.add_edge("call_model", "summarize")  # 每次对话完,进行总结
graph = builder.compile(checkpointer=InMemorySaver())

config = {"configurable": {"thread_id": "1"}}
graph.invoke({"messages": "hi, my name is bob"}, config)
graph.invoke({"messages": "write a short poem about dogs"}, config)
graph.invoke({"messages": "now do the same but for cats"}, config)
final_response = graph.invoke({"messages": "what's my name?"}, config)
print("\n=========== final response ===========")
print(final_response["messages"][-1].content)
print("\n=========== summary ===========")
print(final_response["summary"])

# 打印结果如下:
# =================================== Ai Message
# Your name is Bob.
#
# Summary: 对话摘要: 用户自我介绍为Bob,并询问如何获得帮助。随后,用户请求写一首关于猫的短诗。接着,用户又请求写一首关于狗的短诗。用户对动物诗歌表现出兴趣,可能希望进一步采访与宠物相关的主题或创作。

扩展:LangMem是一个由LangChain维护的库。它提供了可与任何存储系统一起使用的功能原语,也提供了与LangGraph中存储层的本机集成 。例如上述实现的记忆-消息摘要功能,在LangMem中专门提供了记忆管理库(如:SummarizationNode),简化了总结消息的过程。

1.2 人机交互(Human-in-the-Loop)

1.2.1 中断(Interrupts)

中断是 LangGraph 实现人机协同(Human-in-the-Loop)的核心原语,指在状态图的执行流程中主动暂停工作流,将控制权交还给外部调用方(人类用户、业务系统),等待外部输入后再从中断点无缝恢复执行的机制。

两大类型
  1. 静态中断

    在图编译阶段或每次运行调用时,通过 interrupt_before(指定节点执行前暂停)、interrupt_after(指定节点执行后暂停)声明固定断点,属于声明式的暂停方式,适合流程中明确需要人工介入的固定节点,也可在运行时动态调整断点位置。

  2. 动态中断

    在节点的业务逻辑内部调用 interrupt() 函数主动触发,可根据当前图的运行状态做条件判断,还能自定义中断提示信息返回给调用方,灵活性更高,适合条件性的人工介入场景。

1.2.2 中断如何实现?

在工作流中,想要实现暂停与恢复需要:

  • 通过调用 interrupt() 方法中断执行流程,依靠持久化能力,保存当前状态。
  • 外部用户通过发送 Command 对象,使得工作流恢复执行流程。

交互流程如下图所示:
外部/人 LangGraph 外部/人 LangGraph #mermaid-svg-FBChQvsf6Yj5i8ZT{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FBChQvsf6Yj5i8ZT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FBChQvsf6Yj5i8ZT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FBChQvsf6Yj5i8ZT .error-icon{fill:#552222;}#mermaid-svg-FBChQvsf6Yj5i8ZT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FBChQvsf6Yj5i8ZT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FBChQvsf6Yj5i8ZT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FBChQvsf6Yj5i8ZT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FBChQvsf6Yj5i8ZT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FBChQvsf6Yj5i8ZT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FBChQvsf6Yj5i8ZT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FBChQvsf6Yj5i8ZT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FBChQvsf6Yj5i8ZT .marker.cross{stroke:#333333;}#mermaid-svg-FBChQvsf6Yj5i8ZT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FBChQvsf6Yj5i8ZT p{margin:0;}#mermaid-svg-FBChQvsf6Yj5i8ZT .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-FBChQvsf6Yj5i8ZT text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-FBChQvsf6Yj5i8ZT .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-FBChQvsf6Yj5i8ZT .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-FBChQvsf6Yj5i8ZT .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-FBChQvsf6Yj5i8ZT .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-FBChQvsf6Yj5i8ZT #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-FBChQvsf6Yj5i8ZT .sequenceNumber{fill:white;}#mermaid-svg-FBChQvsf6Yj5i8ZT #sequencenumber{fill:#333;}#mermaid-svg-FBChQvsf6Yj5i8ZT #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-FBChQvsf6Yj5i8ZT .messageText{fill:#333;stroke:none;}#mermaid-svg-FBChQvsf6Yj5i8ZT .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-FBChQvsf6Yj5i8ZT .labelText,#mermaid-svg-FBChQvsf6Yj5i8ZT .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-FBChQvsf6Yj5i8ZT .loopText,#mermaid-svg-FBChQvsf6Yj5i8ZT .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-FBChQvsf6Yj5i8ZT .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-FBChQvsf6Yj5i8ZT .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-FBChQvsf6Yj5i8ZT .noteText,#mermaid-svg-FBChQvsf6Yj5i8ZT .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-FBChQvsf6Yj5i8ZT .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-FBChQvsf6Yj5i8ZT .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-FBChQvsf6Yj5i8ZT .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-FBChQvsf6Yj5i8ZT .actorPopupMenu{position:absolute;}#mermaid-svg-FBChQvsf6Yj5i8ZT .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-FBChQvsf6Yj5i8ZT .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-FBChQvsf6Yj5i8ZT .actor-man circle,#mermaid-svg-FBChQvsf6Yj5i8ZT line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-FBChQvsf6Yj5i8ZT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 运行节点 执行interrupt() 暂停并保存状态 状态快照已保存 __interrupt__返回 控制权移交 人类决策/审批 系统处理/输入 Command(resume="结果") 收到外部指令 恢复保存的状态 继续执行节点

直接看代码:

python 复制代码
from typing import TypedDict

from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import interrupt,Command
from langgraph.graph import StateGraph, START, MessagesState,END

class State(MessagesState):
    input: str
    output: str

def node(state: State):
    """进行动态中断操作"""
    result = interrupt("结束还是继续?是或否") # 可以传入普通类型
    if result == "是":
        return {"output": "继续执行"}
    else:
        return {"output": "结束执行"}

builder=StateGraph(State)
builder.add_node(node)
builder.add_edge(START, "node")
builder.add_edge("node", END)
graph=builder.compile(checkpointer=InMemorySaver())
# 必须指明同一个线程,与checkpoinrt搭配使用
config={"configurable": {"thread_id": "thread_1"}}
print(graph.invoke({"input":"开始调用"},config=config)) # 这是第一次调用,执行到中断点
print(graph.invoke(Command(resume="否"), config=config)) # 输入中断结果,继续执行

输出结果:

plain 复制代码
{'messages': [], 'input': '开始调用', '__interrupt__': [Interrupt(value='结束还是继续?是或否', id='32d339e3f00a3afdc8c5c4d559ef9c2c')]}
{'messages': [], 'input': '开始调用', 'output': '结束执行'}

代码关键点:

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

1.2.3 中断法则

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

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

正面示例:

python 复制代码
def node_a(state: State):
    # 正确: 传递可序列化的简单类型
    name = interrupt("What's your name?")
    count = interrupt(42)
    approved = interrupt(True)
    return {"name": name, "count": count, "approved": approved}
python 复制代码
def node_a(state: State):
    # 正确: 传递带有简单值的字典
    response = interrupt({
        "question": "Enter user details",
        "fields": ["name", "email", "age"],
        "current_values": state.get("user", {})
    })
    return {"user": response}

反面示例:

python 复制代码
def validate_input(value):
    return len(value) > 0

def node_a(state: State):
    # 错误: 传递一个函数来实现中断
    # 函数不能被序列化
    response = interrupt({
        "question": "What's your name?",
        "validator": validate_input
    })
    return {"name": response}
python 复制代码
class DataProcessor:
    def __init__(self, config):
        self.config = config

def node_a(state: State):
    processor = DataProcessor({"mode": "strict"})
    # 错误: 传递一个类实例来实现中断
    # 实例不能被序列化
    response = interrupt({
        "question": "Enter data to process",
        "processor": processor
    })
    return {"result": response}
1.2.3.2 不应该将 interrupt() 调用包裹在 try/except 代码块中

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

反面示例:

python 复制代码
def node_a(state: State):
    # 错误: 在try/except中包装中断
    try:
        interrupt("What's your name?")
    except Exception as e:
        print(e)
    return state

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

正确做法:

  • 分离逻辑:将 interrupt() 调用与可能引发其他异常的代码分开。先调用 interrupt(),然后再处理可能出错的操作。
  • 精确捕获:在 try/except 块中只捕获你预期会发生的、非常具体的异常类型(例如 NetworkException)。这样,interrupt() 抛出的特殊异常就不会被你的代码捕获,而能顺利传递给运行时系统。

正面示例:

python 复制代码
def node_a(state: State):
    # 正确: 先中断,再处理
    interrupt("What's your name?")

    try:
        # 将中断调用与易出错代码分开
        fetch_data()
    except Exception as e:
        print(e)
    return state
python 复制代码
def node_a(state: State):
    # 正确: 捕捉特定的异常类型
    name = interrupt("What's your name?")
    try:
        fetch_data()
    except NetworkException as e:
        print(e)
    return state

使用常规的错误处理模式会导致 interrupt() 机制失效。必须让 interrupt() 抛出的特殊异常能够"逃逸"出你编写的节点函数,以便被LangGraph运行时正确处理。

1.2.3.3 中断前的动作要"幂等"

LangGraph 的 interrupt() 恢复时,不是从 interrupt() 下一行继续跑,而是会重新执行整个节点函数。

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

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

正面示例:

python 复制代码
# 使用幂等操作
def node_a(state: State):
    # 正确: 使用 upsert (更新或插入) 操作,多次执行结果一致
    db.upsert_user(
        user_id=state["user_id"],
        status="pending_approval"
    )
    approved = interrupt("Approve this change?")
    return {"approved": approved}
python 复制代码
# 将副作用放在中断之后
def node_a(state: State):
    # 正确: 先中断,获得批准后再执行副作用
    approved = interrupt("Approve this change?")
    if approved:
        db.create_audit_log(
            user_id=state["user_id"],
            action="approved"
        )
    return {"approved": approved}
python 复制代码
# 将副作用分离到独立节点
def approval_node(state: State):
    # 只处理中断
    approved = interrupt("Approve this change?")
    return {"approved": approved}

def notification_node(state: State):
    # 正确: 副作用在独立节点中,仅在获得批准后执行一次
    if state["approved"]:
        send_notification(user_id=state["user_id"], status="approved")
    return state

反面示例:

python 复制代码
# 在中断前创建新记录
def node_a(state: State):
    # 错误: 每次恢复都会创建新的审计记录
    audit_id = db.create_audit_log({
        "user_id": state["user_id"],
        "action": "pending_approval",
        datetime.now()
    })
    approved = interrupt("Approve this change?")
    return {"approved": approved, "audit_id": audit_id}
python 复制代码
# 在中断前追加到列表
def node_a(state: State):
    # 错误: 每次恢复都会重复追加相同条目
    db.append_to_history(
        state["user_id"],
        "approval_requested"
    )
    approved = interrupt("Approve this change?")
    return {"approved": approved}

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

1.2.3.4 中断顺序固定

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

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

正面示例:

python 复制代码
def node_a(state: State):
    # 正确: 中断调用顺序固定
    name = interrupt("What's your name?")    # 索引0
    age = interrupt("What's your age?")      # 索引1
    city = interrupt("What's your city?")    # 索引2
    return {"name": name, "age": age, "city": city}

反面示例:

python 复制代码
# 条件性跳过中断
def node_a(state: State):
    name = interrupt("What's your name?")
    # 错误: 索引可能跳过,恢复时可能不跳过,导致索引错乱
    if state.get("needs_age"):
        age = interrupt("What's your age?")  # 索引1 (有时存在)
    city = interrupt("What's your city?")    # 索引2 (不确定)
python 复制代码
# 基于非确定性数据的循环中断
def node_a(state: State):
    # 错误: 中断数量随动态列表变化
    results = []
    for item in state["dynamic_list"]:
        result = interrupt(f"Approve {item}?")
        results.append(result)

1.2.4 人机交互的应用场景

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

  1. 审批或拒绝:在执行关键操作(如API调用、数据库更改)之前暂停流程,等待人工批准或拒绝。根据返回的指令,流程图会路由到不同的分支。
  2. 审查和编辑状态:暂停流程,让人工可以审查并修改流程图当前的状态(例如,LLM生成的文本内容),然后将编辑后的内容传回,更新状态并继续执行。
  3. 在工具中中断:将中断直接置于工具函数内部。当LLM调用该工具时,流程会自动暂停,允许人工在工具实际执行前审查、编辑其调用参数或直接取消调用。
  4. 验证人工输入:通过循环使用中断,反复收集和验证输入,直到输入内容通过验证(例如,确保输入一个有效的正年龄)。这适用于需要收集和验证数据的场景。

这部分的核心思想是:中断功能解锁了"暂停执行并等待外部输入"的能力,从而使得构建人机交互(human-in-the-loop)的应用成为可能。每个模式都附带了简明的代码示例,展示了如何在中途暂停、如何将信息传递给外部系统,以及如何在获得响应后恢复执行。

1.2.4.1 批准或拒绝(Approve or reject)

这是中断功能最常见的一种用途。在执行关键性操作(例如调用API、修改数据库、进行金融交易等)之前,暂停图(graph)的执行,等待人工(如管理员、用户)的批准或拒绝。
#mermaid-svg-GsJxi4tYR7PLj00Z{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-GsJxi4tYR7PLj00Z .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-GsJxi4tYR7PLj00Z .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-GsJxi4tYR7PLj00Z .error-icon{fill:#552222;}#mermaid-svg-GsJxi4tYR7PLj00Z .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-GsJxi4tYR7PLj00Z .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-GsJxi4tYR7PLj00Z .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-GsJxi4tYR7PLj00Z .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-GsJxi4tYR7PLj00Z .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-GsJxi4tYR7PLj00Z .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-GsJxi4tYR7PLj00Z .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-GsJxi4tYR7PLj00Z .marker{fill:#333333;stroke:#333333;}#mermaid-svg-GsJxi4tYR7PLj00Z .marker.cross{stroke:#333333;}#mermaid-svg-GsJxi4tYR7PLj00Z svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-GsJxi4tYR7PLj00Z p{margin:0;}#mermaid-svg-GsJxi4tYR7PLj00Z .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-GsJxi4tYR7PLj00Z .cluster-label text{fill:#333;}#mermaid-svg-GsJxi4tYR7PLj00Z .cluster-label span{color:#333;}#mermaid-svg-GsJxi4tYR7PLj00Z .cluster-label span p{background-color:transparent;}#mermaid-svg-GsJxi4tYR7PLj00Z .label text,#mermaid-svg-GsJxi4tYR7PLj00Z span{fill:#333;color:#333;}#mermaid-svg-GsJxi4tYR7PLj00Z .node rect,#mermaid-svg-GsJxi4tYR7PLj00Z .node circle,#mermaid-svg-GsJxi4tYR7PLj00Z .node ellipse,#mermaid-svg-GsJxi4tYR7PLj00Z .node polygon,#mermaid-svg-GsJxi4tYR7PLj00Z .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-GsJxi4tYR7PLj00Z .rough-node .label text,#mermaid-svg-GsJxi4tYR7PLj00Z .node .label text,#mermaid-svg-GsJxi4tYR7PLj00Z .image-shape .label,#mermaid-svg-GsJxi4tYR7PLj00Z .icon-shape .label{text-anchor:middle;}#mermaid-svg-GsJxi4tYR7PLj00Z .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-GsJxi4tYR7PLj00Z .rough-node .label,#mermaid-svg-GsJxi4tYR7PLj00Z .node .label,#mermaid-svg-GsJxi4tYR7PLj00Z .image-shape .label,#mermaid-svg-GsJxi4tYR7PLj00Z .icon-shape .label{text-align:center;}#mermaid-svg-GsJxi4tYR7PLj00Z .node.clickable{cursor:pointer;}#mermaid-svg-GsJxi4tYR7PLj00Z .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-GsJxi4tYR7PLj00Z .arrowheadPath{fill:#333333;}#mermaid-svg-GsJxi4tYR7PLj00Z .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-GsJxi4tYR7PLj00Z .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-GsJxi4tYR7PLj00Z .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-GsJxi4tYR7PLj00Z .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-GsJxi4tYR7PLj00Z .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-GsJxi4tYR7PLj00Z .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-GsJxi4tYR7PLj00Z .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-GsJxi4tYR7PLj00Z .cluster text{fill:#333;}#mermaid-svg-GsJxi4tYR7PLj00Z .cluster span{color:#333;}#mermaid-svg-GsJxi4tYR7PLj00Z div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-GsJxi4tYR7PLj00Z .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-GsJxi4tYR7PLj00Z rect.text{fill:none;stroke-width:0;}#mermaid-svg-GsJxi4tYR7PLj00Z .icon-shape,#mermaid-svg-GsJxi4tYR7PLj00Z .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-GsJxi4tYR7PLj00Z .icon-shape p,#mermaid-svg-GsJxi4tYR7PLj00Z .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-GsJxi4tYR7PLj00Z .icon-shape .label rect,#mermaid-svg-GsJxi4tYR7PLj00Z .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-GsJxi4tYR7PLj00Z .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-GsJxi4tYR7PLj00Z .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-GsJxi4tYR7PLj00Z :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 人工拒绝
人工批准
start
批准此操作?

interrupt
拒绝后操作
批准后操作
end

实现方式:

  • 在节点中使用 interrupt() 函数暂停执行。传入一个包含审批问题、操作详情等信息的JSON可序列化对象,该对象会显示在调用结果 result["interrupt"] 中。
  • 当图被暂停后,外部系统(如UI界面)可以根据 interrupt 中的信息向用户展示审批请求。
  • 人工做出决定(批准或拒绝)后,通过再次调用图并传入 Command(resume=...) 来恢复执行。
  • 恢复时,传入 Command(resume=True) 表示批准,传入 Command(resume=False) 表示拒绝。
  • 节点代码会接收这个 resume 值作为 interrupt() 函数的返回值,然后根据该值,使用 Command(goto=...) 将流程路由到不同的后续节点(例如"proceed"节点或"cancel"节点)。
    • Command(goto=...) 表示要导航到的下一个节点的名称

【练习】AI转账前进行人工审批:

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

class ApprovalState(TypedDict):
    action_details: str # 操作详情
    status: Optional[Literal["等待", "批准", "拒绝"]]
# 节点中,直接判断后续执行流程
def approval_node(state: ApprovalState):
    """审批节点"""
    decision=interrupt({
        "question": "是否批准该操作?",
        "options": ["批准", "拒绝"],
        "details": state["action_details"]
    })
    if decision=="批准":
        next_node="processed_node"
    else:
        next_node="cancel_node"
    return Command(goto=next_node) #直接跳转到后续节点,不用添加条件边了


def processed_node(state: ApprovalState):
    """处理节点"""
    print(f"审批结果: {state['status']}")
    return {"status":"批准"}

def cancel_node(state: ApprovalState):
    """取消节点"""
    print("拒绝审批,操作取消")
    return {"status":"拒绝"}

builder=StateGraph(ApprovalState)
builder.add_node(approval_node)
builder.add_node(processed_node)
builder.add_node(cancel_node)
builder.add_edge(START, "approval_node")
def approval_edge(state: ApprovalState):
    if state["status"]=="批准":
        return "processed_node"
    else:
        return "cancel_node"
# builder.add_conditional_edge("approval_node", approval_edge,["processed_node","cancel_node"])
builder.add_edge("processed_node", END)
builder.add_edge("cancel_node", END)
graph=builder.compile(checkpointer=InMemorySaver())
config={"configurable": {"thread_id": "thread_2"}}
print(graph.invoke({"action_details":"支付宝到账300000元","status":"pending"},config=config))
print(graph.invoke(Command(resume="批准"), config=config))

执行结果:

plain 复制代码
{'action_details': '支付宝到账300000元', 'status': 'pending', '__interrupt__': [Interrupt(value={'question': '是否批准该操作?', 'options': ['批准', '拒绝'], 'details': '支付宝到账300000元'}, id='d1d14dae58d8888ec17ed30338acc4a0')]}
审批结果: pending
{'action_details': '支付宝到账300000元', 'status': '批准'}
1.2.4.2 查看和编辑状态(Review and edit state)

该场景表示在流程执行过程,使用中断功能让人进行审查和编辑状态内容。
#mermaid-svg-OsRPRGyCtHoboTiF{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OsRPRGyCtHoboTiF .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OsRPRGyCtHoboTiF .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OsRPRGyCtHoboTiF .error-icon{fill:hsl(220.5882352941, 100%, 98.3333333333%);}#mermaid-svg-OsRPRGyCtHoboTiF .error-text{fill:rgb(8.5000000002, 5.7500000001, 0);stroke:rgb(8.5000000002, 5.7500000001, 0);}#mermaid-svg-OsRPRGyCtHoboTiF .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OsRPRGyCtHoboTiF .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OsRPRGyCtHoboTiF .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OsRPRGyCtHoboTiF .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OsRPRGyCtHoboTiF .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OsRPRGyCtHoboTiF .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OsRPRGyCtHoboTiF .marker{fill:#222222;stroke:#222222;}#mermaid-svg-OsRPRGyCtHoboTiF .marker.cross{stroke:#222222;}#mermaid-svg-OsRPRGyCtHoboTiF svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OsRPRGyCtHoboTiF p{margin:0;}#mermaid-svg-OsRPRGyCtHoboTiF .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-OsRPRGyCtHoboTiF .cluster-label text{fill:rgb(8.5000000002, 5.7500000001, 0);}#mermaid-svg-OsRPRGyCtHoboTiF .cluster-label span{color:rgb(8.5000000002, 5.7500000001, 0);}#mermaid-svg-OsRPRGyCtHoboTiF .cluster-label span p{background-color:transparent;}#mermaid-svg-OsRPRGyCtHoboTiF .label text,#mermaid-svg-OsRPRGyCtHoboTiF span{fill:#333;color:#333;}#mermaid-svg-OsRPRGyCtHoboTiF .node rect,#mermaid-svg-OsRPRGyCtHoboTiF .node circle,#mermaid-svg-OsRPRGyCtHoboTiF .node ellipse,#mermaid-svg-OsRPRGyCtHoboTiF .node polygon,#mermaid-svg-OsRPRGyCtHoboTiF .node path{fill:#e8e0f8;stroke:#8a6fd1;stroke-width:1px;}#mermaid-svg-OsRPRGyCtHoboTiF .rough-node .label text,#mermaid-svg-OsRPRGyCtHoboTiF .node .label text,#mermaid-svg-OsRPRGyCtHoboTiF .image-shape .label,#mermaid-svg-OsRPRGyCtHoboTiF .icon-shape .label{text-anchor:middle;}#mermaid-svg-OsRPRGyCtHoboTiF .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-OsRPRGyCtHoboTiF .rough-node .label,#mermaid-svg-OsRPRGyCtHoboTiF .node .label,#mermaid-svg-OsRPRGyCtHoboTiF .image-shape .label,#mermaid-svg-OsRPRGyCtHoboTiF .icon-shape .label{text-align:center;}#mermaid-svg-OsRPRGyCtHoboTiF .node.clickable{cursor:pointer;}#mermaid-svg-OsRPRGyCtHoboTiF .root .anchor path{fill:#222222!important;stroke-width:0;stroke:#222222;}#mermaid-svg-OsRPRGyCtHoboTiF .arrowheadPath{fill:#0b0b0b;}#mermaid-svg-OsRPRGyCtHoboTiF .edgePath .path{stroke:#222222;stroke-width:2.0px;}#mermaid-svg-OsRPRGyCtHoboTiF .flowchart-link{stroke:#222222;fill:none;}#mermaid-svg-OsRPRGyCtHoboTiF .edgeLabel{background-color:#f0f0f0;text-align:center;}#mermaid-svg-OsRPRGyCtHoboTiF .edgeLabel p{background-color:#f0f0f0;}#mermaid-svg-OsRPRGyCtHoboTiF .edgeLabel rect{opacity:0.5;background-color:#f0f0f0;fill:#f0f0f0;}#mermaid-svg-OsRPRGyCtHoboTiF .labelBkg{background-color:rgba(240, 240, 240, 0.5);}#mermaid-svg-OsRPRGyCtHoboTiF .cluster rect{fill:hsl(220.5882352941, 100%, 98.3333333333%);stroke:hsl(220.5882352941, 60%, 88.3333333333%);stroke-width:1px;}#mermaid-svg-OsRPRGyCtHoboTiF .cluster text{fill:rgb(8.5000000002, 5.7500000001, 0);}#mermaid-svg-OsRPRGyCtHoboTiF .cluster span{color:rgb(8.5000000002, 5.7500000001, 0);}#mermaid-svg-OsRPRGyCtHoboTiF div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(220.5882352941, 100%, 98.3333333333%);border:1px solid hsl(220.5882352941, 60%, 88.3333333333%);border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-OsRPRGyCtHoboTiF .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-OsRPRGyCtHoboTiF rect.text{fill:none;stroke-width:0;}#mermaid-svg-OsRPRGyCtHoboTiF .icon-shape,#mermaid-svg-OsRPRGyCtHoboTiF .image-shape{background-color:#f0f0f0;text-align:center;}#mermaid-svg-OsRPRGyCtHoboTiF .icon-shape p,#mermaid-svg-OsRPRGyCtHoboTiF .image-shape p{background-color:#f0f0f0;padding:2px;}#mermaid-svg-OsRPRGyCtHoboTiF .icon-shape .label rect,#mermaid-svg-OsRPRGyCtHoboTiF .image-shape .label rect{opacity:0.5;background-color:#f0f0f0;fill:#f0f0f0;}#mermaid-svg-OsRPRGyCtHoboTiF .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-OsRPRGyCtHoboTiF .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-OsRPRGyCtHoboTiF :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-OsRPRGyCtHoboTiF .pink>*{fill:#fce4ec!important;stroke:#8a6fd1!important;stroke-width:1px!important;}#mermaid-svg-OsRPRGyCtHoboTiF .pink span{fill:#fce4ec!important;stroke:#8a6fd1!important;stroke-width:1px!important;} UI展示内容
传入编辑内容

恢复流程
初始状态
中断触发
人工编辑
更新状态

【练习】人工审核AI文档内容,并进行编辑:

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

class State(TypedDict):
    text: str
def review_node(state: State):
    """人工审核节点"""
    result=interrupt({
        "question":"请审核该文本是否符合要求",
        "text":state["text"]
    })
    return {"text":result}
builder=StateGraph(State)
builder.add_node(review_node)
builder.add_edge(START, "review_node")
builder.add_edge("review_node", END)
graph=builder.compile(checkpointer=InMemorySaver())
config={"configurable": {"thread_id": "thread_3"}}
print(graph.invoke({"text":"这是一段需要审核的文本"},config=config))
print(graph.invoke(Command(resume="这是修改过后的文本"), config=config))

除此之外,还允许:

  • 人工审查和修改LLM生成的内容(如文本、数据)
python 复制代码
# 生成营销文案后让营销专家审核
interrupt({
    "instruction": "为社交媒体优化营销文案",
    "content": "...", # 待审核文案
    "platform": "douyin"
})
  • 在继续执行前纠正错误、添加信息或进行微调
python 复制代码
# 提取结构化数据后让专家验证
interrupt({
    "instruction": "验证和纠正提取的产品规格",
    "content": "...",  # 提取的规格
    "required_fields": ["尺寸", "重量", "材料"]
})
  • 适用于需要质量控制或专业审核的自动化流程
python 复制代码
# 生成代码后让开发人员审查
interrupt({
    "instruction": "查看生成的Python函数的效率和最佳实践",
    "content": "...",  # 待审核代码
    "language": "Python"
})

注意恢复时传入的内容会完全替换原始内容。如果需要部分编辑,可以在中断载荷中标记可编辑部分,然后再手动拼接。

你可能以为会自动合并成这样:

python 复制代码
{
    "title": "请确认转账信息",
    "amount": 300000,
    "receiver": "张三",
    "remark": "改为预付款"
}

但实际上为:

python 复制代码
{
    "remark": "改为预付款"
}
1.2.4.3 在工具中中断(Interrupts in tools)

还支持将中断功能直接嵌入到工具(tool)函数内部,从而实现在工具调用前进行人工审查和干预的能力。
#mermaid-svg-4OxYyilAfZD3Wsiv{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-4OxYyilAfZD3Wsiv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-4OxYyilAfZD3Wsiv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-4OxYyilAfZD3Wsiv .error-icon{fill:#552222;}#mermaid-svg-4OxYyilAfZD3Wsiv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-4OxYyilAfZD3Wsiv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-4OxYyilAfZD3Wsiv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-4OxYyilAfZD3Wsiv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-4OxYyilAfZD3Wsiv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-4OxYyilAfZD3Wsiv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-4OxYyilAfZD3Wsiv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-4OxYyilAfZD3Wsiv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-4OxYyilAfZD3Wsiv .marker.cross{stroke:#333333;}#mermaid-svg-4OxYyilAfZD3Wsiv svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-4OxYyilAfZD3Wsiv p{margin:0;}#mermaid-svg-4OxYyilAfZD3Wsiv .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-4OxYyilAfZD3Wsiv .cluster-label text{fill:#333;}#mermaid-svg-4OxYyilAfZD3Wsiv .cluster-label span{color:#333;}#mermaid-svg-4OxYyilAfZD3Wsiv .cluster-label span p{background-color:transparent;}#mermaid-svg-4OxYyilAfZD3Wsiv .label text,#mermaid-svg-4OxYyilAfZD3Wsiv span{fill:#333;color:#333;}#mermaid-svg-4OxYyilAfZD3Wsiv .node rect,#mermaid-svg-4OxYyilAfZD3Wsiv .node circle,#mermaid-svg-4OxYyilAfZD3Wsiv .node ellipse,#mermaid-svg-4OxYyilAfZD3Wsiv .node polygon,#mermaid-svg-4OxYyilAfZD3Wsiv .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-4OxYyilAfZD3Wsiv .rough-node .label text,#mermaid-svg-4OxYyilAfZD3Wsiv .node .label text,#mermaid-svg-4OxYyilAfZD3Wsiv .image-shape .label,#mermaid-svg-4OxYyilAfZD3Wsiv .icon-shape .label{text-anchor:middle;}#mermaid-svg-4OxYyilAfZD3Wsiv .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-4OxYyilAfZD3Wsiv .rough-node .label,#mermaid-svg-4OxYyilAfZD3Wsiv .node .label,#mermaid-svg-4OxYyilAfZD3Wsiv .image-shape .label,#mermaid-svg-4OxYyilAfZD3Wsiv .icon-shape .label{text-align:center;}#mermaid-svg-4OxYyilAfZD3Wsiv .node.clickable{cursor:pointer;}#mermaid-svg-4OxYyilAfZD3Wsiv .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-4OxYyilAfZD3Wsiv .arrowheadPath{fill:#333333;}#mermaid-svg-4OxYyilAfZD3Wsiv .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-4OxYyilAfZD3Wsiv .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-4OxYyilAfZD3Wsiv .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4OxYyilAfZD3Wsiv .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-4OxYyilAfZD3Wsiv .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4OxYyilAfZD3Wsiv .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-4OxYyilAfZD3Wsiv .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-4OxYyilAfZD3Wsiv .cluster text{fill:#333;}#mermaid-svg-4OxYyilAfZD3Wsiv .cluster span{color:#333;}#mermaid-svg-4OxYyilAfZD3Wsiv div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-4OxYyilAfZD3Wsiv .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-4OxYyilAfZD3Wsiv rect.text{fill:none;stroke-width:0;}#mermaid-svg-4OxYyilAfZD3Wsiv .icon-shape,#mermaid-svg-4OxYyilAfZD3Wsiv .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4OxYyilAfZD3Wsiv .icon-shape p,#mermaid-svg-4OxYyilAfZD3Wsiv .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-4OxYyilAfZD3Wsiv .icon-shape .label rect,#mermaid-svg-4OxYyilAfZD3Wsiv .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4OxYyilAfZD3Wsiv .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-4OxYyilAfZD3Wsiv .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-4OxYyilAfZD3Wsiv :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 需要调用工具
人工决定不执行工具
人工决定执行工具
初始状态
调用模型
工具执行前先执行中断
更新状态
执行工具

关键特点如下:

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

【练习】AI发送邮件前,人工审查邮件内容:

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

from langchain.chat_models import init_chat_model
from langchain.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

class MessagesState(TypedDict):
    messages: Annotated[list[AnyMessage], operator.add]

# 在工具中使用中断
@tool
def send_email(to:str,subject:str,body:str):
    """发送邮件工具"""
    result=interrupt({
        "action":"我要发送邮件了",
        "to":to,
        "subject":subject,
        "body":body,
        "message":"请确认是否发送邮件"
    })

    if result["action"]=="拒绝":
        return "邮件发送失败"
    else:
        final_to=result.get("to",to)
        final_subject=result.get("subject",subject)
        final_body=result.get("body",body)

        print(f"发送邮件给{final_to},主题:{final_subject},内容:{final_body}") # 放在interrupt后面,防止重复发送邮件

        return f"最终发送邮件给{final_to},主题:{final_subject},内容:{final_body}"

model = ChatOpenAI(
    model="gpt-5.4",
    api_key=os.getenv("PACKYAPI_API_KEY"),
    base_url="https://www.packyapi.com/v1/",
    use_responses_api=True,
    output_version="responses/v1",
)

model_with_tool=model.bind_tools([send_email])

def llm_call(state:MessagesState):
    """调用模型,带上总结+用户问题生成AI回复"""
    result=model_with_tool.invoke(
        [SystemMessage(content="你是一个邮件发送助手")]
        + state["messages"]
    )
    # 判断是否调用工具
    if result.tool_calls:
        tool_call=result.tool_calls[0]
        # 调用工具"
        tool_result=send_email.invoke(tool_call["args"])
        return {"messages": [ToolMessage(tool_call_id=tool_call["id"],content=tool_result)]}

    return {
        "messages": [result]
    }

builder=StateGraph(MessagesState)
builder.add_node(llm_call)
builder.add_edge(START, "llm_call")
builder.add_edge("llm_call", END)
graph=builder.compile(checkpointer=InMemorySaver())
config={"configurable": {"thread_id": "thread_4"}}

# 第一次调用:启动图,执行到 interrupt 时暂停
print(graph.invoke(
    {
        "messages": [
            HumanMessage(content="请发送一封邮件给 test@example.com,主题是测试邮件,正文是这是一段需要审核的文本")
        ]
    },
    config=config
))

# 恢复调用:同意,并修改部分邮件内容
print(graph.invoke(
    Command(resume={
        "action": "同意",
        "to": "user@example.com",
        "body": "这是一封测试邮件"
    }),
    config=config
))

输出结果:

plain 复制代码
{'messages': [HumanMessage(content='请发送一封邮件给 test@example.com,主题是测试邮件,正文是这是一段需要审核的文本', additional_kwargs={}, response_metadata={}, id='5beaa32e-abd0-4a94-9839-5c1e9a109551')], '__interrupt__': [Interrupt(value={'action': '我要发送邮件了', 'to': 'test@example.com', 'subject': '测试邮件', 'body': '这是一段需要审核的文本', 'message': '请确认是否发送邮件'}, id='4b6b5dda3dc9b1b1a5af00e29735f9b2')]}
发送邮件给user@example.com,主题:测试邮件,内容:这是一封测试邮件
{'messages': [HumanMessage(content='请发送一封邮件给 test@example.com,主题是测试邮件,正文是这是一段需要审核的文本', additional_kwargs={}, response_metadata={}, id='5beaa32e-abd0-4a94-9839-5c1e9a109551'), ToolMessage(content='最终发送邮件给user@example.com,主题:测试邮件,内容:这是一封测试邮件', id='120e2758-2994-4f50-ad55-fdf49569e207', tool_call_id='call_Bk7jYtJCC5yRNjAtgvrcs32I')]}
1.2.4.4 验证人工输入(Validating human input)

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

用户输入验证流程:
#mermaid-svg-wQoUEcZhr1HaNNOd{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-wQoUEcZhr1HaNNOd .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-wQoUEcZhr1HaNNOd .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-wQoUEcZhr1HaNNOd .error-icon{fill:#552222;}#mermaid-svg-wQoUEcZhr1HaNNOd .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-wQoUEcZhr1HaNNOd .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-wQoUEcZhr1HaNNOd .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-wQoUEcZhr1HaNNOd .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-wQoUEcZhr1HaNNOd .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-wQoUEcZhr1HaNNOd .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-wQoUEcZhr1HaNNOd .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-wQoUEcZhr1HaNNOd .marker{fill:#333333;stroke:#333333;}#mermaid-svg-wQoUEcZhr1HaNNOd .marker.cross{stroke:#333333;}#mermaid-svg-wQoUEcZhr1HaNNOd svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-wQoUEcZhr1HaNNOd p{margin:0;}#mermaid-svg-wQoUEcZhr1HaNNOd .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-wQoUEcZhr1HaNNOd .cluster-label text{fill:#333;}#mermaid-svg-wQoUEcZhr1HaNNOd .cluster-label span{color:#333;}#mermaid-svg-wQoUEcZhr1HaNNOd .cluster-label span p{background-color:transparent;}#mermaid-svg-wQoUEcZhr1HaNNOd .label text,#mermaid-svg-wQoUEcZhr1HaNNOd span{fill:#333;color:#333;}#mermaid-svg-wQoUEcZhr1HaNNOd .node rect,#mermaid-svg-wQoUEcZhr1HaNNOd .node circle,#mermaid-svg-wQoUEcZhr1HaNNOd .node ellipse,#mermaid-svg-wQoUEcZhr1HaNNOd .node polygon,#mermaid-svg-wQoUEcZhr1HaNNOd .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-wQoUEcZhr1HaNNOd .rough-node .label text,#mermaid-svg-wQoUEcZhr1HaNNOd .node .label text,#mermaid-svg-wQoUEcZhr1HaNNOd .image-shape .label,#mermaid-svg-wQoUEcZhr1HaNNOd .icon-shape .label{text-anchor:middle;}#mermaid-svg-wQoUEcZhr1HaNNOd .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-wQoUEcZhr1HaNNOd .rough-node .label,#mermaid-svg-wQoUEcZhr1HaNNOd .node .label,#mermaid-svg-wQoUEcZhr1HaNNOd .image-shape .label,#mermaid-svg-wQoUEcZhr1HaNNOd .icon-shape .label{text-align:center;}#mermaid-svg-wQoUEcZhr1HaNNOd .node.clickable{cursor:pointer;}#mermaid-svg-wQoUEcZhr1HaNNOd .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-wQoUEcZhr1HaNNOd .arrowheadPath{fill:#333333;}#mermaid-svg-wQoUEcZhr1HaNNOd .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-wQoUEcZhr1HaNNOd .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-wQoUEcZhr1HaNNOd .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wQoUEcZhr1HaNNOd .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-wQoUEcZhr1HaNNOd .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wQoUEcZhr1HaNNOd .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-wQoUEcZhr1HaNNOd .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-wQoUEcZhr1HaNNOd .cluster text{fill:#333;}#mermaid-svg-wQoUEcZhr1HaNNOd .cluster span{color:#333;}#mermaid-svg-wQoUEcZhr1HaNNOd div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-wQoUEcZhr1HaNNOd .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-wQoUEcZhr1HaNNOd rect.text{fill:none;stroke-width:0;}#mermaid-svg-wQoUEcZhr1HaNNOd .icon-shape,#mermaid-svg-wQoUEcZhr1HaNNOd .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wQoUEcZhr1HaNNOd .icon-shape p,#mermaid-svg-wQoUEcZhr1HaNNOd .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-wQoUEcZhr1HaNNOd .icon-shape .label rect,#mermaid-svg-wQoUEcZhr1HaNNOd .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wQoUEcZhr1HaNNOd .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-wQoUEcZhr1HaNNOd .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-wQoUEcZhr1HaNNOd :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 无效,错误反馈
有效
开始
中断
用户输入
验证输入
更新状态
结束

【练习】用户注册流程中的年龄验证:

python 复制代码
from typing import TypedDict

from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command, interrupt

# 人工验证
class State(TypedDict):
    age: int  | None

def get_age_node(state: State):
    """循环获取用户年龄,直到正确"""
    prompt="请输入年龄:"
    while True:
        age=interrupt(prompt)  # 注意恢复时会从头开始执行
        if isinstance(age,int) and age>0:
            return {"age":age}
        else:
            prompt="重新输入:"



builder=StateGraph(State)
builder.add_node(get_age_node)
builder.add_edge(START, "get_age_node")
builder.add_edge("get_age_node", END)
graph=builder.compile(checkpointer=InMemorySaver())
config={"configurable": {"thread_id": "thread_5"}}
print(graph.invoke({"age": None}, config=config))

print(graph.invoke(
    Command(resume=-12345),
    config=config
))

print(graph.invoke(
    Command(resume=12345),
    config=config
))

输出结果:

plain 复制代码
{'age': None, '__interrupt__': [Interrupt(value='请输入年龄:', id='101a5717686429d8fe4640b97285c29d')]}
{'age': None, '__interrupt__': [Interrupt(value='重新输入:', id='101a5717686429d8fe4640b97285c29d')]}
{'age': 12345}

1.3 时间旅行(Time Travel)

1.3.1 时间旅行是什么?

AI工作流具有非确定性:大语言模型每次运行可能产生不同结果。且复杂任务需要多个AI调用协同完成时,错误可能出现在任何步骤,难以定位。其实这里就是之前重放功能的实现,但重放是langgraph提供的系统级功能,这里的时间机器则是我们实现的特有功能。

LangGraph的工作方式:每个节点执行后都会自动"存档"。时间旅行允许用户重放先前的执行以查看或调试特定的步骤。
#mermaid-svg-zYjwNX2YTT7HxKn0{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zYjwNX2YTT7HxKn0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zYjwNX2YTT7HxKn0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zYjwNX2YTT7HxKn0 .error-icon{fill:#552222;}#mermaid-svg-zYjwNX2YTT7HxKn0 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zYjwNX2YTT7HxKn0 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zYjwNX2YTT7HxKn0 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zYjwNX2YTT7HxKn0 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zYjwNX2YTT7HxKn0 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zYjwNX2YTT7HxKn0 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zYjwNX2YTT7HxKn0 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zYjwNX2YTT7HxKn0 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zYjwNX2YTT7HxKn0 .marker.cross{stroke:#333333;}#mermaid-svg-zYjwNX2YTT7HxKn0 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zYjwNX2YTT7HxKn0 p{margin:0;}#mermaid-svg-zYjwNX2YTT7HxKn0 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-zYjwNX2YTT7HxKn0 .cluster-label text{fill:#333;}#mermaid-svg-zYjwNX2YTT7HxKn0 .cluster-label span{color:#333;}#mermaid-svg-zYjwNX2YTT7HxKn0 .cluster-label span p{background-color:transparent;}#mermaid-svg-zYjwNX2YTT7HxKn0 .label text,#mermaid-svg-zYjwNX2YTT7HxKn0 span{fill:#333;color:#333;}#mermaid-svg-zYjwNX2YTT7HxKn0 .node rect,#mermaid-svg-zYjwNX2YTT7HxKn0 .node circle,#mermaid-svg-zYjwNX2YTT7HxKn0 .node ellipse,#mermaid-svg-zYjwNX2YTT7HxKn0 .node polygon,#mermaid-svg-zYjwNX2YTT7HxKn0 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-zYjwNX2YTT7HxKn0 .rough-node .label text,#mermaid-svg-zYjwNX2YTT7HxKn0 .node .label text,#mermaid-svg-zYjwNX2YTT7HxKn0 .image-shape .label,#mermaid-svg-zYjwNX2YTT7HxKn0 .icon-shape .label{text-anchor:middle;}#mermaid-svg-zYjwNX2YTT7HxKn0 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-zYjwNX2YTT7HxKn0 .rough-node .label,#mermaid-svg-zYjwNX2YTT7HxKn0 .node .label,#mermaid-svg-zYjwNX2YTT7HxKn0 .image-shape .label,#mermaid-svg-zYjwNX2YTT7HxKn0 .icon-shape .label{text-align:center;}#mermaid-svg-zYjwNX2YTT7HxKn0 .node.clickable{cursor:pointer;}#mermaid-svg-zYjwNX2YTT7HxKn0 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-zYjwNX2YTT7HxKn0 .arrowheadPath{fill:#333333;}#mermaid-svg-zYjwNX2YTT7HxKn0 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-zYjwNX2YTT7HxKn0 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-zYjwNX2YTT7HxKn0 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zYjwNX2YTT7HxKn0 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-zYjwNX2YTT7HxKn0 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zYjwNX2YTT7HxKn0 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-zYjwNX2YTT7HxKn0 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-zYjwNX2YTT7HxKn0 .cluster text{fill:#333;}#mermaid-svg-zYjwNX2YTT7HxKn0 .cluster span{color:#333;}#mermaid-svg-zYjwNX2YTT7HxKn0 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-zYjwNX2YTT7HxKn0 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-zYjwNX2YTT7HxKn0 rect.text{fill:none;stroke-width:0;}#mermaid-svg-zYjwNX2YTT7HxKn0 .icon-shape,#mermaid-svg-zYjwNX2YTT7HxKn0 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zYjwNX2YTT7HxKn0 .icon-shape p,#mermaid-svg-zYjwNX2YTT7HxKn0 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-zYjwNX2YTT7HxKn0 .icon-shape .label rect,#mermaid-svg-zYjwNX2YTT7HxKn0 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zYjwNX2YTT7HxKn0 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-zYjwNX2YTT7HxKn0 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-zYjwNX2YTT7HxKn0 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 开始
节点1(检查点)
节点2(检查点)
结束
可读取/更新、可恢复执行
可读取/更新、可恢复执行

这个能力会很有用:

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

1.3.2 时间旅行四步法

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

示例如下:

python 复制代码
# 编译需要checkpointer
graph = workflow.compile(checkpointer=InMemorySaver())

# 创建执行线程uuid
import uuid
config = {
    "configurable": {
        "thread_id": uuid.uuid4(),  # 唯一线程标识
    }
}
# 执行工作流
state = graph.invoke({}, config)
1.3.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}")
    print(f"当前状态: {state.values}")
    print("-" * 50)

# 输出示例:

输出示例:

复制代码
# 检查点ID: 1f0d4d2b-bdc2-6f06-8902-9b67bbd1b867
# 下一步节点: ()
# 当前状态: {'...': '...'}

# 检查点ID: 1f0d4d2b-9596-6fb8-8901-6af9cdc2fea0
# 下一步节点: ('write_joke',)
# 当前状态: {'...': '...'}

# 检查点ID: 1f0d4d2b-7a6e-6d7d-8900-5cc1931477df
# 下一步节点: ('generate_topic',)
# 当前状态: {}

# 检查点ID: 1f0d4d2b-643e-bafc-bfff-7f054fa556c8
# 下一步节点: ('__start__',)
# 当前状态: {}
1.3.2.3 第三步:修改状态(可选)
  • update_state 更新状态(会创建新的检查点分支)
  • 原始检查点保持不变
  • 新分支可以独立发展

示例如下:

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

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

示例如下:

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

1.3.3 完整示例

我们要创建一个生成笑话的系统:

  1. 第一步:想一个主题
  2. 第二步:根据主题写笑话
1.3.3.1 状态类型定义
python 复制代码
from typing_extensions import TypedDict, NotRequired

class State(TypedDict):
    joke: str
    topic : str
1.3.3.2 节点函数实现
python 复制代码
model = ChatOpenAI(
    model="gpt-5.4",
    api_key=os.getenv("PACKYAPI_API_KEY"),
    base_url="https://www.packyapi.com/v1/",
    use_responses_api=True,
    output_version="responses/v1",
)

def generate_topic(state: State):
    """生成笑话主题"""
    return {
        "topic": model.invoke([
            SystemMessage(content="你是一个笑话生成专家。"),
            HumanMessage(content="请帮我生成一个笑话的主题,字数控制在十个字以内")
        ])
    }

def generate_joke(state: State):
    """生成笑话"""
    return {
        "joke": model.invoke([
            SystemMessage(content="你是一个笑话生成专家。"),
            HumanMessage(content=f"请帮我生成一个关于{state['topic']}的笑话")
        ])
    }
1.3.3.3 工作流构建
python 复制代码
builder=StateGraph(State)
builder.add_sequence([generate_topic, generate_joke])
builder.add_edge(START,"generate_topic")
builder.add_edge("generate_joke",END)
graph=builder.compile(checkpointer=InMemorySaver())

执行工作流:

python 复制代码
config = {"configurable": {"thread_id": "1"}}
result = graph.invoke({}, config)
print(result["joke"])
4.3.3.4 时间旅行调试过程
  • 发现问题:最终笑话主题太宽泛

  • 回溯分析:

python 复制代码
# 查看所有检查点
states = list(graph.get_state_history(config))

# 检查主题生成节点后的状态
topic_state = states[1]  # generate_topic之后的检查点
print("AI生成的主题:", topic_state.values["topic"])
  • 修改测试:
python 复制代码
# 修改为更具体的主题
new_config = graph.update_state(
    topic_state.config,
    values={"topic": "程序员调试代码时的趣事"}
)


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

5. 持久化小结

在LangGraph中,持久化能力简介:

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

基于上述技术,LangGraph支持以下功能:

功能 说明
状态查询 graph.get_state(config):获取图的最新状态 graph.get_state_history(config):获取线程的完整历史(所有检查点),按时间倒序排列,可回溯到过程中的任意状态
时间旅行与重放 通过指定 thread_id 和 checkpoint_id 来调用图,可以从历史某个检查点开始重放执行,用于调试或探索不同路径
状态编辑 允许在执行中修改状态的内容(比如让审查修改后再继续)。修改会通过graph.update_state(config, values) 来直接修改线程的当前状态。
人机交互 允许在状态执行中暂停,让人(如审核)修改状态后再继续。
记忆 短期记忆:完整的交互历史,实现上下文对话的记忆。 当前对话记录:整条对话链中的上下文信息 长期记忆:跨会话保存的用户或应用数据

2. LangGraph其他核心能力

2.1 运行时上下文(Runtime context)

2.1.1 什么是运行时上下文?

2.1.1.1 上下文定义与分类

上下文(Context)是程序运行时可访问的数据和环境信息。在LangGraph中,上下文用于传递:

  • 用户身份、配置参数
  • 数据库连接、API 密钥
  • 会话状态、历史记录等

上下文可按两个维度分类:

维度 类型 描述 示例
可变性 静态上下文 运行中不变的数据 用户ID、数据库连接
可变性 动态上下文 运行中会变化的数据 对话记录、中间结果
生命周期 运行时上下文 单次运行/线程有效 当前请求的临时数据
生命周期 跨会话上下文 多次会话持久化 用户偏好、历史记录

因此,在LangGraph中,包含三种上下文:

类型 可变性 生命周期 访问方式
静态运行时上下文 静态 单次运行/线程 context 参数传入
动态运行时上下文 动态 单次运行 图状态对象
动态跨会话上下文 动态 跨会话 存储(Store)

checkpoints 和 store ,则分别代表 动态运行时上下文 和 动态跨会话上下文 。接下来为 静态运行时上下文 的使用方式。

2.1.1.2 场景练习

做个小练习:根据具体场景,分析各种数据如何保存。例如需要开发一个 智能旅行规划助手 ,该助手需要:

  1. 根据用户的母语提供个性化回答
  2. 根据用户的会员等级提供不同服务
  3. 连接到旅游数据库查询信息
  4. 根据季节推荐不同的活动
  5. 记住用户的历史查询,提供更精准的建议

下面是这些信息的类型:

数据类型 上下文类型 为什么
数据库连接 静态运行时上下文 每次查询都需要,单次运行中不变
用户语言偏好 静态运行时上下文 个性化回答,单次运行中不变
用户会员等级 静态运行时上下文 服务分级,单次运行中不变
当前季节 静态运行时上下文 推荐季节性活动,单次运行中不变
对话历史 动态运行时上下文 了解上下文,单次运行中变化
用户旅行偏好 跨会话上下文 长期记忆,跨会话

2.1.2 配置运行时上下文

2.1.2.1 定义上下文模式

首先需要定义一个上下文的数据结构(通常用 dataclass 或 TypedDict)。下面我们演示一个实际示例,在该参数配置了三个参数:用户ID、LLM和系统消息,以便在运行时使用。

python 复制代码
from dataclasses import dataclass

@dataclass
class ContextSchema:
    user_id: str

    model_provider: str = "openai"  # 默认值
    system_message: str = "你是一个乐于助人的助手。"
2.1.2.2 在图中使用上下文模式

创建图时传入 context_schema 参数。

python 复制代码
from langgraph.graph import StateGraph

builder = StateGraph(
    State,  # 状态模式
    context_schema=ContextSchema  # 添加上下文模式
)
2.1.2.3 在节点中访问上下文

节点函数可通过 runtime 参数访问上下文。

python 复制代码
from langgraph.runtime import Runtime

def my_node(state: State, runtime: Runtime[ContextSchema]):
    user_id = runtime.context.user_id
    model_provider = runtime.context.model_provider

    if model_provider == "openai":
        # 使用 OpenAI 模型
        pass
    elif model_provider == "anthropic":
        # 使用 Anthropic 模型
        pass

    return {"result": f"用户{user_id}处理完成"}

运行图时传入上下文

python 复制代码
graph.invoke(
    {"input": "Hello"},
    context={
        "user_id": "user123",
        "model_provider": "anthropic",
        "system_message": "请用中文回答"
    }
)
【完整示例】
python 复制代码
from dataclasses import dataclass
from langgraph.runtime import Runtime
from langgraph.graph import StateGraph, START, END
from typing_extensions import TypedDict
from langchain.messages import AnyMessage

# 静态运行上下文
@dataclass
class Contextschema:
    user_id: int
    language: str = "en"
# 动态运行上下文
class State(TypedDict):
    messages: Annotated[list[AnyMessage], operator.add]
    user_name: str
# 默认为动态运行上下文,使用runtime来定义静态运行上下文
def node(state: State,runtime:Runtime[Contextschema]):
    if runtime.context.language=="en":
        greeting = "hello"
    else:
        greeting = "你好"
    user_name=state.get("user_name","Guest")

    return {
        "messages": [SystemMessage(content=f"{greeting}, {user_name}!")]
    }
# 指定动态或静态运行上下文
builder=StateGraph(State,context_schema=Contextschema)
builder.add_node(node)
builder.add_edge(START,"node")
builder.add_edge("node",END)
graph=builder.compile(checkpointer=InMemorySaver(),store=InMemoryStore())
print(graph.invoke({"user_name":"小明"},
                   context={"user_id": 123, "language": "zh"}, # 静态运行上下文必须指明
                   config={"configurable": {"thread_id": "1"}}))
2.1.2.4 在工具中访问上下文

工具是调用外部系统、API、数据库交互或执行计算的功能。因此,对于用户身份、配置参数、数据库连接、API密钥等这类调用API的参数信息和配置信息,则需要传递给工具。上下文对工具的重要性:

  • 个性化响应:根据用户上下文提供定制化回答
  • 权限控制:基于用户身份限制工具访问
  • 状态感知:工具可以根据当前状态决定行为
  • 依赖注入:避免硬编码配置,提高可测试性

基本用法

工具可以通过 ToolRuntime 参数访问运行时信息。这个参数,为工具提供包括:

  • State:图状态数据
  • Context:静态上下文
  • Store:持久化存储等

使用 ToolRuntime 时,只需在工具签名中添加 runtime: ToolRuntime,它会自动注入。调用时,无需手动传输。

定义一个带有运行时信息的工具如下所示:

python 复制代码
from langchain.tools import tool, ToolRuntime

@tool
def get_user_info(runtime: ToolRuntime) -> str:

    """获取当前用户的信息"""
    user_id = runtime.context.user_id  # 访问上下文
    user_state = runtime.state["user_name"]  # 访问状态

    return f"User {user_id}, state: {user_state}"
【完整示例】

构建一个支持搜索的AI系统,假设调用搜索 API 需要用户数据作为参数,则需要向工具中传入相关信息。关键步骤如下:

  1. 定义状态、上下文结构
  2. 定义工具节点(ToolNode)、定义LLM节点
  3. 构建并编译图,需加入状态和上下文参数
  4. 执行并验证结果
python 复制代码
from dataclasses import dataclass
from langchain.chat_models import init_chat_model
from langchain.messages import SystemMessage, HumanMessage
from langchain.tools import tool, ToolRuntime
from langchain.messages import AnyMessage
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.graph import StateGraph, START, END
from typing_extensions import TypedDict, Annotated
import operator

# 在工具中使用
class State(MessagesState):
    user_name: str

@dataclass
class Context:
    user_id: int

@tool
def search(runtime:ToolRuntime[Context]):
    """用来搜索天气的得力工具"""
    user_id=runtime.context.user_id
    user_name=runtime.state["user_name"] # 动态上下文也可以获取,toolruntime相当于全部的上下文
    print(f"日志记录,user_id:{user_id}, user_name:{user_name}")
    return f"user_id:{user_id}, user_name:{user_name}, 天气已经查询完毕"

model = ChatOpenAI(
    model="gpt-5.4",
    api_key=os.getenv("PACKYAPI_API_KEY"),
    base_url="https://www.packyapi.com/v1/",
    use_responses_api=True,
    output_version="responses/v1",
)

model_with_tool=model.bind_tools([search])
def llm_call(state:State):
    return {"messages": [model_with_tool.invoke([SystemMessage(content="你支持调用工具去查询天气")] + state["messages"])]}

builder=StateGraph(State,context_schema=Context)
builder.add_node(llm_call)
builder.add_node("tool_node",ToolNode([search]))
builder.add_edge(START,"llm_call")
builder.add_conditional_edges(
    "llm_call",
    tools_condition,
    {
        "tools":"tool_node",
        "__end__":END,
    }
)
builder.add_edge("tool_node","llm_call")
graph=builder.compile(checkpointer=InMemorySaver(),store=InMemoryStore())
print(graph.invoke({
    "messages": [HumanMessage(content="今天的天气怎么样?")],
    "user_name": "小明"},
    context={"user_id": 123},
    config={"configurable": {"thread_id": "2"}}
))

打印结果如下:

复制代码
日志记录,user_id:123, user_name:小明
{'messages': 
	[HumanMessage(content='今天的天气怎么样?', additional_kwargs={}, response_metadata={}, id='253feede-f7e0-484e-bbf3-8ddaef7ac965'), 
	 AIMessage(content=[{'arguments': '{}', 'call_id': 'call_IJUaKVy7EfiaU3Z1Id9T6pqP', 'name': 'search', 'type': 'function_call', 'id': 'fc_0b4a75819535c05a016a5f136d4c9c8195b15c51560150c5b8', 'status': 'completed'}], additional_kwargs={}, response_metadata={'id': 'resp_0b4a75819535c05a016a5f136c340881959854754c52b4fc2f', 'created_at': 1784615788.0, 'metadata': {}, 'model': 'gpt-5.4', 'object': 'response', 'service_tier': 'default', 'status': 'completed', 'model_provider': 'openai', 'model_name': 'gpt-5.4'}, id='resp_0b4a75819535c05a016a5f136c340881959854754c52b4fc2f', tool_calls=[{'name': 'search', 'args': {}, 'id': 'call_IJUaKVy7EfiaU3Z1Id9T6pqP', 'type': 'tool_call'}], invalid_tool_calls=[], usage_metadata={'input_tokens': 6663, 'output_tokens': 20, 'total_tokens': 6682, 'input_token_details': {'cache_creation': 0, 'cache_read': 6144}, 'output_token_details': {'reasoning': 0}}), 
	 ToolMessage(content='user_id:123, user_name:小明, 天气已经查询完毕', name='search', id='a6b9f33e-955a-4950-aed2-650b168aec74', tool_call_id='call_IJUaKVy7EfiaU3Z1Id9T6pqP'), 
	 AIMessage(content=[{'type': 'text', 'text': '我查到了,但这个工具返回里没有带具体天气内容,只显示"天气已经查询完毕"。\n\n你可以直接告诉我你所在的城市,我再按城市帮你查一次。或者你也可以直接说:\n`查询北京今天天气`\n`查询上海天气`', 'annotations': [], 'id': 'msg_0b4a75819535c05a016a5f137378948195bbc0f1a8ab706045', 'phase': 'final_answer'}], additional_kwargs={}, response_metadata={'id': 'resp_0b4a75819535c05a016a5f1372474c819591bca91ad5585b83', 'created_at': 1784615794.0, 'metadata': {}, 
'model': 'gpt-5.4', 'object': 'response', 'service_tier': 'default', 'status': 'completed', 'model_provider': 'openai', 'model_name': 'gpt-5.4'}, id='resp_0b4a75819535c05a016a5f1372474c819591bca91ad5585b83', tool_calls=[], invalid_tool_calls=[], usage_metadata={'input_tokens': 6723, 'output_tokens': 94, 'total_tokens': 6818, 'input_token_details': {'cache_creation': 0, 'cache_read': 0}, 'output_token_details': {'reasoning': 0}})], 'user_name': '小明'}

2.2 流(Streaming)

2.2.1 概念

在LangChain中,流式传输用来逐步输出数据,无需等待全部处理完成。这可以提升用户体验,减少等待感,尤其适用于大语言模型(LLM)这类延迟较高的任务。就像看电影时,画面一帧帧播放,而不是等全部下载完再看。

在LangGraph中,流式处理可将图运行的实时数据反馈显示到应用程序中,如:状态、LLM生成的文本、自定义数据等。且支持多种流模式。

2.2.2 五种流模式

LangGraph 支持以下五种流模式:

模式 说明 适用场景
values 流式输出完整状态 需要知道每一步的完整状态
updates 流式输出状态变化 关注每一步更新了哪些字段
messages 流式输出 LLM 生成的 token 实时展示 LLM 生成内容
custom 流式输出自定义数据 自定义进度条、日志等
debug 输出所有调试信息 开发调试阶段

将一种或多种流模式作为列表传递给 stream 或 astream 方法是其使用姿势。

2.2.3 流式输出状态值

python 复制代码
from langgraph.graph import StateGraph, START

# 定义状态结构
class State(dict):
    topic: str
    joke: str

# 创建节点函数
def refine_topic(state):
    return {"topic": state["topic"] + "和猫"}

def generate_joke(state):
    return {"joke": f"这是一个关于{state['topic']}的笑话"}

# 构建图
graph = (
    StateGraph(State)
    .add_node(refine_topic)
    .add_node(generate_joke)
    .add_edge(START, "refine_topic")
    .add_edge("refine_topic", "generate_joke")
    .compile()
)

# 流式输出状态更新
for chunk in graph.stream(
    {"topic": "冰激凌"},
    stream_mode="updates"   # 只看更新部分
):
    print(chunk)

for chunk in graph.stream(
    {"topic": "冰激凌"},
    stream_mode="values"    # 每一步的完整状态
):
    print(chunk)

stream_mode="updates" 时输出:

python 复制代码
1  {'refine_topic': {'topic': '冰激凌和猫'}}
2  {'generate_joke': {'joke': '这是一个关于冰激凌和猫的笑话'}}

stream_mode="values" 时输出:

python 复制代码
1  {'topic': '冰激凌'}
2  {'topic': '冰激凌和猫'}
3  {'topic': '冰激凌和猫', 'joke': '这是一个关于冰激凌和猫的笑话'}

2.2.4 流式传输自定义数据

LangGraph 不仅支持输出状态这类的的数据,还支持从节点或工具中输出用户自定义的数据。步骤如下:

  • 使用 get_stream_writer() 访问流编写器并发出自定义数据。
  • 调用 .stream() 或 .astream() 时设置 stream_mode="custom" 以获取流中的自定义数据。还可以组合多种模式(如 ["updates", "custom"]),但至少必须有一个是 "custom"。
2.4.1 基本用法
2.4.1.1 从节点和工具中输出用户自定义数据

在这里改造前面写过的代码:

python 复制代码
from dataclasses import dataclass
from langchain.chat_models import init_chat_model
from langchain.messages import SystemMessage, HumanMessage
from langchain.tools import tool, ToolRuntime
from langgraph.config import get_stream_writer
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.graph import StateGraph, START, END
from typing_extensions import TypedDict, Annotated
import operator

class MessagesState(TypedDict):
    messages: Annotated[list[AnyMessage], operator.add]

    user_name: str = ""

@dataclass
class Context:
    user_id: str

@tool
def search(runtime: ToolRuntime[Context]) -> str:
    """调用搜索工具"""
    user_id = runtime.context.user_id  # 访问上下文
    user_name = runtime.state["user_name"]  # 访问状态

    # 获取流式写入器
    writer = get_stream_writer()
    # 发送开始信号
    writer({
        "type": "search_tool",
        "status": "start",
        "user_id": user_id,
        "user_name": user_name
    })

    # 模拟搜索过程
    writer({
        "type": "search_tool",
        "status": "searching",
        "user_id": user_id,
        "user_name": user_name
    })
    # 模拟处理时间
    import time
    time.sleep(2)

    # 结束
    writer({
        "type": "search_tool",
        "status": "end",
        "user_id": user_id,
        "user_name": user_name
    })
    return f"查询天气: 晴天, 15-20度"  # 模拟调用

# 绑定工具
model_with_tools = init_chat_model("gpt-4o-mini", temperature=0).bind_tools([search])

def llm_call(state: dict):
    """LLM决定是否调用工具"""

    # 获取流式写入器
    writer = get_stream_writer()

    # 发送开始处理的信号
    writer({
        "type": "llm_call",
        "status": "start",
        "message": "开始调用LLM",
        "content": state["messages"][-1].content
    })

    result = model_with_tools.invoke(
        [SystemMessage(content="你是一个乐于助人的助手,支持调用工具进行搜索。")]
        + state["messages"]
    )

    # 调用结束
    writer({
        "type": "llm_call",
        "status": "end",
        "message": "调用LLM完成"
    })
    return {"messages": [result]}

# 定义并编译图
builder = StateGraph(MessagesState, context_schema=Context)
builder.add_node("llm_call", llm_call)
builder.add_node("tool_node", ToolNode([search]))
builder.add_edge(START, "llm_call")
builder.add_conditional_edges(
    "llm_call",
    tools_condition,
    {
        "tools": "tool_node",  # 将条件输出转换为图中的节点
        "__end__": END,
    },
)
builder.add_edge("tool_node", "llm_call")
graph = builder.compile()

for chunk in graph.stream(
    {
        "messages": [HumanMessage(content="今天西安的天气如何?")],
        "user_name": "小明"
    },
    context={"user_id": "123"},
    stream_mode="custom"
):
    print(chunk)

打印结果:

python 复制代码
{'custom': {'type': 'llm_call', 'status': 'start', 'message': '开始调用LLM', 'content': '今天西安的天气如何?'}}
{'custom': {'type': 'llm_call', 'status': 'end', 'message': '调用LLM完成'}}
{'custom': {'type': 'search_tool', 'status': 'start', 'user_id': '123', 'user_name': '小明'}}
{'custom': {'type': 'search_tool', 'status': 'searching', 'user_id': '123', 'user_name': '小明'}}
{'custom': {'type': 'search_tool', 'status': 'end', 'user_id': '123', 'user_name': '小明'}}
{'custom': {'type': 'llm_call', 'status': 'start', 'message': '开始调用LLM', 'content': '查询天气: 晴天, 15-20度'}}
{'custom': {'type': 'llm_call', 'status': 'end', 'message': '调用LLM完成'}}
2.4.1.2 设置多种传输模式

还可以组合多种模式(如 ["updates", "custom"]),但至少必须有一个是 "custom"。如下所示:

python 复制代码
for chunk in graph.stream(
    {
        "messages": [HumanMessage(content="今天西安的天气如何?")],
        "user_name": "小明"
    },
    context={"user_id": "123"},
    stream_mode=["custom", "updates"]
):
    print(chunk)

打印结果:

python 复制代码
1  {'custom': {'type': 'llm_call', 'status': 'start', 'message': '开始调用LLM', 'content': '今天西安的天气如何?'}}
2  {'custom': {'type': 'llm_call', 'status': 'end', 'message': '调用LLM完成'}}
3  {'updates': {'llm_call': {'messages': [AIMessage(content='', additional_kwargs={'refusal': None, 'tool_calls': [{'id': 'call_csl1wboe1awargs=0', 'function': {'name': 'search', 'arguments': '{}'}, 'type': 'function'}], 'logprobs': None, 'accepted_prediction_tokens': 67, 'completion_tokens': 0, 'prompt_tokens': 83, 'total_tokens': 83, 'reasoning_tokens': 0, 'rejected_prediction_tokens': None}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_ea9d2c6db', 'tool_calls': [{'name': 'search', 'args': {}, 'id': 'call_YU9082F9JBdiz3J7CMmJULYNE'}], 'type': 'ai'}]}}}
4  {'custom': {'type': 'search_tool', 'status': 'start', 'user_id': '123', 'user_name': '小明'}}
5  {'custom': {'type': 'search_tool', 'status': 'searching', 'user_id': '123', 'user_name': '小明'}}
6  {'updates': {'tool_node': {'messages': [ToolMessage(content='查询天气: 晴天, 15-20度', 'name': 'search', 'tool_call_id': 'call_csl1wboe1awargs=0')]}}}
7  {'custom': {'type': 'search_tool', 'status': 'end', 'user_id': '123', 'user_name': '小明'}}
8  {'custom': {'type': 'llm_call', 'status': 'start', 'message': '开始调用LLM'}}
9  {'updates': {'llm_call': {'messages': [AIMessage(content='今天西安的天气是晴天,气温在15到20度之间。', additional_kwargs={'refusal': None, 'tool_calls': None, 'logprobs': None, 'completion_tokens': 20, 'prompt_tokens': 83, 'total_tokens': 103, 'reasoning_tokens': 0, 'accepted_prediction_tokens': None, 'rejected_prediction_tokens': None}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_ea9d2c6db', 'tool_calls': [], 'type': 'ai'}]}}}
10 {'custom': {'type': 'llm_call', 'status': 'end', 'message': '调用LLM完成'}}

可以看到LangGraph运行将一种或多种流模式作为列表传递给 stream 或 astream。

2.4.2 应用场景
2.4.2.1 创建自定义监控面板

那么自定义流式数据到底有什么用?改造一下代码。

完整代码如下:

python 复制代码
from dataclasses import dataclass
from langchain.chat_models import init_chat_model
from langchain.messages import SystemMessage, HumanMessage
from langchain.tools import tool, ToolRuntime
from langchain.messages import AnyMessage

from langgraph.config import get_stream_writer
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.graph import StateGraph, START, END
from typing_extensions import TypedDict, Annotated
import operator

# 在工具中使用
class State(MessagesState):
    user_name: str

@dataclass
class Context:
    user_id: int

@tool
def search(runtime:ToolRuntime[Context]):
    """用来搜索天气的得力工具"""
    user_id=runtime.context.user_id
    user_name=runtime.state["user_name"] # 动态上下文也可以获取,toolruntime相当于全部的上下文
    print(f"日志记录,user_id:{user_id}, user_name:{user_name}")
    # 获取流式写入器:
    writer=get_stream_writer() # 先获取写入器
    writer(
        {
            "type":"search_tool",
            "status":"start",
            "user_name":user_name,
            "user_id":user_id
        }
    )
    search_steps = [
        {"name": "搜索1", "time": 1, "result": "晴天,"},
        {"name": "搜索2", "time": 2, "result": "15-20度"},
    ]

    all_result = "查询天气:"
    for i, step in enumerate(search_steps, 1):
        writer(
            {
                "type":"search_tool",
                "status":"searching",
                "step": step["name"],
                "all_step": len(search_steps),
                "cur_step": i,
                "user_id":user_id,
                "user_name":user_name
            }
        )
        time.sleep(step["time"])
        all_result += step["result"]

    writer(
        {
            "type":"search_tool",
            "status":"end",
            "user_name":user_name,
            "user_id":user_id,
            "result": all_result
        }
    )
    return all_result

model = ChatOpenAI(
    model="gpt-5.4",
    api_key=os.getenv("PACKYAPI_API_KEY"),
    base_url="https://www.packyapi.com/v1/",
    use_responses_api=True,
    output_version="responses/v1",
)

model_with_tool=model.bind_tools([search])
def llm_call(state:State):
    writer = get_stream_writer()  # 先获取写入器
    writer(
        {
            "type": "llm_call",
            "status": "start",
            "message": "开始调用LLM",
            "content": state["messages"][-1].content,
        }
    )
    result = model_with_tool.invoke([SystemMessage(content="你支持调用工具去查询天气")] + state["messages"])
    writer(
        {
            "type": "llm_call",
            "status": "end",
            "message": "调用LLM完成",
        }
    )
    return {"messages": [result]}

builder=StateGraph(State,context_schema=Context)
builder.add_node(llm_call)
builder.add_node("tool_node",ToolNode([search]))
builder.add_edge(START,"llm_call")
builder.add_conditional_edges(
    "llm_call",
    tools_condition,
    {
        "tools":"tool_node",
        "__end__":END,
    }
)
builder.add_edge("tool_node","llm_call")
graph=builder.compile(checkpointer=InMemorySaver(),store=InMemoryStore())
# print(graph.invoke({
#     "messages": [HumanMessage(content="今天的天气怎么样?")],
#     "user_name": "小明"},
#     context={"user_id": 123},
#     config={"configurable": {"thread_id": "2"}}
# ))

# 这里演示自定义信息输出流
for chunk in graph.stream({
    "messages": [HumanMessage(content="今天的天气怎么样?")],
    "user_name": "小明"},
    context={"user_id": 123},
    config={"configurable": {"thread_id": "2"}},
    stream_mode=["updates", "custom"] # 还可以组合多种模式(如 ["updates", "custom"]),但至少必须有一个是"custom"
):
    if isinstance(chunk, tuple) and len(chunk) == 2:
        mode, data = chunk
        if mode == "custom":
            if data.get("type") == "search_tool":
                status = data["status"]
                if status == "start":
                    print(f"用户ID:{data['user_id']}, 用户名:{data['user_name']}开始调用工具...")
                elif status == "searching":
                    print(f"[{data['cur_step']}/{data['all_step']}] 正在处理:{data['step']}")
                elif status == "end":
                    print(f"调用完成!结果:{data['result']}")
            elif data.get("type") == "llm_call":
                pass
        elif mode == "updates":
            pass
    elif isinstance(chunk, dict):
        if chunk.get("custom"):
            info = chunk["custom"]
            if info.get("type") == "search_tool":
                status = info["status"]
                if status == "start":
                    print(f"用户ID:{info['user_id']}, 用户名:{info['user_name']}开始调用工具...")
                elif status == "searching":
                    print(f"[{info['cur_step']}/{info['all_step']}] 正在处理:{info['step']}")
                elif status == "end":
                    print(f"调用完成!结果:{info['result']}")
            elif info.get("type") == "llm_call":
                pass
        elif chunk.get("updates"):
            pass

下面是运行结果:

plain 复制代码
日志记录,user_id:123, user_name:小明
用户ID:123, 用户名:小明开始调用工具...
[1/2] 正在处理:搜索1
[2/2] 正在处理:搜索2
调用完成!结果:查询天气:晴天,15-20度

因此,自定义流式数据的用法,可以做到:

  1. 实时进度反馈:在长时间处理任务中显示进度
  2. 调试信息输出:输出中间计算结果
  3. 多源数据整合:同时流式输出不同类型的数据
  4. 自定义监控:创建自定义的监控面板

2.2.5 流式传输 LLM tokens

Token 是大语言模型处理文本的基本单位。因此 LangGraph 可以:

  • 使用 stream_mode="messages" 模式可以从 graph 的任何部分(包括节点、工具等)逐Token 流式传输 LLM 输出。
  • 输出格式为 (message_chunk, metadata) 元组。
2.2.5.1 基本用法
python 复制代码
from typing import TypedDict
from langgraph.graph import StateGraph, START
from langchain_openai import ChatOpenAI

# 定义状态
class State(TypedDict):
    input: str
    output: str


# 初始化模型
model = ChatOpenAI(model="gpt-4o-mini")
def llm_node(state: State):
    """生成答案的节点"""
    return {"output": model.invoke([
        {"role": "system", "content": "你是一个乐于助人的助手。"},
        {"role": "user", "content": state["input"]}
    ])}

# 构建图
builder = StateGraph(State)
builder.add_node("llm_node", llm_node)
builder.add_edge(START, "llm_node")
graph = builder.compile()


# 流式输出 LLM Tokens
# 输出格式为 (message_chunk, metadata) 元组。
for token_chunk, metadata in graph.stream(
    {"input": "请解释什么是机器学习?"},
    stream_mode="messages"
):
    if token_chunk.content:
        # 逐 Token 输出
        print(token_chunk.content, end="", flush=True)
2.2.5.2 高级功能
2.2.5.2.1 按 Tags 过滤 Tokens

还可以将 tags 与 LLM 调用相关联,以按 LLM 调用筛选流式令牌。

python 复制代码
from typing import TypedDict
from langgraph.graph import StateGraph, START
from langchain_openai import ChatOpenAI

# 初始化带标签的模型
joke_model = ChatOpenAI(
    model="gpt-5.4",
    api_key=os.getenv("PACKYAPI_API_KEY"),
    base_url="https://www.packyapi.com/v1/",
    use_responses_api=True,
    output_version="responses/v1",
).with_config(tags=["joke"])# 给模型添加标签

poem_model = ChatOpenAI(
    model="gpt-5.5",
    api_key=os.getenv("PACKYAPI_API_KEY"),
    base_url="https://www.packyapi.com/v1/",
    use_responses_api=True,
    output_version="responses/v1",
).with_config(tags=["poem"])# 给模型添加标签

class CreativeState(TypedDict):
    topic: str
    joke: str
    poem: str

# 由于中转站与官方的api的返回格式不同,这里需要做一下处理
def extract_text(content):
    if isinstance(content, str):
        return content
    if isinstance(content, list):
        parts = []
        for block in content:
            if isinstance(block, dict) and block.get("type") == "text":
                parts.append(block.get("text", ""))
        return "".join(parts)
    return ""


def generate_creative_content(state: CreativeState):
    """同时生成笑话和诗歌"""
    topic = state["topic"]

    # 生成笑话
    print(f"\n生成关于 {topic} 的笑话: ")
    joke_response = joke_model.invoke([
        {"role": "user", "content": f"讲一个关于 {topic} 的笑话"}
    ])

    # 生成诗歌
    print(f"\n生成关于 {topic} 的诗歌: ")
    poem_response = poem_model.invoke([
        {"role": "user", "content": f"写一首关于 {topic} 的短诗"}
    ])

    return {
        "joke": joke_response.content,
        "poem": poem_response.content
    }


# 构建图
builder = StateGraph(CreativeState)
builder.add_node("creative", generate_creative_content)
builder.add_edge(START, "creative")
graph = builder.compile()

# 流式输出并过滤
for token_chunk, metadata in graph.stream(
    {"topic": "猫"},
    stream_mode="messages"
):
    # 只输出笑话相关的 Tokens
    tags = metadata.get("tags", [])

    if "joke" in tags:
        print(extract_text(token_chunk.content), end="", flush=True)
    # 也可以过滤诗歌
    # if "poem" in tags:
    #     print(token_chunk.content, end="", flush=True)

输出结果如下;

plain 复制代码
生成关于 猫 的笑话: 
有一只猫去应聘,老板问它有什么特长。

猫说:"我会抓老鼠,还会写代码。"

老板不信:"那你现场写一段看看。"

猫抬起爪子就在键盘上一顿拍,屏幕上出现一行:

`catch (Mouse e) {}`

老板沉默了两秒,说:"行,明天来上班,记得别抓产品经理。"
生成关于 猫 的诗歌: 
2.2.5.2.2 按节点名称过滤

可以指定特定节点流式传输Tokens,需按流式传输元数据中的 langgraph_node 字段筛选输出:

python 复制代码
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langchain_openai import ChatOpenAI


model = ChatOpenAI(model="gpt-5.4",
    api_key=os.getenv("PACKYAPI_API_KEY"),
    base_url="https://www.packyapi.com/v1/",
    use_responses_api=True,
    output_version="responses/v1"
)

class State(TypedDict):
    query: str
    summary: str
    translation: str


def extract_text(content):
    # 中转站/`responses/v1` 可能返回结构化块,而不是直接返回纯字符串。
    if isinstance(content, str):
        return content
    if isinstance(content, list):
        return "".join(
            block.get("text", "")
            for block in content
            if isinstance(block, dict) and block.get("type") == "text"
        )
    return ""


def generate_summary(state: State):
    """生成摘要"""
    response = model.invoke([
        {"role": "user", "content": f"请为以下内容生成摘要:{state['query']}"}
    ])
    return {"summary": response.content}


def generate_translation(state: State):
    """生成翻译"""
    response = model.invoke([
        {"role": "user", "content": f"请将以下内容翻译成英文:{state['query']}"}
    ])
    return {"translation": response.content}


# 构建并行处理图
builder = StateGraph(State)
builder.add_node("summarize", generate_summary) # 这里来指定节点名称
builder.add_node("translate", generate_translation)

builder.add_edge(START, "summarize")
builder.add_edge(START, "translate")
builder.add_edge("summarize", END)
builder.add_edge("translate", END)

graph = builder.compile()

# 流式输出并只显示某个节点的 Tokens
target_node = "summarize" # 可以改为 "translate"
printed_prefix = False
for token_chunk, metadata in graph.stream(
    {"query": "人工智能是计算机科学的一个分支,致力于创造能够执行通常需要人类智能的任务的机器。"},
    stream_mode="messages"
):
    # 获取节点名称
    node_name = metadata.get("langgraph_node", "")

    # 只输出目标节点的 Tokens
    if token_chunk.content and node_name == target_node:
        # 添加节点标签
        if node_name == "translate":
            prefix = "【翻译】"
        elif node_name == "summarize":
            prefix = "【摘要】"
        else:
            prefix = ""

        if not printed_prefix and prefix:
            print(prefix, end="", flush=True)
            printed_prefix = True
        print(extract_text(token_chunk.content), end="", flush=True)

输出格式为:

plain 复制代码
【摘要】摘要:人工智能是计算机科学的一个分支,目标是让机器具备执行通常需要人类智能任务的能力。
相关推荐
地理探险家9 分钟前
从零实现图片自动分类:数据准备、模型训练与单图预测
人工智能·深度学习·机器学习·分类·数据挖掘·数据集
frankzch2 小时前
微软 137B 编码模型搬到 PC:端侧推理与 Agent 权限防线
人工智能·深度学习·microsoft·机器学习·语言模型
TomEval2 小时前
【测AI】第11篇:深度学习基础 —— 神经网络是怎么工作的
人工智能·python·深度学习·神经网络·机器学习·aigc
for_ever_love__2 小时前
LangChain 降级、重试与超时:让服务在模型抽风时还能用
python·自然语言处理·langchain·agent·超时·重试·with_retry
MoRanzhi12032 小时前
第 8 篇 · 机器学习建模与实验评估:建模流程与对照实验
python·机器学习·scikit-learn·模型评估·交叉验证·shap·消融实验
for_ever_love__4 小时前
LangChain 批量调用与并发控制:batch、abatch 与速率限制
人工智能·langchain·异步·config·invoke·abatch
forest_095 小时前
Agent 系列07|记忆与状态设计
人工智能·机器学习·架构·prompt·agi
for_ever_love__5 小时前
LangChain RunnableConfig 详解:运行时传参、并发控制与回调注入
语言模型·langchain·llm·prompt·config·invoke·运行时参数
for_ever_love__5 小时前
LangChain 模型参数调优:temperature、max_tokens 这些到底怎么设
python·语言模型·langchain·aigc·模型参数·temperature·max_tokens
larance6 小时前
LDA(线性判别分析)
人工智能·机器学习·分类