Python & AI Tutorials Logo
LangChain & LangGraph

8. 对话状态与记忆

到目前为止,我们构建的每个 LLM 交互都是无状态的。无状态意味着每个请求都是独立的——模型不记得之前的对话。这对于一次性任务(如文档摘要或单个问题的问答)来说没问题。

但构建对话代理(conversational agent)则完全不同。你需要代理回忆之前讨论的内容,理解像"它"或"那个"这样的代词,并在整个对话过程中保持上下文。要做到这一点,你必须显式管理状态(state)

(这里的"状态"指的是程序记住的信息。对于对话代理来说,之前的对话历史就是状态。)

在本章中,我们将涵盖:

  • 为什么 LLM 不"记住"对话
  • 如何使用 LangChain 的消息历史工具实现对话记忆
  • 如何管理令牌预算以防止上下文溢出

8.1) 为什么 LLM 会遗忘

LLM 没有记忆

LLM 有一个关键特性:它们不记得之前对话的任何内容

当你调用 LLM API 时,模型处理你的输入并生成响应。但它不会在任何地方存储该记录。模型内部没有维护状态的记忆,没有对话历史。每次 API 调用都是完全独立的。就像每次都重新开始一样。

这是设计使然。LLM 的工作方式类似于无状态函数:你提供输入,它们产生输出,不保留任何内容。现在运行在 OpenAI 服务器上的模型没有你刚才提问的任何记录。

为什么 LLM 看起来能记住

但是等等——当你使用 ChatGPT 或 Claude 时,感觉它们记得你的对话。你可以说"告诉我关于巴黎的信息",然后跟进"人口是多少?",模型知道你仍在谈论巴黎。这是如何工作的?

原因如下:应用程序会将之前的对话历史与每条新消息一起发送。

实际发生的情况如下:

LLM应用用户LLM应用用户应用存储消息历史"告诉我关于巴黎的信息"[消息 1: "告诉我关于巴黎的信息"]"巴黎是法国的首都...""巴黎是法国的首都...""人口是多少?"[消息 1: "告诉我关于巴黎的信息"消息 2: "巴黎是法国的首都..."消息 3: "人口是多少?"]"巴黎约有 210 万人口...""巴黎约有 210 万人口..."

LLM 并不"记得"你之前问过关于巴黎的问题——它只是知道,因为应用程序将之前的对话历史与新消息一起发送了。最终,是应用程序在管理状态,而不是模型。

为什么"状态"必须在你的应用程序中管理,而不是在模型中

将 LLM 视为纯函数:给定输入,它产生输出。除了你提供的消息之外,LLM 不管理任何内部状态。这是设计使然。

对于对话代理,这意味着状态必须在你的应用程序中管理

状态——对话历史——存在于你的应用程序代码中,而不是在模型中。这意味着你负责:

  • 存储对话历史
  • 发送相关历史与每个新请求
  • 管理历史的大小(在 8.3 节中介绍)

在 8.2 节中,我们将使用 LangChain 的消息历史工具来实现这一点。

如果不管理状态会发生什么?

如果不管理状态,你的代理(你的应用程序)无法维持连贯的对话。以下是最常见的失败情况:

1. 无法记住之前的对话

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
 
llm = ChatOpenAI(model="gpt-4o-mini")
 
# 第一个问题
response1 = llm.invoke([HumanMessage(content="我的名字是 Alice")])
print(response1.content)  # 输出: 很高兴认识你,Alice!
 
# 第二个问题(没有发送历史)
response2 = llm.invoke([HumanMessage(content="我的名字是什么?")])
print(response2.content)  # 输出: 我不知道你的名字...

模型不知道你说过你的名字是 Alice,因为我们在第二次调用中没有发送该信息。

2. 无法判断代词指代什么

python
# 用户询问一个主题
response1 = llm.invoke([HumanMessage(content="告诉我关于 Python 的信息")])
print(response1.content)  
# 输出: Python 是一种以可读性著称的高级编程语言...
 
# 用户用代词跟进
response2 = llm.invoke([HumanMessage(content="它的主要特性是什么?")])
print(response2.content)  
# 输出: 我很乐意帮忙!你能具体说明你在问什么吗?

没有之前的消息,模型无法判断"它"指的是什么。

实际影响:

想象构建一个没有状态管理的客户支持代理:

用户: "我的订单 #12345 有问题"
代理: "很抱歉听到这个消息。有什么问题?"
用户: "配送地址错了"
代理: "我可以帮你解决。你能提供你的订单号吗?"
用户: "我刚告诉你了..."

