跳到主要内容

做一个聊天机器人

先决条件

本指南是假设您已经熟悉下面这些概念:

  • Chat Models 聊天模型
  • Prompt Templates 提示模板
  • Chat History 聊天记录

概述

我们将通过一个示例来说明如何设计和实现一个 LLM 驱动的聊天机器人,该聊天机器人将能够进行对话并记住之前的交互。

注意,我们构建的这个聊天机器人将仅使用语言模型进行对话。您可能正了解到其他几个相关概念:

  • Conversational RAG(会话式 RAG):通过外部数据源实现聊天机器人体验
  • Agents(智能体):构建可以支持行动的聊天机器人

本教程将涵盖对这两个更高级主题有帮助的基础知识,但如果您选择,请随意直接跳到那里。

设置

Jupyter Notebook, 安装LangChain,LangSmith,langchain-openai 参考第一篇教程。

快速入门

我们先直接使用模型。 ChatModels 是 LangChain“Runnables”的实例,这意味着它们公开了一个标准接口来与之交互。为了简单地调用模型,我们可以将消息列表传递给 .invoke 方法。

import getpass
import os

os.environ["OPENAI_API_KEY"] = getpass.getpass()

from langchain_openai import ChatOpenAI

model = ChatOpenAI(model="gpt-3.5-turbo")
from langchain_core.messages import HumanMessage

model.invoke([HumanMessage(content="Hi! I'm Bob")])

API 参考:HumanMessage

AIMessage(content='Hello Bob! How can I assist you today?', response_metadata={'token_usage': {'completion_tokens': 10, 'prompt_tokens': 12, 'total_tokens': 22}, 'model_name': 'gpt-3.5-turbo-0125', 'system_fingerprint': None, 'finish_reason': 'stop', 'logprobs': None}, id='run-d939617f-0c3b-45e9-a93f-13dafecbd4b5-0', usage_metadata={'input_tokens': 12, 'output_tokens': 10, 'total_tokens': 22})

该模型本身没有任何状态概念。例如,如果您提出后续问题:

model.invoke([HumanMessage(content="What's my name?")])
AIMessage(content="I'm sorry, I don't have access to personal information unless you provide it to me. How may I assist you today?", response_metadata={'token_usage': {'completion_tokens': 26, 'prompt_tokens': 12, 'total_tokens': 38}, 'model_name': 'gpt-3.5-turbo-0125', 'system_fingerprint': None, 'finish_reason': 'stop', 'logprobs': None}, id='run-47bc8c20-af7b-4fd2-9345-f0e9fdf18ce3-0', usage_metadata={'input_tokens': 12, 'output_tokens': 26, 'total_tokens': 38})

可以看出,它没有把前面的对话转入上下文,无法回答问题。这会带来糟糕的聊天机器人体验!

为了解决这个问题,我们需要将整个对话历史记录传递到模型中。让我们看看这样做时会发生什么:

from langchain_core.messages import AIMessage

model.invoke(
[
HumanMessage(content="Hi! I'm Bob"),
AIMessage(content="Hello Bob! How can I assist you today?"),
HumanMessage(content="What's my name?"),
]
)

API 参考:AIMessage

AIMessage(content='Your name is Bob. How can I help you, Bob?', response_metadata={'token_usage': {'completion_tokens': 13, 'prompt_tokens': 35, 'total_tokens': 48}, 'model_name': 'gpt-3.5-turbo-0125', 'system_fingerprint': None, 'finish_reason': 'stop', 'logprobs': None}, id='run-9f90291b-4df9-41dc-9ecf-1ee1081f4490-0', usage_metadata={'input_tokens': 35, 'output_tokens': 13, 'total_tokens': 48})

现在我们可以看到我们得到了很好的回应!

这是支撑聊天机器人对话交互能力的基本思想。那么我们如何最好地实现这一点呢?

消息历史

我们可以使用消息历史记录类来包装我们的模型并使其具有状态。这将跟踪模型的输入和输出,并将它们存储在某个数据存储中。未来的交互将加载这些消息并将它们作为输入的一部分传递到链中。让我们看看如何使用它!

首先,让我们确保安装 langchain-community,因为我们将使用其中的集成来存储消息历史记录。

pip install langchain_community

之后,我们可以导入相关的类并设置封装模型并添加此消息历史记录的链。这里的关键部分是我们作为 get_session_history 传入的函数。该函数预计会接受 session_id 并返回消息历史记录对象。这个 session_id 用于区分单独的对话,并且应该在调用新链时作为配置的一部分传入(我们将展示如何做到这一点)。

