LangChain LangGraph
Module 1 Module 2 Module 3 Module 4 Module 5 Module 6
Module 2 · State & Memory
1 2-1 State Schema
2 2-2 Reducers
3 2-3 Multi Schemas
4 2-4 Trim & Filter
5 2-5 Chatbot Summary
6 2-6 Ext Memory
SUM Module Summary
Home›LangGraph›Module 2 · State & Memory›2-4 Trim & Filter
📓 Notebook 📖 Explained
LANGGRAPH MODULE 2 · LESSON 4

消息裁剪与过滤
Trim & Filter Messages

LLM 的上下文窗口是有限的,对话历史会无限增长。
学会用 filter_messages 和 trim_messages 精准控制发给模型的内容。

1 核心问题:无限增长的消息历史

在构建聊天机器人时,我们把对话消息存在 MessagesState 里,每轮对话都会追加新消息。表面上很完美——但隐藏着一个根本性的工程挑战:

根本矛盾

LLM 的上下文窗口是有上限的(token 数量固定),而聊天历史会随着对话进行无限增长。把全部历史消息都扔给模型,后果是:费用激增、延迟飙高、甚至超出 token 上限导致 API 报错。

下面用一个直观的 Token 消耗示意图来理解这个问题:

一场长对话的 Token 使用趋势

第 1 轮
~120 tok
第 5 轮
~850 tok
第 15 轮
~4.2K tok
第 50 轮
~18K tok

随着对话轮数增加,每次 LLM 调用的 token 消耗线性甚至超线性增长。

这个 Notebook 介绍了解决这一问题的三种策略,分别对应不同的使用场景和取舍:

策略 核心手段 State 是否被修改
RemoveMessage 通过 Reducer 直接从 State 中删除消息 是,State 中的消息被永久删除
Filter(过滤) 节点内只取部分消息传给 LLM 否,State 保留全部历史
Trim(裁剪) 按 token 数量裁剪后传给 LLM 否,State 保留全部历史

2 MessagesState 与基础聊天图回顾

在深入讲解三种策略之前,先回顾 Notebook 建立的基础聊天图,它是后续所有变体的出发点。

定义初始消息列表

Notebook 首先创建了一个简单的消息列表,模拟一场关于"海洋哺乳动物"的对话:

from langchain_core.messages import AIMessage, HumanMessage

# 构建一个两条消息的初始对话历史
messages = [AIMessage("So you said you were researching ocean mammals?", name="Bot")]
messages.append(HumanMessage("Yes, I know about whales. But what others should I learn about?", name="Lance"))

for m in messages:
    m.pretty_print()
# 输出:
# =================================== Ai Message ==================================
# Name: Bot
# So you said you were researching ocean mammals?
# ================================ Human Message =================================
# Name: Lance
# Yes, I know about whales. But what others should I learn about?
AI
So you said you were researching ocean mammals?
Bot · AIMessage · id: 自动生成
人
Yes, I know about whales. But what others should I learn about?
Lance · HumanMessage · id: 自动生成

最简单的聊天图(无任何消息管理)

Notebook 先建立了一个"裸奔"的聊天图:把 State 里所有消息原封不动地传给 LLM。

from langgraph.graph import MessagesState, StateGraph, START, END
from langchain_deepseek import ChatDeepSeek

llm = ChatDeepSeek(model="deepseek-v4-pro")

# 节点:直接把 state["messages"] 全部传给 LLM
def chat_model_node(state: MessagesState):
    return {"messages": llm.invoke(state["messages"])}
    # ↑ 无任何裁剪!所有历史都发给模型

builder = StateGraph(MessagesState)
builder.add_node("chat_model", chat_model_node)
builder.add_edge(START, "chat_model")
builder.add_edge("chat_model", END)
graph = builder.compile()

基础聊天图结构

__start__
↓
chat_model
把所有消息传给 LLM
↓
__end__
这张图的问题

每次 graph.invoke(),State 里积累的所有消息都会被传给 LLM。对话越长,每次调用消耗的 token 越多,费用和延迟线性增长。

MessagesState 内置了什么?

MessagesState 是 LangGraph 提供的内置 State 类,等价于:

