在第3章中,你学习了如何为你的AI聊天机器人应用提供最新且相关的上下文。这使得你的聊天机器人能够基于用户的输入生成准确的回答。但仅此不足以构建一个生产级应用。如何才能让你的应用真正地与用户进行“来回对话”,同时记住之前的对话和相关上下文呢?
大型语言模型是无状态的,这意味着每次模型被提示生成新回复时,它并不记得之前的提示或模型回应。为了向模型提供这些历史信息,我们需要一个强大的记忆系统,能够跟踪先前的对话和上下文。然后,这些历史信息可以包含在最终发送给LLM的提示中,从而赋予它“记忆”。图4-1展示了这一点。
在本章中,你将学习如何使用LangChain的内置模块构建这个基本的记忆系统,从而简化开发过程。
构建聊天机器人记忆系统
任何强大记忆系统的设计背后有两个核心决策:
- 如何存储状态
- 如何查询状态
构建一个聊天机器人记忆系统的简单方法是存储并重用用户和模型之间所有聊天互动的历史。这个记忆系统的状态可以是:
- 作为一系列消息存储(有关消息的更多内容,请参见第1章)
- 通过在每次对话后附加最近的消息进行更新
- 通过将消息插入到提示中来附加到提示中
图4-2展示了这个简单的记忆系统。
下面是一个代码示例,展示了如何使用LangChain构建这个简单的记忆系统:
Python代码:
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
prompt = ChatPromptTemplate.from_messages([
("system", """You are a helpful assistant. Answer all questions to the best
of your ability."""),
("placeholder", "{messages}"),
])
model = ChatOpenAI()
chain = prompt | model
chain.invoke({
"messages": [
("human","""Translate this sentence from English to French: I love
programming."""),
("ai", "J'adore programmer."),
("human", "What did you just say?"),
],
})
JavaScript代码:
import {ChatPromptTemplate} from '@langchain/core/prompts'
import {ChatOpenAI} from '@langchain/openai'
const prompt = ChatPromptTemplate.fromMessages([
["system", `You are a helpful assistant. Answer all questions to the best
of your ability.`],
["placeholder", "{messages}"],
])
const model = new ChatOpenAI()
const chain = prompt.pipe(model)
await chain.invoke({
"messages": [
["human",`Translate this sentence from English to French: I love
programming.`],
["ai", "J'adore programmer."],
["human", "What did you just say?"],
],
})
输出:
I said, "J'adore programmer," which means "I love programming" in French.
可以注意到,将先前的对话纳入链中使得模型能够在上下文感知的方式下回答后续问题。
虽然这个方法简单且有效,但当你将应用推向生产环境时,你将面临一些关于在大规模管理记忆的挑战,例如:
- 你需要在每次互动后原子性地更新记忆(即,在发生错误时,不仅仅记录问题或仅记录回答)。
- 你需要将这些记忆存储在持久化存储中,例如关系型数据库。
- 你需要控制存储多少以及哪些消息用于以后使用,并且决定哪些消息用于新的互动。
- 你需要在不调用LLM的情况下检查和修改这个状态(目前仅是消息列表)。
接下来,我们将介绍一些更好的工具,帮助解决这些问题,并且帮助你完成后续章节的内容。
介绍LangGraph
在本章剩余部分和接下来的章节中,我们将开始使用LangGraph,这是一个由LangChain开发的开源库。LangGraph的设计旨在帮助开发者实现多参与者、多步骤、有状态的认知架构,称为图(graphs)。这句话包含了很多信息,让我们逐一解读。图4-3展示了多参与者的方面。
一支由专家组成的团队可以一起构建出单个成员无法单独完成的东西。LLM应用程序也是如此:一个LLM提示(非常适合生成回答、任务规划以及更多其他功能)与搜索引擎(擅长查找当前事实)配合使用时,功能会更强大,甚至与不同的LLM提示配合时也是如此。我们已经看到开发者通过将这些构建模块(以及其他模块)以新颖的方式结合在一起,构建出了一些令人惊叹的应用程序,如Perplexity或Arc Search。
就像一个人独立工作时需要更少的协调,而一个由多个参与者组成的团队则需要更多协调一样,一个有多个参与者的应用程序也需要一个协调层来完成以下任务:
- 定义涉及的参与者(图中的节点)以及它们如何互相交接工作(图中的边)。
- 在适当的时间调度每个参与者的执行——如果需要,可以并行执行,并且保证结果是确定性的。
图4-4展示了多步骤的维度。
当每个参与者将工作交给另一个参与者时(例如,一个LLM提示请求搜索工具返回给定搜索查询的结果),我们需要理解多个参与者之间的来回交互。我们需要知道这个过程发生的顺序,每个参与者被调用的次数,等等。为此,我们可以将参与者之间的互动建模为发生在多个离散时间步骤中的过程。当一个参与者将工作交给另一个参与者时,它会导致下一步计算的调度,依此类推,直到没有更多的参与者将工作交给其他参与者,最终得到结果。
图4-5展示了有状态的方面。
跨步骤的通信需要跟踪某些状态——否则,当你第二次调用LLM参与者时,你会得到与第一次相同的结果。将这些状态从每个参与者中提取出来,并让所有参与者协作更新一个单一的中央状态非常有帮助。有了单一的中央状态,我们可以:
- 在每次计算过程中或计算后拍摄并存储中央状态快照。
- 暂停并恢复执行,这使得从错误中恢复变得容易。
- 实现人工干预控制(在第8章中会详细讨论)。
每个图由以下部分组成:
- 状态
从应用程序外部接收到的数据,在应用程序运行时进行修改和生成。 - 节点
每个要执行的步骤。节点只是Python/JS函数,它们接收当前状态作为输入,并可以返回对该状态的更新(即它们可以添加、修改或删除现有数据)。 - 边
节点之间的连接。边决定了从第一个节点到最后一个节点的路径,可以是固定的(即在B节点之后,始终访问D节点),也可以是条件性的(通过评估一个函数来决定在C节点之后访问哪个节点)。
LangGraph提供了可视化这些图的工具,并提供了许多功能来调试它们在开发过程中的工作。然后,这些图可以轻松地部署,以处理生产工作负载并支持高规模。
如果你已经按照第1章中的指示操作,你应该已经安装了LangGraph。如果没有,可以通过在终端中运行以下命令之一进行安装:
Python:
pip install langgraph
JavaScript:
npm i @langchain/langgraph
为了帮助你熟悉使用LangGraph,我们将创建一个简单的聊天机器人,这是一个使用LLM调用架构的典型例子,只调用一次LLM。这个聊天机器人将直接响应用户的消息。尽管很简单,但它展示了使用LangGraph构建的核心概念。
创建一个StateGraph
首先,我们创建一个StateGraph,并添加一个节点来表示LLM调用:
Python代码:
from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
class State(TypedDict):
# 消息的类型是“list”。注解中的 `add_messages`
# 函数定义了如何更新这个状态(在这种情况下,它将新消息附加到
# 列表中,而不是替换之前的消息)
messages: Annotated[list, add_messages]
builder = StateGraph(State)
JavaScript代码:
import {
StateGraph,
StateType,
Annotation,
messagesStateReducer,
START, END
} from '@langchain/langgraph'
const State = {
/**
* State定义了三件事:
* 1. 图状态的结构(哪些“通道”可以读/写)
* 2. 状态通道的默认值
* 3. 状态通道的reducers。Reducers是确定如何应用更新到状态的函数。
* 下面,新消息将附加到消息数组中。
*/
messages: Annotation({
reducer: messagesStateReducer,
default: () => []
}),
}
const builder = new StateGraph(State)
注意:
定义图时,首先需要定义图的状态。状态由图状态的形状或架构组成,并且有reducer函数,指定如何将更新应用到状态。在这个例子中,状态是一个字典,只有一个键:messages
。messages
键用add_messages
reducer函数进行注解,告诉LangGraph将新消息附加到现有列表中,而不是覆盖它。没有注解的状态键将被每次更新覆盖,只保留最新的值。你可以编写自己的reducer函数,这些函数简单地接收两个参数——参数1是当前状态,参数2是将写入状态的下一个值——并返回下一个状态,即将当前状态与新值合并的结果。最简单的例子是一个将下一个值附加到列表并返回该列表的函数。
现在我们的图知道了两件事:
- 我们定义的每个节点将接收当前状态作为输入,并返回一个更新该状态的值。
messages
将被附加到当前列表,而不是直接覆盖。这通过Python示例中的add_messages
函数注解或JavaScript示例中的reducer函数传达。
接下来,添加聊天机器人节点。节点代表工作单元,通常它们只是函数:
Python代码:
from langchain_openai import ChatOpenAI
model = ChatOpenAI()
def chatbot(state: State):
answer = model.invoke(state["messages"])
return {"messages": [answer]}
# 第一个参数是唯一的节点名称
# 第二个参数是要执行的函数或可运行对象
builder.add_node("chatbot", chatbot)
JavaScript代码:
import {ChatOpenAI} from '@langchain/openai'
import {
AIMessage,
SystemMessage,
HumanMessage
} from "@langchain/core/messages";
const model = new ChatOpenAI()
async function chatbot(state) {
const answer = await model.invoke(state.messages)
return {"messages": answer}
}
builder = builder.addNode('chatbot', chatbot)
这个节点接收当前状态,执行一次LLM调用,然后返回一个更新的状态,包含LLM生成的新消息。add_messages
reducer将此消息附加到状态中已存在的消息。
最后,我们添加边:
Python代码:
builder.add_edge(START, 'chatbot')
builder.add_edge('chatbot', END)
graph = builder.compile()
JavaScript代码:
builder = builder
.addEdge(START, 'chatbot')
.addEdge('chatbot', END)
let graph = builder.compile()
这做了几件事:
- 它告诉图在每次运行时从哪里开始工作。
- 它指示图在哪里应该退出(这是可选的,因为LangGraph会在没有更多节点需要运行时停止执行)。
- 它将图编译成一个可运行的对象,包含熟悉的
invoke
和stream
方法。
我们还可以绘制图的可视化表示:
Python代码:
graph.get_graph().draw_mermaid_png()
JavaScript代码:
await graph.getGraph().drawMermaidPng()
我们刚刚创建的图看起来像图4-6所示。
你可以使用之前章节中看到的熟悉的 stream()
方法运行它:
Python:
input = {"messages": [HumanMessage('hi!')]}
for chunk in graph.stream(input):
print(chunk)
JavaScript:
const input = {messages: [new HumanMessage('hi!')]}
for await (const chunk of await graph.stream(input)) {
console.log(chunk)
}
输出:
{ "chatbot": { "messages": [AIMessage("How can I help you?")] } }
注意,传递给图的输入与我们之前定义的 State 对象的形状相同;也就是说,我们通过字典中的 messages
键发送了一条消息列表。此外,stream
函数会在每次图的步骤之后流式传输状态的完整值。
向 StateGraph 添加内存
LangGraph 具有内建的持久化功能,无论是最简单的图还是最复杂的图,使用方式都相同。让我们来看一下将它应用于这个第一个架构的样子。我们将重新编译图,附加一个检查点(checkpointer),它是 LangGraph 的一个存储适配器。LangGraph 附带了一个基类,任何用户都可以通过子类化来为他们喜爱的数据库创建适配器;截至目前,LangGraph 提供了由 LangChain 维护的多个适配器:
- 一个内存适配器,我们将在这里的示例中使用
- 一个 SQLite 适配器,使用流行的进程内数据库,适合本地应用和测试
- 一个 Postgres 适配器,优化了流行的关系型数据库,适合大规模应用
许多开发者为其他数据库系统(如 Redis 或 MySQL)编写了适配器:
Python:
from langgraph.checkpoint.memory import MemorySaver
graph = builder.compile(checkpointer=MemorySaver())
JavaScript:
import {MemorySaver} from '@langchain/langgraph'
const graph = builder.compile({ checkpointer: new MemorySaver() })
这返回一个可运行的对象,具有与前面代码块中使用的相同方法。但现在,它会在每个步骤结束时存储状态,因此每次调用之后不会从空白状态开始。每次调用图时,它都会先使用检查点来获取最近保存的状态(如果有的话),然后将新输入与之前的状态合并。只有在此之后,它才会执行第一个节点。
让我们看看实际效果的区别:
Python:
thread1 = {"configurable": {"thread_id": "1"}}
result_1 = graph.invoke(
{ "messages": [HumanMessage("hi, my name is Jack!")] },
thread1
)
// { "chatbot": { "messages": [AIMessage("How can I help you, Jack?")] } }
result_2 = graph.invoke(
{ "messages": [HumanMessage("what is my name?")] },
thread1
)
// { "chatbot": { "messages": [AIMessage("Your name is Jack")] } }
JavaScript:
const thread1 = {configurable: {thread_id: '1'}}
const result_1 = await graph.invoke(
{ "messages": [new HumanMessage("hi, my name is Jack!")] },
thread1
)
// { "chatbot": { "messages": [AIMessage("How can I help you, Jack?")] } }
const result_2 = await graph.invoke(
{ "messages": [new HumanMessage("what is my name?")] },
thread1
)
// { "chatbot": { "messages": [AIMessage("Your name is Jack")] } }
注意对象 thread1
,它标识当前交互属于某个特定的交互历史——这些历史在 LangGraph 中称为线程(threads)。线程在第一次使用时会自动创建。任何字符串都是有效的线程标识符(通常使用全局唯一标识符 [UUIDs])。线程的存在帮助你实现 LLM 应用程序中的一个重要里程碑;它现在可以被多个用户使用,每个用户的对话都是独立的,彼此之间不会混淆。
如前所述,聊天机器人节点首先使用一条消息(我们刚才传递的那条)调用,并返回另一条消息,这两条消息都会被保存在状态中。
第二次我们在同一线程上执行图时,聊天机器人节点会使用三条消息来调用——第一执行时保存的两条消息和用户的下一个问题。这就是内存的本质:之前的状态仍然存在,这使得我们能够回答之前提到的内容(并做更多有趣的事情,我们稍后会看到更多)。
你还可以直接检查和更新状态;让我们看看如何操作:
Python:
graph.get_state(thread1)
JavaScript:
await graph.getState(thread1)
这将返回该线程的当前状态。
你也可以像这样更新状态: Python:
graph.update_state(thread1, [HumanMessage('I like LLMs!')])
JavaScript:
await graph.updateState(thread1, [new HumanMessage('I like LLMs!')])
这将向状态中的消息列表添加一条消息,以便下次在该线程上调用图时使用。
修改聊天历史
在许多情况下,聊天历史中的消息可能不适合或不符合最佳格式,无法生成准确的模型响应。为了解决这个问题,我们可以通过三种主要方式修改聊天历史:修剪、过滤和合并消息。
修剪消息
LLM(大型语言模型)有有限的上下文窗口;换句话说,LLM可以接收作为提示的最大令牌数。因此,发送给模型的最终提示不能超过这个限制(每种模式下都有特定限制),因为模型会拒绝过长的提示或将其截断。此外,过多的提示信息可能会分散模型的注意力,导致幻觉(即生成不相关的内容)。
解决这个问题的有效方法是限制从聊天历史中检索并附加到提示中的消息数量。实际上,我们只需要加载和存储最新的消息。让我们来看一个带有一些预加载消息的聊天历史示例。
幸运的是,LangChain 提供了内建的 trim_messages
辅助工具,涵盖了多种策略来满足这些需求。例如,trim_messages
工具可以指定我们希望保留或移除多少令牌。
以下是一个示例,通过设置策略参数为 "last" 来获取消息列表中的最后 max_tokens
令牌:
Python:
from langchain_core.messages import SystemMessage, trim_messages
from langchain_openai import ChatOpenAI
trimmer = trim_messages(
max_tokens=65,
strategy="last",
token_counter=ChatOpenAI(model="gpt-4o"),
include_system=True,
allow_partial=False,
start_on="human",
)
messages = [
SystemMessage(content="you're a good assistant"),
HumanMessage(content="hi! I'm bob"),
AIMessage(content="hi!"),
HumanMessage(content="I like vanilla ice cream"),
AIMessage(content="nice"),
HumanMessage(content="what's 2 + 2"),
AIMessage(content="4"),
HumanMessage(content="thanks"),
AIMessage(content="no problem!"),
HumanMessage(content="having fun?"),
AIMessage(content="yes!"),
]
trimmer.invoke(messages)
JavaScript:
import {
AIMessage,
HumanMessage,
SystemMessage,
trimMessages,
} from "@langchain/core/messages";
import { ChatOpenAI } from "@langchain/openai";
const trimmer = trimMessages({
maxTokens: 65,
strategy: "last",
tokenCounter: new ChatOpenAI({ modelName: "gpt-4o" }),
includeSystem: true,
allowPartial: false,
startOn: "human",
});
const messages = [
new SystemMessage("you're a good assistant"),
new HumanMessage("hi! I'm bob"),
new AIMessage("hi!"),
new HumanMessage("I like vanilla ice cream"),
new AIMessage("nice"),
new HumanMessage("what's 2 + 2"),
new AIMessage("4"),
new HumanMessage("thanks"),
new AIMessage("no problem!"),
new HumanMessage("having fun?"),
new AIMessage("yes!"),
]
const trimmed = await trimmer.invoke(messages);
输出:
[
SystemMessage(content="you're a good assistant"),
HumanMessage(content='what's 2 + 2'),
AIMessage(content='4'),
HumanMessage(content='thanks'),
AIMessage(content='no problem!'),
HumanMessage(content='having fun?'),
AIMessage(content='yes!')
]
注意以下几点:
- 参数
strategy
控制从列表的开头还是结尾开始修剪。通常,您会希望优先保留最新的消息,并裁剪掉不适合的旧消息,也就是说,从列表的结尾开始。对于这种行为,选择last
作为值。另一个可选的值是first
,它会优先保留最旧的消息,并裁剪掉较新的消息。 token_counter
是一个 LLM 或聊天模型,它将用于使用适合该模型的分词器来计算令牌数。- 我们可以添加参数
include_system=True
,确保修剪器保留系统消息。 - 参数
allow_partial
决定是否裁剪最后一条消息的内容,以适应限制。在我们的示例中,我们将其设置为false
,这意味着会完全移除那条会导致总数超出限制的消息。 - 参数
start_on="human"
确保我们永远不会删除一个AIMessage
(即模型的响应),而不删除相应的HumanMessage
(即该响应的问题)。
过滤消息
随着聊天历史消息列表的增长,可能会使用更多种类的消息、子链和模型。LangChain 提供了 filter_messages
辅助工具,使得按照类型、ID 或名称过滤聊天历史消息变得更加容易。
以下是一个示例,我们过滤出人类消息:
Python:
from langchain_core.messages import (
AIMessage,
HumanMessage,
SystemMessage,
filter_messages,
)
messages = [
SystemMessage("you are a good assistant", id="1"),
HumanMessage("example input", id="2", name="example_user"),
AIMessage("example output", id="3", name="example_assistant"),
HumanMessage("real input", id="4", name="bob"),
AIMessage("real output", id="5", name="alice"),
]
filter_messages(messages, include_types="human")
JavaScript:
import {
HumanMessage,
SystemMessage,
AIMessage,
filterMessages,
} from "@langchain/core/messages";
const messages = [
new SystemMessage({content: "you are a good assistant", id: "1"}),
new HumanMessage({content: "example input", id: "2", name: "example_user"}),
new AIMessage({content: "example output", id: "3", name: "example_assistant"}),
new HumanMessage({content: "real input", id: "4", name: "bob"}),
new AIMessage({content: "real output", id: "5", name: "alice"}),
];
filterMessages(messages, { includeTypes: ["human"] });
输出:
[
HumanMessage(content='example input', name='example_user', id='2'),
HumanMessage(content='real input', name='bob', id='4')
]
我们再尝试一个示例,过滤掉特定用户和 ID,并包括消息类型:
Python:
filter_messages(messages, exclude_names=["example_user", "example_assistant"])
"""
[SystemMessage(content='you are a good assistant', id='1'), HumanMessage(content='real input', name='bob', id='4'), AIMessage(content='real output', name='alice', id='5')]
"""
filter_messages(
messages,
include_types=[HumanMessage, AIMessage],
exclude_ids=["3"]
)
"""
[HumanMessage(content='example input', name='example_user', id='2'), HumanMessage(content='real input', name='bob', id='4'), AIMessage(content='real output', name='alice', id='5')]
"""
JavaScript:
filterMessages(
messages,
{ excludeNames: ["example_user", "example_assistant"] }
);
/*
[SystemMessage(content='you are a good assistant', id='1'),
HumanMessage(content='real input', name='bob', id='4'),
AIMessage(content='real output', name='alice', id='5')]
*/
filterMessages(messages, { includeTypes: ["human", "ai"], excludeIds: ["3"] });
/*
[HumanMessage(content='example input', name='example_user', id='2'),
HumanMessage(content='real input', name='bob', id='4'),
AIMessage(content='real output', name='alice', id='5')]
*/
filter_messages
辅助工具还可以以命令式或声明式的方式使用,这使得它可以方便地与链中的其他组件组合使用:
Python:
model = ChatOpenAI()
filter_ = filter_messages(exclude_names=["example_user", "example_assistant"])
chain = filter_ | model
JavaScript:
const model = new ChatOpenAI()
const filter = filterMessages({
excludeNames: ["example_user", "example_assistant"]
})
const chain = filter.pipe(model)
合并连续消息
某些模型不支持输入,包括连续的相同类型的消息(例如,Anthropic 的聊天模型)。LangChain 提供的 merge_message_runs
工具使得合并连续的相同类型的消息变得非常简单:
Python:
from langchain_core.messages import (
AIMessage,
HumanMessage,
SystemMessage,
merge_message_runs,
)
messages = [
SystemMessage("you're a good assistant."),
SystemMessage("you always respond with a joke."),
HumanMessage(
[{"type": "text", "text": "i wonder why it's called langchain"}]
),
HumanMessage("and who is harrison chasing anyway"),
AIMessage(
'''Well, I guess they thought "WordRope" and "SentenceString" just
didn't have the same ring to it!'''
),
AIMessage("""Why, he's probably chasing after the last cup of coffee in the
office!"""),
]
merge_message_runs(messages)
JavaScript:
import {
HumanMessage,
SystemMessage,
AIMessage,
mergeMessageRuns,
} from "@langchain/core/messages";
const messages = [
new SystemMessage("you're a good assistant."),
new SystemMessage("you always respond with a joke."),
new HumanMessage({
content: [{ type: "text", text: "i wonder why it's called langchain" }],
}),
new HumanMessage("and who is harrison chasing anyway"),
new AIMessage(
`Well, I guess they thought "WordRope" and "SentenceString" just didn't
have the same ring to it!`
),
new AIMessage(
"Why, he's probably chasing after the last cup of coffee in the office!"
),
];
mergeMessageRuns(messages);
输出:
[
SystemMessage(content="you're a good assistant.\nyou always respond with a joke."),
HumanMessage(content=[{'type': 'text', 'text': "i wonder why it's called langchain"}, 'and who is harrison chasing anyway']),
AIMessage(content='Well, I guess they thought "WordRope" and "SentenceString" just didn't have the same ring to it!\nWhy, he's probably chasing after the last cup of coffee in the office!')
]
注意,如果合并的其中一条消息的内容是一个内容块列表,那么合并后的消息也将包含内容块列表。如果两条要合并的消息的内容都是字符串,那么它们会用换行符连接起来。
merge_message_runs
辅助工具可以以命令式或声明式的方式使用,使得它能够方便地与链中的其他组件组合使用:
Python:
model = ChatOpenAI()
merger = merge_message_runs()
chain = merger | model
JavaScript:
const model = new ChatOpenAI()
const merger = mergeMessageRuns()
const chain = merger.pipe(model)
总结
本章介绍了构建一个简单记忆系统的基础,使你的 AI 聊天机器人能够记住与用户的对话。我们讨论了如何使用 LangGraph 自动化存储和更新聊天历史,从而简化这一过程。我们还讨论了修改聊天历史的重要性,并探索了修剪、过滤和总结聊天消息的各种策略。
在第五章中,你将学习如何让你的 AI 聊天机器人不仅仅是回应聊天:例如,你的新模型将能够做出决策、选择行动,并反思它的过去输出。