LangGraph学习分享-1

21 阅读44分钟

Quick Start

因为时间原因,当前我的项目中使用的的 LangGraph 是0.2.67版本,所以语法可能和 1.0 版本存在些许不同,但在可能的情况在我提供了LangGraph1.0版本的代码示例。从该名称中,我们也可以感知到,它构建了一个长的的 Graph

我们在使用 LangGraph 的过程也就是将我们的程序作为图的一个一个的节点,然后搭建这些节点之间的关系,最终构建成一个 Graph

在写代码之前,我们说一些概念

  • State

LangGraph 中的 State 用于存储我们的和 LLM 沟通的消息,或者是其他我们在执行过程中需要使用的数据,在我们编写的程序中,它的体现为一个 Dict 或者 Pydantic 模型

但 state 和 LCEL 中的输入输出不同的是,state 在 graph 中是全局共享的,每次一个节点执行完进行输出时,是会被 langgraph 捕获输出,然后根据是否有归约函数,然后进行 state 更新,而 LCEL 中每次输出会作为下一个节点的输入。

  • Node

节点,在 LangGraph 中节点通常是一个 python 函数 或是 Runnable 实例,这个函数在 LangGraph 执行的时候,会传递 2 个参数 state: dict, config: Config而 Node 也是我们具体编写程序执行逻辑的地方,节点函数的返回值一般都是 State,整个图的起点被称为开始节点,最后的终点被称为结束节点

  • Edge

边,边在图中定义了路由逻辑,即不同节点之间是如何传递的,传递给谁,以及图节点从哪里开始,从哪里结束,并且一个节点可以设置多条边,如果有多条边,则下一条边连接的所有节点都会并行运行

通常我们构建 LangGraph 的程序流程为:

  1. 分析我们的需求,提取出我们需要使用的数据模型

  2. 定义我们的 State

  3. 定义我们的节点

    1. 可能是模型的节点
    2. 工具节点,如果是工具节点,要定义具体的工具函数,然后使用一个函数封装我们的工具函数
    3. 可能会定义我们的 RAG 中的一些 retriever
  4. 创建图对象,以及定义具体的节点,注意节点名称要保持唯一,其次节点名称和 State 中的 key 的名称不要出现同名

  5. 构建节点与边的关系,完成一张图的构建

  6. 编译,通过 invoke 运行

# pip
pip install -U langgraph
​
# uv
uv add langgraph

在 LangGraph 中,开始/结束节点 作为特殊节点,并预定义了,导入后即可使用

使用 LangGraph 的时候,可以多参考 langsmith 提供的执行流程:smith.langchain.com/

以及可以在 pycharm 中下载 AI Agents Debugger 插件进行辅助。

from typing import TypedDict, Annotated
​
import dotenv
from langchain_core.messages import AIMessage
from langchain_openai import ChatOpenAI
from langchain_core.runnables import RunnableConfig
from langgraph.graph import START, END, add_messages, StateGraph
​
dotenv.load_dotenv()
​
​
# 1. 定义State
class MyState(TypedDict):
    """图的状态数据,定义为一个 dict 类型"""
    node_name: str
    messages: Annotated[list, add_messages]  # 使用Annotated进行消息的归纳
​
​
# 2. 构建节点
llm = ChatOpenAI(model="gpt-4o")
​
​
def chatbot(state: MyState, config: RunnableConfig):
    """用于与LLM进行交互的函数"""
    # 获取之前操作所生成的消息
    messages = state["messages"]
    ai_message: AIMessage = llm.invoke(messages)
    print("---chatbot---")
    print(f"ai_message: {ai_message}")
    return {  # 一定要返回一个State, 因为使用了归纳函数,所以该数据会拼接到数组中,而不是覆盖
        "messages": [ai_message],
        "node_name": "chatbot"
    }
​
​
# 3. 创建图对象,添加节点
graph_builder = StateGraph(MyState)
​
graph_builder.add_node("chatbot", chatbot)
​
# 4. 构建节点的边关系
graph_builder.add_edge(START, "chatbot")
graph_builder.add_edge("chatbot", END)
​
# 5. 编译运行
graph = graph_builder.compile()
# 这里我们的输入应该是一个State对象
print(graph.invoke(MyState(node_name="",
                           messages=["你好,你是?"])))

Condition Edge And Loop

得益于 Graph 这种数据结构的特性,我们可以通过 Edge 构建出比之前使用 LCEL 更加灵活的执行流程了。

在 Graph 中的 Edge 中存在多种类型,具体可以参考:docs.langchain.com/oss/python/…

常用的就是 add_edgeadd_condition_edges

还有的就是一些辅助的功能了

关于add_condition_edges可以参考:reference.langchain.com/python/lang…

  • source:条件边的起始节点名称,该节点运行结束后会执行条件边。
  • path:确定下一个节点是什么的可运行对象或者函数。如果返回的是END表示 Graph 终止执行
  • path_map:可选参数,类型为一个字典,用于表示 返回的path和 节点名称 的映射关系,如果不设置的话,path 的返回值应该是 节点名称。

我们完成如下的流程

from typing import Literalimport dotenv
from langchain_core.messages import AIMessage, ToolMessage
from langchain_openai import ChatOpenAI
from langchain_core.runnables import RunnableConfig
from langgraph.graph import END, StateGraph
from langgraph.graph import MessagesState
from langchain_community.tools import GoogleSerperRun
from langchain_community.utilities import GoogleSerperAPIWrapper
from pydantic.v1 import BaseModel, Field
​
dotenv.load_dotenv()
​
​
# 定义工具
class GoogleSerperArgsSchema(BaseModel):
    query: str = Field(..., description="执行谷歌搜索时查询的语句")
​
​
google_serper = GoogleSerperRun(
    name="google_serper",
    description=(
        "一个低成本的谷歌搜索API。"
        "当你需要回答有关时事的问题时,可以调用该工具。"
        "该工具的输入是搜索查询语句。"
    ),
    args_schema=GoogleSerperArgsSchema,
    api_wrapper=GoogleSerperAPIWrapper()
)
​
# 1. 构建节点# 构建工具调用的部分
tool_mapping = {
    google_serper.name: google_serper,
}
​
tools = [tool for tool in tool_mapping.values()]
​
llm = ChatOpenAI(model="gpt-4o")
llm_with_tools = llm.bind_tools(tools)
​
​
# 这里为了方便测试,我们使用LangGraph内置的一个State组件来作为State结构约束,它为:MessagesState
def chatbot(state: MessagesState, config: RunnableConfig):
    # 获取之前操作所生成的消息
    messages = state["messages"]
    ai_message: AIMessage = llm_with_tools.invoke(messages)
    print("---chatbot---")
    print(f"ai_message: {ai_message.content} \n\t tool_call: {ai_message.tool_calls}")
    return {  # 一定要返回一个State, 因为使用了归纳函数,所以该数据会拼接到数组中,而不是覆盖
        "messages": [ai_message]
    }
​
​
def tool_executor(state: MessagesState, config: RunnableConfig) -> MessagesState:
    """工具执行函数"""
    # 这里我们默认执行到该节点时存在tool_calls
    tool_calls = state["messages"][-1].tool_calls
    messages = []
​
    for tool_call in tool_calls:
        tool = tool_mapping[tool_call["name"]]
        if tool:
            messages.append(ToolMessage(
                tool_call_id=tool_call["id"],
                content=tool.invoke(tool_call["args"]),
                name=tool_call["name"]
            ))
    return {
        "messages": messages
    }
​
​
def router(state: MessagesState, config: RunnableConfig) -> Literal["tool_executor", "__end__"]:
    """定义条件边中的条件判断, 注意该函数不是node,它是作为Edge中的条件判断,判断下一步执行那个节点"""
    message = state["messages"][-1]
​
    if hasattr(message, "tool_calls") and len(getattr(message, "tool_calls")) > 0:
        return "tool_executor"
    return END