from langgraph.graph.message import add_messages
from typing_extensions import Annotated, TypedDict

class MessagesState(TypedDict):
    # Annotated + add_messages:新消息追加到列表,而非覆盖
    messages: Annotated[list, add_messages]

核心是 add_messages 这个 Reducer:它让每次节点返回的新消息追加到现有列表,而不是替换。这是消息积累的根本原因,也是为什么我们需要主动管理消息数量。

3 方案一:用 RemoveMessage 在图的 State 层删除消息

第一种策略是在图里插入一个专门的过滤节点,它负责从 State 中永久删除不需要的旧消息,然后再把更新后的 State 交给 LLM 节点。

RemoveMessage 是什么?

RemoveMessage 是 LangGraph 提供的特殊消息类型。当你把一个 RemoveMessage(id="某条消息的id") 返回给 State,add_messages Reducer 会识别出这是"删除指令",并从 State 的消息列表中彻底移除对应 id 的消息。

from langchain_core.messages import RemoveMessage

# 过滤节点:只保留最近 2 条消息,删除其余所有旧消息
def filter_messages(state: MessagesState):
    # state["messages"][:-2] = 除最后2条外的所有消息
    delete_messages = [RemoveMessage(id=m.id) for m in state["messages"][:-2]]
    return {"messages": delete_messages}
    # ↑ 返回一组"删除指令",Reducer 会执行实际删除

# 聊天节点:此时 State 里只剩最近 2 条,正常传给 LLM
def chat_model_node(state: MessagesState):
    return {"messages": [llm.invoke(state["messages"])]}

# 图:先过滤,再聊天
builder = StateGraph(MessagesState)
builder.add_node("filter", filter_messages)
builder.add_node("chat_model", chat_model_node)
builder.add_edge(START, "filter")   # 先过滤
builder.add_edge("filter", "chat_model")  # 再聊天
builder.add_edge("chat_model", END)
graph = builder.compile()

带过滤节点的图结构

__start__
↓
filter
删除 State 中的旧消息
↓
chat_model
State 已只剩最近 2 条
↓
__end__

实际运行演示

Notebook 构造了一个 4 条消息的列表,并且显式指定了每条消息的 id(id="1"、id="2"……)——这是因为 RemoveMessage 需要凭 id 来找到目标消息:

# 构建 4 条消息的对话,显式指定 id 以便后续删除
messages = [AIMessage("Hi.", name="Bot", id="1")]
messages.append(HumanMessage("Hi.", name="Lance", id="2"))
messages.append(AIMessage("So you said you were researching ocean mammals?", name="Bot", id="3"))
messages.append(HumanMessage("Yes, I know about whales. But what others should I learn about?", name="Lance", id="4"))

# 运行:filter 节点会删除 id="1" 和 id="2" 的消息
output = graph.invoke({'messages': messages})
消息在 State 中的演变过程
invoke() 传入时(4 条):
AI
Hi.
id="1"
人
Hi.
id="2"
AI
So you said you were researching ocean mammals?
id="3"
人
Yes, I know about whales. But what others should I learn about?
id="4"
↓ filter 节点执行:RemoveMessage(id="1"), RemoveMessage(id="2")
chat_model 收到时(2 条):
AI
So you said you were researching ocean mammals?
id="3" · 保留
人
Yes, I know about whales. But what others should I learn about?
id="4" · 保留
重要警告:删除是永久的

使用 RemoveMessage 后,被删除的消息从 State 中彻底消失。如果你之后需要这些历史消息(如生成摘要、溯源分析等),将无从找回。这是此方案最大的代价。

4 方案二:filter_messages——在节点内过滤,State 保持完整

第二种策略比第一种更"温和":State 里的消息列表原封不动,只在节点内部临时过滤,传给 LLM 的是一个经过筛选的子集,但 State 本身不受影响。

核心思想

在聊天节点里,不把 state["messages"] 全部传给 LLM,而是先做一次简单的切片,只取最后 1 条(或 N 条):

# 节点:只把最后 1 条消息传给 LLM
def chat_model_node(state: MessagesState):
    return {"messages": [llm.invoke(state["messages"][-1:])]}
    #                                            ↑ [-1:] 只取最后 1 条
    #   state 里仍然保存着全部历史,只是传给 LLM 的只有最后 1 条

