LangGraph-官方使用指南笔记-全-
LangGraph 官方使用指南笔记(全)
LangChain 使用指南:01:LangGraph 简介 🚀



在本节课中,我们将要学习 LangGraph 的基本概念,了解它是什么、为什么被创建,以及它能解决什么问题。LangGraph 是一个构建在 LangChain 之上的新库,旨在让创建智能体(Agent)和智能体运行时(Agent Runtime)变得更加简单和灵活。


什么是智能体与智能体运行时?

在 LangChain 中,我们将智能体定义为一个由语言模型驱动的系统,它负责决定采取什么行动。



智能体的核心逻辑可以表示为:
智能体 = 语言模型 + 决策逻辑


随后,智能体运行时负责在一个循环中运行这个智能体。其工作流程是:调用智能体,决定采取何种行动,执行该行动,记录观察结果,然后将结果反馈回去,并再次开始循环。这个过程会持续进行,直到智能体决定任务完成。
这个循环过程可以抽象为以下伪代码:
while not agent.is_finished():
action = agent.decide_next_action()
observation = execute_action(action)
agent.record_observation(observation)



为什么需要 LangGraph?


过去几个月,我们通过 LangChain 表达式语言让自定义智能体变得容易。然而,智能体运行时部分通常由固定的 AgentExecutor 类处理。它虽然有效,但只代表了一种特定的运行方式,例如以特定方式调用工具和处理错误。
我们希望通过 LangGraph 实现的目标是:让自定义智能体运行时变得更加容易、灵活和动态。


智能体运行时的关键特性是能够处理循环(Cycles)。因为智能体的本质就是在一个循环中运行这个由大语言模型驱动的决策系统。而像 LangChain 表达式语言这样的有向无环图(DAG)框架本身不支持循环结构。因此,我们引入了 LangGraph,专门用于创建这种支持循环的智能体运行时。




LangGraph 的核心功能
以下是 LangGraph 初始版本提供的两个主要智能体运行时:


- 智能体执行器(Agent Executor):这与 LangChain 中原有的
AgentExecutor非常相似,我们使用 LangGraph 重新构建了它。 - 聊天智能体执行器(Chat Agent Executor):这个执行器将对话历史作为消息列表输入,并将智能体的状态也表示为消息列表,最终同样返回一个消息列表。




我们专门为聊天模型创建独立的执行器,是因为许多新模型(如 GPT-4)本质上是基于聊天的。它们将函数调用内在地表示为消息的一部分参数,并将函数响应视为另一种独立类型的消息。因此,将智能体状态表示为消息列表,对于这类模型来说非常自然和高效。

后续内容预告



在本系列后续的视频中,我们将重点介绍如何修改基础的智能体执行器,以实现更高级的功能,例如:
- 添加人在回路(Human-in-the-loop) 的交互。
- 强制智能体优先调用某个特定工具。
- 以及其他令人兴奋的自定义功能。



本节课中,我们一起学习了 LangGraph 的定位和核心价值。它作为 LangChain 的扩展,专注于提供强大且灵活的方式来构建和自定义支持循环执行的智能体运行时,特别是为现代聊天模型提供了更自然的集成方式。
002:LangGraph Agent Executor构建指南 🚀
概述
在本节课中,我们将学习如何使用LangGraph从零开始构建一个与当前LangChain Agent Executor功能等效的智能体执行器。我们将看到这个过程是多么简单。
环境设置与安装 🔧
首先,我们需要安装必要的软件包。


以下是需要安装的包:
- langchain:我们将使用它来调用LangChain中现有的智能体类。在LangGraph中,我们仍然可以轻松地使用这些智能体类。
- langchain-openai:我们将使用它来接入OpenAI的包,并用它来驱动我们的智能体。
- tavily-python:这将为我们将要使用的搜索工具提供支持,该工具将作为智能体的工具之一。
安装完成后,我们需要设置API密钥。
- 设置OpenAI API密钥。
- 设置Tavily API密钥。
- 设置LangSmith API密钥。
LANGSMITH_TRACING_V2和LANGCHAIN_API_KEY这两个变量如果被设置,将会把运行日志记录到LangSmith(我们的可观测性平台)。你可以在这里查看说明。如果你还没有LangSmith的访问权限,它目前处于内测阶段,可以通过Twitter或LinkedIn联系我获取API密钥。
创建LangChain智能体 🤖
我们要做的第一件事是创建LangChain智能体。
这段代码与我们在LangChain中使用的代码完全相同。如果你需要更多详细信息,请查阅LangChain文档。简单来说,我们将:
- 创建一个工具,即Tavily搜索工具。
- 获取我们的提示词模板,这里我们从Hub拉取。
- 选择我们想要使用的大语言模型(LLM),这里使用的是OpenAI的LLM。
- 创建一个OpenAI函数智能体,这是一种特定类型的智能体。
定义图状态 📊
接下来,我们需要定义图状态。这是在图运行过程中将被追踪的状态。
定义状态之所以重要,是因为一旦我们建立了这个状态,每个节点都可以向该状态推送更新。这样,我们就不必在节点之间频繁地传递整个状态。相反,我们可以只传递对该状态的更新。
在定义状态时,我们需要指定我们向该状态推送的更新类型。默认情况下,更新会覆盖该状态的现有属性。这在某些情况下很有用,但在其他情况下,你可能希望实际添加到现有的状态中。我们在这里会看到一个例子。重申一下,默认行为是覆盖,但通常你希望添加到该状态的属性中。
让我们看看现有的智能体状态定义。前两个基本上是输入:对话的输入消息和聊天历史记录(如果有的话)。这些是我们稍后会传入的内容。
接下来的两个是图随着时间推移将添加的内容。agent_outcome将由一个特定的节点在智能体被调用后设置,它基本上代表智能体应该调用的工具或应该传递的最终结果。因此,我们使用AgentAction和AgentFinish来分别表示工具调用和最终结果。我们还定义了None,因为这是初始时的默认值。
最后,我们有一个列表,记录了智能体到目前为止所采取的步骤。这是我们不希望被覆盖的属性之一,相反,我们希望随着时间的推移不断追加并增长这个列表。因此,我们在这里用add操作符来注解它。这意味着任何时候一个节点写入这个属性,它都会添加到现有值中,而不是覆盖它。我们将其类型定义为List[Tuple[AgentAction, str]],这是当前LangChain智能体中表示中间步骤的方式。
定义节点与边 🔗
现在我们需要定义节点和边。
首先,我们实际上需要两个节点:
- 智能体节点:使用智能体来决定采取什么行动。
- 工具调用节点:接收智能体的决策,调用相应的工具,然后处理结果。
除了这些节点,我们还需要添加一些边。主要有两种类型的边:
- 条件边:一个节点会导致一个“岔路口”,根据前一个节点的结果,可能有两条、三条或更多不同的路径。我们将看到一个添加条件边的例子。我们将添加的条件边是基于
agent_outcome的:我们要么想调用一个工具,要么想返回结果给用户。这将是我们必须做出的分支决策。 - 普通边:总是发生的事件。例如,在调用工具之后,我们总是希望返回到智能体,让它决定下一步做什么。
让我们看看这里定义的节点。
run_agent节点:调用智能体,接收数据,执行agent_runnable.invoke,然后将结果赋值给agent_outcome。这将覆盖agent_outcome的现有值。execute_tools函数:接收数据,获取当前的agent_outcome,使用tool_executor(我们创建的一个辅助函数,用于方便地运行工具)执行它,然后返回中间步骤。记住,intermediate_steps是我们正在追加的属性。这里我们定义了一个包含AgentAction和输出(转换为字符串)的列表,这个列表将被追加到现有的列表中。should_continue函数:这基本上将用于创建条件边。我们查看agent_outcome,如果是AgentFinish,则返回"end",否则返回"continue"。我们稍后在构建图时会看到如何使用这些返回值。
构建与编译图 🏗️
现在我们来构建图。
首先,我们从langgraph导入StateGraph并创建一个新图,传入我们上面定义的AgentState。然后我们添加两个节点,通过指定节点名称(字符串)和函数(可以是函数或Runnable)来添加。这样我们就可以在下面引用这个节点。
我们将入口点设置为"agent"(使用我们在这里设置的相同字符串),这基本上是说当有输入时,这将是第一个被调用的节点。
然后我们添加一个条件边。我们定义起点,表示在agent节点运行完毕后,我们将调用should_continue函数。这个函数接收agent节点调用后的任何输出,然后查看数据并返回"end"或"continue"。我们传入这个函数的最后一个参数是一个字符串到字符串的映射。这个映射的键应该与should_continue的输出匹配。因此,如果should_continue返回"continue",那么我们调用上面定义的"action"节点;如果返回"end",那么我们调用这个特殊的"__end__"节点,这是一个内置节点,表示我们应该结束并返回给用户。
接着,我们添加一个从"action"回到"agent"的普通边。这是在工具被调用之后,我们返回到智能体。
最后,我们编译这个图。这基本上将这个图结构转换成一个LangChain Runnable,然后我们就可以使用它了:我们可以使用invoke、stream等方法。
运行与观察结果 👀

现在我们可以调用并运行它了。
记住,我们需要input键和chat_history作为两个输入。我们在此之后使用stream方法,这将打印出每个节点的结果。
我们可以看到,我们首先得到了agent_outcome(这是智能体节点的输出),返回的内容指示使用Tavily搜索工具并带有查询输入。然后我们得到intermediate_steps(中间步骤是工具调用及其结果的元组)。接着我们得到一个新的agent_outcome,这次是AgentFinish,表示智能体执行完毕,并返回最终结果,也就是整个状态,包括输入、聊天历史、当前的agent_outcome以及任何中间步骤。
我们可以在LangSmith中查看一个更好的示例。点击进入LangGraph追踪,我们可以看到它开始运行,然后在底层调用智能体(智能体调用OpenAI),我们可以看到输入的确切提示词。我们看到返回了一个函数调用,然后在action中看到这个函数调用及其返回结果。之后,它再次回到agent节点,再次调用OpenAI,现在提示词中包含了之前的结果,并得到了新的响应。

总结 🎯
本节课中,我们一起学习了如何使用LangGraph从零开始构建一个智能体执行器,其功能与现有的LangChain Agent Executor非常相似。

在未来的视频中,我们将深入探讨更多内容,例如:
- 深入探讨
StateGraph暴露的接口。 - 更详细地介绍
stream方法以及以不同方式流式返回结果的其他途径。
003:聊天代理执行器
在本节课中,我们将学习 LangGraph 中的聊天代理执行器。这是一个专门处理消息列表的代理执行器,它通过向列表中添加消息来跟踪代理随时间变化的状态。这对于基于聊天的模型尤其有用,因为这些模型通常将函数调用和函数响应表示为消息。
概述
我们将构建一个基于消息列表的聊天代理。与传统的 LangChain 代理相比,本教程将使用更少的 LangChain 抽象概念,更接近底层实现。我们将使用支持函数调用的 OpenAI 模型,并结合 LangChain 的工具,但不会使用 LangChain 的高级代理抽象。
环境设置
首先,我们需要安装必要的包并设置环境。
# 安装所需包
# pip install langchain langchain-openai tavily-python
import os
from langchain_openai import ChatOpenAI
from langchain_community.tools.tavily_search import TavilySearchResults
from langgraph.graph import StateGraph, END
from typing import TypedDict, Annotated, List
from langchain_core.messages import HumanMessage, AIMessage, FunctionMessage
import operator
接下来,设置 API 密钥和 LangSmith 追踪(可选,但有助于观察代理内部运行情况)。
# 设置 API 密钥
os.environ["OPENAI_API_KEY"] = "your-openai-api-key"
os.environ["TAVILY_API_KEY"] = "your-tavily-api-key"
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_API_KEY"] = "your-langsmith-api-key" # 可选
定义工具与模型
上一节我们设置了环境,本节中我们来看看如何定义代理将使用的工具和语言模型。
以下是需要定义的核心组件:
- 工具:代理可以调用的外部函数,例如网络搜索。
- 工具执行器:一个帮助调用这些工具的辅助方法。
- 模型:支持函数调用的语言模型。
# 1. 设置工具
tool = TavilySearchResults(max_results=2)
tools = [tool]
# 2. 设置工具执行器
from langchain.tools.render import format_tool_to_openai_function
from langchain.agents import ToolExecutor
tool_executor = ToolExecutor(tools)
# 3. 设置模型
model = ChatOpenAI(model="gpt-3.5-turbo", temperature=0, streaming=True)
# 将 LangChain 工具转换为 OpenAI 函数调用格式
functions = [format_tool_to_openai_function(t) for t in tools]
model = model.bind(functions=functions)
定义代理状态
代理状态是在图的所有节点之间传递并随时间更新的数据结构。对于聊天代理,状态的核心就是一个消息列表。
我们使用 TypedDict 和 Annotated 来定义状态。Annotated[..., operator.add] 表示对该字段的更新是追加(add)操作,而非覆盖。
# 定义代理状态
class AgentState(TypedDict):
messages: Annotated[List, operator.add] # 消息列表,更新操作为追加
构建图节点与边
现在,我们来定义构成代理工作流的节点和边。节点执行具体工作,边则定义节点间的流转逻辑。
我们需要三个主要部分:
- 代理节点:调用语言模型并获取响应。
- 动作节点:检查响应中是否需要调用工具,如果需要则调用工具并将结果追加到消息列表。
- 条件判断函数:决定在代理节点之后,是进入动作节点还是结束流程。
首先,定义条件判断函数 should_continue。
def should_continue(state: AgentState) -> str:
"""根据最后一条消息判断是否继续调用工具。"""
messages = state['messages']
last_message = messages[-1]
# 如果最后一条消息包含函数调用,则继续执行动作节点
if hasattr(last_message, 'tool_calls') and last_message.tool_calls:
return "continue"
# 否则,结束流程
return "end"
接着,定义调用模型的 call_model 函数。
def call_model(state: AgentState):
"""调用语言模型并返回其响应消息。"""
messages = state['messages']
response = model.invoke(messages)
# 将响应作为列表返回,以便追加到状态中的消息列表
return {"messages": [response]}
然后,定义执行工具的 call_tool 函数。
def call_tool(state: AgentState):
"""执行模型请求的工具调用,并将结果作为消息返回。"""
messages = state['messages']
last_message = messages[-1] # 获取最新的 AI 消息,其中包含工具调用请求
# 根据 OpenAI 函数调用格式构建工具调用请求
tool_call = last_message.tool_calls[0]
tool_name = tool_call['name']
tool_input = tool_call['args']
# 执行工具
output = tool_executor.invoke({tool_name: tool_input})
# 将工具执行结果封装为 FunctionMessage
function_message = FunctionMessage(
content=str(output),
name=tool_name,
tool_call_id=tool_call['id']
)
# 返回结果消息,以便追加到状态
return {"messages": [function_message]}
组装与编译图
定义了所有组件后,现在可以将它们组装成一个可执行的工作流图。
# 创建图,并指定状态模式
workflow = StateGraph(AgentState)
# 添加节点
workflow.add_node("agent", call_model)
workflow.add_node("action", call_tool)
# 设置入口点:从 agent 节点开始
workflow.set_entry_point("agent")
# 添加条件边:在 agent 节点后,根据 should_continue 决定下一步
workflow.add_conditional_edges(
"agent",
should_continue,
{
"continue": "action", # 继续则跳转到 action 节点
"end": END # 结束则到达终点
}
)
# 添加固定边:在 action 节点后,总是返回 agent 节点进行下一轮思考
workflow.add_edge("action", "agent")
# 编译图,生成一个可运行的对象
app = workflow.compile()
运行代理
图编译完成后,我们就可以通过输入消息列表来运行代理了。
# 准备输入:一个包含消息列表的字典
inputs = {
"messages": [
HumanMessage(content="今天旧金山的天气怎么样?")
]
}
# 运行代理
for output in app.stream(inputs):
for node_name, node_output in output.items():
print(f"--- 节点 '{node_name}' 的输出 ---")
print(node_output)
print("\n")
运行后,state['messages'] 将包含完整的对话历史:你输入的人类消息、模型首次响应的 AI 消息(可能包含函数调用)、工具返回的 FunctionMessage 以及模型最终回答的 AI 消息。
使用 LangSmith 进行追踪
如果你想深入了解代理的每一步执行过程,可以使用 LangSmith。运行上述代码后,你可以在 LangSmith 控制台中看到类似下图的追踪信息:


追踪图会显示详细的调用链:
- 首次调用 OpenAI 模型,输入是人类消息,返回一个函数调用请求。
- 进入
action节点,执行 Tavily 搜索工具。 - 再次调用
agent节点,此时输入包含了历史消息和工具结果,模型生成最终回答。
总结
本节课中我们一起学习了如何使用 LangGraph 构建一个聊天代理执行器。我们完成了以下步骤:
- 设置环境与工具:引入了必要的库,并配置了搜索工具和 OpenAI 模型。
- 定义状态:使用
TypedDict创建了以消息列表为核心的可追加状态。 - 构建工作流节点:实现了负责调用模型的
agent节点和执行工具的action节点。 - 设计控制流:通过条件边和固定边,将节点连接成
模型 -> 判断 -> (工具 -> 模型...) -> 结束的循环工作流。 - 运行与追踪:输入消息来运行代理,并利用 LangSmith 观察其内部执行步骤。

这个代理的核心优势在于它直接操作消息列表,与基于聊天的模型原生兼容,并且通过 LangGraph 获得了清晰、可调试的工作流定义。在下一节课中,我们将探索 LangGraph 的流式输出能力。
004:LangGraph中实现人机交互循环
在本节课中,我们将学习如何修改聊天智能体执行器,为其添加一个“人在回路”组件。该组件的作用是,在智能体执行任何工具操作之前,会先请求用户确认。我们将基于基础教程进行构建,因此如果你尚未学习之前的课程,强烈建议你先完成,因为本节视频将主要聚焦于对原有代码的修改部分。
概述与准备工作


我们将按照之前的步骤进行设置,无需安装额外的包。首先,我们需要创建工具、工具执行器,然后设置模型并将工具绑定到该模型。
接下来,我们将定义智能体状态。到目前为止,所有步骤都与之前相同。

定义节点:关键修改点

第一个不同之处出现在我们开始定义节点时。其中,判断是否继续的逻辑(should_continue)和调用模型的逻辑(call_model)保持不变。
真正需要修改的是 call_tool 函数。


修改 call_tool 函数
以下是修改的核心逻辑。我们在此处添加了代码,用于在交互式IDE中提示用户,询问是否继续执行该操作。如果用户的回答是“否”,我们将抛出一个错误并终止流程。

# 伪代码示例:在调用工具前添加确认步骤
def call_tool(state):
# ... 获取工具调用信息 ...
user_input = input(f"是否要执行操作 '{tool_name}'? (yes/no): ")
if user_input.lower() != 'yes':
raise ValueError("用户取消了操作。")
# 如果用户同意,则继续执行工具调用
result = tool_executor.invoke(tool_call)
return {"messages": [ToolMessage(content=str(result), tool_call_id=tool_call['id'])]}
这是我们对基础教程所做的唯一修改。图的定义将完全保持不变,之后我们就可以使用它了。


运行与测试

当我们调用这个修改后的图时,可以看到在调用工具之前,系统会给出提示。如果我们回答“是”,流程将继续,正常调用工具并返回响应。


如果我们回答“否”,则会引发一个 ValueError 错误,流程将停止。
总结与扩展
这是一个非常简单的示例。在实际应用中,你可能希望进行比抛出 ValueError 更复杂的错误处理,并且很可能不希望这个交互发生在Jupyter Notebook中,而是集成到其他用户界面里。但本示例清晰地展示了如何在LangGraph智能体中添加一个简单而有效的“人在回路”组件,让你能够控制关键操作的执行。

本节课中,我们一起学习了如何通过修改 call_tool 节点,在LangGraph工作流中引入人工确认步骤,从而实现对智能体行为的实时监督和控制。
005:动态直接返回工具输出
在本节课中,我们将学习如何修改基础的聊天代理执行器,使其能够动态决定是否将工具调用的结果直接返回给用户,而无需经过语言模型的进一步处理或总结。这对于那些有时能直接提供有效答案的工具非常有用。
概述
上一节我们介绍了基础的聊天代理执行器循环。本节中,我们来看看如何对其进行修改,赋予代理动态决定是否将工具输出直接返回给用户的能力。核心在于为工具模式添加一个 return_direct 参数,并调整图的工作流程来处理这个新逻辑。
修改工具模式
首先,我们需要修改工具的定义。我们将为工具的模式添加一个名为 return_direct 的布尔型参数,默认值为 False。这个参数本身不会被工具使用,而是告知代理它可以选择将此工具的结果直接返回。
以下是定义工具模式的代码示例:
# 假设使用 Pydantic 定义工具参数模式
from pydantic import BaseModel, Field
class ToolArguments(BaseModel):
query: str = Field(..., description="查询内容")
return_direct: bool = Field(False, description="是否直接返回结果")
构建图节点与边
接下来,我们需要调整代理图的结构。关键的修改在于 should_continue 函数和工具调用节点。
1. 修改 should_continue 逻辑
之前的逻辑是:如果没有函数调用,则结束。现在,我们增加一个判断:如果函数调用参数中设置了 return_direct=True,则转向一个最终节点;否则,继续循环。
2. 处理工具调用
在调用工具时,我们需要从参数中剔除 return_direct 字段,因为它仅用于控制流程,并非工具本身的输入参数。
3. 创建两个工具调用节点
为了实现不同的流程走向,我们创建两个节点:
action节点:处理return_direct=False的情况,调用工具后返回结果给代理(语言模型)进行下一步处理。final节点:处理return_direct=True的情况,调用工具后直接将结果返回给用户,结束流程。
以下是定义节点和边的核心逻辑:
# 伪代码示意
def should_continue(state):
if no function call:
return “end”
elif function call has “return_direct” == True:
return “final_node” # 转向最终节点
else:
return “continue”
def call_tool(state):
tool_name = state[‘tool_to_call’]
tool_args = state[‘tool_args’]
# 如果参数中包含 return_direct,则删除它
if ‘return_direct’ in tool_args:
del tool_args[‘return_direct’]
result = tool_executor.invoke({‘name’: tool_name, ‘args’: tool_args})
return {‘result’: result}
# 定义图
graph = StateGraph(AgentState)
graph.add_node(“agent”, call_model) # 调用模型的节点
graph.add_node(“action”, call_tool) # 普通工具调用节点
graph.add_node(“final”, call_tool) # 直接返回的工具调用节点
# 设置边
graph.add_conditional_edges(“agent”, should_continue) # 代理节点根据条件决定下一步
graph.add_edge(“action”, “agent”) # 普通工具调用后返回代理
graph.add_edge(“final”, END) # 最终节点调用后直接结束
graph.set_entry_point(“agent”)
运行与演示


