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

Message tailoring and filtering
Trim & Filter Messages

The context window of LLM is limited and the conversation history grows infinitely.
Learn to use filter_messages and trim_messages Precisely control the content sent to the model.

1Core Problem: Infinitely Growing Message History

When building a chatbot, we store conversation messagesMessagesStateHere, new messages will be added in each round of dialogue. It looks perfect on the surface - but hides a fundamental engineering challenge:

fundamental contradiction

The context window of LLM has an upper limit (the number of tokens is fixed), and the chat history will follow the conversationInfinite growth. Throwing all historical information to the model will result in: skyrocketing fees, soaring delays, and even exceeding the token upper limit, causing API errors.

Let’s use an intuitive Token consumption diagram to understand this problem:

Token usage trends for a long conversation

Round 1
~120 tok
Round 5
~850 tok
Round 15
~4.2K tok
Round 50
~18K tok

As the number of dialogue rounds increases, the token consumption of each LLM call increases linearly or even superlinearly.

This notebook describes a solution to this problemthree strategies, corresponding to different usage scenarios and trade-offs:

Strategy core means Whether State has been modified
RemoveMessage Delete messages directly from State through reducer Yes, messages in State are permanently deleted
Filter Only part of the message is taken from the node and passed to LLM No, State retains all history
Trim Cut it according to the number of tokens and pass it to LLM No, State retains all history

2MessagesState and basic chat graph review

Before explaining the three strategies in depth, let’s first review theBasic chat diagram, which is the starting point for all subsequent variations.

Define initial message list

Notebook first created a simple message list to simulate a conversation about "marine mammals":

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: automatically generated
people
Yes, I know about whales. But what others should I learn about?
Lance · HumanMessage · id: automatically generated

The simplest chat graph (without any message management)

Notebook first created a "streaking" chat graph: put it in StateallThe message was passed intact to 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()

Basic chat graph structure

__start__
chat_model
Send all messages to LLM
__end__
The problem with this picture

every timegraph.invoke(), all messages accumulated in State will be passed to LLM. The longer the conversation, the more tokens are consumed per call, and the cost and latency increase linearly.

What's built into MessagesState?

MessagesStateIt is the built-in State class provided by LangGraph, which is equivalent to:

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

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

The core isadd_messagesThis reducer: it returns a new message every time the nodeAppendto the existing list instead of replacing. This is the root cause of message accumulation and why we need to proactively manage the number of messages.

3Option 1: Use RemoveMessage to delete messages in the State layer of the graph

The first strategy isInsert a dedicated filter node into the graph, which is responsible for extracting data from StateDelete permanentlyUnnecessary old messages are removed, and then the updated State is handed over to the LLM node.

What is RemoveMessage?

RemoveMessageIt is a special message type provided by LangGraph. when you put aRemoveMessage(id="某条消息的id")Return to State,add_messagesThe reducer will recognize that this is a "delete instruction" and remove it from the State's message list.Completely removeThe message corresponding to 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()

Graph structure with filter nodes

__start__
filter
Delete old messages in State
chat_model
State has only the last 2 items left.
__end__

Practical running demonstration

Notebook constructs a list of 4 messages, andExplicitly specify the id of each messageid="1"id="2"...)——This is becauseRemoveMessageYou need to find the target message by 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})
The evolution of messages in State
When invoke() is passed in (4 items):
AI
Hi.
id="1"
people
Hi.
id="2"
AI
So you said you were researching ocean mammals?
id="3"
people
Yes, I know about whales. But what others should I learn about?
id="4"
↓ filter node execution: RemoveMessage(id="1"), RemoveMessage(id="2")
When chat_model is received (2 items):
AI
So you said you were researching ocean mammals?
id="3" · Reserved
people
Yes, I know about whales. But what others should I learn about?
id="4" · Reserved
Important warning: deletion is permanent

useRemoveMessageAfter that, the deleted messages were deleted fromCompletely disappeared in State. If you need these historical messages later (such as generating summaries, traceability analysis, etc.), there will be no way to retrieve them. This is the biggest cost of this solution.

4Option 2: filter_messages - filter within the node and keep the State intact

The second strategy is more "mild" than the first:The message list in State remains intact and is only temporarily filtered within the node., what is passed to LLM is a filtered subset, but the State itself is not affected.

core idea

