LangChain——代理 II

196 阅读17分钟

第六章介绍了代理架构,这是我们迄今为止见过的最强大的 LLM 架构。将链式思维提示、工具使用和循环结合起来的潜力难以夸大。

本章讨论了两种对代理架构的扩展,它们能在某些用例中提高性能:

  • 反思
    这是借鉴人类思维模式的另一个例子,它为 LLM 应用提供了分析过去输出和决策的机会,并能够记住过去迭代中的反思。
  • 多代理
    就像一个团队能够比单个人完成更多工作一样,有些问题最适合通过多个 LLM 代理的团队来解决。

让我们从反思开始。

反思

我们尚未讨论的一种提示技术是反思(也叫自我批评)。反思是创建一个创作者提示和修订者提示之间的循环。这与许多人类创作过程相似,例如你正在阅读的这一章,它是作者、审稿人和编辑之间来回交流的结果,直到每个人都对最终产品满意为止。

像我们之前看到的许多提示技术一样,反思可以与其他技术结合使用,例如链式思维和工具调用。在本节中,我们将单独看一下反思。

这可以与丹尼尔·卡尼曼在《思考,快与慢》(Farrar, Straus and Giroux,2011)一书中首次介绍的人类思维模式——系统 1(反应性或本能)和系统 2(有条理和反思性)相类比。正确应用时,自我批评可以帮助 LLM 应用更接近类似于系统 2 行为的模式(见图 7-1)。

image.png

我们将反思实现为一个包含两个节点的图:generatereflect。这个图的任务是写三段式文章,其中 generate 节点负责编写或修订文章草稿,而 reflect 节点则写出批评意见来指导下一次修订。我们将循环执行固定次数,但该技术的变体是让 reflect 节点决定何时结束。让我们看看它的实现:

Python:

from typing import Annotated, TypedDict

from langchain_core.messages import (
    AIMessage,
    BaseMessage,
    HumanMessage,
    SystemMessage,
)
from langchain_openai import ChatOpenAI

from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import add_messages

model = ChatOpenAI()

class State(TypedDict):
    messages: Annotated[list[BaseMessage], add_messages]

generate_prompt = SystemMessage(
    """You are an essay assistant tasked with writing excellent 3-paragraph 
        essays."""
    "Generate the best essay possible for the user's request."
    """If the user provides critique, respond with a revised version of your 
        previous attempts."""
)

def generate(state: State) -> State:
    answer = model.invoke([generate_prompt] + state["messages"])
    return {"messages": [answer]}

reflection_prompt = SystemMessage(
    """You are a teacher grading an essay submission. Generate critique and 
        recommendations for the user's submission."""
    """Provide detailed recommendations, including requests for length, depth, 
        style, etc."""
)

def reflect(state: State) -> State:
    # Invert the messages to get the LLM to reflect on its own output
    cls_map = {AIMessage: HumanMessage, HumanMessage: AIMessage}
    # First message is the original user request. 
    # We hold it the same for all nodes
    translated = [reflection_prompt, state["messages"][0]] + [
        cls_map[msg.__class__](content=msg.content) 
            for msg in state["messages"][1:]
    ]
    answer = model.invoke(translated)
    # We treat the output of this as human feedback for the generator
    return {"messages": [HumanMessage(content=answer.content)]}

def should_continue(state: State):
    if len(state["messages"]) > 6:
        # End after 3 iterations, each with 2 messages
        return END
    else:
        return "reflect"

builder = StateGraph(State)
builder.add_node("generate", generate)
builder.add_node("reflect", reflect)
builder.add_edge(START, "generate")
builder.add_conditional_edges("generate", should_continue)
builder.add_edge("reflect", "generate")

graph = builder.compile()

JavaScript:

import {
  AIMessage,
  BaseMessage,
  SystemMessage,
  HumanMessage,
} from "@langchain/core/messages";
import { ChatOpenAI } from "@langchain/openai";
import {
  StateGraph,
  Annotation,
  messagesStateReducer,
  START,
  END,
} from "@langchain/langgraph";

const model = new ChatOpenAI();

const annotation = Annotation.Root({
  messages: Annotation({ reducer: messagesStateReducer, default: () => [] }),
});

// fix multiline string
const generatePrompt = new SystemMessage(
  `You are an essay assistant tasked with writing excellent 3-paragraph essays.
  Generate the best essay possible for the user's request.
  If the user provides critique, respond with a revised version of your 
    previous attempts.`
);

async function generate(state) {
  const answer = await model.invoke([generatePrompt, ...state.messages]);
  return { messages: [answer] };
}