from langchain_core.chat_history import (
BaseChatMessageHistory,
InMemoryChatMessageHistory,
)
from langchain_core.runnables.history import RunnableWithMessageHistory

store = {}


def get_session_history(session_id: str) -> BaseChatMessageHistory:
if session_id not in store:
store[session_id] = InMemoryChatMessageHistory()
return store[session_id]


with_message_history = RunnableWithMessageHistory(model, get_session_history)

API 参考: BaseChatMessageHistory | InMemoryChatMessageHistory | RunnableWithMessageHistory

我们现在需要创建一个每次传递到可运行程序中的配置。此配置包含的信息不是直接输入的一部分,但仍然有用。在本例中,我们想要包含一个 session_id。这应该看起来像:

config = {"configurable": {"session_id": "abc2"}}
response = with_message_history.invoke(
[HumanMessage(content="Hi! I'm Bob")],
config=config,
)

response.content
'Hi Bob! How can I assist you today?'
response = with_message_history.invoke(
[HumanMessage(content="What's my name?")],
config=config,
)

response.content
'Your name is Bob. How can I help you today, Bob?'

太棒了!我们的聊天机器人现在可以记住有关我们的事情。如果我们更改配置以引用不同的 session_id,我们可以看到它重新开始对话。

config = {"configurable": {"session_id": "abc3"}}

response = with_message_history.invoke(
[HumanMessage(content="What's my name?")],
config=config,
)

response.content
"I'm sorry, I cannot determine your name as I am an AI assistant and do not have access to that information."

然而,我们总是可以回到原来的对话(因为我们将其保存在数据库中)

config = {"configurable": {"session_id": "abc2"}}

response = with_message_history.invoke(
[HumanMessage(content="What's my name?")],
config=config,
)

response.content
'Your name is Bob. How can I assist you today, Bob?'

这就是我们如何支持聊天机器人与许多用户进行对话!

现在,我们所做的就是在模型周围添加一个简单的持久层。我们可以通过添加提示模板来开始使内容变得更加复杂和个性化。

提示语模板(Prompt templates)

提示模板有助于将原始用户信息转换为大模型可以使用的格式。在这种情况下,原始用户输入只是一条消息,我们将其传递给 LLM。现在让我们把它变得更复杂一点。 首先,让我们添加带有一些自定义指令的系统消息(但仍将消息作为输入)。接下来,除了消息之外,我们还将添加更多输入。

首先,让我们添加一条系统消息。为此,我们将创建一个 ChatPromptTemplate。我们将利用 MessagesPlaceholder 传递所有消息。

from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder

prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"You are a helpful assistant. Answer all questions to the best of your ability.",
),
MessagesPlaceholder(variable_name="messages"),
]
)

chain = prompt | model

API 参考: ChatPromptTemplate | MessagesPlaceholder

请注意,这会稍微改变输入类型 - 我们现在不是传递消息列表,而是传递带有消息键的字典,其中包含消息列表。

response = chain.invoke({"messages": [HumanMessage(content="hi! I'm bob")]})

response.content
'Hello Bob! How can I assist you today?'

我们现在可以将其包装在与以前相同的 Messages History 对象中

with_message_history = RunnableWithMessageHistory(chain, get_session_history)
config = {"configurable": {"session_id": "abc5"}}
response = with_message_history.invoke(
[HumanMessage(content="Hi! I'm Jim")],
config=config,
)

response.content
'Your name is Jim.'

令人惊叹!现在让我们的提示变得更复杂一些。我们假设提示模板现在看起来像这样:

prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"You are a helpful assistant. Answer all questions to the best of your ability in {language}.",
),
MessagesPlaceholder(variable_name="messages"),
]
)

chain = prompt | model

请注意,我们已在提示中添加了新的语言输入。我们现在可以调用该链并传递我们选择的语言。

response = chain.invoke(
{"messages": [HumanMessage(content="hi! I'm bob")], "language": "Spanish"}
)

response.content
'¡Hola, Bob! ¿En qué puedo ayudarte hoy?'

现在让我们将这个更复杂的链包装在消息历史记录类中。这次,因为输入中有多个键,所以我们需要指定正确的键来保存聊天记录。

with_message_history = RunnableWithMessageHistory(
chain,
get_session_history,
input_messages_key="messages",
)
config = {"configurable": {"session_id": "abc11"}}
response = with_message_history.invoke(
{"messages": [HumanMessage(content="hi! I'm todd")], "language": "Spanish"},
config=config,
)

