LangChain & LangGraph
Pipelines, stateful agents, and framework selection guidance.
Introduction
LangChain and LangGraph are two of the most widely adopted open-source frameworks for LLM-powered applications. While LangChain provides reusable building blocks for composing AI pipelines, LangGraph adds a graph-based execution model for complex, stateful workflows. But more tooling is not always better: knowing when to reach for these frameworks, and when a 20-line script does the job just as well is one of the most practical skills an AI developer can have.
Quick Navigation
1. What is LangChain?
Chains, LCEL, and the integration ecosystem
2. What is LangGraph?
StateGraph, cycles, and multi-agent workflows
3. Comparison
Side-by-side: paradigm, state, complexity
4. When to Use Each
Decision guide for LangChain, LangGraph, or plain scripts
5. The Case for Plain Scripts
When frameworks add more cost than value
1. What is LangChain?
LangChain is an open-source framework for composing LLM-powered applications from interchangeable components. Instead of writing bespoke glue code for every new project, LangChain gives you a standard set of building blocks, a common interface across 100+ model providers and vector stores, and a composition syntax (LCEL) that makes pipelines readable.
Core Primitives
Chat Models
Unified interface over any LLM provider: Anthropic, OpenAI, Bedrock, Ollama, Groq, and 100+ more. Swap providers without rewriting your chain.
Prompt Templates
Reusable, parameterized prompts that separate your instructions from your inputs. Supports system messages, few-shot examples, and dynamic slots.
Output Parsers
Post-process the LLM's text response: extract structured JSON, validate with Pydantic models, or split into lists.
Document Loaders
Ingest content from PDFs, web pages, databases, S3, Notion, and dozens of other sources into a common Document format.
Text Splitters
Chunk large documents into sizes suitable for embedding and retrieval, with configurable overlap to avoid cutting context mid-sentence.
Vector Stores
Integration layer for Chroma, Pinecone, pgvector, Weaviate, and others. Insert embeddings and run similarity searches with the same API regardless of backend.
LCEL: LangChain Expression Language
LCEL is the glue. It lets you compose any two Runnables (models, prompts, retrievers, parsers) using the | pipe operator, turning a pipeline into a single Runnable that supports streaming, batching, and async out of the box.
from langchain_anthropic import ChatAnthropic
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
prompt = ChatPromptTemplate.from_template("Define {topic} in one short sentence.")
llm = ChatAnthropic(model="claude-sonnet-5")
output_parser = StrOutputParser()
# LCEL composes components with the pipe operator.
chain = prompt | llm | output_parser
# Every piece is a Runnable, so the composed chain is one too and inherits
# the whole interface: invoke, batch, and stream, with no extra work.
print("invoke:", chain.invoke({"topic": "machine learning"}))
print("\nstream:", end=" ")
for chunk in chain.stream({"topic": "a vector database"}):
print(chunk, end="", flush=True)
print("\n\nbatch:")
for answer in chain.batch([{"topic": "RAG"}, {"topic": "an LLM agent"}]):
print(" -", answer)Expected Output:
invoke: Machine learning is a way of teaching computers to learn patterns from data and make predictions or decisions without being explicitly programmed for each specific task. stream: A vector database is a specialized data store that indexes and searches data as high-dimensional vectors, enabling fast similarity-based retrieval rather than exact matching. batch: - RAG (Retrieval-Augmented Generation) is a technique where a language model retrieves relevant external information from a knowledge source and uses it to generate more accurate, grounded responses. - An LLM agent is a system that uses a large language model as its core reasoning engine to autonomously perceive information, make decisions, and take actions—often using tools or external resources—to achieve a specific goal.
LangChain in Practice: A RAG Pipeline
The most common real-world use case for LangChain is a Retrieval-Augmented Generation (RAG) pipeline: retrieve relevant chunks from a vector store and inject them into a prompt before calling the LLM. LCEL makes the flow explicit and easy to read.
from langchain_anthropic import ChatAnthropic
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings
# --- Build and populate the vector store ---
# Chroma runs embedded here: from_texts creates the collection, embeds the
# documents, and holds them in memory. No server and no pre-existing data.
vectorstore = Chroma.from_texts(
texts=[
"Returns are accepted within 30 days of delivery, unopened and in "
"the original packaging. Refunds go back to the original payment "
"method within 5 business days of us receiving the item.",
"Standard shipping takes 3 to 5 business days. Express shipping is "
"next business day if ordered before 2pm.",
"Warranty covers manufacturing defects for 2 years from purchase. "
"It does not cover accidental damage or normal wear.",
],
embedding=OpenAIEmbeddings(model="text-embedding-3-small"),
collection_name="docs",
)
retriever = vectorstore.as_retriever(search_kwargs={"k": 2})
# --- Define the prompt ---
prompt = ChatPromptTemplate.from_template("""
Answer the question using only the context below.
Context: {context}
Question: {question}
""")
# --- Compose the RAG chain with LCEL ---
# Note the mix: OpenAI for embeddings, Anthropic for generation. Swapping
# either one is a single-line change, which is LangChain's main selling point.
llm = ChatAnthropic(model="claude-sonnet-5")
parser = StrOutputParser()
rag_chain = (
{"context": retriever, "question": RunnablePassthrough()}
| prompt
| llm
| parser
)
# --- Invoke ---
print(rag_chain.invoke("What is the return policy?"))Expected Output:
Based on the provided context, the return policy is as follows: - Returns are accepted **within 30 days of delivery**. - Items must be **unopened** and in their **original packaging**. - Refunds are issued to the **original payment method** within **5 business days** after the item is received.
Chroma(collection_name=..., persist_directory=...), which silently assumes a store somebody already populated, so the example cannot run as printed. Chroma.from_texts() above does the whole job: it creates the collection, embeds the documents, and keeps them in memory for the life of the process. Nothing to install, no server to start. Add persist_directory="./chroma_db" to write it to disk and reload it later, or point at a Chroma server the way Lesson 8 does. Note also that this chain mixes providers, OpenAI embeddings with Anthropic generation, which is precisely the interchangeability LangChain is selling.LangChain's Learning Curve
LangChain is powerful, but its abstraction layers can make debugging difficult. Errors often surface several layers deep with stack traces that reference internal framework code. Before adopting it, make sure the integration savings genuinely outweigh this overhead for your project.
2. What is LangGraph?
LangGraph is built on top of LangChain and extends it with a graph-based execution model. Where LangChain chains are linear sequences (A then B then C), LangGraph lets you define workflows as directed graphs where nodes are Python functions and edges can branch conditionally or loop back. This makes it possible to build agents that retry, self-correct, wait for human approval, or coordinate multiple sub-agents in parallel.
The StateGraph Mental Model
Every LangGraph application is a StateGraph. You define:
- State: a TypedDict that represents the data flowing through the graph. Every node reads and writes to it.
- Nodes: plain Python functions that receive the current state and return a partial update.
- Edges: static transitions between nodes, or conditional functions that return the name of the next node dynamically.
Because edges can point back to earlier nodes, LangGraph supports cycles, something a LangChain chain cannot do. An agent can call a tool, observe the result, decide it needs another tool call, loop back, and only move to the final node when it has enough information.
LangGraph Agent Loop
Figure 1: The model loops through tool calls until it has a final answer
LangGraph in Practice: A Tool-Calling Agent
The graph below is written out by hand, node by node, because that is the way to see what a tool-calling loop actually is. In production you would usually not write this: LangChain 1.x ships create_agent, a prebuilt harness that assembles exactly this loop for you. Build it manually once so the harness is not a black box, then use the harness.
from typing import Annotated, TypedDict
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import BaseMessage, HumanMessage, ToolMessage
from langchain_core.tools import tool
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
# --- State definition ---
class AgentState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
# --- Tools ---
@tool
def get_weather(city: str) -> str:
"""Return the current weather for a city."""
readings = {"lisbon": "19C, clear", "oslo": "3C, sleet"}
return readings.get(city.lower(), "no data for that city")
tools = [get_weather]
tool_map = {t.name: t for t in tools}
llm = ChatAnthropic(model="claude-sonnet-5").bind_tools(tools)
# --- Nodes ---
# Nodes return a partial update, not the whole state, so the annotation is
# dict rather than AgentState.
def call_model(state: AgentState) -> dict:
return {"messages": [llm.invoke(state["messages"])]}
def run_tools(state: AgentState) -> dict:
last_msg = state["messages"][-1]
results = []
for call in last_msg.tool_calls:
result = tool_map[call["name"]].invoke(call["args"])
print(f" [tool] {call['name']}({call['args']}) -> {result}")
results.append(ToolMessage(content=result, tool_call_id=call["id"]))
return {"messages": results}
def should_continue(state: AgentState) -> str:
return "run_tools" if state["messages"][-1].tool_calls else END
# --- Build the graph ---
graph = StateGraph(AgentState)
graph.add_node("call_model", call_model)
graph.add_node("run_tools", run_tools)
graph.add_edge(START, "call_model")
graph.add_conditional_edges("call_model", should_continue)
graph.add_edge("run_tools", "call_model")
agent = graph.compile()
# --- Run ---
result = agent.invoke({"messages": [
HumanMessage("Compare the weather in Lisbon and Oslo. One sentence.")
]})
# .text, not .content: with thinking enabled, content is a list of blocks.
print(result["messages"][-1].text)
print(f"\nmessages in final state: {len(result['messages'])}")Expected Output:
[tool] get_weather({'city': 'Lisbon'}) -> 19C, clear
[tool] get_weather({'city': 'Oslo'}) -> 3C, sleet
Lisbon is much warmer and clear at 19°C, while Oslo is cold and wintry with sleet at just 3°C.
messages in final state: 5run_tools loops over a list of tool calls rather than handling one: writing that loop as if there were always exactly one call is a common bug that survives testing until a model decides to parallelise. And the final answer is read with .text, not .content. With thinking enabled, which is the default on current models, content is a list of blocks, so printing it dumps a raw reasoning signature instead of the answer. The five messages in the final state are the human turn, the tool-calling reply, two tool results, and the summary.Key LangGraph Features
Cycles
Loops and retries are first-class. An agent can call tools indefinitely until a stopping condition is met.
Human-in-the-Loop
Pause the graph at any node and wait for human input or approval before continuing. Checkpointing saves state across the pause.
Persistence
Attach a checkpointer (SQLite, Postgres) to save and restore graph state. This enables multi-turn conversations and resumable workflows.
Multi-Agent
Compose multiple sub-graphs as nodes in a parent graph. Agents can hand off control to each other, run in parallel, or aggregate results.
3. LangChain vs LangGraph
LangGraph is not a replacement for LangChain, it is a layer on top. Most LangGraph applications still use LangChain components (ChatAnthropic, tools, prompts) inside their graph nodes. The distinction is about execution model: sequential chains versus stateful graphs.
| Dimension | LangChain | LangGraph |
|---|---|---|
| Paradigm | Sequential pipeline (A then B then C) | Directed graph (nodes + conditional edges) |
| Cycles / loops | Not supported | First-class feature |
| State management | Implicit (passed between chain steps) | Explicit TypedDict shared across all nodes |
| Human-in-the-loop | Awkward to implement | Built-in via checkpointers and interrupt() |
| Persistence | Manual | Native checkpointing (SQLite, Postgres) |
| Multi-agent | Limited | First-class: sub-graphs as nodes |
| Best for | RAG, document Q&A, sequential pipelines | Complex agents, retry logic, long-running workflows |
| Complexity | Low to medium | Medium to high |
| Learning curve | Moderate (LCEL, component model) | Steeper (graph model, state reducers, checkpointers) |
| Relationship | Standalone or used inside LangGraph nodes | Extends LangChain, uses its components internally |
They Work Together
In most production LangGraph apps, the nodes contain LangChain components. You might use a LangChain ChatAnthropic model, a LangChain tool bound to it, and a LangChain prompt template, all wired together inside a LangGraph StateGraph. Think of LangChain as the component library and LangGraph as the orchestration engine.
4. When to Use Each
Use LangChain when...
- You are building a RAG pipeline, retrieval, chunking, embedding, and generation in sequence
- Your workflow is linear and predictable: no branching, no retries, no loops
- You need fast integrations: your stack includes Pinecone, Chroma, OpenAI, Bedrock, or similar. LangChain already wraps them
- You are building a document Q&A system, summarization pipeline, or structured extraction job
- Your team is new to LLMs and wants guardrails and conventions rather than designing everything from scratch
Use LangGraph when...
- You need a tool-calling agent that can loop, calling tools, evaluating results, and deciding whether to continue
- Your workflow requires conditional branching: different paths based on model output or user input
- You need human-in-the-loop: pause the workflow, collect approval, then resume from the saved checkpoint
- You are orchestrating multiple agents that coordinate, hand off tasks, or run in parallel
- You need long-running stateful conversations that persist across sessions (e.g., a coding assistant with memory)
Avoid Both When...
- Your use case is a single API call. Adding a framework for one
messages.create()call is pure overhead - You are prototyping or experimenting, iterate fast with raw SDK calls, then introduce a framework if complexity warrants it
- The team does not need the integrations, if you already control your vector store, embedding logic, and model calls, the framework adds nothing
- You are hitting a performance bottleneck, framework layers add latency and memory overhead that a hand-optimized script avoids
5. The Case for Plain Scripts
There is a persistent temptation to reach for a framework the moment a task involves an LLM. Resist it. Every framework is a tradeoff: you gain convenience and standardized patterns, but you pay in added dependencies, more complex debugging, and a steeper onboarding curve for new engineers. For a surprisingly large class of problems, a plain Python script calling the Anthropic SDK directly is the right answer.
What a "Plain Script" Looks Like
import anthropic
client = anthropic.Anthropic()
TEXT = (
"The 30 day return window starts on the delivery date, not the order "
"date. Items must be unopened. Refunds reach the original payment "
"method within 5 business days of the warehouse receiving the item."
)
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
messages=[{"role": "user", "content": f"Summarize in one sentence:\n\n{TEXT}"}],
)
print(next(b.text for b in response.content if b.type == "text"))Expected Output:
The 30-day return period begins upon delivery (not order placement) and requires unopened items, with refunds processed to the original payment method within 5 business days after the warehouse receives the returned item.
That is the entire call. No chains, no graphs, no state reducers. If your task is "summarize this document" or "classify this input", ten lines with the raw SDK is simpler, faster, and easier to debug than an equivalent LangChain chain.
Signals That You Should Stay Simple
Single inference step
If your app calls the LLM once to produce an output, there is nothing to chain or graph. One function is enough.
Early exploration
You are still figuring out what the model can and cannot do. Plain calls let you iterate in seconds without fighting framework abstractions.
Full-stack control
You own the vector store, the embedding pipeline, and the model call. None of LangChain's integrations save you any work.
Latency or cost sensitivity
Framework overhead is small but non-zero. High-throughput or cost-optimized inference paths benefit from cutting every unnecessary layer.
Small team, long maintenance
Every dependency you add is a dependency your team must understand and upgrade. Fewer moving parts means fewer future surprises.
A Practical Rule of Thumb
Start with a plain script. If you find yourself writing custom retry logic, managing state across multiple model calls, or manually wiring in several external systems, that is when a framework earns its keep. Introduce LangChain when the integration savings are concrete, and add LangGraph when your workflow genuinely needs cycles or stateful branching.
Bonus: core-genai - Lightweight Provider Abstraction
If you want a clean abstraction over multiple LLM providers without the overhead of LangChain, core-genai is a lightweight library that fits the plain-scripts philosophy. It provides a single unified interface for Claude, Gemini, ChatGPT, and Grok, with built-in cost tracking and normalized batch operations, but adds none of the orchestration complexity that LangChain brings.
The library provides:
- Provider-agnostic IAgent interface - swap Claude for Gemini or ChatGPT by changing a single
create_agent()call; the rest of your code stays identical - Automatic cost calculation - every response includes USD cost via
agent.get_cost(output)and normalized token metadata - Batch inference support - a unified
ISchedulerinterface for submitting, polling, and extracting batch jobs across all supported providers - Minimal footprint - provider SDKs are optional extras; install only what you need:
pip install "core-genai[claude]"
Key Takeaways
- LangChain is a component library for LLM apps: prompt templates, output parsers, document loaders, vector store integrations, and a pipe-based composition syntax (LCEL)
- LangGraph extends LangChain with a graph execution model that supports cycles, conditional branching, human-in-the-loop, and persistent state across sessions
- They are complementary - LangGraph nodes typically use LangChain components internally. You rarely choose one over the other; you choose which layer fits the current problem
- LangChain shines for RAG pipelines, sequential document processing, and cases where prebuilt integrations save meaningful work
- LangGraph shines for agents with tool loops, multi-agent orchestration, and workflows that need pausing, resuming, or branching based on dynamic conditions
- Plain scripts win for single-step inference, prototyping, full-stack-controlled pipelines, and performance-sensitive paths where framework overhead is unjustified
- Read the answer with
.text- on current modelscontentis a list of blocks once thinking is on, so printing it dumps a reasoning signature instead of the reply - Handle tool calls as a list - a model can request several in one turn, and code written for exactly one call breaks the first time it parallelises
- The best engineers default to the simplest solution and add framework complexity only when the problem genuinely requires it