const reflectionPrompt = new SystemMessage(
  `You are a teacher grading an essay submission. Generate critique and 
    recommendations for the user's submission.
  Provide detailed recommendations, including requests for length, depth, 
    style, etc.`
);

async function reflect(state) {
  // Invert the messages to get the LLM to reflect on its own output
  const clsMap: { [key: string]: new (content: string) => BaseMessage } = {
    ai: HumanMessage,
    human: AIMessage,
  };
  // First message is the original user request. 
  // We hold it the same for all nodes
  const translated = [
    reflectionPrompt,
    state.messages[0],
    ...state.messages
      .slice(1)
      .map((msg) => new clsMap[msg._getType()](msg.content as string)),
  ];
  const answer = await model.invoke(translated);
  // We treat the output of this as human feedback for the generator
  return { messages: [new HumanMessage({ content: answer.content })] };
}

function shouldContinue(state) {
  if (state.messages.length > 6) {
    // End after 3 iterations, each with 2 messages
    return END;
  } else {
    return "reflect";
  }
}

const builder = new StateGraph(annotation)
  .addNode("generate", generate)
  .addNode("reflect", reflect)
  .addEdge(START, "generate")
  .addConditionalEdges("generate", shouldContinue)
  .addEdge("reflect", "generate");

const graph = builder.compile();

图形的可视化表示如图 7-2 所示。

image.png

请注意,反射节点如何通过欺骗LLM让它认为正在批评用户写的文章。同时,生成节点被让认为批评来自用户。这个策略是必要的,因为对话调优的LLM是基于人类与AI消息对训练的,因此同一参与者的多条消息会导致性能下降。

还有一点需要注意:乍一看,您可能会期望结束出现在修改步骤之后,但在这个架构中,我们有一个固定的生成-反射循环迭代次数;因此,我们在生成之后终止(以便处理最后一组修改请求)。这种架构的变体则会让反射步骤决定是否结束过程(当没有更多评论时)。

让我们来看一下其中一个批评的样子: { 'messages': [ HumanMessage(content='你关于《小王子》的主题性以及它在现代生活中的意义的文章写得很好,富有洞察力。你有效地突出了这本书的持久相关性及其在当今社会的重要性。然而,有几个方面你可以增强你的文章:\n\n1. 深度:虽然你提到了珍惜简单的快乐、培养联系和理解人际关系的主题,但可以考虑更深入地探讨这些主题。提供书中的具体例子来支持你的观点,并探讨这些主题如何在当代生活中体现。\n\n2. 分析:考虑分析这本书的信息如何应用于当前的社会问题或个人经历。例如,你可以讨论《小王子》对物质主义的看法如何与消费文化相关,或者探索他的关系处理方式如何影响数字时代的人际关系。\n\n3. 长度:通过增加更多的例子、讨论反方观点,或者探讨《小王子》在世界不同地区的文化影响来扩展你的想法。这将丰富你分析的深度,并提供对这本书相关性的更全面的理解。\n\n4. 风格:你的文章清晰且结构良好。为了增强读者的参与感,可以考虑引用书中的名言来说明关键观点,或通过加入轶事来个性化你的分析。\n\n5. 结论:通过总结《小王子》的持久意义以及它的信息如何激发现代社会中的积极变化来结束你的文章。反思这本书主题的广泛影响,并给读者留下深刻印象。\n\n通过扩展你的分析、加入更多的例子,并加深对书籍信息的探索,你可以写出一篇更全面、更具吸引力的关于《小王子》在现代生活中的主题性的文章。对于你富有思考的分析,做得很棒,继续努力!', id='70c22b1d-ec96-4dc3-9fd0-d2c6463f9e2c'), ], } 最终输出: { 'messages': [ AIMessage(content='"《小王子》" 由安托万·德·圣-埃克苏佩里(Antoine de Saint-Exupéry)创作,是一部永恒的杰作,继续为人类关系和价值观提供深刻的见解,跨代共鸣。小王子的旅行叙事及其与各种人物的邂逅,构成了一个丰富的寓言式表现,......', response_metadata= {'token_usage': {'completion_tokens': 420, 'prompt_tokens': 2501, 'total_tokens': 2921}, 'model_name': 'gpt-3.5-turbo', 'system_fingerprint': None, 'finish_reason': 'stop', 'logprobs': None}, id='run-2e8f9f13-f625-4820-9c8b-b64e1c23daa2-0', usage_metadata={'input_tokens': 2501, 'output_tokens': 420, 'total_tokens': 2921}), ], } 这种简单的反射方式有时能提高性能,因为它给予LLM多次优化输出的机会,同时让反射节点在批评输出时采取不同的角色。