builder = StateGraph(MessagesState)
builder.add_node("chat_model", chat_model_node)
builder.add_edge(START, "chat_model")
builder.add_edge("chat_model", END)
graph = builder.compile()

Filter 方案的图结构(与最简图相同,区别在节点内部)

__start__
↓
chat_model
内部只取 messages[-1:] 传给 LLM
↓
__end__

State 与 LLM 入参的分离

这是理解此方案的关键:State 里存的 ≠ LLM 收到的。

messages(State 中,6 条)

msg1, msg2, msg3,
msg4, msg5, msg6
Graph State(完整历史)
→
LLM 实际收到

msg6
llm.invoke(messages[-1:])

Notebook 中的完整演示

Notebook 在构建 filter 图之后,进一步追加了新消息来测试多轮对话:

# 将上一次 LLM 的回复追加到消息列表
messages.append(output['messages'][-1])
# 追加新的用户问题
messages.append(HumanMessage("Tell me more about Narwhals!", name="Lance"))

# 此时 messages 列表有多条消息
for m in messages:
    m.pretty_print()

# 再次调用图(State 里有全部历史,但 LLM 只收到最后 1 条)
output = graph.invoke({'messages': messages})
用 LangSmith 验证过滤效果

Notebook 提供了 LangSmith 追踪链接来验证 LLM 实际收到了什么。在 LangSmith 的 trace 里,你能清楚地看到:虽然 State 里有 6 条消息,但模型调用时 API payload 里只有 1 条。这证明了 State 和 LLM 入参确实是分离的。

过滤的灵活性:不只是切片

除了简单的列表切片 messages[-1:],你还可以按消息类型过滤,例如只保留 HumanMessage,或者按 id 精确过滤:

from langchain_core.messages import filter_messages

# 只保留 HumanMessage(过滤掉所有 AI 消息)
human_only = filter_messages(state["messages"], include_types=[HumanMessage])

# 只保留 AIMessage
ai_only = filter_messages(state["messages"], include_types=[AIMessage])

# 排除某些类型
no_system = filter_messages(state["messages"], exclude_types=["system"])

# 按 name 过滤(只取某个 agent 的消息)
lance_msgs = filter_messages(state["messages"], include_names=["Lance"])
适用场景

当你需要保留完整的对话历史(用于日志、摘要、溯源),但又想控制每次 LLM 调用的 token 用量时,节点内过滤是最合适的选择。State 是完整的,LLM 看到的是你精心挑选的子集。

5 方案三:trim_messages——按 Token 数量精确裁剪

第三种策略是最精细化的管控方式:使用 trim_messages 函数,精确指定允许发送给 LLM 的最大 token 数,由函数自动决定保留哪些消息。

trim_messages 的工作原理

不同于手动切片(messages[-1:]),trim_messages 使用模型本身的 tokenizer 来计算每条消息的 token 数,然后从指定的方向(最新的或最旧的)开始,在不超过 max_tokens 限制的前提下,尽量多保留消息。

from langchain_core.messages import trim_messages

# 节点:使用 trim_messages 裁剪后再传给 LLM
def chat_model_node(state: MessagesState):
    messages = trim_messages(
        state["messages"],
        max_tokens=100,          # 最多允许 100 个 token
        strategy="last",          # 优先保留最新的消息
        token_counter=ChatDeepSeek(model="deepseek-v4-pro"),
                                   # 用这个模型来计算 token 数
        allow_partial=False,      # 不允许截断单条消息(要么整条保留,要么整条丢弃)
    )
    return {"messages": [llm.invoke(messages)]}

关键参数详解

参数 Notebook 中的值 含义
max_tokens 100 裁剪后的消息总 token 上限,不超过此值
strategy "last" 优先保留最新的消息(从末尾往前取);另一个选项是 "first",优先保留最旧的
token_counter ChatDeepSeek 实例 用于计算 token 数的模型;也可以传入一个自定义的计数函数
allow_partial False False 时,单条消息要么整条保留要么整条丢弃(推荐);True 时可以截断消息内容
include_system 未指定(默认 False) True 时始终保留第一条 SystemMessage(通常是系统提示词)