这种体验让用户感到沮丧并失去信任。状态管理对于对话代理来说不是可选的——它对于创建连贯、有用的交互是必不可少的

在 8.2 节中,我们将使用 LangChain 的消息历史工具实现状态管理,并为第 3 章的 CLI 聊天添加对话记忆。

8.2) 管理对话状态

现在我们理解了为什么状态管理至关重要,让我们学习如何实现它。我们将使用 LangChain 的内置消息历史工具来管理对话历史。最后,我们将应用所学知识为第 3 章的 CLI 聊天添加状态管理。

理解消息类型:HumanMessage、AIMessage 和 SystemMessage

在学习如何管理对话状态之前,你需要了解对话状态中使用的消息类型。LangChain 使用三种消息类型来表示对话:HumanMessage(用户输入)、AIMessage(模型响应)和 SystemMessage(指令)。每条消息都有一个角色(role)(消息类型)和内容(content)(实际文本)。

python
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
 
# SystemMessage: 模型行为的指令
system_msg = SystemMessage(content="你是一个专门研究 Python 编程的有帮助的助手。")
 
# HumanMessage: 用户输入
user_msg = HumanMessage(content="如何在 Python 中读取文件?")
 
# AIMessage: 模型的响应
# (实际上,LangChain 会将模型的响应包装在这个对象中 - 这里仅用于说明)
ai_msg = AIMessage(content="你可以使用带有上下文管理器的 `open()` 函数...")

为什么要分离消息类型?

为了让模型有效理解对话历史,它需要知道每条消息的目的和是谁说的。三种消息类型服务于不同的目的:

  • SystemMessage: 定义模型应如何行为的指令(例如,"简洁一点","你是一个 Python 导师")
  • HumanMessage: 用户说的话
  • AIMessage: 模型之前的响应

这种结构允许模型区分指令、用户问题和它自己过去的答案——这对于维持连贯的多轮对话至关重要。

手动构建对话历史

现在我们理解了三种消息类型,让我们看看如何手动构建对话历史:

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
 
llm = ChatOpenAI(model="gpt-4o-mini")
 
# 构建对话历史
# SystemMessage 设置指令(在开始时一次)
# 然后: 用户输入 → AI 响应 → 用户输入(自然对话流程)
messages = [
    SystemMessage(content="你是一个简洁的 Python 导师。"),
    HumanMessage(content="什么是列表推导式?"),
    AIMessage(content="列表推导式是创建列表的简洁方式: [x*2 for x in range(5)]"),
    HumanMessage(content="你能给我展示一个更复杂的例子吗?")
]
 
# 发送完整历史与新问题
response = llm.invoke(messages)
print(response.content)

输出:

当然!这是一个过滤和转换的列表推导式:
[x**2 for x in range(10) if x % 2 == 0]
这会产生:
[0, 4, 16, 36, 64]

模型理解"一个更复杂的例子"指的是更复杂的列表推导式示例,因为我们发送了完整的对话历史。

使用 InMemoryChatMessageHistory

随着对话的增长,手动构建消息列表变得繁琐。LangChain 的 InMemoryChatMessageHistory 通过提供添加消息和检索完整历史的方法来简化这一过程。

关键方法:

  • add_message(message): 添加单条消息(HumanMessage、AIMessage、SystemMessage)
  • add_messages(messages): 一次添加多条消息
  • messages: 返回完整消息列表的属性
  • clear(): 删除所有消息(用于重新开始)

示例:

python
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
 
# 创建消息历史存储
history = InMemoryChatMessageHistory()
 
# 一次添加多条消息
history.add_messages([
    SystemMessage(content="你是一个有帮助的 Python 导师。"),
    HumanMessage(content="Python 中的装饰器是什么?")
])
 
# 逐条添加消息
history.add_message(AIMessage(content="装饰器是修改另一个函数行为的函数..."))
history.add_message(HumanMessage(content="你能展示一个例子吗?"))
 
# 检索所有消息
messages = history.messages

实际示例:使用 InMemoryChatMessageHistory 进行状态管理:

python
from langchain_openai import ChatOpenAI
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.messages import SystemMessage, HumanMessage
 
llm = ChatOpenAI(model="gpt-4o-mini")
history = InMemoryChatMessageHistory()
 
# 设置系统消息并添加到历史(在开始时执行一次)
system_msg = SystemMessage(content="你是一个有帮助的 Python 导师。")
history.add_message(system_msg)
 
