高级应用与多代理系统

254 阅读49分钟

在上一章中,我们定义了什么是代理。那么,如何设计和构建一个高效能的代理呢?与我们之前探讨的提示工程技术不同,开发有效的代理涉及若干不同的设计模式,每位开发者都应熟悉这些模式。本章将讨论智能代理背后的关键架构模式。

我们将深入研究多代理架构及代理之间的通信组织方式。接着,我们将开发一个具备自我反思能力的高级代理,利用工具回答复杂的考试问题。同时,学习实现智能代理架构时有用的 LangChain 和 LangGraph API,如 LangGraph 的流式处理细节及如何将任务交接作为高级控制流程的一部分实现。

随后,我们会简要介绍 LangGraph 平台,探讨如何开发包含人类参与的自适应系统,以及 LangGraph 提供的相关预构建构件。我们还将了解“思维树”(Tree-of-Thoughts,ToT)模式,亲自开发一个 ToT 代理,并讨论通过实现高级修剪机制进一步改进它的方法。最后,学习 LangChain 和 LangGraph 中的高级长期记忆机制,如缓存和存储。

本章将涵盖以下主题:

  • 智能代理架构
  • 多代理架构
  • 构建自适应系统
  • 探索推理路径
  • 代理记忆机制

智能代理架构

正如我们在第5章所学,代理帮助人类解决任务。构建代理需要平衡两个方面:一方面,它类似于应用开发,即将各种 API(包括调用基础模型)组合起来,确保具备生产级质量;另一方面,它是帮助大语言模型(LLM)进行思考和解决任务。

如第5章所述,代理并没有特定的算法可循。我们赋予 LLM 对执行流程的部分控制权,但为了引导它,我们会使用各种技巧,帮助我们人类更好地推理、解决问题和清晰思考。我们不应假设 LLM 能够神奇地自行搞定一切;在现阶段,我们应通过创建推理工作流来引导它。回想一下第5章中介绍的 ReACT 代理,它是工具调用模式的一个典型例子:

image.png

让我们来看几个相对简单的设计模式,这些模式有助于构建高效能的代理。你会在不同领域和各种代理架构中看到这些模式的多种组合:

  • 工具调用:LLM 通过工具调用来实现受控生成。因此,在合适的情况下,将问题封装成工具调用问题,而不是设计复杂提示。记住,工具应有清晰的描述和属性名称,反复试验也是提示工程的一部分。我们在第5章已讨论过这一模式。
  • 任务分解:保持提示相对简洁,提供具体指令和少量示例,将复杂任务拆分为更小步骤。可以让 LLM 部分控制任务分解和规划流程,由外部协调器管理整体流程。我们在第5章构建计划-解决型代理时用到了这一模式。
  • 协作与多样性:在复杂任务中,引入多个基于 LLM 的代理实例之间的协作,可以提升最终输出质量。交流、辩论、共享不同视角非常有帮助,还可以通过为代理设定不同的系统提示、可用工具集等获得多样技能。自然语言是这些代理沟通的天然方式,因为 LLM 是基于自然语言任务训练的。
  • 反思与适应:加入隐式反思循环,通常能提升端到端复杂任务推理质量。LLM 通过调用工具获取外部环境反馈(调用可能失败或产生意外结果),同时可持续迭代、自我修正。夸张地说,我们经常让同一个 LLM 担任“裁判”,让它评估自己的推理并发现错误,常有助于修复问题。本章后续会学习如何构建自适应系统。
  • 模型的非确定性与多候选生成:不要只关注单一输出;通过扩展潜在选项维度,探索不同推理路径,尤其在 LLM 与外部环境交互寻找解法时非常关键。我们将在下面讨论 ToT(思维树)和语言代理树搜索(LATS)示例时详细探讨这一模式。
  • 以代码为中心的问题表述:写代码对 LLM 来说很自然,因此如果可能,尝试将问题表述为代码编写问题。这种方法特别强大,尤其当你结合代码执行沙箱、基于输出的改进循环,以及访问强大数据分析或可视化库,并进行后续生成时。第7章会详细介绍。

两个重要的建议:
第一,开发代理时遵循最佳软件开发实践,使其敏捷、模块化且易于配置。这让你可以组合多个专用代理,且方便用户根据具体任务调优每个代理。
第二,再次强调评估和实验的重要性。第9章将深入探讨评估。请牢记,没有单一通向成功的路径。不同模式对不同任务效果各异。勇于尝试、实验、迭代,并务必评估工作成果。任务数据和预期输出,以及模拟器(让 LLM 安全调用工具的方式),是构建复杂高效代理的关键。

现在我们已经建立了各种设计模式的认知框架,接下来将深入讨论这些原则,探讨各种智能代理架构并结合实例。我们将从在第4章讨论过的 RAG 架构中加入智能代理方法开始。

智能代理驱动的 RAG(Agentic RAG)

大语言模型(LLMs)使得开发智能代理成为可能,这些代理能够处理复杂且非重复性任务,这些任务无法用确定性的工作流来描述。通过以多种方式将推理拆分成多个步骤,并以相对简单的方式进行编排,代理在复杂开放任务上的完成率能显著提升。

这种基于代理的方法可应用于多个领域,包括我们在第4章讨论过的 RAG(检索增强生成)系统。回顾一下,什么是智能代理驱动的 RAG?经典的 RAG 模式是:根据查询检索信息片段,将其组合成上下文,再结合系统提示和问题交给 LLM 生成答案。

我们可以用前面提到的原则(任务分解、工具调用、适应)来改进每一个步骤:

  • 动态检索:将检索查询生成交给 LLM,自主决定使用稀疏嵌入、混合方法、关键词搜索还是网络搜索。你可以将检索封装成工具,利用 LangGraph 进行编排。
  • 查询扩展:让 LLM 基于初始查询生成多个查询,然后通过互惠融合或其他技术合并检索结果。
  • 对检索到的信息块进行推理分解:让 LLM 针对每个信息块结合问题进行评估(过滤无关内容),弥补检索不准的问题;或者让 LLM 仅保留与输入问题相关的信息总结每个信息块。这样,你不是把大量上下文一次性丢给 LLM,而是先并行做多个小的推理步骤。这不仅提升 RAG 的质量,还能通过降低相关性阈值增加初始检索片段数量,或者扩展每个信息块与其邻近片段。换言之,利用 LLM 推理克服部分检索难题。这可能提高整体性能,但也会带来延迟和潜在成本的增加。
  • 反思步骤与迭代:让 LLM 动态迭代检索和查询扩展,在每次迭代后评估输出。你还可以将额外的定锚(grounding)和归因(attribution)工具作为独立步骤加入工作流,基于此推理是否继续完善答案或将其返回给用户。