strategy="last" 的行为可视化

假设有 5 条消息,总计 280 tokens,max_tokens=100,strategy="last":

trim_messages 执行过程
AI
Hi.  [~15 tok]
丢弃 — 从最旧开始淘汰
人
Hi.  [~15 tok]
丢弃
AI
So you said you were researching ocean mammals?  [~28 tok]
丢弃 — 加进来会超过 100 tok
人
Yes, I know about whales...  [~38 tok]
保留 — 38 tok
人
Tell me where Orcas live!  [~20 tok]
保留 — 38+20=58 tok
最终发送给 LLM:2 条消息,~58 tokens(未超过 100)

直接调用 trim_messages 观察结果

Notebook 也展示了在图之外直接调用 trim_messages 来预览裁剪效果:

# 直接调用 trim_messages 预览裁剪结果(不经过图)
trimmed = trim_messages(
    messages,
    max_tokens=100,
    strategy="last",
    token_counter=ChatDeepSeek(model="deepseek-v4-pro"),
    allow_partial=False
)

# 查看裁剪后的消息列表
# 会输出保留下来的 N 条消息,其余旧消息被排除在外
for m in trimmed:
    m.pretty_print()
trim vs filter 的本质区别

filter(切片/类型过滤):以条数为单位,简单粗暴但精准。trim:以 token 数为单位,更贴近 LLM 的实际资源限制,但需要调用 tokenizer 计算(有额外开销)。

strategy="first" 的用法

当你的场景需要保留对话开头(例如重要的系统指令或背景信息),可以改用 strategy="first":

# strategy="first": 优先保留最旧的消息(从开头往后取)
trimmed = trim_messages(
    messages,
    max_tokens=100,
    strategy="first",  # ← 改为 first
    token_counter=llm,
    allow_partial=False,
    include_system=True  # 始终保留 SystemMessage
)

与图的集成:完整示例

# 使用 trim 方案构建图
def chat_model_node(state: MessagesState):
    trimmed = trim_messages(
        state["messages"],
        max_tokens=100,
        strategy="last",
        token_counter=ChatDeepSeek(model="deepseek-v4-pro"),
        allow_partial=False,
    )
    return {"messages": [llm.invoke(trimmed)]}

builder = StateGraph(MessagesState)
builder.add_node("chat_model", chat_model_node)
builder.add_edge(START, "chat_model")
builder.add_edge("chat_model", END)
graph = builder.compile()

# 在消息列表上追加更多消息后再调用
messages.append(output['messages'][-1])
messages.append(HumanMessage("Tell me where Orcas live!", name="Lance"))

# 调用图(State 包含全部历史,LLM 只收到 ≤100 token 的子集)
messages_out = graph.invoke({'messages': messages})

6 三种方案对比与选型指南

三种策略各有千秋,适用于不同的场景。下面从多个维度进行对比:

维度 RemoveMessage 节点内 Filter(切片) trim_messages(Token 裁剪)
State 历史 被永久删除 完整保留 完整保留
裁剪粒度 以"条"为单位,手动决定 以"条"为单位,简单切片 以"token"为单位,自动计算
额外开销 无(纯 Python 操作) 无(纯 Python 操作) 需要调用 tokenizer(额外延迟)
上下文感知 依赖你手写的保留逻辑 固定条数,不考虑 token 量 精确控制 token 消耗
可溯源性 差(历史被删除) 好(State 完整) 好(State 完整)
适用场景 明确不需要历史数据,且要节省 State 存储空间 快速原型开发、历史条数可预估、消息长度相对均匀 生产环境、消息长度差异大、需精确控制 API 费用

选型决策树

实践推荐

对于大多数实际 chatbot 项目,推荐优先使用 trim_messages。它把"该保留多少消息"这个决策权交给了 tokenizer(基于真实 token 数量),比固定条数的切片更科学,也更能预测 API 费用。配合 include_system=True 使用,能保证重要的系统提示词不被裁掉。

7 完整流程:消息列表随对话演化

Notebook 并非孤立地演示每种方案,而是贯穿了一个持续增长的消息列表,让你看到消息是如何在多轮对话中积累的,以及过滤/裁剪在哪个环节介入。

