LangChain——充分利用LLM的模式

236 阅读21分钟

如今,LLM存在一些主要的局限性,但这并不意味着你梦想中的LLM应用无法构建。你为用户设计的应用体验需要绕过这些局限性,理想情况下还要与这些局限性协同工作。

第五章提到了构建LLM应用时我们面临的关键权衡:自主性(LLM的自主行动能力)与可靠性(我们能够信任其输出的程度)之间的权衡。从直觉上看,任何LLM应用如果能在没有我们介入的情况下执行更多的操作,将对我们更有用,但如果我们让自主性过于加强,应用将不可避免地做出我们不希望它做的事情。

图8-1展示了这种权衡。

image.png

借用其他领域的概念,我们可以将这种权衡可视化为一条前沿——前沿曲线上的所有点都是某个应用的最优LLM架构,标志着在自主性和可靠性之间的不同选择。(有关不同LLM应用架构的概述,请参阅第五章。)举个例子,注意到链式架构具有相对较低的自主性,但可靠性较高,而Agent架构则牺牲了可靠性,获得了更高的自主性。

让我们简要地讨论一下你可能希望你的LLM应用具备的其他(但仍然重要的)目标。每个LLM应用将根据这些目标中的一个或多个的不同组合进行设计:

  • 延迟
    最小化获取最终答案的时间
  • 自主性
    最小化需要人工输入的干预
  • 方差
    最小化调用之间的变化

这并不是所有可能目标的详尽清单,而是为了说明你在构建应用时面临的权衡。每个目标在某种程度上与其他目标相矛盾(例如,实现更高可靠性的最简单方法要么需要更高的延迟,要么需要更低的自主性)。如果完全追求某一个目标,其他目标将会被抵消(例如,最小延迟的应用就是那个完全不做任何事情的应用)。图8-2展示了这一概念。

image.png

作为应用开发者,我们真正希望的是将前沿向外扩展。在相同的可靠性水平下,我们希望能够实现更高的自主性;在相同的自主性水平下,我们希望能够实现更高的可靠性。本章介绍了一些可以帮助实现这一目标的技术:

流式/中间输出

如果在处理过程中能有一些进度或中间输出的反馈,那么更高的延迟就更容易接受。

结构化输出

要求LLM以预定义的格式生成输出,可以使它更有可能符合预期。

人机交互

高自主性的架构在运行过程中受益于人工干预:中断、批准、分支或撤销。

双重输入模式

LLM应用在回答问题时所需时间越长,用户可能会在前一个输入尚未完成处理时就发送新的输入。

结构化输出

LLM返回结构化输出通常非常关键,原因可能是下游应用需要以特定的模式使用该输出(例如,定义字段名称和类型的结构化输出)或纯粹为了减少变动,避免完全自由格式的文本输出。

针对这一需求,你可以使用几种不同的策略来处理不同的LLM:

提示

这种方法是要求LLM(非常友好地)返回所需格式的输出(例如,JSON、XML或CSV)。提示的最大优点是它在一定程度上适用于任何LLM;缺点是它更像是对LLM的建议,而不是保证输出会按这种格式生成。

工具调用

这种方法适用于已经经过微调的LLM,可以从一系列可能的输出模式中进行选择,并生成符合所选模式的内容。通常这需要为每个可能的输出模式编写:标识符、帮助LLM决定何时适用的描述,以及所需输出格式的模式(通常采用JSONSchema格式)。

JSON模式

这是一些LLM(如最近的OpenAI模型)提供的模式,强制LLM输出有效的JSON文档。

不同模型可能支持这些方法的不同变体,且参数略有不同。为了方便LLM返回结构化输出,LangChain模型实现了一个通用接口——名为.with_structured_output的方法。通过调用这个方法,并传入JSON模式或Pydantic(Python)或Zod(JS)模型,模型将添加必要的模型参数和输出解析器来生成并返回结构化输出。如果某个特定模型实现了多种前述策略,你可以配置使用哪种方法。

