构建智能代理

148 阅读43分钟

随着生成式AI的广泛应用,我们开始使用大语言模型(LLMs)来完成更多开放且复杂的任务,这些任务通常需要了解最新事件或与现实世界交互。这类应用通常被称为“智能代理”应用。本章后面会详细定义什么是代理,但你可能已经在媒体上看到过相关说法:2025年将是智能代理AI的时代。例如,在最近推出的RE-Bench基准测试中,该测试包含复杂的开放式任务,AI代理在某些场景下(比如有30分钟思考预算时)或某些特定类别任务(如编写Triton内核)中表现优于人类。

为了理解这些智能代理能力在实际中如何构建,我们将先从LLM的工具调用开始讲起,以及它如何在LangChain中实现。我们将详细介绍ReACT模式,以及LLM如何利用工具与外部环境交互,提升在特定任务上的表现。接着,我们会探讨LangChain中工具的定义,以及现有的预置工具有哪些。还会讨论如何开发自定义工具、错误处理和使用高级工具调用功能。作为实操示例,我们会比较使用工具生成结构化输出与模型提供商内置能力的区别。

最后,我们会讲解什么是代理,深入研究如何用LangGraph构建更高级的代理模式,并基于LangGraph开发第一个ReACT代理——一个遵循“计划-解决”设计模式的研究代理,使用了诸如网络搜索、arXiv和维基百科等工具。

简而言之,本章将覆盖以下主题:

  • 什么是工具?
  • 定义LangChain内置工具和自定义工具
  • 高级工具调用能力
  • 将工具纳入工作流
  • 什么是代理?

本章代码可在本书GitHub仓库的chapter5/目录中找到,最新更新请访问:github.com/benman1/gen…
第2章包含环境配置说明。如运行代码遇到问题,请在GitHub创建issue,或加入Discord讨论:packt.link/lang

我们先从工具讲起。与其直接定义代理,不如先了解如何通过工具增强LLM的实际应用。通过一步步讲解,你将看到这些集成如何解锁新的能力。那么,工具究竟是什么,它们如何扩展LLM的功能呢?

什么是工具?

大语言模型(LLMs)是基于庞大的通用语料库(如网络数据和书籍)训练的,这赋予它们广泛的知识面,但限制了它们在需要特定领域知识或最新信息的任务中的表现。然而,LLMs擅长推理,可以通过工具与外部环境交互——这些工具是API或接口,允许模型与外部世界互动。借助这些工具,LLMs能够执行特定任务并从外部获得反馈。

使用工具时,LLMs会执行三个特定的生成任务:

  1. 通过生成特殊标记和工具名称选择要使用的工具。
  2. 生成发送给工具的请求载荷。
  3. 根据初始问题和与工具交互的历史(本次运行的上下文),生成对用户的响应。

现在我们来了解LLMs如何调用工具,以及如何让LLMs具备工具感知能力。举一个稍显人工但有代表性的问题:当前美国总统年龄的平方根乘以132是多少?这个问题有两个具体挑战:

  • 它涉及当前信息(截至2025年3月),可能不在模型训练数据范围内。
  • 需要精确的数学计算,仅靠LLM的自回归生成可能无法正确回答。

与其强迫LLM只凭内部知识生成答案,我们给它接入两个工具:搜索引擎和计算器。期望模型能判断是否需要使用这些工具及如何使用。

为简化演示,先从简单问题入手,并用伪造函数模拟工具,总是返回固定结果。稍后本章会实现真正功能的工具并调用它们:

question = "how old is the US president?"
raw_prompt_template = (
    "You have access to search engine that provides you an "
    "information about fresh events and news given the query. "
    "Given the question, decide whether you need an additional "
    "information from the search engine (reply with 'SEARCH: "
    "<generated query>' or you know enough to answer the user "
    "then reply with 'RESPONSE <final response>').\n"
    "Now, act to answer a user question:\n{QUESTION}"
)
prompt_template = PromptTemplate.from_template(raw_prompt_template)
result = (prompt_template | llm).invoke(question)
print(result,response)
# >> SEARCH: current age of US president

当LLM具备足够内部知识时,应直接回复用户:

question1 = "What is the capital of Germany?"
result = (prompt_template | llm).invoke(question1)
print(result,response)
# >> RESPONSE: Berlin

接下来,将工具的输出纳入提示:

query = "age of current US president"
search_result = (
    "Donald Trump ' Age 78 years June 14, 1946\n"
    "Donald Trump 45th and 47th U.S. President Donald John Trump is an American "
    "politician, media personality, and businessman who has served as the 47th "
    "president of the United States since January 20, 2025. A member of the "
    "Republican Party, he previously served as the 45th president from 2017 to 2021. Wikipedia"
)
raw_prompt_template = (
    "You have access to search engine that provides you an "
    "information about fresh events and news given the query. "
    "Given the question, decide whether you need an additional "
    "information from the search engine (reply with 'SEARCH: "
    "<generated query>' or you know enough to answer the user "
    "then reply with 'RESPONSE <final response>').\n"
    "Today is {date}."
    "Now, act to answer a user question and "
    "take into account your previous actions:\n"
    "HUMAN: {question}\n"
    "AI: SEARCH: {query}\n"
    "RESPONSE FROM SEARCH: {search_result}\n"
)
prompt_template = PromptTemplate.from_template(raw_prompt_template)
result = (prompt_template | llm).invoke(
    {"question": question, "query": query, "search_result": search_result,
    "date": "Feb 2025"})
print(result.content)
# >> RESPONSE: The current US President, Donald Trump, is 78 years old.

最后,如果搜索结果不理想,LLM会尝试优化查询:

query = "current US president"
search_result = (
    "Donald Trump 45th and 47th U.S."
)
result = (prompt_template | llm).invoke(
    {"question": question, "query": query,
    "search_result": search_result, "date": "Feb 2025"})
print(result.content)
# >> SEARCH: Donald Trump age

以上演示了工具调用的工作流程。请注意,示例中提供的提示仅用于演示,实际基础LLM可能需要针对提示进行调优。好消息是,使用工具比看起来更简单!

如你所见,我们在提示中描述了工具信息和调用格式。如今,大多数LLM提供更优的工具调用API,因为现代LLM经过后期训练,能更好地完成这类任务。模型开发者了解数据集构建细节,因此通常你无需自己在提示里写工具描述,而是分别提供提示和工具描述,模型提供方会自动合成完整提示。一些开源小模型仍然需要在原始提示里包含工具描述,并且期望格式规范。

LangChain简化了LLM调用多工具的流程,并提供了丰富的内置工具。接下来,我们来看看LangChain中工具的处理机制。

LangChain中的工具

对于大多数现代的大语言模型(LLMs)来说,使用工具时可以将一组工具描述作为单独的参数传入。与LangChain一贯的设计一样,每个具体集成实现都会将接口映射到提供者的API上。对于工具调用,这通过LangChain向invoke方法传递的tools参数来实现(以及一些其他有用的方法,如bind_tools等,本章后面会介绍)。

