本文将实现一个比较完善的聊天机器人的主要功能。包括:

  • 使用 LangGraph 构建聊天机器人
  • 自动裁剪聊天历史
  • 管理聊天会话的方法
  • 以流的方式输出回复

我们将同时使用 llama3.1deepseek 做演示。由于 langchain 可能对不同大模型支持程度不同以及其它限制,所以这个对比并不能说明哪个模型更好。

准备

在正式开始撸代码之前,需要准备一下编程环境。

  1. 计算机
    本文涉及的所有代码可以在没有显存的环境中执行。 我使用的机器配置为:

    • CPU: Intel i5-8400 2.80GHz
    • 内存: 16GB
  2. Visual Studio Code 和 venv 这是很受欢迎的开发工具,相关文章的代码可以在 Visual Studio Code 中开发和调试。 我们用 pythonvenv 创建虚拟环境, 详见:
    在Visual Studio Code中配置venv

  3. Ollama 在 Ollama 平台上部署本地大模型非常方便,基于此平台,我们可以让 langchain 使用 llama3.1qwen2.5 等各种本地大模型。详见:
    在langchian中使用本地部署的llama3.1大模型

自动裁剪聊天历史

我们知道,LangGraph 构建的聊天机器人可以基于 State 自动记录聊天历史,这样大模型可以了解会话上下文,聊天的体验更好。
显然,由于大模型token大小限制以及内存限制,我们不可能每次把所有的聊天历史都发给大模型。
Langchain 提供了 trim_messages 方法,可以利用大模型的能力,智能裁剪聊天历史。
我们先定义裁剪聊天历史的方法:

def get_trimmer(model_name,max_tokens):
    """
    重要:请务必在在加载之前的消息之后,并且在提示词模板之前使用它。
    """
    model = ChatOllama(model=model_name,temperature=0.3,verbose=True)
    trimmer = trim_messages(
        max_tokens=max_tokens,  #设置裁剪后消息列表中允许的最大 token 数量
        strategy="last",        #指定裁剪策略为保留最后的消息,即从消息列表的开头开始裁剪,直到满足最大 token 数量限制。
        token_counter=model,    #通过model来计算消息中的 token 数量。
        include_system=True,    #在裁剪过程中包含系统消息(SystemMessage)
        allow_partial=False,    #不允许裁剪出部分消息,即要么保留完整的消息,要么不保留,不会出现只保留消息的一部分的情况。
        start_on="human",   #从人类消息(HumanMessage)开始进行裁剪,即裁剪时会从第一个HumanMessage开始计算 token 数量,之前的系统消息等也会被包含在内进行整体裁剪考量。
    )
    return trimmer

通过查看代码注释,我们可以发现这种裁剪方式很智能。
下面我们初始化一个消息列表,对它进行一下裁剪测试:

messages = [
    SystemMessage(content="你是个好助手"),
    HumanMessage(content="你好,我是刘大钧"),
    AIMessage(content="你好"),
    HumanMessage(content="我喜欢香草冰淇淋"),
    AIMessage(content="很好啊"),
    HumanMessage(content="3 + 3等于几?"),
    AIMessage(content="6"),
    HumanMessage(content="谢谢"),
    AIMessage(content="不客气"),
    HumanMessage(content="和我聊天有意思么?"),
    AIMessage(content="是的,很有意思"),
]

def test_trimmer(model_name,max_tokens):
    trimmer = get_trimmer(model_name,max_tokens)
    messages_trimed = trimmer.invoke(messages)
    print(f'messages_trimed:{messages_trimed}')

现在,我们分别用 llama3.1deepseek-r1 调用 test_trimmer 方法进行测试,设置 max_token = 140 ,它们裁剪的结果是一样的:

[
    SystemMessage(content='你是个好助手', additional_kwargs={}, response_metadata={}), 
    HumanMessage(content='我喜欢香草冰淇淋', additional_kwargs={}, response_metadata={}),
    AIMessage(content='很好啊', additional_kwargs={}, response_metadata={}),
    HumanMessage(content='3 + 3等于几?', additional_kwargs={}, response_metadata={}), 
    AIMessage(content='6', additional_kwargs={}, response_metadata={}), 
    HumanMessage(content='谢谢', additional_kwargs={}, response_metadata={}), 
    AIMessage(content='不客气', additional_kwargs={}, response_metadata={}), 
    HumanMessage(content='和我聊天有意思么?', additional_kwargs={}, response_metadata={}), 
    AIMessage(content='是的,很有意思', additional_kwargs={}, response_metadata={})
]

在裁剪的过程中保留了 SystemMessage ,从 HumanMessage 开始,将 名字 部分的聊天记录裁掉了。

聊天机器人

定义提示词模板

prompt_template = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "你是一个乐于助人的助手。请用{language}尽力回答所有问题。",
        ),
        MessagesPlaceholder(variable_name="messages"),
    ]
)

定义 state

用于在 LangGraph 中携带数据。

class State(TypedDict):
    messages: Annotated[Sequence[BaseMessage], add_messages]
    language: str

构建 app

def build_app(model_name,max_tokens):
    model = ChatOllama(model=model_name,temperature=0.3,verbose=True)

    def call_model(state: State):
        trimmer = get_trimmer(model_name=model_name,max_tokens=max_tokens)
        trimmed_messages = trimmer.invoke(state["messages"])
        prompt = prompt_template.invoke(
            {"messages": trimmed_messages, "language": state["language"]}
        )
        response = model.invoke(prompt)
        return {"messages": [response]}

    workflow = StateGraph(state_schema=State)
    workflow.add_edge(START, "model")
    workflow.add_node("model", call_model)

    memory = MemorySaver()
    app = workflow.compile(checkpointer=memory)
    return app

