LangGraph 系列 · 第 3 讲:管理短期记忆,防止上下文"爆窗"

0 阅读16分钟

📖 1. 导读

💡本文是 LangGraph 系列的第 3 讲,承接第 2 讲的记忆机制,聚焦一个更实际的问题:开启短期记忆之后,如何在长对话里不"撑爆" LLM 的上下文窗口?

当我们为智能体打开线程级短期记忆(如InMemorySaver等)后,对话会越来越长。 如果不做任何控制,所有消息都会被一股脑塞给模型,最终要么超出上下文窗口、要么成本失控、要么回复开始变得迟钝和跑题

这一讲,我们不再纠结"能不能记住",而是重点回答:记多少、记什么、怎么记

1.1 本讲你将学会

  • ✅ 理解短期记忆与上下文窗口的关系,以及不做控制会有什么副作用
  • ✅ 掌握 3 类典型策略:
  • ✅ 学会结合**检查点(checkpointer)**管理消息历史:按 thread 分片、按时间回溯
  • ✅ 能为自己项目设计一套自定义短期记忆策略组合,在"记得住"和"不爆窗"之间找到平衡

1.2 前置要求

  • 📚 已学习本系列第 1、2 讲,对以下概念有基本印象:
  • 🛠️ 已准备好:

🔍 2. 短期记忆 vs 上下文窗口:问题到底出在哪?

2.1 上下文窗口的硬限制

无论是 DeepSeek、OpenAI 还是其他模型,都有一个最大上下文长度(context window),可以简单理解为:

⚠️"单次请求中,提示词 + 历史对话 + 工具输出 + 系统消息的总 tokens 不能超过某个上限。"

当我们把所有历史消息都塞进来时:

  • 📈 对话轮次一多 → tokens 急速上涨
  • ⚠️ 一旦接近/超过窗口上限:

2.2 典型"爆窗"症状

  • ❌ 模型突然说:"我不记得前面说了什么",或者答非所问
  • 🐌 相同问题变慢许多,日志里看到prompt_tokens飙升
  • 💰 高并发场景下,费用异常高、延迟变长

2.3 设计短期记忆的两个核心原则

  1. 尽量保留"必要语境",而不是"所有历史"
  2. 把"信息量大但可压缩"的内容尽早做摘要,减少体积

📋 3. 管理短期记忆的 5 种常见策略

策略核心动作适用场景优点缺点
修剪消息(Trimming)只保留最近 N 条 / N 轮消息纯闲聊、对"近期上下文"敏感的场景实现简单、可靠过早截断可能丢掉还会用到的信息
删除无用消息(Deleting)移除系统日志、Debug 信息等有大量工具 / 日志输出的图迅速降噪、减小体积需要提前定义"什么是无用"
消息摘要(Summarization)把一段历史压缩成简短 summary长对话、任务型对话在保留关键信息的前提下大幅减小体积会有摘要损失,依赖摘要模型质量
检查点管理(Checkpointing)按时间/步骤切分消息历史复杂工作流、需要"时间回溯"方便定位某一步之前的状态本身不减体积,需要结合其他策略
自定义策略(Custom Rules)按角色/标签/优先级选择性保留有明确业务规则的应用可高度贴合业务需求需要更多设计和测试

下面我们用代码示例,展示如何在 LangGraph 里把这些策略"落地"。

🛠️ 4. 实战一:修剪消息(最近 N 条)

一种决定何时截断消息的方法是统计消息历史记录中的词元数量,并在接近该限制时进行截断。

最简单也最常用的做法只保留最近 N 条或最近 N 轮对话。 在本节中,我们直接使用 LangChain 官方提供的trim_messages工具函数来完成修剪。

代码实现

以下代码可以直接复制运行。

# 导入官方提供的消息修剪工具和 token 近似统计函数
from langchain_core.messages.utils import (
    trim_messages,                  # 用于裁剪对话消息列表
    count_tokens_approximately      # 粗略统计一段消息的 token 数
)

from langchain.chat_models import init_chat_model
from langgraph.graph import StateGraph, START, MessagesState
from langgraph.checkpoint.memory import InMemorySaver