这种架构有几种可能的变体。例如,我们可以将反射步骤与第六章中的代理架构结合,将其作为最后一个节点,直接在将输出发送给用户之前进行。这会让批评看起来来自用户,并给应用程序一个机会,在没有直接用户干预的情况下改进其最终输出。显然,这种方法会牺牲一些延迟。

在某些用例中,将批评与外部信息结合可能会有帮助。例如,如果你正在编写一个代码生成代理,你可以在反射之前添加一个步骤,将代码通过静态分析工具或编译器并报告任何错误,作为输入传递给反射。

TIP 每当这种方法可能适用时,我们强烈建议尝试一下,因为它很可能会提高最终输出的质量。

LangGraph中的子图

在我们深入讨论多代理架构之前,让我们先了解一个在LangGraph中启用多代理架构的重要技术概念:子图。子图是作为另一个图的一部分使用的图。以下是子图的一些使用场景:

  1. 构建多代理系统(在下一节讨论)。
  2. 当你希望在多个图中重用一组节点时,可以在子图中定义这些节点一次,然后在多个父图中使用它们。
  3. 当你希望不同团队独立工作于图的不同部分时,可以将每个部分定义为子图,只要子图接口(输入和输出模式)得到遵守,父图就可以在不了解子图任何细节的情况下构建。

向父图中添加子图节点有两种方式:

  1. 直接调用子图的节点
    当父图和子图共享状态键时,这种方式非常有用,且不需要在传入或传出时转换状态。
  2. 使用调用子图的函数节点
    当父图和子图具有不同的状态模式时,这种方式很有用,需要在调用子图之前或之后转换状态。

我们逐一来看。

直接调用子图

创建子图节点最简单的方式是将子图直接作为一个节点附加。当这样做时,父图和子图必须共享状态键,因为这些共享的键将被用来进行通信。(如果你的图和子图没有共享任何键,请参见下一节。)

注意
如果你向子图节点传递额外的键(即,除了共享键之外的键),子图节点将忽略它们。类似地,如果你从子图返回额外的键,父图也会忽略它们。

让我们来看一下实际的代码示例:

Python

from langgraph.graph import START, StateGraph
from typing import TypedDict

class State(TypedDict):
    foo: str  # 这个键与子图共享

class SubgraphState(TypedDict):
    foo: str  # 这个键与父图共享
    bar: str

# 定义子图
def subgraph_node(state: SubgraphState):
    # 注意这个子图节点可以通过共享的 "foo" 键与父图通信
    return {"foo": state["foo"] + "bar"}

subgraph_builder = StateGraph(SubgraphState)
subgraph_builder.add_node(subgraph_node)
...
subgraph = subgraph_builder.compile()

# 定义父图
builder = StateGraph(State)
builder.add_node("subgraph", subgraph)
...
graph = builder.compile()

JavaScript

import { StateGraph, Annotation, START } from "@langchain/langgraph";

const StateAnnotation = Annotation.Root({
  foo: Annotation(),
});

const SubgraphStateAnnotation = Annotation.Root({
  // 注意这个键与父图状态共享
  foo: Annotation(), 
  bar: Annotation(),
});

// 定义子图
const subgraphNode = async (state) => {
  // 注意这个子图节点可以通过共享的 "foo" 键与父图通信
  return { foo: state.foo + "bar" };
};

const subgraph = new StateGraph(SubgraphStateAnnotation)
  .addNode("subgraph", subgraphNode);
  ...
  .compile();

// 定义父图
const parentGraph = new StateGraph(StateAnnotation)
  .addNode("subgraph", subgraph)
  .addEdge(START, "subgraph")
  // 其他父图设置
  .compile();

使用函数调用子图

你可能希望定义一个具有完全不同模式的子图。在这种情况下,你可以创建一个节点,该节点通过一个函数来调用子图。这个函数需要在调用子图之前将父状态转换为子图状态,并在返回节点的状态更新之前将结果转换回父状态。

让我们来看一下这个例子:

Python

class State(TypedDict):
    foo: str

class SubgraphState(TypedDict):
    # 这些键与父图状态没有共享
    bar: str
    baz: str

# 定义子图
def subgraph_node(state: SubgraphState):
    return {"bar": state["bar"] + "baz"}

subgraph_builder = StateGraph(SubgraphState)
subgraph_builder.add_node(subgraph_node)
...
subgraph = subgraph_builder.compile()

# 定义父图
def node(state: State):
    # 将状态转换为子图状态
    response = subgraph.invoke({"bar": state["foo"]})
    # 将响应转换回父图状态
    return {"foo": response["bar"]}