根据前几章的定义,当你与 LLM 共享对执行流程的部分控制时,传统 RAG 就成为了智能代理驱动的 RAG。例如,当 LLM 决定如何检索、对检索信息进行反思并基于初版答案进行适应时,就是智能代理驱动的 RAG。从我们的视角看,这时迁移到专为构建此类应用设计的 LangGraph 会更加合理,当然,你也可以继续使用 LangChain 或其他你喜欢的框架(第3章中我们分别用 LangChain 和 LangGraph 实现了 map-reduce 视频摘要,供你参考)。

多代理架构

在第5章中,我们了解到将复杂任务拆解为更简单的子任务通常能提升 LLM 的性能。我们构建了一个计划-解决型代理,这比链式思维(CoT)更进一步,鼓励 LLM 生成并遵循计划。在某种程度上,该架构已具备多代理特征,因为研究代理(负责生成和执行计划)会调用另一个专注于不同任务类型的代理——利用工具解决非常具体的任务。多代理工作流通过协调多个代理,实现彼此增强,同时保持代理的模块化(便于测试和复用)。

本章剩余部分将介绍几个核心的智能代理架构,并引入一些重要的 LangGraph 接口(如流式处理细节和任务交接),这些对开发代理非常有用。如果感兴趣,你可以访问 LangChain 官方文档页面查看更多示例和教程:langchain-ai.github.io/langgraph/t… 。我们先从多代理系统中“专业化”的重要性讲起,包括什么是共识机制以及不同的共识机制。

代理角色与专业化

在处理复杂任务时,我们人类通常知道拥有一个技能多样、背景丰富的团队更有利。大量研究和实验表明,这对生成式 AI 代理同样适用。事实上,开发专业化代理对复杂 AI 系统有多方面优势:

  • 提升特定任务的性能。这让你能够:

    • 为每种任务类型选择最优工具集合。
    • 设计针对性的提示词和工作流。
    • 针对特定场景微调超参数(如温度)。
  • 帮助管理复杂性。当前的 LLM 在同时处理过多工具时会遇到困难。最佳实践是限制每个代理只使用5到15个不同的工具,而非将所有可用工具堆积到单一代理。如何合理分组工具仍是一个开放问题,通常将工具归为工具包以构建连贯的专业代理是有效方法。

image.png

除了专业化之外,还应保持代理的模块化。这样可以更方便地维护和改进代理。在企业助理等应用场景中,最终你会拥有许多供用户和开发者使用的不同代理,这些代理可以组合使用。因此,务必确保这些专业代理具有良好的可配置性。

LangGraph 支持轻松地将图作为子图嵌入到更大的图中,组合出复杂的代理体系。实现方式主要有两种:

  1. 将代理编译为图,并在定义另一个代理节点时作为可调用对象传入

    builder.add_node("pay", payments_agent)
    
  2. 用 Python 函数封装子代理调用,并在父代理节点定义中使用该函数

    def _run_payment(state):
        result = payments_agent.invoke({"client_id": state["client_id"]})
        return {"payment status": ...}
    
    builder.add_node("pay", _run_payment)
    

需要注意的是,不同代理可能有不同的状态模式(schema),因为它们执行不同任务。在第一种方式中,父代理调用子代理时会传递相同的键给子代理,子代理完成后会更新父代理状态,并返回对应键的值。第二种方式则让你完全控制传给子代理的状态构造以及父代理状态更新的逻辑。

更多内容请参考文档:langchain-ai.github.io/langgraph/h…

共识机制

我们也可以让多个代理并行处理同一任务。这些代理可能拥有不同的“个性”(通过系统提示定义;例如,有的更好奇和探索,有的更严谨且基于事实),甚至架构不同。它们独立尝试解决问题,然后通过共识机制,从多个方案草稿中选出最佳解决方案。

image.png

我们在第3章中看到过一个基于多数投票实现共识机制的示例。你可以将其封装为一个独立的 LangGraph 节点。此外,实现多个代理之间达成共识还有以下几种替代方式:

  • 让每个代理查看其他方案,并对每个方案按 0 到 1 的评分标准进行打分,最后选择得分最高的方案。
  • 使用其他投票机制。
  • 使用多数投票。多数投票通常适用于分类或类似任务,但对于自由文本输出的情况实现起来较难。这是最快且最节省令牌消耗的机制,因为无需运行额外的提示。
  • 如果存在外部“神谕”系统,可以使用它。例如,求解数学方程时可以轻松验证方案的可行性。计算成本依问题而异,但通常较低。
  • 使用另一个(或更强大的)LLM 作为裁判来挑选最佳方案。你可以让 LLM 给每个方案打分,或者把所有方案当作多分类问题呈现给它,让它选出最优解。
  • 研发另一个专门负责从一组方案中挑选最佳方案的代理。

需要提及的是,共识机制会带来一定的延迟和成本,但相对于解决任务本身的成本,这些开销通常可以忽略不计。如果你让 N 个代理同时执行同一任务,令牌消耗会增加 N 倍,共识机制只会在此基础上增加相对较小的额外开销。

你也可以自己实现共识机制,设计时请考虑:

  • 使用少样本提示(few-shot prompting)让 LLM 作为裁判。
  • 添加示例,展示如何为不同的输入-输出对评分。
  • 考虑包含不同类型响应的评分标准。
  • 在多样化输出上测试机制以确保一致性。

关于并行执行,一个重要的说明是:当你允许 LangGraph 并行执行节点时,更新会按照你添加节点的顺序依次应用到主状态中。

通信协议

第三种架构选择是让代理之间进行通信,并协同完成任务。例如,代理可以通过系统提示配置不同的“个性”以提升协作效果。将复杂任务拆分为更小的子任务,也有助于你保持对应用的控制以及管理代理间的通信方式。

image.png

代理可以通过提供批评和反思来协同完成任务。反思有多种模式,起始于自我反思——代理分析自身步骤,识别改进点(但如前所述,你可以用稍有不同的系统提示初始化反思代理);跨代理反思——使用另一个代理(例如,另一个基础模型)进行评估;甚至包括关键节点上的“人机闭环”(Human-in-the-Loop, HIL)反思(我们将在下一节看到如何构建这类自适应系统)。

你可以让一个代理担任监督者,允许代理在网络中通信(让它们决定向哪个代理发送消息或任务),引入一定层级关系,或设计更复杂的流程。欲获取灵感,可以访问 LangGraph 文档中关于多代理的示意图:langchain-ai.github.io/langgraph/c…

多代理工作流的设计仍属于开放的研究和实验领域,你需要回答很多问题:

  • 系统中应包含多少个代理?
  • 应为这些代理分配什么角色?
  • 每个代理应使用哪些工具?
  • 代理之间应如何交互?通过何种机制?
  • 工作流的哪些具体部分应实现自动化?
  • 如何评估自动化效果?如何收集评估数据?成功标准又是什么?

在了解了多代理通信的核心考量和待解问题后,我们来看两个实用机制以构建和促进代理间互动:

  • 语义路由:根据任务内容智能分发任务;
  • 组织交互:详细说明代理之间有效交换信息时可采用的格式和结构。

语义路由器