创建一个模式以供使用:

Python
from pydantic import BaseModel, Field

class Joke(BaseModel):
    setup: str = Field(description="The setup of the joke")
    punchline: str = Field(description="The punchline to the joke")
JavaScript
import { z } from "zod";

const joke = z.object({
  setup: z.string().describe("The setup of the joke"),
  punchline: z.string().describe("The punchline to the joke"),
});

注意,我们为每个字段添加了描述。这是关键,因为—与字段名称一起—这是LLM用来决定哪些部分应该进入每个字段的信息。我们也可以使用原始的JSONSchema表示法来定义模式,如下所示:

{
  "properties": {
    "setup": {
      "description": "The setup of the joke",
      "title": "Setup",
      "type": "string"
    },
    "punchline": {
      "description": "The punchline to the joke",
      "title": "Punchline",
      "type": "string"
    }
  },
  "required": ["setup", "punchline"],
  "title": "Joke",
  "type": "object"
}

让LLM生成符合此模式的输出:

Python
from langchain_openai import ChatOpenAI

model = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)
model = model.with_structured_output(Joke)

model.invoke("Tell me a joke about cats")
JavaScript
import { ChatOpenAI } from "@langchain/openai";

let model = new ChatOpenAI({
  model: "gpt-3.5-turbo",
  temperature: 0
});
model = model.withStructuredOutput(joke);

await structuredLlm.invoke("Tell me a joke about cats");

输出示例

{
    "setup": "Why don't cats play poker in the wild?",
    "punchline": "Too many cheetahs."
}

有几点需要注意:

  • 我们像往常一样创建模型实例,指定要使用的模型名称和其他参数。
  • 低温度通常适用于结构化输出,因为它减少了LLM生成不符合模式的无效输出的可能性。
  • 然后,我们将模式附加到模型上,模型返回一个新对象,该对象将生成符合提供模式的输出。当你传入Pydantic或Zod对象作为模式时,这也将用于验证;即,如果LLM生成不符合模式的输出,将返回验证错误,而不是失败的输出。
  • 最后,我们使用(自由格式)输入调用模型,并接收符合我们期望结构的输出。

使用结构化输出的这种模式非常有用,无论是作为独立工具,还是作为更大应用的一部分;例如,回顾第五章,我们利用这一能力实现了路由架构的路由步骤。

中间输出

你的LLM架构越复杂,执行时间就越有可能增加。如果回顾第5章和第6章中的架构图,每次你看到多个步骤(或节点)按顺序或循环连接时,这表明完整调用的时间正在增加。

如果不解决这种延迟增加,它可能会成为用户接受LLM应用的障碍,因为大多数用户期望计算机应用能够在几秒钟内产生一些输出。有几种策略可以使较高的延迟更容易接受,但它们都属于流式输出的范畴,即在应用仍在运行时接收输出。

本节将使用“处理多个工具”中描述的最后一个架构。完整的代码片段请参考第6章。

要使用LangGraph生成中间输出,您只需使用stream方法调用图,这样每个节点完成时就会输出其结果。让我们看看它是什么样的:

Python
input = {
    "messages": [
        HumanMessage("""How old was the 30th president of the United States 
            when he died?""")
    ]
}
for c in graph.stream(input, stream_mode='updates'):
    print(c)