消息列表的完整演化时间线

阶段 1:初始 2 条
AI
So you said you were researching ocean mammals?
人
Yes, I know about whales. But what others should I learn about?
↓ graph.invoke() → LLM 回复 Narwhals 等 → output['messages'][-1] 追加
阶段 2:4 条(Filter 演示阶段)
AI
So you said you were researching ocean mammals?
人
Yes, I know about whales...
AI
(LLM 关于 Narwhals 的回复)
人
Tell me more about Narwhals!
Filter 节点内部:llm.invoke(messages[-1:]) → 只发最后一条给 LLM
↓ 继续追加消息
阶段 3:6 条(Trim 演示阶段)
AI
So you said you were researching ocean mammals?
可能被 trim 淘汰
人
Yes, I know about whales...
可能被 trim 淘汰
AI
(关于 Narwhals 的回复)
可能被 trim 淘汰
人
Tell me more about Narwhals!
可能被 trim 淘汰
AI
(关于 Narwhals 的更多信息)
trim 保留
人
Tell me where Orcas live!
trim 保留(最新消息)
trim_messages(max_tokens=100, strategy="last") → 从末尾往前取,凑够但不超过 100 token

filter 与 trim 在 LangSmith 中的行为差异

Notebook 分别提供了 filter 和 trim 两个版本的 LangSmith 追踪链接。关键观察点:

观察点 Filter 追踪 Trim 追踪
State 中的消息数 N 条(全部保留) N 条(全部保留)
LLM API 实际收到的消息数 1 条(messages[-1:]) K 条(token 数 ≤ max_tokens)
裁剪依据 固定:取最后 1 条 动态:算完 token 才知道取几条
是否丢失上下文 是(只有最后 1 条,前面的 AI 回复都没了) 视 max_tokens 而定,通常保留更多上下文

8 核心概念总结与最佳实践

消息管理:全景视图
RemoveMessage(State 删除)
  • • 通过 add_messages Reducer 执行删除
  • • 需要专门的过滤节点(filter → chat)
  • • 历史被永久抹去
  • • 适合:明确不需要历史的场景
节点内 Filter(切片过滤)
  • • State 完整,仅传给 LLM 的是子集
  • • 实现极简:messages[-1:]
  • • 支持按类型、name、id 精确过滤
  • • 适合:PoC、消息长度均匀
trim_messages(Token 裁剪)
  • • State 完整,LLM 只收到裁剪后的消息
  • • 按 token 精确控制,不会超出上限
  • • 支持 strategy="last"/"first"
  • • 适合:生产环境,需精确控制费用
MessagesState(基础设施)
  • • 内置 add_messages Reducer
  • • 新消息自动追加到 messages 列表
  • • 支持 RemoveMessage 删除指令
  • • 是上述三种策略的共同基础

最佳实践清单

实践 1 生产环境优先使用 trim_messages,设置合理的 max_tokens(建议为模型上下文窗口的 60%~80%,给 LLM 的回复留出空间)
实践 2 有系统提示词(SystemMessage)时,必须设置 include_system=True,否则 trim 可能把系统提示词也裁掉
实践 3 不要轻易使用 RemoveMessage 永久删除消息,除非你确定那些历史永远不需要(例如已经生成了摘要替代)
实践 4 使用 LangSmith 追踪来验证 LLM 实际收到的消息,而不是靠猜测——State 里的消息数 ≠ LLM 收到的消息数
实践 5 消息管理是构建有记忆的 chatbot 的前置知识——后续 Module 2 Lesson 5+ 的长期记忆功能都建立在这些消息管理技术之上
实践 6 如果消息历史确实很有价值,可以考虑"先摘要、再裁剪"的组合策略:定期把旧消息压缩成一条摘要 AIMessage,然后删除原始旧消息(这是 Module 2 后续课程的内容)
本节核心结论

Trim & Filter Messages 解决的是 LLM 聊天机器人的根本工程挑战:在"对话历史无限增长"和"LLM 上下文窗口有限"之间找到平衡。三种方案的选择取决于你是否需要保留完整历史、裁剪粒度要求和工程复杂度预算。掌握这些技术,是构建生产级对话 AI 系统的必备基础。