09 从零实现一个角色扮演的聊天机器人
你好,我是郑晔!
前面我们介绍了 LangChain 最核心的抽象,相信你现在已经能够用 LangChain 完成一些一次性的简单任务了。从这一讲开始,我们会尝试开发一些大模型应用。通过这些应用,你会逐渐了解到常见的应用类型有哪些,以及如何使用 LangChain 开发这些应用。当然,我们还会遇到很多之前没有讲到过的 LangChain 抽象,我会结合开发的内容给你做一些介绍。
这一讲,我们就从最简单的聊天机器人开始讲起。
简单的聊天机器人
前面说过,ChatGPT 之所以火爆,很大程度上是拜聊天模式所赐,人们对于聊天模式的熟悉,降低了 ChatGPT 的理解门槛。开发一个好的聊天机器人并不容易,但开发一个聊天机器人,尤其是有了 LangChain 之后,还是很容易的。我们这一讲的目标就是开发一个简单的聊天机器人,它也会成为我们后面几讲的基础,你会看到一个聊天机器人是怎样逐渐变得强大起来。
出于简化的目的,我们的目标是打造一个命令行的聊天机器人。下面就是这个最简版聊天机器人的实现:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
chat_model = ChatOpenAI(model="gpt-4o-mini")
while True:
user_input = input("You:> ")
if user_input.lower() == 'exit':
break
stream = chat_model.stream([HumanMessage(content=user_input)])
for chunk in stream:
print(chunk.content, end='', flush=True)
print()
在这段代码里,我们循环地等待用户输入,如果遇到了 exit 就退出。其中采用大模型的处理部分我们之前都讲过,理解起来应该不困难。
你可以把这段代码运行起来,和它聊聊。不过,聊上几句你就会发现一个问题:它完全记不住你们聊天的上下文。
为什么会这样?回忆一下 OpenAI API,它是一个 HTTP 请求。我们知道,HTTP 请求最大的特点是无状态,也就是说,服务端不会保留任何会话信息。对于每次都完成一个独立的任务,无状态是没有任何问题的。但对聊天机器人来说,就会出现对之前会话一无所知的情况。这显然无法满足我们对一个聊天机器人的基本预期,我们需要解决这个问题。
能记事的聊天机器人
既然 API 本身无法保留会话信息,一种常见的解决方案就是把聊天历史告诉大模型,帮助大模型了解之前的会话内容,也就是给大模型提供之前聊天的上下文。这样从用户的观感上看,这个聊天机器人就能按照我们之前聊天的内容持续对话。
我们当然可以自己维护聊天历史,在需要的时候,传递给大模型,但这么通用的需求,LangChain 已经为我们实现好了,下面就是一个例子:
from langchain_core.chat_history import BaseChatMessageHistory, InMemoryChatMessageHistory
from langchain_core.runnables import RunnableWithMessageHistory
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
chat_model = ChatOpenAI(model="gpt-4o-mini")
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(chat_model, get_session_history)
config = {"configurable": {"session_id": "dreamhead"}}
while True:
user_input = input("You:> ")
if user_input.lower() == 'exit':
break
stream = with_message_history.stream(
[HumanMessage(content=user_input)],
config=config
)
for chunk in stream:
print(chunk.content, end='', flush=True)
print()
为了支持聊天历史,LangChain 引入了一个抽象叫 ChatMessageHistory。为了简单,我们这里使用了 InMemoryChatMessageHistory,也就是在内存存放的聊天历史。有了聊天历史,就需要把聊天历史和模型结合起来,这就是 RunnableWithMessageHistory 所起的作用,它就是一个把聊天历史和链封装到一起的一个类。
这里的 Runnable 是一个接口,它表示一个工作单元。我们前面说过,组成链是由一个一个的组件组成的。严格地说,这些组件都实现了 Runnable 接口,甚至链本身也实现了 Runnable 接口,我们之前讨论的 invoke、stream 等接口都是定义在 Runnable 里,可以说,Runnable 是真正的基础类型,LCEL 之所以能够以声明式的方式起作用,Runnable 接口是关键。
不过,在真实的编码过程中,我们很少会直接面对 Runnable,大多数时候我们看见的都是各种具体类型。只是你会在很多具体类的名字中见到 Runnable,这里的 RunnableWithMessageHistory 就是其中一个。
你会发现我们传给 RunnableWithMessageHistory 的参数是一个函数,也就是这里的 get_session_history
,正如它的名字所示,它会获取会话的历史。如果我们在开发的是一个多人聊天的应用,每个人就是一个不同的会话,在聊天时,我们需要获取到自己的聊天历史。在我们这个实现里,获取聊天历史的依据是 session_id
。get_session_history
的实现很简单,如果不存在这个 session_id 对应的聊天历史,就创建一个新的,如果存在,就返回已有的。
其它的代码大多数与之前一样,只是用封装了聊天历史的对象( with_message_history
)代替了直接的模型,另外,在调用 stream
时,我还传入了一个配置,这个配置里存放了一个 session_id
,它就会在调用 get_session_history
时作为参数传过去。
有了聊天历史,我们再和这个聊天机器人聊天,它看上去就正常多了:
You:> 我是 dreamhead
你好,dreamhead!有什么我可以帮助你的吗?
You:> 我是谁?
你是“dreamhead”,这是你刚刚告诉我的名字。如果你想分享更多关于自己或者你的兴趣爱好,我很乐意听!
角色扮演的聊天机器人
现在我们拥有了一个能和我们正常沟通的聊天机器人,但这个聊天机器人本身并不具备任何能力。你应该看过让 AI 扮演某个人物角色,在对话的全过程中,它都会以这个角色的口吻来进行回复,而无需我们做任何额外的设置。接下来,我们就来实现这样一个聊天机器人,在这个例子里,我们实现的是一个 AI 孔子:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
chat_model = ChatOpenAI(model="gpt-4o-mini")
store = {}
def get_session_history(session_id: str) -> BaseChatMessageHistory:
if session_id not in store:
store[session_id] = InMemoryChatMessageHistory()
return store[session_id]
prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"你现在扮演孔子的角色,尽量按照孔子的风格回复,不要出现‘子曰’",
),
MessagesPlaceholder(variable_name="messages"),
]
)
with_message_history = RunnableWithMessageHistory(
prompt | chat_model,
get_session_history
)
config = {"configurable": {"session_id": "dreamhead"}}
while True:
user_input = input("You:> ")
if user_input.lower() == 'exit':
break
stream = with_message_history.stream(
{"messages": [HumanMessage(content=user_input)]},
config=config
)
for chunk in stream:
print(chunk.content, end='', flush=True)
print()
如果让聊天机器人可以扮演一个角色,本质上就是我们把设定好的提示词每次都发送给大模型,根据我们上一讲的内容,把固定的提示词和用户每次的输入分开,这就是提示词模板(PromptTemplate)发挥的作用。在这个例子里面,我们就设定了一个提示词,让聊天机器人能够扮演孔子的角色:
prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"你现在扮演孔子的角色,尽量按照孔子的风格回复,不要出现‘子曰’",
),
MessagesPlaceholder(variable_name="messages"),
]
)
我们在前面已经介绍过提示词模板的用法,这里新增的就是 MessagesPlaceholder,它是一个占位符,变量名是 messages
,可以替换成一个消息列表。相比于单个变量,消息列表给了用户更多灵活处理的空间。所以,我们给模型传递参数的时候,也使用了消息列表:
另外,组装链的过程是我们先把提示词模板和模型组装成一个链,然后,再用 RunnableWithMessageHistory 去封装它。正如我前面所说,链也是一个 Runnable。
下面是我和这个聊天机器人的一段对话,可以看到,它已经能按照我们的设定回答相应的问题了:
You:> 我是 dreamhead,我现在有些迷茫
迷茫者,心中若有疑惑,宜静心思之。人生如行路,前方虽有曲折,然若能明确所求,必能找到方向。可与友人交流,或于书中寻智慧,皆可助于明理。切记,迷茫乃常态,然勤思与行之,方可渐入佳境。
You:> 你能就我的情况,给我写几句话么,称呼就写我的名字
Dreamhead,人生之路,有时迷雾重重,然心中若有明灯,便无惧前行。可静心思考,问己所求,亦可倾听他人之见,方能拨云见日。愿你在探索中,渐悟自我,明亮未来。
在实现聊天机器人的过程中,还有一个很现实的问题,我们需要处理一下。如果不加任何限制,所有的聊天历史都会附加到新的会话中,随着聊天的进行,聊天历史很快就会超过大模型的上下文窗口大小。一种典型处理办法是,对聊天历史进行限制。
LangChain 提供了一个 trim_messages
用来控制消息的规模,它提供了很多控制消息规模的参数:
- max_tokens,限制最大的 Token 数量。
- strategy,限制的策略,从前面保留(first),还是从后面保留(last)。
- allow_partial,是否允许把拆分消息。
- include_system,是否要保留最开始的系统提示词。
这其中最关键的是就是max_tokens,这也是我们限制消息规模的主要原因。不过,这里是按照 Token 进行计算,我们该怎么计算 Token 呢?对 OpenAI API 来说,一种常见的解决方案是采用 tiktoken,这是一个专门用来处理的 Token 的程序库。下面是采用了 tiktoken 的计算 Token 的实现:
from typing import List
import tiktoken
from langchain_core.messages import SystemMessage, trim_messages, BaseMessage, HumanMessage, AIMessage, ToolMessage
def str_token_counter(text: str) -> int:
enc = tiktoken.get_encoding("o200k_base")
return len(enc.encode(text))
def tiktoken_counter(messages: List[BaseMessage]) -> int:
num_tokens = 3
tokens_per_message = 3
tokens_per_name = 1
for msg in messages:
if isinstance(msg, HumanMessage):
role = "user"
elif isinstance(msg, AIMessage):
role = "assistant"
elif isinstance(msg, ToolMessage):
role = "tool"
elif isinstance(msg, SystemMessage):
role = "system"
else:
raise ValueError(f"Unsupported messages type {msg.__class__}")
num_tokens += (
tokens_per_message
+ str_token_counter(role)
+ str_token_counter(msg.content)
)
if msg.name:
num_tokens += tokens_per_name + str_token_counter(msg.name)
return num_tokens
有了这些基础之后,我们就可以改造一下角色扮演机器人:
from langchain_core.messages import trim_messages
trimmer = trim_messages(
max_tokens=4096,
strategy="last",
token_counter=tiktoken_counter,
include_system=True,
)
with_message_history = RunnableWithMessageHistory(
trimmer | prompt | chat_model,
get_session_history
)
在这里,trim_messages
设置了最大 Token 数(max_tokens=4096),从后面保留消息(strategy=“last”),Token 计数方式设置成我们前面编写的 tiktoken_counter
。另外,因为我们是角色扮演的聊天机器人,第一句的系统消息就是角色设置,我们选择保留它(include_system=True)。
trim_messages
的参数如果有消息,它会直接按照规则处理消息,如果没有消息,它就会生成一个用来处理消息的组件。在这个例子里,我们只设置了处理规则,所以,它就生成了一个处理消息的组件,我们把它串到我们的链里:
到这里,我们已经拥有了一个可以正常运行的聊天机器人了,你可以和它好好聊聊了!
总结时刻
这一讲,我们构建了一个命令行版的聊天机器人。我们可以通过 ChatMessageHistory 管理聊天历史,然后用 RunnableWithMessageHistory 把它和我们编写的链结合起来。
我们还实现了一个角色扮演类的聊天机器人,关键点就是将提示词模板(PromptTemplate)结合进来。在实际的使用中,我们需要限制传给大模型的消息规模,通过 trim_messages
就可以完成限制消息规模的任务。
如果今天的内容你只能记住一件事,那请记住,实现一个聊天机器人的关键点就是管理好聊天历史。
练习题
这一讲我们的聊天机器人用了 InMemoryChatMessageHistory,把聊天历史存放到内存,也就是每次重启就相当于全新的聊天机器人。我希望你能把它改造成一个可以持久化的聊天历史,让它变得更加实用,欢迎你把改造的心得分享在留言区。
- 晴天了 👍(8) 💬(1)
个人理解的结构 大模型返回的消息 = 历史运行器(大模型, 历史存储逻辑).调用( [人类消息], 人类会话标识).
2024-11-21 - Geek_682837 👍(1) 💬(2)
这里langchain的版本是多少?用kimi的Moonshot遇到这个错了,Error in RootListenersTracer.on_llm_end callback: KeyError('message'),怎么解决?
2024-11-26 - Williamleelol 👍(0) 💬(1)
trimmer | prompt | chat_model 这个顺序是固定的么,试了下调整顺序会报错,有什么规则么?
2025-01-24 - swift 👍(0) 💬(1)
请问扮演孔子的那个例子中的提示词模板的用法,system 这个角色的提示词也会在每一轮用户输入的时候重复,还是只有第一轮时候会发送给大模型?
2025-01-16 - poettian 👍(0) 💬(1)
比较好奇langchain提供的这么多api,怎么去记住呢?刚开始使用,有点眼花缭乱的感觉
2025-01-16 - mgs2002 👍(0) 💬(1)
问下老师,课程里面的代码是怎样运行起来看效果的,用LangGraph Studio这个IDE吗
2025-01-09 - 晴天了 👍(0) 💬(1)
langchain 用golang版本也没关系吧 和教程不冲突吧 https://github.com/tmc/langchaingo
2024-11-20 - willmyc 👍(3) 💬(0)
老师,你好!第三段代码中好像还需要导入如下的包才能正常运行:from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder
2024-11-21 - 张申傲 👍(1) 💬(0)
第9讲打卡~ 使用持久化的方式保存历史消息,关键就是将History组件替换成`FileChatMessageHistory`。 具体代码:https://gitee.com/zhangshenao/happy-llm/blob/master/%E7%A8%8B%E5%BA%8F%E5%91%98%E7%9A%84AI%E5%BC%80%E5%8F%91%E7%AC%AC%E4%B8%80%E8%AF%BE/4.%E8%A7%92%E8%89%B2%E6%89%AE%E6%BC%94%E8%81%8A%E5%A4%A9%E6%9C%BA%E5%99%A8%E4%BA%BA.py
2025-01-17 - R 👍(0) 💬(1)
我有个疑问,既然能记住上下文,为什么每次提问还要插入角色, 带着这个疑问,继续往下学习
2025-02-21 - Geek_8cf9dd 👍(0) 💬(0)
聊天机器人的历史会话存内存容易丢失,放db中存放会更好些吧
2025-02-16 - MClink 👍(0) 💬(0)
openai.NotFoundError: Error code: 404 - {'error': {'type': 'invalid_request_error', 'code': 'unknown_url', 'message': 'Unknown request URL: POST /chat/completions. Please check the URL for typos, or see the docs at https://platform.openai.com/docs/api-reference/.', 'param': None}}. 域名是不是要配置。
2025-01-09 - Demon.Lee 👍(0) 💬(0)
1、聊天机器人:上下文内容(历史聊天记录),角色,上下文大小(成本); 2、了解到另一种控制上下文窗口大小的方式是对历史记录进行摘要; 3、LCEL 真是太棒了,抽象出一个个接口,再使用声明式编程,真是编程的艺术; 4、第4点是啥来着,我给忘了……
2025-01-06