与之前的例子的主要区别是:在 call_model 方法中对 messages 进行了裁剪;我们还可以发现:LangChain 的很多对象都支持 invoke 方法。

测试

我们通过 configurable 来配置聊天会话,并通过下面的方法进行测试,看看输出是什么。

def test_app(model_name,max_tokens):
    app = build_app(model_name,max_tokens)

    config = {"configurable": {"thread_id": "abc456"}}
    language = "简体中文"

    query = "我叫什么名字?"    

    input_messages = messages + [HumanMessage(query)]

    output = app.invoke(
        {"messages": input_messages, "language": language},
        config,
    )
    print(output["messages"][-1].pretty_print())

    app = build_app(model_name,max_tokens)
    """
    重新构建app的目的是方便测试消息裁剪,否则app会缓存messages,导致下面的问题回答不出来。
    """

    query = "我问过什么数学问题?"

    input_messages = messages + [HumanMessage(query)]
    output = app.invoke(
        {"messages": input_messages},
        config,
    )
    print(output["messages"][-1].pretty_print())
  • llama3.1
================================== Ai Message ==================================

你没有告诉我你的名字,我也不知道你的名字。
None
================================== Ai Message ==================================

你刚才问了一个简单的加法题:3 + 3等于几。
None
  • deepseek-r1
================================== Ai Message ==================================

<think>
嗯,用户刚才问“我叫什么名字?”,看起来是在寻求自我介绍。之前对话中提到喜欢香草冰淇淋,然后讨论了3+3等于6的问题,接着又聊到聊天有趣的事情。现在用户直接询问自己的名字,可能有点紧张或者不确定。

...

同时,要保持语气友好和积极,让用户感到被尊重,并鼓励他们继续互动。避免使用过于正式或生硬的语言,让对话显得自然流畅。
</think>

很抱歉,我无法得知你的名字。不过没关系,如果你愿意分享,我可以叫你“小助手”或者你的昵称都可以!
None
================================== Ai Message ==================================

<think>
嗯,用户之前问了“3加3等于几”,这看起来像是一个基本的算术题。我回答了说等于6,并且很自然地接着问:“谢谢”。然后用户又发来一条消息:“和我聊天有意思么?”我回答说是有意思的。

...

总结一下,我应该礼貌地告诉用户,只提到过一个数学问题,并确认是3加3等于6,这样既回答了他们的问题,又保持了对话的连贯性。
</think>

只提到过一个数学问题:“3 + 3 等于几?”
None

显然,llama3.1deepseek-r1 都可以轻松处理上述任务,只是 deepseek-r1 会把思考的过程也返回来。

流式输出

聊天机器人一点一点的吐出反馈,而不是等很长时间一下子返回结果,这样可以提升用户体验。
LangGraph 提供了 stream 方法,只要设置 stream_mode=“messages” 即可产生流式输出。 我们先封装一下 stream 方法:

def stream(human_message,thread_id,model_name,max_tokens=140,language="简体中文"):
    '''
    流式输出
    '''
    app = build_app(model_name,max_tokens)
    for chunk, _ in app.stream(
        {"messages":[HumanMessage(content=human_message)],"language":language}, 
        config={"configurable": {"thread_id": thread_id}},
        stream_mode="messages",
    ):
        if isinstance(chunk, AIMessage):
            yield chunk.content

现在让两个大模型做一首打油诗,看看谁更有才华。

def test_2(model_name):
    max_token = 140
    thread_id = "liupras"
    query = "请以葛优的语气,写一首幽默的打油诗。"
    language = "简体中文"

    print(f"---------{model_name}---------------")

    for r in stream(query,thread_id,model_name,max_tokens=max_token ,language=language):
        if r is not None:            
            print (r, end="|")

llama3.1deepseek-r1 调用 test_2 方法后,它们的输出如下:

  • llama3.1
|我|是|葛|优|,不|是|真的|,
|但|我|能|写|个|诗|,让|你|笑|得|肚|子|疼|。
|我|爱|吃|面|,|特别|是|拉|面|,
|一|碗|热|乎|乎|的|面|,|我的|心|就|甜|。

|我|不是|演|员|,但|我|能|表|演|,
|在|梦|里|,我|就是|葛|优|,|很|帅|很|酷|。
|我|能|跳|舞|,|能|唱|歌|,
|但|我|不|敢|跳|广|场|舞|,|怕|被|嘲|笑|。

|我|爱|喝|酒|,不|是|每|天|,
|但|一|杯|啤|酒|,|我的|心|就|轻|松|。
|我|不是|大|明|星|,但|我|能|闪|耀|,
|在|你的|眼|里|,我|就是|最|帅|的|。
  • deepseek-r1
|《|筷子|夹|菜|》|
|筷子|夹|菜|真|有趣|,
|夹|起|青|菜|笑|哈哈|。
|不|料|鱼|儿|也|开口|,
|张|大|嘴|把|汤|吞|下|。

总结

在这篇文章里,我们探讨了实现聊天机器人需要的主要技术:管理聊天历史、自动裁剪聊天历史、流式输出。
我的感受是用 langGraph 实现还是比较优雅直观的,后面我们会继续使用它来实现 RAG(Retrieval-Augmented Generation,检索增强生成)Agent(智能体)

代码

本文涉及的所有代码以及相关资源都已经共享,参见:

参考:

🪐祝好运🪐