完成图构建后,我们可以运行代理。
- 默认情况:当代理决定不直接返回时(
return_direct=False或未指定),流程为:语言模型 -> 工具调用 -> 语言模型 -> 结束。 - 直接返回情况:当代理决定直接返回时(通过提示工程或手动指定
return_direct=True),流程简化为:语言模型 -> 工具调用 -> 结束。结果直接从工具返回给用户。
通过 LangSmith 等追踪工具,可以清晰地看到两种情况下不同的执行路径和节点调用顺序。

总结

本节课中我们一起学习了如何利用 LangGraph 创建能够动态决定是否将工具输出直接返回给用户的智能代理。我们通过修改工具模式、调整条件逻辑以及创建分支节点来实现这一功能。记住,如果某个工具需要始终直接返回结果,更简单的方法是在工具定义时直接设置 return_direct=True 属性。而本教程的方法适用于需要由代理动态决策的场景。
006:让智能体以特定格式响应
在本节课中,我们将学习如何让一个聊天智能体执行器以特定的结构化格式进行响应。这在您希望智能体的输出遵循特定格式,并希望通过函数调用来强制执行时非常有用。本教程基于基础的聊天智能体执行器示例,因此建议您先熟悉那个示例。
概述
我们将创建一个智能体,它不仅能调用工具完成任务,还能确保其最终响应符合我们预定义的结构。核心在于,除了绑定工具函数外,我们还将一个“响应模式”函数绑定到模型上,引导智能体在需要输出最终答案时调用这个特定函数。
创建工具与模型
首先,我们像往常一样创建工具和工具执行器。这部分与基础示例相同。
接下来是创建模型,但这里有一个关键修改。之前,我们只将工具作为可调用函数绑定到模型。现在,我们还需要绑定一个额外的函数定义,即我们希望智能体遵循的响应模式。
例如,我们定义一个响应模式,要求输出包含 temperature(温度)和 other_notes(其他备注)两个字段。我们将这个模式转换为 OpenAI 函数调用的格式。
以下是核心代码修改:
# 将工具转换为 OpenAI 函数格式
tool_functions = [convert_to_openai_function(t) for t in tools]
# 定义并转换响应模式函数
response_schema = convert_to_openai_function(response_format_function)
# 将工具函数和响应函数一同绑定到模型
model_with_functions = model.bind(functions=tool_functions + [response_schema])
通过这一步,模型在生成回复时,不仅知道可以调用哪些工具,还知道有一个名为 response 的特定函数用于格式化最终答案。
定义智能体状态与节点
智能体状态的定义与之前保持一致。
在定义节点时,should_continue 判断逻辑需要调整,以适应新的响应函数。以下是判断逻辑:
- 如果上一条消息中没有函数调用,则流程结束。
- 如果上一条消息中有函数调用,且调用的函数名是
response,则流程也结束。 - 如果上一条消息中有函数调用,且调用的函数名不是
response,则流程继续。
其他两个节点(call_model 和 call_tool)的定义与基础示例相同。
构建与运行图
定义好节点后,我们像之前一样构建图并运行它。
当我们向智能体提问时,例如“What‘s the weather in SF?”,流程如下:
- 模型返回一条 AI 消息,调用天气搜索工具。
- 工具执行器返回包含结果的函数消息。
- 模型再次返回一条 AI 消息,但这次它调用的是
response函数,并按照我们定义的结构(包含temperature和other_notes)来组织答案。 - 由于函数名是
response,根据should_continue的逻辑,流程结束。
与之前智能体将答案直接放在消息的 content 字段中不同,现在我们获得了一个结构化的响应对象。
底层机制
如果我们查看 LangSmith 的追踪记录,可以看到模型与 OpenAI 的交互详情。在调用记录中,tools 列表里现在包含两个函数:一个是工具函数(如 tavily_search_results_json),另一个就是我们定义的 response 函数。智能体在最后一步正是通过调用这个 response 函数来生成格式化的最终答案。
总结

本节课我们一起学习了如何利用 LangGraph 让智能体以特定格式进行响应。关键步骤是:
- 定义一个期望的响应模式(如包含特定字段的 JSON)。
- 将该模式转换为一个函数,并与工具函数一同绑定到语言模型。
- 在智能体的状态判断逻辑中,将调用此特定响应函数作为流程结束的条件之一。

这种方法使得智能体的输出不再是自由文本,而是可控、结构化的数据,便于后续的系统处理或展示。
007:管理智能体步骤 🛠️
在本节课中,我们将学习如何利用 LangGraph 来管理和修改智能体在执行过程中的内部状态与步骤。与之前封闭的 LangChainAgentExecutor 类不同,LangGraph 将所有流程暴露出来,使得我们可以轻松地定制智能体的行为。
概述
我们经常遇到一个问题:如何修改现有智能体执行器,以对其内部状态进行不同的处理。过去这并不容易实现,因为逻辑被封装在 LangChainAgentExecutor 类中。但借助 LangGraph,所有流程都是透明的,你可以轻松修改智能体在执行过程中的任何步骤。
本教程基于基础的聊天智能体执行器笔记本构建。如果你尚未完成该笔记本,请先完成它。这里我们只介绍需要进行的修改,这些修改非常小。
设置环境与工具

首先,我们进行与之前相同的设置:配置工具和模型。这部分代码与基础教程完全一致。
# 设置工具(示例)
tools = [tool1, tool2, ...]
# 设置模型(示例)
llm = ChatOpenAI(temperature=0)
定义智能体状态
接下来,我们定义智能体的状态。这部分也与之前相同,用于跟踪对话和中间步骤。
from typing import TypedDict, List, Annotated
import operator



class AgentState(TypedDict):
messages: Annotated[List[BaseMessage], operator.add]
intermediate_steps: Annotated[List[tuple[AgentAction, str]], operator.add]
修改节点逻辑:过滤消息
现在进入核心修改部分。我们将定义节点和边逻辑,这与之前类似。但关键的修改在于,我们在其中添加了一些逻辑,用于过滤最终传递给模型的消息。
例如,如果我们只想传递最近的五条消息,可以将逻辑放在这里。如果我们想采用不同的逻辑,比如取系统消息加上最近五条消息,也可以在这里实现。如果我们想对超过五条的历史消息进行总结,同样可以在这里添加逻辑。
这里是你可以插入自定义逻辑的地方,用于处理智能体的中间步骤。我们将在此处实现它,而其他所有部分保持不变。这是一个非常微小的修改,但功能强大。
以下是修改后的节点函数示例:
def agent_node(state: AgentState):
# 从状态中提取消息
messages = state['messages']
# 自定义逻辑:例如,只取最近5条消息
filtered_messages = messages[-5:] if len(messages) > 5 else messages
# 将过滤后的消息传递给模型进行处理
response = llm.invoke(filtered_messages)
# 返回更新后的状态
return {"messages": [response]}
由于我目前只传入了一条消息,且中间步骤长度最多为2,所以这里看不出明显差异。但重要的是,任何我们想要修改智能体步骤表示方式的逻辑,都可以放在这里。

应用于其他执行器



本节介绍的方法虽然以修改聊天智能体执行器为例,但完全相同的思路也适用于普通的智能体执行器。你可以根据需要在相应的节点函数中插入自定义的状态处理逻辑。
总结

本节课我们一起学习了如何使用 LangGraph 来管理智能体的步骤。关键点在于,通过暴露的执行流程,我们可以在智能体的节点函数中轻松插入自定义逻辑,例如过滤消息、总结历史或改变状态表示方式。这为定制智能体行为提供了极大的灵活性。记住,无论处理聊天智能体还是普通智能体,此方法都同样适用。
008:强制调用工具 ⚙️
在本节课中,我们将学习如何对聊天代理执行器进行一个简单的修改,以实现强制首先调用一个特定工具的功能。如果你还没有观看关于聊天代理执行器的视频,建议你先观看以获取完整的背景知识。我们将基于那个视频中的笔记本来进行修改,并且本节课只专注于讲解这些修改部分。
概述
我们将创建一个名为 first_agent 的新节点,它会强制系统在流程开始时,不经过语言模型推理,直接调用一个预设的工具。这可以用于确保某些操作(如数据检索)总是优先执行。
工具与模型设置
大部分基础设置与之前的聊天代理执行器相同。以下是关键步骤:
- 创建工具:我们定义一个工具,例如一个搜索工具。
- 创建工具执行器:用于实际调用这些工具。
- 创建语言模型:例如使用
ChatOpenAI。 - 绑定工具到模型:使用
.bind_tools()方法将工具列表绑定到模型。 - 定义代理状态:使用
TypedDict定义一个状态结构,通常包含messages字段。
这些步骤的代码与基础版本完全一致。
定义“首个代理”节点
核心修改在于定义一个额外的节点函数。这个函数将模拟语言模型的输出,强制返回一个要求调用特定工具的消息。
以下是该节点的定义:
def first_agent(state):
# 这是一个硬编码的函数,模拟AI返回一个工具调用请求
from langchain_core.messages import AIMessage
tool_name = “tavily_search_results_json” # 要强制调用的工具名称
query = state[“messages”][-1].content # 获取用户最新消息的内容作为查询参数
# 构造一个AIMessage,其内容是一个工具调用请求
message = AIMessage(
content=“”,
tool_calls=[{
“name”: tool_name,
“args”: {“query”: query},
“id”: “forced_tool_call_id”
}]
)
return {“messages”: [message]}
代码解释:
tool_name必须与你定义的工具名称完全一致。- 我们直接从状态中获取最新的用户消息内容作为工具调用的参数。
- 函数返回一个包含
AIMessage的字典,这个消息对象内嵌了一个工具调用请求,从而“欺骗”系统认为这是语言模型决定要调用工具。
修改图结构
接下来,我们需要修改图(Graph)的结构,以纳入这个新的节点并调整执行流程。
以下是修改后的图定义逻辑:
from langgraph.graph import StateGraph, END
# 初始化图
workflow = StateGraph(AgentState)
# 添加节点
workflow.add_node(“first_agent”, first_agent) # 新增的强制调用节点
workflow.add_node(“agent”, run_agent) # 原有的代理节点(调用语言模型)
workflow.add_node(“action”, execute_tools) # 原有的执行工具节点
# 设置入口点:现在从 first_agent 开始
workflow.set_entry_point(“first_agent”)
# 定义边(连接)
# 1. 从 first_agent 出来后,强制进入 action 节点执行工具
workflow.add_edge(“first_agent”, “action”)
# 2. 从 action 执行完工具后,进入 agent 节点让语言模型处理结果
workflow.add_edge(“action”, “agent”)
# 3. 从 agent 节点出来后,根据其输出决定下一步
# 如果返回了工具调用,则继续到 action 节点
# 如果没有工具调用,则结束流程
workflow.add_conditional_edges(
“agent”,
should_continue,
{
“continue”: “action”,
“end”: END
}
)
# 编译图
app = workflow.compile()
图结构解析:
- 入口变化:图的入口点从原来的
agent改为了first_agent。 - 强制路径:我们添加了一条从
first_agent到action的直连边。这意味着流程启动后,会立即执行first_agent节点,然后无条件地跳转到action节点去执行工具。 - 后续流程:执行完工具后,流程进入
agent节点(真正的语言模型),由它来决定后续是继续调用工具还是结束。这之后的逻辑与基础版本相同。
运行与验证
当我们运行这个修改后的图时,可以观察到以下现象:
- 首次响应极快:对用户第一个问题的响应速度会非常快,因为它跳过了初始的语言模型调用,直接执行了工具(例如搜索)。
- 查看内部流程:通过 LangSmith 等追踪工具,可以清晰地看到:第一个
first_agent节点并没有产生真正的语言模型调用记录,而是直接生成了一个工具调用消息。随后,系统首先调用了我们指定的工具,之后才在必要时调用了语言模型。
这种模式适用于需要确保某些前置操作(如检索、验证)必须优先执行的场景。
总结
本节课我们一起学习了如何通过修改 LangGraph 的图结构来强制代理首先调用一个特定工具。关键步骤包括:
- 定义一个硬编码的
first_agent节点函数,用于生成工具调用请求。 - 修改图结构,将
first_agent设置为新的入口点,并创建从它到工具执行节点的直连边。 - 这样,系统流程将绕过初始的语言模型决策,直接执行我们指定的操作,然后再进入正常的“思考-行动”循环。


这种方法增强了我们对代理行为流程的控制力,可以满足特定的业务逻辑需求。
009:LangGraph 多智能体工作流 🚀
在本节课中,我们将学习如何使用 LangGraph 库来构建三种不同类型的多智能体工作流。我们将从基础概念开始,逐步深入到复杂的协作模式,并通过实际代码示例展示如何实现它们。
概述
LangGraph 是一个用于构建有状态、多步骤应用程序的库,特别适合创建涉及循环和条件逻辑的智能体工作流。我们可以将其视为一个有向图,其中节点代表处理步骤(如智能体),边代表步骤之间的转换逻辑。本节课将重点介绍三种多智能体模式:多智能体协作、智能体监督者和分层智能体团队。
多智能体协作 🤝
上一节我们介绍了 LangGraph 的基本概念。本节中,我们来看看第一种模式:多智能体协作。在这种模式下,多个智能体共享同一个全局状态(例如消息历史),并基于此状态进行协作。
核心概念与设置
首先,我们需要定义智能体。一个智能体通常由一个提示词(Prompt)、一个大语言模型(LLM) 和一组工具(Tools) 组成。
以下是创建一个智能体的辅助函数:
def create_agent(llm, tools, system_message):
prompt = ChatPromptTemplate.from_messages([
("system", system_message),
MessagesPlaceholder(variable_name="messages"),
MessagesPlaceholder(variable_name="agent_scratchpad"),
])
agent = create_openai_tools_agent(llm, tools, prompt)
executor = AgentExecutor(agent=agent, tools=tools)
return executor
接下来,我们定义智能体可以使用的工具,例如一个搜索工具和一个 Python 代码执行工具。
search_tool = TavilySearchResults(max_results=2)
python_tool = PythonREPLTool()
构建协作图
协作的核心是定义一个共享的状态(State),所有智能体都读取和写入这个状态。
我们定义一个状态类来追踪消息和最近的消息发送者:
from typing import Annotated
import operator
from typing_extensions import TypedDict
class AgentState(TypedDict):
messages: Annotated[list, operator.add] # 消息列表
sender: str # 最近的消息发送者
然后,我们创建代表不同智能体的节点。每个节点函数接收当前状态,调用相应的智能体,并将结果以特定格式添加回状态。
def agent_node(state, agent, name):
result = agent.invoke(state)
# 将AI消息转换为带名称的人类消息,以便其他智能体识别
if isinstance(result, AIMessage) and not result.tool_calls:
result = HumanMessage(content=result.content, name=name)
return {"messages": [result], "sender": name}
我们创建两个智能体节点:研究员(Researcher)和图表生成器(Chart Generator),以及一个专门执行工具调用的节点。
定义路由逻辑是关键,它决定了在智能体执行后下一步该去哪里:

def router(state):
messages = state[‘messages’]
last_message = messages[-1]
if last_message.tool_calls:
return "call_tool"
if "FINAL ANSWER" in last_message.content:
return "end"
return "continue"
以下是构建图的步骤:
- 创建图并添加状态。
- 添加研究员、图表生成器和工具节点。
- 添加条件边:根据路由器的返回值,决定从研究员节点是前往图表生成器、调用工具还是结束。
- 为图表生成器节点添加类似的条件边。
- 为工具节点添加边:工具执行完成后,根据
sender状态返回到对应的智能体。 - 设置图的入口点为研究员节点。

运行与调试
现在我们可以调用这个图来处理用户请求,例如:“获取英国过去五年的GDP数据,然后绘制折线图。”
使用 stream 方法可以实时观察执行过程。为了更清晰地调试,我们使用 LangSmith 来记录和可视化整个调用链。在 LangSmith 中,你可以看到每个智能体的调用、它们使用的提示词、生成的响应以及工具调用的输入和输出,这对于理解复杂的多智能体交互至关重要。
智能体监督者 👨💼
在协作模式中,智能体共享所有中间状态。而智能体监督者模式则不同,监督者智能体将任务分配给独立的子智能体,每个子智能体在完成自己的任务后,只将最终结果返回给监督者。
工作流程
在这种模式下,监督者智能体拥有多个“工具”,而这些工具本身就是封装好的 LaneChain 智能体(包含 AgentExecutor)。监督者根据当前对话状态,决定调用哪个子智能体。子智能体内部可以运行多次 LLM 调用和工具调用,但对外只暴露一个最终结果。
构建监督者图
首先,我们创建子智能体(如研究员、程序员),它们是完全独立的 LaneChain 智能体。
def create_agent(llm, tools, system_msg):
prompt = ChatPromptTemplate.from_messages([
("system", system_msg),
MessagesPlaceholder(variable_name="messages"),
MessagesPlaceholder(variable_name="agent_scratchpad"),
])
agent = create_openai_tools_agent(llm, tools, prompt)
executor = AgentExecutor(agent=agent, tools=tools, verbose=True)
return executor
然后,我们创建监督者智能体。它的核心是一个特殊的提示词,要求它根据对话内容选择下一个要调用的角色(子智能体)或结束对话。
members = ["Researcher", "Coder"]
system_prompt = (
"As a supervisor, you manage conversation between these workers: {members}. "
"Based on the conversation, select the next role to respond or ‘FINISH‘."
)

监督者智能体被构造成一个可以调用特定路由函数的链。这个路由函数返回下一个节点的名称。

图的构建逻辑如下:
- 定义状态(包含消息和
next字段)。 - 添加研究员、程序员和监督者节点。
- 为研究员和程序员节点添加边:执行完成后,总是返回监督者节点。
- 为监督者节点添加条件边:根据其输出的
next字段值,决定是前往研究员、程序员节点还是结束。

当运行此图时,监督者首先被调用,它决定让“程序员”处理任务。程序员智能体内部进行一系列操作(如调用 Python 工具),完成后将最终结果(如“代码已执行”)返回。监督者看到这个结果后,可能决定任务已完成并结束流程。在 LangSmith 中,你只能看到监督者与子智能体之间的高层交互,而子智能体内部的具体步骤被封装了起来。

分层智能体团队 🏢
分层团队模式是监督者模式的延伸,它将复杂性提升了一个层级。在这种模式下,每个子节点本身不是一个简单的智能体,而是另一个由监督者和多个工作者智能体组成的 子图(Subgraph)。
构建分层结构
例如,我们可以构建一个“研究团队”子图和一个“文档撰写团队”子图,然后再用一个顶层的“团队监督者”来协调这两个子团队。

- 构建研究团队子图:这个子图内部包含一个研究监督者、一个搜索智能体和一个爬虫智能体。它接收任务,协调内部智能体完成研究,并返回摘要。
- 构建文档撰写团队子图:这个子图内部包含一个写作监督者、以及负责创建大纲、撰写、编辑等工作的智能体。
- 构建顶层图:顶层状态只包含消息列表。我们将“研究团队”和“文档撰写团队”作为两个独立的节点加入图中。顶层监督者根据任务(如“撰写一份关于北美鲟鱼的研究报告,并包含图表”)决定调用哪个子团队。
图的嵌套与执行

在 LangGraph 中,我们可以将一个编译好的图(CompiledGraph)作为一个节点添加到另一个图中,实现图的嵌套。

# 假设 research_team 和 writing_team 是已编译好的子图
workflow = StateGraph(TeamState)
workflow.add_node("research_team", research_team)
workflow.add_node("writing_team", writing_team)
workflow.add_node("supervisor", supervisor_chain)
# ... 添加边和条件逻辑

当执行这个分层图时,顶层监督者可能先调用“研究团队”。“研究团队”子图内部开始运行,其监督者协调搜索和爬虫工作,最终将研究结果返回给顶层。接着,顶层监督者可能调用“文档撰写团队”,该团队基于研究结果生成报告。在 LangSmith 的追踪视图中,你可以层层下钻,查看顶层调用、子团队内部的调用以及子智能体内部的详细步骤,这为调试极其复杂的工作流提供了强大的可视化支持。

总结

本节课我们一起学习了使用 LangGraph 构建多智能体工作流的三种主要模式:
- 多智能体协作:智能体共享全局状态,高度协同,适合需要紧密配合的任务。
- 智能体监督者:监督者分配任务给独立的子智能体,子智能体的内部过程被封装,提供了清晰的职责分离。
- 分层智能体团队:将复杂的系统分解为多层级的团队,每层都有自己的监督逻辑,适合构建大规模、模块化的智能体应用。