builder = StateGraph(State)
# 注意我们使用的是 `node` 函数而不是编译后的子图
builder.add_node(node)
...
graph = builder.compile()

JavaScript

import { StateGraph, START, Annotation } from "@langchain/langgraph";

const StateAnnotation = Annotation.Root({
  foo: Annotation(),
});

const SubgraphStateAnnotation = Annotation.Root({
  // 注意这些键与父图状态没有共享
  bar: Annotation(),
  baz: Annotation(),
});

// 定义子图
const subgraphNode = async (state) => {
  return { bar: state.bar + "baz" };
};

const subgraph = new StateGraph(SubgraphStateAnnotation)
  .addNode("subgraph", subgraphNode);
  ...
  .compile();

// 定义父图
const subgraphWrapperNode = async (state) => {
  // 将状态转换为子图状态
  const response = await subgraph.invoke({
    bar: state.foo,
  });
  // 将响应转换回父图状态
  return {
    foo: response.bar,
  };
}

const parentGraph = new StateGraph(StateAnnotation)
  .addNode("subgraph", subgraphWrapperNode)
  .addEdge(START, "subgraph")
  // 其他父图设置
  .compile();

现在我们了解了如何使用子图,让我们来看一下它们的一个重要应用场景:多代理架构。

多代理架构

随着LLM代理在规模、范围或复杂性上的增长,一些问题可能会出现并影响它们的性能,例如:

  1. 代理被提供了太多的工具,难以做出关于选择哪个工具来调用的决策(第六章讨论了该问题的一些解决方法)。
  2. 上下文变得过于复杂,单个代理无法跟踪;即,提示的大小和提到的事物数量超出了你所使用的模型的能力。
  3. 你希望为某个特定领域使用一个专门的子系统,例如规划、研究、解决数学问题等。

为了解决这些问题,你可以考虑将应用拆分成多个更小的独立代理,并将它们组合成一个多代理系统。这些独立代理可以简单到仅仅是一个提示和一次LLM调用,也可以复杂到像ReAct代理(在第六章中介绍)那样。图7-3展示了在多代理系统中连接代理的几种方式。

image.png

让我们更详细地看看图7-3:

网络架构
每个代理都可以与其他所有代理进行通信。任何代理都可以决定下一个执行哪个代理。

主管架构
每个代理与一个单独的代理进行通信,称为主管代理。主管代理决定下一个应该调用哪个代理(或哪些代理)。这种架构的一个特殊案例是将主管代理实现为一个带工具的LLM调用,正如第六章中所介绍的那样。

层级架构
你可以定义一个具有主管代理的多代理系统,这是一种对主管架构的概括,允许更复杂的控制流。

自定义多代理工作流
每个代理仅与一部分代理进行通信。部分流程是确定性的,只有特定的代理才能决定下一个调用哪些其他代理。

下一节将深入探讨主管架构,我们认为这种架构在能力和易用性之间达到了很好的平衡。

主管架构

在这种架构中,我们将每个代理添加到图中作为一个节点,并且还会添加一个主管节点,主管节点决定应该调用哪个代理。我们使用条件边根据主管的决策将执行路径引导到适当的代理节点。有关LangGraph的介绍,请回顾第5章,其中详细介绍了节点、边等概念。

首先让我们看看主管节点的样子:

Python

from typing import Literal
from langchain_openai import ChatOpenAI
from pydantic import BaseModel

class SupervisorDecision(BaseModel):
    next: Literal["researcher", "coder", "FINISH"]

model = ChatOpenAI(model="gpt-4o", temperature=0)
model = model.with_structured_output(SupervisorDecision)

agents = ["researcher", "coder"]

system_prompt_part_1 = f"""You are a supervisor tasked with managing a 
conversation between the following workers: {agents}. Given the following user 
request, respond with the worker to act next. Each worker will perform a
task and respond with their results and status. When finished,
respond with FINISH."""

system_prompt_part_2 = f"""Given the conversation above, who should act next? Or 
    should we FINISH? Select one of: {', '.join(agents)}, FINISH"""

def supervisor(state):
    messages = [
        ("system", system_prompt_part_1),
        *state["messages"],
        ("system", system_prompt_part_2)
    ]
    return model.invoke(messages)

JavaScript

import { ChatOpenAI } from 'langchain-openai';
import { z } from 'zod';

const SupervisorDecision = z.object({
  next: z.enum(['researcher', 'coder', 'FINISH']),
});

const model = new ChatOpenAI({ model: 'gpt-4o', temperature: 0 });
const modelWithStructuredOutput = model.withStructuredOutput(SupervisorDecision);

const agents = ['researcher', 'coder'];