定义工具时,需要用OpenAPI格式来指定它的模式(schema)。我们提供工具的标题和描述,并且指定它的参数(每个参数都包含类型、标题和描述)。这样的模式可以继承自多种格式,LangChain会将其转换为OpenAPI格式。在接下来的几节里,我们会演示如何从函数、docstrings、Pydantic定义,或者继承自BaseTool类并直接提供描述来定义工具。对于LLM来说,工具就是任何带有OpenAPI规范的东西——换句话说,就是可以被某种外部机制调用的接口。

LLM本身不关心调用机制,只负责生成调用工具的指令,告诉什么时候以及如何调用工具。对于LangChain来说,工具也是可以被调用的对象(稍后我们会看到,工具是从Runnable继承而来的),当程序执行时可以调用它们。

在工具的标题和描述字段中使用的措辞非常关键,这部分内容可以视为提示工程的一部分。更精准、恰当的描述有助于LLM更好地决定何时以及如何调用特定工具。需要注意的是,对于更复杂的工具,编写这样详细的模式会比较繁琐,本章后面会介绍一种更简单的定义工具的方式:

search_tool = {
    "title": "google_search",
    "description": "Returns about fresh events and news from Google Search engine based on a query",
    "type": "object",
    "properties": {
        "query": {
            "description": "Search query to be sent to the search engine",
            "title": "search_query",
            "type": "string"
        },
    },
    "required": ["query"]
}

result = llm.invoke(question, tools=[search_tool])

如果查看result.content字段,会发现它是空的,因为LLM决定调用工具,输出消息中包含调用提示。LangChain会将模型提供者返回的特定格式映射为统一的工具调用格式:

print(result.tool_calls)
# >> [{'name': 'google_search', 'args': {'query': 'age of Donald Trump'}, 'id': '6ab0de4b-f350-4743-a4c1-d6f6fcce9d34', 'type': 'tool_call'}]

请注意,有些模型提供者在调用工具时,内容可能不为空(例如,可能包含为什么调用该工具的推理痕迹),具体要看模型提供者的规范,决定如何处理这些情况。

如上所示,LLM返回了一个工具调用字典的数组——每个字典包含唯一标识符、工具名称以及传递给该工具的参数字典。接下来,我们继续调用模型:

from langchain_core.messages import SystemMessage, HumanMessage, ToolMessage

tool_result = ToolMessage(content="Donald Trump ' Age 78 years June 14, 1946\n", tool_call_id=step1.tool_calls[0]["id"])

step2 = llm.invoke([
    HumanMessage(content=question), step1, tool_result], tools=[search_tool])

assert len(step2.tool_calls) == 0
print(step2.content)
# >> Donald Trump is 78 years old.

ToolMessage是LangChain中的一种特殊消息类型,允许你将工具执行的输出反馈给模型。该消息的content字段包含工具的输出内容,特殊字段tool_call_id将它映射到模型生成的具体工具调用。现在我们可以将完整的消息序列(包括最初的输出、工具调用步骤和工具输出)作为消息列表传回模型。

通常,为LLM每次调用都传入工具列表显得不太方便(因为该列表对给定工作流通常是固定的)。因此,LangChain的Runnable提供了bind方法,用于“记忆”参数并自动添加到后续调用中。示例代码如下:

llm_with_tools = llm.bind(tools=[search_tool])
llm_with_tools.invoke(question)

调用llm.bind(tools=[search_tool])后,LangChain会创建一个新对象(此处赋值为llm_with_tools),它会在后续对该对象的调用中自动包含该工具列表。这样就不必每次调用invoke方法都传入tools参数了。等效于:

llm.invoke(question, tools=[search_tool])

这主要是为了方便,如果你想用固定的一组工具多次调用,就很合适。接下来我们将探索如何更充分地利用工具调用,提升LLM的推理能力!

ReACT

你可能已经想到,LLM 在生成最终回复给用户之前,可以调用多个工具(而且下一步调用哪个工具或发送什么请求,可能依赖于之前工具调用的结果)。这种方法由普林斯顿大学和谷歌研究院的研究人员在2022年提出,称为 ReACT(Reasoning and ACT,推理与行动)(arxiv.org/abs/2210.03…)。其核心思想很简单——我们应赋予LLM访问工具的能力,以便与外部环境交互,并让LLM在一个循环中运行:

  • Reason(推理):生成关于当前情况的观察文本,以及解决任务的计划。
  • Act(行动):基于上述推理采取行动(通过调用工具与环境交互,或直接回应用户)。

研究表明,与第3章讨论的链式思维(CoT)提示相比,ReACT能有效降低模型产生幻觉(hallucination)的概率。

image.png

让我们自己构建一个 ReACT 应用。首先,创建模拟的搜索和计算器工具:

import math

def mocked_google_search(query: str) -> str:
    print(f"CALLED GOOGLE_SEARCH with query={query}")
    return "Donald Trump is a president of USA and he's 78 years old"

def mocked_calculator(expression: str) -> float:
    print(f"CALLED CALCULATOR with expression={expression}")
    if "sqrt" in expression:
        return math.sqrt(78 * 132)
    return 78 * 132

下一节我们将学习如何构建真正的工具。现在,先为计算器工具定义一个模式(schema),并让 LLM 识别它可以使用的两个工具。我们还将使用之前熟悉的构建模块——ChatPromptTemplateMessagesPlaceholder,在调用图时添加预设的系统消息:

from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder

calculator_tool = {
    "title": "calculator",
    "description": "Computes mathematical expressions",
    "type": "object",
    "properties": {
        "expression": {
            "description": "A mathematical expression to be evaluated by a calculator",
            "title": "expression",
            "type": "string"
        },
    },
    "required": ["expression"]
}

prompt = ChatPromptTemplate.from_messages([    ("system", "Always use a calculator for mathematical computations, and use Google Search for information about fresh events and news."),    MessagesPlaceholder(variable_name="messages"),])

llm_with_tools = llm.bind(tools=[search_tool, calculator_tool]).bind(prompt=prompt)

现在我们有了可以调用工具的 LLM,接着创建所需节点。我们需要一个函数调用 LLM,另一个函数调用工具并返回工具调用结果(通过在状态消息列表中追加 ToolMessage),还有一个函数决定协调器是否继续调用工具或直接返回结果给用户:

from typing import TypedDict
from langgraph.graph import MessagesState, StateGraph, START, END
from langchain_core.messages import ToolMessage, HumanMessage

def invoke_llm(state: MessagesState):
    return {"messages": [llm_with_tools.invoke(state["messages"])]}

def call_tools(state: MessagesState):
    last_message = state["messages"][-1]
    tool_calls = last_message.tool_calls
    new_messages = []
    for tool_call in tool_calls:
        if tool_call["name"] == "google_search":
            tool_result = mocked_google_search(**tool_call["args"])
            new_messages.append(ToolMessage(content=tool_result, tool_call_id=tool_call["id"]))
        elif tool_call["name"] == "calculator":
            tool_result = mocked_calculator(**tool_call["args"])
            new_messages.append(ToolMessage(content=tool_result, tool_call_id=tool_call["id"]))
        else:
            raise ValueError(f"Tool {tool_call['name']} is not defined!")
    return {"messages": new_messages}

def should_run_tools(state: MessagesState):
    last_message = state["messages"][-1]
    if last_message.tool_calls:
        return "call_tools"
    return END

将以上整合到 LangGraph 工作流中:

