LangChain-生成式-AI-第二版--三-
LangChain 生成式 AI(第二版)(三)
原文:Generative AI with LangChain
译者:飞龙
文档检索
rag.py 模块基于语义相似性实现文档检索。我们有这些主要组件:
-
文本分分
-
内存向量存储
-
DocumentRetriever类
让我们再次开始导入:
import os
import tempfile
from typing import List, Any
from langchain_core.callbacks import CallbackManagerForRetriever
from langchain_core.documents import Document
from langchain_core.retrievers import import BaseRetriever
from langchain_chain.vectorstores import import InMemoryVectorStore
from langchain_text_splitters import import RecursiveCharacterTextSplitter
from chapter4.document_loader import load_document
from chapter4.llms import EMBEDDINGS
我们需要为检索器设置一个向量存储:
VECTOR_STORE = InMemoryVectorStore(embedding=EMBEDDINGS)
文档块存储在 InMemoryVectorStore 中,使用嵌入以便快速检索。该模块使用 RecursiveCharacterTextSplitter 将文档拆分为更小的单元:
def split_documents(docs: List[Document]) -> list[Document]:
"""拆分文档."""
splitter = RecursiveCharacterTextSplitter(
chunk_size=1500, chunk_overlap=200
)
return splitter.split_documents(docs)
此自定义检索器继承自 BaseRetriever 并管理内部文档列表:
class DocumentRetriever(BaseRetriever):
"""一个获取用户查询的检索器."""
documents: List[Document] = []
k: int = 5
def model_post_init(self, Any) -> None:
self.store_documents(self.documents)
@staticmethod
def store_documents(docs: List[Document]) -> None:
"""将文档添加到向量存储."""
splits = split_documents(docs)
VECTOR_STORE.add_documents(splits)
def add_uploaded_docs(self, uploaded_files):
"""添加上传的文档."""
docs = []
temp_dir = tempfile.TemporaryDirectory()
for file in uploaded_files:
temp_filepath = os.path.join(temp_dir.name, file.name)
with open(temp_filepath, "wb") as f:
f.write(file.getvalue())
docs.extend(load_document(temp_filepath))
self.documents.extend(docs)
self.store_documents(docs)
def get_relevant_documents(
self, query: str, *run_manager: CallbackManagerRetrieverRun
) -> List[Document]:
"""检索器的同步实现."""
if len(self.documents) == 0:
return []
return VECTOR_STORE.similarity_search(query, k=self.k)
我们解释以下几个方法:
-
store_documents(docs)拆分文档并将其添加到存储。 -
add_uploaded_docs(docs)处理用户上传的文件,将其临时存储,加载为文档并添加到向量存储。 -
get_relevant_documents(query)从向量存储中获取与给定查询相关的 k 个文档。这就是我们使用的相似性结果。
设计状态图
rag.py 模块实现了 RAG 流水线,将文档检索与基于 LLM 的生成结合:
-
系统提示词:模板提示指示 AI 在生成响应时使用提供的文档片段。该提示词设置了上下文并指导如何利用检索到的信息。
-
状态定义:
TypedDict类定义了图状态的结构,跟踪关键信息,如用户查询、检索到的上下文文档、生成的答案、问题报告以及对话消息历史。此状态对象在我们的流水线中的每个节点之间流动并在每一步进行更新。
Pipeline 步骤
该模块定义了几个关键函数,作为我们图中的处理节点:
-
retrieve function:根据用户查询获取相关文档
-
generate function:使用检索到的文档和查询创建草稿答案
-
double_check function:评估生成的内容是否符合公司标准
-
doc_finalizer function:如果没有发现问题,则返回原始答案;或者根据检查器的反馈进行修订
-
Graph compilation:使用状态图(通过
LangGraph的StateGraph)定义步骤的序列。然后将流水线编译为可运行的图,该图能够通过整个工作流处理流程查询。
让我们先处理导入部分:
from typing import Annotated
from langchain_core.documents import Document
from langchain_core.messages import AIMessage
from langchain_core.prompts import ChatPromptTemplate
from langgraph.checkpoint.memory import MemorySaver
from langgraph.constants import END
from langgraph.graph import START, StateGraph, add_messages
from typing_extensions import List, TypedDict
from chapter4.llms import chat_model, DocumentRetriever
正如我们之前提到的,系统提示词模板指示 AI 如何在生成响应时使用提供的文档片段:
system_prompt = (
"你是一个乐于帮助的 AI 助手。给定用户问题 "
"和一些公司文档片段,请编写文档。"
"如果没有任何文档与问题相关,"
"请说明没有相关文档,然后"
"根据你所知的知识尽力回答问题。"
"\n\n以下是公司文档:"
"{context}"
)
然后我们将实例化一个 DocumentRetriever 和一个提示词:
retriever = DocumentRetriever()
prompt = ChatPromptTemplate.from_messages(
[
("system", system_prompt),
("human", "(question)"),
]
)
在这里我们需要定义图的状态。使用 TypedDict 状态来保存应用程序当前的状态(例如:问题、上下文文档、答案、问题报告):
class State(TypedDict):
question: str
context: List[Document]
answer: str
issues_report: str
issues_detected: bool
messages: Annotated[list, add_messages]
这些字段每一个对应于我们用 LangGraph 定义的图节点。我们在节点中执行了以下处理:
-
retrieve function:使用检索器根据最新的消息获取相关文档
-
generate function:通过聊天提示词将检索到的文档内容与用户问题结合,创建草稿答案
-
double_check function:审查生成的草稿是否符合公司标准。它会检查草稿并在发现问题时设置标记
-
doc_finalizer function:如果发现问题,它将根据提供的反馈修订文档;否则,返回原始答案
让我们从检索开始:
def retrieve(state: State):
retrieved_docs = retriever.invoke(state["messages"][-1].content)
print(retrieved_docs)
return {"context": retrieved_docs}
def generate(state: State):
docs_content = "\n\n".join(doc.page_content for doc in state["context"])
messages = prompt.invoke(
{"question": state["messages"][-1].content, "context": docs_content}
)
response = chat_model.invoke(messages)
return {"answer": response.content}
我们将内容验证检查作为 RAG 流水线中一个关键的质量保证步骤。在生产环境中,我们可以实现人工参与(human-in-the-loop)的审查流程或更复杂的护栏。在这里,我们使用 LLM 来分析生成的内容是否存在问题:
def double_check(state: State):
result = chat_model.invoke(
{
"role": "user",
"content": (
"检查以下项目文档以符合符合我们的公司标准。\n"
"返回 'ISSUES FOUND' 并跟上任何发现的问题,或者返回 'NO ISSUES':{state['answer']}"
)
}
)
if "ISSUES FOUND" in result.content:
print("issues detected")
return {
"issues_report": result.split("ISSUES FOUND", 1)[1].strip(),
"issues_detected": True
}
print("no issues detected")
return {
"issues_report": "",
"issues_detected": False
}
最终节点整合任何反馈以生成最终的、合规文档:
def doc_finalizer(state: State):
"""通过整合反馈完成文档编制。",
if "issues_detected" in state and state['issues_detected']:
response = chat_model.invoke({
"role": "user",
"content": (
f"修订以下文档以解决这些反馈点:n{state['issues_report']}\n\n原始文档:{state['answer']}\n\n即使不需要更改,也始终返回完整的修订后文档。"
)
})
现在我们定义图:
builder = StateGraph(State)
builder.add_node("retrieve", retrieve)
builder.add_node("generate", generate)
builder.add_node("double_check", double_check)
builder.add_node("doc_finalizer", doc_finalizer)
builder.set_entry_point(START, "retrieve")
builder.add_edge("retrieve", "generate")
builder.add_edge("generate", "double_check")
builder.add_edge("double_check", "doc_finalizer")
builder.add_edge("doc_finalizer", END)
memory = MemorySaver()
graph = builder.compile(checkpointer=memory)
测试图:
inputs = {"messages": [HumanMessage("远程办公的政策是什么?")]}
config = {"configurable": {"thread_id": "1"}}
for event in graph.stream(inputs, config):
print(event)
我们还可以可视化图:
from IPython.display import Image, display
display(Image(graph.get_graph().draw_mermaid_png()))
集成 Streamlit
我们将流水线集成到 Streamlit 以实现交互式文档生成。该界面允许用户提交文档请求并实时查看过程:
import streamlit as st
from langchain_core.messages import HumanMessage
from chapter4.document_loader import DocumentLoader
from chapter4.rag import graph, config, retriever
我们将配置 Streamlit 页面标题和宽布局以获得更好的可读性:
st.set_page_config(page_title="公司文档管理器", layout="wide")
我们将初始化用于聊天和文件管理的会话状态:
if "chat_history" not in st.session_state:
st.session_state.chat_history = []
if 'uploaded_files' not in st.session_state:
st.session_state.uploaded_files = []
每次重新加载应用时,我们在应用上显示历史中的聊天消息:
for message in st.session_state.chat_history:
with st.chat_message(message["role"]):
st.markdown(message["content"])
st.sidebar("上传文档")
检索器处理所有上传的文件并对其进行嵌入以供语义搜索:
docs = retriever.add_uploaded_docs(st.session_state.uploaded_files)
请记住避免调用相同的文档,我们正在使用缓存。
我们需要一个调用图并返回字符串的函数:
def process_message(message):
"""助手响应"""
if prompt := st.chat_input("输入问题..."):
st.session_state.chat_history.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
with st.chat_message("assistant"):
response = process_message(prompt)
st.markdown(response)
st.session_state.chat_history.append({"role": "assistant", "content": response})
response = graph.invoke({"messages": HumanMessage(message)}, config=config)
return response["messages"][-1].content
这忽略了之前的消息。我们可以更改提示词,将之前的消息提供给 LLM。然后我们可以使用 markdown 显示项目描述。简要介绍如下:
st.markdown("""
带有引用的企业级文档管理器
接下来,我们将 UI 分为两列,一列用于聊天,另一列用于文件管理:
col1, col2 = st.columns([2, 1])
第一列如下所示:
with col1:
st.subheader("Chat Interface")
# 响应用户输入
if user_message := st.chat_input("Enter your message:"):
# 在聊天消息容器中显示用户消息
with st.chat_message("User"):
st.markdown(user_message)
# 将用户消息添加到聊天历史
st.session_state.chat_history.append({"role": "User", "content": user_message})
response = process_message(user_message)
with st.chat_message("Assistant"):
st.markdown(response)
# 将响应添加到聊天历史
st.session_state.chat_history.append(
{"role": "Assistant", "content": response}
)
第二列获取文件并将其交给检索器:
with col2:
st.subheader("Document Management")
## File uploader
uploaded_files = st.file_uploader(
"Upload Documents",
type=list(DocumentLoader.supported_extensions),
accept_multiple_files=True
)
for file in uploaded_files:
if file.name not in st.session_state.uploaded_files:
st.session_state.uploaded_files.append(file)
若要在 Linux 或 macOS 上运行我们的企业级文档管理器应用程序,请按照以下步骤操作:
-
打开终端并切换到项目文件所在的目录。这确保了
chapter4/目录是可以访问的。 -
设置
PYTHONPATH并运行Streamlit。项目中的导入依赖于当前目录在 Python 模块搜索路径中。因此,我们将在运行Streamlit时设置PYTHONPATH。PYTHONPATH= streamlit run chapter4/streamlit_app.py前面的命令告诉 Python 在当前目录中查找模块,以便其找到
chapter4包。 -
命令成功运行后,
Streamlit将启动一个 Web 服务器。打开你的网络浏览器并导航到 http://localhost:8501 来使用该应用程序。
故障排除提示
-
确保你已安装所有所需的包。你可以按照第 2 节介绍的方法,使用
pip或其他包管理器确保系统上安装了 Python。 -
如果遇到导入错误,请验证你是否处于正确的目录中,以及
PYTHONPATH是否设置正确。
通过遵循这些步骤,你应该能够运行应用程序并轻松用其生成、检查和定稿企业级文档。
评估与性能考量
在第 3 章中,我们探索了在企业级文档管理器中实现带有引用的 RAG。为了进一步提高可靠性,可以在流水线中加入额外的机制。一种改进方法是集成健壮的检索系统(如 FAISS、Pinecone 或 Elasticsearch)来获取实时来源。配合精确率(precision)、召回率(recall)和平均倒数排名(mrr)等评分机制可以评估检索质量。另一项改进涉及通过将生成的响应与真实值(ground-truth)或策划的引用进行比较来评估准确性,并引入人工参与(human-in-the-loop)以确保输出正确且有效。
在每个节点内部实现健壮的错误处理程序同样重要。例如,如果引用检索失败,系统可能会回退到默认源或注明无法检索引用。通过记录 API 调用、节点执行时间和检索性能,在流水线中建立可观测性,对于扩展规模和维护生产环境的可靠性至关重要。在可能的情况下利用本地模型优化 API、缓存查询,并在处理大规模嵌入时高效管理内存,进一步支持成本优化和可扩展性。
评估和优化我们的文档机器人对于准确性和效率都至关重要。现代基准关注文档是否符合企业标准,以及它们多准确地回答了原始请求。检索质量指标(如精确率、召回率和平均倒数排名)衡量了合规性检查期间检索相关内容的有效性。将 AI 生成的文档与真实值或手动策划的示例进行比较,为评估准确性提供了依据。通过微调参数以实现快速检索、优化大规模嵌入的内存管理以及在适用于使用本地模型进行推理以降低 API ,可以提高性能。
这些策略构建了一个更可靠、透明且适用于生产的 RAG 应用,它不仅能生成内容,还能解释其来源。进一步的性能和可观测性策略将在第 8 章中介绍。
构建有效的 RAG 系统意味着理解其常见的失败点并通过定量和基于测试的策略解决它们。在下一节中,我们将探索 RAG 系统中的典型失败点和最佳实践。
RAG 系统的故障排除
Barnett 及其同事在论文 Seven Failure Points When Engineering a Retrieval Augmented Generation System (2024) 中,以及 Li 同事在论文 Enhancing Retrieval-Augmented Generation: A Study of Best Practices (2025) 中强调了健壮设计和持续校准的重要性:
-
基础设置:确保拥有全面且高质量的文档集合、清晰的提示词表述以及能够提高精确性和相关性的有效检索技术。
-
持续校准:定期监控、用户反馈和更新知识库有助于解决运行期间出现的问题。
通过在开发早期实施这些实践,可以防止许多 RAG 失败。然而,即使是设计良好的系统也会遇到问题。接下来的章节将探讨 Barnett 及其同事(2024)识别的最常见失败点,并根据实证研究提供针对性的策略。
以下是一些常见的失败点及其补救措施:
-
内容缺失:当系统缺少相关文档时发生失败。通过在摄取期间验证内容并添加领域特定资源来防止这一点。使用显式信号指示信息何时可用。
-
错过排名靠前的文档:即使存在相关文档,排名不佳也会导致它们被排除。通过先进的嵌入模型、混合语义-词搜索以及句子级检索来改进这一点。
-
上下文窗口限制:当信息分布在超过模型上下文限制的文档中时,可能会被截断。通过优化文档分块(
chunking)和提取最相关的句子来缓解此。 -
信息提取失败:有时
LLM无法合成可用上下文。这可以通过改进提示词设计来解决——使用显式指令和对比示例可以提高提取准确性。 -
格式合规性问题:答案可能是正确的但格式错误(例如错误的表格或
JSON结构)。使用解析器强制结构化输出、精确的格式示例和后处理验证。 -
特定性不匹配:输出可能太泛泛或过于详细。通过查询扩展技术和根据用户的专业水平定制提示词来解决此问题。
-
信息不完整:答案可能捕获了专业细节的一部分。确保检索多样性(例如使用最大边际相关性)并优化查询转换方法以涵盖查询的所有方面。
集成检索方法(例如先检索文档再提取关键句子)已被证明可以提高性能——弥补了模型尺寸较小的某些差距。持续测试和提示词工程对于随着运行环境演变时保持系统质量至关重要。
总结
在本章中,我们探索了 RAG 的关键方面,包括向量存储、文档处理、检索策略和实现。随后,我们构建了一个全面的 RAG 机器人,它利用 LangChain 进行大语言模型(LLM)交互和工作流编排。这是一个如何设计模块化、可维护且用户友好 LLM 应用的典型示例,这些应用不仅能生成创造性输出,还能结合反馈循环。
这一基础为更高级的 RAG 系统开启了大门,无论你是在检索文档、增强上下文,还是根据用户需求定制应用程序。当你继续开发生产级别的 LLM 应用时,请考虑如何适配并扩展这些模式以满足你的需求。在第 8 章中,我们将讨论如何进行基准测试并量化 RAG 系统的性能,以确保性能符合要求。
在下一章中,我们将在此基础上引入智能体(Agents),它们可以利用工具来增强交互。我们将涵盖各种集成策略、结构化工具输出生成以及如 ReACT 等智能体架构。这将允许我们开发出更强大的 AI 系统,能够与外部资源动态交互。
问题
-
- 在
RAG中使用向量嵌入的主要优势是什么?
- 在
-
MMR如何改进文档检索?
-
- 为什么分块(
Chunking)对于有效的文档检索是必要的?
- 为什么分块(
-
- 可以使用哪些策略来缓解
RAG实现中的幻觉?
- 可以使用哪些策略来缓解
-
- 混合搜索技术如何增强检索过程?
-
- 为什么性能评估对于基于
RAG的系统至关重要?
- 为什么性能评估对于基于
-
- 为什么
RAG系统中有不同的检索方法?
- 为什么
-
- 上下文压缩在
LLM处理之前如何精炼检索到的信息?
- 上下文压缩在
订阅我们的每周时报
订阅 AI_Distilled,这是 AI 行业人士、研究人员和创新者的首选时报,地址为: https://packt.link/05Uyu 。
5 构建智能智能体
随着生成式 AI 的日益普及,我们开始使用 LLM 处理更开放、复杂的任务,这些任务需要关于新事件的知识或与世界进行交互。这通常被称为智能体应用(agentic applications)。我们将在本章稍后定义什么是智能体,但你可能已经在媒体上看到这个短语:2025 年是智能体 AI 之年。例如,在最近引入的包含复杂开放式任务的 RE-Bench 基准中,AI 智能体在某些设置下优于人类(例如,在 30 分钟的思考预算内),或在某些特定任务上(如编写 Triton 内核)。
为了理解实践中如何构建这些智能体能力,我们将从讨论与 LLM 的工具调用(tool calling)以及它如何在 LangChain 中实现开始。我们将详细研究 ReACT 模式,以及 LLM 如何使用工具与外部环境交互并提高它们在特定任务上的性能。然后,我们将涉及如何在 LangChain 中定义工具以及提供哪些预构建工具。我们还将讨论如何开发你自己的自定义工具、处理错误以及使用高级的工具调用能力。作为一个实际示例,我们将查看使用工具通过 LLM 生成结构化输出与使用模型提供者提供的内置能力相比。
最后,我们将讨论什么是智能体,并研究使用 LangGraph 构建智能体的更高级模式,然后我们将使用 LangGraph 开发我们的第一个 ReACT 智能体——一个遵循“计划与解决”设计模式并使用网络搜索、arXiv 和维基百科等工具的研究型智能体。
简而言之,本章将涵盖以下主题:
-
什么是工具?
-
定义内置的
LangChain工具和自定义工具 -
高级工具调用能力
-
将工具整合到工作流中
-
什么是智能体?
你可以在书籍
GitHub仓库的chapter5/目录中找到本章的代码。请访问 https://github.com/benman1/generative_ai_with_langchain/tree/second_edition 获取最新更新。设置说明请参见第 2 章。如果你在运行代码时有任何问题或遇到问题,请在GitHub上创建issue或加入Discord( https://packt.link/lang )参与讨论。
让我们从工具开始。与其直接定义什么是智能体,不如探索通过工具增强 LLM 在实践中实际上是如何运作的更有帮助。通过逐步这些这些,你将看到这些集成如何释放新能力。那么,工具到底是什么?它们是如何扩展 LLM 功能的?
什么是工具?
LLM 在海量语料库数据(如网络数据和书籍)上进行训练,这赋予了它们广博的知识,但限制了它们在需要领域特定或最新知识的任务中的有效性。然而,由于 LLM 擅长推理,它们可以通过工具与外部环境交互——工具是允许模型与外部世界交互的 API 或接口。这些工具使 LLM 执行特定任务并接收来自外部世界的反馈。
使用工具时,LLM 执行三个特定的生成任务:
-
- 通过生成特殊标记和工具名称来选择要使用的工具。
-
- 生成发送到工具的有效负载(
payload)。
- 生成发送到工具的有效负载(
-
- 根据初始问题和与工具的交互历史(针对本次运行)为用户生成响应。
现在是时候弄清楚 LLM 如何调用工具,以及我们如何让 LLM 具备工具感知能力(tool-aware)了。考虑一个有些人为但具有启发性的问题:现任美国总统年龄乘以 132 的平方根是多少?这个问题提出了两个特定挑战:
-
它引用了当前信息(截至 2025 年 3 月),这些信息可能落在模型的训练数据之外。
-
它需要精确的数学计算,
LLM可能无法仅通过自回归标记生成来正确回答。
与其强制 LLM 仅根据内部知识生成答案,不如让 LLM 访问两个工具:搜索引擎和计算器。我们期望模型能够确定需要哪些工具以及如何使用它们。
为了清晰起见,我们从一个简单的问题开始,通过创建始终返回相同响应的虚拟函数来模拟我们的工具。在本章晚些时候,我们将实现功能完整的工具并调用它们:
question = "美国总统多大了?"
raw_prompt_template = (
"你可以访问一个搜索引擎,它可以根据查询提供"
"关于事件和新闻的信息。根据问题,判断你是否需要"
"额外的信息(回复 'SEARCH: <生成的查询>' 或如果你有足够的"
"知识回答则回复 'RESPONSE <最终回答>')。\n"
"现在,回答用户问题:\n{QUESTION}"
)
prompt_template = PromptTemplate.from_template(raw_prompt_template)
result = (prompt_template | llm).invoke(question)
print(result.response)
让我们确保当
LLM有足够的内部知识时,它会直接回答用户:
LangChain 工具调用与 ReACT 模式
question1 = "德国的首都是哪里?"
result = (prompt_template | llm).invoke(question1)
print(result.response)
RESPONSE: 柏林
最后,让我们通过将工具整合到提示词中为模型提供输出:
query = "现任美国总统的年龄"
search_result = (
"唐纳德·特朗普 年龄 78 岁 1946年6月14日\n"
"唐纳德·特朗普 美国第45任和第47任总统唐纳德·特朗普是一位"
"美国政治家、媒体人物和企业家,自 2025 年 1 月 20 日起担任"
"美国第47任总统。他是共和党成员,此前于 2017 年至 2021 年担任"
"第45任总统。维基百科"
)
"根据给的问题,判断你是否需要从搜索引擎获取额外的"
"信息(回复 'SEARCH: ')"
"<生成的查询语句' 或你已有足够的信息来回答用户"
"则回复 'RESPONSE <最终回复>'。\n"
"今天是 {date}。"
"现在,请回答用户的问题,并"
"考虑到你之前的操作:\n"
"人类: {question}\n"
"AI: SEARCH: {query}\n"
"来自搜索的结果: {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)
作为最后一步观察,如果搜索结果不成功,LLM 将尝试精炼查询:
```python
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
至此,我们已经演示了工具调用(tool calling)是如何工作的。请注意,我们提供的提示示例仅用于演示。其他基础模型 LLM 可能需要一些提示工程(prompt engineering),而我们的提示仅仅是一个插例。好消息是:使用工具比这些示例看起来要简单得多!
正如你所注意到的,我们在提示中描述了一切内容,包括工具描述和工具调用格式。如今,大多数 LLM 为工具调用提供了更好的 API,因为现代 LLM 是在帮助它们在此类任务中表现优异的数据集上进行后训练的。LLM 的创建者了解这些数据集是如何构建的。这就是为什么,通常你不需要在提示中自己整合工具描述;你只需要将提示和工具描述作为独立的参数提供,它们会在提供者端被合并为单个提示。一些较小的开源 LLM 期望工具描述是原始提示的一部分,但它们期望一个定义良好的格式。
LangChain 使开发流水线变得简单,LLM 可以调用不同的工具并提供许多实用的内置工具访问。让我们看看 LangChain 是如何处理工具的。
LangChain 中的工具
对于大多数现代 LLM,为了使用工具,你可以将工具描述列表作为单独参数提供。在 LangChain 中一如既往,每个特定的集成实现都会将接口映射到提供者的 API。对于工具,这是通过 LangChain 的 invoke 方法中的 tools 参数实现的(以及其他一些有用方法,如 bind_tools 等,我们将在本章中学习)。
在定义工具时,我们需要以 OpenAPI 格式指定其架构(schema)。我们提供了工具的标题(title)和描述(description),并指定了它的参数(每个参数都有类型(type)、标题(title)和描述(description))。我们可以从各种格式继承此类架构,LangChain 会将其转换为 OpenAPI 格式。在接几个章节中,我们将演示如何为函数、文档字符串(docstrings)、Pydantic 定义或通过继承 BaseTool 类并直接提供描述来实现此。对于 LLM 来说,工具是任何具有 OpenAPI 规范的东西——换句话说,它可以被外部机制调用。
LLM 本身并不关心这种机制,它只产生何时以及如何调用工具的指令。对于 LangChain 来说,工具也是我们在执行程序时可以被调用的东西(我们稍后会看到工具继承自 Runnables)。
在标题(title)和描述(description)字段中使用的措辞至关重要,你可以将其视为提示工程练习的一部分。更好的措辞有助于 LLM 在何时以及如何调用特定工具上做出更好的决策。请注意,对于更复杂的工具,编写这样的架构可能是繁琐的,我们将在本章稍些时候看到一种更简单的定义工具的方法:
search_tool = {
"title": "google_search",
"description": "根据查询从 Google 搜索引擎返回最新事件和新闻",
"type": "object",
"properties": {
"query": {
"description": "发送到搜索引擎的查询语句",
"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-d6f6fce9d34', 'type': 'tool_call')]
请记住,某些模型提供者可能会返回非空内容,即使在工具调用的情况下(例如,关于模型决定调用工具的推理)。你需要查看模型提供者的规范以如何处理这些情况。正如我们看到的,LLM 返回了一个工具调用数组——每个字典包含一个唯一标识符、要调用的工具名称以及提供给此工具的参数。让我们进入下一步并再次调用模型:
from langchain_core.messages import SystemMessage, HumanMessage, ToolMessage
tool_result = ToolMessage(content="Donald Trump 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)
唐纳德·特朗普 78 岁。
ToolMessage 是 LangChain 中的一种特殊消息,允许你将工具的输出反馈给模型。此类消息的 content 字段包含工具输出,特殊的 tool_call_id 将其映射到特定的工具调用。
始终向 LLM 传递工具列表可能是奇怪的(因为通常情况下这种列表是固定的)。因此,LangChain 的 Runables 提供了一个 bind 方法,它可以记住参数并将其添加到每次调用中。看下面的代码:
llm_with_tools = llm.bind(tools=[search_tool])
llm_with_tools.invoke(question)
这是因为 bind 已经“记住”了你的工具列表。本质上,你不再需要在每次 invoke 方法中传递 tools 参数。所以,调用上述代码与以下代码等同:
## REACT(推理与行动)
正如你可能想到的,LLM 在生成最终回复之前可以调用多个工具(下一个要调用的工具或发送到此工具的负载可能取决于之前工具调用的结果)。这是普斯顿大学和 Google 研究的研究人员在2022年提出的 ReACT 方法:*Reasoning and ACT (https://arxiv.org/abs/2210.03629)*。这个想法很简单——我们应该让 LLM 访问工具以与外部环境交互,并让 LLM 循环:
- **Reason(推理)**:生成关于当前情况的观察和解决任务的文本输出。
- **Act(行动)**:根据上述推理采取行动(通过调用工具交互,或回答用户)。
已经证明,与我们在第 3 章讨论的 CoT 提示相比,REACT 有助于减少幻觉率。
<https://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/generative-ai-with-langchain-second-edition/img/b4e68161fea00e7befce1e978c814dda_284_0.png>
图 5.1: ReACT 模式
让我们亲手构建一个 ReACT 应用。首先,让我们创建模拟的搜索和计算器工具:
```python
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 知到它可以使用的两个工具。我们还将使用已经熟悉的构建块——ChatPromptTemplate 和 MessagesPlaceholder,在调用我们的图时前置一个预定义的系统消息:
from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder
calculator_tool = {
"title": "calculator",
"description": "计算数学表达式",
"type": "object",
"properties": {
"expression": {
"description": "由计算器评估的数学表达式",
"title": "expression",
"type": "string",
}
},
"required": ["expression"]
}
prompt = ChatPromptTemplate.from_messages([ ("system", "始终使用计算器进行数学计算,并使用 Google Search 获取有关最新事件和新闻的信息."), MessagesPlaceholder(variable_name="messages"), ])
llm_with_tools = llm.bind(tools=[search_tool, calculator_tool]).bind(prompt=prompt)
现在我们已经有了一个可以调用工具的 LLM,让我们创建所需的节点吧。我们需要一个调用另一个 LLM 的函数,另一个一个调用工具并返回工具调用结果的函数(通过向状态中的消息列表添加 ToolMessages),以及一个决定编排器应该继续调用工具还是可以将结果返回给用户的函数:
from typing import TypedDict
from langgraph.graph import MessagesState, StateGraph, START, END
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_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)
78 乘以 132(即 10296)的平方根约为 101.47。
这演示了 LLM 如何通过多次调用来处理复杂问题——首先是 Google Search,然后是两次调用 Calculator,并且每次都利用之前收到的信息来调整自己的行动。这就是 ReACT 模式的实际运作。
到此,我们已经通过自己构建学习了 ReACT 模式的工作原理。好消息是,LangGraph 提供了内置的 ReACT 模式实现,因此你不需要自己实现它:
from langgraph.prebuilt import create_react_agent
ReACT 工作流与自定义工具
预构建的 ReACT 工作流
以下代码使用 create_react_agent 创建一个 ReACT 代理,其包含搜索引擎和计算器两个工具,并指定了系统提示词:
agent = create_react_agent(
llm=llm,
tools=[search_tool, calculator_tool],
prompt=system_prompt
)
图 5.2: LangGraph 上预构建的 ReACT 工作流
这正是我们之前看到的——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
明天慕尼黑清晨的温度为 4 °C... <TRUNCATED>
============================= Ai Message
================================
明天慕尼黑的天气将是 5°C,早晨降雨概率为 0%。风速为 11 km/h。晚些时候,气温将达到 53°F(约 12°C)。早晨天气晴朗。
我们的代理由消息列表表示,因为这是 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工具,允许你使用 Gmail 账户搜索、检索、创建和发送消息。你可以看到,不仅可以为 LLM 提供关于用户的额外上下文,而且通过这些工具,LLM 可以执行实际上影响外部环境的操作,例如在 GitHub 上创建拉取请求或在 Slack 上发送消息! -
让 LLM 访问代码解释器的工具:这些工具通过远程启动隔离容器并允许 LLM 访问该容器来提供代码解释器。这些工具需要提供沙箱的供应商 API 密钥。LLM 特别擅长编程,一种广泛使用的模式是让 LLM 通过编写代码来解决某些复杂任务,而不是让它生成代表任务解决方案的 Token。当然,你应该谨慎执行 LLM 生成的代码,这就是为什么隔离沙箱起着巨大的作用。一些示例包括:
- 代码执行:Python REPL 和 Bash
- 云服务:AWS Lambda
- API 工具:GraphQL 和 Requests
- 文件操作:File System
-
通过编写和执行 SQL 代码让 LLM 访问数据库的工具:例如,
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,就可以被封装为工具:
- 为用户或工作流提供相关的领域知识
- 允许 LLM 对用户的行为执行操作
在与 LangChain 集成此类工具时,请考虑以下方面:
- 身份验证 (Authentication):安全地访问外部系统
- 负载 (Payload schema):为输入/输出定义适当的数据结构
- 错误处理 (Error handling):为失败和边缘情况制定计划
- 安全考量 (Safety considerations):例如,在开发 SQL 转文本代理时,限制为只读操作以防止意外修改
因此,一个重要的工具包是 RequestsToolkit,它允许人们轻松地封装任何 HTTP API:
from langchain_community.agent_toolkits.openapi.toolkit import RequestsToolkit
from langchain_community.utilities.requests import TextRequestsWrapper
更多集成工具参见:https://python.langchain.com/docs/integrations/tools/.
自定义工具
我们已经了解了 LangGraph 提供的各种内置工具。现在是时候讨论你如何创建自己的工具了,除了我们之前通过提供 API 规范并使用 RequestsToolkit 封装第三方 API 时看到的示例之外。让我们开始吧!
将 Python 函数封装为工具
任何 Python 函数(或可调用对象)都可以封装为工具。正如我们记忆的,LangChain 中的工具应该有一个名称、一个描述和一个参数架构。让我们基于 Python 的 numexpr 库构建一个我们自己的计算器——这是一个基于 NumPy( https://github.com/pydata/numexpr )的快速数值表达式评估器。我们将使用一个特殊的 @tool 装饰器将我们的函数封装为工具:
import math
from langchain_core.tools import tool
import numexpr as ne
@tool
def calculator(expression: str) -> str:
"""计算单个数学表达式,包括
复数。
运算符之间务必添加 *,例如:
73i -> 7*3i
7pi**2 -> 7*pi**2
"""
math_constants = {"pi": math.pi, "i": 1j, "e": math.exp}
result = ne.evaluate(expression.strip(), local_dict=math_constants)
return str(result)
让我们探索一下我们拥有的计算器对象!注意 LangChain 自动从文档字符串(docstring)和类型提示继承了名称、描述和参数架构。请注意,我们使用了少样本(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': 'Calculates a single mathematical expression, incl. complex numbers.\n\nAlways add * to operations, examples:\n 73i -> 7*3i 7pi**2 -> 7*pi**2', 'properties': {'expression': {'title': 'Expression', 'type': 'string'}, 'required': ['expression'], 'title': 'calculator', 'type': 'object'}}
让我们尝试用我们的新工具评估一个包含复数的表达式,复数通过特殊的虚数单位 i 扩展了实数,i 具有 i**2=-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)^2 = -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 能够逐步查询工具:
- 它使用查询语句 "current US president" 调用了搜索引擎。
- 然后,它再次使用查询语句 "donald trump age" 调用了搜索引擎。
- 作为最后一步,LLM 使用表达式 "sqrt(78*132)" 调用了计算器工具。
- 最后,它向用户返回了正确答案。
每一步,LLM 都根据之前收集的信息进行推理,然后使用适当工具的行动——这就是 ReACT 方法的核心。
从 Runnable 创建工具
有时,LangChain 可能无法从函数推导出通过的描述或参数架构,或者我们可能在使用难以用装饰器封装的复杂可调用对象。例如,我们可以将另一个 LangChain 链 LangGraph 图作为工具。我们可以通过显式指定所有需要的描述从任何 Runnable 创建工具。让我们用另一种方式从函数创建计算器,并调整重试行为(在案例中,我们将重试三次,并在连续尝试之间添加指数退避):
请注意我们使用了上面相同的函数,但删除了
@tool装饰器。
from langchain_core.runnables import RunnableLambda, RunnableConfig
from langchain_core.tools import tool, convert_runnable_to_tool
def calculator(expression: str) -> str:
math_constants = {"pi": math.pi, "i": 1j, "e": math.exp}
result = ne.evaluate(expression.strip(), local_dict=math_constants)
return str(result)
calculator_tool = convert_runnable_to_tool(
RunnableLambda(calculator),
name="calculator",
description="Calculates a single mathematical expression, incl. complex numbers.\n\nAlways add * to operations, examples:\n73i -> 7*3i\n7pi**2 -> 7*pi**2",
)
输出:
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+3i)**2', 'id': 'f8be9cbc-4bdc-4107-8cfb-fd84f5030299', 'type': 'tool_call'}
我们可以在运行时调用计算器工具,并将其传递给 LangGraph 配置:
math_constants = {"pi": math.pi, "i": 1j, "e": math.exp}
config = {"configurable": {"math_constants": math_constants}}
calculator_tool.invoke(tool_call["args"], config=config)
输出:
>> (-5+12j)
通过这些,我们学习了如何通过向 LangChain 提供额外细节,将任何 Runnable 轻松转换为工具,以确保 LLM 能够正确处理该工具。
子类 StructuredTool 或 BaseTool
(此部分内容未提供完整文本,可能存在截图或后续章节内容。)
定义工具的另一种方法是通过子类 BaseTool 类来创建自定义工具。与其他方法一样,你必须指定工具的名称、描述和参数模式(schema)。你还需要实现一个或两个抽象方法:用于同步执行的 run,如果需要,用于异步行为的 arun(如果它与简单封装同步版本有所不同)。当你的工具需要是有状态的(例如,为了维持长连接的客户端)或者其逻辑太复杂,无法用单个函数或 Runnable 实现时,这种选项特别有用。
如果你希望获得比 @tool 装饰器更多的灵活性,但又不想实现自己的类,那么有一种折中方法。你也可以使用 StructuredTool.from_function 类方法,它允许你仅几行代码即可显式地指定工具的元参数,如描述或 args_schema:
from langchain_core.tools import StructuredTool
calculator_tool = StructuredTool.from_function(
name="calculator",
description=(
"计算单个数学表达式,包括复数."),
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_function。你也可以只提供异步实现,但你将无法以同步方式调用你的工作流。
最后,让我们再次看看创建 LangChain 工具的三种方案,以及何时何时使用它们。
| 创建工具的方法 | 适用场景 |
|---|---|
| @tool 装饰器 | 你有一个具有清晰文档字符串的函数,且该函数在代码的任何地方未被使用 |
| convert_runnable_to_tool | 你有一个现有的 Runnable,或者你需要更详细地控制如何将参数或工具描述传递给 LLM(在这种情况下你通过 RunnableLambda 封装现有函数) |
| 子类 StructuredTool 或 BaseTool | 你对工具描述和逻辑有完全控制权(例如,你想区分处理同步和异步请求) |
表 5.1:创建 LangChain 工具的选项。
当 LLM 生成有效负载并调用工具时,它可能会产生幻觉或其他错误。因此,我们需要仔细考虑错误处理。
错误处理
我们已经在第3章中讨论过错误处理,但当你用工具增强 LLM 时,它变得更加重要;你更需要日志记录、异常处理等。额外的考虑是,思考你是否希望在其中一个工具失败时,你的工作流继续并尝试自动恢复。LangChain 有一个特殊的 ToolException,它允许其中一个工具失败时允许工作流继续并尝试自动恢复。
通过这些,我们学习了如何通过向 LangChain 提供额外细节,将任何 Runnable 轻松转换为工具,以确保 LLM能够正确处理该工具。
高级工具调用能力
许多大语言模型(LLM)为工具调用提供了额外的配置选项。首先,某些模型支持并行函数调用——具体来说,LLM 可以同时调用多个工具。LangChain 原生支持此功能,因为 AIMessage 的 tool_calls 字段是一个列表。当 ToolMessage 对象作为函数调用结果时,你应该仔细地将 ToolMessage 的 tool_call_id 字段与生成的负载(payload)匹配。这种对齐是必要的,以便 LangChain 和底层 LLM 在进行下一轮对话时可以将它们匹配在一起。
另一个高级能力是强制 LLM 调用工具,甚至调用特定的工具。通常情况下,LLM 会决定是否应该调用工具,以及如果需要,应该从提供的工具列表中选择哪一个。这通常通过传递给 invoke 方法的 tool_choice 和/或 tool_config 参数处理,但实现取决于模型提供商。Anthropic、Google、OpenAI 以及其他主要供应商的 API 略有不同,尽管 LangChain 尝试统一参数,在这种情况下,你应该通过模型提供商双检细节。
通常,可用选项如下:
"auto":LLM 可以直接响应或调用一个或多个工具。"any":强制 LLM 通过调用一个或多个工具来响应。"tool"或带有提供的工具列表的"any":强制 LLM 通过从受限列表中调用工具来响应。"None":强制 LLM 在不调用工具的情况下响应。
另一个需要注意的重要点是,架构(schemas)可能会变得非常复杂——即,它们可能包含可空字段或嵌套字段、枚举,或引用其他架构。根据模型提供商的不同,某些定义可能不受支持(你会看到警告或编译错误)。尽管 LangChain 旨在实现跨供应商的无缝切换,但对于某些复杂的工作流,情况可能并非,因此请注意错误日志中的警告。有时,将提供的 schema 编译为模型提供商支持的 schema 是在尽力而为的基础上完成的——例如,如果类型为 Union[str, int] 的字段对应的底层 LLM 不支持工具调用中的 Union 类型,则它会被编译为 str 类型。你会收到警告,但在迁移过程中忽略此类警告可能会导致应用程序的行为发生不可预测的变化。
最后,值得提到的是,某些供应商(例如 OpenAI 或 Google)提供了自定义工具,如代码解释器或 Google 搜索,这些工具由模型自身调用,模型将使用工具的输出来准备最终生成。这种方法降低了延迟和成本。在这种情况下,你通常为 LangChain 封装提供使用提供商 SDK 创建的自定义工具,而不是使用 LangChain 构建的工具(即不继承自 BaseTool 类的工具),这意味着你的代码无法跨模型迁移。
将工具整合到工作流
既然我们已经知道了如何创建和使用工具,让我们讨论如何将工具调用范式更深入地整合到我们开发的工作流中。
控制生成
在第 3 章中,我们开始讨论控制生成,即你希望 LLM 遵循特定的架构。我们不仅可以通过创建更复杂、更可靠的解析器来改进解析工作流,还可以通过更严格地强制 LLM 遵循某些架构。调用工具需要控制生成,因为生成的负载应该遵循特定的架构,但我们可以退后一步,用遵循预期架构的强制工具调用来代替我们预期的架构。LangChain 有一个内置机制来帮助实现——LLM 拥有 with_structured_output 方法,该方法接受一个 Pydantic 模型作为架构,将其转换为工具,通过通过给定提示词强制其调用此工具来调用 LLM,并通过将其编译为相应的 Pydantic 模型实例来解析输出。
在本章稍后部分,我们将讨论计划与解决代理(plan-and-solve agent),所以让我们开始准备一个构建模块。让我们的 LLM 为给的操作生成一个计划,但不是解析该计划,而是将其定义为 Pydantic 模型(Plan 是 Steps 的列表):
from pydantic import BaseModel, Field
class Step(BaseModel):
"""解决任务的计划中的步骤。"""
step: str = Field(description="步骤的描述")
class Plan(BaseModel):
"""解决任务的计划。"""
steps: list[Step]
请记住,我们使用了嵌套模型(一个字段引用另一个字段),但 LangChain 会为我们编译一个统一的架构。让我们组合一个简单的工作流并运行它:
prompt = PromptTemplate.from_template(
"准备一个逐步解决给定任务的计划。\n"
"TASK:\n{task}\n"
)
result = (prompt | llm.with_structured_output(Plan)).invoke(
"如何在 Amazon 上写一本关于生成式 AI 的畅销书?"
)
如果我们检查输出,会看到得到了一个 Pydantic 模型作为结果。我们不再需要解析输出;我们直接获得了一系列特定的步骤(稍后,我们将看到如何进一步使用它):
assert isinstance(result, Plan)
print(f"步骤数量: {len(result.steps)}")
for step in result.steps:
print(step.step)
break
# >> 步骤数量: 21
# **1. 想法生成与验证:****
供应商提供的控制生成
另一种方法取决于供应商的。某些基础模型提供商提供了额外的 API 参数,可以指示模型生成结构化输出(通常是 JSON 或枚举)。你可以像上面一样使用 with_structured_output 强制模型使用 JSON 生成,但提供另一个参数 method="json mode"(并双检底层模型提供商是否支持 JSON 的控制生成):
plan_schema = {
"type": "ARRAY",
"items": {
"type": "OBJECT",
"properties": {
"step": {"type": "STRING"},
}
}
}
query = "如何在 Amazon 上写一本关于生成式 AI 的畅销书?"
result = (prompt | llm.with_structured_output(schema=plan_schema, method="json mode")).invoke(query)
注意,JSON schema 不包含字段的描述,因此通常,你的提示词应该更详细且包含更多信息。但作为输出,我们得到了一个完全限定的字典:
assert isinstance(result, list)
print(f"步骤数量: {len(result)}")
# >> 步骤数量: 10
# 'step': '1. 定义你的利基市场和目标受众。生成式 AI 是一个广泛的话题。关注一个特定领域,如营销、艺术、音乐或写作领域的生成式 AI。识别你的理想读者(例如营销人员、艺术家、开发者)。'
你可以直接指示 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(
"分类以下客户评论的基调:"
"\{review}\n"
)
review = "我喜欢这部电影!"
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 通过 method="json_mode" 参数或允许将自定义 kwargs 传递给模型抽象了模型提供者的实现细节。某些受控生成能力是模型特定的。请检查您的模型文档以获取支持的 schema 类型、约束和参数。
ToolNode
为了简化代理(agent)开发,LangGraph 构建了内置 ToolNode 和 tool_conditions 等。ToolNode 会检查 messages 中的最后一条消息(你可以重新定义键名)。如果此消息包含工具调用(tool calls),它将调用相应的工具并更新状态。另一方面,tool_conditions 是一个条件边,用于检查是否应该调用 ToolNode(否则则结束)。
现在我们可以在几分钟内构建我们的 ReACT 引擎:
from langgraph.prebuilt import ToolNode, tools_condition
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()
Tool-calling paradigm
工具调用(Tool calling)是一种非常强大的设计范式,要求改变你开发应用程序的方式。在许多情况下,与其进行多轮提示词工程并多次尝试改进提示词,不如思考是否可以要求模型调用工具。
假设我们正在开发一个处理合同取消的代理,它应该遵循某些业务逻辑。首先,我们需要理解合同的开始日期(而处理日期可能会困难!)。如果你尝试编写一个可以正确处理此类情况的提示词,你会发现这可能非常困难:
examples = [
"我两年前签署了合同",
"我是在去年二月与贵公司开始交易的",
"我们的合同是年前 3 月 24 日开始的"
]
相反,强制模型调用工具(甚至可以通过 ReACT 代理!)。例如,我们在 Python 中有两个非常原生工具——date 和 timedelta:
from datetime import date, timedelta
@tool
def get_date(year: int, month: int = 1, day: int = 1) -> date:
"""给定年、月、日返回日期对象。
"""
默认月份和日期为 1(1月)和 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。
示例:
two weeks ago -> time_difference(weeks=2)
last year -> 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
llm = ChatVertexAI(model="gemini-1.5-pro-002")
使用工具调用来构建代理
我们学习了如何使用工具(或函数调用)来增强 LLM 在复杂任务上的性能。这是代理背后的基本架构模式之一——现在是讨论什么是代理了。
什么是代理?
代理(Agents)是如今生成式 AI 最热门的话题之一。人们经常讨论代理,有很多定义。LangChain 本身将代理定义为“一个使用 LLM 来决定应用程序流程的系统”。作为 Python 开发者,你可能熟悉通过鸭类型(duck typing)来确定对象的行为,所谓的鸭子测试:“如果它走起来像鸭子,叫起来像鸭子,那么它就是鸭子。”基于这一概念,让我们描述生成式 AI 背景下代理的一些属性:
-
代理帮助用户解决非确定性任务,高级代理甚至代表用户采取行动。由于为了解决任务,代理通常执行多个步骤:推理(生成新信息)、行动(与环境交互)、观察(纳入环境反馈)。
-
代理利用 LLM 进行推理。保留代理的行为——代理工作流——是 LangGraph 的核心概念。虽然 LangGraph 提供了丰富的构建块(如内存管理、工具调用),但其主要设计模式专注于管理 LLM 在执行任务时的流和自主性。
示例:创建 ReACT 代理
agent = create_react_agent(
llm, [get_date, time_difference], prompt="提取合同的开始日期。当前年份是2025。"
)
例如 examples 中的内容:
result = agent.invoke({"messages": ["user", example]})
print(example, result["messages"][-1].content)
我两年前签署了合同 合同于 2023-02-07 开始。
我是在去年二月与贵公司开始交易的 合同于 2024-02-01 开始。
我们的合同是年前 3 月 24 日开始的 合同于 2023-03-24 开始。
Plan-solve 代理
当我们面对复杂任务时,人类通常会做什么?计划!2023 年,Lei Want 等人证明了计划解决提示(plan-and-solve prompting)可以提高推理。多项研究表明,随着提示词复杂性(特别是长度和指令数量)的增加,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 = (
"对于给任务,请制定一个分步骤的计划。"
"该计划应包含各个任务,这些任务如果执行正确,将产生正确的答案。不要添加任何多余的步骤。"
"最后一步的结果应该是最终答案。确保每一步都包含了所需的信息——不要跳过步骤。"
)
planner_prompt = ChatPromptTemplate.from_messages(
[("system", system_prompt_template),
("user", "Prepare a plan how to solve the following\ntask:\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_steps 和 final_response:
class PlanState(TypedDict):
task: str
plan: Plan
past_steps: Annotated[list[str], operator.add]
final_response: 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"]):
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
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.invoke(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.invoke({
"plan": get_full_plan(plan),
"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).invoke({
"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()
from IPython.display import Image, display
display(Image(graph.get_graph().draw_mermaid_png()))
图 5.3:计划与解决型代理工作流
现在我们可以运行工作流了:
task = "写一份关于建立 AI 初创公司的战略性单页报告"
result = await graph.ainvoke({"task": task})
你可以在我们的 GitHub 上看到完整的输出,我们鼓励你亲自尝试。调查与给定任务的单一 LLM 提示词相比,你是否更喜欢这个结果,可能会特别有趣。
总结
在本章中,我们探索了如何通过集成工具和工具调用模式来增强大语言模型(LLMs),包括 ReACT 模式。我们从零开始构建了一个 ReACT 代理,然后展示了如何使用一行代码创建一个自定义代理。
接下来,我们深入探讨了受控生成技术——展示了如何让 LLM 调用任何工具或特定工具,并使其以结构化格式(如 JSON、枚举或 Pydantic 模型)返回响应。在此背景下,我们介绍了 LangChain 的 with_structured_output 方法,它将数据结构转换为工具模式,提示模型调用工具,解析输出并将其编译为相应的实例。
最后,我们使用 LangGraph 构建了第一个计划与解决型代理,应用了我们目前学习的所有概念:工具调用、ReACT、结构化输出等。在下一章中,我们将继续讨论代理开发并查看更高级的架构。
问题
-
- LLM 使用工具的主要优势是什么?为什么它们很重要?
-
- LangChain 的
ToolMessage类如何促进 LLM 与外部环境之间的通信?
- LangChain 的
-
- 解释 ReACT 模式。它的两个主要步骤是什么?它如何提高 LLM 的性能?
-
- 你如何定义生成式 AI 代理?这与 LangChain 的定义有什么联系或区别?
-
- 说明使用
with_structured_output方法与直接使用受控生成相比的优缺点。
- 说明使用
-
- 如何在 LangChain 中通过编程方式定义一个自定义工具?
-
- 解释 LangChain 中
Runnable.bind()和bind_tools()方法的用途。
- 解释 LangChain 中
-
- LangChain 如何处理工具执行期间发生的错误?有哪些可用选项?
我们在第 5 章中学习了 agent,这是一个工具调用(tool-calling)模式的示例:
图 6.1:LangGraph 上预构建的 REACT 工作流
让我们来看看有助于构建高性能 agent 的相对简单的设计模式。你将在不同领域和 agent 架构中看到这些模式的各种组合:
-
工具调用(Tool calling): LLM 经过训练可以通过工具调用进行受控生成。因此,在适当的情况下,将你的问题封装为工具调用问题,而不是创建复杂的提示词。记住,工具应该具有清晰的描述和属性名称,对它们进行实验是提示词工程的一部分。我们在第 5 章中讨论过这种模式。
-
任务分解(Task decomposition): 保持提示词相对简单。提供带有少量示例(few-shot examples)的特定指令,并将复杂任务拆分为更小的步骤。你可以让 LLM 对任务分解和规划过程拥有部分控制权,并通过外部编排器管理工作流。我们在第 5 章构建“规划并解决”(plan-and-solve)agent 时使用了这种模式。
-
协作与多样性(Cooperation and diversity): 如果在多个启用能力的 agent 实例之间引入协作,可以改进复杂任务的最终输出。通信、辩论和共享不同观点有所帮助,你还可以通过启动具有不同系统提示词、可用工具集的 agent 来从各种技能组合中受益。自然语言是此类 agent 通信的原生方式,因为 LLM是在自然语言任务上训练的。
-
反思与适应(Reflection and adaptation): 添加隐式的反思循环通常会提高复杂任务端到端推理的质量。LLM 通过调用工具从外部环境获取反馈(这些调用可能会失败或产生意外结果),但同时,LLM 可以继续迭代并从错误中自我恢复。作为一个夸张,记住我们经常使用“LLM 作为裁判”(LLM-as-a-judge),因此当我们要求 LLM 评估自己的推理并发现错误时添加循环,通常有助于它恢复。我们将在本章晚些时候学习如何构建自适应系统。
-
模型是非确定性的可以生成多个候选结果(Models are nondeterministic and can generate multiple candidates): 不要专注于单一输出;在 LLM 与外部环境交互寻找解决方案时,通过扩展潜在选项的维度来探索不同的推理路径。我们将在下文讨论 ToT(思维树)和语言 agent 树搜索(LATS)示例时详细研究这种模式。
-
以代码为中心的问题建模(Code-centric problem framing): 写代码对 LLM 来说非常自然,因此如果可能,尝试将问题建模为代码编写问题。这可能成为解决任务的一种非常有力的方法,特别是当你将其与代码执行沙箱、基于输出的改进循环、访问用于数据分析或可视化的各种强大库以及之后的生成步骤封装时。我们将在第 7 章中详细介绍。
两个重要的注释:第一,根据最佳软件开发实践开发你的 agent,并使它们敏捷、模块化且易于配置。这将允许你将多个专业的 agent 组合在一起,并让用户能够根据其特定任务轻松调整每个 agent。
其次,我们想要强调(再次强调!)评估和实验的重要性。我们将在第 9 章更详细地讨论评估。但记住,成功没有明确的路径。不同的模式在不同类型的任务上表现更好。尝试、实验、迭代,并且不要忘记评估你的工作结果。数据(如任务和预期输出)以及模拟器(LLM 与工具交互的安全方式)是构建真正复杂且有效的 agent 的关键。
现在我们已经建立了各种设计模式的心理地图,我们将通过讨论各种 agent 架构和查看示例来深入研究这些原则。我们将首先通过 agent 方法增强我们在第 4 章中讨论过的 RAG 架构。
Agentic RAG
LLM 使智能 agent 能够处理无法描述为确定工作流的复杂非重复任务。通过以不同的方式将推理拆分为步骤并以相对简单的方式进行编排,agent 在复杂任务上可以表现出更高的任务完成率。
这种基于 agent 的方法可以应用于许多领域,包括我们在第 4 章中讨论过的 RAG 系统。提醒一下,到底什么是 Agentic RAG?记住,RAG 系统的经典模式是根据查询检索分块,将它们合并到上下文中并根据系统提示词、结合上下文和问题让 LLM 生成答案。
我们可以使用上述讨论的原则(分解、工具调用和适应)来改进这些步骤中的每一个:
-
动态检索(Dynamic retrieval): 将检索查询的生成交给 LLM。它可以自己决定是否使用稀疏嵌入、混合方法、关键词搜索或网络搜索。你可以将检索封装为工具并作为 LangGraph 图进行编排。
-
查询扩展(Query expansion): 指派 LLM 根据初始查询生成多个查询,然后你根据倒数数融合(reciprocal fusion)或其他技术合并搜索结果。
-
对检索分块进行推理分解(Decomposition of reasoning on retrieved chunks): 允许你要求 LLM 根据问题评估每个分块(并过滤掉无关内容),以补偿检索的不准确性。你可以要求 LLM 总结每个分块,只保留针对输入问题提供的信息。无论如何,与其将一个巨大的上下文丢到 LLM 面前,不如先并行执行许多较小的推理步骤。这不仅可以提高 RAG 自身的质量,还能增加初始检索的数量(通过降低相关性阈值)或通过邻居扩展每个分块。换句话,你可以通过 LLM 推理克服一些检索挑战。这可能会提高应用程序的整体性能,但同时也带来了延迟和潜在成本的影响。
-
反思步骤和迭代(Reflection steps and iterations): 让 LLM 通过评估每次迭代后的输出对检索和查询进行动态迭代。你还可以将额外的落地(grounding)和归因工具作为工作流中的独立步骤,并据此推理是否需要继续处理答案或可以将答案返回给用户。
根据我们之前章节的定义,当你与 LLM 共享对执行流的部分控制权时,RAG 就变为了 Agentic RAG。例如,如果 LLM 决定如何检索、反思检索到的分块并根据第一个版本的答案进行适应,它就变成了 Agentic RAG。从我们的角度来看,此时迁移到 LangGraph 开始变得意义,因为它是专为构建此类应用而设计的,但当然你也可以留在 LangChain 或任何你喜欢的框架中(比较我们在第 3 章中分别用 LangChain 和 LangGraph 实现 map-reduce 视频摘要)。
多 agent 架构
在第 5 章,我们学习了将复杂任务分解为更简单的子任务通常会提高性能。我们构建了一个“规划并解决”agent,它比 CoT(思维链)更进一步,鼓励 LLM 生成规划并遵循它。在某种程度上,这种架构是多 agent 架构,因为负责生成和遵循规划的调研 agent 调用了另一个专注于不同类型任务的 agent —— 使用提供的工具解决非常特定的任务。
多 agent 工作流编排多个 agent,允许它们相互增强,同时保持 agent 的模块化(这使其更容易测试和重用)。
在本章的剩余部分,我们将查看一些核心 agent 架构,并介绍一些开发 agent 重要的 LangGraph 接口(如流细节和交接)。如果你感兴趣,可以在 LangChain 文档页面找到更多示例和教程: https://langchain-ai.github.io/langgraph/tutorials/#agent-architectures 。我们将从讨论多 agent 系统中专业化的重要性开始,包括共识机制是什么。
Agent 角色与专业化
在处理复杂任务时,我们人类知道通常拥有一个具有多样技能和背景的团队是有益的。许多研究和实验证据表明,对于生成式 AI 也是如此。
智能体。事实上,开发专门的智能体为复杂的 AI 系统提供了多项优势。
首先,专门化可以提高特定任务的性能。这允许你:
- 为每种任务类型选择最优工具集。
- 编写定制的提示词(prompts)和工作流。
- 针对特定上下文微调温度(temperature)等超参数。
其次,专门的智能体有助于管理复杂性。当前的 LLM 在同时处理过多工具时会显得不从心。作为最佳实践,请将每个智能体限制在 5-15 个不同的工具,而不是让单个智能体过载所有可用工具。如何分组工具仍然是一个开放的问题;通常,将它们分组为工具包(toolkits)以创建连贯的专门智能体是有帮助的。
图 6.2:主管模式
除了专业化之外,保持你的智能体模块化。这样更容易维护和改进此类智能体。此外,通过开发企业级助手用例,你最终将为组织内的用户和开发者提供许多不同的智能体,它们可以组合在一起。因此,请记住你应该让这些专门的智能体具有可配置性。
LangGraph 允许你通过将图作为大图中的子图来轻松组合图。实现此方法有两种:
-
将智能体编译为图,并在定义另一个智能体的节点时将其作为可调用对象传递:
builder.add_node("pay", payments_agent) -
用 Python 函数封装子智能体的调用,并在父智能体节点的定义中使用它:
def run_payment(state):
result = payments_agent.invoke({"client_id": state["client_id"]})
return {"payment status": result}
builder.add_node
注意
共识机制
我们也可以让多个智能体并行处理相同的任务。这些智能体可能具有不同的“性格”(由其系统提示词引入;例如,某些可能更好奇且乐于探索,而另一些可能更严格且深度基于事实),甚至具有不同的架构。它们中的每一个都独立工作以为问题寻求解决方案,然后你使用共识机制从几个草案中选出最佳方案。
图 6.3:带有最终共识步骤的任务并行执行
我们在第 3 章中看过了基于多数投票实现共识机制的示例。你可以将其封装为独立的 LangGraph 节点,并且还有在多个智能体之间达成共识的替代方法:
-
让每个智能体查看其他解决方案并在 0 到 1 的范围内对每个方案进行评分,然后取得分最高的方案。
-
使用替代的投票机制。
-
使用多数投票。它通常适用于分类或类似任务,但如果你有自由文本输出,实现多数投票可能会困难。这是最快且最廉(( token 消耗)的机制,因为你不需要运行任何额外的提示词。
-
如果存在,使用外部 Oracle(裁判)。例如,在求解数学方程时,你可以轻松验证解决方案是否可行。计算成本取决于问题,但通常较低。
-
使用另一个(可能更强大的)LLM 作为法官来挑选最佳解决方案。你可以要求 LLM 为每个方案打分,或者通过展示所有方案并让它挑选最佳的一个来将其任务化为多分类问题。
-
开发另一个智能体,它擅长从一组解决方案中为通用任务选择最佳方案。
值得提到的是,共识机制具有特定的延迟和成本影响,但通常与解决任务本身的成本相比,这些影响是微忽略不的。如果你指派 N 个智能体执行相同的任务,你的 token 消耗将增加 N 倍,共识机制在此基础上增加了相对较小的开销。
你还可以实现自己的共识机制。当你这样做时,请考虑:
- 使用 LLM 作为法官来处理不同的提示词。
- 考虑不同的输出。
- 使用多数投票。
一个关于并行化的重要说明,当你让 LangGraph 执行节点时,通过这种方式可以对你的应用保持一致性。
通信协议
第三种架构是让智能体协作处理任务。例如,智能体可能会通过不同的系统提示词受益于任务分解(为了获取灵感,请查看 LangGraph 页面 上的图表)。
图 6.4:反射模式
智能体可以通过提供批判来协作处理任务。有多种反射模式,从自反思(self-reflection,智能体分析自己的步骤);交叉反思(cross-reflection,使用另一个智能体;例如使用另一个基础模型);甚至是反思,这包含了人类回路 (HIL) 在关键检查点(我们将在下一节介绍自适应系统)。你可以让一个智能体作为主管,允许智能体在网络中通信(允许它们决定发送消息),引入某些层次结构或开发更复杂的流程。
设计智能体工作流仍然是一个开放的研究领域,你需要回答许多问题:
- 我们应该在系统中包含哪些以及多少智能体?
- 我们应该给这些智能体分配什么角色?
- 每个智能体可以使用哪些工具?
- 智能体之间如何交互?通过什么机制?
- 我们应该自动化工作流的哪些部分?
- 我们如何评估自动化,以及如何收集数据?此外,我们的成功标准是什么?
正如我们在第 3 章中所学的,一种提高 LLM 分类准确率的方法。
图 6.5: 语义路由模式
值得注意的是,一个任务可能属于两个或多个类别——例如,我可以问:“什么是 X,以及我该如何做 Y?” 这种在助手场景中可能不是这么常见的用例,你可以根据情况决定如何处理。首先,你可能可以通过回复解释来教育用户,告诉他们每轮只给应用分配一个单一问题。有时,开发者倾向于过度关注尝试用程序解决所有问题。但某些产品功能通过 UI 相对容易解决,而且用户(尤其是)已经准备好提供输入了。也许与其在 prompt 上解决分类问题,不如在 UI 中添加一个简单的复选框,或者让系统在置信度较低时进行双检。
你还可以使用工具调用(tool calling)或其他我们学过的控制生成技术来提取目标,并将执行路由到两个具有不同任务的专用代理。
语义路由的另一个重要方面是,你的应用程序性能很大程度上取决于分类准确率。你可以使用我们在书中讨论过的所有技术来提高它——少样本提示(包括动态少样本)、结合用户反馈、采样等。
组织交互
在多代理系统中,组织通信有两种方式:
-
代理之间通过特定的结构进行通信,这些结构迫使它们将想法和推理轨迹放入特定形式,正如我们在上一章的“计划并解决”示例中看到的。我们看到了规划节点如何通过带有良好结构计划的 Pydantic 模型与 ReACT 代理进行通信(而该计划反过来是 LLM 控制生成的 result)。
-
另一方面,LLM 经过训练,可以接受自然语言作为输入并产生相同格式的输出。因此,它们通过消息进行通信是一种非常自然的方式,你可以通过将不同代理的消息应用到共享的消息列表中来实现通信机制!
使用消息进行通信
另一种方案是仅共享每次执行的最终结果。这可以保持消息列表相对较短。
现在是时候看一个实际示例了。让我们开发一个研究代理,它使用工具根据公开的 MMLU 数据集回答复杂的多选择题(我们将使用高中地理题)。首先,我们需要从 Hugging Face 获取数据集:
from datasets import load_dataset
ds = load_dataset("cais_mmlu", "high_school_geography")
ds_dict = ds["test"].take(2).to_dict()
print(ds_dict["question"][0])
阻碍自给自足经济进步的主要因素是缺乏
这些是我们的备选项:
print(ds_dict["choices"][0])
让我们从 ReACT 代理开始,但我们要偏离默认的系统提示词并编写自己的提示词。让这个代理专注于创意和基于证据的解决方案(请注意,我们使用了在第 3 章中讨论过的思维链(CoT)提示的元素):
from langchain.agents import load_tools
from langgraph.prebuilt import create_react_agent
research_tools = load_tools(
tool_names=["ddg-search", "arxiv", "wikipedia"],
llm=llm
)
system_prompt = (
"You're a hard-working, curious and creative student. "
"You're preparing an answer to a exam question. "
"Work hard, think step by step."
"Always provide an argumentation for your answer. "
"Do not assume anything, use available tools to search "
"for evidence and supporting statements."
)
现在,让我们创建代理本身。由于我们为代理准备了自定义提示词,我们需要一个包含系统消息的提示词模板、一个根据提供的问题和答案格式化第一个用户消息的模板,以及一个用于添加到图状态中的后续消息的占位符。我们还通过继承 AgentState 并为其添加额外的键来重新定义默认代理的状态:
from langchain_core.prompts import ChatPromptTemplate, PromptTemplate
from langgraph.graph import MessagesState
from langgraph.prebuilt.chat_agent_executor import AgentState
raw_prompt_template = (
"Answer the following multiple-choice question. "
"\nQUESTION:\n{question}\nnANSWER\n OPTIONS:\n{option}\n"
)
prompt = ChatPromptTemplate.from_messages(
[
("system", system_prompt),
("user", raw_prompt_template),
("placeholder", "{messages}")
]
)
class MyAgentState(AgentState):
question: str
options: str
research_agent = create_react_agent(
model=llm_small, tools=research_tools,
state_schema=AgentState
)
我们本可以在此停止,但让我们更进一步。我们为它添加了一个反思步骤,一个专门的教授角色将用来批评答案:
reflection_prompt = (
"You are a university professor and you're supervising a student "
"who tried to answer the exam question and get feedback from your "
"professor. Work on improving your answer and incorporating the "
"feedback.\nQUESTION:\n{question}\n\nANSWER OPTIONS:\n{options}\n\n"
"STUDENT'S ANSWER:\n{answer}\n\nFEEDBACK:\n{feedback}\n\n"
"Reflect on the answer."
)
from pydantic import BaseModel, Field
from typing import Optional
class Response(BaseModel):
"""A final response."""
answer: Optional[str] = Field(
None,
description="The final answer. It should be empty if critique has been provided."
)
critique: Optional[str] = Field(
None,
description="A critique of the initial answer. If you think it might be incorrect, provide actionable feedback"
)
reflection_chain = PromptTemplate.from_template(reflection_prompt) | llm.with_structured_output(Response)
现在我们需要另一个研究代理,它不仅接收问题和选项,还接收之前的答案和反馈,以改进答案。我们创建了一个说明性的示例。你可以通过添加错误处理、Pydantic 验证(例如检查提供了答案还是批评)或处理冲突或模糊的反馈(例如,帮助代理优先考虑反馈点的提示词)来改进它。
注意,我们在 ReACT 代理中使用了较弱的 LLM,仅仅是为了演示反思方法的强大(否则代理可能会在第一次迭代中得出正确答案):
raw_prompt_template_with_critique = (
"You tried to answer the exam question and get feedback from your "
"professor. Work on improving your answer and incorporating the feedback. "
"\nQUESTION:\n{question}\n\nANSWER OPTIONS:\n{options}\n\n"
"INITIAL\nANSWER\n{answer}\n\nFEEDBACK:\n{feedback}"
)
prompt = ChatPromptTemplate.from_messages(
[
("system", system_prompt),
("user", raw_prompt_template_with_critique),
("placeholder", "{messages}")
]
)
class ReflectionState(ResearchState):
answer: str
feedback: str
research_agent_with_critique = create_react_agent(
model=llm_small, tools=research_tools,
state_schema=ReflectionState, prompt=prompt
)
在定义图的状态时,我们需要跟踪问题和选项、当前答案以及批评。此外注意,我们跟踪了学生和教授之间的交互次数(以避免它们之间的无限循环),并为之使用了一个自定义的还原器(它在每次运行时都会汇总旧步骤和新步骤)。让我们定义完整的状态、节点和条件边:
from typing import Annotated, Literal, TypedDict
from langchain_core.runnables.config import RunnableConfig
from operator import add
from langchain_core.output_parsers import StrOutputParser
class ReflectionAgentState(TypedDict):
question: str
options: str
answer: str
steps: Annotated[int, add]
response: Response
def _should_end(state: AgentState, config: RunnableConfig) -> Literal["research", END]:
max_reasoning_steps = config["configurable"].get("max_reasoning_steps", 10)
if state.get("response") and state["response"].answer:
return END
if state.get("steps", 1) > max_reasoning_steps:
return END
return "research"
reflection_chain = PromptTemplate.from_template(reflection_prompt) | \
llm.with_structured_output(Response)
def _reflection_step(state):
result = reflection_chain.invoke(state)
return {"response": result, "steps": 1}
def _research_start(state):
answer = research_agent.invoke(state)
return {"answer": answer["messages"][-1].content}
def _research(state):
agent_state = {
"answer": state["answer"],
"question": state["question"],
"options": state["options"],
"feedback": state["response"].critique
}
answer = research_agent_with_critique.invoke(agent_state)
return {"answer": answer["messages"][-1].content}
让我们将这一切整合在一起并创建我们的图:
builder = StateGraph(ReflectionAgentState)
builder.add_node("research_start", _research_start)
builder.add_node("research", "research")
builder.add_node("reflect", _reflection_step)
builder.add_edge(START, "research_start")
builder.add_edge("research_start", "reflect")
builder.add_edge("research", "reflect")
builder.add_conditional_edges("reflect", _should_end)
graph = builder.compile()
display(Image(graph.get_graph().draw_mermaid_png()))
图 6.6:带有反思能力的智能体
让我们运行它并观察发生了什么:

浙公网安备 33010602011771号