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 的程序流程为:
-
分析我们的需求,提取出我们需要使用的数据模型
-
定义我们的 State
-
定义我们的节点
- 可能是模型的节点
- 工具节点,如果是工具节点,要定义具体的工具函数,然后使用一个函数封装我们的工具函数
- 可能会定义我们的 RAG 中的一些 retriever
-
创建图对象,以及定义具体的节点,注意节点名称要保持唯一,其次节点名称和 State 中的 key 的名称不要出现同名
-
构建节点与边的关系,完成一张图的构建
-
编译,通过 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_edge 和 add_condition_edges
还有的就是一些辅助的功能了
关于add_condition_edges可以参考:reference.langchain.com/python/lang…
- source:条件边的起始节点名称,该节点运行结束后会执行条件边。
- path:确定下一个节点是什么的可运行对象或者函数。如果返回的是
END表示 Graph 终止执行 - path_map:可选参数,类型为一个字典,用于表示 返回的path和 节点名称 的映射关系,如果不设置的话,path 的返回值应该是 节点名称。
我们完成如下的流程
from typing import Literal
import 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 Type
import 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/…
具体的使用流程为:
- 实例化一个检查点,例如 AsyncSqliteSaver 或者 MemorySaver(),亦或者自定义检查点。
- 在图编译的时候传递检查点,例如
compile(checkpointer=my_checkpointer) - 接下来在和图程序交互时传递 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 版本的
大致的执行流程为:
- 配置持久化层 (Checkpointer):中断的本质是状态的保存与恢复。因此,在编译 Graph 时,必须为其指定一个 checkpointer,用于在每一步执行后自动保存状态
- 在节点中调用 interrupt():在需要人工干预的节点函数中,调用 interrupt() 函数。此函数会立即暂停执行,并可以向用户传递一个 JSON 可序列化的对象,其中包含需要审查的数据,在 langgraph1.0 的版本中可以通过
__interrupt__获取中断返回的结果,而早期的版本中需要通过graph.get_state()获取返回的结果 - 运行并触发中断:中断需要指定当前graph调用的用户,也就是thread_id,当graph执行到第二步提供的interrupt时,会暂停,然后返回一个对象(1.0 版本),该对象中存在一个特殊的key:
__interrupt__, 该keyd对应的value中包含了中断的详细信息,如传递给用户的数据 - 使用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, Literal
import 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, Literal
import 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() 做的事情是:
-
把任意工具(函数或 BaseTool)规范化成 LangChain 的 BaseTool
-
再创建一个“同名、同 schema 的新工具”:call_tool_with_interrupt
-
新工具在真正执行原工具前,会先调用 interrupt(...) 触发图暂停
-
图恢复时(resume),会拿到人工选择(accept/edit/response),再决定:
- accept:原参数调用原工具
- edit:替换参数后调用原工具
- 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 中去实现子图,从而将一些功能相近的节点单独组装成图,单独进行状态的管理
这里共享数据指的是如果父图状态与子图状态定义名一样,则状态是共享的 。
如果当父子图状态结构不同时,需要在父图中创建一个专门的节点函数,手动调用图并处理状态数据。
现在网上有一些很火的说通过Agent进行文案的生成,当然我个人认为这种内容一般是需要人工进行辅助的。但用于看到这个功能的使用是可以的。
例如我们实现一个 多个平台宣传文案的智能体,其功能为 根据用户传递的query生成多平台的直播文案,
对于这种需求,可以使用单图,也可以使用多图,这里我们使用多图,而每个不同的功能设计为子图,因为他们的功能是相互独立的
我们大致会构建一个这样的结构
待续...