📖 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 设计短期记忆的两个核心原则
- 尽量保留"必要语境",而不是"所有历史"
- 把"信息量大但可压缩"的内容尽早做摘要,减少体积
📋 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. 实战二:删除无用消息
在开启短期记忆后,消息会越积越多,其中有不少是对最终回复帮助不大的"噪声",例如冗长的工具日志、调试信息或阶段性已过时的上下文。
本节我们从两个层次来处理这类无用消息:
- 在调用 LLM 前做按角色 / 标签的过滤
- 在确有需要时从 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
在管理短期记忆时,可以利用检查点做两件事:
- 安全回滚:尝试激进的修剪/删除/摘要策略之前,先有"后悔药"
- 版本对比:对比不同策略下回复质量的差异
伪代码示意
from langgraph.checkpoint.memory import InMemorySaver
checkpointer = InMemorySaver()
builder = StateGraph(MessagesState)
# 你可以在图节点内部进行:
# - 修剪(trim)
# - 删除无用消息(filter)
# - 摘要(summarize)
# 然后让 checkpointer 自动把结果存下来,方便回滚/调试
graph = builder.compile(checkpointer=checkpointer)
💡 实际应用
在实际项目中,可以配合日志系统,把"修剪前后消息长度""摘要触发次数"等指标打到日志里,帮助你找到更合理的参数(N 取多少、多久摘要一次等)。
🎯 8. 实战五:自定义策略组合示例
最后给出一个综合示例: 每轮对话时,执行以下顺序:
- 过滤掉无用角色消息(日志/工具细节)
- 对早期部分做摘要(例如每 30 条消息触发一次)
- 再按条数做一次兜底修剪(最多保留 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. 小结
本讲我们围绕"短期记忆怎么管",给出了一整套可落地的思路与代码:
- ✅ 明确了短期记忆与上下文窗口的矛盾
- ✅ 介绍了修剪、删除无用消息、摘要、检查点管理以及自定义策略
- ✅ 给出了可直接复制修改的综合示例,帮助你为自己的智能体设计一套"既记得住、又不爆窗"的短期记忆方案