from dotenv import load_dotenv
load_dotenv()

# 初始化模型(这里使用 DeepSeek,你也可以换成 OpenAI 等)
model = init_chat_model(model="deepseek-chat", model_provider="deepseek")

def call_model(state: MessagesState):
    """
    在调用大模型前,对 messages 做一次"按 token 数"修剪:
    - 只保留末尾一段对话(strategy="last")
    - 控制总 token 数不超过 max_tokens
    - 用 start_on / end_on 保证不会把一轮对话"截断在中间"

    为了方便观察效果,这里增加了一些调试输出:
    - 修剪前后消息条数
    - 修剪前后估算 token 数
    """
    original_messages = state["messages"]
    original_token_count = count_tokens_approximately(original_messages)

    print("\n" + "=" * 60)
    print(f"[DEBUG] 修剪前 - 消息条数: {len(original_messages)}")
    print(f"[DEBUG] 修剪前 - 估算 token 数: {original_token_count}")

    messages = trim_messages(
        original_messages,                  # 当前线程里积累的全部消息
        strategy="last",                    # 从末尾往前截,保留最近一段
        token_counter=count_tokens_approximately,  # 用于估算 token 数
        max_tokens=128,                     # 喂给模型的最大 token 预算
        start_on="human",                   # 起点:从最近一条 human 消息往前找
        end_on=("human", "tool"),           # 终点:在人类 / 工具消息边界处截断
    )

    trimmed_token_count = count_tokens_approximately(messages)
    print(f"[DEBUG] 修剪后 - 消息条数: {len(messages)}")
    print(f"[DEBUG] 修剪后 - 估算 token 数: {trimmed_token_count}")
    print("=" * 60 + "\n")

    # 使用"修剪后"的 messages 调用模型
    response = model.invoke(messages)

    # 注意:这里只是控制这一轮送进模型的上下文,
    # 返回时仍然把完整历史 + 新回复一起写回 state
    return {"messages": [response]}


# 使用 InMemorySaver 作为检查点(短期记忆),方便多轮对话续接
checkpointer = InMemorySaver()

# 构建最简单的单节点图:START -> call_model -> 结束
builder = StateGraph(MessagesState)
builder.add_node(call_model)
builder.add_edge(START, "call_model")

# 编译图时挂上 checkpointer,这样就可以用 thread_id 记住一条对话线
graph = builder.compile(checkpointer=checkpointer)

# 配置一个线程 ID,用于标识"同一条对话"
config = {"configurable": {"thread_id": "1"}}

# 连续发起几轮对话,请求里 messages 可以是字符串或消息列表
graph.invoke({"messages": "hi, my name is bob"}, config)
graph.invoke({"messages": "write a short poem about cats"}, config)
graph.invoke({"messages": "now do the same but for dogs"}, config)

# 最后问一句,"我叫什么名字?",验证修剪后记忆仍然有效
final_response = graph.invoke({"messages": "what's my name?"}, config)

# 打印最后一条 AI 回复
final_response["messages"][-1].pretty_print()

运行输出

================================== Ai Message ==================================

Your name is Bob, as you mentioned when you first introduced yourself.

💡 要点提示

  • trim_messages是 LangChain / LangGraph 官方推荐的工具,用来在不打乱对话轮次的前提下做安全修剪。
  • max_tokens控制的是"这一轮真正送进模型的上下文长度",而不是整个线程里 state 能存多少历史。
  • 你可以在此基础上,再结合后面的小节(过滤无用消息、摘要等)做更精细的控制。

🗑️ 5. 实战二:删除无用消息

在开启短期记忆后,消息会越积越多,其中有不少是对最终回复帮助不大的"噪声",例如冗长的工具日志、调试信息或阶段性已过时的上下文。

本节我们从两个层次来处理这类无用消息:

  1. 在调用 LLM 前做按角色 / 标签的过滤
  2. 在确有需要时从 State 中真正删除这些消息

既保证模型看到的上下文"干净"、又避免状态无休止膨胀。

5.1 按角色 / 标签过滤