JavaScript
const input = {
  messages: [
    new HumanMessage(`How old was the 30th president of the United States when 
      he died?`)
  ]
}
const output = await graph.stream(input, streamMode: 'updates')
for await (const c of output) {
  console.log(c)
}
输出示例:
{
    "select_tools": {
        "selected_tools": ['duckduckgo_search', 'calculator']
    }
}
{
    "model": {
        "messages": AIMessage(
            content="",
            tool_calls=[
                {
                    "name": "duckduckgo_search",
                    "args": {
                        "query": "30th president of the United States"
                    },
                    "id": "9ed4328dcdea4904b1b54487e343a373",
                    "type": "tool_call",
                }
            ],
        )
    }
}
{
    "tools": {
        "messages": [
            ToolMessage(
                content="Calvin Coolidge (born July 4, 1872, Plymouth, Vermont, 
                    U.S.—died January 5, 1933, Northampton, Massachusetts) was 
                    the 30th president of the United States (1923-29). Coolidge 
                    acceded to the presidency after the death in office of 
                    Warren G. Harding, just as the Harding scandals were coming 
                    to light....",
                name="duckduckgo_search",
                tool_call_id="9ed4328dcdea4904b1b54487e343a373",
            )
        ]
    }
}
{
    "model": {
        "messages": AIMessage(
            content="Calvin Coolidge, the 30th president of the United States, 
                was born on July 4, 1872, and died on January 5, 1933. To 
                calculate his age at the time of his death, we can subtract his 
                birth year from his death year. \n\nAge at death = Death year - 
                Birth year\nAge at death = 1933 - 1872\nAge at death = 61 
                years\n\nCalvin Coolidge was 61 years old when he died.",
        )
    }
}

注意,每个输出条目是一个字典,其中节点的名称作为键,该节点的输出作为值。这提供了两个关键信息:

  • 应用当前所在的位置;也就是说,如果回想一下前面章节中展示的架构图,我们当前在那个图中处于哪个位置?
  • 应用的共享状态的每次更新,这些更新共同构建了图的最终输出。

此外,LangGraph支持更多的流模式:

  • updates:这是默认模式,如上所述。

  • values:此模式每次图的状态发生变化时都返回当前状态,即每一组节点执行完毕后返回状态。这在你需要根据图的状态来展示输出给用户时非常有用。

  • debug:此模式会在图中发生任何事件时返回详细的事件,包括:

    • checkpoint事件:每次保存当前状态的新检查点时触发
    • task事件:每次节点即将开始运行时触发
    • task_result事件:每次节点运行结束时触发

最后,你可以结合这些模式,例如通过传递一个列表来请求同时使用updatesvalues模式。

你可以通过stream_mode参数控制流模式。

流式LLM输出逐词输出

有时候,你可能还想从每个LLM调用中获取流式输出,尤其是在更大的LLM应用程序中。这在构建交互式聊天机器人时非常有用,因为你希望每个单词一旦由LLM生成,就立即显示出来。

你也可以通过LangGraph实现这一点:

Python
input = {
    "messages": [
        HumanMessage("""How old was the 30th president of the United States 
            when he died?""")
    ]
}
output = app.astream_events(input, version="v2")

async for event in output:
    if event["event"] == "on_chat_model_stream":
        content = event["data"]["chunk"].content
        if content:
            print(content)
JavaScript
const input = {
  messages: [
    new HumanMessage(`How old was the 30th president of the United States when 
      he died?`)
  ]
}

const output = await agent.streamEvents(input, {version: "v2"});

for await (const { event, data } of output) {
  if (event === "on_chat_model_stream") {
    const msg = data.chunk as AIMessageChunk;
    if (msg.content) {
      console.log(msg.content);
    }
  }
}

这将随着LLM接收的每个词(技术上是每个token)立即输出。你可以从LangChain中找到更多关于这种模式的详细信息。

人机交互模式

当我们走上自主性(或代理性)阶梯时,我们会发现自己逐渐放弃控制(或监督),以换取能力(或自主性)。LangGraph中使用的共享状态模式(参见第5章的介绍)使得观察、打断和修改应用程序变得更加容易。这使得使用许多人机交互模式成为可能,即开发者或最终用户可以影响LLM的行为。

本节我们将再次使用“处理多个工具”中描述的最后一个架构。完整的代码片段请参阅第6章。对于所有人机交互模式,首先需要将检查点添加到图中;有关更多细节,请参见“为StateGraph添加内存”:

Python
from langgraph.checkpoint.memory import MemorySaver