In the chat node, do not putstate["messages"]Pass them all to LLM, but do a simple slicing first and only take the last 1 (or 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()

Graph structure of Filter scheme (same as the simplest graph, the difference is within the nodes)

__start__
chat_model
Internally only messages[-1:] are taken and passed to LLM
__end__

Separation of State and LLM input parameters

Here's the key to understanding this scenario:What is stored in State ≠ what is received by LLM

messages (State, 6)

msg1, msg2, msg3,
msg4, msg5, msg6
Graph State (Full History)
LLM actually received

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

Full demo in Notebook

After constructing the filter graph, Notebook further added new messages to test multiple rounds of dialogue:

# 将上一次 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})
Use LangSmith to verify the filtering effect

Notebook provides LangSmith tracking links toVerify what LLM actually received. In LangSmith's trace, you can clearly see: Although there are 6 messages in the State, there is only 1 in the API payload when the model is called. This proves that the State and LLM input parameters are indeed separated.

Filtering flexibility: not just slicing

Beyond simple list slicingmessages[-1:], you can also pressMessage typeFilter, e.g. keep onlyHumanMessage, or pressidPrecise filtering:

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"])
Applicable scenarios

When you need to keep the complete conversation history (for logs, summaries, traceability) but want to control the token usage for each LLM call,Intra-node filteringis the most suitable choice. State is complete, LLM sees your carefully selected subset.

5Option 3: trim_messages - precise trimming based on the number of Tokens

The third strategy is the most refined control method: usetrim_messagesfunction,Precisely specify the maximum number of tokens allowed to be sent to LLM, the function automatically determines which messages to keep.

How trim_messages works

Unlike manual slicing (messages[-1:]),trim_messagesUsethe tokenizer of the model itselfto calculate the number of tokens for each message, and then start from the specified direction (latest or oldest), within no more thanmax_tokensUnder the premise of restrictions, try to retain as many messages as possible.

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)]}

Detailed explanation of key parameters

parameter Values in Notebook meaning
max_tokens 100 The total token limit of the clipped messages shall not exceed this value.
strategy "last" Priority reservationlatestmessage (taken from the end forward); another option is"first", keeping the oldest first
token_counter ChatDeepSeek instance A model used to calculate the number of tokens; a custom counting function can also be passed in
allow_partial False When False, a single message will either be retained or discarded in its entirety (recommended); when True, the message content can be truncated
include_system Not specified (default False) When True, always retain the first SystemMessage (usually the system prompt word)

Behavioral visualization of strategy="last"

Assume there are 5 messages totaling 280 tokens,max_tokens=100strategy="last"

trim_messages execution process
AI
Hi.  [~15 tok]
Discard — eliminate from oldest
people
Hi.  [~15 tok]
discard
AI
So you said you were researching ocean mammals?  [~28 tok]
Discarded — adding in would exceed 100 tok
people
Yes, I know about whales...  [~38 tok]
Reserved — 38 tok
people
Tell me where Orcas live!  [~20 tok]
Reserved — 38+20=58 tok
Final sent to LLM: 2 messages, ~58 tokens (not more than 100)

Call trim_messages directly to observe the results

Notebook also shows direct calls outside the diagramtrim_messagesTo preview the cropping effect:

# 直接调用 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()
The essential difference between trim vs filter

filter(slicing/type filtering): withNumber of itemsas a unit, simple and crude but precise.trim: withnumber of tokensis a unit, which is closer to the actual resource limit of LLM, but requires calling the tokenizer for calculation (there is additional overhead).

Usage of strategy="first"

When your scenario requiresKeep conversation starter(such as important system instructions or background information), you can instead usestrategy="first"

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

Integration with graphs: complete example

# 使用 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})

6Comparison and selection guide of three options

Each of the three strategies has its own merits and is suitable for different scenarios. The following is a comparison from multiple dimensions:

Dimensions RemoveMessage In-node Filter (slice) trim_messages (Token trimming)
History permanently deleted keep intact keep intact
Clipping granularity In "bar" as unit, manually determined Simple slicing in "bar" units Calculated automatically in "token" units
additional overhead None (pure Python operation) None (pure Python operation) Need to call tokenizer (extra delay)
context aware Rely on your handwritten retention logic Fixed number of items, regardless of token amount Precisely control token consumption
Traceability Bad (history deleted) Good (State complete) Good (State complete)
Applicable scenarios It is clear that historical data is not needed and State storage space must be saved. Rapid prototype development, predictable number of historical items, and relatively uniform message lengths Production environment, message lengths vary widely, API fees need to be precisely controlled

Selection decision tree

Practical recommendations

