一个聊天机器人刚上线时,消息列表只有三五条,一切正常。

用户聊了半小时后,响应越来越慢,token 越来越多。为了控制成本,开发者粗暴删掉前一半历史。模型速度恢复了,却忘记用户姓名,还把一次工具调用的结果和另一轮问题混在一起。

这不是简单的「记住最近几轮」问题,而是上下文治理问题。

大模型通常不会替应用保存会话历史。应用需要决定消息归谁所有、保存在哪里、每次发送哪些内容、何时裁剪、何时摘要,以及哪些敏感信息根本不该留下。

本文从一个最小多轮对话开始,逐步增加会话隔离、token 预算、工具消息完整性和摘要策略。读完后,你应该能区分对话历史、短期记忆和长期记忆,而不是把它们统称为一个 messages 列表。

模型无状态,应用拥有状态

一次模型调用只看到这次传入的上下文。

模型 会话存储 应用 用户 模型 会话存储 应用 用户 第一轮消息 保存用户消息 system + 第一轮 第一轮回答 保存 AIMessage 第二轮消息 读取历史 system + 历史 + 第二轮 带上下文的回答

模型负责生成,应用负责状态。这条边界非常重要。

如果两个用户共用同一个全局列表,就可能发生串话。如果用户刷新页面后历史全丢,说明状态只存在进程内存。如果服务扩容到多台机器,内存列表还会因为请求落到不同实例而失效。

运行前准备

下面的示例使用 Python 3.10+、OpenAI 集成和 python-dotenv。先安装依赖。

python -m pip install -U langchain langchain-openai python-dotenv

创建 .env,并将模型 ID 改为当前账号可以调用的值。

OPENAI_API_KEY=replace_with_your_openai_key
OPENAI_MODEL=gpt-5.4-mini

一个最小多轮对话

下面的程序可以直接运行。会话历史通过参数显式传入,因而不会意外借用进程级全局变量。它仍然只是内存中的演示,程序重启后历史会消失。

import os

from dotenv import load_dotenv
from langchain.messages import HumanMessage, SystemMessage
from langchain_core.messages import BaseMessage
from langchain_openai import ChatOpenAI


load_dotenv()

if not os.getenv("OPENAI_API_KEY"):
    raise RuntimeError("请设置 OPENAI_API_KEY")

model = ChatOpenAI(
    model=os.getenv("OPENAI_MODEL", "gpt-5.4-mini"),
    temperature=0,
)


def new_history() -> list[BaseMessage]:
    """为一个会话创建独立的初始消息历史。"""
    return [SystemMessage(content="你是简洁的学习助理。")]


def chat_once(history: list[BaseMessage], user_text: str) -> str:
    """完成一轮对话,并把双方的原始消息写回指定会话。"""
    history.append(HumanMessage(content=user_text))
    response = model.invoke(history)
    history.append(response)
    return str(response.content)


xiaoming_history = new_history()
print(chat_once(xiaoming_history, "我叫小明,正在学习 Python。"))
print(chat_once(xiaoming_history, "我叫什么,正在学什么?"))

这段代码有两个关键动作,调用前追加用户消息,调用后追加模型返回的原始 AIMessage。漏掉其中任何一个,下一轮上下文都会不完整。保留原始回复,而不是只保存它的文本,才能在后续接入工具调用、用量统计和供应商元数据时不丢协议字段。

xiaoming_history 只属于一个会话。真正的服务还要把这份状态写进会话存储,并在读取时校验身份与并发版本。

会话隔离比记忆算法更重要

每个会话至少需要稳定的 session_id 或 thread_id,并在服务端校验它属于当前用户或租户。

不要直接相信客户端传来的 thread ID。攻击者若能猜到其他会话标识,可能读取或污染别人的上下文。会话存储必须同时按用户、租户和 thread ID 做授权。

开发实验可以使用内存 Checkpointer。生产环境应使用数据库支持的 Checkpointer 或自己的持久化层,保证多实例、重启和并发更新下的一致性。

为什么不能无限追加历史

消息历史越长,模型输入 token 越多。成本和延迟会上升,最终还会超过上下文窗口。

更长也不一定更聪明。大量无关历史会稀释当前问题,旧指令可能与新要求冲突,模型还可能关注到早已失效的信息。

历史不断增长

输入 token 增加

成本和延迟增加

接近上下文上限

无关信息干扰

调用失败或内容被截断

回答质量下降

治理目标不是保留最多内容,而是在预算内保留完成当前任务所需的信息。

方案一, 只裁剪纯文本历史

按最近 N 轮裁剪简单,但不同轮次的长度可能差几十倍。更稳妥的是按 token 预算裁剪。下面这段只适用于没有工具调用的普通聊天历史。