builder = StateGraph(MessagesState)
builder.add_node("invoke_llm", invoke_llm)
builder.add_node("call_tools", call_tools)
builder.add_edge(START, "invoke_llm")
builder.add_conditional_edges("invoke_llm", should_run_tools)
builder.add_edge("call_tools", "invoke_llm")
graph = builder.compile()

question = "What is a square root of the current US president's age multiplied by 132?"
result = graph.invoke({"messages": [HumanMessage(content=question)]})
print(result["messages"][-1].content)

输出示例:

CALLED GOOGLE_SEARCH with query=age of Donald Trump
CALLED CALCULATOR with expression=78 * 132
CALLED CALCULATOR with expression=sqrt(10296)
The square root of 78 multiplied by 132 (which is 10296) is approximately 101.47.

这演示了 LLM 如何针对复杂问题多次调用工具——先调用 Google Search,再调用计算器两次,每次都利用前一次获得的信息调整操作。这就是 ReACT 模式的实际应用。

至此,我们通过亲自构建,详细了解了 ReACT 模式的工作原理。好消息是,LangGraph 提供了 ReACT 模式的预置实现,无需自行实现:

from langgraph.prebuilt import create_react_agent

agent = create_react_agent(
    llm=llm,
    tools=[search_tool, calculator_tool],
    prompt=system_prompt
)

第6章中,我们将看到 create_react_agent 函数支持的一些额外调整选项。

定义工具

到目前为止,我们已经将工具定义为 OpenAPI 模式(schema)。但为了实现端到端的工作流执行,LangGraph 需要在运行时能够自己调用工具。因此,本节将讨论如何将工具定义为 Python 函数或可调用对象。

一个 LangChain 工具包含三个核心组成部分:

  • 名称(Name):工具的唯一标识符
  • 描述(Description):帮助 LLM 理解何时以及如何使用该工具的文本
  • 请求载荷模式(Payload schema):工具接受输入的结构化定义

这些组成允许 LLM 决定何时以及如何调用工具。另一个重要特点是,LangChain 的工具可以被调度器(如 LangGraph)执行。工具的基础接口是 BaseTool,它继承自 RunnableSerializable。这意味着工具既可以像任何 Runnable 一样被调用或批量执行,也可以像任何 Serializable 一样被序列化和反序列化。

内置的 LangChain 工具

LangChain 已提供大量工具,涵盖多个类别。由于工具通常由第三方提供,有的工具需要付费 API 密钥,有的完全免费,还有一些提供免费额度。有些工具会组合成工具包(toolkits),用于在特定任务中一同使用。下面举一些使用工具的例子。

工具赋予 LLM 访问搜索引擎的能力,如 Bing、DuckDuckGo、Google 和 Tavily。这里以 DuckDuckGoSearchRun 为例,因为该搜索引擎无需额外注册或 API 密钥。

请参考第2章获取环境配置说明。如运行代码有疑问或遇到问题,请在 GitHub 创建 issue,或加入 Discord 讨论:packt.link/lang

该工具也具备名称、描述和输入参数模式:

from langchain_community.tools import DuckDuckGoSearchRun

search = DuckDuckGoSearchRun()
print(f"Tool's name = {search.name}")
print(f"Tool's description = {search.description}")
print(f"Tool's arg schema = {search.args_schema}")

输出示例:

Tool's name = duckduckgo_search
Tool's description = A wrapper around DuckDuckGo Search. Useful for when you need to answer questions about current events. Input should be a search query.
Tool's arg schema = class 'langchain_community.tools.ddg_search.tool.DDGInput'

参数模式 arg_schema 是一个 Pydantic 模型,后面章节会说明其用处。我们可以通过编程方式查看其字段,或者查看文档页面——它只需要一个输入字段 query

from langchain_community.tools.ddg_search.tool import DDGInput

print(DDGInput.__fields__)

输出示例:

{'query': FieldInfo(annotation=str, required=True, description='search query to look up')}

现在可以调用该工具,获取字符串形式的搜索结果:

query = "What is the weather in Munich like tomorrow?"
search_input = DDGInput(query=query)
result = search.invoke(search_input.dict())
print(result)

我们也可以让 LLM 调用工具,确保 LLM 会调用搜索工具而非直接回答:

result = llm.invoke(query, tools=[search])
print(result.tool_calls[0])

输出示例:

{'name': 'duckduckgo_search', 'args': {'query': 'weather in Munich tomorrow'}, 'id': '222dc19c-956f-4264-bf0f-632655a6717d', 'type': 'tool_call'}

现在我们的工具已经成为 LangGraph 可以程序化调用的可调用对象。接下来把所有内容整合,创建第一个代理。当我们对图进行流式调用时,会得到状态更新,当前我们的状态仅包含消息:

from langgraph.prebuilt import create_react_agent

agent = create_react_agent(model=llm, tools=[search])

image.png

这正是我们之前看到的——LLM 会不断调用工具,直到决定停止并返回答案给用户。我们来测试一下!

当我们对 LangGraph 进行流式调用时,会收到表示图状态更新的新事件。我们关注状态中的 message 字段,打印出新增的消息:

for event in agent.stream({"messages": [("user", query)]}):
    update = event.get("agent", event.get("tools", {}))
    for message in update.get("messages", []):
        message.pretty_print()

示例输出:

=============================== Ai Message ==================================
Tool Calls:
duckduckgo_search (a01a4012-bfc0-4eae-9c81-f11fd3ecb52c)
Call ID: a01a4012-bfc0-4eae-9c81-f11fd3ecb52c
Args:
query: weather in Munich tomorrow
================================= Tool Message =================================
Name: duckduckgo_search
The temperature in Munich tomorrow in the early morning is 4 ° C… <TRUNCATED>
================================== Ai Message ==================================
The weather in Munich tomorrow will be 5°C with a 0% chance of rain in the morning. The wind will blow at 11 km/h. Later in the day, the high will be 53°F (approximately 12°C). It will be clear in the early morning.

我们的代理由一系列消息组成,这正是 LLM 预期的输入输出格式。我们后续深入探讨智能代理架构时还会再次见到这一模式,下一章会详细介绍。

接下来简单介绍一下 LangChain 上已有的其他工具类型:

增强 LLM 知识的工具(除搜索引擎外):

  • 学术研究:arXiv、PubMed
  • 知识库:Wikipedia、Wikidata
  • 财务数据:Alpha Vantage、Polygon、Yahoo Finance
  • 天气:OpenWeatherMap
  • 计算:Wolfram Alpha

提升生产力的工具:
可以与 Gmail、Slack、Office 365、Google Calendar、Jira、Github 等交互。例如,GmailToolkit 提供 GmailCreateDraft、GmailSendMessage、GmailSearch、GmailGetMessage、GmailGetThread 等工具,支持搜索、检索、创建及发送邮件。不仅为 LLM 提供用户上下文,有些工具还能让 LLM 实际影响外部环境,如在 GitHub 创建 pull request 或在 Slack 发送消息。

提供代码解释器访问的工具:
通过远程启动隔离容器,赋予 LLM 代码执行环境。需要供应商 API 密钥。LLM 擅长编程,常用方法是让 LLM 编写代码解决复杂任务,而非仅生成文本解答。代码执行需谨慎,隔离沙箱很重要。示例包括:

  • 代码执行:Python REPL、Bash
  • 云服务:AWS Lambda
  • API 工具:GraphQL、Requests
  • 文件操作:文件系统