​
​
# 这里为了方便测试,我们使用LangGraph内置的一个State组件来作为State结构约束,它的内容很简单,只有如下内容
# class MessagesState(TypedDict):
#     messages: Annotated[list[AnyMessage], add_messages]
graph_builder = StateGraph(MessagesState)
# 定义节点
graph_builder.add_node("chatbot", chatbot)
graph_builder.add_node("tool_executor", tool_executor)
​
# 定义边
graph_builder.set_entry_point("chatbot")  # 等同于 graph_builder.add_edge(START, "chatbot")
graph_builder.add_conditional_edges("chatbot", router)
graph_builder.add_edge("tool_executor", "chatbot")
​
# 编译
graph = graph_builder.compile()
​
# 执行
print(graph.invoke({"messages": [("human", "python3.14版本的新特性有哪些?")]}))
​

这里我们使用了 [], get, getatter这些方式访问数据,他们的具体区别为:

场景方法示例
字典取值dict.get()data.get("key", default)
字典/列表下标[]data["key"] / list[0]
对象属性检查hasattr()hasattr(obj, "attr")
对象属性获取getattr()getattr(obj, "attr", default)
对象属性访问.obj.attr

Implement ReACT By LangGraph

在 LangGraph 中除了能使用基础组件(节点、边、数据状态)来构建 Agent 智能体,我们还可以使用 LangGraph 预构建的代理来快速创建智能体,例如:ReACT智能体 亦或者 工具调用智能体。

现在我们将通过该预编译的智能体完成一个文生图的功能

import os
from typing import Typeimport dotenv
from dashscope import ImageSynthesis
from dashscope.api_entities.dashscope_response import ImageSynthesisResponse
from langchain_community.tools import GoogleSerperRun
from langchain_community.tools.openai_dalle_image_generation import OpenAIDALLEImageGenerationTool
from langchain_community.utilities import GoogleSerperAPIWrapper
from langchain_core.tools import BaseTool
from langchain_openai import ChatOpenAI
from langgraph.prebuilt import create_react_agent
from pydantic.v1 import BaseModel, Field
​
dotenv.load_dotenv()
​
​
# 定义工具参数约束
class GoogleSerperArgsSchema(BaseModel):
    query: str = Field(description="执行谷歌搜索的查询语句")
​
​
class QwenImagePlusArgsSchema(BaseModel):
    query: str = Field(description="输入内容应该是生成图像的文本提示(prompt)")
​
​
# 定义工具
google_serper = GoogleSerperRun(
    name="google_serper",
    description=(
        "一个低成本的谷歌搜索API。"
        "当你需要回答有关时事的问题时,可以调用该工具。"
        "该工具的输入是搜索查询语句。"
    ),
    args_schema=GoogleSerperArgsSchema,
    api_wrapper=GoogleSerperAPIWrapper(),
)
​
​
class QwenImagePlusGenerationTool(BaseTool):
    """使用通义千问的 qwen-image-plus 模型生成图片的工具"""
​
    name: str = "qwen-imageplus"
    description: str = "一个基于文本生成图片的工具"
    args_schema: Type[BaseModel] = QwenImagePlusArgsSchema
​
    def _qwen_image_plus_generator(self, query: str):
        api_key = os.getenv("DASHSCOPE_API_KEY")
        print('----同步调用,请等待任务执行----')
        rsp: ImageSynthesisResponse = ImageSynthesis.call(api_key=api_key,
                                                          model="qwen-image-plus",
                                                          prompt=query,
                                                          n=1,
                                                          size='1328*1328',
                                                          prompt_extend=True)
        urls = []
        if hasattr(rsp.output, 'results') and len(rsp.output.results) > 0:
            for result in rsp.output.results:
                urls.append(result.url)
​
        return urls[0]
​
    # 这里要注意,我们的函数调用是用于给LLM进行二次处理,所以我们这里返回了str,他会在create_react_agent内部封装成一个ToolMessage内容
    def _run(
            self,
            query: str
    ) -> str:
        """Use the OpenAI DALLE Image Generation tool."""
        return self._qwen_image_plus_generator(query)
​
​
tools = [google_serper, QwenImagePlusGenerationTool()]
​
# 1. 定义模型节点
llm = ChatOpenAI(
    model="qwen3-max", temperature=0
)
​
# 2. 构建agent, 使用 langgraph 预购建的 agent 处理
agent = create_react_agent(model=llm, tools=tools)
​
# 3. 调用并输出
res = agent.invoke(
    {"messages": [("human",
                   "请帮我绘制一幅日本动漫《やはり俺の青春ラブコメはまちがっている》中雪之下雪乃角色在雪天咖啡馆旁边撑伞的图片")]})
if res.get("messages"):
    print(getattr(res.get("messages")[-1], "content"))

create_react_agent 在 langchain1.0 版本中被 create_agent 替换了

具体说明:docs.langchain.com/oss/python/…

在 langchain1.0 版本中我们可以执行如下的逻辑

from langchain.agents import create_agent
​
# ...
# 1. 定义模型节点
llm = ChatOpenAI(
    model="qwen3-max", temperature=0
)
​
agent = create_agent(model=llm, tools=tools, system_prompt="你是一个有用的AI工具,可以在需要的时候调用人类提供的工具来进行更好的回复", state_schema=MessagesState)
agent.invoke({
    "messages": [("human", "请帮我绘制一幅日本动漫《やはり俺の青春ラブコメはまちがっている》中雪之下雪乃角色在雪天咖啡馆旁边撑伞的图片, 请严格保持角色的面部特征与动漫中是一致的")]
})

整个方法的执行流程大致如下

Update Or Delete Message In Graph

相关的文档描述可以参考:reference.langchain.com/python/lang…

在图结构应用程序中,消息列表是一种高频使用的状态,通常情况下我们只会往状态中添加消息。但是在某些特殊的情况下,我们可能希望删除消息列表中的某一条消息(亦或者是修改消息列表中的某一条数据)。

这个时候就需要使用 LangGraph 为我们提供的 RemoveMessage 类配合 add_messages() 函数一起来实现这个功能。其核心思想是归纳函数 add_messages() 底层针对更新的消息类型做了检测,如果检测到是 RemoveMessage 类型,则不会新增数据,而是执行删除数据的操作。

所以对于需要删除的消息,只需要在节点返回的时候,创建 RemoveMessage 实例并传递 消息id 即可,例如下方提问后删除人类消息:

import dotenv
from langchain_core.messages import RemoveMessage, AIMessage, HumanMessage
from langchain_core.runnables import RunnableConfig
from langchain_openai import ChatOpenAI
from langgraph.graph import MessagesState, StateGraph
​
dotenv.load_dotenv()
​
llm = ChatOpenAI(model="qwen3-max")
​
​
# chatbot
def chatbot(state: MessagesState, config: RunnableConfig) -> MessagesState:
    messages = state.get("messages")
    ai_message = llm.invoke(messages)
    return {  # 一定要返回一个State, 因为使用了归纳函数,所以该数据会拼接到数组中,而不是覆盖
        "messages": [ai_message],
    }
​
​
# 定义删除消息的节点
def remove_human_message(state: MessagesState, config: RunnableConfig) -> MessagesState:
    """删除提问中的人类最后一条消息"""
​
    # 获取消息队列中的最后一条人类消息
    messages = state.get("messages")
​
    target_id = ""
    messages.reverse()
    for msg in messages:
        if isinstance(msg, HumanMessage):
            target_id = msg.id
​
    return MessagesState(messages=[
        RemoveMessage(id=target_id)
    ])
​
​
# 创建图
graph_builder = StateGraph(state_schema=MessagesState)
graph_builder.add_node("llm", chatbot)
graph_builder.add_node("remove_human_message", remove_human_message)
​
# build edge
graph_builder.set_entry_point("llm")
graph_builder.add_edge("llm", "remove_human_message")
graph_builder.set_finish_point("remove_human_message")
​
graph = graph_builder.compile()
​
print(
    "\n".join([str(msg) for msg in graph.invoke({"messages": [("human", "hey, 好久不见,近来可好?")]}).get("messages")]))
​

核心在我们返回的 RemoveMessage 对象,这里我们需要了解一下 langgraph 中的 add_messages 函数的具体实现,这里是文档中没有说明的地方,所以我们需要通过阅读其具体的源码进行学习与了解。