from langchain.messages import AIMessage, ToolMessage, trim_messages


def contains_tool_protocol(history: list[BaseMessage]) -> bool:
    """判断历史中是否存在不能按单条消息随意切开的工具协议。"""
    return any(
        isinstance(message, ToolMessage)
        or (isinstance(message, AIMessage) and message.tool_calls)
        for message in history
    )


plain_history = xiaoming_history
if contains_tool_protocol(plain_history):
    raise ValueError("含工具调用的历史请使用下一节的完整轮次裁剪策略")


trimmed_history = trim_messages(
    plain_history,
    max_tokens=2_000,
    token_counter=model,
    strategy="last",
    include_system=True,
    start_on="human",
    allow_partial=False,
)

next_messages = [
    *trimmed_history,
    HumanMessage(content="请用一句话概括我正在学习什么。"),
]
response = model.invoke(next_messages)
print(response.content)

include_system=True 尝试保留系统消息,strategy="last" 优先保留最近内容,start_on="human" 帮助得到合理的对话起点。allow_partial=False 不截断一条普通文本消息。

token 统计可能依赖模型的计数能力,也可能只是近似值。应给实际输出预留空间,不能把整个上下文窗口都分给历史。这个示例刻意在发现 AIMessage.tool_calls 或 ToolMessage 时立即拒绝,因为工具协议不能被当成普通聊天文本处理。

裁剪时不能破坏工具调用协议

历史并不是任意消息数组。带工具调用的 AIMessage 与对应 ToolMessage 是一个协议单元。

AIMessage
tool_call id=1、2

ToolMessage
tool_call_id=1

ToolMessage
tool_call_id=2

AIMessage
最终解释

如果裁剪后只剩 ToolMessage,模型不知道它对应哪个请求。如果保留工具调用却删掉结果,多数供应商也会拒绝消息序列。

trim_messages 可以参与消息裁剪,但它不会替业务定义一轮工具链的语义边界。对含工具调用的历史,先确认每个调用都有对应结果,再以完整用户轮次为最小裁剪单位。不要简单执行 history[-10:] 后就假定序列合法。

下面的辅助函数适用于常见的 SystemMessage* + (HumanMessage ...)* 会话形状。它有意做得严格,遇到未完成的工具调用、孤立工具结果或不以用户消息开始的历史就报错,而不是把无效上下文交给模型。

from collections.abc import Callable

from langchain.messages import (
    AIMessage,
    HumanMessage,
    SystemMessage,
    ToolMessage,
)
from langchain_core.messages import BaseMessage


TokenCounter = Callable[[list[BaseMessage]], int]


def validate_tool_call_pairs(history: list[BaseMessage]) -> None:
    """确认每个工具调用都有紧随其后的、ID 唯一匹配的工具结果。"""
    index = 0
    while index < len(history):
        message = history[index]
        if isinstance(message, ToolMessage):
            raise ValueError("发现孤立的 ToolMessage")

        if not isinstance(message, AIMessage) or not message.tool_calls:
            index += 1
            continue

        expected_ids = {call.get("id") for call in message.tool_calls}
        if None in expected_ids:
            raise ValueError("工具调用缺少 ID,不能安全裁剪")

        index += 1
        returned_ids: set[str] = set()
        while index < len(history) and isinstance(history[index], ToolMessage):
            tool_result = history[index]
            if tool_result.tool_call_id not in expected_ids:
                raise ValueError("ToolMessage 与前一个 AIMessage 的调用 ID 不匹配")
            if tool_result.tool_call_id in returned_ids:
                raise ValueError("同一个 tool_call_id 不能出现两次结果")
            returned_ids.add(tool_result.tool_call_id)
            index += 1

        if returned_ids != expected_ids:
            raise ValueError("AIMessage.tool_calls 没有得到完整的 ToolMessage 结果")


def split_complete_turns(
    history: list[BaseMessage],
) -> tuple[list[BaseMessage], list[list[BaseMessage]]]:
    """拆分开头系统消息和以 HumanMessage 开始的完整用户轮次。"""
    system_messages: list[BaseMessage] = []
    index = 0
    while index < len(history) and isinstance(history[index], SystemMessage):
        system_messages.append(history[index])
        index += 1

    turns: list[list[BaseMessage]] = []
    current_turn: list[BaseMessage] = []
    for message in history[index:]:
        if isinstance(message, HumanMessage):
            if current_turn:
                turns.append(current_turn)
            current_turn = [message]
        elif not current_turn:
            raise ValueError("除开开头系统消息后,每轮历史必须从 HumanMessage 开始")
        else:
            current_turn.append(message)

    if current_turn:
        turns.append(current_turn)
    return system_messages, turns


def flatten(turns: list[list[BaseMessage]]) -> list[BaseMessage]:
    """按原有顺序展开多轮消息。"""
    return [message for turn in turns for message in turn]