通过执行 SQL 代码访问数据库的工具:
如 SQLDatabase,支持查询数据库及其对象,执行 SQL 查询。还能用 GoogleDriveLoader 访问 Google Drive,或用 FileManagementToolkit 操作文件系统。

其他工具:
集成第三方系统,帮助 LLM 获取更多信息或执行操作。支持从 Google Maps、NASA 等平台检索数据。

调用其他 AI 系统或自动化的工具:

  • 图像生成:DALL-E、Imagen
  • 语音合成:Google Cloud TTS、Eleven Labs
  • 模型访问:Hugging Face Hub
  • 工作流自动化:Zapier、IFTTT

任何带 API 的外部系统都可封装为工具,条件是:

  • 为用户或工作流提供相关领域知识
  • 允许 LLM 代表用户执行操作

集成此类工具时需关注:

  • 身份认证:安全访问外部系统
  • 请求载荷模式:定义输入输出数据结构
  • 错误处理:处理失败和边界情况
  • 安全考虑:如限制 SQL-to-text 代理仅读操作,防止意外修改

一个重要的工具包是 RequestsToolkit,可轻松封装任意 HTTP API:

from langchain_community.agent_toolkits.openapi.toolkit import RequestsToolkit
from langchain_community.utilities.requests import TextRequestsWrapper

toolkit = RequestsToolkit(
    requests_wrapper=TextRequestsWrapper(headers={}),
    allow_dangerous_requests=True,
)

for tool in toolkit.get_tools():
    print(tool.name)

输出示例:

requests_get
requests_post
requests_patch
requests_put
requests_delete

举例说明,我们用一个免费开源的货币汇率 API(frankfurter.dev/),仅作演示如何把已有 API 封装为工具。首先根据 OpenAPI 格式准备 API 规范(此处截断,完整版本见 GitHub):

openapi: 3.0.0
info:
  title: Frankfurter Currency Exchange API
  version: v1
  description: API for retrieving currency exchange rates. Pay attention to the base currency and change it if needed.
servers:
  - url: https://api.frankfurter.dev/v1
paths:
  /v1/latest:
    get:
      summary: Get the latest exchange rates.
      parameters:
        - in: query
          name: symbols
          schema:
            type: string
          description: Comma-separated list of currency symbols to retrieve rates for. Example: CHF,GBP
        - in: query
          name: base
          schema:
            type: string
          description: The base currency for the exchange rates. If not provided, EUR is used as a base currency. Example: USD
  /v1/{date}:
    ...

现在构建并运行我们的 ReACT 代理,LLM 可以查询第三方 API,给出最新货币汇率答案:

system_message = (
    "You're given the API spec:\n{api_spec}\n"
    "Use the API to answer users' queries if possible. "
)
agent = create_react_agent(llm, toolkit.get_tools(), state_modifier=system_message.format(api_spec=api_spec))
query = "What is the swiss franc to US dollar exchange rate?"
events = agent.stream(
    {"messages": [("user", query)]},
    stream_mode="values",
)
for event in events:
    event["messages"][-1].pretty_print()

示例输出:

============================== Human Message =================================
What is the swiss franc to US dollar exchange rate?
================================== Ai Message ==================================
Tool Calls:
requests_get (541a9197-888d-4ffe-a354-c726804ad7ff)
Call ID: 541a9197-888d-4ffe-a354-c726804ad7ff
Args:
url: https://api.frankfurter.dev/v1/latest?symbols=CHF&base=USD
================================= Tool Message =================================
Name: requests_get
{"amount":1.0,"base":"USD","date":"2025-01-31","rates":{"CHF":0.90917}}
================================== Ai Message ==================================
The Swiss franc to US dollar exchange rate is 0.90917.

注意这次我们使用了 stream_mode="values" 选项,该模式每次都会返回图的完整当前状态。

目前已有超过50种工具可用,完整列表请查阅官方文档:python.langchain.com/docs/integr…

自定义工具

我们已经了解了 LangGraph 提供的各种内置工具,现在是时候讨论如何创建自己的自定义工具了,除了之前用 RequestsToolkit 通过提供 API 规范封装第三方 API 的示例外。下面我们开始动手!

将 Python 函数封装为工具

任何 Python 函数(或可调用对象)都可以被封装为工具。正如我们所知,LangChain 中的工具应有名称、描述和参数模式。这里,我们基于 Python 的 numexpr 库(一个基于 NumPy 的高速数值表达式求值器,github.com/pydata/nume…)构建自己的计算器工具。使用特殊的@tool 装饰器将函数封装为工具:

import math
from langchain_core.tools import tool
import numexpr as ne

@tool
def calculator(expression: str) -> str:
    """计算单个数学表达式,包括复数。
    始终在运算符之间添加 *,示例:
    73i -> 73*i
    7pi**2 -> 7*pi**2
    """
    math_constants = {"pi": math.pi, "i": 1j, "e": math.exp(1)}
    result = ne.evaluate(expression.strip(), local_dict=math_constants)
    return str(result)

让我们看看这个 calculator 对象!LangChain 会自动从函数的文档字符串和类型提示继承名称、描述和参数模式。注意,我们在文档字符串里用到了少量示例(few-shot 技巧,详见第3章),以教会 LLM 如何为该工具准备请求载荷:

from langchain_core.tools import BaseTool

assert isinstance(calculator, BaseTool)
print(f"Tool schema: {calculator.args_schema.model_json_schema()}")

输出示例:

Tool schema: {
  'description': '计算单个数学表达式,包括复数。\n\n始终在运算符之间添加 *,示例:\n 73i -> 73*i\n 7pi**2 -> 7*pi**2',
  'properties': {'expression': {'title': 'Expression', 'type': 'string'}},
  'required': ['expression'],
  'title': 'calculator',
  'type': 'object'
}

现在尝试用我们新工具计算包含复数的表达式,复数是在实数基础上加上虚数单位 i,满足 i2=−1i^2 = -1i2=−1:

query = "How much is 2+3i squared?"
agent = create_react_agent(llm, [calculator])

for event in agent.stream({"messages": [("user", query)]}, stream_mode="values"):
    event["messages"][-1].pretty_print()

示例输出:

=============================== Human Message ================================
How much is 2+3i squared?
================================== Ai Message ==================================
Tool Calls:
calculator (9b06de35-a31c-41f3-a702-6e20698bf21b)
Call ID: 9b06de35-a31c-41f3-a702-6e20698bf21b
Args:
expression: (2+3*i)**2
================================= Tool Message =================================
Name: calculator
(-5+12j)
================================== Ai Message ==================================
(2+3i)² = -5+12i.

只需几行代码,我们就成功扩展了 LLM 处理复数的能力。现在可以将之前的示例组合起来:

question = "What is a square root of the current US president's age multiplied by 132?"
system_hint = "Think step-by-step. Always use search to get the fresh information about events or public facts that can change over time."

agent = create_react_agent(
    llm, [calculator, search],
    state_modifier=system_hint
)

for event in agent.stream({"messages": [("user", question)]}, stream_mode="values"):
    event["messages"][-1].pretty_print()
    print(event["messages"][-1].content)

