Agentic AI using LangGraph: Building a ChatGPT-Style Resume Chat Feature
This chapter focuses on architecting and implementing a multi-session conversational interface with a "Resume Chat" mechanism similar to ChatGPT. By leveraging LangGraph's native state-tracking mechanisms alongside Streamlit's session state, the system dynamically generates conversation threads, retains past conversational contexts, and switches seamlessly between isolated threads without modifying the underlying LangGraph backend graph.
1. The Resume Chat Paradigm
In standard conversational AI interfaces, maintaining conversation history across different user sessions is critical for modern user experience. Industrial conversational agents (such as ChatGPT or Claude) provide two core user actions:
- Start a New Conversation: Wipes the current view and begins a fresh context on an entirely new topic without interference from past exchanges.
- Resume an Existing Conversation: Loads past interactions, contexts, and specific system memory from hours or days prior, allowing users to pick up right where they left off.
2. Architecture Overview: Decoupled Backend vs. Stateful Frontend
A key architectural advantage of LangGraph is that its state checkpointing is inherently decoupled from the frontend presentation layer.
The backend relies on a Checkpointer configured with a thread_id. Because the LangGraph engine already isolates execution graphs based on configurable: {"thread_id": ...}, the entire "Resume Chat" capability can be achieved purely by orchestrating the frontend state and querying LangGraph's state inspection APIs (chatbot.get_state).
Frontend State Responsibilities vs. Backend Checkpointer
3. Modular Implementation Roadmap
To build this architecture systematically, the implementation is decomposed into four discrete, testable phases:
4. Phase 1: Designing the Sidebar Layout
The user interface requires a clear visual separation between the active conversation viewport and past conversation sessions. Streamlit provides st.sidebar to place navigation elements outside the primary interaction flow.
import streamlit as st
# Setup Sidebar Header
st.sidebar.title("LangGraph Chatbot")
# Action button to trigger context reset
if st.sidebar.button("New Chat"):
# Trigger reset logic (defined in subsequent steps)
pass
# Header for historical navigation
st.sidebar.header("My Conversations")
At this initial stage, the sidebar contains the controls, but clicking the button performs no state transitions.
5. Phase 2: Dynamic Thread Generation via uuid4
Naive vs. Dynamic Approach
- Naive Approach: Hardcoding a static string identifier (e.g.,
thread_id = "thread_1"). This causes all interactions across all browser clicks to pollute a single context. - Dynamic Approach: Using Python's standard
uuidmodule to generate a cryptographically strong, universally unique identifier (UUID4) whenever a new chat begins.
import uuid
# ==========================================
# UTILITY FUNCTIONS
# ==========================================
def generate_thread_id() -> str:
"""Generates a unique string identifier for LangGraph thread isolation."""
return str(uuid.uuid4())
Initializing the Dynamic Session State
When a user visits the application for the first time, verify whether an active thread_id is registered in st.session_state. If not, generate one:
# ==========================================
# SESSION STATE INITIALIZATION
# ==========================================
if "thread_id" not in st.session_state:
st.session_state.thread_id = generate_thread_id()
# Streamlit config parameter passed to LangGraph
config = {"configurable": {"thread_id": st.session_state.thread_id}}
6. Phase 3: Persistent Session Tracking across Conversations
When a user clicks "New Chat", three sequential state mutations must occur:
- A fresh
thread_idmust be generated. st.session_state.thread_idmust be updated with this new identifier.- The visual message history (
st.session_state.message_history) must be cleared so the chat window appears blank.
The Problem of Disappearing Thread IDs
If only the active thread_id is stored, previous thread identifiers will be overwritten and lost upon creating a new chat. To prevent this, retain all created thread IDs in a collection named chat_threads.
# Initialize thread registry in session state
if "chat_threads" not in st.session_state:
st.session_state.chat_threads = []
def add_thread(thread_id: str):
"""Registers a thread ID in the session history if not already present."""
if thread_id not in st.session_state.chat_threads:
st.session_state.chat_threads.append(thread_id)
def reset_chat():
"""Resets the UI context and initializes a brand new conversation thread."""
new_id = generate_thread_id()
st.session_state.thread_id = new_id
st.session_state.message_history = []
add_thread(new_id)
# Ensure the very first thread created on page load is tracked
add_thread(st.session_state.thread_id)
7. Phase 4: Thread State Retrieval and Format Adapter
To resume a past chat, clicking a thread's button in the sidebar must query LangGraph to pull all past messages associated with that thread and project them onto the frontend.
Inspecting State via chatbot.get_state
LangGraph graphs provide the .get_state(config) method. This returns a StateSnapshot containing graph metadata, values, and checkpoints:
# Querying LangGraph for thread state
target_config = {"configurable": {"thread_id": selected_thread_id}}
state_snapshot = chatbot.get_state(target_config)
# state_snapshot.values contains the current graph state dictionary
raw_messages = state_snapshot.values.get("messages", [])
Schema Incompatibility & Transformation
LangGraph stores message histories as instances of LangChain base message classes (HumanMessage, AIMessage, SystemMessage), while the Streamlit rendering loop expects serializable dictionaries formatted as {"role": "user" | "assistant", "content": "..."}.
LangGraph Backend State
[
HumanMessage(content="My name is Nitish"),
AIMessage(content="Hello Nitish!")
]
|
| Format Adapter Function
v
Streamlit Frontend State
[
{"role": "user", "content": "My name is Nitish"},
{"role": "assistant", "content": "Hello Nitish!"}
]
Implementing load_conversation and the Format Adapter
from langchain_core.messages import HumanMessage, AIMessage
def load_conversation(thread_id: str) -> list:
"""Fetches all message objects for a given thread from LangGraph checkpoint state."""
cfg = {"configurable": {"thread_id": thread_id}}
state = chatbot.get_state(cfg)
return state.values.get("messages", [])
def switch_to_thread(selected_thread_id: str):
"""Updates active session to selected thread and loads historical messages."""
st.session_state.thread_id = selected_thread_id
raw_messages = load_conversation(selected_thread_id)
# Adapter: Convert LangChain message objects to Streamlit dictionary schemas
temp_messages = []
for msg in raw_messages:
if isinstance(msg, HumanMessage):
role = "user"
else:
role = "assistant"
temp_messages.append({"role": role, "content": msg.content})
st.session_state.message_history = temp_messages
8. Reverse Chronological Ordering & UX Optimization
By default, appending new threads to a list displays the oldest threads at the top and the newest at the bottom. Standard messaging UX mandates that the most recently active or created thread appears at the very top.
This is implemented using Python list slicing [::-1] over chat_threads:
# Render past conversations as interactive sidebar buttons in reverse order
st.sidebar.subheader("My Conversations")
for tid in st.session_state.chat_threads[::-1]:
# Display thread ID inside clickable button
if st.sidebar.button(str(tid), key=str(tid)):
switch_to_thread(tid)
9. End-to-End Application Code
Below is the complete, integrated frontend script containing streaming support, dynamic thread isolation, state retrieval, and thread switching:
import uuid
import streamlit as st
from langchain_core.messages import HumanMessage, AIMessage
from backend import chatbot # Pre-configured LangGraph compiled graph
# ==========================================
# 1. UTILITY FUNCTIONS
# ==========================================
def generate_thread_id() -> str:
return str(uuid.uuid4())
def add_thread(thread_id: str):
if thread_id not in st.session_state.chat_threads:
st.session_state.chat_threads.append(thread_id)
def reset_chat():
new_id = generate_thread_id()
st.session_state.thread_id = new_id
st.session_state.message_history = []
add_thread(new_id)
def load_conversation(thread_id: str):
cfg = {"configurable": {"thread_id": thread_id}}
state = chatbot.get_state(cfg)
return state.values.get("messages", [])
def switch_to_thread(selected_thread_id: str):
st.session_state.thread_id = selected_thread_id
raw_messages = load_conversation(selected_thread_id)
temp_messages = []
for msg in raw_messages:
role = "user" if isinstance(msg, HumanMessage) else "assistant"
temp_messages.append({"role": role, "content": msg.content})
st.session_state.message_history = temp_messages
# ==========================================
# 2. SESSION STATE INITIALIZATION
# ==========================================
if "chat_threads" not in st.session_state:
st.session_state.chat_threads = []
if "thread_id" not in st.session_state:
st.session_state.thread_id = generate_thread_id()
if "message_history" not in st.session_state:
st.session_state.message_history = []
# Register the initial thread
add_thread(st.session_state.thread_id)
# ==========================================
# 3. SIDEBAR USER INTERFACE
# ==========================================
st.sidebar.title("LangGraph Chatbot")
if st.sidebar.button("New Chat"):
reset_chat()
st.sidebar.markdown("---")
st.sidebar.subheader("My Conversations")
# Display threads in reverse chronological order
for tid in st.session_state.chat_threads[::-1]:
if st.sidebar.button(str(tid), key=str(tid)):
switch_to_thread(tid)
# ==========================================
# 4. MAIN CHAT VIEWPORT & EXECUTION
# ==========================================
st.title("Conversation")
# Render active message history
for message in st.session_state.message_history:
with st.chat_message(message["role"]):
st.markdown(message["content"])
# User prompt handling
user_input = st.chat_input("Type your message here...")
if user_input:
# 1. Append user prompt to visual history
st.session_state.message_history.append({"role": "user", "content": user_input})
with st.chat_message("user"):
st.markdown(user_input)
# 2. Stream AI response with dynamic thread configuration
config = {"configurable": {"thread_id": st.session_state.thread_id}}
with st.chat_message("assistant"):
response_container = st.empty()
full_response = ""
# Invoke LangGraph streaming generator
for chunk in chatbot.stream({"messages": [HumanMessage(content=user_input)]}, config=config):
# Extract content from streamed state chunks
if "messages" in chunk:
chunk_msg = chunk["messages"][-1]
full_response = chunk_msg.content
response_container.markdown(full_response)
# 3. Append final AI response to visual history
st.session_state.message_history.append({"role": "assistant", "content": full_response})
10. Current Limitations & The Path to Database Persistence
While the current implementation fully mimics ChatGPT's session management during a live server session, it has one major architectural limitation:
Memory Checkpointer vs. External Checkpointer
To achieve true long-term persistence where old conversations survive page reloads and cross-device sessions, the in-memory checkpointer must be swapped for persistent database-backed checkpointers (e.g., AsyncPostgresSaver or SqliteSaver).
11. Summary and Key Takeaways
- Backend Decoupling: The LangGraph state engine isolates conversations automatically via
configurable: {"thread_id": ...}; no backend graph modifications are required to enable multi-session resume chat features. - Dynamic Identification: Hardcoded thread IDs must be replaced with dynamically generated UUIDs (
uuid.uuid4()) to avoid context cross-contamination across sessions. - Three-Step Context Reset: Starting a new chat requires generating a new UUID, updating the active session identifier, and clearing the frontend visual message history (
st.session_state.message_history = []). - Thread Registry: Maintaining a dedicated collection (
chat_threads) within session state prevents previously created thread IDs from being overwritten and lost. - State Inspection (
get_state): Historical state snapshots for any thread can be queried on demand from LangGraph viachatbot.get_state({"configurable": {"thread_id": ...}})without re-running graph nodes. - Format Adapter Requirement: Messages retrieved from LangGraph checkpointers (
HumanMessage,AIMessage) must be adapted into frontend-compatible dictionaries ({"role": ..., "content": ...}) before rendering. - Reverse Chronological UX: Slicing the conversation registry in reverse (
chat_threads[::-1]) ensures newly initialized threads render at the top of the navigation sidebar. - Volatile Storage Caveat: Utilizing an in-memory saver (
MemorySaver) binds state lifecycle to RAM; production persistence across reloads requires external database checkpointers (e.g., SQLite or PostgreSQL).