LangGraph vs LlamaIndex: One Agent, Two Architectures
Machine Learning
Agents
LangGraph
LlamaIndex
LLM Orchestration
Author
Ravi Kalia
Published
July 23, 2026
LangGraph vs LlamaIndex: One Agent, Two Architectures
Multi-step LLM workflows need control flow beyond a single chat() call. LangGraph models agents as graph traversal over shared state; LlamaIndex Workflows models them as event-driven @step functions whose type signatures define wiring.
This post builds the same customer-support email router in both frameworks, then adds a three-reply cap with human escalation. Full code: code/.
1 Task definition
Inbound email router:
Classify into billing, support, or sales.
Retrieve top-2 policy snippets from that department’s knowledge base (RAG).
Draft a reply grounded only in retrieved context.
Data: nine hand-written policy snippets (three per department) — refund window, error-code fix, Team plan price. Stand in for help-center or internal wiki text; small enough for a blog post.
Embeddings:text-embedding-3-small → in-memory vector store per department.
Model:gpt-4o-mini (architecture comparison, not model comparison).
Classification asymmetry: LangGraph uses with_structured_output; LlamaIndex uses free-form text + parse fallback — idiomatic per framework, noted below.
2 Agent abstraction
An LLM is stateless: messages in → one assistant message out (answer or tool call).
An agent wraps it with transcript, extra state, tools, and a max-turn stop condition. Only llm.chat() is stochastic.
Code
@dataclassclass Message: role: str# "system" | "user" | "assistant" | "tool" content: str tool_call: dict|None=None# e.g. {"name": "lookup_refund", "args": {...}}class LLM:"""Stateless: a sequence of messages in, ONE new message out."""def chat(self, messages: list[Message]) -> Message: ... # one forward pass; the model never executes anything itself
An agent is the deterministic machinery wrapped around that stochastic call. Concretely, it’s a class holding four things — a growing message sequence (the conversation transcript, which is the model’s only memory), any extra state the task needs, a registry of tools the model is allowed to request, and a stop condition with a max-turn cap so the loop provably terminates. Its run method is a plain deterministic loop: the only nondeterminism anywhere is inside llm.chat; everything else — append, dispatch, check, repeat — is ordinary code you could unit-test with a mocked model.
Code
class Agent:"""Deterministic loop around a stochastic LLM call."""def__init__(self, llm: LLM, tools: dict[str, Callable], max_turns: int=10):self.llm = llmself.tools = tools # name -> callable the agent may runself.messages: list[Message] = [] # the transcript IS the memoryself.state: dict= {} # task state beyond the transcriptself.max_turns = max_turnsdef run(self, user_input: str) -> Message:self.messages.append(Message(role="user", content=user_input))for _ inrange(self.max_turns): reply =self.llm.chat(self.messages) # the ONLY stochastic stepself.messages.append(reply)ifself.is_done(reply): # stop condition: no toolreturn reply # requested -> final answer result =self.tools[reply.tool_call["name"]](**reply.tool_call["args"])self.messages.append(Message(role="tool", content=str(result)))returnself.escalate() # max turns hit: fail safelydef is_done(self, reply: Message) ->bool:return reply.tool_call isNone
Frameworks differ in how transcript, routing, and stop conditions are modeled — not in the loop shape above.
3 LangGraph: shared state graph
Nodes are functions; edges (including conditional) define order. One State object flows through the graph.
3.1 State schema
Code
class State(TypedDict): email: str department: str context: str draft: str# Structured output schema so classification is typed, not string-parsedclass RouteDecision(BaseModel): department: Literal["billing", "support", "sales"] = Field( description="The department best suited to answer this email." )
Node signature: State in → partial dict out. LangGraph merges partial updates.
Code
def classify(state: State) ->dict: decision = llm.with_structured_output(RouteDecision).invoke("Classify this customer email into one department.\n\n"f"EMAIL:\n{state['email']}" )return {"department": decision.department}def make_retrieve_node(dept: str):"""Factory: one retrieval node per department."""def retrieve(state: State) ->dict: docs = STORES[dept].similarity_search(state["email"], k=2)return {"context": "\n\n".join(d.page_content for d in docs)}return retrieve
Three retrieval nodes (retrieve_billing, etc.) via factory — branch visible in graph topology for logging/tracing/draw_mermaid().
No explicit graph object. Steps declare consumed/emitted Event types; framework infers edges from signatures.
4.1 Event types
Code
class RoutedEvent(Event): department: strclass ContextReadyEvent(Event): department: str context: str
@step methods: input/output types define wiring. No add_edge calls.
Code
class EmailRouterWorkflow(Workflow):@stepasyncdef classify(self, ctx: Context, ev: StartEvent) -> RoutedEvent: email: str= ev.emailawait ctx.store.set("email", email) # stash for later steps resp =await Settings.llm.acomplete("Classify this customer email into exactly one department: ""billing, support, or sales.\n""Reply with only the department name, lowercase.\n\n"f"EMAIL:\n{email}" ) dept = resp.text.strip().lower()if dept notin INDEXES: dept ="support"return RoutedEvent(department=dept)@stepasyncdef retrieve(self, ctx: Context, ev: RoutedEvent) -> ContextReadyEvent: email: str=await ctx.store.get("email") retriever = INDEXES[ev.department].as_retriever(similarity_top_k=2) nodes =await retriever.aretrieve(email) context ="\n\n".join(n.get_content() for n in nodes)return ContextReadyEvent(department=ev.department, context=context)
Single retrieve step branches on ev.department inside the function — not visible in static workflow structure.
4.2 Classification note
LlamaIndex classify uses free-form text + fallback; LangGraph uses structured output. LlamaIndex can use structured output; this script does not.
4.3 Context store
Values not carried on events (e.g. original email) go in ctx.store — async key-value scoped to one workflow run.
Code
@stepasyncdef draft(self, ctx: Context, ev: ContextReadyEvent) -> StopEvent: email: str=await ctx.store.get("email") resp =await Settings.llm.acomplete(f"You are a {ev.department} representative. Draft a short, ""friendly reply to the customer email below. Base your answer ""ONLY on the company knowledge provided.\n\n"f"COMPANY KNOWLEDGE:\n{ev.context}\n\n"f"CUSTOMER EMAIL:\n{email}\n\n""REPLY:" )return StopEvent(result={"department": ev.department, "draft": resp.text.strip()})
Single-email routers are comparable in length and complexity.
Branching: LangGraph — named nodes + branch dict (inspectable topology). LlamaIndex — if inside one step.
Incidental state: LangGraph — any State key. LlamaIndex — explicit ctx.store.
Validation: LlamaIndex events validated at construction. LangGraph TypedDict documented but not runtime-validated (typos create silent new keys).
6 Reply cap requirement
New rule: max three agent replies per customer thread; fourth inbound email escalates to human inbox.
Demands:
Thread-scoped state across multiple invocations.
Reply counter persistence.
New escalation branch skipping draft.
6.1 LangGraph v2
Code
class State(TypedDict, total=False): messages: Annotated[list[BaseMessage], add_messages] # full thread department: str context: str reply_count: int escalated: bool forward: str# the internal forward composed on escalation
Annotated[..., add_messages] — reducer appends message updates instead of replacing the list.
New gate node before classify; route_gate reads reply_count:
Code
def gate(state: State) ->dict:"""Anchor node for the reply-cap branch. No state change."""return {}def route_gate(state: State) ->str:if state.get("reply_count", 0) >= MAX_AGENT_REPLIES:return"escalate"return"classify"
gate returns {} — anchor node for conditional edge only. classify skips re-routing if department already set.
Code
def classify(state: State) ->dict:if state.get("department"): # already routed on a previous turnreturn {} decision = llm.with_structured_output(RouteDecision).invoke(...)return {"department": decision.department}
No graph insertion point. Entry step = whichever consumes StartEvent → logic folds into renamed intake:
Code
class EmailThreadWorkflow(Workflow):@stepasyncdef intake(self, ctx: Context, ev: StartEvent ) -> RoutedEvent | EscalateEvent:"""Record the inbound email, enforce the reply cap, route.""" email: str= ev.email history: list=await ctx.store.get("history", default=[]) history.append({"role": "customer", "content": email})await ctx.store.set("history", history) reply_count: int=await ctx.store.get("reply_count", default=0) department: str|None=await ctx.store.get("department", default=None)if reply_count >= MAX_AGENT_REPLIES:return EscalateEvent(department=department or"support")if department isNone: # classify once per thread; then it sticks resp =await Settings.llm.acomplete(...) department = resp.text.strip().lower()if department notin INDEXES: department ="support"await ctx.store.set("department", department)return RoutedEvent(department=department)
Branch via return type union RoutedEvent | EscalateEvent — no edge table.
Persistence: reuse same Context per customer; manual ctx.store read/write. No built-in checkpointer; survives only while Context reference is held.
Code
wf = EmailThreadWorkflow(timeout=120, verbose=False)ctx = Context(wf) # ONE context = ONE customer thread; reuse across runsfor email in inbound: result =await wf.run(email=email, ctx=ctx)