输出示例:

The square root of Donald Trump's age multiplied by 132 is approximately 101.47.

书中未完全列出输出内容(可在我们的 GitHub 查看),但运行该代码片段时,你会看到 LLM 逐步调用工具:

  1. 首先调用搜索引擎查询 “current US president”。
  2. 接着再次调用搜索引擎查询 “donald trump age”。
  3. 最后调用计算器工具计算表达式 sqrt(78*132)
  4. 最终向用户返回正确答案。

每一步,LLM 都基于之前收集的信息进行推理,然后用合适的工具采取行动——这就是 ReACT 方法的核心精髓。

从 Runnable 创建工具

有时,LangChain 可能无法从函数自动推断出合适的描述或参数模式,或者我们使用的是较复杂的可调用对象,不方便用装饰器封装。比如,我们可以将另一个 LangChain 链或 LangGraph 图作为工具。只要明确提供所需的描述信息,就能从任何 Runnable 创建工具。下面演示另一种方式创建计算器工具,并调整重试策略(这里设置重试三次,并在连续尝试间加入指数退避):

注意,我们复用了之前的函数,但去掉了 @tool 装饰器。

from langchain_core.runnables import RunnableLambda
from langchain_core.tools import convert_runnable_to_tool
import math
import numexpr as ne

def calculator(expression: str) -> str:
    math_constants = {"pi": math.pi, "i": 1j, "e": math.exp(1)}
    result = ne.evaluate(expression.strip(), local_dict=math_constants)
    return str(result)

calculator_with_retry = RunnableLambda(calculator).with_retry(
    wait_exponential_jitter=True,
    stop_after_attempt=3,
)

calculator_tool = convert_runnable_to_tool(
    calculator_with_retry,
    name="calculator",
    description=(
        "Calculates a single mathematical expression, incl. complex numbers."
        "\nAlways add * to operations, examples:\n73i -> 73*i\n"
        "7pi**2 -> 7*pi**2"
    ),
    arg_types={"expression": "str"},
)

可以看到,这里定义函数的方式类似于定义 LangGraph 节点——函数接收一个状态(现在是 Pydantic 模型)和一个配置对象。我们先将函数包装成 RunnableLambda,再加上重试逻辑。如果你想保持 Python 函数本体不变,不用装饰器封装,或者封装的是外部 API(这时无法自动继承文档字符串描述和参数模式),此方法非常实用。任何 Runnable(如链或图)都能转换成工具,这使得构建多代理系统成为可能——一个基于 LLM 的工作流可以调用另一个。

下面我们修改工具,使其接受 Pydantic 模型,类似定义 LangGraph 节点的方式:

from pydantic import BaseModel, Field
from langchain_core.runnables import RunnableConfig

class CalculatorArgs(BaseModel):
    expression: str = Field(description="Mathematical expression to be evaluated")

def calculator(state: CalculatorArgs, config: RunnableConfig) -> str:
    expression = state.expression
    math_constants = config["configurable"].get("math_constants", {})
    result = ne.evaluate(expression.strip(), local_dict=math_constants)
    return str(result)

这样工具的完整模式就变得规范了:

from langchain_core.tools import BaseTool

assert isinstance(calculator_tool, BaseTool)
print(f"Tool name: {calculator_tool.name}")
print(f"Tool description: {calculator_tool.description}")
print(f"Args schema: {calculator_tool.args_schema.model_json_schema()}")

输出示例:

Tool name: calculator
Tool description: Calculates a single mathematical expression, incl. complex numbers.
Always add * to operations, examples:
73i -> 73*i
7pi**2 -> 7*pi**2
Args schema: {
  'properties': {'expression': {'title': 'Expression', 'type': 'string'}},
  'required': ['expression'],
  'title': 'calculator',
  'type': 'object'
}

接下来,和 LLM 一起测试该工具:

tool_call = llm.invoke("How much is (2+3i)**2", tools=[calculator_tool]).tool_calls[0]
print(tool_call)

示例输出:

{'name': 'calculator', 'args': {'expression': '(2+3*i)**2'}, 'id': 'f8be9cbc-4bdc-4107-8cfb-fd84f5030299', 'type': 'tool_call'}

最后,我们可以调用计算器工具,并在运行时将其传入 LangGraph 配置:

math_constants = {"pi": math.pi, "i": 1j, "e": math.exp(1)}
config = {"configurable": {"math_constants": math_constants}}

calculator_tool.invoke(tool_call["args"], config=config)

输出示例:

(-5+12j)

至此,我们学会了如何通过向 LangChain 提供额外信息,轻松地将任意 Runnable 转换为工具,从而让 LLM 能正确调用和处理该工具。

继承 StructuredTool 或 BaseTool

另一种定义工具的方法是通过继承 BaseTool 类来自定义工具。与其他方法类似,你需要指定工具的名称、描述和参数模式(argument schema)。此外,还必须实现一个或两个抽象方法:用于同步执行的 _run,以及如果异步行为与同步版本不同,则实现异步方法 _arun。当你的工具需要维护状态(比如保持长连接客户端),或者逻辑过于复杂,无法用单个函数或 Runnable 实现时,这种方式尤其适用。

如果你希望比 @tool 装饰器更灵活,但又不想自己写类,还有一种折衷方法。可以使用 StructuredTool.from_function 类方法,只需几行代码即可显式指定工具的元参数,比如描述和参数模式:

from langchain_core.tools import StructuredTool

calculator_tool = StructuredTool.from_function(
    name="calculator",
    description="Calculates a single mathematical expression, incl. complex numbers.",
    func=calculator,
    args_schema=CalculatorArgs
)

tool_call = llm.invoke(
    "How much is (2+3i)**2", tools=[calculator_tool]
).tool_calls[0]

此时需要说明同步与异步实现的区别:如果你的工具底层函数是同步的,LangChain 会自动通过新线程包装该函数,来实现工具的异步版本。多数情况下这无关紧要,但如果你在意创建线程的开销,有两种选择——要么继承 BaseTool 并重写异步实现,要么单独编写异步函数,并通过 StructuredTool.from_functioncoroutine 参数传入。你也可以只提供异步实现,但这样就无法以同步方式调用工作流。

总结一下,我们有三种创建 LangChain 工具的方式及其适用场景:

创建工具方法适用场景
@tool 装饰器你有带清晰文档字符串的函数,且该函数未在其他地方使用
convert_runnable_to_tool你已有 Runnable,或需更精细控制参数和工具描述传递给 LLM 的方式
继承 StructuredToolBaseTool需要对工具描述和逻辑进行全面控制(如差异化处理同步和异步请求)

(表 5.1:创建 LangChain 工具的选项)

最后要提醒,LLM 在生成请求载荷和调用工具时可能出现幻觉(hallucination)或其他错误,因此我们必须谨慎设计错误处理机制。

错误处理

我们在第3章已经讨论过错误处理,但当你给 LLM 增加工具能力时,错误处理变得更加重要,需要更多的日志记录、异常处理等。另一个需要考虑的问题是:当某个工具调用失败时,你是否希望工作流继续执行并尝试自动恢复。LangChain 提供了一个特殊的异常类型 ToolException,允许工作流捕获该异常并继续执行。