@_add_messages_wrapper
def add_messages(
    left: Messages,
    right: Messages,
    *,
    format: Optional[Literal["langchain-openai"]] = None,
) -> Messages:
    # coerce to list
    if not isinstance(left, list):
        left = [left]  # type: ignore[assignment]
    if not isinstance(right, list):
        right = [right]  # type: ignore[assignment]
    # coerce to message
    left = [
        message_chunk_to_message(cast(BaseMessageChunk, m))
        for m in convert_to_messages(left)
    ]
    right = [
        message_chunk_to_message(cast(BaseMessageChunk, m))
        for m in convert_to_messages(right)
    ]
    # assign missing ids
    for m in left:
        if m.id is None:
            m.id = str(uuid.uuid4())
    for m in right:
        if m.id is None:
            m.id = str(uuid.uuid4())
    # merge
    left_idx_by_id = {m.id: i for i, m in enumerate(left)}
    merged = left.copy()
    ids_to_remove = set()
    for m in right:
        if (existing_idx := left_idx_by_id.get(m.id)) is not None:
            if isinstance(m, RemoveMessage):
                ids_to_remove.add(m.id)
            else:
                merged[existing_idx] = m
        else:
            if isinstance(m, RemoveMessage):
                raise ValueError(
                    f"Attempting to delete a message with an ID that doesn't exist ('{m.id}')"
                )
​
            merged.append(m)
    merged = [m for m in merged if m.id not in ids_to_remove]
​
    if format == "langchain-openai":
        merged = _format_messages(merged)
    elif format:
        msg = f"Unrecognized {format=}. Expected one of 'langchain-openai', None."
        raise ValueError(msg)
    else:
        pass
​
    return merged

底层会进行一次判断

  • update 数据
def update_ai_message(state: MessagesState, config: RunnableConfig) -> Any:
    """修改AI消息节点"""
    ai_message = state["messages"][-1]
    return {"messages": [AIMessage(id=ai_message.id, content="我是被修改过后的AI消息:" + ai_message.content)]}

这也是为什么使用 LangGraph 提供的 add_messages 而不是 operator.add 来实现,operator.add 虽然能实现对列表的相加,但没有针对修改亦或者删除的逻辑,仍然需要手动去实现,因为本质上这里的删除/更新逻辑是 归纳函数 实现的,所以对于没有配置归纳函数,或者归纳函数没有该逻辑的则无法实现

删除消息一定要特别注意,因为绝大部分模型期望消息列表存在某些规则。例如,有些模型期望它们以 user 消息开头,其他模型期望所有带有工具调用的消息后面都跟着工具消息。删除消息时,需要确保不会违反这些规则。

Filter And Trim Messages

在 LangGraph 中 状态 可以很便捷管理整个过程中产生的所有消息信息,但是随着持续对话,亦或者图结构组件的增加,对话历史会不断累积,并占用越来越多的上下文窗口,这通常是不可取的,因为它会导致对 LLM 的调用变得非常昂贵和耗时,并降低 LLM 生成内容的正确性,所以在 LangGraph 中一般还需要对消息进行过滤和修剪

过滤/修剪 一般不会更改 状态,而是在调用 LLM 时,只传递特定条数的消息或者按照 token长度 进行修剪。

例如使用 过滤消息 可以单独创建一个函数(非节点),在调用 LLM 前,对消息进行过滤,使用固定条数的 消息列表:

其实就是我们手动对 state 中的消息进行 filter, 然后当前节点的处理都是基于这个 filter 之后的数据进行的。

  • filter
def filter_messages(state: MessagesState) -> Any:
    """过滤数据状态并返回最后一条消息"""
    return state["messages"][-1:]
​
def chatbot(state: MessagesState, config: RunnableConfig) -> Any:
    """聊天机器人节点"""
    messages = filter_messages(state)
    return {"messages": [llm.invoke(messages)]}

这样在使用 LLM 时就可以避免全部将消息传递过去,并且在图架构程序内,状态 仍然会保存最完整的信息。

除此之外,还可以依据 Token长度限制 对消息列表进行修剪,在 LangChain 中对于该需求还封装了特定的函数 trim_messages,它可用于将聊天历史的大小减少到指定的 token 数量或消息数量,该函数的参数如下:

  • messages:需要修剪的消息列表。
  • max_tokens:修剪消息的最大 Token 数。
  • token_counter:计算 Token 数的函数,或者传递大语言模型(使用大语言模型的 .get_num_tokens_from_messages() 计算 Token 数)。
  • strategy:修剪策略,first 代表从前往后修剪消息,last 代表从后往前修剪消息,默认为 last。
  • allow_partial:如果只能拆分消息的一部分,是否拆分消息,默认为 False,拆分可以分成多种,一种是消息文本单独拆分,另外一种是如果设置了 n,一次性返回多条消息,针对消息的拆分。
  • end_on:修剪消息结束的类型,如果执行,则在这种类型的最后一次出现时将被忽略,类型为列表或者单个值(支持传递消息的类型字符串,例如:system、human、ai、tool 等,亦或者传递消息类)。
  • start_on:修剪消息开始的类型,如果执行,则在这种类型的最后一次出现时将被忽略,类型为列表或者单个值(支持传递消息的类型字符串,例如:system、human、ai、tool 等,亦或者传递消息类)。
  • include_system:是否保留系统消息,只有在 strategy="last" 时设置才有效。
  • text_splitter:文本分割器,默认为空,当设置 allow_partial=True 时才有用,用于对某个消息类型中的大文本进行分割。

例如实现对消息列表进行修剪,使其 Token 数不超过 80,保留前置消息,允许部分分割,使用的模型为 gpt-4o,代码如下

import dotenv
from langchain_core.messages import HumanMessage, AIMessage, trim_messages
from langchain_openai import ChatOpenAI
from langchain_text_splitters import RecursiveCharacterTextSplitter
​
dotenv.load_dotenv()
​
messages = [
    HumanMessage(content="你好, 你喜欢什么呢?"),
    AIMessage([
        {"type": "text", "text": "你好,我对很多话题感兴趣,比如探索新知识和帮助解决问题。"},
        {
            "type": "text",
            "text": "你好,我是一个有用的AI助手?"
        },
    ]),
    HumanMessage(content="如果我想学习关于LangGraph的知识,你能给我一些建议么?"),
    AIMessage(
        content="当然可以!你可以从基础的python入手,然后逐步深入到更具体的langgraph领域。"
    ),
]
​
update_messages = trim_messages(
    messages,
    max_tokens=80,  # 消息列表的token不超过 80
    token_counter=ChatOpenAI(model="gpt-4o"),
    # 使用计算的token模型,这里底层Langchain OpenAI进行了约束。  if model.startswith("gpt-3.5-turbo-0301"):   elif model.startswith("gpt-3.5-turbo") or model.startswith("gpt-4"):  模型名必须这样
    strategy="first",  # 从0开始进行剪切
    allow_partial=True,  # 消息内容允许分割
    text_splitter=RecursiveCharacterTextSplitter(),  # 使用的消息分割器
)
​
print(update_messages)

并且在 LangChain 中 trim_messages() 函数使用 @_runnable_support 装饰器进行装饰,所以该函数也是一个 Runnable 可运行组件,可以直接拼接到 LCEL 表达式构建的链应用中

在后续,我们将会实现 结合 摘要记忆组件 的思想,实现一个能同时记录 历史对话摘要 和 最近N条消息 的 Graph 程序,当 Token 数不超过 1000 时,使用消息列表,当 Token 数超过 1000 时,使用 历史对话摘要 + Token数不超过1000的N条消息 作为 LLM 的输入,请思考使用 数据状态+Graph 的方式实现

Checkpoint

在 LCEL 表达式构建的链应用中,我们将 Memory组件 通过 .with_listen() 函数绑定到整个链的 运行结束生命周期 上,从而去实现链记忆功能的自动管理,在 LangGraph 中也有类似的功能,不过该功能是 检查点,在编程中 检查点 通常用于记录或标记程序在某个阶段的 状态,以便在程序运行过程中出现问题时,可以回溯到特定的状态,亦或者在图执行的过程中将任意一个节点的状态进行保存

但是要注意的是,langgraph 在设计的初期就考虑一个 graph 可能会存在多个不同环境(用户)中的执行,所以提供了它的检查点的设计需要一些能够标记当前是哪个用户。这种标记,langgraph 中称为 thread_id

具体可以参考该链接:docs.langchain.com/oss/python/…