response.content
'¡Hola Todd! ¿En qué puedo ayudarte hoy?'
response = with_message_history.invoke(
{"messages": [HumanMessage(content="whats my name?")], "language": "Spanish"},
config=config,
)

response.content
'Tu nombre es Todd.'

Managing Conversation History 管理对话历史记录

构建聊天机器人时需要理解的一个重要概念是如何管理对话历史记录。如果不加以管理,消息列表将无限制地增长,并可能溢出 LLM 的上下文上限。 因此,添加一个步骤来限制您传入的消息的大小非常重要

重要的是,您需要在提示模板之前但在从消息历史记录加载以前的消息之后执行此操作。

我们可以通过在提示前面添加一个简单的步骤来适当地修改消息键,然后将该新链包装在 Message History 类中来实现此目的。

LangChain 附带了一些内置的辅助程序来管理消息列表。在本例中,我们将使用trim_messages辅助器来减少发送到模型的消息数量。修剪器允许我们指定要保留多少token,以及其他参数,例如我们是否要始终保留系统消息以及是否允许部分消息:

from langchain_core.messages import SystemMessage, trim_messages

trimmer = trim_messages(
max_tokens=65,
strategy="last",
token_counter=model,
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="whats 2 + 2"),
AIMessage(content="4"),
HumanMessage(content="thanks"),
AIMessage(content="no problem!"),
HumanMessage(content="having fun?"),
AIMessage(content="yes!"),
]

trimmer.invoke(messages)

API 参考: SystemMessage | trim_messages

[SystemMessage(content="you're a good assistant"),
HumanMessage(content='whats 2 + 2'),
AIMessage(content='4'),
HumanMessage(content='thanks'),
AIMessage(content='no problem!'),
HumanMessage(content='having fun?'),
AIMessage(content='yes!')]

要在我们的链中使用它,我们只需在将消息输入传递到提示符之前运行修剪器即可。

现在,如果我们尝试询问模型我们的名字,它不会知道,因为我们修剪了聊天记录的这一部分:

from operator import itemgetter

from langchain_core.runnables import RunnablePassthrough

chain = (
RunnablePassthrough.assign(messages=itemgetter("messages") | trimmer)
| prompt
| model
)

response = chain.invoke(
{
"messages": messages + [HumanMessage(content="what's my name?")],
"language": "English",
}
)
response.content

API 参考: RunnablePassthrough

"I'm sorry, but I don't have access to your personal information. How can I assist you today?"

但如果我们询问最近几条消息中的信息,它会记住:

response = chain.invoke(
{
"messages": messages + [HumanMessage(content="what math problem did i ask")],
"language": "English",
}
)
response.content
'You asked "what\'s 2 + 2?"'

现在让我们将其包含在消息历史记录中

with_message_history = RunnableWithMessageHistory(
chain,
get_session_history,
input_messages_key="messages",
)

config = {"configurable": {"session_id": "abc20"}}
response = with_message_history.invoke(
{
"messages": messages + [HumanMessage(content="whats my name?")],
"language": "English",
},
config=config,
)

response.content
"I'm sorry, I don't have access to that information. How can I assist you today?"

正如预期,我们说出我们名字的第一条消息已被修剪。另外,聊天历史记录中现在有两条新消息(我们的最新问题和最新回复)。这意味着我们的对话历史记录中过去可以访问的更多信息不再可用!在这种情况下,我们最初的数学问题也已从历史记录中删除,因此模型不再知道它:

response = with_message_history.invoke(
{
"messages": [HumanMessage(content="what math problem did i ask?")],
"language": "English",
},
config=config,
)

response.content
"You haven't asked a math problem yet. Feel free to ask any math-related question you have, and I'll be happy to help you with it."

Streaming 流

现在我们有了一个功能聊天机器人。然而,聊天机器人应用程序的一个真正重要的用户体验考虑因素是流。 LLM 有时可能需要一段时间才能响应,因此为了改善用户体验,大多数应用程序所做的一件事就是在生成每个令牌时将其流回。这允许用户看到进度。

事实上,做到这一点非常简单!

所有链都公开一个 .stream 方法,使用消息历史记录的链也没有什么不同。我们可以简单地使用该方法来获取流响应。

config = {"configurable": {"session_id": "abc15"}}
for r in with_message_history.stream(
{
"messages": [HumanMessage(content="hi! I'm todd. tell me a joke")],
"language": "English",
},
config=config,
):
print(r.content, end="|")
|Hi| Todd|!| Sure|,| here|'s| a| joke| for| you|:| Why| couldn|'t| the| bicycle| find| its| way| home|?| Because| it| lost| its| bearings|!| 😄||