在真正的多代理系统中,有许多组织代理通信的方式,其中一个重要的方式是语义路由器。想象开发一个企业助理应用。随着其处理的问题类型日益多样化,复杂度迅速提升——包括一般性问题(需公共数据和通用知识)、公司相关问题(需访问专有公司级数据源)、以及用户特定问题(需访问用户自带的数据)。将所有职责都交给单一代理几乎不可行。此时,设计模式——任务分解与协作——再次派上用场!

假设我们实现了三类代理:

  1. 基于公共数据回答通用问题的代理;
  2. 基于公司全局数据集,熟悉公司细节的代理;
  3. 专门处理用户提供的小规模文档数据的代理。

这种专业化帮助我们使用少样本提示和受控生成等模式。接着,我们引入语义路由器——它是第一层,由 LLM 对问题进行分类,并根据分类结果将问题路由给相应代理。部分代理(或全部)甚至可以使用第3章介绍的自洽性(self-consistency)方法,提高 LLM 分类的准确性。

image.png

值得一提的是,一个任务可能属于两个或多个类别——比如,我可能会问:“什么是 X?我该如何做 Y?”这种情况在助理应用中可能不是特别常见,你可以自行决定如何处理。首先,你可以通过回复解释来教育用户,告诉他们每次交互最好只提出一个问题。有时候开发者过于专注于用程序解决所有问题,但有些产品功能通过界面实现相对简单,用户(尤其是在企业环境中)也愿意提供输入。也许,不必通过提示解决分类问题,而是在界面加个简单的复选框,或者当置信度较低时让系统进行二次确认。

你还可以利用工具调用或其他受控生成技术,提取多个目标,并将执行路由给两个负责不同任务的专业代理。

语义路由的另一个重要方面是,应用性能在很大程度上依赖于分类准确率。你可以使用本书中讨论的各种方法来提升准确率——少样本提示(包括动态少样本)、结合用户反馈、采样等手段。

组织交互

在多代理系统中,有两种组织通信的方式:

  1. 结构化交流
    代理通过特定结构进行通信,迫使它们以特定格式表达想法和推理痕迹。就像上一章的计划-解决示例中,我们的规划节点通过一个带有结构化计划的 Pydantic 模型与 ReACT 代理通信(该计划又是 LLM 受控生成的结果)。
  2. 自然语言消息交流
    LLM 是以自然语言作为输入并生成相同格式的输出,因此通过消息进行通信非常自然。你可以实现一个通信机制,将不同代理的消息合并到共享的消息列表中。

使用消息通信时,所有消息通过所谓的“草稿板”(scratchpad)共享——一个共享的消息列表。这样上下文会快速增长,你可能需要用一些机制来修剪聊天记忆(比如准备运行摘要),正如第3章所讨论的。一般建议是,如果你需要对多代理通信历史的消息进行过滤或优先排序,采用第一种结构化输出方式让它们通过受控输出交流,这样能更好地掌控工作流的状态。此外,复杂的消息序列(如 [SystemMessage, HumanMessage, AIMessage, ToolMessage, AIMessage, AIMessage, SystemMessage, …])是否被支持,需根据所用基础模型的提供商而定。许多模型提供商之前只支持相对简单的消息序列——SystemMessage 后跟交替的人类和 AI 消息(若调用工具则用 ToolMessage 替代人类消息)。

另一种方案是仅共享每次执行的最终结果,这样消息列表长度相对较短。

实践示例

我们开发一个研究代理,利用工具基于公开的 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])
# >> The main factor preventing subsistence economies from advancing economically is the lack of

print(ds_dict["choices"][0])
# >> ['a currency.', 'a well-connected transportation infrastructure.', 'government activity.', 'a banking service.']

接着,创建一个 ReACT 代理,不过我们不使用默认系统提示,而是自定义一个提示,聚焦代理的创造力和基于证据的解题方法(注意,这里用到了第3章提到的链式思维提示元素):

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 an 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}\n\nANSWER 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=MyAgentState,
    prompt=prompt
)

我们不仅止步于此,还给研究代理添加了反思步骤,使用另一个角色扮演监督学生作答的“教授”,对答案进行建设性批评:

reflection_prompt = (
    "You are a university professor and you're supervising a student who is "
    "working on multiple-choice exam question. "
    "QUESTION: {question}.\nANSWER OPTIONS:\n{options}\n."
    "STUDENT'S ANSWER:\n{answer}\n"
    "Reflect on the answer and provide feedback whether the answer "
    "is right or wrong. If you think the final answer is correct, reply with "
    "the final answer. Only provide critique if you think the answer might "
    "be incorrect or there are reasoning flaws. Do not assume anything, "
    "evaluate only the reasoning the student provided and whether there is "
    "enough evidence for their answer."
)

from pydantic import BaseModel, Field
from typing import Optional

class Response(BaseModel):
    """给用户的最终回答"""
    answer: Optional[str] = Field(
        description="最终答案。如有批评意见此字段应为空",
        default=None,
    )
    critique: Optional[str] = Field(
        description="对初始答案的批评。如认为答案可能不正确,提供可操作的反馈",
        default=None,
    )

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 you 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 ANSWER:\n{answer}\n\nFEEDBACK:\n{feedback}"
)

prompt = ChatPromptTemplate.from_messages([
    ("system", system_prompt),
    ("user", raw_prompt_template_with_critique),
    ("placeholder", "{messages}")
])

class ReflectionState(MyAgentState):
    answer: str
    feedback: str

research_agent_with_critique = create_react_agent(
    model=llm_small,
    tools=research_tools,
    state_schema=ReflectionState,
    prompt=prompt
)

定义图状态,需跟踪题目、选项、当前答案和反馈。我们还跟踪师生互动次数(避免无限循环),并用自定义 reducer 汇总历史和新步骤:

from typing import Annotated, Literal, TypedDict
from langchain_core.runnables.config import RunnableConfig
from operator import add

class ReflectionAgentState(TypedDict):
    question: str
    options: str
    answer: str
    steps: Annotated[int, add]
    response: Response

def _should_end(state: ReflectionAgentState, 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"

def _reflection_step(state: ReflectionAgentState):
    result = reflection_chain.invoke(state)
    return {"response": result, "steps": 1}

def _research_start(state: ReflectionAgentState):
    answer = research_agent.invoke(state)
    return {"answer": answer["messages"][-1].content}

def _research(state: ReflectionAgentState):
    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()))

image.png

让我们运行程序,观察发生了什么:

question = ds_dict["question"][0]
options = "\n".join(
    [f"{i}. {a}" for i, a in enumerate(ds_dict["choices"][0])]
)

async for _, event in graph.astream({"question": question, "options": options}, stream_mode=["updates"]):
    print(event)

这里省略了完整输出(你可以从我们的 GitHub 仓库获取代码并自行尝试),但第一次的答案是错误的:

根据 DuckDuckGo 的搜索结果,提供的选项中没有一个是完全正确的。搜索显示,虽然全球女性劳动力参与率有显著提升,但还没有达到大多数女性从事农业工作的程度,也没有全球范围内参与率下降的趋势。此外,关于工作时长的信息表明,在大多数地区女性工作时间普遍长于男性的说法并不成立。因此,提供的选项中没有正确答案。