许多应用里,state["messages"]里不仅有用户/助手对话,还有:

  • 🔧 工具调用的详细中间结果
  • 🐛 Debug 日志或系统诊断信息
  • 📝 与业务无关的提示

这些内容对最终回复帮助不大,却大量占用上下文,可以考虑在进入 LLM 之前过滤掉。

代码实现

from typing import List

from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, ToolMessage
from langchain.chat_models import init_chat_model
from langgraph.graph import StateGraph, START, MessagesState
from langgraph.checkpoint.memory import InMemorySaver

from dotenv import load_dotenv

load_dotenv()

# 初始化模型(这里使用 DeepSeek,你也可以换成 OpenAI 等)
model = init_chat_model(model="deepseek-chat", model_provider="deepseek")

def filter_messages_for_llm(messages: List[BaseMessage]) -> List[BaseMessage]:
    """
    示例策略:
    - 保留 system / human / ai
    - 丢弃 tool / function 等中间消息
    - (可按需扩展:只保留最近 N 次工具结果等)
    """
    allowed_roles = {"system", "user", "assistant", "human", "ai"}
    filtered: List[BaseMessage] = []
    for m in messages:
        role = getattr(m, "role", getattr(m, "type", None))
        if role in allowed_roles:
            filtered.append(m)
    return filtered


def chatbot_with_filter(state: MessagesState):
    """
    在调用 LLM 之前,对 state["messages"] 做一层"按角色过滤":
    - 只保留 system / human / ai 等对 LLM 有用的消息
    - 丢弃 tool / function 等中间消息,避免把工具中间态传给 LLM
    """
    original_messages = state["messages"]

    usable = filter_messages_for_llm(original_messages)

    print("\n" + "=" * 60)
    print(f"[DEBUG] 原始消息条数: {len(original_messages)}")
    print(f"[DEBUG] 过滤后可送入 LLM 的消息条数: {len(usable)}")
    print("============================================================\n")

    # 用"过滤后"的消息列表调用模型
    response = model.invoke(usable)

    # 按 LangGraph 约定,只返回新消息列表,框架会自动把它 append 到历史中
    return {"messages": [response]}


# 使用 InMemorySaver 作为检查点(短期记忆),方便多轮对话续接
checkpointer = InMemorySaver()

# 构建最简单的单节点图:START -> chatbot_with_filter -> 结束
builder = StateGraph(MessagesState)
builder.add_node("chatbot_with_filter", chatbot_with_filter)
builder.add_edge(START, "chatbot_with_filter")

# 编译图时挂上 checkpointer,这样就可以用 thread_id 记住一条对话线
graph = builder.compile(checkpointer=checkpointer)

# 配置一个线程 ID,用于标识"同一条对话"
config = {"configurable": {"thread_id": "filter-demo-1"}}

if __name__ == "__main__":
    # 先单独验证 filter_messages_for_llm 对 ToolMessage 的过滤效果
    debug_msgs = [
        HumanMessage(content="hi"),
        AIMessage(content="I'm a bot"),
        ToolMessage(content="internal search result", tool_call_id="call-1", name="search_tool"),
    ]
    print("=== 手动构造消息列表,用于验证过滤逻辑 ===")
    print("原始消息条数:", len(debug_msgs), [m.__class__.__name__ for m in debug_msgs])
    filtered = filter_messages_for_llm(debug_msgs)
    print("过滤后消息条数:", len(filtered), [m.__class__.__name__ for m in filtered])
    print("============================================\n")

    # 再跑一遍基于 LangGraph 的对话示例(这里只是简单示例,不包含真正的 tool 消息)
    graph.invoke({"messages": "hi, I'm Alice, I love cats and dogs."}, config)
    graph.invoke({"messages": "Please write a short poem about my pets."}, config)

    final_response = graph.invoke({"messages": "Based on our chat, what do I like?"}, config)

    # 打印最后一条 AI 回复
    final_response["messages"][-1].pretty_print()

运行输出

=== 手动构造消息列表,用于验证过滤逻辑 ===
原始消息条数: 3 ['HumanMessage', 'AIMessage', 'ToolMessage']
过滤后消息条数: 2 ['HumanMessage', 'AIMessage']
============================================