graph = builder.compile(checkpointer=MemorySaver())
JavaScript
import {MemorySaver} from '@langchain/langgraph'

graph = builder.compile({ checkpointer: new MemorySaver() })

这将返回一个图实例,在每一步结束时存储状态,因此每次调用都不会从空白开始。每次调用图时,它首先使用检查点获取最近保存的状态(如果有的话),然后将新输入与以前的状态结合。只有在此之后,图才会执行第一个节点。这是启用人机交互模式的关键,因为这些模式都依赖于图记住先前的状态。

第一个模式是中断,它是最简单的控制形式——用户正在查看应用程序生成的流式输出,并在适当时手动中断它(参见图8-3)。状态会在用户点击中断按钮之前的最后一个完整步骤时保存。从此,用户可以选择:

  • 从此点继续,计算将继续进行,就好像没有被中断一样(参见“恢复”)。
  • 向应用程序发送新输入(例如,聊天机器人的新消息),这将取消任何待处理的未来步骤并开始处理新输入(参见“重新开始”)。
  • 什么也不做,则不会再运行任何操作。

image.png

让我们看看如何在LangGraph中实现这一点:

Python
import asyncio

event = asyncio.Event()

input = {
    "messages": [
        HumanMessage("""How old was the 30th president of the United States 
            when he died?""")
    ]
}

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

async with aclosing(graph.astream(input, config)) as stream:
    async for chunk in stream:
        if event.is_set():
            break
        else:
            ... # 对输出做一些操作

# 在应用程序的其他地方

event.set()
JavaScript
const controller = new AbortController()

const input = {
  "messages": [
    new HumanMessage(`How old was the 30th president of the United States when 
      he died?`)
  ]
}

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

try {
  const output = await graph.stream(input, {
    ...config,
    signal: controller.signal
  });
  for await (const chunk of output) {
    console.log(chunk); // 对输出做一些操作
  }
} catch (e) {
  console.log(e);
}

// 在应用程序的其他地方
controller.abort()

这利用了事件或信号,允许你从外部控制中断。注意Python代码块中使用了aclosing,这确保了在中断时流能够正确关闭。注意JavaScript中使用了try-catch语句,因为中断运行时会触发一个中止异常。最后,注意使用检查点时需要传入一个线程标识符,以区分此次与图的交互与其他交互。

image.png

第二种控制模式是授权,在这种模式下,用户提前定义他们希望在每次特定节点即将被调用时,应用程序将控制权交还给他们(参见图8-4)。这通常用于工具确认——在调用任何工具(或特定工具)之前,应用程序将暂停并请求确认,在此时用户可以再次选择:

  • 恢复计算,接受工具调用。
  • 发送新消息,引导机器人朝不同方向发展,这样工具将不会被调用。
  • 什么也不做

下面是代码示例:

Python
python
复制
input = {
    "messages": [
        HumanMessage("""How old was the 30th president of the United States 
            when he died?""")
    ]
}

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

output = graph.astream(input, config, interrupt_before=['tools'])

async for c in output:
    ... # 对输出做一些操作
JavaScript
javascript
复制
const input = {
  "messages": [
    new HumanMessage(`How old was the 30th president of the United States when 
      he died?`)
  ]
}

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

const output = await graph.stream(input, {
  ...config,
  interruptBefore: ['tools']
});
for await (const chunk of output) {
  console.log(chunk); // 对输出做一些操作
}

这将执行图的计算直到即将进入名为tools的节点,从而给你机会检查当前状态,并决定是否继续。注意,interrupt_before是一个列表,顺序不重要;如果你传入多个节点名称,它将在进入每个节点之前进行中断。

恢复

要从中断的图中继续计算——例如使用前述的两种模式之一——你只需使用null输入(或者在Python中为None)重新调用图。这会被视为继续处理先前非空输入的信号:

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

output = graph.astream(None, config, interrupt_before=['tools'])

async for c in output:
    ... # 对输出做一些操作