def trim_complete_turns(
    history: list[BaseMessage],
    max_tokens: int,
    token_counter: TokenCounter,
) -> list[BaseMessage]:
    """在 token 预算内保留最近完整轮次,不拆分工具调用和结果。"""
    validate_tool_call_pairs(history)
    system_messages, turns = split_complete_turns(history)
    if token_counter(system_messages) > max_tokens:
        raise ValueError("系统消息自身超过 token 预算,请缩短系统提示词或提高预算")

    kept_turns: list[list[BaseMessage]] = []

    for turn in reversed(turns):
        candidate = [*system_messages, *turn, *flatten(kept_turns)]
        if token_counter(candidate) > max_tokens:
            if not kept_turns:
                raise ValueError("最近一轮自身超过 token 预算,请缩短输入或提高预算")
            break
        kept_turns.insert(0, turn)

    return [*system_messages, *flatten(kept_turns)]
tool_history = [
    SystemMessage(content="你是天气助手。"),
    HumanMessage(content="北京天气如何?"),
    AIMessage(
        content="",
        tool_calls=[
            {
                "name": "get_weather",
                "args": {"city": "北京"},
                "id": "call_weather_001",
                "type": "tool_call",
            }
        ],
    ),
    ToolMessage(
        content="北京晴,最高温度 28 摄氏度。",
        tool_call_id="call_weather_001",
        name="get_weather",
    ),
    AIMessage(content="北京晴,最高温度 28 摄氏度。"),
    HumanMessage(content="请用一句话重述刚才的天气。"),
]

trimmed_tool_history = trim_complete_turns(
    tool_history,
    max_tokens=2_000,
    token_counter=model.get_num_tokens_from_messages,
)
answer = model.invoke(trimmed_tool_history)
print(answer.content)

降低 max_tokens 时,这个策略会保留或丢弃整个用户轮次,绝不会只留下半段工具协议。协议正确不等于语义一定充足,若早期工具结果仍是当前问题的必要依据,应把结论写入摘要或权威的结构化状态,而不是强行保留全部原始消息。

方案二, 摘要早期历史

直接删除消息会丢信息。对于长对话,可以把较早内容压缩成摘要,再保留最近原始消息。

摘要应保留稳定事实、用户目标、已经做出的决定、未完成事项和必要约束。闲聊、重复表达和已经失效的中间细节可以压缩。

摘要不是无损压缩。生成摘要的模型可能遗漏或改写事实,因此高风险信息不要只存在自然语言摘要中。订单 ID、金额、审批状态和权限信息更适合结构化存储。

LangChain Agent 可以使用 SummarizationMiddleware 在达到 token 条件时自动摘要。普通聊天程序也可以在自己的会话层实现同样策略。

方案三, 把稳定事实从消息中分离

用户姓名、偏好、账户状态和任务进度不应该永远靠翻历史寻找。

短期历史负责当前对话连贯性。长期存储负责跨会话稳定信息。业务数据库负责订单、权限和交易等权威状态。

这三类数据的生命周期、修改权限和可信度不同,不应全部塞进 messages。

历史应该保存多久

没有统一答案,需要同时考虑产品体验、法律合规、成本和用户选择。

数据可能策略主要风险
当前会话消息会话期或短期保存上下文成本、敏感信息
会话摘要随会话保存摘要失真、事实遗漏
用户偏好用户可查看和删除过度画像、错误偏好
业务状态按业务系统规则保存权限与一致性
Trace按观测策略采样和保留隐私与第三方传输

给用户提供清除会话、关闭个性化或删除长期偏好的能力,比偷偷保存所有历史更可靠。

一套实用的上下文组装顺序

每次调用前,可以按以下顺序构造上下文。

验证用户与会话

读取系统规则

读取必要的结构化状态

读取历史摘要

选择最近消息

按 token 预算裁剪

校验消息序列

调用模型

保存新消息与用量

上下文组装应该是一个可测试的模块。给定同一会话状态,它应能输出可预测的消息序列,并保证不超过预算。

多轮聊天的检查清单

  • 每个会话都有稳定且经过授权的 ID。
  • 用户消息和 AI 回复都被追加,失败调用有明确处理。
  • 历史按 token 而不是只按轮数控制。
  • system 消息和工具调用协议在裁剪后仍然有效。
  • 关键业务事实存入结构化系统,不只存在摘要。
  • 会话、长期偏好和业务状态分开管理。
  • 敏感数据有保留期限、删除入口和观测脱敏策略。

真正成熟的聊天系统,不是记住一切,而是知道什么该留、什么该忘、什么必须交给权威系统保存。

对话历史只是上下文。治理它,才接近记忆。

延伸阅读

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