💡 扩展建议

在真实项目中,你可以进一步:

  • 对"工具输出类"消息设置单独的上限(例如只保留最近 3 次工具结果)
  • 对 debug / 日志类消息统一打标签,方便集中过滤

5.2 从 State 中真正删除消息(RemoveMessage)

前面的示例只是在送进 LLM 之前做过滤state["messages"]里的完整历史其实都还在。

在下面这些场景里,我们会希望某些消息从整条对话的状态中真正消失

  • 🔒隐私 / 敏感信息被误发:用户发了手机号、银行卡号,要求"从系统里删掉"
  • 📦阶段性归档:一个阶段已经结束,只保留摘要,不再保留早期的细碎聊天记录
  • 📊中间结果 / 日志太大:工具返回了超长 JSON、调试日志,以后也不会再用到,只想保留少量关键信息
  • 💾控制长期运行 Agent 的状态体积:对话持续很久、消息持续累积,希望定期"瘦身"

这时就可以使用 LangGraph 提供的RemoveMessage,从MessagesState中删除指定消息。 要让RemoveMessage生效,需要这个 state 键使用带有add_messagesreducer 的类型,比如MessagesState

代码示例:删除最早的一批消息

from langchain_core.messages import RemoveMessage
from langchain.chat_models import init_chat_model
from langgraph.graph import StateGraph, START, MessagesState
from langgraph.checkpoint.memory import InMemorySaver

from dotenv import load_dotenv

load_dotenv()

# 初始化模型
model = init_chat_model(model="deepseek-chat", model_provider="deepseek")

def delete_early_messages(state: MessagesState):
    """
    示例:当消息条数太多时,从 State 中删除最早的一批消息
    """
    messages = state["messages"]

    # 如果消息不多,就不删
    if len(messages) <= 40:
        return {}

    # 为了方便快速验证,这里先删除最早的 3 条(你可以改成自己的规则)
    to_remove = [RemoveMessage(id=m.id) for m in messages[:3]]
    return {"messages": to_remove}

def call_model(state: MessagesState):
    """
    在删除节点之后被调用,直接使用当前 state["messages"] 调用模型
    """
    response = model.invoke(state["messages"])
    # 按 LangGraph 约定,只返回新消息列表
    return {"messages": [response]}

checkpointer = InMemorySaver()
builder = StateGraph(MessagesState)
builder.add_node("delete_early_messages", delete_early_messages)
builder.add_node("call_model", call_model)

# 使用官方推荐的 add_sequence 方式串联节点:
# START -> delete_early_messages -> call_model
builder.add_edge(START, "delete_early_messages")
builder.add_sequence(["delete_early_messages", "call_model"])

graph = builder.compile(checkpointer=checkpointer)

代码示例:清空当前对话(重置线程)

from langchain_core.messages import RemoveMessage
from langgraph.graph.message import REMOVE_ALL_MESSAGES

def reset_conversation(state: MessagesState):
    """
    清空当前线程中的所有消息,相当于"完全忘记这段对话"
    """
    return {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES)]}

💡 小结

  • 只过滤、不删 State:适合"这次调用 LLM 不需要这些消息,但以后可能还用得到"
  • State 真删:适合"这部分内容今后都不再需要(或必须被遗忘)",还能顺便控制内存与存储体积

📝 6. 实战三:对早期消息做摘要(Summary + 最近若干轮)

经典组合模式

💡"summary + 最近 N 轮原始消息"

  • 把很早的一大段对话总结成一条 summary system message
  • 再加上最近几轮完整对话,提供细节语境

代码实现

from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.messages import BaseMessage
from typing import List

from langchain.chat_models import init_chat_model
from langgraph.graph import StateGraph, START, MessagesState
from langgraph.checkpoint.memory import InMemorySaver

from dotenv import load_dotenv

# 环境配置
load_dotenv()

# 初始化模型
model = init_chat_model(model="deepseek-chat", model_provider="deepseek")