# 聊天函数
def chat(user_input):
    """处理用户输入,发送到 LLM,并自动管理对话历史"""
    # 将用户输入添加到历史
    history.add_message(HumanMessage(content=user_input))
    
    # 发送用户输入以及之前的对话历史
    response = llm.invoke(history.messages)
    
    # 将模型响应添加到历史
    history.add_message(response)
    
    return response.content
 
# 模拟对话
print(chat("什么是 lambda 函数?"))
print(chat("给我展示一个例子"))  # 模型记住上下文
print(chat("它与常规函数有什么区别?"))  # 仍然记得

输出:

lambda 函数是用 lambda 关键字定义的匿名函数...
 
这是一个例子: square = lambda x: x**2
你可以这样使用它: square(5) # 返回 25
 
lambda 函数限于单个表达式,而常规函数...

chat() 函数自动处理状态管理:它将每个用户请求与之前的对话历史一起发送到 LLM,并将请求和响应都添加回历史。只需调用 chat() 就能维持对话状态,无需额外管理。

重构第 3 章:为你的 CLI 聊天添加记忆

让我们将第 3 章的流式 CLI 聊天添加对话记忆。这是原始的无状态版本:

python
# chapter3_cli.py (原始 - 无状态)
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
 
llm = ChatOpenAI(model="gpt-4o-mini")
 
def chat_loop():
    print("聊天已开始。输入 'quit' 退出。\n")
    while True:
        user_input = input("你: ")
        if user_input.lower() == "quit":
            break
        
        # 无状态 - 没有历史
        response = llm.stream([HumanMessage(content=user_input)])
        print("AI: ", end="", flush=True)
        for chunk in response:
            print(chunk.content, end="", flush=True)
        print("\n")
 
if __name__ == "__main__":
    chat_loop()

带记忆的重构版本:

python
# chapter8_cli.py (带记忆)
from langchain_openai import ChatOpenAI
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
 
llm = ChatOpenAI(model="gpt-4o-mini")
 
def chat_loop():
    print("聊天已开始。输入 'quit' 退出。\n")
    
    # 创建历史并添加系统消息
    history = InMemoryChatMessageHistory()
    history.add_message(SystemMessage(content="你是一个有帮助的助手。"))
    
    while True:
        user_input = input("你: ")
        if user_input.lower() == "quit":
            break
        
        # 将用户消息添加到历史
        history.add_message(HumanMessage(content=user_input))
        
        # 流式响应
        print("AI: ", end="", flush=True)
        full_response = ""
        for chunk in llm.stream(history.messages):
            print(chunk.content, end="", flush=True)
            full_response += chunk.content
        print("\n")
        
        # 将 AI 响应添加到历史
        history.add_message(AIMessage(content=full_response))
 
if __name__ == "__main__":
    chat_loop()

改变了什么:

  1. 添加了历史存储: 在 chat_loop() 内创建了 InMemoryChatMessageHistory() 实例
  2. 系统消息: 在开始时一次性添加到历史
  3. 发送用户输入与历史: llm.stream(history.messages) 包含之前的对话
  4. 跟踪对话: 将用户输入和 LLM 响应都添加到历史

测试重构后的聊天:

聊天已开始。输入 'quit' 退出。
 
你: 我的名字是 Alice
AI: 很高兴认识你,Alice!今天我能帮你什么?
 
你: 我的名字是什么?
AI: 你的名字是 Alice。
 
你: 我刚才问了你什么?
AI: 你问我你的名字是什么。
 
你: quit

模型现在在整个对话中保持上下文。它记住你的名字、之前的问题,并可以引用对话的早期部分。

持久化存储:超越内存选项

InMemoryChatMessageHistory 对于本地开发很方便,但迁移到生产环境需要用保证持久化的存储解决方案替换它。

InMemory 的技术限制:

  • 易失性 RAM: 当服务器进程终止或重启时,存储在内存中的所有对话历史会立即删除。更新或错误恢复会导致用户上下文完全丢失。
  • 无法水平扩展: 随着服务扩展到多个服务器实例,每个服务器维护自己的隔离内存。连接到不同服务器的用户无法共享对话历史。
  • 资源效率低: 将所有对话历史存储在 RAM 中占用大量内存,随着并发用户增加会威胁系统稳定性。

专业替代方案:

  • PostgresChatMessageHistory(推荐): 最稳健且广泛采用的选择。使用 PostgreSQL 进行永久存储,擅长复杂查询和数据分析。
  • SQLChatMessageHistory: 利用现有的 SQL 数据库如 MySQL。允许你使用当前基础设施而无需更改。
  • RedisChatMessageHistory: 适用于响应速度至关重要的服务。基于内存并具有持久化选项,专门用于处理高流量。

"存储改变,代码保持不变"