BaseTool 有两个特殊标志位:handle_tool_errorhandle_validation_error。由于 StructuredTool 继承自 BaseTool,你也可以将这两个标志传给 StructuredTool.from_function 方法。如果设置了这些标志,当工具执行时遇到 ToolException 或 Pydantic 验证异常(验证输入载荷时)时,LangChain 会构造一段字符串作为工具调用的结果返回。

来看 LangChain 源码中 _handle_tool_error 函数:

def _handle_tool_error(
    e: ToolException,
    *,
    flag: Optional[Union[Literal[True], str, Callable[[ToolException], str]]],
) -> str:
    if isinstance(flag, bool):
        content = e.args[0] if e.args else "Tool execution error"
    elif isinstance(flag, str):
        content = flag
    elif callable(flag):
        content = flag(e)
    else:
        msg = (
            f"Got an unexpected type of `handle_tool_error`. Expected bool, str "
            f"or callable. Received: {flag}"
        )
        raise ValueError(msg)
    return content

可以看到,handle_tool_error 标志可以是布尔值、字符串或可调用对象(接收 ToolException 并返回字符串)。LangChain 会根据该标志捕获异常并返回对应字符串,传递给后续流程。基于此,我们可以将异常处理机制集成进工作流,实现自动恢复循环。

举个例子,我们修改计算器函数,去掉 i 替换为 j 的逻辑(数学中虚数单位 i 转为 Python 虚数单位 j 的替换),同时让 StructuredTool 自动从文档字符串继承描述和参数模式:

from langchain_core.tools import StructuredTool
import numexpr as ne

def calculator(expression: str) -> str:
    """Calculates a single mathematical expression, incl. complex numbers."""
    return str(ne.evaluate(expression.strip(), local_dict={}))

calculator_tool = StructuredTool.from_function(
    func=calculator,
    handle_tool_error=True
)

agent = create_react_agent(
    llm, [calculator_tool]
)

for event in agent.stream({"messages": [("user", "How much is (2+3i)^2")]}, stream_mode="values"):
    event["messages"][-1].pretty_print()

输出示例:

============================== Human Message ================================
How much is (2+3i)^2
================================== Ai Message ==================================
Tool Calls:
calculator (8bfd3661-d2e1-4b8d-84f4-0be4892d517b)
Call ID: 8bfd3661-d2e1-4b8d-84f4-0be4892d517b
Args:
expression: (2+3i)^2
================================= Tool Message =================================
Name: calculator
Error: SyntaxError('invalid decimal literal', ('<expr>', 1, 4, '(2+3i)^2', 1, 4))
Please fix your mistakes.
================================== Ai Message ==================================
(2+3i)^2 is equal to -5 + 12i. I tried to use the calculator tool, but it returned an error. I will calculate it manually for you.
(2+3i)^2 = (2+3i)*(2+3i) = 2*2 + 2*3i + 3i*2 + 3i*3i = 4 + 6i + 6i - 9 = -5 + 12i

如你所见,计算器执行失败,但错误信息不够清晰,LLM 选择不再使用工具,而是自己回复答案。具体行为可根据需求调整,比如让工具返回更有意义的错误信息,或让工作流尝试修正工具调用载荷等。

LangGraph 还提供了内置的 ValidationNode,它会检查图状态中的最后消息,判断是否包含工具调用;若工具调用不符合预期模式,会抛出带有验证错误的 ToolMessage,并附带默认修正命令。你可以添加条件边将控制权回传给 LLM,促使其重新生成工具调用,类似第3章讨论的模式。

至此,我们已了解什么是工具、如何创建工具以及如何使用内置的 LangChain 工具。接下来,来看看可以传给 LLM 的附加指令,告诉它如何更好地使用工具。

高级工具调用能力

许多大语言模型(LLMs)在工具调用方面提供了额外的配置选项。首先,有些模型支持并行函数调用——即 LLM 可以同时调用多个工具。LangChain 原生支持这一点,因为 AIMessagetool_calls 字段就是一个列表。当你将 ToolMessage 对象作为函数调用结果返回时,需要仔细对应每个 ToolMessagetool_call_id 与生成的调用载荷保持一致。这种对齐对 LangChain 及其底层的 LLM 在进行下一轮交互时正确匹配调用至关重要。

另一个高级功能是强制 LLM 调用工具,甚至强制调用特定工具。通常,LLM 会决定是否调用工具,若调用则从提供的工具列表中选择具体工具。这个过程通常由传递给 invoke 方法的 tool_choice 和/或 tool_config 参数控制,但具体实现依赖于模型供应商。Anthropic、Google、OpenAI 等主流厂商的 API 存在细微差别,尽管 LangChain 力求统一参数接口,但在这类情况下应仔细查阅对应模型提供商的文档细节。

常见的配置选项包括:

  • "auto":允许 LLM 自由选择响应或调用一个或多个工具。
  • "any":强制 LLM 通过调用一个或多个工具进行响应。
  • "tool" 或带有限定工具列表的 "any":强制 LLM 从指定的限制列表中调用工具响应。
  • "none":强制 LLM 不调用任何工具直接响应。

需要注意的是,工具调用的模式(schema)可能非常复杂,比如包含可空字段、嵌套字段、枚举类型或引用其他模式。不同模型供应商对这些定义的支持程度不同,可能会产生警告或编译错误。尽管 LangChain 致力于实现跨供应商的无缝切换,但在某些复杂工作流下,可能不完全适用,因此应关注错误日志中的警告信息。有时提供的模式会基于经验做最佳匹配转译成模型支持的形式——例如,当模型不支持 Union[str, int] 类型时,会被简化为 str 类型,并发出警告。迁移时忽视这类警告可能导致应用行为不可预测的变化。

最后值得一提的是,一些供应商(如 OpenAI、Google)提供了可由模型自身调用的定制工具,比如代码解释器或 Google 搜索,模型会基于工具输出准备最终生成结果。可以将其看作供应商端的 ReACT 代理,模型获得增强响应,显著降低延迟和成本。在此类情况下,你通常需为 LangChain 提供基于供应商 SDK 创建的定制工具包装,而非 LangChain 内置的(即非继承自 BaseTool 类的工具),这意味着相关代码不可跨模型复用。

将工具融入工作流

既然我们已经了解如何创建和使用工具,接下来讨论如何更深入地将工具调用范式整合进我们开发的工作流中。

受控生成

在第3章,我们开始讨论受控生成,即希望 LLM 遵循特定模式(schema)生成内容。我们不仅可以通过设计更复杂、可靠的解析器来改进解析工作流,还可以更严格地强制 LLM 遵循某个模式。调用工具本质上就是一种受控生成,因为生成的请求载荷必须符合指定模式。但我们可以退一步,将期望的模式替换为强制调用符合该模式的工具。

LangChain 内置了相关机制——LLM 提供了 with_structured_output 方法,它接收一个 Pydantic 模型作为模式,将其转为工具,使用给定提示强制调用该工具,并通过编译输出为对应的 Pydantic 模型实例完成解析。

本章后面会讨论“计划-解决”型代理,先准备一个构建模块。让 LLM 生成一个行动计划,但不直接解析文本,而是用 Pydantic 模型来定义(Plan 是一系列 Step):

from pydantic import BaseModel, Field