具体的使用流程为:

  1. 实例化一个检查点,例如 AsyncSqliteSaver 或者 MemorySaver(),亦或者自定义检查点。
  2. 在图编译的时候传递检查点,例如 compile(checkpointer=my_checkpointer)
  3. 接下来在和图程序交互时传递 config,并配置 thread_id 即可记住以往的历史记忆/存档,代码如下:
from langchain_community.tools import GoogleSerperRun
from langchain_community.utilities import GoogleSerperAPIWrapper
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import MemorySaver
from langgraph.prebuilt import create_react_agent
import dotenv
from pydantic.v1 import BaseModel, Field
​
dotenv.load_dotenv()
​
llm = ChatOpenAI(model="qwen3-max")
​
​
# 定义工具
class GoogleSerperArgsSchema(BaseModel):
    query: str = Field(description="执行谷歌搜索的查询语句")
​
​
google_serper = GoogleSerperRun(
    name="google_serper",
    description=(
        "一个低成本的谷歌搜索API。"
        "当你需要回答有关时事的问题时,可以调用该工具。"
        "该工具的输入是搜索查询语句。"
    ),
    args_schema=GoogleSerperArgsSchema,
    api_wrapper=GoogleSerperAPIWrapper(),
)
tools = [google_serper]
​
# 创建一个内存级别的 checkpoint
checkpointer = MemorySaver()
​
# create_react_agent 默认使用 AgentState 作为State, 该AgentState内部具有messages属性
agent = create_react_agent(model=llm, tools=tools, checkpointer=checkpointer)
​
# 通过同一个用户多次调用,内存中的消息会维护,不同用户调用 graph 使用不同的消息容器
# 这里的config内部的配置结构是固定的一定要注意!!!
print(agent.invoke(
    {"messages": [("human", "你好,我是飘零的雪花,我喜欢音乐,以及希望通过程序完成一些真正可以用于生活的功能")]},
    config={"configurable": {"thread_id": "1"}}
))
​
print(agent.invoke(
    {"messages": [("human", "hey,你知道我是谁吗?我希望完成什么呢?")]},
    config={"configurable": {"thread_id": "1"}}
))
​
print(agent.invoke(
    {"messages": [("human", "hey,你知道我是谁吗?我希望完成什么呢?")]},
    config={"configurable": {"thread_id": "2"}}  # 修改 thread_id 观察输出
))

上面我们使用的是内存级别的 checkpoint,LangGraph 中也提供了一些持久化存储的 checkpoint,但是我们需要根据业务进行自定义,所以这里我们先行跳过,后面到了具体业务时,我们将会尝试自定义持久化的内容。

自定义检查点总共要实现 4 种方法:

  • put():使用其配置和元数据存储检查点。
  • put_writes():存储与检查点相关联的中间写入(即挂起的写入)。
  • get_tuple():使用给定配置(thread_id 和 checkpoint_id)获取检查点元组。
  • list():列出与给定配置和筛选条件匹配的检查点。

Human In The Loop(HIL)

人机交互(Human-in-the-loop,简称 HIL)交互对于 Agent 系统至关重要,特别是在一些特定领域的 Agent 中,需要经过人类的允许或者指示才能进入下一步(例如某些敏感或者重要操作),而 HIL 最重要的部分就是 断点(interrupt)

通过这种 interrupt(中断)机制,构建了需要人类进行二次处理(审查、编辑和批准)的人机交互 (Human-in-the-Loop)工作流成为可能。

当工作流(Graph)执行到中断点时,它会保存当前的所有状态,然后无限期暂停,直到接收到人类的输入指令后再从断点处继续。这为构建可靠、安全且透明的 Agent 应用奠定了基石

它允许用户在工作流的任何阶段进行干预。这对于大型语言模型驱动的应用程序尤其有用,因为模型输出可能需要验证、更正或补充上下文。该功能包括两种中断类型:动态中断和静态中断,允许用户暂停图执行并进行审查或编辑。此外,灵活的集成点使人类可以针对特定步骤进行干预,例如批准 API 调用、更正输出或引导对话。

让我们使用 HIL 机制完成人类进行审核执行流程的效果

因为 langgraph 在不同的版本中对 interrupt 的返回值的位置做了修改,我们这里提供 0.2 版本和 1.0 版本的

大致的执行流程为:

  1. 配置持久化层 (Checkpointer):中断的本质是状态的保存与恢复。因此,在编译 Graph 时,必须为其指定一个 checkpointer,用于在每一步执行后自动保存状态
  2. 在节点中调用 interrupt():在需要人工干预的节点函数中,调用 interrupt() 函数。此函数会立即暂停执行,并可以向用户传递一个 JSON 可序列化的对象,其中包含需要审查的数据,在 langgraph1.0 的版本中可以通过 __interrupt__获取中断返回的结果,而早期的版本中需要通过graph.get_state()获取返回的结果
  3. 运行并触发中断:中断需要指定当前graph调用的用户,也就是thread_id,当graph执行到第二步提供的interrupt时,会暂停,然后返回一个对象(1.0 版本),该对象中存在一个特殊的key:__interrupt__, 该keyd对应的value中包含了中断的详细信息,如传递给用户的数据
  4. 使用Command恢复graph的执行,当human执行完对应的操作后,可以继续调用graph的invoke或者 stream传递一个Command(resume=...)对象来恢复执行, 这里的resume要注意,它的值作为interrupt函数的返回值

核心机制:恢复即重跑 (Resume Reruns the Node)

一定要注意,graph调用invoke / stream 时恢复执行,它的执行步骤不是从调用interrupt的那一行代码位置开始!!!

而是从包含 interrupt() 的那个节点的开头重新执行整个节点,在重跑期间,当执行流再次遇到 interrupt() 时,它不会再次暂停,而是直接返回 Command(resume=...) 中提供的值

这个设计虽然巧妙,但也意味着任何位于 interrupt() 调用之前的、具有副作用的操作(如 API 调用、数据库写入)都会被重复执行。因此,最佳实践是将副作用操作放在 interrupt() 之后,或置于一个独立的后续节点中。

执行流程为:

案例参考:mp.weixin.qq.com/s/m37YhDsKs…

Approve Or Reject(practice)

通过interrupt实现审批或否决的功能,执行高风险操作前,强制要求人工批准。根据用户的决策,Graph 可以走向不同的分支,例如继续执行或者直接否决

0.2 版本
import os
import uuid
from typing import TypedDict, Annotated, Literalimport dotenv
from langchain_core.messages import BaseMessage, AIMessage
from langchain_openai import ChatOpenAI
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import StateGraph, add_messages
from langgraph.constants import END
from langgraph.types import interrupt, Command, StateSnapshot
from pydantic.v1 import BaseModel, Field
​
dotenv.load_dotenv()
​
llm = ChatOpenAI(model=os.getenv("DASHSCOPE_TEXT_MODEL"))
​
​
def approve_or_reject():
    """通过interrupt实现审批或否决的功能,执行高风险操作前,强制要求人工批准。根据用户的决策,Graph 可以走向不同的分支"""
​
    # 1. 定义图的共享状态,包含一个 'decision' 字段来记录人类的决定
    class MyState(TypedDict):
        messages: Annotated[list[BaseMessage], add_messages]  # 大模型的输出
        decision: str  # 决策的内容
​
    def generate_llm_output(state: MyState, config: RunnableConfig) -> MyState:
        print("\n--- 步骤1:AI生成内容 ---")
        messages = state["messages"]
        ai_message = llm.invoke(messages)
        return {
            "messages": [ai_message],
        }
​
    # 2. 定义人工审批节点。注意:返回值类型是 Command,意味着此节点将发出控制指令。这里我们认定:human同意执行approved_path节点,拒绝执行rejected_path节点
    def human_approval(state: MyState, config: RunnableConfig) -> Command[Literal["approved_path", "rejected_path"]]:
        """此节点暂停并等待人类决策,然后根据决策返回一个带有 goto 参数的 Command,从而控制图的走向。"""
        print("\n--- 暂停:等待人工审批 ---")
​
        # 3. 执行interrupt,此时将会真正的暂定graph的执行,等待human的决策
        decision = interrupt({  # interrupt内部的内容将会返回给上层,client,我们继续往下看
            "question": "请审批以下内容,回复 'approve' 或 'reject':",
            "messages": "\n".join(f"\t{message.name}: {message.content}" for message in state["messages"])
        })
        decision_res = decision