LangChain 在所有存储后端提供统一接口。你在 InMemoryChatMessageHistory 中使用的相同方法——如 add_message()add_messages()——在其他存储选项中完全相同。这意味着你的业务逻辑(对话处理代码)在切换存储时无需更改。

python
# [开发] 本地内存
# from langchain_core.chat_history import InMemoryChatMessageHistory
# history = InMemoryChatMessageHistory()
 
# [生产] PostgreSQL 持久化存储
import psycopg
from langchain_postgres import PostgresChatMessageHistory
 
# 创建 PostgreSQL 连接
sync_connection = psycopg.connect(
    "postgresql://user:password@10.1.1.100:5432/agent_db",
    autocommit=True,
)
 
# 指定数据库连接和会话 ID
# session_id: 用于识别对话的唯一键
#   - 按用户管理: session_id = user_id (每个用户一个对话)
#   - 按会话管理: session_id = uuid (每个对话新 ID)
history = PostgresChatMessageHistory(
    table_name="chat_history",
    session_id="user_123",
    sync_connection=sync_connection
)
 
# --- 无论存储类型如何,接口都相同 ---
history.add_message(HumanMessage(content="显示我之前的订单。"))
print(history.messages)

重要提示: 本指南使用 InMemory 以便快速推进,但生产部署必须切换到持久化存储,如 PostgresChatMessageHistory

8.3) 管理对话长度

我们在上一节中实现的对话记忆功能有一个重要问题:它只向历史添加消息。这意味着历史不断增长,这会产生两个主要问题:

  1. 成本: 每次请求都会发送完整历史,因此随着历史增长,每次请求的成本持续增加
  2. 上下文窗口限制: 模型在单个请求中可以处理的最大输入大小有限(例如,GPT-5 为 400K 令牌)。当对话历史超过此限制时,模型无法正确处理请求。

最简单的解决方案之一是滑动窗口模式。

滑动窗口模式(仅保留最后 N 条消息)

滑动窗口模式通过仅保留最近的 N 条消息来解决上述两个问题。它通过丢弃旧消息来管理历史大小,提供以下好处:

  1. 成本控制: 通过无论对话长度如何都将历史保持在一定大小以下,防止每次请求成本无限增长
  2. 无溢出: 将输入大小保持在模型的最大限制内

概念图:

消息 1

消息 2

消息 3

消息 4

消息 5

消息 6

窗口大小 = 4

窗口大小为 4 时,我们只保留最近的 4 条消息(3、4、5、6)并丢弃较旧的消息(1、2)。当新消息(7)到达时,窗口向最新消息移动,丢弃最旧的消息(3)并保留消息 4、5、6、7。

滑动窗口的权衡:

  • 优点: 限制历史大小以保持成本恒定并防止上下文窗口溢出
  • 缺点: 超出窗口大小的消息被丢弃,因此模型无法引用它们

这种权衡可能会有问题。解决方案是使用滑动窗口保留最近的对话,同时在需要时从单独的存储中检索必要的过去信息。这可以使用 RAG(检索增强生成)来实现,我们将在第 9 章中介绍。

窗口大小单位:消息计数 vs 令牌计数

上图显示了按消息计数设置窗口大小的示例。然而,在生产环境中,基于令牌的窗口大小更常用,因为消息大小各不相同:

基于消息计数的修剪:

按消息计数限制窗口大小(例如,仅保留最后 20 条消息)。

  • 特点: 固定消息计数,但总令牌计数仍可能变化
  • 适用场景: 消息大小受控(短信、字符限制聊天)
  • 风险: 一条长消息仍可能超过上下文窗口

基于令牌计数的修剪(生产推荐):

按令牌计数限制窗口大小,令牌是 LLM 处理的输入单位(例如,仅保留最后 5,000 个令牌)。

  • 特点: 无论消息长度如何都不会超过上下文窗口
  • 适用场景: 消息大小各不相同

实现基于令牌的修剪:trim_messages()

LangChain 提供了一个 trim_messages() 实用程序,实现了基于令牌的滑动窗口模式。

trim_messages() 的工作原理:

此函数接受完整消息列表和最大令牌计数,仅返回适合令牌限制内的最近消息。

关键参数:

  • messages: 要修剪的消息列表
  • max_tokens: 要维护的最大令牌计数
  • token_counter: 计算每条消息令牌计数的函数(使用模型的分词器返回消息令牌计数)
  • include_system: 是否始终保留 SystemMessage(通常为 True)

设置令牌计数器:

python
import tiktoken
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage
from langchain_core.messages.utils import trim_messages
 
