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
HomeLangGraphModule 2 · State & Memory2-4 Trim & Filter
📓 Notebook 📖 Explained
LANGGRAPH MODULE 2 · LESSON 4

消息裁剪与过滤
Trim & Filter Messages

LLM 的上下文窗口是有限的,对话历史会无限增长。
学会用 filter_messagestrim_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 条消息的列表,并且显式指定了每条消息的 idid="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=100strategy="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 系统的必备基础。