​
        if decision not in ["approve", "reject"]:
            class LLMDecisionByHumanContent(BaseModel):
                """基于人类回复的消息判断是 approve 还是 reject"""
                decision: Literal["approve", "reject"] = Field(...,
                                                               description="对人类回复的内容进行判断,是 approve 还是 reject")
​
            human_decision_by_llm: LLMDecisionByHumanContent = llm.with_structured_output(
                LLMDecisionByHumanContent).invoke([
                ("system", "当前你是一名资深的人类语言学专家,你将根据用户提供的消息判断他是 approve 还是 reject"),
                ("human", f"{decision}")
            ])
            print(f"human_decision_by_llm.decision: {human_decision_by_llm.decision}")
            decision_res = human_decision_by_llm.decision
​
        # 4. 核心逻辑:根据人类的决策('decision' 变量的值)进行判断。
        if decision_res == "approve":
            print("\n--- 决策:批准 ---")
            # 5. 如果批准,返回一个 Command 指令,强制图跳转到 'approved_path' 节点。
            #    'goto' 是实现条件路由的关键。'update' 是一个可选参数,用于同时更新状态。
            return Command(goto="approved_path", update={"decision": "approved"})
        else:
            print("\n--- 决策:拒绝 ---")
            # 6. 如果拒绝,则跳转到 'rejected_path' 节点。
            return Command(goto="rejected_path", update={"decision": "rejected"})
​
    # 批准后的流程节点
    def approved_node(state: MyState) -> MyState:
        print("--- 步骤2 (分支A): 已进入批准流程。---")
        return state
​
    # 拒绝后的流程节点
    def rejected_node(state: MyState) -> MyState:
        print("--- 步骤2 (分支B): 已进入拒绝流程。---")
        return state
​
    builder = StateGraph(MyState)
    builder.add_node("generate_llm_output", generate_llm_output)
    builder.add_node("human_approval", human_approval)
    builder.add_node("approved_path", approved_node)
    builder.add_node("rejected_path", rejected_node)
​
    # 7. 设置图的入口和边,定义了基本的流程。
    #    注意,从 human_approval 节点出发的路径将由其返回的 Command(goto=...) 动态决定。
    builder.set_entry_point("generate_llm_output")
    builder.add_edge("generate_llm_output", "human_approval")  # 该节点内部将会动态处理后续执行哪个分支
    builder.add_edge("approved_path", END)  # 批准分支的终点
    builder.add_edge("rejected_path", END)  # 拒绝分支的终点
​
    checkpointer = MemorySaver()
    graph = builder.compile(checkpointer=checkpointer)
​
    config = {"configurable": {"thread_id": f"thread-{uuid.uuid4()}"}}
    print("首次调用,启动审批流程...")
    result = graph.invoke({
        "messages": [("human", "hey, 好久不见,近来可好?")]
    }, config=config)
​
    # v1.0 中可以直接在返回值中获取 __interrupt__ 属性来获取interrupt返回的值
    print("\n图已暂停,等待审批...")
​
    interrupt_state_snap: StateSnapshot = graph.get_state(config)
​
    # 获取interrupt返回的数据
    if interrupt_state_snap.tasks:
        for task in interrupt_state_snap.tasks:
            if hasattr(task, 'interrupts') and task.interrupts:
                interrupt_data = task.interrupts[0].value
                print(f"当前数据为:\n{interrupt_data['question']}\n{interrupt_data['messages']}")
​
    #  用户输入的 这个字符串会成为 interrupt() 的返回值,并赋给 'decision' 变量。
    # print(f"当前数据为:\n{result['question']}\n{result['messages']}")
    human_input = input("输入approve 或 reject: ")
    print("\n--- 恢复执行:传入 'approve' 决策 ---")
    final_result = graph.invoke(Command(resume=human_input), config=config)
​
    # 打印最终结果。由于我们 resume="approve",流程会走 approved_path,最终状态会包含 'decision': 'approved'。
    print("\n流程执行完毕,最终状态如下:")
    for msg in final_result.get("messages"):
        msg: BaseMessage
        msg.pretty_print()
​
​
approve_or_reject()
1.0 版本
import os
import uuid
from typing import TypedDict, Annotated, Literalimport dotenv
from langchain_core.messages import BaseMessage, AIMessage
from langchain_openai import ChatOpenAI
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import StateGraph, add_messages
from langgraph.constants import END
from langgraph.types import interrupt, Command
from pydantic import BaseModel, Field
​
dotenv.load_dotenv()
​
llm = ChatOpenAI(model=os.getenv("DASHSCOPE_TEXT_MODEL"))
​
"""通过interrupt实现审批或否决的功能,执行高风险操作前,强制要求人工批准。根据用户的决策,Graph 可以走向不同的分支"""
​
​
# 1. 定义图的共享状态,包含一个 'decision' 字段来记录人类的决定
class MyState(TypedDict):
    messages: Annotated[list[BaseMessage], add_messages]  # 大模型的输出
    decision: str  # 决策的内容
​
​
def generate_llm_output(state: MyState, config: RunnableConfig) -> MyState:
    print("\n--- 步骤1:AI生成内容 ---")
    messages = state["messages"]
    ai_message = llm.invoke(messages)
    return {
        "messages": [ai_message],
    }
​
​
# 2. 定义人工审批节点。注意:返回值类型是 Command,意味着此节点将发出控制指令。这里我们认定:human同意执行approved_path节点,拒绝执行rejected_path节点
def human_approval(state: MyState, config: RunnableConfig) -> Command[Literal["approved_path", "rejected_path"]]:
    """
    此节点暂停并等待人类决策,然后根据决策返回一个带有 goto 参数的 Command,从而控制图的走向。
    """
    print("\n--- 暂停:等待人工审批 ---")
​
    # 3. 执行interrupt,此时将会真正的暂定graph的执行,等待human的决策
    decision = interrupt({  # interrupt内部的内容将会返回给上层,client,我们继续往下看
        "question": "请审批以下内容,回复 'approve' 或 'reject':",
        "messages": "\n".join(f"\t{message.name}: {message.content}" for message in state["messages"])
    })
    decision_res = decision
​
    if decision not in ["approve", "reject"]:
        class LLMDecisionByHumanContent(BaseModel):
            """基于人类回复的消息判断是 approve 还是 reject"""
            decision: Literal["approve", "reject"] = Field(...,
                                                           description="对人类回复的内容进行判断,是 approve 还是 reject")
​
        human_decision_by_llm: LLMDecisionByHumanContent = llm.with_structured_output(LLMDecisionByHumanContent).invoke(
            [
                ("system", "当前你是一名资深的人类语言学专家,你将根据用户提供的消息判断他是 approve 还是 reject"),
                ("human", f"{decision}")
            ])
        print(human_decision_by_llm.decision)
        decision_res = human_decision_by_llm.decision
​
    # 4. 核心逻辑:根据人类的决策('decision' 变量的值)进行判断。
    if decision_res == "approve":
        print("\n--- 决策:批准 ---")
        # 5. 如果批准,返回一个 Command 指令,强制图跳转到 'approved_path' 节点。
        #    'goto' 是实现条件路由的关键。'update' 是一个可选参数,用于同时更新状态。
        return Command(goto="approved_path", update={"decision": "approved"})
    else:
        print("\n--- 决策:拒绝 ---")
        # 6. 如果拒绝,则跳转到 'rejected_path' 节点。
        return Command(goto="rejected_path", update={"decision": "rejected"})
​
​
# 批准后的流程节点
def approved_node(state: MyState) -> MyState:
    print("--- 步骤2 (分支A): 已进入批准流程。---")
    return state
​
​
# 拒绝后的流程节点
def rejected_node(state: MyState) -> MyState:
    print("--- 步骤2 (分支B): 已进入拒绝流程。---")
    return state