For most practical chatbot projects, it is recommendedpriority usetrim_messages. It hands over the decision-making power of "how many messages to retain" to the tokenizer (based on the real number of tokens), which is more scientific than a fixed number of slices and can better predict API costs. cooperateinclude_system=TrueUse it to ensure that important system prompt words are not cut off.

7Complete process: message list evolves with conversation

The notebook does not demonstrate each scenario in isolation, but runs through aContinuously growing list of messages, allowing you to see how messages accumulate over multiple rounds of conversations, and at what point filtering/clipping intervenes.

Complete evolution timeline of message lists

Stage 1: Initial 2 items
AI
So you said you were researching ocean mammals?
people
Yes, I know about whales. But what others should I learn about?
↓ graph.invoke() → LLM reply Narwhals etc. → output['messages'][-1] append
Stage 2: 4 items (Filter demo stage)
AI
So you said you were researching ocean mammals?
people
Yes, I know about whales...
AI
(LLM’s reply on Narwhals)
people
Tell me more about Narwhals!
Inside the Filter node: llm.invoke(messages[-1:]) → only send the last message to LLM
↓ Continue to add messages
Stage 3: 6 bars (Trim demo stage)
AI
So you said you were researching ocean mammals?
May be eliminated by trim
people
Yes, I know about whales...
May be eliminated by trim
AI
(Reply to Narwhals)
May be eliminated by trim
people
Tell me more about Narwhals!
May be eliminated by trim
AI
(More about Narwhals)
trim reserved
people
Tell me where Orcas live!
trim reserved (latest news)
trim_messages(max_tokens=100, strategy="last") → Take from the end and make up enough but no more than 100 tokens

Behavioral differences between filter and trim in LangSmith

Notebook provides two versions of LangSmith tracking links: filter and trim. Key observations:

observation point Filter tracking Trim Tracking
Number of messages in State N items (reserve all) N items (reserve all)
Number of messages actually received by LLM API 1 item (messages[-1:] K items (number of tokens ≤ max_tokens)
Crop by Fixed: take the last one Dynamic: After counting the tokens, you will know how many to take.
Whether context is lost Yes (only the last one, the previous AI replies are gone) Depending on max_tokens, usually more context is retained

8Summary of core concepts and best practices

Message Management: Panoramic View
RemoveMessage(State delete)
  • • Removal via add_messages reducer
  • • Requires a dedicated filtering node (filter → chat)
  • • History permanently erased
  • • Suitable for: Scenarios that clearly do not require history
In-node Filter (slice filter)
  • • State is complete, only a subset is passed to LLM
  • • Achieve minimalism:messages[-1:]
  • • Supports precise filtering by type, name, and id
  • • Suitable for: PoC, uniform message length
trim_messages (Token trimming)
  • • State is complete, LLM only receives the clipped message
  • • Precise control by token without exceeding the upper limit
  • • Support strategy="last"/"first"
  • • Suitable for: production environment where precise cost control is required
MessagesState (Infrastructure)
  • • Built-in add_messages reducer
  • • New messages are automatically appended to the messages list
  • • Support RemoveMessage deletion command
  • • Is the common basis for the above three strategies

Best Practice Checklist

Practice 1 Production environment is preferredtrim_messages, set reasonablymax_tokens(It is recommended to use 60% to 80% of the model context window to leave space for LLM’s reply)
Practice 2 When there is a system prompt word (SystemMessage), it must be setinclude_system=True, otherwise trim may also trim the system prompt words.
Practice 3 Don't use it lightlyRemoveMessagePermanently delete messages unless you are sure those history will never be needed (e.g. a digest replacement has been generated)
Practice 4 Use LangSmith tracing to verify the actual messages received by LLM instead of guessing - the number of messages in State ≠ the number of messages received by LLM
Practice 5 Message management is built as a chatbot with memoryprerequisite knowledge——The long-term memory functions of subsequent Module 2 Lesson 5+ are built on these message management technologies
Practice 6 If the message history is indeed valuable, you can consider the combined strategy of "summarize first, then prune": periodically compress old messages into a digest AIMessage, and then delete the original old messages (this is the content of the subsequent course in Module 2)
The core conclusion of this section

Trim & Filter Messages solves the problem of LLM chatbotfundamental engineering challenges: Find a balance between "conversation history grows infinitely" and "LLM context window is limited". The choice of the three options depends onDo you need to keep the complete history?Cutting granularity requirementsandProject complexity budget. Mastering these technologies is an essential foundation for building a production-level conversational AI system.