This article is published in English.
Chat history, facts, workflow state, and checkpoints are four different stores
Stop calling everything memory. Separate session transcripts, durable facts, ticket workflow state, and LangGraph checkpoints—with retention and auth rules for each.
Part 9 of 14: Separate chat history, saved facts, and resumable workflow data
Ninth installment in a fourteen-post helpdesk build that walks LangChain from a first model call through production habits. Later posts convert the finished system into interview drills.
The previous installment wired runbook search: query a curated corpus, retain provenance metadata, and block advice that cites documents the search never returned.
Someone on-call asks whether the system can “remember” the incident tomorrow. The ask is underspecified. Persist the chat turns? Team prefs? Labels and retrieved passages? A write waiting on approval? Every intermediate field from the graph? People lump those under one vague label. Each needs its own key, TTL, ACL, and failure policy.
This part separates four ideas:
chat history
ordered messages for one conversation
saved facts
selected application data about a user or accountworkflow state
the current named values for one runcheckpoint
a saved snapshot of workflow state that can be loaded later
Handing prior messages into a static LangChain runnable does not magically unlock pause-and-resume. Durable threads and snapshots come from LangGraph’s state and checkpointer model.
The running ticket
Reuse the familiar incident for examples:
After the 14:05 release, checkout calls from the EU region fail. checkout-api logs report that the database refused new connections.
Assign each kind of record its own key space:
chat session: chat:INC-2048
user facts: user-17
workflow thread: ticket:INC-2048
Treating the incident identifier as if it were a person identifier collapses unrelated namespaces. A single shared transcript list likewise blends separate cases.
First, stop saying “the memory”
Name the record you mean.
Chat history
An ordered list such as:
human: The failure began after 14:05.
assistant: I recorded the start time.
human: The failed requests are only in the EU region.
assistant: I added the affected region to the investigation context.
Sequence is load-bearing when you later assemble prompts from those turns.
Saved facts
Selected fields such as:
{
"team": "commerce-platform",
"timezone": "America/Los_Angeles"
}
Facts can survive past a single chat. Persist them only via explicit app rules—not by scraping every claim the model invents.
Workflow state
Current data for one ticket process:
{
"ticket_id": "INC-2048",
"details": "checkout-api reports database connection refused",
"classification": "database",
"recommendation": "Compare database settings with the last good release.",
"audit": [
"ticket_received",
"classified:database",
"recommendation_created"
]
}
State changes as steps run.
Checkpoint
Think of a checkpoint as a frozen workflow picture plus the bookkeeping the runtime needs to continue. You load the newest picture for a thread, resume after a pause, inspect what a step saw, and recover after a crash. Process-local savers evaporate on exit; production recovery needs a database-backed saver.
Retrieval is not any of these
The curated runbook index is a search corpus. Opening it during an incident does not convert hits into chat history. Snippets should not auto-promote into durable profile facts. Embeddings indexes are not checkpoint databases. Isolate the stores even when a single HTTP request touches several.
What a fixed chain remembers by default
Across independent invocations the chain remembers nothing unless your app injects or persists context.
This call:
result = chain.invoke(current_input)
does not automatically forward earlier inputs or outputs. You may append prior turns yourself, or use older history wrappers. In the LangChain release reviewed here, RunnableWithMessageHistory warns and steers new work toward LangGraph persistence.
With a static chain, owning history in application code is usually easier to reason about:
read permitted messages
-> select the messages needed for this request
-> call the chain
-> store the new turn under the correct session ID
That is what the companion code does.
Project structure
The Part 9 snapshot contains:
langchain-helpdesk/
├── app.py
├── checkpoint_graph.py
├── facts.py
├── history.py
└── tests/
└── test_state.py
Install packages:
python -m pip install -U langchain-core langgraph pydantic pytest
The example makes no provider calls.
Step 1: Store chat messages by session
Create history.py:
from dataclasses import dataclass, field
from langchain_core.messages import (
AIMessage,
BaseMessage,
HumanMessage,
)
@dataclass
class ChatHistoryStore:
histories: dict[str, list[BaseMessage]] = field(
default_factory=dict
) def read(self, session_id: str) -> list[BaseMessage]:
return list(self.histories.get(session_id, [])) def add_turn(
self,
session_id: str,
user_text: str,
reply_text: str,
) -> None:
history = self.histories.setdefault(session_id, [])
history.extend(
[
HumanMessage(content=user_text),
AIMessage(content=reply_text),
]
) def prior_turn_count(self, session_id: str) -> int:
return len(self.histories.get(session_id, [])) // 2
histories keys one session to one ordered list. read clones so callers cannot append into storage by side effect. add_turn records a human/assistant pair. prior_turn_count halves the length because the sample only stores complete pairs. Live transcripts also carry tool messages, half-finished turns, and errors—do not assume neat pairing in production.
History needs a retention rule
Keeping every turn forever is not a product feature. Policy must name what may be retained, for how long, who can read it, which fields are redacted, how erasure works, and how many turns enter the next model call. Oversized histories waste tokens and money. Summaries can help yet invent errors—treat them as derived artifacts with clear provenance rules.
Step 2: Store selected facts separately
Create facts.py:
from dataclasses import dataclass, field
@dataclass
class UserFactsStore:
records: dict[str, dict[str, str]] = field(
default_factory=dict
) def put(self, user_id: str, key: str, value: str) -> None:
self.records.setdefault(user_id, {})[key] = value def get(self, user_id: str) -> dict[str, str]:
return dict(self.records.get(user_id, {}))
Index facts by user_id, never by session or incident id. Accept named fields only; never stash a whole transcript under one key. Real put paths need allowlists, validation, authz, and audit events. A model hint is not authorization to persist.
Step 3: Define workflow state
Cross into a stateful workflow. TypedDict in checkpoint_graph.py:
from operator import add
from typing import Annotated, TypedDict
class TicketWorkflowState(TypedDict, total=False):
ticket_id: str
details: str
classification: str
recommendation: str
audit: Annotated[list[str], add]
total=False lets fields stay missing until a node writes them. Audit events use a reducer:
Annotated[list[str], add]
When a node returns more audit rows, the reducer concatenates instead of overwriting. Pick reducers on purpose—append for event streams, replace for scalar fields.
Step 4: Write small deterministic nodes
Teaching graph uses plain Python so checkpoint behavior is visible:
def classify_node(state: TicketWorkflowState) -> TicketWorkflowState:
details = state["details"].lower()
if "database" in details or "connection refused" in details:
category = "database"
elif "access" in details or "role" in details:
category = "access"
else:
category = "unknown" return {
"classification": category,
"audit": [f"classified:{category}"],
}
Each node reads state and returns a patch; it never mutates the inbound dict. The recommendation node consumes classification:
def recommend_node(state: TicketWorkflowState) -> TicketWorkflowState:
category = state["classification"]
if category == "database":
recommendation = (
"Compare database settings with the last good release."
)
elif category == "access":
recommendation = (
"Confirm the requested role and current access policy."
)
else:
recommendation = "Ask a person to classify the ticket." return {
"recommendation": recommendation,
"audit": ["recommendation_created"],
}
These are ordinary Python functions—this installment focuses on state and persistence, not classifier accuracy.
Step 5: Build the graph
from langgraph.graph import END, START, StateGraph
def build_checkpointed_graph(checkpointer=None):
builder = StateGraph(TicketWorkflowState)
builder.add_node("classify", classify_node)
builder.add_node("recommend", recommend_node)
builder.add_edge(START, "classify")
builder.add_edge("classify", "recommend")
builder.add_edge("recommend", END) return builder.compile(
checkpointer=checkpointer or InMemorySaver()
)
StateGraph(TicketWorkflowState) ties shared state to the typed dictionary. Nodes and edges set order; compile validates the shape and wires the checkpointer. The route remains linear—the graph earns its keep because state and checkpoints are first-class, not because the diagram is fancy.
Step 6: Give every workflow a thread ID
def thread_config(thread_id: str) -> dict[str, dict[str, str]]:
return {"configurable": {"thread_id": thread_id}}
Run the ticket:
config = thread_config("ticket:INC-2048")
result = graph.invoke(
{
"ticket_id": "INC-2048",
"details": (
"checkout-api reports database connection refused"
),
"audit": ["ticket_received"],
},
config,
)
thread_id partitions checkpoint history. Reusing one thread across unrelated incidents leaks state between them.
Step 7: Read the saved state
snapshot = graph.get_state(config)
print(snapshot.values)
Values contain:
{
"ticket_id": "INC-2048",
"details": "checkout-api reports database connection refused",
"classification": "database",
"recommendation": "Compare database settings with the last good release.",
"audit": [
"ticket_received",
"classified:database",
"recommendation_created"
]
}
That snapshot is workflow data only—it is neither a profile store nor the runbook corpus.
What InMemorySaver can and cannot do
It retains checkpoints only for the life of the Python process—fine for unit tests and notebooks. It will not survive restarts, span service replicas, or meet retention/encryption/backup needs. Current docs steer production agent memory and resumable threads toward a database-backed saver such as Postgres.
Production shape:
from langgraph.checkpoint.postgres import PostgresSaver
with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
checkpointer.setup()
graph = build_checkpointed_graph(checkpointer)
Connection secrets, migrations, pooling, and cleanup remain application concerns. Keep database URIs out of committed source.
Where LangChain ends and LangGraph begins
Fixed LangChain is enough when
the sequence is fixed; a single request can finish without a human pause; restarting the whole request is fine; mid-step state need not be durable; ordinary app code can hold the small history you need.
LangGraph is clearer when
control flow branches or loops over named state; a person must approve mid-flight; work continues later on the same thread; a process restart must keep pending state; operators need inspectable snapshots; recovery should resume from a saved point instead of starting over.
Today’s create_agent helper already returns an agent seated on LangGraph. Supply a checkpointer and persistence flows from that runtime—expected architecture, not an accidental leak.
A checkpoint is not an audit log
Checkpoints exist so the runtime can continue. Audit logs exist so security and business reviewers can reconstruct actions. They sometimes share fields; their mandates differ. An audit row should name the requester, proposed tool, approver, executed arguments, outcome, and timestamp. Do not treat an internal serialized snapshot as a compliance-grade audit trail.
Checkpoints and side effects
Persisting state does not make an external write idempotent. If the process updates a ticket then dies before the next checkpoint, resume may repeat the write. Tools need idempotency keys or “already applied” checks. Place side effects after approval; stamp stable operation IDs; document retry semantics. The next installment parks before the ticket write and exercises approve versus reject.
Test the separation
Three offline tests in the companion snapshot.
Message histories stay separate
history.add_turn("chat:first", "First note", "First reply")
history.add_turn("chat:first", "Second note", "Second reply")
history.add_turn("chat:second", "Other ticket", "Other reply")
assert history.prior_turn_count("chat:first") == 2
assert history.prior_turn_count("chat:second") == 1
Saved facts are not chat messages
facts.put("user-17", "team", "commerce-platform")
assert facts.get("user-17") == {
"team": "commerce-platform"
}
assert history.read("user-17") == []
Checkpoints stay separate by ticket thread
first, first_config = run_ticket(
graph,
"INC-2048",
"checkout-api reports database connection refused",
)
second, second_config = run_ticket(
graph,
"INC-2050",
"identity-api denied an access role request",
)
assert graph.get_state(first_config).values["ticket_id"] == "INC-2048"
assert graph.get_state(second_config).values["ticket_id"] == "INC-2050"
Run:
pytest -q
Expected:
3 passed
They prove namespace boundaries and thread isolation—they do not prove database durability while using InMemorySaver.
Common mistakes
One global history list
Turns from unrelated users or incidents collide. Always key history with an authenticated, narrowly scoped identifier.
Saving every model statement as a fact
Models invent confidently. Persist only allowlisted fields through a validated write path.
Putting secrets in state
Snapshots get copied, inspected, and retained. Leave secrets in a vault and pass references instead.
Using thread_id as authorization
A thread ID finds state; it never proves the caller may read it. Authorize separately.
Calling a vector store “long-term memory”
The slogan hides ownership and erasure. Name the records, writers, query path, and deletion policy.
Expecting a checkpoint to repair a bad step
Snapshots preserve whatever was written—including mistakes. Validation and tests remain mandatory.
The result of Part 9
Four named storage boundaries:
session ID -> ordered chat messages
user ID -> selected saved facts
thread ID -> current workflow state
checkpoint -> persisted workflow snapshot
A static chain still fits one-pass jobs. LangGraph is the clearer shape once you need durable state, pause, resume, or recovery. Next comes the first genuine agent decision—read-only tools may auto-run; ticket mutations wait for a human gate.
Documentation check: reviewed against LangChain short-term-memory docs and LangGraph persistence on August 26, 2026. Package APIs change.
Further reading: LangChain short-term memory, LangChain agents, LangGraph persistence.
Production helpdesk systems usually need all four stores at once: a session-scoped chat buffer for the current engineer, a user-scoped fact store for durable preferences, a thread-scoped workflow state for the ticket graph, and a searchable runbook index that never doubles as either history or checkpoint. Naming those boundaries in code reviews prevents the classic shortcut of stuffing everything into one Redis list labeled “memory.” When onboarding a new teammate, ask them to draw the four boxes and label the keys—if they cannot, the design is not ready for interrupt/resume.
When you later introduce human approval (Part 10), the checkpoint becomes the place the workflow parks while waiting. Chat history continues independently so the engineer can ask clarifying questions without mutating the pending write. Facts stay out of the interrupt path unless an explicit rule copies a field. That separation is what keeps “resume after lunch” from becoming “replay the whole conversation into a ticket update.”
Mixing retention policies across stores
Chat transcripts, durable facts, workflow checkpoints, and runbook embeddings almost never share the same retention clock. Aligning them “for simplicity” usually violates either privacy deletion requests or incident-replay needs. Document four clocks, four owners, and four deletion entry points—even if two of them currently point at the same Redis instance.
Treating deprecation warnings as optional
When the library warns that history wrappers are moving to LangGraph persistence, treat that as a design signal. Shipping a new helpdesk feature on the deprecated path buys a rewrite under deadline later. Prefer the checkpointer model for any flow that might pause.
Forgetting that reducers are part of the schema
Teams debate field names for hours and then casually attach an append reducer to a field that should replace. The bug appears weeks later as duplicated classifications or erased audit rows. Review reducers in the same PR as the TypedDict.