​
​
builder = StateGraph(MyState)
builder.add_node("generate_llm_output", generate_llm_output)
builder.add_node("human_approval", human_approval)
builder.add_node("approved_path", approved_node)
builder.add_node("rejected_path", rejected_node)
​
# 7. 设置图的入口和边,定义了基本的流程。
#    注意,从 human_approval 节点出发的路径将由其返回的 Command(goto=...) 动态决定。
builder.set_entry_point("generate_llm_output")
builder.add_edge("generate_llm_output", "human_approval")  # 该节点内部将会动态处理后续执行哪个分支
builder.add_edge("approved_path", END)  # 批准分支的终点
builder.add_edge("rejected_path", END)  # 拒绝分支的终点
​
checkpointer = MemorySaver()
graph = builder.compile(checkpointer=checkpointer)
​
config = {"configurable": {"thread_id": f"thread-{uuid.uuid4()}"}}
print("首次调用,启动审批流程...")
result = graph.invoke({
    "messages": [("human", "hey, 好久不见,近来可好?")]
}, config=config)
print("\n图已暂停,等待审批...\n")
​
#  "approve" 这个字符串会成为 interrupt() 的返回值,并赋给 'decision' 变量。
#    你可以尝试将 "approve" 改为 "reject" 来测试另一条分支。
interrupt_output = getattr(result['__interrupt__'][0], "value")
print(f"当前数据为:\n{interrupt_output['question']}\n{interrupt_output['messages']}")
human_input = input("输入approve 或 reject: ")
print("\n--- 恢复执行:传入 'approve' 决策 ---")
final_result = graph.invoke(Command(resume=human_input), config=config)
​
# 打印最终结果。由于我们 resume="approve",流程会走 approved_path,最终状态会包含 'decision': 'approved'。
print("\n流程执行完毕,最终状态如下:")
for message in final_result["messages"]:
    message: BaseMessage
    message.pretty_print()

输出

首次调用,启动审批流程...
​
--- 步骤1:AI生成内容 ------ 暂停:等待人工审批 ---
​
图已暂停,等待审批...
当前数据为:
请审批以下内容,回复 'approve''reject'None: hey, 好久不见,近来可好?
    None: 嘿!好久不见啦~ 😊  
我最近一切安好,随时待命陪你聊天、帮忙解惑,或者就这样轻松地叙叙旧也很好!你呢?最近过得怎么样?有什么开心或烦恼的事想聊聊吗?
输入approve 或 reject: 同意
​
--- 恢复执行:传入 'approve' 决策 ------ 暂停:等待人工审批 ---
human_decision_by_llm.decision: approve
​
--- 决策:批准 ---
--- 步骤2 (分支A): 已进入批准流程。---
​
流程执行完毕,最终状态如下:
================================ Human Message =================================
​
hey, 好久不见,近来可好?
================================== Ai Message ==================================
​
嘿!好久不见啦~ 😊  
我最近一切安好,随时待命陪你聊天、帮忙解惑,或者就这样轻松地叙叙旧也很好!你呢?最近过得怎么样?有什么开心或烦恼的事想聊聊吗?

Review and Edit State(practice)

我们可以将 AI 生成的内容交由人类进行审核编辑,然后再继续后续的操作

def review_and_edit_state():
    """允许用户审查和编辑Agent在执行过程中生成的数据,如修正一篇 AI 生成的文章草稿,或者是AI提取出来的文本数据"""
​
    # 1.定义Graph的状态
    class State(TypedDict):
        messages: Annotated[list[BaseMessage], add_messages]
​
    # 2. 定义节点
    def generate_content(state: State) -> State:
        print("--- 步骤1: AI 正在生成摘要... ---")
        messages = state.get("messages")
        ai_message: AIMessage = llm.invoke(messages)
​
        return {
            "messages": [ai_message]
        }
​
    def human_review_edit(state: State) -> State:
        print("--- 步骤2: 等待人工审查和编辑... ---")
        # 调用 interrupt() 来暂停图的执行。这是实现人机交互的关键。
        # 传入的字典会作为中断的有效负载(payload)发送给调用方(例如前端应用),
        # 以便向用户展示任务和当前数据
        messages = deepcopy(state.get("messages"))
        messages.reverse()
        human_fixed_message = []
        for msg in messages:
            if isinstance(msg, AIMessage):
                human_fixed_content = interrupt({
                    "ai_message": msg
                })
                # 构建人类修改后的数据
                msg.content = human_fixed_content
                human_fixed_message.append(msg)
                break
​
        return {
            "messages": [*human_fixed_message]
        }
​
    # 模拟使用编辑后消息的下游任务
    def downstream_use(state: State) -> State:
        print(f"--- 步骤3: 正在使用编辑后的消息... ---")
        # 打印最终确认的摘要,证明状态已经被人工输入所更新。
        for msg in state['messages']:
            msg.pretty_print()
        return state
​
    # 3. 构建图(Graph)
    builder = StateGraph(State)
​
    # 将上面定义的函数注册为图中的节点
    builder.add_node("generate_content", generate_content)
    builder.add_node("human_review_edit", human_review_edit)
    builder.add_node("downstream_use", downstream_use)
​
    # 设置图的流程:定义入口和节点之间的固定连接顺序。
    builder.set_entry_point("generate_content")
    builder.add_edge("generate_content", "human_review_edit")
    builder.add_edge("human_review_edit", "downstream_use")
    builder.add_edge("downstream_use", END)  # END 表示流程结束
​
    # 4. 编译图
    # 设置一个内存检查点(checkpointing)。这是使用 interrupt 功能的必要条件,
    # 因为图需要一个地方来保存它暂停时的状态。
    checkpointer = MemorySaver()
    graph = builder.compile(checkpointer=checkpointer)
​
    # 5. 执行图 - 第一次调用(触发中断)
    # 为本次运行创建一个唯一的线程ID(thread_id),用于追踪和恢复状态。
    config = {"configurable": {"thread_id": str(uuid.uuid4())}}
    print("--- 开始执行图 ---")
    # 首次调用图。执行将会在 'human_review_edit' 节点中的 interrupt() 处暂停。
    graph.invoke({"messages": [("human", "好久不见,近来可好,生活好累啊")]}, config=config)
    # 当图被中断时,返回的结果会包含一个 '__interrupt__' 键,其值是中断时发送的数据。
    if graph.get_state(config).tasks:
        print("\n--- 图已暂停,等待输入 ---")
        interrupt_data = dict()
        for task in graph.get_state(config).tasks:
            interrupt_data = task.interrupts[0].value
​
        ai_message: AIMessage = interrupt_data.get("ai_message")
        human_fixed_content = "我是经历过了人类修改后的content: " + ai_message.content
​
        # 恢复执行
        graph.invoke(Command(resume=human_fixed_content), config=config)

输出

--- 开始执行图 ---
--- 步骤1: AI 正在生成摘要... ---
--- 步骤2: 等待人工审查和编辑... ------ 图已暂停,等待输入 ---
--- 步骤2: 等待人工审查和编辑... ---
--- 步骤3: 正在使用编辑后的消息... ---
================================ Human Message =================================
​
好久不见,近来可好,生活好累啊
================================== Ai Message ==================================
​
我是经历过了人类修改后的content: 哎呀,真的好久不见啦!看到你消息心里暖暖的~不过听你说生活好累,我有点心疼呢 🥺  
最近是不是遇到什么烦心事了?工作太忙?还是压力太大?  
​
你知道吗?有时候累了就允许自己停下来喘口气,哪怕只是发个呆、喝杯热茶,或者像现在这样随便聊聊——都不是浪费时间,而是给自己充电呀 💪  
​
要是愿意的话,可以和我说说具体哪里让你觉得特别累?说不定我们一起想想办法,或者…至少让我当个树洞陪你一会儿? 
​
(悄悄说:你上次提到喜欢喝奶茶,最近有犒劳自己一杯吗?)

Review Tool Calls(practice)

这是保障 Agent 安全性的终极防线。可以在 Agent 决定调用某个工具时强制中断,等待人工确认。下面会介绍如何在agent内部通过中断interrupt()实现人工干预