经过五轮迭代,能力较弱的 LLM 终于找出了正确答案(注意这里的“教授”只评估了推理过程本身,没有使用外部工具或自身知识)。技术上讲,我们实现的是跨代理反思(cross-reflection),而非自我反思(self-reflection),因为用于反思的 LLM 与用于推理的不同。以下是第一轮反馈示例:

学生的推理依赖于未提供的外部搜索结果,导致难以评估其论断的准确性。学生认为所有选项均不完全正确,但多项选择题通常有一个最佳答案,即使该答案需要一定的细微判断。为了正确评估答案,需要提供搜索结果,并将每个选项与这些结果对比,以找出最准确的选项,而不是全部否定。某个选项可能比其他选项更接近正确,尽管不完全正确。没有搜索结果,无法判断学生“没有正确答案”的结论是否成立。此外,学生应明确陈述搜索结果内容。

接下来,我们将讨论另一种多代理通信方式——通过共享消息列表进行交流。但在此之前,我们先介绍 LangGraph 的交接机制,并深入探讨 LangGraph 的流式处理细节。

LangGraph 流式处理

LangGraph 的流式处理有时会让人感到困惑。每个图不仅有一个 stream 方法和对应的异步方法 astream,还有一个叫 astream_events 的方法。下面我们来详细区分它们的区别。

  • stream 方法允许你在每个超级步骤(super-step)之后,流式获取图状态的变化。关于超级步骤,我们在第3章已有讨论,简单来说,超级步骤是对图的一次迭代:并行节点属于同一个超级步骤,顺序节点属于不同的超级步骤。如果你需要真实的流式体验(例如聊天机器人中,让用户感觉模型正在思考并不断产生输出),建议使用带有 messages 模式的 astream

你可以在 stream / astream 方法中使用以下五种模式(当然也可以组合使用多个模式):

模式说明输出示例
updates只流式传输由节点产生的状态更新一个字典,键为节点名,值为对应的状态更新
values在每个超级步骤后流式传输图的完整状态包含整个图状态的字典
debug尽可能多地流式输出调试信息包含时间戳、任务类型和每个事件所有相关信息的字典
custom通过节点使用 StreamWriter 发送的自定义事件节点写入自定义写入器的字典
messages在支持流式节点中流式传输完整事件(如 ToolMessages 或其片段)包含分段令牌或消息及节点元数据的元组

示例

如果用上面章节中的 ReACT 代理,使用 values 模式流式处理,我们会在每个超级步骤后收到图的完整状态(你会发现消息总数不断增加):

async for _, event in research_agent.astream({"question": question, "options": options}, stream_mode=["values"]):
    print(len(event["messages"]))
# 输出示例
# 0
# 1
# 3
# 4

如果改用 updates 模式,则得到一个字典,键为节点名(并行节点可能在同一超级步骤调用),值为该节点发送的状态更新:

async for _, event in research_agent.astream({"question": question, "options": options}, stream_mode=["updates"]):
    node = list(event.keys())[0]
    print(node, len(event[node].get("messages", [])))
# 输出示例
# agent 1
# tools 2
# agent 1

关于 astream_events

LangGraph 的流总是返回一个元组,第一个值表示流模式(因为你可以传入多个模式组成列表)。

astream_events 方法则流式返回节点内部发生的各种事件——不仅是 LLM 生成的令牌,还有任何可用于回调的事件:

seen_events = set([])
async for event in research_agent.astream_events({"question": question, "options": options}, version="v1"):
    if event["event"] not in seen_events:
        seen_events.add(event["event"])
print(seen_events)
# 输出示例
# {'on_chat_model_end', 'on_chat_model_stream', 'on_chain_end', 'on_prompt_end', 'on_tool_start', 'on_chain_stream', 'on_chain_start', 'on_prompt_start', 'on_chat_model_start', 'on_tool_end'}

你可以在这里查看完整的事件列表:python.langchain.com/docs/concep…

交接机制(Handoffs)

到目前为止,我们了解到 LangGraph 中的节点负责完成一部分工作并将更新发送到共享状态,而边(edge)则控制流程,决定接下来调用哪个节点(可以是确定性的,也可以基于当前状态)。在实现多代理架构时,你的节点不仅可以是函数,还可以是其他代理或子图(拥有自己的状态)。这时,你可能需要同时合并状态更新和流程控制。

LangGraph 允许你使用 Command 来实现这一点——你可以更新图的状态,同时传递一个自定义状态调用另一个代理。这就是所谓的“交接”,即一个代理将控制权交给另一个代理。你需要传入两个参数:

  • update:一个包含当前状态更新的字典,用于发送给你的图;
  • goto:节点名称或节点名称列表,表示将控制权交给哪些节点。

示例:

from langgraph.types import Command

def _make_payment(state):
    ...
    if ...:
        return Command(
            update={"payment_id": payment_id},
            goto="refresh_balance"
        )
    ...

目标代理可以是当前图或父图(Command.PARENT)中的节点。换句话说,你只能在当前图内部改变控制流程,或者将控制流程传回启动当前图的上级工作流(你不能任意将控制权传给任意工作流)。你也可以从工具中调用 Command,或将 Command 封装成工具,然后由 LLM 决定将控制权交给哪个具体代理。

在第3章,我们讨论了 map-reduce 模式和 Send 类,它允许我们通过传入特定的输入状态来调用图中的节点。这里可以把 CommandSend 结合使用(示例中目标代理属于父图):

from langgraph.types import Send

def _make_payment(state):
    ...
    if ...:
        return Command(
            update={"payment_id": payment_id},
            goto=[Send("refresh_balance", {"payment_id": payment_id})],
            graph=Command.PARENT
        )
    ...

通过共享消息列表的通信

几章前我们讨论过两代理通过受控输出(发送特殊的 Pydantic 实例)通信。现在回到通信话题,示范如何用原生 LangChain 消息让代理互相通信。

以带有跨代理反思的研究代理为例,其本身状态很简单——它的默认状态是接收用户作为 HumanMessage 的问题:

system_prompt = (
    "You're a hard-working, curious and creative student. "
    "You're working on exam question. 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."
)

research_agent = create_react_agent(
    model=llm_small, tools=research_tools, prompt=system_prompt
)

稍微修改反思提示:

reflection_prompt = (
    "You are a university professor and you're supervising a student who is "
    "working on multiple-choice exam question. Given the dialogue above, "
    "reflect on the answer provided and give feedback "
    "if needed. If you think the final answer is correct, reply with "
    "an empty message. Only provide critique if you think the last answer "
    "might be incorrect or there are reasoning flaws. Do not assume anything, "
    "evaluate only the reasoning the student provided and whether there is "
    "enough evidence for their answer."
)

节点定义更简单,但我们在反思节点后添加了 Command,由节点决定下一步调用哪个节点。同时,我们不再把 ReACT 研究代理封装为节点:

from langgraph.types import Command
from langchain_core.prompts import PromptTemplate

question_template = PromptTemplate.from_template(
    "QUESTION:\n{question}\n\nANSWER OPTIONS:\n{options}\n\n"
)

def _ask_question(state):
    return {"messages": [("human", question_template.invoke(state).text)]}

def _give_feedback(state, config):
    messages = state["messages"] + [("human", reflection_prompt)]
    max_messages = config["configurable"].get("max_messages", 20)
    if len(messages) > max_messages:
        return Command(update={}, goto="END")

    result = llm.invoke(messages)
    if result.content:
        return Command(
            update={"messages": [("assistant", result.content)]},
            goto="research"
        )
    return Command(update={}, goto="END")

图结构如下:

class ReflectionAgentState(MessagesState):
    question: str
    options: str

builder = StateGraph(ReflectionAgentState)
builder.add_node("ask_question", _ask_question)
builder.add_node("research", research_agent)
builder.add_node("reflect", _give_feedback)
builder.add_edge(START, "ask_question")
builder.add_edge("ask_question", "research")
builder.add_edge("research", "reflect")
graph = builder.compile()

运行时,你会发现图中各阶段都在操作同一个(且不断增长的)消息列表。

LangGraph 平台

正如你所知,LangGraph 和 LangChain 都是开源框架,但作为一家公司,LangChain 提供了 LangGraph 平台——这是一个商业化解决方案,帮助你开发、管理和部署智能代理应用。LangGraph 平台的一个组成部分是 LangGraph Studio——一个集成开发环境(IDE),可以帮助你可视化和调试代理;另一个部分是 LangGraph Server。

你可以在官网(langchain-ai.github.io/langgraph/c…)了解更多关于LangGraph 平台的信息。这里我们简要介绍几个关键概念,帮助你更好地理解开发智能代理意味着什么。

开发好代理后,你可以将其封装为 HTTP API(使用 Flask、FastAPI 或任何其他 Web 框架)。LangGraph 平台为你提供了原生的代理部署方式,并用统一的 API 封装代理,使得你的应用更方便地调用这些代理。当你将代理构建为 LangGraph 的图对象后,就可以部署一个助手(assistant)——这是一个具体的部署实例,包含你的图和配套配置。你可以在 UI 中轻松地版本管理和配置助手,但务必保持参数可配置性(通过 RunnableConfig 传递给节点和工具)。

另一个重要概念是“线程(thread)”。不要混淆,LangGraph 的线程与 Python 线程不同(当你在 RunnableConfig 中传递 thread_id 时,传递的是 LangGraph 线程 ID)。你可以把 LangGraph 线程想象成会话或者 Reddit 讨论串。线程表示助手(一个带有特定配置的图)与用户之间的会话。你可以使用第3章讨论的检查点机制,实现线程级别的持久化。

一次“运行(run)”是一次助手的调用。大多数情况下,运行都在某个线程上执行(以支持持久化)。LangGraph Server 也支持调度无状态运行——它们不归属于任何线程,因此交互历史不会被保存。LangGraph Server 支持长时间运行任务、定时任务(cron)等调度功能,还提供了丰富的 Webhook 机制,支持将结果轮询返回给用户。

本书不涉及 LangGraph Server API,建议你查阅官方文档以获得详细信息。

构建自适应系统

适应性是代理的重要特质。代理应当根据外部环境和用户反馈不断调整并修正自身行为。正如我们在第5章讨论的,生成式AI代理的适应性体现在以下几个方面:

  • 工具交互:代理会结合之前工具调用及其输出的反馈(通过包含表示工具调用结果的 ToolMessages),来规划后续步骤(例如我们的 ReACT 代理根据搜索结果进行调整)。
  • 显式反思:代理可以被指示分析当前结果并有意识地调整行为。
  • 人类反馈:代理可以在关键决策点纳入用户输入。

动态行为调整

我们已经看到如何给计划-执行代理添加反思步骤。基于初始计划和当前执行步骤的输出,LLM 会反思该计划并做出调整。这里再次强调,反思不会自发发生,你需要将其作为单独任务(任务分解),并通过设计通用组件对执行流程保持部分控制。

人类参与(Human-in-the-loop)

在开发具有复杂推理轨迹的代理时,在某些阶段引入人类反馈非常有益。代理可以请求人类批准或拒绝某些动作(比如调用不可逆的支付工具),为代理提供额外上下文,或通过修改图状态来输入特定信息。

举例来说,假设我们开发一个代理,负责搜索职位信息、生成求职申请并提交。我们可能希望在提交申请前询问用户确认,或者代理正在收集用户信息,但对某些职位缺少过去工作经验的相关上下文,应当请求用户补充,并将这些信息持久化存储于长期记忆中以利于长期适应。

LangGraph 提供了特殊的 interrupt 功能用于实现人机交互(HIL)。你应将此功能包含于节点中,首次执行时会抛出 GraphInterrupt 异常(该异常内容会呈现给用户)。要恢复图的执行,客户端应使用本章前述的 Command 类,LangGraph 会从该节点重新开始执行,并返回调用中断函数的结果值(若节点中有多个中断,LangGraph 会保持顺序)。你也可以基于用户输入使用 Command 路由到不同节点。当然,interrupt 只能在为图提供了检查点(checkpointer)时使用,因为状态需要持久化。

简单示例:询问用户家庭住址

from langgraph.types import interrupt, Command
from typing import Optional

class State(MessagesState):
    home_address: Optional[str]

def _human_input(state: State):
    address = interrupt("What is your address?")
    return {"home_address": address}

builder = StateGraph(State)
builder.add_node("human_input", _human_input)
builder.add_edge(START, "human_input")

checkpointer = MemorySaver()
graph = builder.compile(checkpointer=checkpointer)

config = {"configurable": {"thread_id": "1"}}

for chunk in graph.stream({"messages": [("human", "What is weather today?")]}, config):
    print(chunk)
# 输出示例:
# {'__interrupt__': (Interrupt(value='What is your address?', resumable=True, ns=['human_input:b7e8a744-b404-0a60-7967-ddb8d30b11e3'], when='during'),)}

此时图返回了一个特殊的 __interrupt__ 状态并暂停执行。应用程序(客户端)应将该问题呈现给用户,等待用户回答后再继续执行。注意恢复时传入相同的 thread_id 以从检查点恢复:

for chunk in graph.stream(Command(resume="Munich"), config):
    print(chunk)
# 输出示例:
# {'human_input': {'home_address': 'Munich'}}

图继续执行 human_input 节点,但这次 interrupt 函数返回了结果,且图的状态被更新。

至此,我们讨论了几种开发代理的架构模式。接下来,让我们看看另一个有趣的模式,它允许 LLM 在寻找解决方案时运行多次模拟。

探索推理路径

在第3章中,我们讨论了链式思维(CoT)提示方法。但链式思维是在单轮对话中生成一条推理路径。那么,如果结合任务分解和自适应模式,把推理拆分成多个部分呢?

