构建一个聊天机器人 #
先决条件
本教程假定您对以下概念有一定了解:
概述 #
我们将讨论一个如何设计和实现 LLM 驱动的聊天机器人的示例。这个聊天机器人能够进行对话并记住之前的互动。
请注意,我们构建的这个聊天机器人将仅使用语言模型进行对话。您可能还在寻找其他几个相关概念:
- 对话式RAG(Conversational RAG):在外部数据源上启用聊天机器人体验
- 代理(Agents):构建可以采取行动的聊天机器人
本教程将涵盖基础知识,这对这两个更高级的主题将有帮助,但如果您愿意,可以直接跳到那些部分。
安装 #
1pip install langchain
LangSmith跟踪配置(可选) #
略,参见这里
快速入门 #
首先,让我们学习如何单独使用语言模型。LangChain支持许多不同的语言模型,这里使用OpenAI。
1pip install -qU langchain-openai
2pip install -qU python-dotenv
1from dotenv import load_dotenv
2# OPENAI_API_KEY, OPENAI_BASE_URL, MODEL_NAME在.env文件中配置
3assert load_dotenv()
4
5import os
6MODEL_NAME = os.environ.get("MODEL_NAME")
7from langchain_openai import ChatOpenAI
8model = ChatOpenAI(model=MODEL_NAME)
让我们首先直接使用模型。ChatModels是 LangChain “Runnables” 的实例,这意味着它们提供了一个标准接口用于交互。要简单地调用模型,我们可以将一系列消息传递给.invoke
方法。
1from langchain_core.messages import HumanMessage
2model.invoke([HumanMessage(content="Hi! I'm Bob")])
1AIMessage(content='Hi Bob! How can I assist you today?', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 10, 'prompt_tokens': 11, 'total_tokens': 21, 'completion_tokens_details': None}, 'model_name': 'gpt-4o-mini', 'system_fingerprint': 'fp_878413d04d', 'finish_reason': 'stop', 'logprobs': None}, id='run-331e102a-b01d-475a-8407-b7e20055eb88-0', usage_metadata={'input_tokens': 11, 'output_tokens': 10, 'total_tokens': 21})
模型本身是无状态的。例如,如果您提出一个后续问题:
1model.invoke([HumanMessage(content="What's my name?")])
1AIMessage(content='I don’t know your name! But if you’d like to share it, I’d be happy to use it.', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 24, 'prompt_tokens': 11, 'total_tokens': 35, 'completion_tokens_details': None}, 'model_name': 'gpt-4o-mini', 'system_fingerprint': 'fp_878413d04d', 'finish_reason': 'stop', 'logprobs': None}, id='run-e20fdf3c-ed7a-4cbb-8378-bf2e4d487480-0', usage_metadata={'input_tokens': 11, 'output_tokens': 24, 'total_tokens': 35})
我们可以看到,它没有将之前的对话轮次作为上下文,因此无法回答这个问题。这会导致糟糕的聊天机器人体验!
为了克服这个问题,我们需要将整个对话历史传递给模型。让我们看看这样做会发生什么:
1from langchain_core.messages import AIMessage
2
3model.invoke(
4 [
5 HumanMessage(content="Hi! I'm Bob"),
6 AIMessage(content="Hello Bob! How can I assist you today?"),
7 HumanMessage(content="What's my name?"),
8 ]
9)
1AIMessage(content='Your name is Bob! How can I help you today?', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 12, 'prompt_tokens': 33, 'total_tokens': 45, 'completion_tokens_details': None}, 'model_name': 'gpt-4o-mini', 'system_fingerprint': 'fp_878413d04d', 'finish_reason': 'stop', 'logprobs': None}, id='run-dc043d5e-c0ed-4c0c-9afb-cee6cd387299-0', usage_metadata={'input_tokens': 33, 'output_tokens': 12, 'total_tokens': 45})
现在我们可以看到得到了一个不错的回应!
这就是聊天机器人能够进行对话互动的基本思路。那么我们该如何最佳实现这一点呢?
消息历史(Message History) #
我们可以使用一个Message History类来包装我们的模型,使其具有状态。这将跟踪模型的输入和输出,并将它们存储在某个数据存储中。未来的交互将加载这些消息,并将其作为输入的一部分传递给链。让我们看看如何使用这个!
首先,确保安装langchain-community
,因为我们将使用其中的一个集成(integration)来存储消息历史。
1pip install langchain_community
之后,我们可以导入相关类并设置我们的链,它将模型包装起来并添加消息历史。这里的一个关键部分是我们作为 get_session_history
传入的函数。这个函数应该接收一个 session_id
并返回一个消息历史对象。这个 session_id
用于区分不同的对话,应该在调用新链时作为配置的一部分传入(我们会展示如何做到这一点)。
1from langchain_core.chat_history import BaseChatMessageHistory, InMemoryChatMessageHistory
2from langchain_core.runnables.history import RunnableWithMessageHistory
3
4store = {}
5
6def get_session_history(session_id: str) -> BaseChatMessageHistory:
7 if session_id not in store:
8 store[session_id] = InMemoryChatMessageHistory()
9 return store[session_id]
10
11with_message_history = RunnableWithMessageHistory(model, get_session_history)
API Reference: BaseChatMessageHistory | InMemoryChatMessageHistory | RunnableWithMessageHistory
我们现在需要创建一个配置config
,每次都将其传递给Runable的实例。这个配置包含一些不直接作为输入的信息,但仍然很有用。在这种情况下,我们想要包含一个 session_id
。这应该看起来像这样:
1config = {"configurable": {"session_id": "abc2"}}
1response = with_message_history.invoke(
2 [HumanMessage(content="Hi! I'm Bob")],
3 config=config,
4)
5
6response.content
1'Hi Bob! How can I assist you today?'
1response = with_message_history.invoke(
2 [HumanMessage(content="What's my name?")],
3 config=config,
4)
5
6response.content
1'Your name is Bob! How can I help you today?'
太好了!我们的聊天机器人现在能记住关于我们的事情了。如果我们更改配置以引用不同的session_id
,我们可以看到它会重新开始一段全新的对话。
1config = {"configurable": {"session_id": "abc3"}}
2
3response = with_message_history.invoke(
4 [HumanMessage(content="What's my name?")],
5 config=config,
6)
7
8response.content
1"I don't know your name. If you'd like to share it, feel free!"
然而,我们随时可以回到原来的对话(因为我们将其保存在数据库中)
1config = {"configurable": {"session_id": "abc2"}}
2
3response = with_message_history.invoke(
4 [HumanMessage(content="What's my name?")],
5 config=config,
6)
7
8response.content
1'Your name is Bob! What would you like to talk about?'
这就是我们如何支持聊天机器人与多个用户进行对话的方式!
目前,我们所做的只是在模型周围添加了一个简单的持久化层。我们可以通过添加提示模板来使其变得更复杂和个性化。
提示模板(Prompt templates) #
提示模板有助于将原始用户信息转换为语言模型可以处理的格式。在这种情况下,原始用户输入只是一条消息,我们正在将其传递给语言模型。现在让我们稍微复杂化一下。首先,让我们添加一条带有一些自定义指令的系统消息(但仍然以消息作为输入)。接下来,我们将添加更多输入,而不仅仅是消息。
首先,让我们添加一条系统消息。为此,我们将创建一个ChatPromptTemplate。我们将利用MessagesPlaceholder
来传递所有的消息。
1from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
2prompt = ChatPromptTemplate.from_messages([
3 ("system", "You are a helpful assistant. Answer all questions to the best of your ability."),
4 MessagesPlaceholder(variable_name="messages")
5])
6chain = prompt | model
API Reference: ChatPromptTemplate | MessagesPlaceholder
请注意,这略微改变了输入类型 - 我们现在不是传入一个消息列表,而是传入一个带有messages
键的字典,其中包含一个消息列表。
1response = chain.invoke({"messages": [HumanMessage(content="hi! I'm bob")]})
2
3response.content
1'Hi Bob! How can I assist you today?'
我们现在可以像之前一样将其包装在相同的Messages History对象中
1with_message_history = RunnableWithMessageHistory(chain, get_session_history)
2
3config = {"configurable": {"session_id": "abc5"}}
1response = with_message_history.invoke(
2 [HumanMessage(content="Hi! I'm Jim")],
3 config=config,
4)
5
6response.content
1'Hi Jim! How can I assist you today?'
1response = with_message_history.invoke(
2 [HumanMessage(content="What's my name?")],
3 config=config,
4)
5
6response.content
1'Your name is Jim! How can I help you today?'
太棒了!现在让我们把我们的提示模板变得稍微复杂一些。假设提示模板现在看起来像这样:
1prompt = ChatPromptTemplate.from_messages(
2 [
3 (
4 "system",
5 "You are a helpful assistant. Answer all questions to the best of your ability in {language}.",
6 ),
7 MessagesPlaceholder(variable_name="messages"),
8 ]
9)
10
11chain = prompt | model
请注意,我们已向提示添加了一种新的language
输入。现在,我们可以调用链并传入我们选择的语言。
1response = chain.invoke(
2 {"messages": [HumanMessage(content="hi! I'm bob")], "language": "Spanish"}
3)
4
5response.content
1'¡Hola, Bob! ¿Cómo puedo ayudarte hoy?'
现在,让我们将这个更复杂的链包装在一个 Message History 类中。这次,由于输入中有多个键,我们需要指定要用于保存聊天历史的正确键。
1with_message_history = RunnableWithMessageHistory(
2 chain,
3 get_session_history,
4 input_messages_key="messages",
5)
6
7config = {"configurable": {"session_id": "abc11"}}
1response = with_message_history.invoke(
2 {"messages": [HumanMessage(content="hi! I'm todd")], "language": "Spanish"},
3 config=config,
4)
5
6response.content
1'¡Hola, Todd! ¿En qué puedo ayudarte hoy?'
1response = with_message_history.invoke(
2 {"messages": [HumanMessage(content="whats my name?")], "language": "Spanish"},
3 config=config,
4)
5
6response.content
1'Tu nombre es Todd. ¿En qué más puedo ayudarte?'
可以通过使用LangSmith跟踪了解内部发生了什么。
管理对话历史(Managing Conversation History) #
当构建聊天机器人时,要理解的一个重要概念是如何管理对话历史。如果任其不受管理,消息列表将无限增长并有可能溢出 LLM 的上下文窗口。因此,重要的是添加一个步骤来限制您传入的消息的大小。
重要的是,您将应该在提示模板之前执行此操作,但在您从消息历史中加载以前的消息之后。
我们可以通过在提示前面添加一个简单的步骤来修改 messages
键,然后将该新链包装在 Message History 类中来实现这一点。
LangChain 附带了一些用于管理消息列表的内置助手。在这种情况下,我们将使用 trim_messages
助手来减少我们发送给模型的消息数量。修剪器允许我们指定要保留的token数,以及其他参数,例如是否要始终保留系统消息以及是否允许部分消息:
1from langchain_core.messages import SystemMessage, trim_messages
2
3trimmer = trim_messages(
4 max_tokens=65,
5 strategy="last",
6 token_counter=model,
7 include_system=True,
8 allow_partial=False,
9 start_on="human",
10)
11
12messages = [
13 SystemMessage(content="you're a good assistant"),
14 HumanMessage(content="hi! I'm bob"),
15 AIMessage(content="hi!"),
16 HumanMessage(content="I like vanilla ice cream"),
17 AIMessage(content="nice"),
18 HumanMessage(content="whats 2 + 2"),
19 AIMessage(content="4"),
20 HumanMessage(content="thanks"),
21 AIMessage(content="no problem!"),
22 HumanMessage(content="having fun?"),
23 AIMessage(content="yes!"),
24]
25
26trimmer.invoke(messages)
API Reference: SystemMessage | trim_messages
1[SystemMessage(content="you're a good assistant", additional_kwargs={}, response_metadata={}),
2 HumanMessage(content='whats 2 + 2', additional_kwargs={}, response_metadata={}),
3 AIMessage(content='4', additional_kwargs={}, response_metadata={}),
4 HumanMessage(content='thanks', additional_kwargs={}, response_metadata={}),
5 AIMessage(content='no problem!', additional_kwargs={}, response_metadata={}),
6 HumanMessage(content='having fun?', additional_kwargs={}, response_metadata={}),
7 AIMessage(content='yes!', additional_kwargs={}, response_metadata={})]
要将其用于我们的链中,我们只需要在将 messages
输入传递给我们的提示之前运行修剪器trimmer即可。
现在,如果我们尝试向模型询问我们的名字,它将不知道,因为我们删除了聊天历史中的那一部分:
1from operator import itemgetter
2
3from langchain_core.runnables import RunnablePassthrough
4
5chain = (
6 RunnablePassthrough.assign(messages=itemgetter("messages") | trimmer)
7 | prompt
8 | model
9)
10
11response = chain.invoke(
12 {
13 "messages": messages + [HumanMessage(content="what's my name?")],
14 "language": "English",
15 }
16)
17response.content
API Reference: RunnablePassthrough
1'I don’t know your name. What is it?'
但是如果我们询问最近几条消息中的信息,它就能记住:
1response = chain.invoke(
2 {
3 "messages": messages + [HumanMessage(content="what math problem did i ask")],
4 "language": "English",
5 }
6)
7response.content
1'You asked, "What\'s 2 + 2?"'
现在让我们将其包装在 Message History 中
1with_message_history = RunnableWithMessageHistory(
2 chain,
3 get_session_history,
4 input_messages_key="messages",
5)
6
7config = {"configurable": {"session_id": "abc20"}}
1response = with_message_history.invoke(
2 {
3 "messages": messages + [HumanMessage(content="whats my name?")],
4 "language": "English",
5 },
6 config=config,
7)
8
9response.content
1"I don't know your name. What would you like me to call you?"
如预期,我们声明名字的第一条消息已被删除。此外,聊天历史中现在有两条新消息(我们的最新问题和最新响应)。这意味着更多以前可以在我们的对话历史中访问的信息现在不再可用!在这种情况下,我们的初始数学问题也被从历史中删除了,因此模型不再知道它:
1response = with_message_history.invoke(
2 {
3 "messages": [HumanMessage(content="what math problem did i ask?")],
4 "language": "English",
5 },
6 config=config,
7)
8
9response.content
1"You haven't asked a math problem yet. If you have one in mind, feel free to share it!"
可以通过使用LangSmith跟踪了解内部发生了什么。
流式传输(Streaming) #
现在我们已经有了一个功能完善的聊天机器人。然而,对于聊天应用程序来说,一个非常重要的用户体验考虑因素是流式传输。大语言模型有时可能需要一些时间才能作出响应,因此为了改善用户体验,大多数应用程序会在生成每个令牌时逐步返回。这使用户能够看到进度。
其实这样做非常简单!
所有链都暴露了 .stream
方法,使用消息历史的链也不例外。我们只需使用该方法即可获取流式响应。
1config = {"configurable": {"session_id": "abc15"}}
2for r in with_message_history.stream(
3 {
4 "messages": [HumanMessage(content="hi! I'm todd. tell me a joke")],
5 "language": "English",
6 },
7 config=config,
8):
9 print(r.content, end="|")
1|Hi| Todd|!| Here's| a| joke| for| you|:
2
3|Why| did| the| scare|crow| win| an| award|?
4
5|Because| he| was| outstanding| in| his| field|!||
下一步 #
现在你了解了如何在LangChain中创建聊天机器人的基础知识,可能会感兴趣的一些更高级的教程包括:
- 对话式RAG(Conversational RAG):在外部数据源上启用聊天机器人体验
- 代理(Agents):构建可以采取行动的聊天机器人
如果你想深入了解具体内容,值得查看的内容有:
- 流式传输Streaming:流式传输对聊天应用至关重要
- 如何添加消息历史:深入探讨与消息历史相关的所有内容
- 如何管理大型消息历史:更多管理大型聊天历史的技术