JavaScript
const config = {"configurable": {"thread_id": "1"}}

const output = await graph.stream(null, {
  ...config,
  interruptBefore: ['tools']
});
for await (const chunk of output) {
  console.log(chunk); // 对输出做一些操作
}

重新开始

如果你希望中断后的图从第一个节点重新开始,并且有新的输入,你只需使用新的输入调用它:

Python
input = {
    "messages": [
        HumanMessage("""How old was the 30th president of the United States 
            when he died?""")
    ]
}

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

output = graph.astream(input, config)

async for c in output:
    ... # 对输出做一些操作
JavaScript
const input = {
  "messages": [
    new HumanMessage(`How old was the 30th president of the United States when 
      he died?`)
  ]
}

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

const output = await graph.stream(input, config);

for await (const chunk of output) {
  console.log(chunk); // 对输出做一些操作
}

这将保留图的当前状态,将其与新输入合并,并从第一个节点重新开始。如果你想丢弃当前状态,只需更改thread_id,这将从空白状态开始新的交互。任何字符串值都可以作为有效的thread_id;我们建议使用UUID(或其他唯一标识符)作为线程ID。

编辑状态

有时,你可能希望在恢复之前更新图的状态,这可以通过update_state方法来实现。通常,你会先使用get_state方法检查当前状态。

下面是如何操作的:

Python
python
复制
config = {"configurable": {"thread_id": "1"}}

state = graph.get_state(config)

# 你想要添加或替换的内容
update = { }

graph.update_state(config, update)
JavaScript
const config = {"configurable": {"thread_id": "1"}}

const state = await graph.getState(config)

// 你想要添加或替换的内容
const update = { }

await graph.updateState(config, update)

这将创建一个新的检查点,包含你的更新。之后,你就可以从这个新的点继续恢复图。有关如何恢复图的信息,请参见“恢复”部分。

分支

你还可以浏览图经历的所有过去状态的历史记录,并且可以再次访问其中的任何一个状态,例如,获取一个替代答案。在更具创意的应用中,这非常有用,因为每次通过图的运行都可能产生不同的输出。

让我们看看这是什么样的:

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

history = [
    state for state in
    graph.get_state_history(config)
]

# 重播一个过去的状态
graph.invoke(None, history[2].config)
JavaScript
const config = {"configurable": {"thread_id": "1"}}

const history = await Array.fromAsync(graph.getStateHistory(config))

// 重播一个过去的状态
await graph.invoke(null, history[2].config)

注意,在两种语言中我们都将历史记录收集到一个列表/数组中;get_state_history返回的是状态的迭代器(允许惰性消费)。从历史方法返回的状态是按时间排序的,最近的状态排在前面,最旧的状态排在最后。

人机交互控制的真正力量来自于根据你的应用需求混合这些控制模式。

多任务处理LLM

本节讨论了处理LLM应用程序的并发输入问题。考虑到LLM的处理速度较慢,尤其是在生成长输出或在多步骤架构中串联使用时(例如,使用LangGraph时),这个问题尤其相关。即使LLM变得更快,处理并发输入仍将是一个挑战,因为延迟的改进也会为越来越复杂的用例打开大门,就像即使是最富有生产力的人也仍然面临优先处理时间竞争的需求一样。

让我们来看看可选的处理方法。

拒绝并发输入

在处理前一个输入时接收到的任何输入都会被拒绝。这是最简单的策略,但不太可能满足所有需求,因为它实际上意味着将并发管理交给调用方。

独立处理

另一种简单的选择是将任何新输入视为独立的调用,创建一个新的线程(一个用于记住状态的容器)并在该上下文中生成输出。这有一个明显的缺点,就是需要将其显示为两个独立且无法调和的调用,这在某些情况下可能不可行或不理想。另一方面,它的优点是可以扩展到任意大的规模,几乎肯定会在你的应用程序中使用某种程度的这种方法。例如,这就是你如何考虑让聊天机器人与两个不同用户同时“聊天”问题的方式。