思维树(Tree of Thoughts)

谷歌 DeepMind 和普林斯顿大学的研究人员在2023年12月提出了思维树(ToT)技术。他们将链式思维模式进行了推广,利用“思维”作为探索全局解的中间步骤。

回到我们上一章构建的计划-执行代理,我们可以利用 LLM 的非确定性特性改进它。每一步我们都可以生成多个候选的下一步行动(这可能需要提高 LLM 的温度参数),从而让代理更具适应性,因为下一步计划会基于上一步的输出结果进行调整。

我们现在可以构建一个多选项的树结构,并用深度优先搜索或广度优先搜索的方法来探索这棵树。最终会产生多个解决方案,我们可以使用前面讨论的共识机制(比如由 LLM 担任裁判)来选出最佳方案。

image.png

请注意,模型提供方应支持在响应中生成多个候选项(并非所有提供方都支持此功能)。

我们想强调(本章多次重复说明)的是,思维树(ToT)模式本质上并无全新之处。它借用了已有领域的算法和模式,用以构建更强大的代理。

现在开始编写代码。我们将使用第5章中开发的计划-执行代理的组件——一个生成初始计划的规划器(planner)和一个带工具的执行代理(execution_agent),用于执行计划中的具体步骤。执行代理可以简化,因为我们不需要自定义状态:

execution_agent = prompt_template | create_react_agent(model=llm, tools=tools)

还需要一个重新规划器(replanner)组件,它负责基于之前观察结果调整计划,并为下一步动作生成多个候选:

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

class ReplanStep(BaseModel):
    """重新规划的下一步计划。"""
    steps: list[str] = Field(description="候选的下一步动作选项")

llm_replanner = llm.with_structured_output(ReplanStep)

replanner_prompt_template = (
    "建议计划中的下一步动作。不要添加多余的步骤。\n"
    "如果你认为不需要动作,返回空列表。\n"
    "任务:{task}\n"
    "之前的步骤和输出:{current_plan}"
)

replanner_prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "你是个有帮助的助手,目标是规划行动步骤以解决任务,不要直接解决任务。"),
        ("user", replanner_prompt_template)
    ]
)

replanner = replanner_prompt | llm_replanner

这个重新规划器在 ToT 方法中至关重要。它接受当前计划状态,生成多个潜在的下一步,鼓励探索不同的解决路径,而非单线序列。

为了追踪探索路径,我们需要树形数据结构。以下 TreeNode 类帮助维护该结构:

class TreeNode:
    def __init__(self, node_id: int, step: str, step_output: Optional[str] = None, parent: Optional["TreeNode"] = None):
        self.node_id = node_id
        self.step = step
        self.step_output = step_output
        self.parent = parent
        self.children = []
        self.final_response = None

    def __repr__(self):
        parent_id = self.parent.node_id if self.parent else "None"
        return f"Node_id: {self.node_id}, parent: {parent_id}, {len(self.children)} children."

    def get_full_plan(self) -> str:
        """返回格式化的计划,包含步骤编号和历史结果。"""
        steps = []
        node = self
        while node.parent:
            steps.append((node.step, node.step_output))
            node = node.parent
        full_plan = []
        for i, (step, result) in enumerate(steps[::-1]):
            if result:
                full_plan.append(f"# {i+1}. 计划步骤: {step}\n结果: {result}\n")
        return "\n".join(full_plan)

每个 TreeNode 记录自身 ID、当前步骤、输出结果、父节点和子节点。我们还实现了获取格式化完整计划的方法(将用于替代提示模板),并重写了 __repr__ 方法便于调试。

接下来实现代理核心逻辑。我们将在深度优先搜索模式下探索行动树。这是 ToT 模式的核心力量所在:

async def _run_node(state: PlanState, config: RunnableConfig):
    node = state.get("next_node")
    visited_ids = state.get("visited_ids", set())
    queue = state["queue"]

    if node is None:
        while queue and not node:
            node = state["queue"].popleft()
            if node.node_id in visited_ids:
                node = None
        if not node:
            return Command(goto="vote", update={})

    step = await execution_agent.ainvoke({
        "previous_steps": node.get_full_plan(),
        "step": node.step,
        "task": state["task"]
    })
    node.step_output = step["messages"][-1].content
    visited_ids.add(node.node_id)
    return {"current_node": node, "queue": queue, "visited_ids": visited_ids, "next_node": None}


async def _plan_next(state: PlanState, config: RunnableConfig) -> PlanState:
    max_candidates = config["configurable"].get("max_candidates", 1)
    node = state["current_node"]
    next_step = await replanner.ainvoke({"task": state["task"], "current_plan": node.get_full_plan()})

    if not next_step.steps:
        return {"is_current_node_final": True}

    max_id = state["max_id"]
    for step in next_step.steps[:max_candidates]:
        child = TreeNode(node_id=max_id + 1, step=step, parent=node)
        max_id += 1
        node.children.append(child)
        state["queue"].append(child)

    return {"is_current_node_final": False, "next_node": child, "max_id": max_id}


async def _get_final_response(state: PlanState) -> PlanState:
    node = state["current_node"]
    final_response = await responder.ainvoke({"task": state["task"], "plan": node.get_full_plan()})
    node.final_response = final_response
    return {"paths_explored": 1, "candidates": [final_response]}
  • _run_node 执行当前步骤;
  • _plan_next 生成候选步骤并加入探索队列;
  • 当达到无需进一步步骤的终节点时,_get_final_response 根据多个候选方案生成最终解。

因此,代理状态应包含根节点、下一节点、待探索队列、已探索节点等信息:

import operator
from collections import deque
from typing import Annotated, TypedDict

class PlanState(TypedDict):
    task: str
    root: TreeNode
    queue: deque[TreeNode]
    current_node: TreeNode
    next_node: TreeNode
    is_current_node_final: bool
    paths_explored: Annotated[int, operator.add]
    visited_ids: set[int]
    max_id: int
    candidates: Annotated[list[str], operator.add]
    best_candidate: str

此状态结构涵盖了任务、树结构、探索队列、路径元数据和候选解。注意 Annotated 类型结合自定义合并器(如 operator.add)用于正确合并状态值。

需注意,LangGraph 不允许直接修改状态。也就是说,若你在节点中写:

def my_node(state):
    queue = state["queue"]
    node = queue.pop()
    ...
    queue.append(another_node)
    return {"key": "value"}

这样不会影响代理状态中的实际队列。若想修改状态内的队列,应使用自定义合并器(详见第3章)或返回新的队列对象替换原有队列(因为 LangGraph 底层会在传递状态前做深拷贝)。

现在定义最终步骤——基于多个生成候选选择最佳答案的共识机制:

prompt_voting = PromptTemplate.from_template(
    "为给定任务选出最佳方案。"
    "\n任务:{task}\n\n方案列表:\n{candidates}\n"
)