这些模式的核心优势在于它们提供了结构化的心智模型,帮助我们将复杂任务分解为专注特定功能的模块。通过 LangGraph 的图抽象和 LangSmith 的调试工具,我们可以有效地设计、实现和监控这些复杂的多智能体系统。
010:为 LangGraph 智能体添加持久化状态 🧠
在本节课中,我们将学习如何为 LangGraph 智能体添加持久化功能。持久化允许智能体在多次交互中记住状态,例如对话历史,从而实现连续对话和记忆功能。
概述
LangGraph 提供了一个检查点机制,可以在每次执行时保存图的状态。这使得智能体能够从之前中断的地方恢复,并记住所有先前的交互。一个典型的应用场景是为智能体赋予“记忆”能力。

创建基础智能体
首先,我们需要创建一个基础的智能体。我们将使用 OpenAI 模型和一个 Tavily 搜索工具来构建一个简单的消息图智能体。
以下是设置环境和创建智能体的核心代码:
# 设置 API 密钥
import os
os.environ["OPENAI_API_KEY"] = "your-openai-key"
os.environ["TAVILY_API_KEY"] = "your-tavily-key"



# 创建 Tavily 搜索工具
from langchain_community.tools.tavily_search import TavilySearchResults
search_tool = TavilySearchResults(max_results=2)
# 创建工具执行器
from langgraph.prebuilt import ToolExecutor
tool_executor = ToolExecutor([search_tool])
# 创建模型并绑定工具
from langchain_openai import ChatOpenAI
model = ChatOpenAI(model="gpt-4", temperature=0)
model_with_tools = model.bind_tools([search_tool])
构建消息图
接下来,我们定义智能体节点和工具调用节点,并将它们连接成一个图。
from langgraph.graph import MessageGraph, END
# 定义智能体节点
def agent_node(state):
messages = state["messages"]
response = model_with_tools.invoke(messages)
return {"messages": [response]}


# 定义工具调用节点
def tool_node(state):
messages = state["messages"]
last_message = messages[-1]
tool_calls = last_message.tool_calls
results = tool_executor.batch(tool_calls)
response_messages = []
for result in results:
response_messages.append(ToolMessage(content=str(result), tool_call_id=tool_calls[0]['id']))
return {"messages": response_messages}



# 定义路由逻辑
def should_continue(state):
messages = state["messages"]
last_message = messages[-1]
if last_message.tool_calls:
return "tools"
return END



# 构建图
graph = MessageGraph()
graph.add_node("agent", agent_node)
graph.add_node("tools", tool_node)
graph.set_entry_point("agent")
graph.add_conditional_edges("agent", should_continue)
graph.add_edge("tools", "agent")

添加持久化检查点
这是本节课的核心。我们将通过添加一个 SQLite 检查点器来实现持久化。
from langgraph.checkpoint.sqlite import SqliteSaver

# 创建内存中的 SQLite 检查点器
memory = SqliteSaver.from_conn_string(":memory:")
# 编译图并传入检查点器
app = graph.compile(checkpointer=memory)
现在,我们的智能体图具备了状态保存能力。
使用持久化智能体

当我们调用智能体时,需要指定一个 thread_id。这个 ID 用于标识和检索特定的会话状态。
以下是调用智能体的示例:
# 第一次交互,线程 ID 为 2
config = {"configurable": {"thread_id": "2"}}
inputs = {"messages": [("human", "Hi, I'm Bob.")]}
result = app.invoke(inputs, config=config)
print(result["messages"][-1].content) # 输出: Hello Bob. How may I assist you?


# 第二次交互,使用相同的线程 ID,智能体记得名字
inputs = {"messages": [("human", "What is my name?")]}
result = app.invoke(inputs, config=config)
print(result["messages"][-1].content) # 输出: Your name is Bob.
# 使用新的线程 ID (3),智能体没有之前的记忆
new_config = {"configurable": {"thread_id": "3"}}
result = app.invoke(inputs, config=new_config)
print(result["messages"][-1].content) # 输出: I don't have that information.

通过使用不同的 thread_id,我们可以管理多个独立的会话流。相同的 thread_id 可以恢复会话,而新的 thread_id 则会开始一个全新的、没有记忆的会话。

总结
本节课中,我们一起学习了如何为 LangGraph 智能体添加持久化状态。
- 核心概念:通过
SqliteSaver检查点器,在每次图执行时自动保存状态。 - 关键配置:使用
thread_id来唯一标识和检索不同的会话流。 - 实现效果:智能体可以记住同一会话(相同
thread_id)内的历史信息,实现了基础的记忆功能。

这种方法不仅适用于保存消息列表,也通用适用于 StateGraph,可以保存智能体状态的任何其他方面。通过配置 thread_id,你可以轻松地从上次离开的地方恢复对话。
011:构建视觉驱动的网页浏览智能体 🚀

在本教程中,我们将学习如何使用 LangGraph 框架,构建一个能够“看见”并自主浏览网页的视觉智能体。这个智能体的设计灵感来源于浙江大学胡一凡等人的 WebVoyager 论文。我们将一步步解析其核心架构,并动手实现一个基础版本。
概述
LangGraph 是 LangChain 团队开发的开源框架,它擅长构建具有循环结构的 LLM 工作流,例如智能体。我们将基于 WebVoyager 论文的思路,创建一个结合了视觉感知和网页交互的智能体。其核心是一个“思考-行动”循环:智能体观察网页截图,生成推理链,决定下一步操作,执行工具调用,并根据结果决定继续或结束任务。
核心架构与状态定义
上一节我们介绍了智能体的基本概念,本节中我们来看看如何定义其运行状态。在 LangGraph 中,状态是一个有类型的字典,它记录了智能体运行过程中的所有信息。
from typing import TypedDict, List, Annotated
import operator
class AgentState(TypedDict):
# 网页对象
page: Any
# 用户输入的问题
input: str
# 带标注框的网页截图
image: str
# 标注框列表
bounding_boxes: List
# LLM 的预测输出(思考与行动)
prediction: str
# 工具执行后的观察结果
observation: str
# 历史消息记录
messages: Annotated[List, operator.add]
构建交互工具
定义了状态之后,我们需要为智能体提供与外界(浏览器)交互的工具。这些工具是连接 LLM 决策和实际网页操作的 API。
以下是智能体可用的核心工具列表:
- 点击:接收标注框编号,在对应坐标执行点击。
- 输入文本:接收标注框编号和文本内容,在对应输入框键入文字。
- 滚动:控制页面上下滚动以浏览更多内容。
- 等待:让页面加载或等待动态内容。
- 返回上一页:在浏览历史中后退。
- 跳转到谷歌:提供一个重置或重新搜索的快捷方式。
每个工具函数都接收 AgentState 作为输入,从中提取必要信息(如页面对象、LLM 传入的参数)来执行操作,并更新状态。
智能体核心:标注、提示与解析
现在,我们来组装智能体的“大脑”部分。这部分负责将视觉信息转化为 LLM 能理解的指令,并解析 LLM 的回复。
- 标注函数:此函数对浏览器页面进行截图,并使用边界框标注页面上的可交互元素(如按钮、链接、输入框)。标注后的图像会提供给 LLM,帮助它“看见”并精确定位元素。
- 提示工程:我们设计一个系统提示词,指导 LLM 如何根据看到的图像、历史轨迹和当前目标进行思考并选择行动。提示词存储在 LangSmith 上以便管理和迭代。
- 输出解析:LLM 会生成包含
Action: [动作]; [参数]格式的文本。我们需要一个解析函数来提取动作类型和参数,以便调用正确的工具。
我们将提示词、LLM 模型和解析函数组合成一个可运行单元:
# 伪代码示例:组合智能体
prompt = ChatPromptTemplate.from_messages([...])
llm = ChatOpenAI(model="gpt-4-vision-preview")
agent = prompt | llm | output_parser_function
组装 LangGraph 工作流
有了状态、工具和智能体核心,我们现在可以将它们组合成一个完整的、可循环运行的工作流图。
首先,我们需要一个 update_scratchpad 函数,它在每次循环后运行,负责整理工具执行的观察结果,并将其格式化为历史消息,供下一轮思考使用。
接下来,我们按步骤构建图:
- 创建图构建器:
builder = StateGraph(AgentState)。 - 添加节点:
agent节点:运行智能体核心,产生思考和行动决策。update_scratchpad节点:更新历史记录。- 各个工具节点(如
click,type)。
- 定义边(路由逻辑):
- 设置
agent为入口点。 - 从
agent节点出发,根据其输出的action值,通过条件边决定下一步:- 如果
action == “answer”,则结束,返回结果给用户。 - 如果
action == “retry”,则返回agent节点重试。 - 否则,路由到对应的工具节点(如
click)。
- 如果
- 工具节点执行完毕后,总是路由到
update_scratchpad节点。 update_scratchpad节点完成后,路由回agent节点,开始新一轮循环。
- 设置
最后,编译图:graph = builder.compile()。
运行与调试示例
现在,让我们运行这个智能体,并尝试几个任务来观察其表现。我们将使用 Playwright 来控制浏览器。
任务示例 1:解释 WebVoyager 论文
- 过程:智能体打开谷歌,搜索“WebVoyager paper archive”,点击搜索结果链接,阅读页面内容。
- 结果:成功找到并总结了论文的核心内容。
任务示例 2:解释今日 XKCD 漫画
- 过程:智能体搜索“today’s XKCD comic”,导航到官网,查看漫画并阅读文字。
- 结果:成功描述了漫画场景并解释了其幽默之处。
任务示例 3:查找 LangChain 最新博客
- 过程:智能体搜索“LangChain blog”,进入网站,浏览文章标题。
- 结果:成功找到了关于 LangGraph 和多智能体工作流的最新博文。
更具挑战的任务:航班查询与地图导航
当我们尝试更复杂的任务,如“查询纽约到雷克雅未克的单程航班价格”或“规划从旧金山市中心到 SFO 机场的路线”时,智能体可能会遇到困难。它可能因为步骤超限、界面复杂(如 Google Maps)或标注框不准确而无法完成目标。
这时,LangSmith 的追踪和调试功能就至关重要。你可以:
- 查看完整的执行轨迹,精确到每一步的输入和输出。
- 检查每次 LLM 调用时看到的图像和提示词。
- 在 LangSmith 的 Playground 中直接修改提示词并重新测试,无需改动代码。
- 通过分析轨迹,定位问题是出在工具、标注还是提示词上,从而进行针对性优化。
总结与后续步骤
本节课中,我们一起学习了如何使用 LangGraph 构建一个视觉网页浏览智能体。我们涵盖了从定义状态、创建工具、组装智能体核心到构建完整循环工作流的全过程。虽然当前实现的基础版本能完成一些简单任务,但面对复杂场景时仍有很大优化空间。
要改进智能体,你可以考虑:
- 增加更多工具:如下拉选择、鼠标悬停等。
- 优化提示词:提供更明确的指令和示例。
- 改进视觉标注:提高边界框的准确性和覆盖范围。
- 利用 LangSmith:持续追踪、调试和迭代你的智能体。

你可以访问 LangGraph 代码库 查看本教程的完整示例代码,并注册 LangSmith 来获得强大的开发与监控能力。希望本教程能帮助你开启构建强大 AI 智能体的大门!
012:使用 LangGraph 构建自反思 RAG 流程 🧠

在本节课中,我们将学习如何使用 LangGraph 构建一个名为“自反思 RAG”或“主动 RAG”的复杂检索增强生成流程。我们将基于 CRAG 论文的思想,实现一个能够评估检索结果质量、并在必要时进行修正的智能系统。


概述:从线性 RAG 到主动 RAG
传统的 RAG 流程是线性的:问题 -> 检索 -> 生成。然而,在实践中,我们常常需要回答几个关键问题:我们是否真的需要检索?检索到的文档质量如何?如果质量不佳,我们该如何修正?这些问题催生了“主动 RAG”的概念。

主动 RAG 是一个让大语言模型根据现有检索结果或生成内容,动态决定何时、如何进行检索的流程。


核心概念:状态机与流程工程

为了实现主动 RAG,我们需要超越简单的链式调用,引入状态机的概念。状态机允许我们定义更复杂、更多样化的逻辑流程,让 LLM 在不同的步骤间进行选择,同时由我们指定所有可能的转换路径。




LangGraph 是构建此类状态机的理想工具。它鼓励我们进行流程工程:先仔细设计期望的工作流,再将其实现为图。



实现 CRAG 工作流
我们将实现一个简化版的 CRAG 工作流。其核心逻辑如下:
- 检索:根据问题从向量库中获取相关文档。
- 评估:对每个检索到的文档进行相关性评分。
- 决策:
- 如果至少有一个文档是相关的,则直接进入生成步骤。
- 如果所有文档都不相关或模糊,则进行网络搜索以获取补充信息,然后进入生成步骤。




我们会对流程做一个小调整:即使有相关文档,如果存在不相关文档,我们也会进行网络搜索来补充上下文。

第一步:定义状态



状态是一个核心对象,它会在图的各个节点间传递和修改。我们将其定义为一个字典。
from typing import TypedDict, List, Annotated
import operator
class GraphState(TypedDict):
question: str
documents: List[str]
generation: str
search: bool # 是否需要进行网络搜索

第二步:实现节点函数



每个节点都是一个函数,它接收当前状态,执行特定操作,并返回更新后的状态。



以下是关键节点的功能描述:

- 检索节点:从向量库中获取文档,并添加到状态中。
- 评估节点:使用一个 LLM 链对每个文档进行“是否相关”的二元判断。如果任何文档被判定为不相关,则将状态中的
search标志设为True。 - 查询转换节点:如果需要搜索,则对原始问题进行优化重写。
- 网络搜索节点:使用转换后的问题进行网络搜索,并将结果添加到文档列表中。
- 生成节点:将最终的问题和文档列表(可能包含原始检索结果和网络搜索结果)传递给 LLM 生成答案。



第三步:构建图并定义边



我们将各个节点连接起来,并设置条件边来实现决策逻辑。


from langgraph.graph import StateGraph, END



# 初始化图
workflow = StateGraph(GraphState)



# 添加节点
workflow.add_node(“retrieve”, retrieve_node)
workflow.add_node(“grade_documents”, grade_documents_node)
workflow.add_node(“transform_query”, transform_query_node)
workflow.add_node(“web_search”, web_search_node)
workflow.add_node(“generate”, generate_node)

# 设置入口点
workflow.set_entry_point(“retrieve”)

# 添加固定边
workflow.add_edge(“retrieve”, “grade_documents”)
workflow.add_edge(“transform_query”, “web_search”)
workflow.add_edge(“web_search”, “generate”)
workflow.add_edge(“generate”, END)

# 添加条件边(决策点)
def decide_to_search(state):
if state[“search”]:
return “transform_query”
else:
return “generate”

workflow.add_conditional_edges(
“grade_documents”,
decide_to_search,
{
“transform_query”: “transform_query”,
“generate”: “generate”,
}
)



# 编译图
app = workflow.compile()



第四步:运行与调试
运行图应用,并观察其执行过程。通过 LangSmith 可以清晰地追踪每个节点的输入、输出和状态变化,这对于调试和理解流程非常有帮助。


# 运行图
inputs = {“question”: “智能体的记忆系统是如何工作的?”}
for output in app.stream(inputs):
for key, value in output.items():
print(f”节点 ‘{key}’ 完成:”)
print(f” 状态: {value}\n”)





总结


本节课我们一起学习了如何使用 LangGraph 构建一个自反思的 RAG 系统。我们主要掌握了以下内容:





- 主动 RAG 的概念:让 LLM 参与决策,动态管理检索过程。
- 状态机与流程工程:通过设计清晰的状态转换图来构建复杂逻辑。
- LangGraph 核心操作:定义状态、实现节点函数、构建图并设置条件边。
- CRAG 工作流的实现:将“检索-评估-决策-生成”的完整流程编码为可执行的图。

这种“流程工程”的思维方式,使得构建包含复杂逻辑和条件分支的 RAG 应用变得直观且易于维护。通过 LangGraph 和 LangSmith,我们不仅能实现强大功能,还能获得出色的可观察性和调试体验。
013:LangGraph 规划智能体 🧠


在本节课中,我们将学习如何使用 LangGraph 框架创建“规划与执行”风格的智能体。这种智能体设计模式通过将任务分解为规划和执行两个阶段,旨在实现更快的执行速度、更低的令牌成本以及比传统智能体(如 ReAct)更高的可靠性。


背景:从 ReAct 智能体说起
上一节我们提到了规划智能体的优势,为了更好地理解其进步,本节我们先来看看经典的 ReAct 智能体。


ReAct(推理与行动)是一种提示语言模型的方法,使其能够推理需要执行的操作,然后输出一个可被解析并用于在真实世界(如浏览器)中执行的动作。其工作流程如下:
- 模型接收包含历史观察、可用工具和用户问题的提示。
- 模型输出一个动作(例如,
search(NFL top scorer))。 - 系统执行该动作,并将结果作为新的观察反馈给模型。
- 重复此过程,直到模型认为已获得足够信息来生成最终答案。


局限性:
- 串行执行:每个工具调用都需要一次 LLM 调用,速度慢且成本高。
- 短视决策:一次只规划一步,可能缺乏整体战略。
- 无法并行:工具调用通常是顺序进行的。



规划与执行智能体模式 🗺️
为了解决 ReAct 的局限性,业界提出了“规划与执行”智能体模式。该模式将智能体分解为几个核心模块:
以下是其主要组成部分:
- 规划器:接收用户输入和环境信息,生成一个需要执行的步骤计划。
- 执行器:负责执行计划中的具体任务。
- 重新规划器(可选):根据执行结果,决定是否需要调整原计划。这个循环逻辑是智能体的典型特征。
本教程中的示例主要基于 Plan-and-Solve 论文,并参考了 BAGI 项目的思想。


基础实现:Plan-and-Solve 示例
让我们开始构建第一个规划智能体。首先,我们需要设置环境、工具并创建智能体。


步骤 1:环境与工具配置
我们需要配置 API 密钥(如 OpenAI)和工具(如 Tavily 搜索引擎),并设置 LangSmith 用于追踪和调试。
# 示例:配置工具
from langchain_community.tools.tavily_search import TavilySearchResults
search_tool = TavilySearchResults()

步骤 2:创建执行代理
规划器生成任务列表后,执行代理负责处理每个具体任务。
# 示例:创建执行代理(简化概念)
execution_agent = create_execution_agent(tools=[search_tool])

步骤 3:定义图状态与节点
在 LangGraph 中,我们通过定义状态图和节点来构建工作流。状态包含输入、计划、历史步骤和最终响应等信息。



# 示例:定义状态结构(使用 Pydantic)
from typing import List
from pydantic import BaseModel
class PlanExecuteState(BaseModel):
input: str
plan: List[str]
past_steps: List[dict]
response: str = None

步骤 4:组装智能体图
我们将规划器、执行器和重新规划器定义为图的节点,并通过条件边控制流程。



# 示例:构建图(概念代码)
workflow = StateGraph(PlanExecuteState)
workflow.add_node(“planner”, planner_node)
workflow.add_node(“agent”, execution_agent_node)
workflow.add_node(“replan”, replanner_node)
workflow.add_conditional_edges(“replan”, should_end)
workflow.compile()

通过调用这个图,并设置递归限制(防止无限循环),智能体就可以开始处理查询(例如,“谁是美网冠军?”)。在 LangSmith 中,我们可以清晰地追踪每个步骤的决策和执行过程。


进阶:支持变量替换的 ReWOO 智能体 🔄

上一节的基础规划器将计划输出为字符串列表,执行时缺乏灵活性。ReWOO(无需观察的推理)论文对此进行了改进。