排队并发输入

在处理当前输入时接收到的任何输入都会被排队,等当前输入处理完后再处理。这个策略有一些优点:

  • 支持接收任意数量的并发请求。
  • 因为我们等待当前输入处理完成,所以无论新输入是在我们开始处理当前输入后几乎立即到达,还是在我们完成之前立即到达,最终结果都将相同,因为我们会在处理下一个输入之前完成当前输入的处理。

但该策略也有一些缺点:

  • 处理所有排队输入可能需要一段时间;如果输入的生成速度快于处理速度,队列可能会增长无界。
  • 由于输入是在看到前一个输入的响应之前排队的,并且之后没有进行更改,因此输入可能在处理时已经过时。当新输入依赖于先前的答案时,这种策略就不适用了。

中断

当接收到新输入时,放弃当前输入的处理并用新输入重新启动链。这个策略可以根据保留中断运行的内容而有所不同。以下是一些选项:

  • 不保留任何内容:完全忘记前一个输入,就好像它从未被发送或处理过。
  • 保留最后完成的步骤:在检查点应用程序中(它在计算过程中存储进度),保留最后完成的步骤产生的状态,丢弃当前执行步骤的任何待处理状态更新,并在该上下文中开始处理新输入。
  • 保留最后完成的步骤,以及当前进行中的步骤:尝试中断当前步骤,同时小心保存当时正在生成的任何未完成的状态更新。这可能不会在比最简单架构更复杂的情况下通用。
  • 等待当前节点(但不是任何后续节点)完成,然后保存并中断

与排队并发输入相比,这个选项有一些优点:

  • 新输入会尽快处理,从而减少延迟并降低生成过时输出的机会。
  • 对于“保留无内容”变体,最终输出不依赖于新输入何时接收。

但它也有缺点:

  • 实际上,这个策略仍然仅限于一次处理一个输入;任何旧的输入在接收到新输入时会被丢弃。
  • 为下一次运行保留部分状态更新需要状态设计时考虑这一点;否则,应用程序很可能会进入无效状态。例如,OpenAI聊天模型要求请求工具调用的AI消息必须立即跟随工具输出的工具消息。如果在两者之间发生中断,你要么采取防御性措施清理中间状态,要么面临无法继续处理的风险。
  • 生成的最终输出非常敏感于新输入的接收时间;新输入将根据之前处理的(不完整的)进度进行处理。这可能会导致脆弱或不可预测的结果,除非你进行相应的设计。

分支与合并

另一种选择是并行处理新输入,将线程的状态在接收到新输入时分叉,并在输入处理完成时合并最终状态。这种选项要求设计你的状态,使其能够在没有冲突的情况下合并(例如,使用冲突自由的复制数据类型[CRDTs]或其他冲突解决算法),或者让用户在你能够理解输出或发送新输入之前手动解决冲突。如果满足这两项要求,这通常是整体上最佳的选择。这样,新输入能够及时处理,输出与接收时间无关,并且支持任意数量的并发运行。

其中一些策略已经在LangGraph平台中实现,详细内容将在第9章中讨论。

总结

在本章中,我们回到了构建LLM应用时面临的主要权衡:自主性与可靠性。我们了解到,存在一些策略可以在不牺牲自主性的情况下提高可靠性,反之亦然。

我们首先讨论了结构化输出,它可以提高LLM生成文本的可预测性。接着,我们讨论了如何从应用程序中发出流式/中间输出,这可以使得高延迟(目前是自主性的不可避免副作用)应用更易于使用。

我们还介绍了多种人机交互控制技术——即将一些监督权交还给LLM应用的最终用户的技术——这些技术常常能在使高自主性架构变得可靠方面起到关键作用。最后,我们讨论了如何处理应用程序的并发输入问题,这是LLM应用特别突出的一个问题,因为它们的高延迟。

在下一章中,你将学习如何将你的AI应用部署到生产环境中。