class Step(BaseModel):
    """解决任务的步骤之一。"""
    step: str = Field(description="步骤描述")

class Plan(BaseModel):
    """解决任务的计划。"""
    steps: list[Step]

注意这里使用了嵌套模型(一个字段引用另一个模型),LangChain 会帮我们编译成统一的模式。我们写个简单工作流试试:

prompt = PromptTemplate.from_template(
    "Prepare a step-by-step plan to solve the given task.\n"
    "TASK:\n{task}\n"
)

result = (prompt | llm.with_structured_output(Plan)).invoke(
    "How to write a bestseller on Amazon about generative AI?"
)

查看输出,我们会得到一个 Pydantic 模型对象,不需要再手动解析文本,直接获得具体步骤列表(后面会讲如何进一步使用):

assert isinstance(result, Plan)
print(f"Amount of steps: {len(result.steps)}")
for step in result.steps:
    print(step.step)
    break

示例输出:

Amount of steps: 21
**1. Idea Generation and Validation:**

厂商提供的受控生成

另一种方式依赖于模型厂商。有些基础模型提供商支持额外的 API 参数,允许指示模型生成结构化输出(通常是 JSON 或枚举类型)。你可以像使用 with_structured_output 一样强制模型生成 JSON,但传入另一个参数 method="json_mode"(并且需要确认底层模型提供商支持以 JSON 形式进行受控生成):

plan_schema = {
    "type": "ARRAY",
    "items": {
        "type": "OBJECT",
        "properties": {
            "step": {"type": "STRING"},
        },
    },
}

query = "How to write a bestseller on Amazon about generative AI?"

result = (prompt | llm.with_structured_output(schema=plan_schema, method="json_mode")).invoke(query)

注意这里的 JSON schema 不包含字段描述,因此你的提示词通常需要更详尽、信息更丰富。但输出是一个完整的 Python 字典列表:

assert isinstance(result, list)
print(f"Amount of steps: {len(result)}")
print(result[0])

示例输出:

Amount of steps: 10
{'step': 'Step 1: Define your niche and target audience. Generative AI is a broad topic. Focus on a specific area, like generative AI in marketing, art, music, or writing. Identify your ideal reader (such as marketers, artists, developers).'}

你也可以直接指示 LLM 实例遵循受控生成指令。需要注意的是,不同模型厂商对参数和功能的支持存在差异(例如 OpenAI 模型使用 response_format 参数)。下面示例演示如何指示 Gemini 返回 JSON:

from langchain_core.output_parsers import JsonOutputParser

llm_json = ChatVertexAI(
    model_name="gemini-1.5-pro-002",
    response_mime_type="application/json",
    response_schema=plan_schema
)

result = (prompt | llm_json | JsonOutputParser()).invoke(query)
assert isinstance(result, list)

此外,我们也可以要求 Gemini 返回枚举值,也就是说,从一组固定值中返回一个:

from langchain_core.output_parsers import StrOutputParser

response_schema = {"type": "STRING", "enum": ["positive", "negative", "neutral"]}

prompt = PromptTemplate.from_template(
    "Classify the tone of the following customer's review:\n{review}\n"
)

review = "I like this movie!"

llm_enum = ChatVertexAI(
    model_name="gemini-1.5-pro-002",
    response_mime_type="text/x.enum",
    response_schema=response_schema
)

result = (prompt | llm_enum | StrOutputParser()).invoke(review)
print(result)

示例输出:

positive

LangChain 通过 method="json_mode" 参数或允许传递自定义 kwargs 抽象了模型厂商实现的细节。一些受控生成能力是模型特定的,请查看所用模型的文档,了解支持的 schema 类型、约束和参数。

ToolNode(工具节点)

为了简化代理开发,LangGraph 提供了内置功能,如 ToolNodetool_conditionsToolNode 会检查消息列表中的最后一条消息(你可以重新定义消息键名),如果该消息包含工具调用,则执行相应工具并更新状态。另一方面,tool_conditions 是一个条件边,用于判断是否应调用 ToolNode(否则流程结束)。

现在,我们可以用几分钟时间构建自己的 ReACT 引擎:

from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.graph import StateGraph, START, MessagesState

def invoke_llm(state: MessagesState):
    return {"messages": [llm_with_tools.invoke(state["messages"])]}

builder = StateGraph(MessagesState)
builder.add_node("invoke_llm", invoke_llm)
builder.add_node("tools", ToolNode([search, calculator]))
builder.add_edge(START, "invoke_llm")
builder.add_conditional_edges("invoke_llm", tools_condition)
builder.add_edge("tools", "invoke_llm")
graph = builder.compile()

工具调用范式

工具调用是一种非常强大的设计范式,它要求你改变开发应用的方式。在很多情况下,与其反复做提示工程、尝试改进提示词,不如考虑让模型调用工具来完成任务。

举个例子,假设我们开发的代理要处理合同取消,且必须遵守某些业务逻辑。首先要理解合同开始日期(而日期处理通常比较复杂!)。若你试图设计一个提示词来正确处理类似以下示例,可能会发现非常困难:

examples = [
    "I signed my contract 2 years ago",
    "I started the deal with your company in February last year",
    "Our contract started on March 24th two years ago"
]

这时,强制模型调用工具(甚至通过 ReACT 代理)会更有效。比如我们用两个非常本地化的 Python 工具:datetimedelta

from datetime import date, timedelta
from langchain_core.tools import tool

@tool
def get_date(year: int, month: int = 1, day: int = 1) -> date:
    """根据年月日返回日期对象。
    默认月和日为 1。
    示例(YYYY-MM-DD 格式):
    2023-07-27 -> date(2023, 7, 27)
    2022-12-15 -> date(2022, 12, 15)
    March 2022 -> date(2022, 3)
    2021 -> date(2021)
    """
    return date(year, month, day).isoformat()

@tool
def time_difference(days: int = 0, weeks: int = 0, months: int = 0, years: int = 0) -> date:
    """根据与当前日期相差的天数、周数、月数和年数返回日期。
    默认都为 0。
    示例:
    两周前 -> time_difference(weeks=2)
    去年 -> time_difference(years=1)
    """
    dt = date.today() - timedelta(days=days, weeks=weeks)
    new_year = dt.year + (dt.month - months) // 12 - years
    new_month = (dt.month - months) % 12
    return dt.replace(year=new_year, month=new_month)

这样工具就能完美工作了:

from langchain_google_vertexai import ChatVertexAI
from langgraph.prebuilt import create_react_agent

llm = ChatVertexAI(model="gemini-1.5-pro-002")
agent = create_react_agent(
    llm, [get_date, time_difference],
    prompt="Extract the starting date of a contract. Current year is 2025."
)

for example in examples:
    result = agent.invoke({"messages": [("user", example)]})
    print(example, result["messages"][-1].content)

示例输出:

I signed my contract 2 years ago The contract started on 2023-02-07.
I started the deal with your company in February last year The contract started on 2024-02-01.
Our contract started on March 24th two years ago The contract started on 2023-03-24

通过以上,我们学会了如何利用工具或函数调用来提升 LLM 在复杂任务上的表现。这也是智能代理背后的核心架构模式之一——接下来,我们将讨论什么是代理。

什么是代理?