def review_of_tool():
    """工具审查与调用"""

    # 定义工具
    def search_hotel(hotel_name: str) -> str:
        """根据酒店名称查询酒店信息

        Args:
            hotel_name: 需要查询的酒店名称

        Returns:
            str: 查询的酒店相关信息
        """
        print("search_hotel tool: " + hotel_name)
        return f"该 酒店位于3号大街7号路54号房,是一家青旅."

    # 定义可人工干预的通用的包装器:可为任何工具添加,返回添加了中断的工具
    def add_human_in_the_loop(
            tool: Callable | BaseTool,
            *,
            interrupt_config: HumanInterruptConfig = None,
            description: str = ""
    ) -> BaseTool:
        from langchain_core.tools import tool as create_tool
        """可人工干预的通用的包装器,将装饰的工具封装起来,为其添加人机交互(human in the loop) 的功能。这是一个高阶函数,它接收一个工具,
        返回一个带有内置审批流程的新工具。 该中断支持我们修改工具调用的具体参数内容

        Args:
            tool: 可以是一个普通的 Python 函数,也可以是一个继承自 BaseTool 的 LangChain 工具实例
            interrupt_config: 一个可选的字典,用于配置人类审批界面的选项(如是否允许编辑、批准等)

        Returns:
            BaseTool: 一个新的 BaseTool 实例,这个新工具封装了原始工具并加入了人工审核功能
        """
        # 1. 规范化输入工具
        # 检查传入的 tool 是否已经是 BaseTool 的实例。
        # 如果不是(例如,只是一个普通的 Python 函数),则使用  create_tool 将其转换为一个标准的 LangChain 工具。
        # 这确保了后续代码可以统一处理 tool 对象。
        if not isinstance(tool, BaseTool):
            tool: BaseTool = create_tool(tool)
            tool.description = description

        # 2. 设置默认的人机交互配置
        # 如果用户没有提供 interrupt_config,则设置一个默认配置。
        # 默认允许用户:批准 (accept)、编辑 (edit) 或直接回应 (respond)。
        if interrupt_config is None:
            interrupt_config = {
                "allow_accept": True,
                "allow_edit": True,
                "allow_respond": True,
            }

        # 3. 创建并返回一个新的工具
        # 使用langchain内置的create_tool来创建
        # 关键在于,这个新工具会继承原始工具的名称 (name)、描述 (description) 和参数结构 ( args_schema )。
        @create_tool(
            tool.name,
            args_schema=tool.args_schema  # tool底层会将函数参数转成对应的一个BaseModel的类型约束
        )
        def call_tool_with_interrupt(config: RunnableConfig, **tool_input):
            """这是新工具的具体实现函数,它包含了人机交互(HIL)的逻辑。tool_input在底层create_react_agent的调用中,会根据我们的函数参数的key名称,以及上游LLM调用传递的args封装成一个dict"""

            # 3.1 构建中断请求
            # 构建一个 HumanInterrupt 类型的字典,这个字典将作为 interrupt 函数的参数。
            # 它包含了所有需要展示给人类审批的信息:
            # - action_request: AI 想要执行的动作(工具名和参数)。
            # - config: 告诉前端界面应该显示哪些按钮(批准、编辑等)。
            # - description: 给人类审批者的提示信息。
            request: HumanInterrupt = {
                "action_request": {
                    "action": tool.name,
                    "args": tool_input
                },
                "config": interrupt_config,
                "description": "对该工具的执行进行审阅"
            }

            # 3.2 暂停图并等待人类输入
            # 调用 interrupt 函数,将请求打包成列表传入。
            # 这是图执行暂停的地方
            # 当图通过 resume 命令恢复时,interrupt 函数会返回一个响应列表,我们取第一个响应。
            response = interrupt([request])[0]

            # 3.3 根据人类的响应采取行动
            # 如果人类审批认为是:批准 (accept)
            if response["type"] == "accept":
                # 直接使用原始的参数调用原始工具。
                tool_response = tool.invoke(tool_input, config)
            # 如果人类审批认为是:编辑 (edit)
            elif response["type"] == "edit":
                # 从响应中获取新的参数,更新 tool_input
                tool_input = response["args"]
                print("edit tool_input: " + tool_input)
                # 然后用新参数调用原始工具。
                tool_response = tool.invoke(tool_input, config)
            # 如果人类审批选择直接响应 (response),而不是执行工具
            elif response["type"] == "response":
                # 将其提供的反馈内容 (user_feedback) 直接作为工具的输出返回。
                # 这可以用来给 LLM 提供指导,例如:“不要搜索这个,请先总结一下已有信息。”
                user_feedback = response["args"]
                tool_response = user_feedback
            else:
                # 处理未知的响应类型,增加代码的健壮性。
                raise ValueError(f"未知的中断响应类型: {response['type']}")

            # 返回最终结果。这个结果将作为工具的执行输出,返回给调用它的 LLM。
            return tool_response

        return call_tool_with_interrupt

    # 同样,因为要使用中断功能,必须提供一个检查点来保存和恢复 Agent 的状态。
    checkpointer = MemorySaver()

    # 2. 带人机交互的agent定义方法:
    agent = create_react_agent(
        # 指定 Agent 使用的 LLM 模型
        llm,
        # 为 Agent 提供工具列表
        [
            # 关键点:我们没有直接传入 `search_tool`,
            # 而是传入了 `add_human_in_the_loop(search_tool)` 的返回结果。
            # 这意味着 Agent 拿到的 `search_hotel` 工具已经是被封装过的、带有人机交互逻辑的版本。
            # 所以在 create_react_agent中具体调用的是:call_tool_with_interrupt()
            add_human_in_the_loop(search_hotel, description="这是一个根据酒店名称查询酒店信息的工具"),
        ],
        # 将检查点与 Agent 关联起来
        checkpointer=checkpointer,
    )
​
    config = {"configurable": {"thread_id": str(uuid.uuid4())}}
​
    # 使用 stream 方法来运行 Agent,这样我们可以观察到每一步的中间输出
    human_input = input("输入你要查询的信息:")
    for chunk in agent.stream(
            {"messages": [("system", "你是一个酒店查询工具,可以根据用户传递的酒店名称,调用对应的工具获取酒店具体信息"),
                          ("human", human_input)]},
            config
    ):
        print(chunk)
        # for msg in chunk.get('agent').get('messages'):
        #     msg.pretty_print()
        print("\n")
​
    # 判断当前是否存在 interrupt
    state = agent.get_state(config)
    print(f"\n当前状态: {state.next}")  # 显示下一个待执行的节点
    # 如果有中断,获取中断信息
    if not state.tasks:
        return
​
    for task in state.tasks:
        # state.tasks:当前 未完成的节点,中断通常以 task 的形式挂起
        if hasattr(task, 'interrupts') and task.interrupts:
            interrupt_info = task.interrupts[0].value[0]
            print(
                f"中断信息: 中断的工具节点名称:{interrupt_info.get('action_request').get('action')}, 中断的工具节点接收的参数:{interrupt_info.get('action_request').get('args')}\n\t"
                f"中断的工具节点的配置:是否允许用户访问:{interrupt_info.get('config').get('allow_accept')},是否允许用户编辑:{interrupt_info.get('config').get('allow_edit')},是否允许用户直接响应:{interrupt_info.get('config').get('allow_respond')}。"
            )
​
            human_review_type_of_tool = input("工具调用的编辑类型:")
            args = ""
            if human_review_type_of_tool == "edit":
                args = input("输入修改后的工具参数信息:")
​
            for chunk in agent.stream(
                    Command(resume=[{
                        "type": human_review_type_of_tool,
                        "args": args
                    }]),
                    config
            ):
                print(chunk)
                print("\n")