ReWOO 的核心创新是允许在计划中进行变量替换。这意味着初始计划可以引用早期步骤的结果,而无需再次咨询规划器,从而节省了大量时间和令牌。
工作流程:
- 规划器:接收输入,生成一个包含变量占位符(如
#E1)的计划和推理步骤。 - 执行器:顺序执行计划中的工具调用。当一个工具的结果产生后,系统会用该结果替换后续步骤中对应的变量。
- 求解器:所有步骤执行完毕后,根据结果生成最终答案。
优势:
- 减少了与规划器 LLM 的交互次数。
- 通过变量替换实现了步骤间的信息传递。


局限性:
- 任务仍然是顺序执行的,无法并行。
- 需要等待整个计划生成完毕后才开始执行工具。




高效并行:LLM 编译器智能体 ⚡
为了进一步提升性能,LLM Compiler 论文旨在解决 ReWOO 的剩余瓶颈。
LLM Compiler 通过两种主要方式加速:
- 流式任务生成(DAG):将任务输出为有向无环图格式,每个任务明确声明其依赖项。这样,没有依赖关系的任务可以并行执行。
- 任务流式传输:在 LLM 输出令牌流的过程中,一旦解析出一个完整的任务,就立即将其提交给任务调度单元执行,无需等待整个计划生成完毕。
核心组件:任务调度单元
这是 LLM Compiler 最有趣的部分。它接收一个任务字典流,每个字典包含要调用的工具及其依赖项列表。


# 概念性任务格式
task = {
“tool”: “search”,
“args”: {“query”: “GDP of New York”},
“dependencies”: [] # 此任务没有依赖,可立即执行
}
task_with_dep = {
“tool”: “calculate”,
“args”: {“expression”: “#1 + #2”}, # 依赖任务1和2的结果
“dependencies”: [1, 2]
}



调度器使用多线程,一旦某个任务的依赖项全部满足,就立即执行它。同时,它支持变量替换(如 #1 引用第一个任务的结果),使得复杂的依赖关系得以实现。
工作流程:
- 规划/调度节点:流式生成任务 DAG。
- 任务调度单元:并行执行满足依赖条件的任务。
- 合并器:所有任务完成后,决定是输出最终答案还是重新规划。
- 循环:通过条件边在“重新规划”和“结束”之间选择。
通过处理“纽约的 GDP 是多少?”或“现存最老的鹦鹉年龄是多少?”等多步骤问题,可以看到 LLM Compiler 能够有效地并行搜索和计算,显著减少总体响应时间。
总结 🎯
本节课我们一起学习了三种基于 LangGraph 的规划智能体:

- 基础 Plan-and-Solve 智能体:引入了规划与执行分离的概念,通过重新规划实现循环,比 ReAct 更具结构性。
- ReWOO 智能体:在规划中引入变量替换,减少了与 LLM 的冗余交互,提升了效率。
- LLM Compiler 智能体:通过流式生成任务 DAG 和并行执行,实现了性能的极大优化,是迈向生产级高效智能体的重要一步。

这些智能体设计模式都朝着更鲁棒、更有效的方向迈进。使用 LangGraph 实现它们非常直观。我们鼓励你尝试实现这些模式,甚至可以组合它们的思想(例如,将 ReWOO 风格的规划器与 LLM Compiler 风格的流式并行执行器结合),以构建适合自己需求的高性能智能体。
014:反思智能体 🧠
在本节课中,我们将学习如何通过“反思”技术来提升智能体的性能。如果你发现你的智能体经常重复自身行为或无法做出战略性决策,那么本教程将为你提供解决方案。我们将介绍三种不同复杂度的反思智能体实现,从基础循环到结合工具使用和树搜索的高级架构。






概述





反思是一种常见的技术,用于提升智能体在AI系统中的输出质量和成功率。其核心思想是让大语言模型(LLM)对自己过去的行动进行批判和改进,有时会基于额外的外部信息(如工具观察结果或环境反馈)。虽然这通常会略微增加执行时间,但能显著提升整体性能。如果你的应用场景允许,我们推荐使用此技术。





如果你的应用对延迟要求极高,也可以利用此技术生成高质量的微调数据,将计算成本前置,从而在不影响推理速度的情况下提升模型性能。



基础反思图 🔄
上一节我们介绍了反思的基本概念,本节中我们来看看一个最简单的实现:基础反思图。



这个图的基本流程是:首先提示一个LLM生成初始输出,然后将该输出交给另一个扮演“老师”角色的LLM进行反思和批评。模型根据批评改进输出,这个过程会重复固定次数,最后将最终结果返回给用户。

以下是构建此图的关键步骤:



- 设置LLM连接与追踪:首先配置API密钥以连接LLM,并设置LangSmith追踪以便后续复现和改进提示词。
- 定义生成器:创建一个简单的提示模板和LLM(例如Mixtral模型),让它生成一篇关于“小王子”的文章。
- 定义反思步骤:将生成器的输出格式化为“人类”消息,并提示另一个LLM扮演老师,对文章进行批评和改进建议。
- 循环改进:将改进后的输出再次输入生成器,形成“生成-反思-改进”的循环。
在LangGraph中,我们使用MessageGraph来构建这个流程。MessageGraph是一个图原语,其状态存储为聊天消息列表。








我们将上述函数包装成节点。关键点在于,我们需要在消息类型(AI消息和人类消息)之间进行一些转换,以确保扮演反思角色的模型不会混淆对话者。

整个图结构很简单:包含generate(生成)和reflect(反思)两个节点,并通过一个条件边进行循环,直到达到设定的迭代次数后结束。




运行此图,你可以观察到模型根据“老师”的批评(例如“需要加入更多引用和引证”)逐步改进文章,最终生成一个包含引用、看起来更翔实的版本。
需要注意的是,这种简单的单循环反思由于没有将反思步骤“锚定”在任何外部事实上,并不能保证性能一定提升。但它通常仍能带来改进,因为它鼓励LLM进行角色扮演和渐进式优化,有时能为在初始token生成轨迹中陷入局部最优的模型提供新的改进机会。








Reflexion 架构:结合工具使用的反思 🛠️




上一节我们实现了一个基础的反思循环,本节中我们来看看一个更强大的架构:Reflexion。它由Shinn等人提出,旨在通过语言反馈和自我反思进行学习。



Reflexion扩展了基础反思示例,不仅包含了工具观察结果,还引入了更明确的提示来指导如何基于反思进行“锚定”或规划。
其工作流程如下图所示:




- 初始响应:用户请求首先发送给“响应者”,生成初始答案。
- 自我批判与搜索:响应者尝试进行自我批判,并生成一些可用于扩展和改善答案的搜索词。
- 工具执行:所有工具(如搜索)并行执行。
- 修订步骤:基于批判、搜索结果和其他观察,模型重新回答,改进答案,并添加引用以回应最初的批判。
- 循环:重复“反思-搜索-改进”的过程,直到达到满意状态,最终将响应返回给用户。



以下是实现步骤:
- 环境设置:安装依赖,连接OpenAI API,设置Tavili搜索引擎作为工具,并配置LangSmith追踪。
- 定义工具:工具是让智能体“接地气”的关键。这里我们定义搜索工具,并使用一些样板代码来并行执行它们。
- 定义行动者提示模板:我们称初始响应者为“专家研究员”。提示词指示它基于已有知识(参数知识)给出初始答案,然后进行自我批判,并推荐搜索查询来改进答案。
- 使用函数调用:为了便于解析输出,我们使用OpenAI的函数调用功能。我们明确提示批判应同时指出回答中“缺失”和“多余”的方面,以保持平衡。
- 定义修订提示模板:修订提示与初始提示类似,关键区别在于它会注入修订指令,并要求模型使用数字引用来引用搜索结果,这有助于引导LLM生成更接地的输出。
- 构建图:我们再次使用
MessageGraph。图包含三个节点:draft(草稿)、execute_tools(执行工具)和revise(修订)。逻辑是:每次到达draft节点后,会进入execute_tools节点;工具执行完成后,通过条件边判断是否进入revise节点或结束循环。我们设置一个明确的最大迭代次数来终止循环。




运行此智能体,例如询问“应如何应对气候危机?”,可以看到它最终生成了一份包含多个要点和大量引用的报告。通过LangSmith可以查看完整的执行轨迹,观察其“生成-搜索-修订”的多次循环过程。








语言智能体树搜索:反思与规划的结合 🌳



上一节我们介绍了结合工具使用的Reflexion架构,本节我们将探讨一个更复杂的系统:语言智能体树搜索。它由Yao等人提出,将反思、奖励建模和树搜索过程相结合,以找到最佳的问题解决轨迹。




该技术的流程如下图所示:




- 生成候选:首先生成一个候选解决方案。
- 反思与评分:基于候选方案的输出和工具执行结果进行反思。反思会为当前步骤分配一个分数。此外,还可以执行外部检查(例如,如果是生成代码,可以运行单元测试并用测试结果更新分数)。
- 选择与扩展:根据分数(这里使用一种平衡探索与利用的算法,如UCT),从当前树节点中选择一个节点,并基于它生成多个新的候选方案。
- 重复与回溯:对新候选重复反思、评分步骤,并将分数反向传播回父节点,以反映该搜索分支的整体“前景”。
- 终止:重复此过程,直到解决问题或达到最大树深度。



这个方法的优势在于,它同时利用了反思产生的内部度量(分数)和外部观察(如测试结果),有助于平衡搜索过程,可能比简单的深度优先搜索等算法表现更好。

实现的关键组件如下:




- 定义图状态:这是一个核心部分。状态需要存储消息轨迹、父节点、子节点以及一些数值(如访问次数、累计奖励)。
- 选择算法:我们使用UCT(上限置信区间应用于树) 公式来选择节点。公式平衡了节点的平均奖励( exploitation )和其未被充分探索的程度( exploration )。
UCT = (node.value / node.visits) + c * sqrt(ln(parent.visits) / node.visits)
其中c是一个超参数,用于调节探索与利用的权重。 - 定义智能体与工具:使用ChatGPT和搜索引擎。反思步骤会生成对输出充分性、冗余度等的文字评价,并基于此在0-10分之间给出一个质量评分。
- 初始响应生成:一个简单的提示,让助手使用可用工具开始解决问题。
- 候选生成节点:基于当前最佳节点,使用LLM的
generate方法并行生成多个(例如5个)后续候选动作或思考。 - 图构建:图包含
start(开始)和expand(扩展)两个主要节点。终止条件包括:根节点已解决(反思评分表明已回答问题)、树高度超过限制。


运行此智能体处理复杂任务(例如,“撰写一份关于锂污染的研究报告”或“分析一场国际象棋比赛”),可以看到它展开树结构,并行执行多个搜索和反思步骤,并最终输出一个经过多路径探索和评分后的结果。

虽然这种树搜索方法不适合需要即时响应的聊天应用,但对于复杂的推理型任务或集成在更大的系统中非常有效。它也是生成高质量微调数据的绝佳方法,因为单次生成的数据质量通常不如这种经过多轮反思和搜索优化的数据。









总结




本节课中,我们一起学习了如何利用“反思”技术来提升智能体的性能。我们深入探讨了三种不同层级的实现:

- 基础反思图:一个简单的“生成-批评-改进”循环,通过让LLM扮演不同角色来促进自我改进。
- Reflexion架构:在反思循环中引入了工具使用和明确的引用提示,使改进过程基于外部事实,生成更接地、更翔实的输出。
- 语言智能体树搜索:一个高级框架,将反思评分与树搜索算法(UCT)结合,通过并行探索多条推理路径并回溯评分,致力于找到最优的问题解决轨迹。


反思是一种强大的范式,能够显著提升智能体在复杂任务中的表现。你可以根据应用对延迟、成本和复杂度的要求,选择适合的反思策略进行实现和优化。
015:使用 LangGraph 从零构建 STORM 系统 🚀
概述
在本节课中,我们将学习如何利用 LangGraph 框架,从零开始构建一个名为 STORM 的系统。STORM 是一个结合了 AI 媒体助手、检索增强生成和智能体角色扮演三大主题的系统,旨在自动生成高质量的维基百科风格文章。我们将一步步解析其核心流程,并用代码实现关键组件。
章节 1:STORM 系统核心思想与流程 📝
上一节我们介绍了本课程的目标,本节中我们来看看 STORM 系统的设计理念和整体工作流程。
STORM 系统融合了三个关键思想:
- 特定任务的 AI 助手:例如代码生成或网络研究助手。
- 检索增强生成:利用外部知识检索来辅助内容生成。
- 角色扮演:让 AI 扮演不同角色(如编辑、专家)进行协作或辩论,以深化对主题的理解。


STORM 的全称是“通过检索和多视角问答进行主题大纲合成”。其核心目标是自动撰写维基百科式的文章。
以下是系统的主要工作流程图解:

整个流程可以概括为以下几个阶段:
- 主题扩展与编辑角色创建:输入一个核心主题,系统会将其扩展为多个相关子主题,并为每个子主题检索相关的维基百科文章作为参考,进而创建出具有不同视角的“维基百科编辑”角色。
- 专家模块构建:创建一个“专家”模块,其核心能力是利用网络搜索等工具来回答问题。
- 编辑与专家辩论循环:让编辑角色和专家角色就相关主题进行多轮问答式“辩论”。这个过程由 LangGraph 协调,并积累详细的讨论内容。
- 大纲生成与精炼:在另一条并行路径上,系统会根据核心主题生成一个初步的文章大纲,然后利用上述辩论产生的讨论内容来精炼这个大纲。
- 内容撰写与格式化:将辩论内容存入向量数据库以便检索。一个“章节撰写器”会依据精炼后的大纲,检索向量库中的详细讨论,逐节撰写文章内容。最后,一个格式化模块将内容整理成标准的维基百科格式。
章节 2:初始化环境与主题扩展 🌱
上一节我们介绍了 STORM 的整体流程,本节中我们开始动手编码,首先进行环境准备和主题扩展。
我们首先定义核心主题,并实现流程中的第一步:主题扩展。
# 定义核心主题
topic = "What is the impact of million-plus token LLMs on RAG?"
接下来,我们运行主题扩展代码。这部分代码会调用大语言模型,基于核心主题推荐相关的子主题。

# 主题扩展链的Prompt示例
prompt_template = """
你正在撰写一篇关于“{topic}”的维基百科文章。
请推荐一些相关的子主题或页面。
"""
运行后,我们可能得到如下相关的子主题:
- Impact of million-plus token LLMs in RAG
- Long-context language models
- Retrieval-Augmented Generation frameworks
章节 3:创建维基百科编辑角色 👥
上一节我们生成了相关子主题,本节中我们利用这些子主题来创建具有不同视角的编辑角色。
以下是创建编辑角色的关键步骤:
首先,为每个子主题检索现有的维基百科文章作为参考模板。
然后,基于这些参考文章和子主题,生成不同的“编辑”角色。每个角色都被赋予特定的背景、专长和视角。
我们使用 Pydantic 模型来结构化地定义编辑角色:
from pydantic import BaseModel, Field
from typing import List
class Editor(BaseModel):
"""定义编辑角色的数据结构"""
name: str = Field(description="编辑的名称")
affiliation: str = Field(description="编辑的所属机构或领域")
description: str = Field(description="编辑的背景、专长和视角描述")
focus: str = Field(description="编辑关注的具体子主题")
class Perspectives(BaseModel):
"""包含多个编辑角色的列表"""
editors: List[Editor]
通过调用大语言模型并绑定上述结构化输出,我们生成了一组编辑角色。例如:
- Dr. Researcher:专注于分析长上下文窗口对 RAG 检索精度的影响。
- Technical Architect:关注于如何将超长上下文模型集成到现有 RAG 系统架构中。
章节 4:构建专家问答模块 🔍
上一节我们创建了负责提问的编辑角色,本节中我们来构建负责回答的专家模块。
专家模块的核心功能是:接收编辑提出的问题,利用网络搜索获取信息,并生成详实、有引用的回答。
专家的工作流程分为两步:
- 问题分解:将编辑提出的复杂问题分解成几个更具体的子问题。
- 搜索与整合:对每个子问题进行网络搜索,汇总搜索结果并生成最终答案。
以下是专家生成答案的核心函数框架:
async def generate_answer(state: dict):
"""
专家生成答案的函数
state: 包含当前对话状态(如编辑提出的问题)
"""
# 1. 从状态中提取问题
question = state.get("current_question")
# 2. 将问题分解为子问题
sub_questions = break_down_question(question)
# 3. 并行搜索每个子问题
search_results = await search_web(sub_questions)
# 4. 整合信息,生成带引用的最终答案
final_answer = synthesize_answer(question, search_results)
return {"answer": final_answer, "citations": search_results.urls}
专家使用的 Prompt 示例:
你是一位主题专家。你擅长利用网络信息高效回答问题。
一位维基百科编辑正在撰写关于“{topic}”的文章,他向你提问:{question}
请基于可靠的网络搜索信息,提供详细、准确的回答,并注明引用来源。
章节 5:使用 LangGraph 协调辩论循环 🔄
上一节我们分别构建了编辑和专家模块,本节中我们使用 LangGraph 将它们连接起来,形成一个自动化的多轮问答(辩论)循环。
LangGraph 允许我们以图的形式定义工作流。在这个场景中,图包含两个主要节点和条件边:
- “提问”节点:由编辑角色调用,根据当前讨论状态提出一个新问题。
- “回答”节点:由专家模块调用,回答编辑提出的问题。
两个节点之间通过条件路由连接,决定对话是继续还是终止。
from langgraph.graph import StateGraph, END
# 定义图的工作流
workflow = StateGraph(AgentState)
# 添加节点
workflow.add_node("ask_question", ask_question_function)
workflow.add_node("answer_question", answer_question_function)
# 设置入口点
workflow.set_entry_point("ask_question")
# 添加条件边
def route_messages(state: AgentState):
"""
根据当前状态决定下一步是继续提问还是结束。
结束条件:
1. 编辑说“感谢帮助,没有问题要问了”。
2. 达到最大对话轮数(例如5轮)。
"""
if state.get("conversation_end_signal") or len(state["turns"]) >= 5:
return END
else:
return "answer_question"
workflow.add_conditional_edges(
"ask_question",
route_messages
)
workflow.add_edge("answer_question", "ask_question")
# 编译图
app = workflow.compile()
运行这个图,编辑和专家就会自动进行多轮问答。所有对话历史都会被完整记录。
章节 6:生成、精炼文章大纲与撰写内容 ✍️
上一节我们通过辩论循环生成了丰富的讨论内容,本节中我们利用这些内容来生成最终的文章。
此阶段包含三个步骤:
1. 生成初步大纲
基于原始主题,让大语言模型直接生成一个文章大纲。
2. 基于讨论精炼大纲
将上一步的初步大纲和辩论循环中产生的详细讨论一起输入给模型,让它输出一个更完善、更深入的大纲。
refined_outline_chain = refine_prompt | llm
# refine_prompt 示例:你已从专家讨论中收集信息,请据此精炼你的文章大纲。
3. 撰写章节内容
这是最后一步。我们创建一个“章节撰写器”,它有两个输入:
- 精炼后的大纲:告诉它要写哪些章节。
- 向量数据库:其中存储了之前所有的编辑-专家辩论内容。
撰写器为大纲中的每个章节执行以下操作:
- 从向量数据库中检索与该章节最相关的讨论片段。
- 基于这些检索到的细节内容,撰写该章节的完整文本。
for section in refined_outline:
# 检索相关讨论
relevant_discussions = vector_store.retrieve(section.title)
# 撰写该章节
section_content = write_section(section, relevant_discussions)
所有章节撰写完毕后,再通过一个简单的格式化模块,将其组合成符合维基百科风格的最终文章。
章节 7:端到端运行与效果评估 🎯
上一节我们完成了所有组件的构建,本节中我们将整个流程串联起来,进行端到端的运行测试,并查看生成结果。
我们定义一个新的主题来测试整个系统:“Groq 和 NVIDIA 的 LPU 在 AI 推理领域的未来”。
将主题输入给编译好的 LangGraph 应用,系统开始自动执行:
- 扩展主题,创建编辑和专家。
- 启动多轮问答辩论循环。
- 生成并精炼大纲。
- 检索讨论内容,撰写文章章节。
通过 LangSmith 等工具,我们可以实时观察每个步骤的调用详情,例如专家进行了哪些搜索,编辑和专家之间具体的对话内容是什么。
最终,系统在几分钟内生成了一篇结构完整、内容详实的文章,包含引言、对 Groq LPU 和 NVIDIA 技术的介绍、性能基准、对 AI 模型的影响以及未来方向等章节,并且文末附有引用来源。

总结
本节课中我们一起学习了如何使用 LangGraph 从零构建 STORM 系统。我们深入探讨了如何将主题扩展、角色扮演(编辑与专家)和检索增强生成相结合,通过一个由 LangGraph 管理的多轮辩论循环来深化对主题的理解,并最终利用这些讨论内容自动生成结构严谨、信息丰富的维基百科风格文章。这个项目展示了智能体协作和复杂工作流编排的强大能力,是构建高级 AI 助手的优秀范例。
016:使用LangGraph构建计算奥林匹克竞赛智能体
在本教程中,我们将学习如何使用LangGraph框架,构建一个能够解决美国计算机奥林匹克竞赛(USACO)级别编程问题的智能体。我们将跟随一篇来自普林斯顿大学的研究论文的思路,逐步实现一个从基础到高级的智能体系统。
大约一周前,普林斯顿大学的一个团队发表了一篇名为《语言模型能解决奥林匹克编程问题吗?》的论文。该论文由Chen S和Shinny Ya等人完成,他们也曾参与著名的“ReAct”和“思维链”论文的研究。

这篇论文包含两个有趣的部分。一方面,它是一个数据集论文。他们发布了一个包含307道来自USACO竞赛编程问题的挑战性基准测试集。他们展示了GPT-4在使用简单的零样本ReAct智能体框架尝试解决这些问题时,仅有约8.7%的通过率。这与一些现有的基准测试(如HumanEval、MMLU)形成了对比,后者大多已被当前这批语言模型所饱和。
另一方面,论文展示了一些推理过程的优化方法,主要是提示工程或系统工程类型的方法,将平均性能从8.7%提升到了20.2%。在本教程中,我们将更详细地探讨这些技术。

让我们感受一下这个基准测试中问题的难度。以下是一个示例问题。可以看到,它们大多是文字问题,要求你识别需要解决的底层数学问题,使用高级数据结构和算法,并以创造性的方式组合它们来得出正确的解决方案。同时,解决方案必须以在给定时间限制内完成并实现的方式呈现。


从图中可以看出,其中涉及大量集合和其他类型数据结构的使用。这些问题具有挑战性,被称为“奥林匹克”级别是有原因的。
这个基准测试的有趣之处在于,它真正将大语言模型推向了极限,从而可以看到它们在哪里会失效。我认为,当我们在日常生活中使用它们时,常常会将其拟人化,认为它们在推理。但当你接触到这种程度的问题时,你会发现它们开始做出一些看似接近正确、但缺乏逻辑属性的行为。如前所述,当他们首次在GPT-4上运行这些问题时,通过率非常低。
随后,他们展示了许多可以改进的推理技术。其中一些包括自我反思。

另一些则涉及检索,无论是语义知识还是情景知识。他们做了大量实验,以展示哪些类型的检索能真正提高模型的性能。我们将实现性能最好的检索类型,即情景知识类型。因为它似乎与自我反思非常互补,并且很适合我们的教程结构。让我们查看LangGraph文档,了解如何在教程中实现这一点。

在视频的剩余部分,我们将创建一个智能体来解决这类竞赛编程问题。我们将按照论文的结构,将其分解为三个部分,构建能力逐步增强的智能体,以解决这些高级问题。
第一步,我们将实现Reflexion智能体,这是一个进行自我反思的零样本智能体。这对应于我们即将创建的图中的这个模块。它将有两个简单的节点,并循环运行,直到正确解决问题或超时。这大致类似于他们创建的Reflexion智能体,其通过率约为12.38%,同样优于基础的零样本智能体,但不及他们能达到的整体水平。
在本教程的第二部分,我们将实现检索作为一种情景记忆形式,论文称之为“情景记忆”。这是这里的第二部分,我们将检索一些高质量示例包含在提示词中,以期在模型中诱导出更好的逻辑性能。这对应于图中这个部分,在基准测试中整体通过率约为22.2%。
在第三部分,我们将添加人工中断,允许我们作为用户实际参与答案的生成,并帮助引导智能体得出正确答案。你可能认为这是作弊,事实上,我们不会在整个数据集上对此进行基准测试,作者也没有这样做。但在许多应用设计中,你真正想要的是最佳的整体结果。因此,人机协同的设置是一种非常实用的方法,可以在单独的人类或智能体都无法达到的情况下,获得更好的整体结果。
贯穿始终的一个主题是,自主智能体目前还不太成熟,尤其是在使用简单的零样本提示方法时。但是,你可以使用像LangGraph这样的框架来创建状态机,从而在你的任务领域内真正控制和引导它,以期获得更好的整体结果。
让我们下载这个笔记本,然后一起运行它。现在,我们准备开始运行教程。我们已经在Jupyter中打开了它,并在这里安装一些先决条件。主要是LangGraph。我们将使用LangSmith添加一些追踪功能。在我们的案例中,智能体将由Anthropic的Claude模型驱动。我们还需要一些其他东西来从Hub拉取内容。我们将设置环境变量,在我们的案例中是Anthropic的API密钥,用于连接他们的API。然后我们还将配置追踪。这里有很多步骤,每个程序可能相当长,因此在笔记本中可视化可能会很杂乱。我建议使用LangSmith,这样你可以调试每个步骤,确切地看到发生了什么,更容易发现错误,更容易了解情况。
然后,我们将获取存储在Google Cloud存储桶中的数据,并将其加载到内存中。最后,这里的实用程序是运行测试用例。需要注意的是,这是在本地执行代码。因此请谨慎操作,如果LLM生成了恶意代码或类似的东西,存在固有风险。
这里有一个测试用例运行器的示例运行,打印“hello world”,如果通过,则返回“pass”;如果不通过,则返回“wrong answer”以及预期输出。现在,我终于可以开始定义图了。在第一部分,我们将再次实现这个带有反思的简单零样本智能体。
在论文中,他们使用了明确的Reflexion提示。我们稍作调整,然后在这里提示我们的智能体在调用工具时对其输出进行反思。这个智能体将相对基础。它对应于在基准测试中获得约12%通过率的智能体。因此,对于我们的第一个问题,我们预计它不会通过,但这没关系。我们还有其他技巧。
接下来的部分,如果你已经构建过LangGraph,可能会觉得是复习,但我想无论如何还是回顾一下,因为我认为这很重要。LangGraph中的主要原语是状态图。它是你定义状态机的方式。基本上,节点定义了工作单元,边定义了控制流。一旦一个节点完成,边就定义了接下来要传递到哪个节点以继续操作。在最简单的情况下,你基本上有两个节点,在我们的案例中,它会循环返回,然后在达到某个结束状态时输出。
最后非常重要的一部分是状态。当然,这定义了所有节点的接口。因此,每个节点接收状态作为输入,然后返回对状态的更新,这可以是整个状态本身,也可以是某个子集,然后图能够将其合并到先前的状态中。
在我们的案例中,这个状态的主要方面将是这个消息列表,我们使用Python的注解语法,通过这个函数将其注解为仅追加。这基本上保持了智能体的草稿本,因为它生成候选答案,然后接收工具响应,然后继续迭代并尝试改进。
状态的其余部分主要保存测试用例和运行时限制,以配置评估节点的运行方式。智能体本身将忽略这些,但测试运行器会使用它们。
现在我们已经定义了状态。我们将更新数据为正确的格式,以便能够传入。我们可以开始定义核心部分了。记住,这有两个节点:求解节点和评估节点。首先是求解器。
这里很简单。我们基本上是获取一个提示词,然后将其与LLM组合。所以这里是将提示词格式化,然后传递给LLM。这个bind_tools操作只是为LLM配置一个模式,以便它知道应该以什么结构进行响应。在我们的案例中,我们将使用这个write_python工具。它的write_python模式,我们基本上会告诉它先进行一些推理和伪代码,以诱导出链式思维推理,然后最终将所有python3代码写入这里的code字符串中。这使得在后续解析时容易得多,因为你不需要解析原始字符串。
我将在这里运行它,然后定义一个求解器。我们从Hub拉取这个求解器提示词。它很简单,我在这里没有做太多提示工程。注意,我们有一个变量examples,稍后将在第二部分使用。现在,我们只是用一个空字符串来填充它。这将是一个占位符,稍后我们用检索到的附加信息来填充。但更多内容稍后再说。
这里有一个示例运行。我们可以问它:“如何从无限流中获取完全随机的样本?”我们已经得到了响应,你可以看到它生成了这个<thinking>标签,然后最终输出推理过程,你可能会看到伪代码,然后是最终的代码,它确实如我们所料在进行蓄水池抽样。所以至少它学习过一些代码,这是一个好的初步迹象。
我喜欢Claude相对于GPT-4的一点是,它被训练为在真正执行工具调用之前输出这种“思考”或前导文本。我认为,当使用GPT-4进行工具调用时,经常会出现如何整合链式思维的问题,我们发现,由于这个原因,它在处理一些更复杂的任务时有时会表现不佳。我认为,模型被训练为在工具调用之前输出这些内容是非常好的,这样你就不必在思考上做出牺牲。
因此,我们将在这个循环(智能体循环)中定义的第二个节点是评估节点。这里有很多错误处理等内容,但真正的关键部分是,我们将遍历状态中的所有测试用例。再次回忆,每个节点将接收状态的一个实例并返回输出,在这个案例中,是一个更新的消息。但它会遍历测试用例并运行它们。然后,如果成功,它将更新我们状态中的状态;否则,它将分别格式化所有这些内容,然后添加消息。
一旦你到了那里,你就可以创建图了。我们将可视化它。所以,这再次对应于我们上面的那个图。它更简单一些。我们把初始问题放在这里,尝试生成一个解决方案。它进行测试。然后我们进入这个控制边。如果成功,我们就结束;否则,我们回到求解器并继续循环。这里是控制边。
你可以看到,它也接收状态,然后检查状态以查看是否成功。然后它返回。这是一个字符串,带有双下划线。这只是告诉图它不需要继续循环。否则,它说回到求解器节点。这就是我们在这里定义条件边的方式。
让我们看看第一个问题。关于农夫约翰的问题。看看,你知道,前几天有一些生产力。贝西,你有一头牛。她相当复杂。它给出了样本输入和输出的数量,作为问题的一部分。
让我们尝试在这里运行我们的智能体。再次强调,这是我们今天将要构建的智能体中最简单的版本。我完全预计它不会成功。老实说,LangGraph通常通过图递归错误来指示这一点。基本上,默认情况下,图有有限的步骤数。你可以配置这个,我们在文档的其他地方展示了这一点。这里不详细说明。但如果超过了配置的最大步骤数,它将引发递归错误。让我们等待这个继续填充。
看起来,正如我们所说,它将达到递归限制。它实际上无法解决问题。你可以在下面看到每个步骤,我们实际上会跳转到LangSmith查看一下轨迹。
我们已经查看了这里的轨迹,你可以看到它按照我们定义的方式循环,有提示词和LLM,然后有评估,它在这个循环中继续,直到最终出现错误。我总是喜欢跳到一个较晚的LLM调用中,因为它收集了完整的消息历史,你可以确切地看到它在做什么。所以最初它说“解决这个问题”,关键洞察在这里,尝试写出伪代码,然后思考,然后在那里生成所有内容。第二次是“当前解决方案在较大的测试用例中超时,可能是因为它遍历了所有可能的情况”。所以它实际上是在尝试自我纠正,但未能成功。我猜,它的记忆中没有很多解决这类问题的好例子。所以你可以看到,它经历了很多事情。因此,它无法得到正确答案。
没关系,论文至少提出了一个我们可以纳入的自动改进方法,然后还提出了更像人机协同场景的想法,我们将在第三部分讨论。但在第二部分,让我们深入探讨记忆和检索优化。
回到笔记本的第二部分,我们将实现论文提出的这种少样本检索优化。作者称之为“情景记忆”,因为它从语料库中的其他问答对中检索这些输出。所以,如果你假设算法(智能体)已经解决了所有其他问题,它就可以回忆这些内容,并将其用于解决后续问题。这是一种有趣的框架,与人们通常谈论RAG和检索作为改进知识更新方式的经验法则形成对比,而不是作为实际改进智能体推理能力的机制。不过,由于这些是经过精心挑选、精心制作的领域内示例,这确实更符合少样本指令和类似优化。它更像是这种东西。论文还探索了语义记忆,即检索教科书等内容。这确实显示出短暂的提升,但当他们后来结合反思和其他内容时,似乎并没有保持同样的效果,因此它似乎是一种无法像这种高质量指令类型数据集那样扩展的技术。所以,在这里我们将跳过它,遵循论文,我们将使用BM25检索器作为检索器。它本质上是一种更传统的、非基于向量的、基于TF-IDF的检索机制,质量很高。
为了适应这些步骤,与第一部分相比,我们将在状态中添加两个新的键。我们将添加这个首先生成的候选消息,它将用于检索步骤。然后,我们将有格式化为字符串的示例。如果你还记得最初的提示词,它有一个示例模板,我们最终将在这里填充它。然后再次回忆,这个情景记忆发生在我们的智能体循环之前。这部分将保持不变,但在这里我们将有检索步骤,然后我们仍然会忽略这个,并说这是为第三部分准备的。
一旦我们定义了新状态,我们就可以定义求解器。这大部分是重复之前的,只是我们将有一个小的if语句来生成填充候选步骤,如果它仍然处于第一阶段。所以我们将创建这个草稿求解器和求解器。它们几乎相同,只是草稿求解器当然还没有问题在这里。
然后,为了确保避免通过将问题的实际答案放入检索器中来作弊,我们将把这些分为训练和测试语料库。然后我们将在这里创建检索器。最后,是时候定义检索节点了。和之前一样,它接收一个状态(所有节点都这样做),然后返回这个更新后的状态,所以它将特别更新examples键。
然后,在这个节点内部,它调用这里的检索器,所以retriever.invoke,挑选出前K个,然后将其格式化为一个字符串,我们将在提示词中更新。注意,在我们定义的图中,我们在这里添加了这些可运行的配置。我们将让你在调用实际智能体时,配置检索到的示例数量。一种方法是通过配置中的这些可配置参数,它总是第二个位置参数。
关于这个检索器设置,还有一点需要注意,我认为很有趣,那就是我们检索候选程序作为查询,而不是初始问题。这类似于你可能遇到的技术,比如HyDE或RAFT等其他类型的RAG索引策略。这里的观察是,查询的分布与你试图检索的文档的分布不同。因此,你要么想从文档中创建假设查询,以更好地与我们将输入系统的查询类型对齐;要么想将查询映射到你期望文档的样子,然后可能从那里检索。还有其他一些变体。但基本上,你是说我们将放入这两件事中的文本类型和词语类型不会相同。因此,如果你能尝试翻译它们,你会得到更好的结果。
最后,是时候构建图了。所以再次,我们这里的大部分内容是一样的。所以你看到求解、评估和所有这些内容都没有被改动。我们在这里真正添加的是开头的这个草稿节点,我们把求解器放在那里,然后是这里的检索节点。所以我们将草稿节点设置为入口点,进行检索。然后我们将始终从草稿到检索,再从检索到求解设置一条交互边。然后再次,我们将创建求解到评估的循环,然后从评估要么结束,要么回到求解。
所以让我们创建一个节点。这里是可视化,如果更容易看的话。所以再次,其余部分都一样。我们只是在开头添加了这两个步骤。让我们试试看。
我们将添加一个检查点,我们将忽略这个,但我们将说从语料库中检索三个示例并传入。这也会花一些时间,所以请耐心等待。看起来图已经完成了。你可以看到这有点截断。让我们跳转到LangSmith看看具体做了什么,但从我们得到的状态中,你可以看到它在第10次尝试中成功了。
现在你已经跳转到LangSmith跟踪,你可以看到这次只有几个步骤。这很好。按照我们的图结构,我们这里有草稿节点,它再次输入问题、系统提示,并输出初始答案。我们从中检索了一些示例。所以再次看到,查询是我们讨论过的候选程序,并从语料库中检索其他示例。然后我们将其传入。所以你现在可以看到,这些示例在求解器的系统提示中格式化了。然后它拥有了所有内容。我们没有在这里包含初始候选程序,因为我们已将其保存在状态的不同键中。然后它尝试生成一个答案。这次是正确的。测试用例成功。所以这很棒。
我将跳回第三部分,因为我们看到它解决了这个青铜级问题,但它在基准测试中解决一些更困难、更具挑战性的问题表现如何呢?回到笔记本,让我们在一个更难的银级问题上测试它。我们从数据集中得到了这个。你可以看到。这里的问题,它是一个河流巡航问题,基本上你试图检测循环,然后模拟它的不同步骤。它给出了几个样本输入,但这是一个比第一个更具挑战性的问题。我们将格式化它,然后运行它,看看它表现如何。我完全预计这不会成功,我预计这会失败。因为它只是一个更具挑战性的问题。而这些LLM,虽然它们已经训练了大量代码和推理类型的问题,但每当有新的问题时,它们往往难以以创造性的方式组合它们。所以其中一些技术可以提供帮助。但我认为,我们正在触及当前智能体推理能力的一些极限。
所以我们的优化图已经完成,我们得到了另一个图递归错误。它无法在分配的步骤数内正确回答问题。事实上,我们预料到了。这些都是极具挑战性的问题,它们将LLM(至少按它们今天的训练和设计方式)推向了其推理能力的极限。它需要以具有挑战性的方式新颖地组合算法和数据结构。
论文随后探索了最后一个推理时间优化,这真正将我们从自主智能体的领域带入了人机协同的领域。为了能够进行基准测试,他们将人类参与限制为简单的指导和推动,而不透露答案的任何部分。但是,当你构建实际应用程序时,你通常希望真正优化最终用户体验,并最大化实现目标的机会。
如果你要创建一个用户参与其中的应用程序,你希望给他们一个很好的能力,让他们能够随时随地提供指导。LangGraph使这变得相当容易。因此,为了本教程和第三部分的目的,我们只是为我们的智能体添加一个通用的人机协同接口。
我们将把它插入到这里。所以智能体图的结构将与第二部分完全相同。我们将插入问题。智能体将生成一个候选程序。该程序将从语义记忆或情景记忆的语料库中检索类似的高质量示例。然后智能体尝试解决它。它在这里生成程序,然后在它们上运行测试用例。然后,我们在第三部分改变的是,我们将在这里中断,然后说:允许人类在此时查看图的关键状态,或许可以选择添加一条消息,建议考虑替代路线,考虑查看生成程序的特定部分等。然后,由于LangGraph允许你将其持久化在检查点中并继续尝试,我们可以在任何时候恢复执行。你可以继续这个循环,并在过程中不断介入并提供反馈,希望防止LLM陷入这些局部最优解,即它只是循环往复,无法真正完成实际任务。一旦你可以协作得出最终答案,智能体和图就可以最终完成执行。理论上,这种设计只受所涉及用户的质量或能力的限制,因为实际上我们可以提供正确答案或任何类型的反馈,LLM将能够综合这些并将其纳入。
所以让我们深入这里,创建这个用于解决计算奥林匹克问题的人机协同智能体。这里的代码块与第二部分完全相同。我们在这里获取检查点,我们将在内存中进行,我们有我们的状态图,我们使用完全相同的状态,我们有提示词和LLM,我们创建这个草稿求解器,将其添加到节点,我们将其设置为入口点,我们创建检索节点,我们创建求解器节点和评估节点(或运行测试用例的节点),然后我们开始连接它们。所以我们添加从草稿到检索的边,从检索到求解,从求解到评估,然后我们添加这些条件边来定义条件循环。所以我们说,一旦你运行了测试用例,我们要么回到求解器,要么在成功时结束。
我也会创建检查点。第三部分与第二部分相比,一个不同之处是,我们将在评估命令之后添加这个中断。所以基本上,在它进入人工步骤之前,我们将告诉图:嘿,停止并允许人类或任何其他进程修改状态。让我们在这里可视化它。正如你所看到的,图看起来与第二部分完全相同,我们将开始运行它。
所以再次,这将持续执行,直到达到那个中断。我们的图已经停止执行。你可以通过使用配置的快照查看当前状态,你可以再次看到那里的问题。注意,它没有说图递归错误,但它仍然得到了不正确的提交,正如我们所料。由于我们添加了中断,它实际上会停止这个循环,然后我们可以在任何时候恢复它。
所以我们可以再看一下,这是我们之前看的银级问题,到目前为止它无法解决,我们将查看它当前的候选解决方案,这是智能体现在打印出来的。看起来还行,可能有点简单,肯定没有处理所有的边缘情况。然后我们可以看看这个测试,因为这是最后一个工具消息“incorrect submission”,实际上10个中对了8个。所以它非常接近。假设,让我们在这里给它一些建议,作为一条人类消息。然后我们将检查以确保这确实反映在状态中。所以你可以看到我们现在有了这条人类消息。我们通过调用graph.update_state并传入那里的配置来完成。所以它告诉要更新哪个快照,然后你在这里有人类消息,我们可以恢复。
我们在这里恢复的方式是传入空值和None,然后由于我们使用编译到图中的相同配置,它知道从检查点加载当前状态。再次,为了教程的目的,我们在这里使用内存中的SQLite检查点,但有很多实现可以用来连接你自己的存储架构。这将需要一点时间,所以再次,我们将在它执行完成后恢复。
在我们的案例中,这实际上足以让它成功。我这里还有其他代码试图将其引导到正确的位置,因为有时即使在第一次人工反馈后,它也不会成功。但在我们的案例中,它成功了。正如你所看到的,你可以在这里列出它的所有状态,所以我只是从这个图的执行中获取最近的检查点,我们看到它成功了。你实际上可以再次查看LangSmith跟踪,我会跳回去。




看看它的运行情况。我们看到,再次,我们传入了空值。如果你还记得笔记本中的循环,它加载,我们回到求解器,因为那是图中预定要执行的下一个节点。它已经运行了草稿、回忆和检索器以及所有这些步骤。你可以看到完整的消息列表,以确认,是的,它确实一直在从记忆中获取这些内容,并包含了我们添加的这个建议。我们都是在笔记本中完成这些的。但再次,你可以在LangGraph实现之上放置任何类型的UI,并允许用户以任意方式与你的协同系统交互。
因此,AI能够将这种分解纳入更新的响应中,然后通过了所有的测试用例,所以我认为这是成功的。这把我们带到了本教程的结尾。正如我们所看到的,目前训练出来的LLM本身并不太擅长解决这种由奥林匹克编程问题提出的挑战性推理问题。
然而,通过一些提示工程和更好的系统设计,你可以将平均性能从低于9%的低点大幅提高到20%以上。当你构建解决挑战性问题的实际应用程序时,你可以使用LangGraph轻松创建这类人机协同接口,使得与单独使用智能体或单独使用人类相比,能够达到更好的整体结果。我认为这些通用技术相当广泛,可以应用于许多领域。
所以回顾一下,首先我们从一个带有反思的零样本智能体开始。它基本上提示智能体查看测试用例结果,查看当前解决方案,然后尝试将这些结果和反馈纳入更新的候选答案中,最终得出并解决正确的问题。我们看到,即使在一个青铜级问题上,这也不总是有效。
因此,我们随后添加了一个额外的检索优化。这个被作者称为情景记忆的优化,允许模型从语料库中获取这些真正高质量的示例,并利用它们尝试触发一个遵循类似设计和方法的、稍微更好的输出。在这种情况下,由于这些少样本指令,可以诱导出更好的推理。我们看到这能够解决青铜级问题,但在银级问题上失败了。
所以,然后我们添加了这个人机协同接口,并在评估后中断,这允许我们进入并修改检查点状态图,以引导智能体得出问题的正确解决方案。正如你从所有这些步骤中看到的,自主智能体真的很酷,但它们在处理这些挑战性问题方面还不太成熟。然而,通过更好的工程,通过使用状态机和所有这些,可以设计出一些更好的系统,这些系统实际上能够完成一些相当令人印象深刻的工作。
我对这个新的数据集感到兴奋,因为它更具挑战性,并展示了我们当前类型语言模型在能力上的缺陷,同时也展示了这些混合系统、这些神经符号方法在提高性能方面可以非常强大。我期待看到其他人提出更好的系统,超越作者提出的那些,并希望达到即使不依赖更大模型也能解决所有这类问题的程度。
这就是我们今天教程的全部内容。如果你有任何问题或评论,请随时在下面的评论中留言。同时查看描述中的链接以查看代码并自己运行它,并告诉我们你希望看到哪些其他类型的教程,这些教程对你实现自己的智能体、聊天机器人和助手有帮助。直到下次,我是Will,祝你今天愉快。


017:构建客户支持机器人 🛠️

在本教程中,我们将一起构建一个旅行助手聊天机器人。通过这个过程,你将学习到一些可复用的技术,这些技术适用于构建任何客户支持聊天机器人,或者任何具有以下特征的AI系统:
- 使用代表用户执行操作的工具。
- 使用大量工具,因此在选择正确工具和使用方面可能存在困难。
- 需要支持产品中不同的特定用户旅程,例如结账流程或特定功能的管理。
我们将从一个非常简单的旅行助手开始,展示其设计上的局限性,然后逐步增加复杂性和控制逻辑,以更好地支持这些需求。
聊天机器人的演进背景 📜
在开始构建之前,我们先了解一下聊天机器人的发展背景。



传统的聊天机器人通常采用行为树或图结构。用户提出问题后,系统会识别意图并将其路由到特定的技能模块,然后上下文会沿着这些功能向下传播,每个功能解决一个专门的任务。这种设计的问题在于,当需要在任务间跳转时,上下文无法完全共享,缺乏足够的灵活性和支持性,导致用户体验不佳。

2022年,ChatGPT的出现带来了一个激进的想法:消除意图检测、实体链接、路由和响应生成等所有复杂性,让用户直接与一个强大的大语言模型对话。然而,这种方法的问题在于,它没有基于任何具体信息,无法代表用户执行实际的操作。


因此,我们今天的目标是设计一系列复杂度递增的航班助手,从一个简单的使用工具的对话代理开始,逐步发展到具有更具体、任务特定、上下文感知的工作流,以平衡上下文共享、表达能力与设计良好、专注的用户体验。
第一部分:零样本工具执行器 🤖
上一节我们介绍了构建目标,本节中我们来看看第一个设计:零样本工具执行器。
这是一个仅有两个节点的图:
- 助手节点:本质上是一个LLM和提示词。它能够决定调用工具或直接响应用户。
- 工具执行器节点:执行被调用的工具。
所有工具一次性提供给LLM,因此它需要完成两个任务:决定调用哪个工具(及其参数),以及根据工具执行结果合成响应。它可以循环执行这个过程。

这个设计存在一些局限性,我们稍后会讨论。
以下是定义状态和助手节点的核心代码:
# 定义状态
class State(TypedDict):
messages: Annotated[list, add_messages] # 聊天消息列表

# 定义助手节点
def assistant(state: State):
# 提示词和LLM配置
prompt = ChatPromptTemplate.from_messages([...])
llm_with_tools = llm.bind_tools(tools) # 将所有工具绑定到LLM
# 调用LLM并返回结果
...

接下来,我们定义图结构:
# 创建状态图
graph = StateGraph(State)
# 添加节点
graph.add_node("assistant", assistant)
graph.add_node("tools", tool_executor)
# 设置入口点
graph.set_entry_point("assistant")
# 添加条件边:如果助手输出包含工具调用,则路由到工具节点,否则结束
graph.add_conditional_edges(
"assistant",
tools_condition # 检查是否有工具调用的函数
)
graph.add_edge("tools", "assistant") # 工具执行后返回助手
# 编译图并添加检查点(用于记忆)
app = graph.compile(checkpointer=checkpointer)
运行这个简单的代理后,我们发现它存在一些问题:它不会请求用户确认就执行操作(例如直接预订航班),并且在处理某些垂直用例和用户旅程时表现不佳。
第二部分:添加用户确认流程 👥
上一节我们看到了简单代理的局限性,本节中我们通过添加用户确认流程来增加控制。
在这个设计中,当助手决定调用工具时,用户能够介入循环,确认是否允许执行该操作。这样,助手就不会在用户没有发言权的情况下擅自花费用户的金钱或执行敏感操作,我们也不再纯粹依赖LLM,而是增加了代码层面的安全措施。
实现的关键是使用LangGraph的 interrupt_before 功能。我们在工具节点前设置中断,让图暂停执行,等待用户批准或修改操作。
图结构与第一部分类似,但增加了 fetch_user_info 节点来预先获取用户上下文,并在 tools 节点前添加了中断:
graph.add_node("tools", tool_executor)
# 在进入工具节点前中断,等待用户输入
graph.add_interrupt_before(["tools"])
运行这个版本后,用户需要对每一个操作步骤进行确认,包括查询策略这样的简单操作。这显然给用户带来了过多负担。我们希望在保证安全的同时,让代理更具自主性。

第三部分:条件中断与工具分类 🛡️

上一节中用户需要确认所有操作,本节我们通过将工具分类并实施条件中断来优化体验。



我们将工具分为两类:
- 安全工具:只读操作,如搜索航班、查询用户详情、租车、查询短途旅行信息。这些操作不需要用户确认。
- 敏感工具:写操作,如更新预订、取消预订、创建新预订。这些操作需要用户确认。
助手在调用工具时,仍然能看到所有工具。但我们的图编排会根据被调用工具的类型,将其路由到不同的节点,从而提供不同的用户体验。





以下是定义工具分类和修改图逻辑的核心部分:
# 定义安全工具和敏感工具列表
safe_tools = [search_flights, get_user_info, ...]
sensitive_tools = [update_booking, cancel_booking, ...]
# 修改条件边逻辑
def route_tools(state):
ai_message = state['messages'][-1]
if not hasattr(ai_message, 'tool_calls') or not ai_message.tool_calls:
return END
tool_name = ai_message.tool_calls[0]['name']
# 根据工具名称路由到不同节点
if tool_name in [t.name for t in sensitive_tools]:
return "sensitive_tools"
else:
return "safe_tools"
graph.add_conditional_edges("assistant", route_tools)
# 只为敏感工具节点添加中断
graph.add_interrupt_before(["sensitive_tools"])
这个设计大大改善了用户体验,代理可以自主执行安全操作,仅在执行敏感操作前请求确认。然而,我们仍然面临一个挑战:单一的、庞大的提示词难以可靠地处理所有不同的、复杂的用户旅程,导致体验不一致。
第四部分:专业化工作流(多代理模式) 🚀



上一节我们改进了中断逻辑,但代理在处理复杂意图时仍可能不可靠。本节我们将介绍最强大的设计模式:专业化工作流,也可视为多代理模式。


我们将不同的、专注的用户旅程分离成独立的“技能”或“工作流”。一个主助手与用户交互,当用户需求可由某个技能满足时,它将意图路由到相应的专门工作流。这些工作流可以透明地直接与用户交互,使用自己范围内的工具,并引导用户完成特定旅程。由于所有工作流共享同一个状态图,它们可以无缝管理状态转换。
这类似于网站设计:你不会将结账、搜索等所有体验放在一个页面上,而是为每个用户旅程创建专门的页面。
以下是实现此模式的关键步骤:



-
扩展状态:添加一个
dialogue_state字段来跟踪当前处于哪个工作流。class State(TypedDict): messages: Annotated[list, add_messages] user_info: dict dialogue_state: Annotated[list, operator.add] # 对话状态栈 -
创建工作流:为航班预订、酒店预订、租车、短途旅行等分别创建专门的助手和工作流图。每个工作流有自己的提示词、工具集和内部逻辑。
def create_workflow(name, prompt, tools): # 创建工作流专用的图 workflow_graph = StateGraph(State) workflow_graph.add_node(f"enter_{name}", enter_workflow) workflow_graph.add_node(name, specialized_assistant) workflow_graph.add_node(f"{name}_sensitive_tools", ...) workflow_graph.add_node(f"{name}_safe_tools", ...) # ... 添加边和条件逻辑 workflow_graph.add_edge(f"enter_{name}", name) # 定义如何退出工作流(例如,通过一个特殊的“完成”工具) return workflow_graph -
创建主助手:主助手负责路由。它可以将对话委托给上述任何工作流,也可以直接响应用户或使用自己的工具(如通用搜索)。
def primary_assistant(state): # 提示词指示LLM根据用户意图决定是直接响应、使用工具还是委托给某个工作流 ... # LLM可以调用一个名为 `delegate_to_workflow` 的工具来触发路由 -
构建主图:将主助手和所有工作流作为子图集成到一个大图中,并设置正确的路由逻辑。
main_graph = StateGraph(State) main_graph.add_node("primary", primary_assistant) main_graph.add_node("flight_booking", flight_workflow) main_graph.add_node("hotel_booking", hotel_workflow) # ... 添加其他工作流 # 设置从主助手到各工作流的条件边 main_graph.add_conditional_edges("primary", route_to_workflow_or_end) # 设置各工作流完成后的返回边 main_graph.add_edge("flight_booking", "primary") # 假设工作流结束后自动返回
这种架构的优势在于:
- 关注点分离:每个工作流可以独立优化、评估和迭代。
- 一致性:针对常见意图提供可靠、一致的用户体验。
- 上下文共享:所有工作流共享完整的对话历史,用户旅程连贯。
- 灵活性:工作流内部可以是严格的清单流程,也可以是另一个智能代理。
总结 📝
本节课中我们一起学习了构建智能客服聊天机器人的渐进式设计方法。
我们从最简单的零样本工具执行器开始,它具备基础功能但缺乏控制和可靠性。接着,我们通过添加用户确认流程引入了安全护栏。然后,我们通过条件中断和工具分类优化了体验,让代理在安全操作上自主,仅在敏感操作前请求许可。最后,我们引入了专业化工作流(多代理)模式,通过分离关注点来可靠地处理复杂的、特定的用户旅程,同时保持了上下文的共享和LLM的推理优势。
这套设计模式提供了一系列权衡方案,让你能更好地控制用户与产品的交互体验,从而构建出既能在问答任务中提供帮助,又能可靠执行预订、结账等操作的聊天机器人。

教程内容基于 LangGraph 官方文档中的客户支持用例章节。
018:在 LangGraph Cloud 中构建 MemGPT Discord 代理
概述
在本节课中,我们将学习如何使用 LangGraph 构建一个受 MemGPT 论文启发的智能代理。这个代理将具备管理自身记忆的能力,包括核心记忆和语义记忆,并最终将其部署到 Discord 服务器上,使其能够与用户持续互动并学习用户的偏好。
核心概念与准备工作
在开始构建之前,我们需要理解几个核心概念并完成一些准备工作。
核心概念:记忆管理
我们构建的代理将管理两种类型的记忆:
- 核心记忆:关于用户的固定事实列表,例如“用户喜欢游泳”。
- 语义记忆:存储在向量数据库中的对话片段,代理可以根据语义相似性进行检索。

代理的工作流程可以概括为以下步骤:
- 接收用户消息。
- 加载记忆:根据用户ID获取该用户的核心记忆和相关的语义记忆。
- 决策与工具调用:代理决定是否需要使用工具(如网络搜索、保存记忆)来辅助回答。
- 生成响应:代理生成最终的消息回复给用户。
准备工作
在编写代码前,我们需要配置好以下外部服务:
-
向量数据库:我们将使用 Pinecone 来存储和检索语义记忆。
- 登录 Pinecone 控制台,创建一个新的索引(Index)。
- 设置维度为
1536(如果我们使用 OpenAI 的text-embedding-ada-002模型)或768(如果使用nomic-embed-text模型)。 - 记下索引名称和所在区域。
- 创建并保存好 API 密钥。
-
大语言模型:我们将使用 Fireworks AI 提供的 Llama 3 微调模型来驱动代理。
- 前往 Fireworks AI 获取 API 密钥。
-
网络搜索工具:为了让代理能查询实时信息,我们需要一个搜索 API。
- 可以注册 Tavily 等服务来获取搜索 API 密钥。
完成以上配置后,我们就可以开始构建代理了。
项目结构与代码解析
上一节我们介绍了核心概念和准备工作,本节中我们来看看具体的项目代码结构。
我们从 GitHub 上克隆项目仓库到本地。一个典型的 LangGraph Cloud 项目结构包含一个 config.yaml 配置文件,它定义了要暴露的图类型、需要注入的环境变量以及项目依赖。
项目中最关键的文件是 graph.py,它定义了代理的整个逻辑图。
工具定义
在 graph.py 中,我们首先定义了四个与记忆管理相关的工具函数:
# 工具函数示例结构
@tool_node
def save_recall_memory(state):
# 将记忆文本向量化,并附上时间戳等元数据,然后存入Pinecone
pass
@tool_node
def search_memory(state):
# 将查询文本向量化,在Pinecone中搜索相似记忆,并返回结果
pass

@tool_node
def fetch_core_memories(state):
# 直接从数据库(如Redis)中获取当前用户的所有核心记忆
pass

@tool_node
def store_core_memory(state):
# 存储或更新用户的某条核心记忆
pass

以下是这些工具的具体功能:
save_recall_memory: 将对话中的信息作为语义记忆保存到向量数据库。search_memory: 根据用户当前的问题,在向量数据库中检索相关的历史语义记忆。fetch_core_memories: 获取用户的静态核心记忆列表。store_core_memory: 允许代理更新或创建新的核心记忆。
图节点与流程
定义了工具后,我们构建图的主要节点:
load_memories节点:这是图的入口。在将用户消息交给LLM之前,此节点会先根据配置中的用户ID,调用fetch_core_memories和search_memory,获取初始记忆并注入到对话上下文中。agent节点:这是LLM本身。它接收带有记忆的上下文,并决定是直接回复用户,还是调用上述某个工具。route_tools函数:这是一个路由逻辑。它检查LLM的输出,如果包含工具调用,则将状态路由到tools节点;否则,直接生成最终响应。tools节点:这是一个使用@tool_node装饰器预构建的节点。当状态进入此节点时,它会自动执行LLM请求调用的任何工具。

提示词与模型配置
我们为代理编写了系统提示词,鼓励它积极使用记忆工具。提示词中会模板化地插入已获取的记忆内容,并包含当前系统时间,以提供时间上下文。
模型配置在 config.yaml 中完成,默认使用 Fireworks 的 Llama 3 模型,但可以轻松切换为 OpenAI、Anthropic 或 Google 的模型。
现在我们已经了解了代码的核心部分,接下来将其部署到云端。






部署到 LangGraph Cloud
上一节我们分析了代理的代码结构,本节中我们来看看如何将其部署到 LangGraph Cloud 上运行。



- 登录 LangSmith 平台,进入 “Deployments” 页面,点击 “New Deployment”。
- 首次使用需要将 LangGraph Cloud 应用安装到你的 GitHub 账户。授权后,选择包含我们代码的仓库(例如
langchain-memgpt)。 - 创建部署时,需要:
- 为部署命名(如
MemGPT-Agent)。 - 指定配置文件路径(通常是根目录的
config.yaml)。 - 选择部署环境(如
development)。
- 为部署命名(如
- 最关键的一步是添加环境变量。在部署配置界面,添加我们在准备阶段获取的密钥:
PINECONE_API_KEYPINECONE_INDEX_NAMEPINECONE_ENVIRONMENTFIREWORKS_API_KEYTAVILY_API_KEY(如果启用搜索功能)
- 提交后,LangGraph Cloud 会自动构建 Docker 镜像并部署。这个过程通常需要几分钟。如果失败,可以查看构建日志来排查问题,常见问题包括环境变量错误或依赖缺失。
部署成功后,我们可以直接在 LangGraph Cloud 提供的 Playground 中进行测试和调试。在 Playground 中,我们可以设置 user_id,模拟不同用户的对话,并可视化地观察整个图的执行流程和状态变化。


集成 Discord 机器人
代理在云端运行良好,但为了能让最终用户方便地使用,我们需要一个交互界面。本节我们将把代理集成到 Discord 机器人中。
我们使用项目仓库中提供的 advanced_server 代码来搭建 Discord 机器人服务端。
创建 Discord 机器人
- 访问 Discord 开发者门户,创建一个新的应用(Application),例如命名为 “My Memory Bot”。
- 进入该应用的 “Bot” 设置页面:
- 重置令牌(Token)并妥善保存,这是机器人连接 Discord 的密码。
- 在 “Privileged Gateway Intents” 下,开启
MESSAGE CONTENT INTENT,否则机器人无法读取消息内容。
- 进入 “OAuth2” -> “URL Generator” 页面:
- 在 Scopes 中选择
bot。 - 在 Bot Permissions 中勾选
Send Messages,Read Message History等所需权限。 - 生成邀请链接,用此链接将机器人添加到你的 Discord 服务器。
- 在 Scopes 中选择
配置并运行服务器
- 在
advanced_server目录下,创建或修改.env文件,填入必要的配置:DISCORD_TOKEN: 刚才保存的机器人令牌。ASSISTANT_URL: 你在 LangGraph Cloud 上部署的代理的 API URL。
- 此服务器代码使用 Google Cloud Run 进行部署。你需要:
- 安装 Google Cloud SDK 和 Docker。
- 在 Google Cloud 上创建一个项目,并启用 Cloud Run、Cloud Build 等必要 API。
- 为 Cloud Build 服务账户分配适当的 IAM 权限,使其能够构建和部署。
- 运行项目提供的部署脚本(如
deploy_server.sh),脚本会自动构建镜像并部署到 Cloud Run。

部署完成后,你的 Discord 机器人就上线了。在服务器中 @机器人 或与其私聊,它会将消息转发给 LangGraph Cloud 上的代理处理,并将回复传回 Discord。由于代理记住了 user_id(来自 Discord 用户ID),它能跨对话线程维护和更新每个用户的记忆。


测试与评估
构建并部署了功能完整的代理后,确保其可靠运行至关重要。本节我们将探讨如何为这类基于LLM的代理编写测试。


测试AI代理可能具有挑战性,但采用务实的方法非常有效:即使是从简单的断言开始,也比没有测试要好。使用 LangSmith 这样的工具可以极大地帮助调试和定位问题。
我们在项目中提供了一个示例测试文件 test_memories.py。它的基本思路是:
- 模拟对话:编写一系列测试用例,模拟用户与代理的交互。
- 预设记忆:在测试开始时,为模拟用户注入一些初始的核心记忆或语义记忆,观察代理如何利用它们。
- 断言验证:对代理的行为和输出进行断言。例如,在对话后,检查向量数据库中是否保存了预期数量的新记忆,或者检查代理的回复是否包含了预期的信息。
- 使用 LangSmith:通过 LangSmith 的测试装饰器,将测试运行与 LangSmith 数据集同步。这样,每次运行测试都会生成一个可追溯的链路(Trace),方便对比不同版本代理的表现,并在测试失败时深入调试每一步的输入输出。

通过运行 pytest 命令执行这些测试,我们可以持续保障代理核心逻辑的稳定性,就像测试传统软件一样。
总结
在本节课中,我们一起学习了如何使用 LangGraph 构建一个具备长期记忆能力的智能代理。我们从理解 MemGPT 的核心思想出发,逐步完成了以下工作:

- 配置基础服务:设置了 Pinecone 向量数据库和 Fireworks AI 的 LLM。
- 构建代理图:定义了记忆管理工具,并组合成包含记忆加载、LLM推理和工具调用的工作流。
- 云端部署:将代理成功部署到 LangGraph Cloud,并利用其 Playground 进行调试。
- 集成前端:创建并配置了 Discord 机器人,将其与云端代理连接,实现了可交互的产品。
- 测试保障:介绍了如何使用 pytest 和 LangSmith 为代理编写和运行测试,确保其行为符合预期。

最终,我们得到了一个可以部署在 Discord 上、能够记住用户偏好并在多次对话中持续学习的智能助手。你可以 Fork 项目仓库,尝试修改提示词、添加新工具或更换模型,来构建属于你自己的个性化代理。
019:为LangGraph记忆代理添加语义搜索 🧠
在本节课中,我们将学习如何为LangGraph中的长期记忆代理添加语义搜索功能。语义搜索允许你跨对话线程搜索相似的记忆,从而创建更具个性化和更有效的应用程序。
概述
首先进行简要回顾。最基本的记忆类型是工作对话记忆,这由LangGraph的检查点提供,并且每个LangGraph实例都自带此功能。这使得AI助手能够在单个对话中保持连续性。
几个月前,我们在整个LangGraph中发布了基础存储抽象。这提供了一个灵活且模块化的文档存储,让你可以按任意组织方式保存记忆和文档,并在对话间共享信息。作为该版本的一部分,我们创建了记忆代理模板。
记忆代理模板解析
记忆代理模板定义了一个简单的代理,它在两个位置使用存储。
第一个位置是存储记忆工具。该工具允许它将任何字符串内容写入基础存储,以便在对话间持久化。
基础存储的第二个用途是在调用模型节点本身。在调用大语言模型之前,我们从基础存储中获取记忆,将其格式化为持久提示,然后调用大语言模型。这样,它就能够结合当前特定对话的上下文以及它认为足够重要、需要跨对话保存和持久化的长期信息来做出回应。
让我给你一个快速示例来说明我的意思。
我可以告诉代理我喜欢牛角包和蔬菜,并且我住在旧金山。代理随后可以决定调用工具来保存信息。它使用该工具存储记忆,然后代理可以选择回应。之后,我开始一个新的对话。代理能够回忆起这些信息并将其用于建议。你可以看到它回忆起我住在旧金山,并且喜欢牛角包和蔬菜。
你可以点击LangSmith跟踪来确认。在这里,我们确实看到我们成功地将刚刚保存的记忆信息模板化到了系统提示中。你也可以随时通过导航到记忆面板来查看所有已保存的记忆,并查看代理在那里保存的确切内容。
引入语义搜索的必要性
这一切都很好,但是当你保存了太多记忆时会发生什么?很容易陷入精确率-召回率曲线的任一端:要么你在系统提示中放入太多记忆,导致不精确、不相关,并且容易分散大语言模型的注意力;要么你丢弃了太多记忆,从而无法获得上下文最相关的信息。
语义搜索让你能够基于与用户查询的语义相似性或向量相似性来查询记忆。这些相似性分数可以帮助你从大量记忆语料库中获取你认为最合适的信息。
在接下来的内容中,我将展示分步说明,教你如何为任何LangGraph应用中的长期记忆添加语义搜索。
实施步骤
以下是实施语义搜索的具体步骤。
首先,你需要使用LangChain CLI在本地克隆记忆代理模板。我们将在此处使用Python版本。
langchain app new my-app --package memory-agent

接下来,进入目录。

cd my-app
创建一个虚拟环境,安装依赖项,设置环境变量,并安装服务器。

python -m venv .venv
source .venv/bin/activate # 在Windows上使用 `.venv\Scripts\activate`
pip install -r requirements.txt
如果你正确安装了所有内容,它将带你进入我们之前看到的Studio UI。
配置语义搜索

要添加语义搜索支持,我们必须更改配置文件。如果你在此模板中打开 langgraph.json 文件,你会注意到常见的配置项:指向图中路径定义的代理的指针、我们定义的环境,以及其他一些内容。
我们在这里配置存储,并说明通过索引支持语义搜索。我们提供了使用哪个嵌入模型的配置,并且还指定了它的维度。维度允许我们创建用于存储向量的表。
代码实现解析
根据我之前的描述,我们在两个位置使用存储。

第一个位置是在调用模型节点,我们在其中搜索相关记忆并将其放入系统提示中。
第二个位置是在存储记忆工具中,该工具用于将新信息存储到存储中。
让我们看看这些在代码中是如何定义的。首先,我们查看调用模型节点。
你会注意到,在这里我们获取了由LangGraph自动注入的存储。我们在这个配置的用户ID命名空间内进行搜索,以便将我们代理的不同用户的记忆分开。然后,我们使用最近的消息来形成搜索查询,并使用它来通过语义相似性获取10个最相似的记忆。
这些记忆随后被放入系统提示中,然后我们在这里调用大语言模型。
如果我们跳转到工具文件,我们可以看到这个“观察记忆”的工具定义。大语言模型将填充内容和上下文信息,这将被存储在数据库中。
这里的记忆ID是为了让我们能够在需要时更新旧的记忆,例如当我们想要添加能进一步提供上下文信息的额外内容时。然后,我们使用每个LangGraph部署上都可用的相同基础存储来存储这些记忆。同样,通过存储在此命名空间内的记忆ID来区分不同用户的信息,我们将所有值存储在一个字典中。

进一步学习
关于LangGraph中的记忆以及如何在你的代理中实现语义搜索的更多信息,我建议查阅文档。我们有几个关于如何为长期记忆添加语义搜索的指南,展示了如何在几种不同场景中使用它,包括在创建反应代理中,以及一些关于如何将其添加到你的部署中的信息。
这将引导你完成类似的步骤,让你像本视频中一样快速上手,包括关于如何使用自定义嵌入的额外信息。
如果你想更进一步,我建议查看基础存储的参考文档。这可以帮助你更好地理解和使用LangGraph基础存储的不同参数,包括诸如索引之类的功能,以便在将项目插入存储时禁用其索引,或者自定义哪些字段将用于多向量检索。
总结

本节课中,我们一起学习了如何为LangGraph记忆代理集成语义搜索功能。我们从回顾基础记忆类型和记忆代理模板开始,理解了语义搜索在管理大量记忆时的必要性。接着,我们逐步完成了从克隆模板、配置环境到修改代码实现语义搜索索引和查询的全过程。通过将用户查询与记忆库进行向量相似性匹配,代理现在能够更精准地召回相关信息,从而提供更个性化和有效的回应。
020:在 LangGraph 中实现基于语义搜索的智能记忆
在本节课中,我们将学习如何为聊天机器人构建一个由语义搜索驱动的长期记忆系统。这将使它能够记住我们与它进行的数千次对话中的事件和重要细节。课程结束时,你将构建一个能够利用个性化信息提供出色推荐的聊天机器人。
概述
正如你所见,这个聊天机器人记得我喜欢辛辣食物并住在旧金山。这种记忆能力是通过我们为 LangGraph 的 BaseStore API 新增的语义搜索功能实现的。让我们看看它是如何工作的。
核心概念与组件
实现语义搜索的两个关键要素是:一个存储(例如本例中在笔记本中临时运行的存储)和用于嵌入待保存信息的嵌入模型。
以下是初始化存储的代码示例:
# 初始化存储,指定索引配置和嵌入维度
store = BaseStore(index_config=IndexConfig(dimensions=1536))
接下来,我们将存入一些信息。这个过程会将文档放入 BaseStore 并为后续搜索建立索引。
然后,你可以使用自然语言查询来搜索它们,如下所示:

# 使用自然语言查询进行搜索
results = store.search(query="意大利食物")

搜索结果会按相似度排序,例如“意大利食物”与“食物”最相似,“披萨”也相似,“水管工”则不那么相似。
要在你的 LangGraph 智能体中使用此功能,只需将 store 参数添加到图中的任何节点。当你编译图时提供的存储,就可以从智能体的任何节点访问。
构建端到端应用

上一节我们介绍了核心概念,本节中我们来看看如何将其应用于一个完整的应用程序。
首先,打开终端并使用 LangGraph CLI 克隆记忆智能体模板。进入仓库目录,安装依赖,然后启动服务。
这将启动我们在本教程开头看到的应用程序。如果一切设置正确,你将拥有一个能够使用工具保存记忆并在后续对话中回忆它们的智能体。
让我们查看代码,了解如何为 LangGraph 平台进行设置。回想一下,我们需要三个要素:存储配置、嵌入配置以及在图中使用它。
1. 存储配置

在部署到 LangGraph 平台时,我们在 langgraph.json 文件中定义大部分配置。这里指向我们实际的图实现、智能体实现环境以及其他依赖信息。

最后,我们有这个存储配置。如果我提供一个带有嵌入实例的索引,这将允许我们指示 LangGraph 平台为向量搜索准备数据库,从而为我们的存储添加语义搜索。这里我使用简洁的语法指定我将使用 OpenAI 的 text-embedding-3-small 模型。这需要 LangChain 来使用。或者,我可以将其指向一个自定义文件,以便将嵌入定义为一个自定义函数。
2. 在图中使用存储
我们已经介绍了前两个要素,设置了存储配置并添加了嵌入。现在只需要在图中使用它。
从应用程序的可视化中回想,我们的图有两个主要节点:
- 调用模型节点:用迄今为止的对话信息提示大语言模型。我们添加了一个
store.search方法来搜索相关内容,并将其模板化到发送给大语言模型的提示中。 - 存储记忆节点:我们为大语言模型提供了一个工具。如果对话中出现了它希望为以后保存的重要事实,它可以选择性地调用该工具来保存信息。这将在
store_memory节点中执行。
让我们看看代码中的样子。我们有一个 graph.py 文件,定义了主要节点以及实际的图。


首先看 call_model 节点。你会看到我们从上下文中获取最新的消息并将其格式化为查询。这是一种简单快捷的方法,用于查找与当前对话内容语义相似的内容。这些记忆被获取,如果有任何匹配项,它们将被格式化为字符串,然后传递给我们将要调用的模型。
在 call_model 节点之后,有一个条件边。messages 路由决定我们是否有任何工具调用,这将转到 store_memory 节点,否则将直接返回给用户。
store_memory 节点调用我们提供给大语言模型的工具。这个工具也以不同的方式使用存储。大语言模型填充 content 和 context 参数,这两者都将作为文档存储。
LangGraph 提供关于存储以及配置的信息。我们按用户 ID 分隔记忆,这样大语言模型就不会混淆来自不同用户的存储信息。我们用唯一标识符保存它们,这个标识符用于让大语言模型在认为合适时更新现有记忆。
默认情况下,这些记忆将根据我在 langgraph.json 文件中定义的任何配置建立索引,默认是整个对象本身,用美元符号 $ 表示。或者,我可以为多向量搜索向嵌入添加特定字段。我也可以在插入新记忆时动态指定这些。这告诉 LangGraph 仅为此对象内的 content 字段生成嵌入。
如果我想指定不希望某个对象被索引以供搜索(也许我只是不希望它在按语义相似度搜索值时出现在列表前列),我可以指定 index=False。
请注意,如果我们没有满足设置语义搜索的前两个要素(即带有嵌入的索引配置),所有这些参数都将被忽略。每个对象都将被存入存储,但无法通过查询检索。所有记忆仍将插入存储,但不会提供嵌入,因此我们无法通过自然语言查询进行搜索。
总结
本节课中我们一起学习了在 LangGraph 中启用语义搜索所需的三个要素:
- 一个存储:在任何 LangGraph 平台部署中默认可用。
- 带有嵌入的索引配置:指定你希望索引所有存入的信息。
- 修改你的节点:使其接受 BaseStore,以便你可以随时使用它来存入和搜索信息。

如果你想深入了解,建议查阅官方文档。我们有关于如何为长期记忆添加语义搜索的文档,展示了如何在 LangGraph 开源版本中实现。还有一个关于如何在 LangGraph 平台中配置的示例,它引导你完成与本视频类似的步骤,并展示了如何指定自定义嵌入(如果你想使用自定义函数的嵌入)。最后,BaseStore 参考文档也提供了更多详细信息,如果你想了解更多关于索引参数的含义及其行为的信息。

目前,语义搜索在内存存储以及随每个 LangGraph 部署提供的 PostgreSQL 存储中得到支持。
如果你有任何问题或意见,请在下方留言,或在 LangGraph 代码仓库中提出问题或发起讨论。再次感谢,下次见。
021:为 LangGraph 添加自定义身份验证 🔐
在本教程中,我们将学习如何为 LangGraph 应用程序添加自定义的身份验证和访问控制功能。我们将分三步进行:首先实现基础的用户认证,然后添加资源级别的授权,最后集成一个真实的生产级身份验证服务。
概述
LangGraph 平台现已支持自定义身份验证和访问控制。此功能适用于 LangGraph Cloud 和自托管部署。它允许你直接与自己的身份验证服务集成,并在 LangGraph 应用程序中实现自定义权限模型,而无需依赖单独的后端服务器。
我们将通过三个部分来演示如何为现有 LangGraph 应用添加 OAuth 功能。
第一部分:实现用户身份验证
上一节我们介绍了本教程的目标,本节中我们来看看如何实现基础的身份验证。身份验证会检查每个请求,确保其拥有访问服务所需的凭证,然后返回一个用户对象,供后续进行细粒度控制时使用。

首先,我们需要安装 LangGraph 命令行界面并克隆一个新项目。

# 安装 LangGraph CLI 并克隆项目
pip install langgraph-cli
langgraph clone <project-name>
克隆项目后,我们可以启动服务器查看其初始状态。安装项目依赖并以可编辑模式运行。
cd <project-name>
pip install -e .
langgraph dev
启动后,你可以测试应用,目前它只是简单地回显响应。这个图的内容并不重要,重要的是我们今天学习的技能可以应用于任何 LangGraph 平台应用。
为了让 LangGraph 知道调用哪个函数来验证每个请求,我们需要使用从 LangGraph SDK 导出的 auth 对象。我们将初始化这个对象,并用它来装饰一个自定义函数,告诉 LangGraph 此函数应用于身份验证。
我们的函数将接受一个 Authorization 请求头,这是一个在许多验证方案中常见的头部。我们将断言该头部存在,然后检查令牌是否存在于我们的“玩具”数据库中。最后,我们将获取用户信息并返回,这些信息在本教程后续部分会很有用。
以下是实现此功能的代码:


# security/authentication.py
from langgraph_sdk import Auth
auth = Auth()
@auth.authenticate
async def authenticate(authorization: str | None):
# 1. 断言 Authorization 头部存在
if authorization is None:
raise HTTPException(status_code=401, detail="Missing authorization header")
# 2. 从头部提取令牌(假设格式为 "Bearer <token>")
try:
scheme, token = authorization.split()
if scheme.lower() != "bearer":
raise ValueError
except ValueError:
raise HTTPException(status_code=401, detail="Invalid authorization header format")
# 3. 检查令牌是否在“玩具”数据库中
# 这里使用一个简单的字典模拟用户数据库
user_db = {
"super_secret_user_1_token": {"user_id": "user_1", "name": "Alice"},
"super_secret_user_2_token": {"user_id": "user_2", "name": "Bob"},
}
if token not in user_db:
raise HTTPException(status_code=401, detail="Invalid token")
# 4. 返回用户信息
return user_db[token]
现在,我们需要让服务器知道这个 auth 对象的位置。通过修改 langgraph.json 配置文件来实现。


// langgraph.json
{
"auth": {
"file": "./security/authentication.py",
"variable": "auth"
}
// ... 其他配置
}
配置完成后,让我们测试我们的实现。我们将创建客户端代码来连接服务器。
首先,创建一个没有有效凭证的客户端,该客户端将无法访问服务。然后,使用正确的请求头重新创建客户端,再次尝试操作,应该会成功。
以下是测试代码示例:
# test_authentication.py
import asyncio
from langgraph_sdk import LangGraphClient
async def test_auth():
# 测试 1: 无凭证客户端应被阻止
unauthorized_client = LangGraphClient(base_url="http://localhost:8123")
try:
await unauthorized_client.threads.create()
print("FAIL: Unauthorized request succeeded")
except Exception:
print("PASS: Unauthorized request correctly blocked")
# 测试 2: 有凭证客户端应成功
authorized_client = LangGraphClient(
base_url="http://localhost:8123",
headers={"Authorization": "Bearer super_secret_user_1_token"}
)
try:
thread = await authorized_client.threads.create()
print(f"PASS: Successfully created thread {thread.id}")
# 测试创建运行
run = await authorized_client.runs.create(thread_id=thread.id, assistant_id="some_assistant")
print(f"PASS: Successfully created run {run.id}")
except Exception as e:
print(f"FAIL: Authorized request failed: {e}")
if __name__ == "__main__":
asyncio.run(test_auth())
运行测试,如果实现正确,我们将看到所有测试通过。未经身份验证的客户端被服务器阻止,而有凭证的客户端能够成功创建线程和运行。
第一部分总结:我们学习了如何使用 LangGraph 的 auth 对象来拦截未经身份验证的请求。我们创建了一个身份验证函数,将其导出,并在 langgraph.json 文件中配置了服务器。然而,目前任何通过身份验证的用户都可以访问任何资源。在下一节中,我们将学习如何创建授权处理程序来过滤资源并使对话私有化。
第二部分:添加授权处理程序
上一节我们实现了基础身份验证,本节中我们来看看如何添加授权处理程序,以防止已认证用户访问彼此的数据。
在 Part 1 中,我们使用 auth 对象并通过 @auth.authenticate 装饰一个函数来添加身份验证。要添加授权,我们使用事件处理程序,通过 @auth.on() 装饰器来实现。
这意味着在需要对任何资源进行创建、读取或其他操作时,将调用此函数来检查用户是否有权访问该资源。这些函数接收两个参数:
- 认证上下文:包含用户信息以及正在执行的操作。
- 值:实际发送给此操作的数据。对于创建事件,它包含将要保存到数据库的实际数据;对于读取和搜索事件,它只包含搜索或检索信息所需的参数。
每个授权处理程序最多有三项任务:
- 创建过滤器:返回过滤器让系统节点只返回元数据匹配的资源。例如,只返回元数据中
owner键与用户身份匹配的数据。 - 处理创建和更新事件:我们可以添加包含用户身份的元数据,以便保存到数据库。如果不这样做,上述过滤器将永远无法匹配任何内容。
- 直接拒绝请求:在某些条件下直接拒绝访问。


在讨论第三点之前,让我们先将此处理程序添加到服务中。我们将复制以下处理程序并将其添加到现有的授权文件中。
# security/authorization.py (续接之前的 authentication.py)
from langgraph_sdk import Auth
auth = Auth() # 假设 auth 对象已在此文件中定义
@auth.on("*") # 全局处理程序,匹配所有资源的所有操作
async def authorize_all(context, value):
user_id = context.user.get("user_id")
if not user_id:
# 如果用户信息中没有 user_id,拒绝请求(虽然身份验证已通过,但这是安全后备)
raise HTTPException(status_code=403, detail="User identity not found")
action = context.action # 例如:'create', 'read', 'search', 'update'
resource_type = context.resource # 例如:'threads', 'assistants', 'runs'
# 任务 1: 为读取和搜索操作返回过滤器
if action in ["read", "search"]:
# 只允许用户访问自己拥有的资源
return {"filters": {"metadata": {"owner": user_id}}}
# 任务 2: 为创建和更新操作添加所有者元数据
if action in ["create", "update"]:
# 确保 value 是字典且包含 metadata 字段
if not isinstance(value, dict):
value = {}
if "metadata" not in value:
value["metadata"] = {}
# 将所有者信息注入元数据
value["metadata"]["owner"] = user_id
# 返回修改后的值,系统会使用它来保存
return {"value": value}
# 默认情况下,允许操作继续(不返回任何内容意味着通过)
我们添加了这个处理程序,并保持文件的其余部分不变。重启服务后,我们可以运行测试。
对于这些测试,我们将创建两个不同的客户端,一个给 Alice,一个给 Bob。
- 首先检查 Alice 是否可以创建助手、线程和运行。她应该能与服务交互。
- 接下来,检查 Bob 是否无法获取 Alice 刚创建的线程信息。如果授权实现正确,他将无法访问此线程。
- 最后,显示 Bob 仍然可以访问服务,只要他使用自己的信息。我们将列出线程以显示每个用户只能看到自己拥有的数据。
以下是测试代码:
# test_authorization.py
import asyncio
from langgraph_sdk import LangGraphClient
async def test_authorization():
# 创建 Alice 的客户端
client_alice = LangGraphClient(
base_url="http://localhost:8123",
headers={"Authorization": "Bearer super_secret_user_1_token"} # Alice
)
# 创建 Bob 的客户端
client_bob = LangGraphClient(
base_url="http://localhost:8123",
headers={"Authorization": "Bearer super_secret_user_2_token"} # Bob
)
# 测试 1: Alice 创建资源
try:
assistant = await client_alice.assistants.create(name="Alice's Assistant")
thread_alice = await client_alice.threads.create()
run = await client_alice.runs.create(thread_id=thread_alice.id, assistant_id=assistant.id)
print("PASS: Alice successfully created resources.")
except Exception as e:
print(f"FAIL: Alice failed to create resources: {e}")
# 测试 2: Bob 无法读取 Alice 的线程
try:
fetched_thread = await client_bob.threads.read(thread_id=thread_alice.id)
print(f"FAIL: Bob should not have access to Alice's thread, but got: {fetched_thread.id}")
except Exception as e:
if "403" in str(e) or "not found" in str(e).lower():
print("PASS: Bob correctly denied access to Alice's thread.")
else:
print(f"Unexpected error for Bob: {e}")
# 测试 3: Bob 创建自己的线程
try:
thread_bob = await client_bob.threads.create()
print(f"PASS: Bob successfully created his own thread: {thread_bob.id}")
except Exception as e:
print(f"FAIL: Bob failed to create his own thread: {e}")
# 测试 4: 双方只能看到自己的线程
threads_alice = await client_alice.threads.search()
threads_bob = await client_bob.threads.search()
print(f"Alice sees {len(threads_alice.data)} thread(s).")
print(f"Bob sees {len(threads_bob.data)} thread(s).")
# 可选:验证线程 ID 不同
if len(threads_alice.data) == 1 and len(threads_bob.data) == 1:
if threads_alice.data[0].id != threads_bob.data[0].id:
print("PASS: Alice and Bob see distinct threads.")
else:
print("FAIL: Alice and Bob see the same thread ID.")
else:
print("Check thread counts.")
if __name__ == "__main__":
asyncio.run(test_authorization())
运行测试,应该会再次看到所有绿色对勾。Alice 能够创建资源,Bob 无法访问 Alice 的线程,Bob 创建了自己的线程,并且双方都只看到一个线程。
创建这个授权处理程序很简洁。但如果你想根据资源有不同的行为,或者想更清楚地了解 value 字典的内容以获得更多控制权呢?你可以定义作用于单个资源,甚至该资源上特定操作的处理程序。value 的类型有对应的路径,因此你知道期望哪些键。
为了说明作用域授权处理程序,我们将创建三个新函数:
- 第一个函数匹配任何助手操作,并决定拒绝所有请求。这将应用于每个创建、读取、搜索和更新事件。
- 第二个函数将应用于线程的任何读取事件。由于我们只是读取而不写入,我们可以只返回过滤器,而无需尝试修改任何元数据(修改会被忽略)。
- 第三个处理程序将匹配线程资源上的任何创建事件。此代码在功能上与我们之前的全局处理程序相同。
通过创建更具作用域的授权处理程序,你可以对部署中实施的访问策略进行更细粒度的控制。
你可能会想知道,如果有两个处理程序都匹配一个操作会发生什么。在这种情况下,LangGraph 只会为给定操作调用最具体的处理程序。这意味着,在线程创建事件上,我们特定的处理程序将被调用,而不是全局处理程序。对于其他操作也是如此。如果我们定义了一个只作用于线程级别的函数,那么它将只被搜索和更新事件调用,而不被读取和创建事件调用,因为后者有更具体的处理程序。LangGraph 不允许注册两个具有完全相同作用域的函数。

为了完成第二部分,我将把这些作用域处理程序添加到我们的 auth 文件中,然后运行相应的测试。

# security/authorization.py (添加作用域处理程序)
@auth.on("assistants.*") # 匹配助手资源的所有操作
async def authorize_assistants(context, value):
# 拒绝所有对助手的访问
raise HTTPException(status_code=403, detail="Access to assistants is forbidden for all users.")
@auth.on("threads.read")
async def authorize_threads_read(context, value):
# 只允许读取用户自己拥有的线程
user_id = context.user.get("user_id")
return {"filters": {"metadata": {"owner": user_id}}}
@auth.on("threads.create")
async def authorize_threads_create(context, value):
# 为创建的线程注入所有者信息
user_id = context.user.get("user_id")
if not isinstance(value, dict):
value = {}
if "metadata" not in value:
value["metadata"] = {}
value["metadata"]["owner"] = user_id
return {"value": value}
重启服务器并查看新测试。现在,我们主要测试的是,由于我们添加了特定的作用域处理程序,没有人可以创建或搜索助手。最后,我们将确认用户仍然能够访问线程资源。
第二部分总结:在本例中,我们添加了授权处理程序,以确保用户只能访问他们拥有的资源。我们还实现了更具作用域的处理程序,以在不同资源上实施更定制化和限制性的策略。现在,你已掌握在服务中实现身份验证和授权所需的所有技能。在本教程的第三部分,我们将用真实的生产级身份验证服务替换我们的玩具用户数据库,以展示如何为生产应用程序管理用户。
第三部分:集成生产级身份验证服务
上一节我们实现了应用内的授权,本节中我们来看看如何连接到一个真实的外部身份验证服务。我们将使用 Supabase 来演示,它让起步变得容易。如果你要跟着操作,请确保你有一个活跃的 Supabase 账户并创建了新项目。
回顾本视频开头,我们的身份验证服务有三个主要组件:
- 授权服务器:在我们的案例中是 Supabase。它负责生成用户令牌给客户端,并由后端服务器验证。
- 应用后端:在我们的案例中就是 LangGraph 应用程序。这是你所有 AI 业务逻辑所需的一切。
- 客户端应用程序:通常是让用户访问你服务的 Web 或移动应用。
我们将实现的流程大致如下:
- 用户的客户端发起登录。
- 他们将有效的凭证发送到身份验证服务器,服务器将响应一个签名令牌。
- 客户端随后在其向我们的应用后端发出的每个请求的头部中包含此令牌。
- 我们的后端将查看该令牌,并根据身份验证服务器验证它。
- 如果有效,则继续处理,可以返回对话数据或调用你的应用逻辑。
- 如果令牌无效,我们的身份验证处理程序将在任何其他处理之前拒绝该请求。
在更新处理程序之前,让我们确保拥有连接服务器所需的信息。我们的服务器需要两条信息:你项目的 URL 和用于连接它的密钥。我们的客户端则需要访问此 URL 和一个公钥。公钥让客户端能够安全地生成访问你服务的凭证。
你可以在 Supabase 项目的设置中找到这些信息。进入项目设置,点击“API”。这些设置包含你的项目 URL、公钥和私有的服务角色 JWT 密钥。将这些信息从仪表板复制到本地的 .env 文件中。
# .env
SUPABASE_URL=your_supabase_project_url
SUPABASE_ANON_KEY=your_supabase_anon_public_key
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_secret_jwt
现在是将身份验证服务器集成进来的时候了。回顾 Part 1 和 Part 2,我们使用 LangGraph 的 auth 对象来装饰一个验证用户声明的身份验证函数。为了集成身份验证服务器,我们只需要修改这个 authenticate 函数。
我们将通过连接到 Supabase 后端并获取用户信息来实现。如果请求成功,我们可以返回该信息。否则,我们知道服务器已拒绝该请求。为了避免在此处进行 API 调用,你可以改为使用项目设置中的 JWT 密钥来验证用户的持有者令牌,或者至少缓存请求以避免重复调用。
让我们复制这个新的身份验证处理程序并将其添加到我们的应用程序中。我们所有的授权处理程序可以保持不变。
# security/authentication_prod.py (更新后的身份验证)
import os
from supabase import create_client, Client
from langgraph_sdk import Auth
from fastapi import HTTPException

auth = Auth()

# 从环境变量加载 Supabase 配置
SUPABASE_URL = os.getenv("SUPABASE_URL")
SUPABASE_SERVICE_ROLE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
supabase: Client = create_client(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY)
@auth.authenticate
async def authenticate_production(authorization: str | None):
if authorization is None:
raise HTTPException(status_code=401, detail="Missing authorization header")
try:
scheme, token = authorization.split()
if scheme.lower() != "bearer":
raise ValueError
except ValueError:
raise HTTPException(status_code=401, detail="Invalid authorization header format")
# 使用 Supabase 管理 API 验证 JWT 并获取用户信息
try:
# 注意:Supabase Python 客户端可能需要使用 admin 方法。
# 这里假设使用 `auth.get_user` 方法,具体方法请参考 Supabase 文档。
# 以下为示例逻辑:
response = supabase.auth.get_user(jwt=token)
user = response.user
if user is None:
raise HTTPException(status_code=401, detail="Invalid or expired token")
# 返回用户信息,格式与之前玩具数据库一致
return {
"user_id": user.id,
"email": user.email,
# 可以添加其他所需字段
}
except Exception as e:
# 捕获 Supabase 客户端或网络错误
print(f"Auth validation error: {e}")
raise HTTPException(status_code=401, detail="Failed to validate authentication")
更新 langgraph.json 以指向新的身份验证文件。
// langgraph.json
{
"auth": {
"file": "./security/authentication_prod.py",
"variable": "auth"
}
// ... 其他配置
}
重启服务器进行测试。我们的客户端代码比 Part 1 和 Part 2 更复杂,因为我们需要连接到真实的身份验证服务器。
首先,创建两个用户。你可以在笔记本中运行代码,并提供你可以访问的有效电子邮件,然后包含来自 .env 文件的 Supabase 项目 URL 以及你的公钥。记住,你可以从项目设置的 API 设置中获取你的匿名密钥。在继续之前,你必须检查你的电子邮件并确认账户,否则 Supabase 将拒绝进一步的登录尝试。
以下是创建用户和登录的示例客户端代码:


# test_production_auth.py
import asyncio
import os
from supabase import create_client
from langgraph_sdk import LangGraphClient
SUPABASE_URL = os.getenv("SUPABASE_URL")
SUPABASE_ANON_KEY = os.getenv("SUPABASE_ANON_KEY")
async def test_production_auth():
# 初始化 Supabase 客户端(用于客户端操作)
supabase_client = create_client(SUPABASE_URL, SUPABASE_ANON_KEY)
# 1. 创建用户(仅第一次运行需要,之后可以注释掉)
email_user1 = "alice@example.com"
password = "secure_password_123"
try:
# 注意:Supabase 可能需要先配置电子邮件模板或禁用电子邮件确认才能直接登录。
# 以下为示例,生产环境请妥善处理用户注册流程。
auth_response = supabase_client.auth.sign_up({
"email": email_user1,
"password": password,
})
print(f"User created (or exists): {auth_response.user.email}")
# 重要:检查邮箱并点击确认链接!
except Exception as e:
print(f"Sign up might have failed (user may exist): {e}")
# 2. 登录获取令牌
try:
sign_in_response = supabase_client.auth.sign_in_with_password({
"email": email_user1,
"password": password
})
access_token = sign_in_response.session.access_token
print("Successfully obtained access token.")
except Exception as e:
print(f"Login failed: {e}. Ensure email is confirmed.")
return
# 3. 使用令牌创建 LangGraph 客户端
client_user1 = LangGraphClient(
base_url="http://localhost:8123",
headers={"Authorization": f"Bearer {access_token}"}
)
# 4. 测试用户能否创建线程
try:
thread = await client_user1.threads.create()
print(f"PASS: User 1 successfully created thread {thread.id}")
except Exception as e:
print(f"FAIL: User 1 failed to create thread: {e}")
# 5. 测试未经验证的用户(使用无效令牌)无法访问
unauthorized_client = LangGraphClient(
base_url="http://localhost:8123",
headers={"Authorization": "Bearer invalid_token"}
)
try:
await unauthorized_client.threads.read(thread_id=thread.id)
print("FAIL: Unauthorized user accessed thread")
except Exception as e:
if "401" in str(e) or "403" in str(e):
print("PASS: Unauthorized user correctly blocked.")
else:
print(f"Unexpected error for unauthorized user: {e}")
# 6. (可选)创建第二个用户并测试数据隔离
# ... 类似之前的测试,使用不同的邮箱/密码登录获取新令牌
if __name__ == "__main__":
asyncio.run(test_production_auth())
运行代码以确认一切是否按预期实现。你应该看到测试通过的绿色对勾,确认我们的身份验证服务按预期工作。
第三部分总结:在本节中,你成功设置了身份验证提供程序。你添加了具有电子邮件和密码身份验证的真实用户账户。你将 JWT 令牌验证集成到 LangGraph 服务器中,并实施了适当的授权以确保用户只能访问自己的数据。这些都是创建基于 LangGraph 的全栈应用程序所需的全部技能。
课程总结
在本节课中,我们一起学习了如何为 LangGraph 应用程序逐步添加完整的身份验证和授权功能。
- 第一部分:我们实现了基础的身份验证,使用
@auth.authenticate装饰器来验证用户凭证,并拦截未经授权的请求。 - 第二部分:我们引入了授权机制,使用
@auth.on()装饰器创建处理程序,实现了资源级别的访问控制,确保用户数据隔离。我们还探讨了全局和作用域处理程序的用法与优先级。 - 第三部分:我们将应用与 Supabase 生产级身份验证服务集成,演示了如何验证 JWT 令牌,从而构建一个安全、可扩展的全栈 AI 应用。

你现在已经掌握了在 LangGraph 平台实施自定义身份验证和访问控制的核心技能。要了解更多,你可以尝试使用 LangGraph 平台教程自己完成所有这些操作,也可以查看我们关于在 LangGraph 中实现身份验证和访问控制的概念指南,并查阅 LangGraph SDK 参考以获取有关 auth 对象及相关方法的所有详细信息。请继续关注更多关于如何在 LangGraph 平台中实现常见身份验证和访问控制模式的指南。下次见!
022:LangGraph 功能 API 概览 🚀
在本节课中,我们将学习 LangGraph 功能 API 的核心概念。我们将从一个简单的、不使用任何框架的 Python 智能体开始,逐步引入 LangGraph 的功能,展示如何通过添加少量装饰器代码,为你的应用获得持久化、流式处理、调试和部署等强大能力。
概述 📋
LangGraph 是一个用于构建 AI 智能体的框架。它的主要优势在于提供了持久化、流式处理、调试和部署支持。持久化包括短期记忆、长期记忆和人机交互循环。流式处理允许你实时获取 LLM 调用或智能体状态的更新。调试和部署则通过 LangSmith 等工具提供了可视化和可观测性。
使用框架通常需要学习新的 API。LangGraph 功能 API 的目标是:让你在几乎不改变现有 Python 代码结构的情况下,就能获得 LangGraph 框架的所有好处,将开发开销降到最低。
接下来,我们将通过构建一个算术智能体的例子,一步步展示如何实现这一点。
构建基础智能体(无框架)🤖
首先,我们创建一个不使用任何框架的简单智能体。这个智能体能够调用工具进行加、减、乘、除运算。
我们首先定义几个工具函数,并使用 LangChain 的 @tool 装饰器将它们包装成标准工具调用接口。
from langchain_anthropic import ChatAnthropic
from langchain.tools import tool
# 定义工具
@tool
def multiply(a: int, b: int) -> int:
"""Multiply two numbers."""
return a * b
@tool
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
@tool
def divide(a: int, b: int) -> int:
"""Divide two numbers."""
return a / b
# 初始化 LLM 并绑定工具
llm = ChatAnthropic(model="claude-3-haiku-20240307")
llm_with_tools = llm.bind_tools([multiply, add, divide])
然后,我们编写智能体的核心逻辑:循环调用 LLM,如果 LLM 决定调用工具,则执行工具并将结果返回给 LLM,直到 LLM 给出最终答案。
def call_llm(messages):
"""调用绑定了工具的 LLM"""
response = llm_with_tools.invoke(messages)
return response
def call_tool(tool_call):
"""根据 LLM 的决策调用具体工具"""
# 这里简化处理,实际应根据 tool_call 选择对应函数
selected_tool = {"add": add, "multiply": multiply, "divide": divide}.get(tool_call["name"])
if selected_tool:
return selected_tool.invoke(tool_call["args"])
else:
raise ValueError(f"Unknown tool: {tool_call['name']}")
def agent(input_text):
"""智能体主函数"""
messages = [{"role": "user", "content": input_text}]
while True:
# 1. 调用 LLM
response = call_llm(messages)
messages.append(response)
# 2. 检查是否为工具调用
if not response.tool_calls:
# 如果没有工具调用,返回最终答案
return response.content
# 3. 执行工具调用
for tool_call in response.tool_calls:
tool_result = call_tool(tool_call)
# 将工具执行结果作为消息添加回对话历史
messages.append({
"role": "tool",
"content": str(tool_result),
"tool_call_id": tool_call['id']
})
# 测试智能体
result = agent("What is 3 plus 4?")
print(result) # 应输出 7
这个智能体可以正常工作,但它缺乏记忆能力,每次对话都是独立的。
引入 LangGraph 功能 API 获得持久化 💾
上一节我们构建了一个基础智能体。本节中,我们来看看如何使用 LangGraph 功能 API 为其添加持久化(短期记忆)能力,使对话可以跨多次调用延续。
功能 API 主要涉及两个装饰器:
@entry_point: 标记智能体或工作流的起点。它创建一个用于管理执行和状态的Pregel对象。@task: 标记由入口点函数调用的任何函数。这确保了这些函数的执行结果会被检查点保存,这对于缓存耗时操作的结果、支持流式更新和启用追踪至关重要。
以下是修改后的代码:
from langgraph.functional import entry_point, task
from langgraph.checkpoint import MemorySaver
# 1. 使用 @task 装饰器标记函数
@task
def call_llm(messages):
response = llm_with_tools.invoke(messages)
return response
@task
def call_tool(tool_call):
selected_tool = {"add": add, "multiply": multiply, "divide": divide}.get(tool_call["name"])
if selected_tool:
return selected_tool.invoke(tool_call["args"])
else:
raise ValueError(f"Unknown tool: {tool_call['name']}")
# 2. 定义检查点保存器(实现持久化)
checkpointer = MemorySaver()
# 3. 使用 @entry_point 装饰器定义智能体入口,并注入检查点保存器
@entry_point(checkpointer=checkpointer)
def agent(input_text, config):
messages = config.get("messages", [{"role": "user", "content": input_text}])
while True:
# 注意:调用被 @task 装饰的函数后,需要使用 .result() 获取结果
response = call_llm(messages).result()
messages.append(response)
if not response.tool_calls:
return response.content
for tool_call in response.tool_calls:
tool_result = call_tool(tool_call).result()
messages.append({
"role": "tool",
"content": str(tool_result),
"tool_call_id": tool_call['id']
})
现在,我们可以像使用一个 LangGraph 应用一样运行智能体,并指定一个 thread_id 来保存对话线程。
# 配置线程ID
config = {"configurable": {"thread_id": "thread_123"}}
# 第一次调用
result1 = agent.invoke("What is 3 plus 4?", config)
print(f"First result: {result1}") # 输出 7
# 第二次调用,引用之前的结果
result2 = agent.invoke("Take that result and multiply it by two.", config)
print(f"Second result: {result2}") # 输出 14
因为有了持久化层,智能体知道“that result”指的是上一次对话中计算出的 7。我们可以随时查看保存到该线程的完整状态历史。
# 获取保存的状态历史
state_history = agent.get_state_history(config)
print(state_history)
实现人机交互循环(Human-in-the-Loop)🔄
拥有了持久化能力后,我们可以轻松实现人机交互循环,例如在智能体执行敏感操作(如写入数据库)前请求用户批准。
这主要通过 interrupt 函数实现。它允许我们在代码执行中暂停,向用户展示信息并等待输入。
我们对 call_tool 函数进行修改,在真正执行工具前插入一个中断点:
from langgraph.functional import interrupt
@task
def call_tool(tool_call, config):
# 在调用工具前中断,请求用户批准
user_response = interrupt({
"tool_call": tool_call,
"action": "Please approve or reject this tool call."
}, config)
# 假设用户返回 {"resume": True} 表示批准,False 表示拒绝
is_approved = user_response.get("resume", False)
if is_approved:
selected_tool = {"add": add, "multiply": multiply, "divide": divide}.get(tool_call["name"])
if selected_tool:
return selected_tool.invoke(tool_call["args"])
else:
raise ValueError(f"Unknown tool: {tool_call['name']}")
else:
return "Tool call rejected by user."
现在,当智能体尝试调用工具时,执行会暂停。用户(或外部系统)可以通过向智能体发送一个恢复命令来继续。
# 模拟用户批准流程
config = {"configurable": {"thread_id": "thread_456"}}
# 开始执行,会在 tool call 处中断
# 在实际应用中,这通常在一个事件循环或Webhook中处理
# 这里我们模拟用户立即批准
agent.invoke("Add 5 and 6.", config)
# 假设在中断处,系统接收到了 {"resume": True},然后继续执行
一个关键点是 缓存:因为 call_llm 被标记为 @task,它的结果在第一次执行后被缓存。当从中断点恢复时,它不会重新运行,而是直接使用缓存的结果,从而提高了效率。

探索时间旅行与分支 🌳

持久化层还开启了 时间旅行 的可能性。这意味着你可以回退到智能体执行过程中的任何一个先前检查点,并从那里尝试不同的操作路径(即创建分支)。
以下是如何操作的示例:
# 假设我们已经完成了一次对话,线程ID为 `thread_789`
config = {"configurable": {"thread_id": "thread_789"}}
agent.invoke("What is 10 divided by 2?", config) # 得到结果 5
# 获取状态历史并选择第一个检查点(即计算 10/2 之后的状态)
state_history = agent.get_state_history(config)
fork_point = state_history[0] # 选择你想要分叉的检查点
# 从这个检查点分叉,尝试新的输入
new_config = {
"configurable": {
"thread_id": "thread_789_fork", # 新线程ID
"checkpoint_id": fork_point["checkpoint_id"] # 指定从哪个检查点开始
}
}
# 从分叉点尝试不同的操作
new_result = agent.invoke("Now multiply that result by 10.", new_config)
print(new_result) # 输出 50 (5 * 10)
这在你想要测试智能体在不同决策下的表现,或者纠正智能体错误后重新开始时非常有用。
实现长期记忆 🗃️
到目前为止,我们讨论的持久化(短期记忆)是线程内的,即局限于一次对话会话。长期记忆 允许你在所有线程(会话) 之间共享和存储信息,例如用户的姓名、偏好等。
LangGraph 通过 BaseStore 抽象来实现这一点。以下是一个示例:
from langgraph.store import BaseStore, MemoryStore
# 1. 创建一个存储长期记忆的 Store
long_term_memory_store = MemoryStore()
# 2. 定义一个工具,用于向长期记忆写入信息
@tool
def update_memory(key: str, value: str, store: BaseStore):
"""Update long-term memory."""
store.put("user_memories", key, {"info": value})
return f"Memory updated for key: {key}"
# 3. 修改智能体入口,同时注入检查点保存器和长期记忆存储
@entry_point(checkpointer=checkpointer, store=long_term_memory_store)
def agent_with_memory(input_text, config):
# 获取当前线程的短期记忆(消息历史)
thread_messages = config.get("messages", [{"role": "user", "content": input_text}])
# 从长期记忆存储中读取信息
all_memories = {}
for key, value in long_term_memory_store.search("user_memories"):
all_memories[key] = value["info"]
# 将长期记忆格式化并加入系统提示
memory_prompt = "User memories:\n" + "\n".join([f"- {k}: {v}" for k, v in all_memories.items()])
system_message = {"role": "system", "content": f"You are a helpful assistant. {memory_prompt}"}
messages = [system_message] + thread_messages
# ... 后续循环调用 llm 和 tool 的逻辑与之前类似 ...
# 当需要更新记忆时,智能体会调用 `update_memory` 工具
现在,智能体可以在一个会话中记住用户的信息,并在未来的任何新会话中使用它。

# 会话 1:告诉智能体你的名字
config1 = {"configurable": {"thread_id": "session_1"}}
agent_with_memory.invoke("Please remember that my name is Lance.", config1)
# 智能体会调用 update_memory 工具,将 (“name”, “Lance”) 存入长期存储。
# 会话 2:在新的对话中询问名字
config2 = {"configurable": {"thread_id": "session_2"}}
answer = agent_with_memory.invoke("What's my name?", config2)
print(answer) # 输出:Your name is Lance.

总结 🎯
本节课中,我们一起学习了 LangGraph 功能 API 的核心价值与应用方法。
我们从一个纯 Python 实现的简单智能体开始,逐步演示了如何通过添加 @entry_point 和 @task 等少量装饰器,为其注入强大的框架能力:
- 短期记忆(持久化):使智能体能够进行长时间、可中断的对话,所有状态自动保存到线程中。
- 人机交互循环:允许在关键步骤(如工具调用)中断执行,请求用户批准或输入。
- 时间旅行与分支:能够回退到任意历史检查点,并尝试不同的执行路径,便于调试和探索。
- 长期记忆:通过
BaseStore在不同对话线程间共享信息,实现跨会话的记忆。 - 开箱即用的追踪与调试:所有步骤自动被记录,可以在 LangSmith 等工具中可视化查看执行流程。

最重要的是,获得这些功能所需的代码改动非常小。你基本上只需要用装饰器包装现有的函数,就能将普通的 Python 代码转变为具备生产级特性的 LangGraph 应用。这使得 LangGraph 功能 API 成为快速构建和迭代 AI 智能体应用的理想选择。

浙公网安备 33010602011771号