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 Exceptiontry/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_idcheckpoint_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_idcheckpoint_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 定义上下文模式

首先需要定义一个上下文的数据结构(通常用 dataclassTypedDict)。下面我们演示一个实际示例,在该参数配置了三个参数:用户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 输出所有调试信息 开发调试阶段

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

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运行将一种或多种流模式作为列表传递给 streamastream

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 复制代码
【摘要】摘要:人工智能是计算机科学的一个分支,目标是让机器具备执行通常需要人类智能任务的能力。
相关推荐
C^h4 小时前
python函数学习
人工智能·python·机器学习
早点睡啊Y4 小时前
深入学 LangChain 官方文档(十六)Built-in 与 Custom Middleware
langchain
糖果店的幽灵4 小时前
【langgraph 从入门到精通graphApi 篇】综合实战 —— 智能客服 Agent实战代码解读
人工智能·langgraph
老刘说AI5 小时前
AI服务核心: 高并发原理与性能监控调优
人工智能·神经网络·langchain·llama·持续部署
展示猪肝5 小时前
LangChain学习笔记(一):基础入门与核心概念详解
langchain
RD_daoyi6 小时前
外链权重暴跌至13%,品牌提及反超传统链接——2026年不做Digital PR,你的独立站等于隐形
运维·网络·学习·机器学习·搜索引擎
m沐沐7 小时前
【深度学习】卷积神经网络 数据增强、保存最优模型实现,详细解读
人工智能·python·深度学习·机器学习·cnn·数据增强
硅谷秋水7 小时前
PhyGround:生成式世界模型中的物理推理基准测试
人工智能·深度学习·机器学习·计算机视觉·语言模型
TheBestRucy8 小时前
RAG知识库问答系统落地:从向量检索到上下文增强的全链路实践
人工智能·python·langchain·aigc·交互