代理是当前生成式 AI 中非常热门的话题。人们谈论代理时,定义众多。LangChain 本身定义代理为“使用大语言模型(LLM)决定应用控制流程的系统”。虽然这是一个很好的定义,但它略显片面。

作为 Python 开发者,你可能熟悉鸭子类型的概念:“如果它走路像鸭子,叫声像鸭子,那它就是鸭子。”基于此,我们来描述生成式 AI 语境下代理的一些特性:

  • 代理帮助用户解决复杂且非确定性的任务,而无需给出明确算法。高级代理甚至能代表用户主动行动。
  • 为完成任务,代理通常会多次迭代和执行多个步骤:推理(基于上下文生成新信息)、行动(调用工具与外部环境交互)、观察(整合环境反馈)、沟通(与其他代理或人类协作)。
  • 代理利用 LLM 进行推理和任务解决。
  • 虽然代理拥有一定自主性,甚至能通过与环境交互思考学习,发现最佳解决方案,但我们希望在运行代理时仍保持对执行流程的控制。

对代理行为保持控制——即“代理工作流”,是 LangGraph 的核心理念。LangGraph 为开发者提供丰富构建模块(如记忆管理、工具调用、带递归深度控制的循环图),其主要设计模式聚焦于管理 LLM 在执行任务时的控制流和自主权。

下面通过一个例子开发我们的代理。

计划-解决型代理

面对复杂任务,我们人类通常会先做计划!2023年,Lei Want 等人证明计划-解决提示能够提升 LLM 推理效果。多个研究显示,随着提示复杂度(特别是指令长度和数量)增加,LLM 性能会下降。

因此,首要设计模式是任务分解:将复杂任务拆解为一系列更小的子任务,保持提示简洁且聚焦单一任务,并且不吝添加示例。

这里,我们要开发一个研究助理代理。

面对复杂任务,先让 LLM 生成详细计划,然后用同一个 LLM 执行每个步骤。别忘了,LLM 最终是基于输入自回归生成输出,简单模式如 ReACT 或计划-解决帮助更好发挥隐式推理能力。

先定义计划生成器。没什么新意,使用之前讲过的构建模块——聊天提示模板和基于 Pydantic 模型的受控生成:

from pydantic import BaseModel, Field
from langchain_core.prompts import ChatPromptTemplate

class Plan(BaseModel):
    """后续执行的计划"""
    steps: list[str] = Field(description="按顺序排列的多个步骤")

system_prompt_template = (
    "针对给定任务,设计分步执行计划。\n"
    "该计划包含若干独立任务,正确执行能得到正确答案。\n"
    "请勿添加多余步骤。\n"
    "最终步骤结果即为最终答案。确保每一步都有充分信息,不可跳步。"
)

planner_prompt = ChatPromptTemplate.from_messages([
    ("system", system_prompt_template),
    ("user", "请准备解决以下任务的计划:\n{task}\n")
])

planner = planner_prompt | ChatVertexAI(
    model_name="gemini-1.5-pro-002", temperature=1.0
).with_structured_output(Plan)

步骤执行阶段,使用带内置工具的 ReACT 代理——包含 DuckDuckGo 搜索、arXiv 和 Wikipedia 检索器,以及本章早先开发的自定义计算器工具:

from langchain.agents import load_tools

tools = load_tools(
    tool_names=["ddg-search", "arxiv", "wikipedia"],
    llm=llm
) + [calculator_tool]

定义工作流状态,需跟踪初始任务、初步计划,并添加 past_stepsfinal_response

from typing import TypedDict, Annotated
from operator import add

class PlanState(TypedDict):
    task: str
    plan: Plan
    past_steps: Annotated[list[str], add]
    final_response: str
    past_steps: list[str]

def get_current_step(state: PlanState) -> int:
    """返回当前执行的步骤编号"""
    return len(state.get("past_steps", []))

def get_full_plan(state: PlanState) -> str:
    """返回带步骤编号和过去结果的完整计划字符串"""
    full_plan = []
    for i, step in enumerate(state["plan"].steps):
        full_step = f"# {i+1}. Planned step: {step}\n"
        if i < get_current_step(state):
            full_step += f"Result: {state['past_steps'][i]}\n"
        full_plan.append(full_step)
    return "\n".join(full_plan)

定义节点和边:

from typing import Literal
from langgraph.graph import StateGraph, START, END
from langchain_core.prompts import PromptTemplate

final_prompt = PromptTemplate.from_template(
    "你是一个帮助执行计划的助手。"
    "根据执行结果,准备最终回答。\n"
    "不要假设任何信息。\n"
    "任务:\n{task}\n\n计划与结果:\n{plan}\n"
    "最终回答:\n"
)

async def _build_initial_plan(state: PlanState) -> PlanState:
    plan = await planner.ainvoke(state["task"])
    return {"plan": plan}

async def _run_step(state: PlanState) -> PlanState:
    plan = state["plan"]
    current_step = get_current_step(state)
    step = await execution_agent.ainvoke({
        "plan": get_full_plan(state),
        "step": plan.steps[current_step],
        "task": state["task"]
    })
    return {"past_steps": [step["messages"][-1].content]}

async def _get_final_response(state: PlanState) -> PlanState:
    final_response = await (final_prompt | llm).ainvoke({
        "task": state["task"],
        "plan": get_full_plan(state)
    })
    return {"final_response": final_response}

def _should_continue(state: PlanState) -> Literal["run", "response"]:
    if get_current_step(state) < len(state["plan"].steps):
        return "run"
    return "final_response"

组合最终图:

builder = StateGraph(PlanState)
builder.add_node("initial_plan", _build_initial_plan)
builder.add_node("run", _run_step)
builder.add_node("response", _get_final_response)
builder.add_edge(START, "initial_plan")
builder.add_edge("initial_plan", "run")
builder.add_conditional_edges("run", _should_continue)
builder.add_edge("response", END)
graph = builder.compile()

用 IPython 显示图示:

from IPython.display import Image, display
display(Image(graph.get_graph().draw_mermaid_png()))

image.png

现在我们可以运行这个工作流了:

task = "Write a strategic one-pager of building an AI startup"
result = await graph.ainvoke({"task": task})

你可以在我们的 GitHub 上查看完整输出,我们也鼓励你亲自尝试。特别值得探究的是,相比于只用单个 LLM 提示词处理任务,你是否更喜欢这种多步骤计划与执行结合的结果。

总结

本章我们探讨了如何通过集成工具及设计工具调用模式(包括 ReACT 模式)来增强大语言模型(LLM)。首先,我们从零构建了一个 ReACT 代理,随后展示了如何用一行代码利用 LangGraph 创建自定义代理。

接着,我们深入介绍了受控生成的高级技巧——包括如何强制 LLM 调用任意工具或指定工具,以及指导模型以结构化格式(如 JSON、枚举或 Pydantic 模型)返回响应。在此背景下,我们讲解了 LangChain 的 with_structured_output 方法,该方法将你的数据结构转换为工具模式,促使模型调用工具,解析输出,并编译为相应的 Pydantic 实例。

最后,我们利用 LangGraph 构建了第一个计划-解决型代理,综合应用了工具调用、ReACT、结构化输出等所学概念。下一章将继续探讨代理开发,并深入更高级的架构模式。