输入你要查询的信息:我要查酒店
================================== Ai Message ==================================
​
请问您想查询哪家酒店的信息呢?可以提供酒店的名称吗?
​
​
​
输入你要查询的信息:我要查询 ^_- 酒店信息
{'agent': {'messages': [AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_63eed127cbb44f3ea4d4ad62', 'function': {'arguments': '{"hotel_name": "^_- "}', 'name': 'search_hotel'}, 'type': 'function', 'index': 0}], 'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 24, 'prompt_tokens': 300, 'total_tokens': 324, 'completion_tokens_details': None, 'prompt_tokens_details': {'audio_tokens': None, 'cached_tokens': 0}}, 'model_name': 'qwen3-max', 'system_fingerprint': None, 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-f1a85506-4a86-4c7d-8e94-04f7a83f4484-0', tool_calls=[{'name': 'search_hotel', 'args': {'hotel_name': '^_- '}, 'id': 'call_63eed127cbb44f3ea4d4ad62', 'type': 'tool_call'}], usage_metadata={'input_tokens': 300, 'output_tokens': 24, 'total_tokens': 324})]}}
​
​
{'__interrupt__': (Interrupt(value=[{'action_request': {'action': 'search_hotel', 'args': {'hotel_name': '^_- '}}, 'config': {'allow_accept': True, 'allow_edit': True, 'allow_respond': True}, 'description': '对该工具的执行进行审阅'}], resumable=True, ns=['tools:1e1043c6-771e-8352-08dc-3c866c98fb61'], when='during'),)}
​
​
​
当前状态: ('tools',)
中断信息: 中断的工具节点名称:search_hotel, 中断的工具节点接收的参数:{'hotel_name': '^_- 酒店'}
    中断的工具节点的配置:是否允许用户访问:True,是否允许用户编辑:True,是否允许用户直接响应:True。工具调用的编辑类型:edit
输入修改后的工具参数信息:alice
edit tool_input: alice
search_hotel tool: alice
{'tools': {'messages': [ToolMessage(content='该 酒店位于3号大街7号路54号房,是一家青旅.', name='search_hotel', id='b07f9b61-ee52-48cc-bd0d-e22656aeb7b6', tool_call_id='call_63eed127cbb44f3ea4d4ad62')]}}
​
​
{'agent': {'messages': [AIMessage(content='^_- 酒店位于3号大街7号路54号房,是一家青旅。如果您需要更多详细信息或有其他问题,请随时告诉我!', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 35, 'prompt_tokens': 359, 'total_tokens': 394, 'completion_tokens_details': None, 'prompt_tokens_details': {'audio_tokens': None, 'cached_tokens': 0}}, 'model_name': 'qwen3-max', 'system_fingerprint': None, 'finish_reason': 'stop', 'logprobs': None}, id='run-d0bb52a1-e8aa-494f-8c2c-1c6f71f2a01d-0', usage_metadata={'input_tokens': 359, 'output_tokens': 35, 'total_tokens': 394})]}}

上面我们封装了一个较为复杂的函数,不过该函数我们没有提供装饰器的版本,具体内容如下:

值得注意的是,我们这里的 tool 已经是一个 BaseTool 类型了

# 3. 创建并返回一个新的工具
        # 使用langchain内置的create_tool来创建
        # 关键在于,这个新工具会继承原始工具的名称 (name)、描述 (description) 和参数结构 ( args_schema )。
        @create_tool(
            tool.name,
            args_schema=tool.args_schema  # tool底层会将函数参数转成对应的一个BaseModel的类型约束
        )
        def call_tool_with_interrupt(config: RunnableConfig, **tool_input):
            """这是新工具的具体实现函数,它包含了人机交互(HIL)的逻辑。"""
​
            # 3.1 构建中断请求
            # 构建一个 HumanInterrupt 类型的字典,这个字典将作为 interrupt 函数的参数。
            # 它包含了所有需要展示给人类审批的信息:
            # - action_request: AI 想要执行的动作(工具名和参数)。
            # - config: 告诉前端界面应该显示哪些按钮(批准、编辑等)。
            # - description: 给人类审批者的提示信息。
            request: HumanInterrupt = {
                "action_request": {
                    "action": tool.name,
                    "args": tool_input
                },
                "config": interrupt_config,
                "description": "对该工具的执行进行审阅"
            }
​
            # 3.2 暂停图并等待人类输入
            # 调用 interrupt 函数,将请求打包成列表传入。
            # 这是图执行暂停的地方
            # 当图通过 resume 命令恢复时,interrupt 函数会返回一个响应列表,我们取第一个响应。
            response = interrupt([request])[0]
​
            # 3.3 根据人类的响应采取行动
            # 如果人类审批认为是:批准 (accept)
            if response["type"] == "accept":
                # 直接使用原始的参数调用原始工具。
                tool_response = tool.invoke(tool_input, config)
            # 如果人类审批认为是:编辑 (edit)
            elif response["type"] == "edit":
                # 从响应中获取新的参数,更新 tool_input
                tool_input = response["args"]
                print("edit tool_input: " + tool_input)
                # 然后用新参数调用原始工具。
                tool_response = tool.invoke(tool_input, config)
            # 如果人类审批选择直接响应 (response),而不是执行工具
            elif response["type"] == "response":
                # 将其提供的反馈内容 (user_feedback) 直接作为工具的输出返回给下游节点。
                # 这可以用来给 LLM 提供指导,例如:“不要搜索这个,请先总结一下已有信息。”
                user_feedback = response["args"]
                tool_response = user_feedback
            else:
                # 处理未知的响应类型,增加代码的健壮性。
                raise ValueError(f"未知的中断响应类型: {response['type']}")
​
            # 返回最终结果。这个结果将作为工具的执行输出,返回给调用它的 LLM。
            return tool_response

我们详细说一下这个函数的封装:

add_human_in_the_loop() 做的事情是:

  1. 把任意工具(函数或 BaseTool)规范化成 LangChain 的 BaseTool

  2. 再创建一个“同名、同 schema 的新工具”:call_tool_with_interrupt

  3. 新工具在真正执行原工具前,会先调用 interrupt(...) 触发图暂停

  4. 图恢复时(resume),会拿到人工选择(accept/edit/response),再决定:

    1. accept:原参数调用原工具
    2. edit:替换参数后调用原工具
    3. response:不调用工具,直接把人类反馈当成工具输出返回给 LLM

所以:对 agent 来说,它依然在“调用工具”;但对图执行器来说,这个工具内部可能会 暂停执行、等待外部输入,不过这里我们没有对中断接收的数据类型进行具体的约束,不过对中断传递给外部(surface)的数据使用 HumanInterrut 类型进行约束了,这个接口的定义如下

class HumanInterrupt(TypedDict):
    action_request: ActionRequest  # 行为与参数
    config: HumanInterruptConfig  # 配置
    description: Optional[str]  # 描述class ActionRequest(TypedDict):
    action: str  # 行为
    args: dict  # 参数
​
​
# 它可以用于前端权限的一些渲染指令:例如 展示哪些按钮、是否允许编辑参数、是否允许直接回复
class HumanInterruptConfig(TypedDict):
    allow_ignore: bool
    allow_respond: bool
    allow_edit: bool
    allow_accept: bool

Other interrupt method

因为 interrupt 不是从中断的那一行开始处理,所以 LangGraph 还提供了 2 种中断的方式

通过在 graph.complime 的时候进行设置

在 LangGraph 中可以通过在 .compile() 编译的时候传递 interrupt_before(前置断点) 或者 interrupt_after(后置断点),这样在图结构程序执行到 特定的节点 时就会暂停执行,等待其他操作(例如人类提示,修改状态等)。

如果需要恢复图执行,只需要再次调用 invoke/stream 等,并传递 inputs=None,传递输入为 None 意味着像中断没有发生一样继续执行,基于这个思路就可以实现让人类干预图的执行,也支持我们修改一些数据,从而增强我们的回答的上下文的准确度,以及答案的准确性

关于 Human in the Loop 的一些详细设计,可以参考:mp.weixin.qq.com/s/m37YhDsKs…

Subgraph

在LangGraph中允许将一个完整的图作为另一个图的节点,适用于将复杂的任务拆解为多个专业智能体协同完成,每个子图都可以独立开发、测试并且可以复用。每个子图都可以拥有自己的私有数据,也可以与父图共享数据。

因为LangGraph 的节点可以是任意的 Python 函数或者是 Runnable可运行组件,并且 图程序 经过编译后就是一个 Runnable可运行组件,所以我们可以考虑将其中一个 图程序 作为另外一个 图程序 的节点,这样就变相在 LangGraph 中去实现子图,从而将一些功能相近的节点单独组装成图,单独进行状态的管理 图来源:https://mp.weixin.qq.com/s/XhFbLTLcSjDj0r3KGT9EOg

这里共享数据指的是如果父图状态与子图状态定义名一样,则状态是共享的 。

如果当父子图状态结构不同时,需要在父图中创建一个专门的节点函数,手动调用图并处理状态数据。

现在网上有一些很火的说通过Agent进行文案的生成,当然我个人认为这种内容一般是需要人工进行辅助的。但用于看到这个功能的使用是可以的。

例如我们实现一个 多个平台宣传文案的智能体,其功能为 根据用户传递的query生成多平台的直播文案,

对于这种需求,可以使用单图,也可以使用多图,这里我们使用多图,而每个不同的功能设计为子图,因为他们的功能是相互独立的

我们大致会构建一个这样的结构

待续...

参考

案例参考:mp.weixin.qq.com/s/m37YhDsKs…