# 获取你的模型的分词器(不同模型系列使用不同的分词器)
# 提供模型名称以获取适当的分词器
# Claude 模型: 使用 Anthropic 的分词器(tiktoken 是 OpenAI 特定的)
enc = tiktoken.encoding_for_model("gpt-4o")
 
def token_counter(msg: BaseMessage) -> int:
    """计算单条消息中的令牌数。"""
    return len(enc.encode(msg.content or ""))
 
# 创建对话历史
messages = [
    SystemMessage(content="你是一个有帮助的助手。"),
    HumanMessage(content="嗨!"),
    AIMessage(content="你好!我能帮你什么?"),
    HumanMessage(content="2+2 是多少?"),
    AIMessage(content="2+2 等于 4。"),
    HumanMessage(content="3+3 是多少?"),
    AIMessage(content="3+3 等于 6。"),
    HumanMessage(content="4+4 是多少?"),
]
 
# 仅保留最大令牌计数内的消息
trimmed = trim_messages(
    messages,
    max_tokens=30,
    token_counter=token_counter,
    include_system=True,
)
 
print(f"原始: {len(messages)} 条消息")
print(f"修剪后: {len(trimmed)} 条消息")
for m in trimmed:
    print(f"{type(m).__name__}: {m.content}")

注意: 保留的消息数量取决于 max_tokens 值和每条消息的实际令牌计数。在上面的示例中,max_tokens=30 是一个非常小的值,用于测试目的。在生产中,你应该考虑平均消息大小和期望的对话范围来设置适当的值。

示例:将修剪应用于聊天函数

python
import tiktoken
from langchain_openai import ChatOpenAI
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.messages import SystemMessage, HumanMessage, BaseMessage
from langchain_core.messages.utils import trim_messages
 
llm = ChatOpenAI(model="gpt-4o-mini")
history = InMemoryChatMessageHistory()
enc = tiktoken.encoding_for_model("gpt-4o-mini")
 
def token_counter(msg: BaseMessage) -> int:
    """计算单条消息中的令牌数。"""
    return len(enc.encode(msg.content or ""))
 
def chat_with_trimming(user_input: str, max_tokens: int = 1000) -> str:
    """带自动历史修剪的聊天。"""
    history.add_message(HumanMessage(content=user_input))
    
    system_msg = SystemMessage(content="你是一个有帮助的助手。")
    all_messages = [system_msg] + history.messages
    
    # 修剪到最大令牌计数
    trimmed_messages = trim_messages(
        all_messages,
        max_tokens=max_tokens,
        token_counter=token_counter,
        include_system=True,
    )
    
    response = llm.invoke(trimmed_messages)
    history.add_message(response)
    
    return response.content
 
# 使用示例
print(chat_with_trimming("我正在计划去日本旅行"))
print(chat_with_trimming("我应该在东京参观什么?"))
print(chat_with_trimming("我应该在那里待几天?"))
# 即使历史增长,也只有最大令牌计数内的最近对话被发送到 LLM

下一步:对话状态管理的局限性和解决方案(第 9 章:RAG)

向模型提供对话历史有助于维持对话上下文。然而,在某些情况下仅此还不够。例如:

  • 当你需要在公司文档或手册中查找信息时
  • 当你需要引用已被推出滑动窗口的旧对话历史时

这就是需要 RAG(检索增强生成)的地方。RAG 的工作原理如下:

  1. 存储: 将信息存储在向量数据库中以进行语义搜索
  2. 检索: 查询与你要查找的内容含义相似的信息

如果使用 RAG 来补充滑动窗口模式的局限性:

  • 滑动窗口: 保留最后 20 条消息(最近的上下文)
  • RAG: 从被推出窗口的消息中搜索和检索相关内容

RAG 不仅仅是记住最近的对话,还允许你创建一个长期记忆系统

RAG 使代理能够通过外部知识利用过去对话检索来利用更广泛的知识和更长的上下文。

第 9 章将详细介绍如何实现 RAG。


本章总结:

在本章中,你学到了:

  1. 为什么 LLM 会遗忘: 模型是无状态的——记忆是通过重新发送对话历史创造的假象
  2. 消息类型: SystemMessage(指令)、HumanMessage(用户输入)、AIMessage(模型响应)
  3. 状态管理: 使用 InMemoryChatMessageHistory 管理对话历史
  4. 对话长度管理: 为什么无限历史会导致成本和上下文窗口问题
  5. 滑动窗口: 一种仅保留最近消息以防止历史无限增长的模式
  6. 基于令牌的修剪: 使用 trim_messages() 和令牌计数实现滑动窗口