LangChain聊天机器人实战:从零搭建记忆型+角色扮演AI(附源码级解析与代码)

正文内容

你好,我是郑晔!

前面我们介绍了 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 就退出。其中采用大模型的处理部分我们之前都讲过,理解起来应该不困难。

你可以把这段代码运行起来,和它聊聊。不过,聊上几句你就会发现一个问题:它完全记不住你们聊天的上下文。

You:> 我是 dreamhead
你好,Dreamhead!有什么我可以帮你的吗?
You:> 我是谁?
你是提问者,但我无法知道你的具体身份。如果你愿意,可以告诉我更多关于你的信息!

为什么会这样?回忆一下 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_idget_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,可以替换成一个消息列表。相比于单个变量,消息列表给了用户更多灵活处理的空间。所以,我们给模型传递参数的时候,也使用了消息列表:

 {"messages": [HumanMessage(content=user_input)]}

另外,组装链的过程是我们先把提示词模板和模型组装成一个链,然后,再用 RunnableWithMessageHistory 去封装它。正如我前面所说,链也是一个 Runnable。

with_message_history = RunnableWithMessageHistory(
    prompt | chat_model,
    get_session_history
)

下面是我和这个聊天机器人的一段对话,可以看到,它已经能按照我们的设定回答相应的问题了:

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 的参数如果有消息,它会直接按照规则处理消息,如果没有消息,它就会生成一个用来处理消息的组件。在这个例子里,我们只设置了处理规则,所以,它就生成了一个处理消息的组件,我们把它串到我们的链里:

trimmer | prompt | chat_model

到这里,我们已经拥有了一个可以正常运行的聊天机器人了,你可以和它好好聊聊了!

总结时刻

这一讲,我们构建了一个命令行版的聊天机器人。我们可以通过 ChatMessageHistory 管理聊天历史,然后用 RunnableWithMessageHistory 把它和我们编写的链结合起来。

我们还实现了一个角色扮演类的聊天机器人,关键点就是将提示词模板(PromptTemplate)结合进来。在实际的使用中,我们需要限制传给大模型的消息规模,通过 trim_messages 就可以完成限制消息规模的任务。

如果今天的内容你只能记住一件事,那请记住,实现一个聊天机器人的关键点就是管理好聊天历史

练习题

这一讲我们的聊天机器人用了 InMemoryChatMessageHistory,把聊天历史存放到内存,也就是每次重启就相当于全新的聊天机器人。我希望你能把它改造成一个可以持久化的聊天历史,让它变得更加实用,欢迎你把改造的心得分享在留言区。
在这里插入图片描述


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值