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.
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:
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:
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 |
Before explaining the three strategies in depth, let’s first review theBasic chat diagram, which is the starting point for all subsequent variations.
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?
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()
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.
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.
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.
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()
Notebook constructs a list of 4 messages, andExplicitly specify the id of each message(id="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})
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.
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.
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()
Here's the key to understanding this scenario:What is stored in State ≠ what is received by LLM。
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})
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.
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"])
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.
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.
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)]}
| 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) |
Assume there are 5 messages totaling 280 tokens,max_tokens=100,strategy="last":
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()
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).
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
)
# 使用 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})
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 |
Yes → SelectFilterorTrim;no (identifying history is no longer useful) → considerRemoveMessage
Yes (it can be long or short, it’s hard to predict the token) → Selecttrim_messages; No (the messages are relatively even) →Filter sliceenough
PoC/rapid prototyping →Filter slice(Easiest); production environment →trim_messages(accurate, predictable costs)
Yes → Usetrim_messages + include_system=True, ensuring that the system prompt words are always retained
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.
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.
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 |
messages[-1:]trim_messages, set reasonablymax_tokens(It is recommended to use 60% to 80% of the model context window to leave space for LLM’s reply)
include_system=True, otherwise trim may also trim the system prompt words.
RemoveMessagePermanently delete messages unless you are sure those history will never be needed (e.g. a digest replacement has been generated)
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.