def summarize_history_for_model(
    messages: List[BaseMessage],
    keep_last_rounds: int = 4,
) -> List[BaseMessage]:
    """
    对早期消息做摘要,只保留最近几轮的原始对话
    
    策略:
    - 如果消息总数 <= keep_last_rounds,直接返回(不需要摘要)
    - 否则,把早期消息用 LLM 总结成一条 SystemMessage
    - 最终返回:[摘要 SystemMessage] + [最近 N 轮原始对话]
    """
    if len(messages) <= keep_last_rounds:
        return messages

    # 1) 切分"要总结的早期部分"和"保留的近期部分"
    early_messages = messages[:-keep_last_rounds]
    recent_messages = messages[-keep_last_rounds:]

    # 2) 把早期对话内容整理为一个字符串(这里只是示意)
    history_text = "\n".join([str(m.content) for m in early_messages])

    # 3) 让模型生成一条 summary(你也可以用更便宜的模型)
    summary_prompt = HumanMessage(
        content=(
            "请用简洁中文总结下面这段对话历史,"
            "只保留对后续对话真正有用的事实和用户偏好:\n\n"
            f"{history_text}"
        )
    )
    summary_msg = model.invoke([summary_prompt])

    # 4) 用 summary 替代整段早期历史
    summary_system = SystemMessage(
        content=f"对话历史摘要(后续回答时可参考,但不要逐字引用):\n{summary_msg.content}"
    )
    return [summary_system] + recent_messages

def chatbot_with_summary(state: MessagesState):
    """
    在调用 LLM 之前,对 state["messages"] 做摘要压缩:
    - 如果消息太多(超过 keep_last_rounds),把早期消息总结成一条 SystemMessage
    - 只保留最近几轮的原始对话,避免上下文过长
    """
    original_messages = state["messages"]
    
    # 对消息做摘要压缩
    compact = summarize_history_for_model(original_messages, keep_last_rounds=4)
    
    print("\n" + "=" * 60)
    print(f"[DEBUG] 原始消息条数: {len(original_messages)}")
    print(f"[DEBUG] 摘要压缩后消息条数: {len(compact)}")
    if len(compact) < len(original_messages):
        print(f"[DEBUG] 已压缩 {len(original_messages) - len(compact)} 条早期消息为摘要")
    print("============================================================\n")
    
    # 用"压缩后"的消息列表调用模型
    response = model.invoke(compact)
    
    # 按 LangGraph 约定,只返回新消息列表,框架会自动把它 append 到历史中
    return {"messages": [response]}

# 使用 InMemorySaver 作为检查点(短期记忆),方便多轮对话续接
checkpointer = InMemorySaver()

# 构建最简单的单节点图:START -> chatbot_with_summary -> 结束
builder = StateGraph(MessagesState)
builder.add_node("chatbot_with_summary", chatbot_with_summary)
builder.add_edge(START, "chatbot_with_summary")

# 编译图时挂上 checkpointer,这样就可以用 thread_id 记住一条对话线
graph = builder.compile(checkpointer=checkpointer)

# 配置一个线程 ID,用于标识"同一条对话"
config = {"configurable": {"thread_id": "summary-demo-1"}}

# 连续发起多轮对话,让消息历史变长,触发摘要逻辑
graph.invoke({"messages": "hi, my name is Bob, I'm a software engineer."}, config)
graph.invoke({"messages": "I love Python and machine learning."}, config)
graph.invoke({"messages": "My favorite hobby is reading science fiction novels."}, config)
graph.invoke({"messages": "I have a cat named Whiskers."}, config)
graph.invoke({"messages": "I prefer working from home."}, config)

# 这一轮应该会触发摘要(因为前面已经有 5 轮对话,超过 keep_last_rounds=4)
final_response = graph.invoke({"messages": "Can you remind me what I told you about myself?"}, config)

# 打印最后一条 AI 回复
final_response["messages"][-1].pretty_print()

运行输出

============================================================
[DEBUG] 原始消息条数: 11
[DEBUG] 摘要压缩后消息条数: 5
[DEBUG] 已压缩 6 条早期消息为摘要
============================================================

================================== Ai Message ==================================

Of course! Here’s what you've shared so far:

