跳转至

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

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

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,把聊天历史存放到内存,也就是每次重启就相当于全新的聊天机器人。我希望你能把它改造成一个可以持久化的聊天历史,让它变得更加实用,欢迎你把改造的心得分享在留言区。

精选留言(13)
  • 晴天了 👍(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