Context Window Optimization with LangGraph: Complete Guide
Large language models have grown dramatically in capability, but their context windows remain a finite and expensive resource. Every token you pass into a model costs latency, money, and attention. When you build agentic workflows with LangGraph, unoptimized context windows quickly become the bottleneck that turns a snappy assistant into a sluggish, expensive, and unreliable system. This guide walks through what context window optimization means, why it matters inside LangGraph specifically, and how to implement it with practical, production-ready patterns.
What Is Context Window Optimization?
Context window optimization is the practice of managing the tokens that enter a model's prompt at each step of an agent's execution. It is not simply about staying under the model's maximum context length. It is about ensuring that, at every node in your graph, the model receives only the tokens that are relevant to the decision it needs to make next.
In a naive agent loop, the entire conversation history, every tool result, every intermediate thought, and every system instruction is passed forward at every step. As the agent runs longer, the prompt grows linearly or worse. Optimization techniques compress, summarize, filter, route, and structure this information so the model's input stays small, focused, and semantically dense.
Why It Matters in LangGraph
LangGraph models agents as stateful graphs. Each node receives a shared State object, performs work, and returns an update. Because state is passed between nodes by default, it is very easy to accumulate context bloat: tool outputs pile up, messages accumulate, and every downstream node pays the cost of carrying that history forward.
- Latency: Time-to-first-token scales roughly with input length. A 50k-token prompt is measurably slower than a 5k-token prompt on every single call.
- Cost: Most providers charge per input token. Carrying irrelevant history through 20 graph steps multiplies your bill.
- Quality: Models suffer from attention dilution. Lost-in-the-middle effects mean important instructions buried in long contexts get ignored.
- Reliability: Hitting the context limit mid-run causes hard failures. Optimization gives you headroom for long-running agents.
Understanding LangGraph State and Message Accumulation
Before optimizing, you need to understand where context lives. In LangGraph, the canonical pattern uses a MessagesState or a custom typed dictionary that holds a list of messages. Each node appends to that list, and the next node reads the full list when calling the model.
from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langchain_openai import ChatOpenAI
class State(TypedDict):
messages: Annotated[list, add_messages]
llm = ChatOpenAI(model="gpt-4o")
def call_model(state: State):
response = llm.invoke(state["messages"])
return {"messages": [response]}
graph = StateGraph(State)
graph.add_node("model", call_model)
graph.add_edge(START, "model")
graph.add_edge("model", END)
app = graph.compile()
This minimal graph works, but every invocation passes the entire messages list to the model. After dozens of turns or large tool outputs, this list becomes the dominant cost in your system.
Strategy 1: Message Trimming with trim_messages
The simplest optimization is to cap the number of messages passed to the model. LangChain provides trim_messages, which lets you enforce a token budget while preserving structurally important messages like the system prompt.
from langchain_core.messages import trim_messages, SystemMessage
def call_model(state: State):
trimmed = trim_messages(
state["messages"],
max_tokens=4000,
strategy="last",
token_counter=llm,
include_system=True,
start_on="human",
)
response = llm.invoke(trimmed)
return {"messages": [response]}
The strategy="last" option keeps the most recent messages within the budget, which is usually what you want for conversational agents. The include_system=True flag guarantees the system message is never dropped, and start_on="human" ensures the trimmed history begins on a human turn rather than an orphaned AI message.
Note that trimming happens only at the point of model invocation. The full state is still preserved in the graph, which is important: you keep a complete audit trail while only sending a bounded slice to the model.
Strategy 2: Summarization Nodes for Long Conversations
Trimming discards information. When conversations are long and earlier context remains valuable, summarization is a better fit. The pattern is to add a dedicated node that compresses older messages into a single summary message once the history crosses a threshold.
from langchain_core.messages import HumanMessage, AIMessage, RemoveMessage
class State(TypedDict):
messages: Annotated[list, add_messages]
summary: str
def maybe_summarize(state: State):
messages = state["messages"]
if len(messages) <= 6:
return state
summary = state.get("summary", "")
summary_prompt = (
f"Here is the existing summary: {summary}\n\n"
"Extend this summary with the new conversation below:\n"
f"{messages[:-2]}"
)
new_summary = llm.invoke(summary_prompt).content
delete_messages = [
RemoveMessage(id=m.id) for m in messages[:-2]
]
return {"messages": delete_messages, "summary": new_summary}
def call_model(state: State):
summary = state.get("summary", "")
system = SystemMessage(
content=f"You are a helpful assistant. Conversation summary: {summary}"
)
messages = [system] + state["messages"][-2:]
response = llm.invoke(messages)
return {"messages": [response]}
The RemoveMessage reducer is the key mechanism here. When a node returns a RemoveMessage with a specific id, LangGraph's add_messages reducer deletes that message from state rather than appending. This lets you shrink persistent state, not just the model's view of it.
Wiring this into a graph with conditional routing gives you an agent that self-compresses:
graph = StateGraph(State)
graph.add_node("summarize", maybe_summarize)
graph.add_node("model", call_model)
graph.add_edge(START, "summarize")
graph.add_edge("summarize", "model")
graph.add_edge("model", END)
app = graph.compile()
Strategy 3: Selective Tool Output Handling
Tool calls are often the worst context offenders. A single web search or file read can return thousands of tokens, and most of that content is irrelevant after the model has reasoned about it. Two patterns help: truncating tool outputs at the source, and stripping tool messages from history after they have been consumed.
def truncate_tool_output(content: str, max_chars: int = 2000) -> str:
if len(content) <= max_chars:
return content
return content[:max_chars] + "\n...[truncated]..."
def call_tool(state: State):
last_msg = state["messages"][-1]
tool_calls = last_msg.tool_calls
results = []
for tc in tool_calls:
raw = run_my_tool(tc["args"])
results.append({
"role": "tool",
"tool_call_id": tc["id"],
"content": truncate_tool_output(raw),
})
return {"messages": results}
For even tighter control, you can add a cleanup node that removes old tool messages once the model has produced a follow-up response. This keeps the working context focused on the current reasoning step rather than the full artifact history.
Strategy 4: State Channels and Parallel Contexts
Not every node needs every piece of state. LangGraph lets you define multiple channels in your state, and you can route only the relevant channels to each node. This is especially powerful for multi-agent systems where a research agent produces large documents that a planner agent never needs to see in full.
class State(TypedDict):
messages: Annotated[list, add_messages]
research_notes: str
plan: str
def researcher(state: State):
notes = do_research(state["messages"][-1].content)
return {"research_notes": notes}
def planner(state: State):
# Planner sees only the summary, not raw research artifacts
short_notes = state["research_notes"][:1500]
plan = llm.invoke(f"Make a plan based on: {short_notes}")
return {"plan": plan.content}
By keeping bulky artifacts in dedicated channels and only passing trimmed views into model calls, you decouple storage from inference cost. The graph retains rich state for downstream nodes or for the user, while each LLM call stays lean.
Strategy 5: Hierarchical and Recursive Summarization
For agents that run for hundreds of steps, a single summary layer is insufficient. Hierarchical summarization maintains summaries at multiple time scales: a short-term summary of the last few turns, a medium-term summary of the last task, and a long-term summary of the entire session.
def hierarchical_summarize(state: State):
messages = state["messages"]
if len(messages) < 20:
return state
short_term = llm.invoke(
"Summarize the last 10 messages: " + str(messages[-10:])
).content
medium_term = state.get("medium_summary", "")
medium_term = llm.invoke(
f"Existing medium summary: {medium_term}\n"
f"Integrate this short-term summary: {short_term}"
).content
delete_messages = [RemoveMessage(id=m.id) for m in messages[:-2]]
return {
"messages": delete_messages,
"short_summary": short_term,
"medium_summary": medium_term,
}
This mirrors how humans handle long projects: you remember the gist of last week, the details of this morning, and you look up specifics only when needed. The model gets a compact, multi-resolution view of history at every step.
Best Practices
- Measure first. Use a token counter like
llm.get_num_tokens_from_messages(messages)to log prompt sizes per node before optimizing. You cannot optimize what you do not measure. - Preserve system messages. Always use
include_system=Truewhen trimming. Dropping the system prompt mid-conversation causes silent behavioral drift. - Keep full state for audit, trim only at invocation. Trimming at the model call boundary is safer than deleting state, unless you have a deliberate summarization strategy.
- Truncate tool outputs aggressively. Most tool results have a small useful prefix. Truncate at the source rather than carrying full payloads through the graph.
- Use separate channels for bulky artifacts. Documents, search results, and code blobs belong in dedicated state channels, not in the message list.
- Set budgets per node, not globally. A planning node may need 8k tokens of context, while a simple classification node needs 500. Tune budgets to the cognitive load of each node.
- Test summarization for information loss. Summaries can drop critical details. Run evals that check whether the agent can still answer questions about early conversation facts after summarization fires.
- Watch for reducer pitfalls. When using
RemoveMessage, ensure you do not remove messages that downstream conditional edges depend on for routing decisions.
Conclusion
Context window optimization is not a single technique but a layered discipline: trim aggressively where you can, summarize where you must, isolate bulky artifacts in dedicated channels, and budget tokens per node based on what each step actually needs. LangGraph's stateful graph model makes these patterns natural to implement, because every node is an explicit opportunity to reshape what the model sees. By combining trim_messages for bounded recency, summarization nodes for long-running value retention, tool output truncation for artifact-heavy workflows, and channel separation for multi-agent architectures, you can build agents that stay fast, cheap, and accurate even as they run for dozens or hundreds of steps. The result is a system that scales not by buying a bigger context window, but by using the one you have with discipline.