const systemPromptPart1 = `You are a supervisor tasked with managing a 
  conversation between the following workers: ${agents.join(', ')}. Given the 
  following user request, respond with the worker to act next. Each worker 
  will perform a task and respond with their results and status. When 
  finished, respond with FINISH.`;

const systemPromptPart2 = `Given the conversation above, who should act next? Or 
  should we FINISH? Select one of: ${agents.join(', ')}, FINISH`;

const supervisor = async (state) => {
  const messages = [
    { role: 'system', content: systemPromptPart1 },
    ...state.messages,
    { role: 'system', content: systemPromptPart2 }
  ];

  return await modelWithStructuredOutput.invoke({ messages });
};

注意
提示中的代码要求你的子代理名称必须是自解释且唯一的。例如,如果它们仅被命名为agent_1和agent_2,LLM将没有信息来决定哪个代理适合每个任务。如果需要,你可以修改提示来为每个代理添加描述,这可以帮助LLM为每个查询选择合适的代理。

现在,让我们看看如何将这个主管节点集成到一个更大的图中,该图包含两个其他子代理,我们称之为研究员(researcher)和编码员(coder)。我们使用这个图的整体目标是处理可以由研究员或编码员单独回答的查询,或者由它们两者连续回答的查询。这个例子并没有包括研究员或编码员的实现——关键思想是它们可以是任何其他LangGraph图或节点:

Python

from typing import Literal
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, MessagesState, START

model = ChatOpenAI()

class AgentState(BaseModel):
    next: Literal["researcher", "coder", "FINISH"]

def researcher(state: AgentState):
    response = model.invoke(...)
    return {"messages": [response]}

def coder(state: AgentState):
    response = model.invoke(...)
    return {"messages": [response]}

builder = StateGraph(AgentState)
builder.add_node(supervisor)
builder.add_node(researcher)
builder.add_node(coder)

builder.add_edge(START, "supervisor")
# 根据主管的决策路由到一个代理或退出
builder.add_conditional_edges("supervisor", lambda state: state["next"])
builder.add_edge("researcher", "supervisor")
builder.add_edge("coder", "supervisor")

supervisor = builder.compile()

JavaScript

import {
  StateGraph,
  Annotation,
  MessagesAnnotation,
  START,
  END,
} from "@langchain/langgraph";
import { ChatOpenAI } from "@langchain/openai";

const model = new ChatOpenAI({
  model: "gpt-4o",
});

const StateAnnotation = Annotation.Root({
  ...MessagesAnnotation.spec,
  next: Annotation(),
});

const researcher = async (state) => {
  const response = await model.invoke(...);
  return { messages: [response] };
};

const coder = async (state) => {
  const response = await model.invoke(...);
  return { messages: [response] };
};

const graph = new StateGraph(StateAnnotation)
  .addNode("supervisor", supervisor)
  .addNode("researcher", researcher)
  .addNode("coder", coder)
  .addEdge(START, "supervisor")
  // 根据主管的决策路由到一个代理或退出
  .addConditionalEdges("supervisor", async (state) => 
    state.next === 'FINISH' ? END : state.next)
  .addEdge("researcher", "supervisor")
  .addEdge("coder", "supervisor")
  .compile();

注意事项
在这个例子中,两个子代理(研究员和编码员)可以看到彼此的工作,因为所有进展都记录在消息列表中。这并不是组织这种流程的唯一方式。每个子代理可以更复杂。例如,子代理可以是它自己的图,维护内部状态并仅输出其完成工作的摘要。

每个代理执行后,我们会将路由返回到主管节点,主管决定是否还有更多工作需要完成,并且如果需要,决定将任务委托给哪个代理。这种路由并不是这个架构的硬性要求;我们可以让每个子代理决定是否将其输出直接返回给用户。为了实现这一点,我们可以用条件边代替研究员和主管之间的硬性边(该边会读取由研究员更新的某些状态键)。

总结

本章介绍了对代理架构的两个重要扩展:反射和多代理架构。还探讨了如何在LangGraph中使用子图,这是构建多代理系统的关键构件。

这些扩展增强了LLM代理架构的功能,但它们不应是创建新代理时首先选择的工具。通常,最好的起点是第六章中讨论的简单架构。

第八章将回到可靠性与代理能力之间的权衡,这是今天构建LLM应用时的关键设计决策。尤其在使用代理或多代理架构时,这一点尤为重要,因为它们的强大功能如果不加以控制,会以牺牲可靠性为代价。第八章将深入探讨为何存在这种权衡,并介绍你可以使用的最重要的技术,以帮助你做出决策,最终提升你的LLM应用和代理。