def _vote_for_the_best_option(state):
    candidates = state.get("candidates", [])
    if not candidates:
        return {"best_response": None}
    all_candidates = []
    for i, candidate in enumerate(candidates):
        all_candidates.append(f"选项 {i+1}: {candidate}")

    response_schema = {
        "type": "STRING",
        "enum": [str(i+1) for i in range(len(all_candidates))]
    }

    llm_enum = ChatVertexAI(
        model_name="gemini-2.0-flash-001",
        response_mime_type="text/x.enum",
        response_schema=response_schema
    )

    result = (prompt_voting | llm_enum | StrOutputParser()).invoke(
        {"candidates": "\n".join(all_candidates), "task": state["task"]}
    )
    return {"best_candidate": candidates[int(result) - 1]}

该投票机制将所有候选方案展示给模型,让其评估比较并选出最佳方案。

现在让我们添加代理的剩余节点和边。我们需要两个节点——一个用于创建初始计划,另一个用于评估最终输出。除此之外,我们还定义了两个对应的边,用于判断代理是否应该继续探索,以及是否已经准备好向用户提供最终回复:

from typing import Literal
from langgraph.graph import StateGraph, START, END
from langchain_core.runnables import RunnableConfig
from langchain_core.output_parsers import StrOutputParser
from langgraph.types import Command

final_prompt = PromptTemplate.from_template(
    "You're a helpful assistant that has executed on a plan."
    "Given the results of the execution, prepare the final response.\n"
    "Don't assume anything\nTASK:\n{task}\n\nPLAN WITH RESULTS:\n{plan}\n"
    "FINAL RESPONSE:\n"
)

responder = final_prompt | llm | StrOutputParser()

async def _build_initial_plan(state: PlanState) -> PlanState:
    plan = await planner.ainvoke(state["task"])
    queue = deque()
    root = TreeNode(step=plan.steps[0], node_id=1)
    queue.append(root)
    current_root = root
    for i, step in enumerate(plan.steps[1:]):
        child = TreeNode(node_id=i+2, step=step, parent=current_root)
        current_root.children.append(child)
        queue.append(child)
        current_root = child
    return {"root": root, "queue": queue, "max_id": i+2}

async def _get_final_response(state: PlanState) -> PlanState:
    node = state["current_node"]
    final_response = await responder.ainvoke({"task": state["task"], "plan": node.get_full_plan()})
    node.final_response = final_response
    return {"paths_explored": 1, "candidates": [final_response]}

def _should_create_final_response(state: PlanState) -> Literal["run", "generate_response"]:
    return "generate_response" if state["is_current_node_final"] else "run"

def _should_continue(state: PlanState, config: RunnableConfig) -> Literal["run", "vote"]:
    max_paths = config["configurable"].get("max_paths", 30)
    if state.get("paths_explored", 1) > max_paths:
        return "vote"
    if state["queue"] or state.get("next_node"):
        return "run"
    return "vote"

这些函数完善了我们的实现,定义了初始计划的创建、最终响应的生成以及流程控制逻辑。_should_create_final_response_should_continue 函数用于判断何时生成最终响应,何时继续探索。

有了这些组件后,我们构建最终的状态图:

builder = StateGraph(PlanState)
builder.add_node("initial_plan", _build_initial_plan)
builder.add_node("run", _run_node)
builder.add_node("plan_next", _plan_next)
builder.add_node("generate_response", _get_final_response)
builder.add_node("vote", _vote_for_the_best_option)
builder.add_edge(START, "initial_plan")
builder.add_edge("initial_plan", "run")
builder.add_edge("run", "plan_next")
builder.add_conditional_edges("plan_next", _should_create_final_response)
builder.add_conditional_edges("generate_response", _should_continue)
builder.add_edge("vote", END)
graph = builder.compile()

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

这段代码创建了我们完整执行流程的代理。图从初始规划开始,经过执行和重新规划步骤,为完成的路径生成响应,最终通过投票选出最佳方案。我们使用 Mermaid 图表生成器可视化流程,清晰展示了代理的决策过程。

image.png

我们可以控制最大超步数、树中待探索的最大路径数(尤其是最终生成的候选方案的最大数量),以及每步的候选数量。理论上,我们还可以扩展配置来控制树的最大深度。现在让我们运行我们的图:

task = "Write a strategic one-pager of building an AI startup"
result = await graph.ainvoke({"task": task}, config={"recursion_limit": 10000, "configurable": {"max_paths": 10}})
print(len(result["candidates"]))
print(result["best_candidate"])

我们还可以对已探索的树进行可视化:

image.png

我们限制了候选方案的数量,但理论上可以增加这个数量,并且添加额外的剪枝逻辑(用来剪除那些看起来不太有前景的叶子节点)。我们可以继续使用之前提到的“LLM作为裁判”的方法,或者采用其他启发式方法来进行剪枝。也可以探索更高级的剪枝策略,下一节我们将讨论其中一种。

用MCTS优化思维树(ToT)

有些人可能还记得AlphaGo——第一个在围棋比赛中击败人类的计算机程序。谷歌DeepMind团队在2015年开发了它,并将蒙特卡洛树搜索(MCTS)作为其核心决策算法。它的基本思想很简单:在游戏下一步之前,算法会建立一个包含所有潜在未来走法的决策树,树的节点代表你和对手可能的走法(这棵树会迅速膨胀,想必你能理解)。为了防止树膨胀得过快,他们利用MCTS只搜索最有希望获得更好局面的路径。

现在回到上一章讲的思维树(ToT)模式。想想我们之前构建的ToT的维度可能会增长得非常快。如果每步生成3个候选,且流程只有5步,最终就要评估3^5=243个步骤。这会带来很高的计算成本和时间消耗。我们可以用不同的方法来削减这个维度,比如使用MCTS。MCTS包括以下两个部分:

  • 选择(Selection) :在分析树时帮助你选择下一个节点。通过平衡探索(寻找新节点)和利用(利用已知的最佳节点)来完成,也会在此过程中加入一定随机性。
  • 模拟(Simulation) :在扩展树节点(添加新子节点)后,如果该节点不是终止节点,需要模拟其后续结果。模拟可以是随机玩到游戏结束,也可以用更复杂的模拟方法。模拟完成后,将结果反向传播给所有父节点,调整它们下一轮选择的概率评分。

我们这里不打算深入讲解MCTS,只想展示如何将已有算法应用到智能体(agentic)工作流中以提升性能。其中一个例子是2024年6月,Andy Zhou等人在论文《Language Agent Tree Search Unifies Reasoning, Acting, and Planning in Language Models》中提出的LATS方法。该论文在ToT基础上加上了MCTS,并通过在HumanEval基准测试中获得第一名,展示了复杂任务性能的提升。

其关键思想是:不必探索整棵树,而是使用LLM对每一步得到的解决方案质量进行评估(通过查看特定推理步骤的所有步骤序列和迄今为止的输出)。