- **Name:** Bob

💡 实践建议

  • 什么时候触发摘要?
  • 摘要用的模型可以比主模型更便宜,甚至可以单独配置一个"小模型专职做 summarize"

🔄 7. 实战四:结合检查点管理消息历史

在第 2 讲里,我们已经知道:

  • thread_id用来区分不同对话线程
  • Checkpointer(比如InMemorySaver/ RedisSaver)会在每个 super-step 后自动存一份 state

在管理短期记忆时,可以利用检查点做两件事:

  1. 安全回滚:尝试激进的修剪/删除/摘要策略之前,先有"后悔药"
  2. 版本对比:对比不同策略下回复质量的差异

伪代码示意

from langgraph.checkpoint.memory import InMemorySaver

checkpointer = InMemorySaver()
builder = StateGraph(MessagesState)

# 你可以在图节点内部进行:
# - 修剪(trim)
# - 删除无用消息(filter)
# - 摘要(summarize)
# 然后让 checkpointer 自动把结果存下来,方便回滚/调试

graph = builder.compile(checkpointer=checkpointer)

💡 实际应用

在实际项目中,可以配合日志系统,把"修剪前后消息长度""摘要触发次数"等指标打到日志里,帮助你找到更合理的参数(N 取多少、多久摘要一次等)。

🎯 8. 实战五:自定义策略组合示例

最后给出一个综合示例每轮对话时,执行以下顺序:

  1. 过滤掉无用角色消息(日志/工具细节)
  2. 对早期部分做摘要(例如每 30 条消息触发一次)
  3. 再按条数做一次兜底修剪(最多保留 50 条)

代码实现

from typing import List
from langchain_core.messages import BaseMessage
from langchain_core.messages.utils import trim_messages
from langchain.chat_models import init_chat_model
from langgraph.graph import StateGraph, START, MessagesState
from langgraph.checkpoint.memory import InMemorySaver

from dotenv import load_dotenv

load_dotenv()

# 初始化模型
model = init_chat_model(model="deepseek-chat", model_provider="deepseek")

# 这里需要先定义 filter_messages_for_llm 和 summarize_history_for_model
# (前面章节已经给出,这里省略具体实现)

MAX_MESSAGES = 50
SUMMARY_TRIGGER = 30# 超过 30 条时才考虑摘要

def compact_messages_for_model(messages: List[BaseMessage]) -> List[BaseMessage]:
    """
    综合策略:过滤 + 摘要 + 修剪
    """
    # 1) 过滤无用消息
    filtered = filter_messages_for_llm(messages)

    # 2) 条数太多时,触发摘要
    if len(filtered) > SUMMARY_TRIGGER:
        filtered = summarize_history_for_model(filtered, keep_last_rounds=6)

    # 3) 最后再按条数兜底修剪一次
    return trim_messages(filtered, max_messages=MAX_MESSAGES)

def smart_chatbot(state: MessagesState):
    """
    综合策略:过滤 + 摘要 + 修剪
    """
    compact = compact_messages_for_model(state["messages"])
    response = model.invoke(compact)
    # 按 LangGraph 约定,只返回新消息列表
    return {"messages": [response]}

# 构建图
checkpointer = InMemorySaver()
builder = StateGraph(MessagesState)
builder.add_node("smart_chatbot", smart_chatbot)
builder.add_edge(START, "smart_chatbot")
graph = builder.compile(checkpointer=checkpointer)

💡 自定义调整

你可以在自己的项目里调整:

  • MAX_MESSAGES/SUMMARY_TRIGGER/keep_last_rounds
  • 按角色保留/删除的规则
  • 哪些节点使用"激进策略",哪些节点只做轻量修剪

📌 9. 小结

本讲我们围绕"短期记忆怎么管",给出了一整套可落地的思路与代码:

  • ✅ 明确了短期记忆与上下文窗口的矛盾
  • ✅ 介绍了修剪、删除无用消息、摘要、检查点管理以及自定义策略
  • ✅ 给出了可直接复制修改的综合示例,帮助你为自己的智能体设计一套"既记得住、又不爆窗"的短期记忆方案