现在,既然我们讨论了一些能让我们构建更优智能体的高级架构,还有一个最后要简单提及的部分——记忆。帮助智能体从长期交互中保留并检索相关信息,将使我们能开发出更先进、更有用的智能体。

智能体记忆

我们在第3章讨论了记忆机制。简单回顾一下,LangGraph通过Checkpointer机制实现了短期记忆,该机制将检查点保存到持久存储中。这就是所谓的“每线程持久化”(记得我们前面提到过,LangGraph中的线程概念类似于一次对话)。换句话说,智能体会记住特定会话中的交互内容,但每次都会从头开始。

你可以想象,对于复杂的智能体来说,这种记忆机制可能存在两个方面的低效:
一是你可能会丢失关于用户的重要信息;
二是在寻找解决方案的探索阶段,智能体可能学到了环境中的一些重要信息,但每次都被遗忘,这显然不高效。

这就是长期记忆的概念,它帮助智能体积累知识并从历史经验中受益,从而支持其在长期上的持续改进。

如何设计和使用长期记忆在实际中仍是一个开放的问题。
首先,你需要提取想要在运行时保存的有用信息(同时注意隐私要求,详见第9章),然后在下一次执行时再提取这些信息。提取过程类似我们在讲RAG时讨论的检索问题,因为我们只需提取与给定上下文相关的知识。
最后一个环节是记忆压缩——你可能希望定期对已学知识进行反思、优化,并遗忘不相关的事实。

这些是关键考量点,但到目前为止,我们还没有看到成熟的、适用于智能体工作流的长期记忆实践方案。如今,实际应用中通常采用两种组件——内置缓存(用于缓存LLM响应的机制)、内置存储(持久的键值存储)以及自定义缓存或数据库。当你有以下需求时,建议使用自定义方案:

  • 需要更灵活地组织记忆,例如追踪所有记忆状态;
  • 需要对记忆进行高级的读写访问模式;
  • 需要将记忆分布式存储在多个工作节点上,且希望使用除PostgreSQL之外的数据库。

缓存

缓存允许你保存并检索键值对。假设你在开发企业问答助手应用,在用户界面中询问用户是否满意答案。如果用户反馈是肯定的,或者你已有重要主题的问答数据集,可以将它们存入缓存。当以后出现相同或相似问题时,系统能快速返回缓存中的答案,而无需重新生成。

LangChain支持通过如下方式设置全局缓存(初始化缓存后,LLM的响应会自动被添加到缓存中):

from langchain_core.caches import InMemoryCache
from langchain_core.globals import set_llm_cache

cache = InMemoryCache()
set_llm_cache(cache)

llm = ChatVertexAI(model="gemini-2.0-flash-001", temperature=0.5)
llm.invoke("What is the capital of UK?")

LangChain的缓存工作机制如下:
每个厂商实现的ChatModel都继承自基础类,基础类在生成响应时会优先在缓存中查找对应值。cache是一个全局变量(前提是已被初始化)。缓存的键由提示语的字符串表示和LLM实例的字符串表示组成(由llm._get_llm_string方法生成)。

这意味着LLM的生成参数(比如stop_words或temperature)也包含在缓存键中:

import langchain
print(langchain.llm_cache._cache)

LangChain默认支持内存缓存和SQLite缓存(属于langchain_core.caches模块),并且有许多厂商集成——可通过langchain_community.cache子包获取(文档地址:python.langchain.com/api_referen…),或者通过特定厂商集成实现,比如langchain-mongodb支持MongoDB缓存集成(文档地址:langchain-mongodb.readthedocs.io/en/latest/l…)。

我们建议你使用独立的LangGraph节点去访问真正的缓存(基于Redis或其他数据库),因为这样可以控制是否启用我们在第4章讲RAG时提到的向量检索机制来搜索相似问题。

存储(Store)

正如我们之前所学,Checkpointer机制允许你通过线程级持久化记忆来增强工作流;这里的线程级指的是会话级持久化。每次会话都可以从上次停止的地方继续,工作流会执行之前收集的上下文。

BaseStore 是一个持久化的键值存储系统,它通过命名空间来组织你的值(命名空间是字符串路径的层级元组,类似文件夹结构)。它支持标准操作,如存储(put)、删除(delete)和获取(get)操作,并且提供了一个搜索方法,该方法实现了多种语义搜索能力(通常基于嵌入机制),并考虑到了命名空间的层级特性。

下面我们初始化一个存储并添加一些值:

from langgraph.store.memory import InMemoryStore

in_memory_store = InMemoryStore()
in_memory_store.put(namespace=("users", "user1"), key="fact1", value={"message1": "My name is John."})
in_memory_store.put(namespace=("users", "user1", "conv1"), key="address", value={"message": "I live in Berlin."})

我们可以轻松地查询值:

in_memory_store.get(namespace=("users", "user1", "conv1"), key="address")

输出示例:

Item(namespace=['users', 'user1'], key='fact1', value={'message1': 'My name is John.'}, created_at='2025-03-18T14:25:23.305405+00:00', updated_at='2025-03-18T14:25:23.305408+00:00')

如果我们只用部分命名空间路径查询,则不会有结果(必须完全匹配命名空间)。例如,下面的查询不会返回结果:

in_memory_store.get(namespace=("users", "user1"), key="conv1")

但使用搜索功能时,可以用部分命名空间路径:

print(len(in_memory_store.search(("users", "user1", "conv1"), query="name")))
print(len(in_memory_store.search(("users", "user1"), query="name")))

输出:

1
2

由此可见,我们能够通过部分搜索检索到存储在内存中的所有相关事实。

LangGraph 内置了 InMemoryStore 和 PostgresStore 两种实现。智能体的记忆机制仍在不断演进。你可以基于现有组件构建自己的实现,但我们预计未来几年甚至几个月内会看到大量进展。

总结

在本章中,我们深入探讨了大型语言模型(LLM)的高级应用及其支持的架构模式,重点利用了LangChain和LangGraph框架。核心要点是,构建复杂的AI系统不仅仅是对LLM进行简单提示,而是需要对整个工作流进行精心的架构设计,合理利用工具,并赋予LLM对工作流部分控制权。我们还讨论了多种基于智能体(agentic AI)的设计模式,以及如何开发利用LLM调用工具能力来解决复杂任务的智能体。

我们深入了解了LangGraph的流式处理机制,以及如何控制执行过程中返回的信息。讨论了状态更新流与部分答案令牌流的区别,学习了Command接口,它是一种将执行权移交给当前LangGraph工作流内外特定节点的方式。我们还介绍了LangGraph平台及其主要功能,并探讨了如何用LangGraph实现人机协同(HIL)。此外,我们区分了LangGraph中的线程概念与传统Python线程定义(LangGraph线程类似于会话实例),并学习了如何在工作流中添加线程级别和跨线程的持久记忆机制。最后,我们了解了如何利用LangChain和LangGraph的高级能力,超越基础LLM应用,构建健壮、自适应且智能的系统。

下一章,我们将探讨生成式AI如何通过辅助代码开发和数据分析,改变软件工程行业。