๐Ÿ“š Study Notes / Home / GenAI / Session 6
Session 06 ยท LangChain & LangGraph

Building real apps: LangChain & LangGraph

So far you've learned what a model is (Session 1), how to talk to it well (Session 2), how to give it tools and turn it into an agent (Session 3), and how to feed it your own documents with RAG (Sessions 4 & 5). Now we glue all of that into real software. This session introduces two popular tools โ€” LangChain (for wiring model steps together) and LangGraph (for building agents that loop, branch, and remember). As always, we start every topic with a tiny "explain like I'm 5" story, then build up the real detail with code.

โฑ 24 min read๐Ÿ“– 8 topics

1 Why use a framework at all?


Explain like I'm 5

Imagine you want to build a toy car. You could melt your own plastic, cut your own wheels, and make every screw by hand. Or you could grab a LEGO kit where the wheels, axles, and bricks already snap together. A framework is the LEGO kit for AI apps: lots of ready-made pieces that click together, so you spend your time building the fun part instead of re-making the same little parts every single time.

In Session 3 you called a model's API "by hand": you built a request, sent it, read the reply, and wrote your own loop to handle tool calls. That works! But as your app grows, you keep writing the same plumbing over and over. A framework is a library that packages those repeated patterns into reusable parts. LangChain is the most popular framework for building apps on top of LLMs.

The pain a framework removes

Pain when doing it by handWhat the framework gives you
Boilerplate. Formatting messages, parsing JSON out of replies, retrying on errors โ€” the same code in every project.Pre-built helpers for prompts, parsing, and retries you just plug in.
Swapping models. Each provider (OpenAI, Anthropic, Google) has a slightly different API shape, so changing models means rewriting code.A common interface โ€” change one line to swap ChatOpenAI for ChatAnthropic, the rest stays the same.
Chaining steps. "Summarise, then translate the summary, then rate it" means manually passing the output of one call into the next.A clean way to compose steps into a pipeline (Topic 3).
Tools. Wiring functions the model can call, and looping until it's done (Session 3).Standard tool-binding and ready-made agent loops (Topics 4 & 7).
Memory. Re-sending conversation history every turn (remember, the model is stateless โ€” Session 1).Built-in memory and state management (Topic 8).
The one big idea

A framework doesn't make the model smarter. It removes the repetitive glue code around the model, so you can build complex, multi-step, tool-using, memory-having apps without reinventing the plumbing each time.

Be balanced โ€” frameworks aren't free

Abstractions have a cost. A framework adds another layer between you and the raw API, which means: more concepts to learn, occasionally confusing error messages buried deep in the library, and sometimes fighting the framework when you want to do something it didn't anticipate. For a tiny script โ€” one prompt, one reply โ€” calling the API directly is often simpler and clearer. Reach for a framework when the gluing-together starts to hurt, not before.

Concrete example: the same task, two ways

By hand (sketch): build a messages list, call the provider's SDK, dig the text out of the nested response object, then write your own code to extract the JSON you asked for and handle the case where it's malformed.

With LangChain: describe a prompt template, pick a model, attach an output parser, and connect them with a pipe. Swapping the model later is a one-line change. We'll write this exact pipeline in Topic 3.

Recap A framework like LangChain packages the repetitive plumbing around LLMs โ€” formatting, parsing, model-swapping, chaining, tools, memory โ€” into reusable pieces. It makes big apps easier, but adds a learning curve and an abstraction layer, so for trivial scripts the raw API can still be the better choice.

2 LangChain's core building blocks


Explain like I'm 5

Think of a sandwich. You have a recipe card that says how to fill in the blanks ("bread + ___ + bread"), a chef who actually makes it, and a plate that arranges it neatly so you can eat it. LangChain has the same three helpers: a recipe card for your prompt, the model that does the work, and a tidier-upper that arranges the answer into a useful shape.

LangChain is made of a few small, reusable parts. Once you know these four, almost everything else is just combinations of them.

A โ€” Prompt templates

A prompt template is a fill-in-the-blanks version of a prompt. Instead of gluing strings together by hand, you write the prompt once with {placeholders} and fill them in later. This connects straight to the prompt-engineering ideas from Session 2 โ€” your carefully crafted prompt becomes a reusable, versionable template.

from langchain_core.prompts import ChatPromptTemplate

# A reusable recipe with one blank: {topic}
prompt = ChatPromptTemplate.from_template(
    "Explain {topic} to a complete beginner in 2 sentences."
)

# Fill the blank in later, as many times as you like
prompt.invoke({"topic": "embeddings"})

B โ€” Models / LLMs

The model (or "chat model") is the object that actually talks to the LLM. LangChain wraps each provider behind a common interface, so they all share the same .invoke() method. This is the model-swapping superpower from Topic 1.

from langchain_anthropic import ChatAnthropic

model = ChatAnthropic(model="claude-sonnet-4-5")

# Swapping providers is a one-line change โ€” the rest of your code is identical:
# from langchain_openai import ChatOpenAI
# model = ChatOpenAI(model="gpt-4o")

model.invoke("Say hello in one word.")
Two model "shapes"

LangChain has older plain LLMs (text in, text out) and modern chat models (a list of role-tagged messages in โ€” system / user / assistant โ€” exactly the message structure from Session 1). Today you'll almost always use chat models; they match how every current provider actually works.

C โ€” Output parsers

A model returns a message object, but you usually want something cleaner โ€” just the text, or a Python dictionary, or a validated structure. An output parser takes the raw model output and converts it into the shape your code wants, and can complain if the shape is wrong.

from langchain_core.output_parsers import StrOutputParser

parser = StrOutputParser()   # pulls the plain text string out of the model's reply

There are fancier parsers too โ€” e.g. one that forces the output into a typed object so you reliably get {"name": ..., "age": ...} instead of a paragraph you'd have to scrape.

D โ€” Runnables (the secret that ties it all together)

Here's the clever bit. Prompt templates, models, and parsers all follow one shared contract called the Runnable interface. A runnable is simply "any LangChain piece you can call with .invoke(input) and get an output." Because every piece speaks this same language, they all snap together like LEGO bricks โ€” which is exactly what makes the next topic possible.

Key takeaway

Everything in modern LangChain is a Runnable: it has an .invoke() method, takes an input, and returns an output. Prompts, models, parsers, and whole chains all share this one interface โ€” that uniformity is the whole trick.

Example: each block on its own
prompt_value = prompt.invoke({"topic": "tokens"})  # fill the template
message      = model.invoke(prompt_value)              # ask the model
text         = parser.invoke(message)                  # extract clean text
print(text)

It works, but notice we manually pass each output into the next call. The next topic makes this elegant.

Recap The four core blocks are prompt templates (fill-in-the-blanks prompts), models (the common interface to any LLM), output parsers (shape the reply), and the unifying idea of runnables โ€” every block shares the same .invoke() contract, which lets them all click together.

3 Chains & LCEL โ€” composing with the pipe |


Explain like I'm 5

Picture a water slide made of connected tubes. You drop in at the top, and you slide through tube after tube until you splash out at the bottom. A chain is like that: your input drops in, slides through the prompt, then the model, then the parser, and pops out as a finished answer. The little | symbol is the connector that joins one tube to the next.

A chain is several runnables joined into a pipeline so the output of one becomes the input of the next. LangChain lets you build chains with a wonderfully simple syntax called LCEL (LangChain Expression Language), using the pipe operator | โ€” the same symbol you may know from the Unix command line, where cat file | grep x pipes one program's output into the next.

Building a chain

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_anthropic import ChatAnthropic

prompt = ChatPromptTemplate.from_template(
    "Explain {topic} to a beginner in 2 sentences."
)
model  = ChatAnthropic(model="claude-sonnet-4-5")
parser = StrOutputParser()

# Join them with | โ€” read it left to right like a sentence:
# "fill the prompt, then run the model, then parse the output"
chain = prompt | model | parser

# Now the whole pipeline is itself ONE runnable you call once:
chain.invoke({"topic": "attention"})
# -> "Attention lets a model weigh which words matter most..."

Compare this to the manual version at the end of Topic 2 โ€” same result, but the plumbing vanished. And because a chain is itself a runnable, you can pipe a chain into another chain, building bigger pipelines out of smaller ones.

The one big idea of LCEL

Because every piece is a runnable, a | b | c means "send the input through a, then b, then c." The pipe builds a new, bigger runnable out of smaller ones. Composition โ€” small pieces combining into bigger pieces โ€” is the heart of LangChain.

Example: a two-step chain

Summarise some text, then turn that summary into a tweet โ€” two prompts, one pipeline:

summarise = summary_prompt | model | parser
tweetify  = tweet_prompt   | model | parser

pipeline = summarise | tweetify     # the summary flows into the tweet step
pipeline.invoke({"text": long_article})

The output of summarise becomes the input of tweetify โ€” no manual hand-off needed.

Free perks you get for chaining this way

Because chains are built on the runnable interface, you automatically get extra abilities for free: .stream() to get the answer token-by-token as it generates (the "typing out" effect from Session 1), .batch() to run many inputs at once, and async versions โ€” all without writing extra code.

Recap A chain pipes runnables together so each output feeds the next. LCEL expresses this with the | operator: prompt | model | parser is a full, callable pipeline that is itself a runnable โ€” so chains nest into bigger chains, and you get streaming and batching for free.

4 Tool calling in LangChain


Explain like I'm 5

Imagine a very smart friend who's great at talking but can't do math in their head. So you hand them a calculator and say, "If you ever need to multiply, use this." Now when a math question comes up, your friend reaches for the calculator instead of guessing. Tool calling is handing the model a set of little gadgets (a calculator, a search box, a weather lookup) and letting it decide when to grab one.

Back in Session 3 you met tools: functions the model can ask to run when it can't (or shouldn't) answer from memory alone โ€” like looking up live data, doing exact math, or calling another system. LangChain makes wiring tools to a model very tidy.

Step 1 โ€” Define a tool

A tool is just a normal Python function plus a clear description, so the model knows what it does and when to use it. The @tool decorator turns a function into a LangChain tool. Note how important the docstring is โ€” that text is what the model reads to decide whether to call it (a direct echo of the "describe your tools clearly" lesson from Session 3).

from langchain_core.tools import tool

@tool
def get_weather(city: str) -> str:
    """Get the current weather for a given city."""
    # In real life this would call a weather API.
    return f"It is 22C and sunny in {city}."

Step 2 โ€” Bind the tool to the model

Binding means "tell the model these tools exist." After binding, the model can choose to request a tool call in its reply instead of answering directly.

model_with_tools = model.bind_tools([get_weather])

response = model_with_tools.invoke("What's the weather in Paris?")

# The model doesn't run the function itself. It REQUESTS it:
print(response.tool_calls)
# -> [{'name': 'get_weather', 'args': {'city': 'Paris'}, 'id': '...'}]
Key takeaway

The model never executes your code. It only asks to run a tool, returning the tool's name and arguments. Your program reads that request, actually runs the function, and sends the result back to the model so it can finish its answer. Whose hands are on the keyboard matters โ€” the LLM only points; your code pulls the trigger.

Example: the full round-trip
from langchain_core.messages import HumanMessage

messages = [HumanMessage("What's the weather in Paris?")]
ai_msg   = model_with_tools.invoke(messages)
messages.append(ai_msg)                          # the model's tool request

# Run each tool the model asked for, and feed results back:
for call in ai_msg.tool_calls:
    result = get_weather.invoke(call["args"])
    messages.append(get_weather.invoke(call))    # a ToolMessage with the result

final = model_with_tools.invoke(messages)        # now it can answer for real
print(final.content)   # -> "It's currently 22C and sunny in Paris."

Notice the rhythm: ask โ†’ model requests tool โ†’ you run it โ†’ feed the result back โ†’ model answers. Doing this by hand works, but you can see it wants to be a loop โ€” which is exactly what LangGraph is built to manage. On to it.

Recap Define tools as documented functions (the docstring tells the model when to use them), then bind_tools them to the model. The model only requests a tool with a name and arguments; your code runs it and feeds the result back so the model can finish. This ask-run-return cycle naturally becomes a loop.

5 LangGraph โ€” modelling agents as graphs


Explain like I'm 5

A chain is like a straight train track โ€” you go station 1, 2, 3 and you're done. But a real agent is more like a board game: sometimes you move forward, sometimes you go back a few squares, sometimes you take a shortcut, and you carry a little notepad of what's happened so far. LangGraph lets you draw that board game โ€” squares (steps) connected by arrows (which square to go to next) โ€” and the notepad is the agent's memory of the game so far.

Chains (Topic 3) are linear: A โ†’ B โ†’ C, always in that order, always once. But agents need to loop (try again), branch (decide what to do next), and remember. For that, LangChain has a sibling library: LangGraph. It models your app as a graph โ€” a set of steps connected by arrows that can point anywhere, including backwards.

The three pieces of a graph

PieceWhat it isBoard-game analogy
StateA shared "notepad" of data that flows through the whole graph. Every step can read it and write to it.The game board's current situation โ€” where your pieces are, your score.
NodesThe steps that do work โ€” usually a function or an LLM call. Each node takes the state and returns an update to it.The squares you land on, each with an action.
EdgesThe arrows connecting nodes โ€” they decide which node runs next.The lines telling you which square to move to.

What "state" really means

The state is the heart of a LangGraph app. It's a single object (often a dictionary or a typed structure) that holds everything the agent knows right now: the conversation so far, any retrieved documents (Sessions 4 & 5), intermediate results, counters, flags. Each node receives the current state and returns an update, which LangGraph merges back in. Remember from Session 1 that the model itself is stateless โ€” the graph's state object is how we give the app a memory.

from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages

# The shared notepad: here it just holds a growing list of messages.
class State(TypedDict):
    messages: Annotated[list, add_messages]   # new messages get appended

def chatbot(state: State):
    # a node: read state, call the model, return an update
    return {"messages": [model.invoke(state["messages"])]}

graph = StateGraph(State)
graph.add_node("chatbot", chatbot)   # add a square
graph.add_edge(START, "chatbot")     # start here
graph.add_edge("chatbot", END)       # then finish

app = graph.compile()
app.invoke({"messages": [("user", "Hi!")]})
Why graphs beat linear chains for agents

A chain can only go forward, once. A graph's edges can point to any node โ€” forward, sideways, or back to a previous one. That single freedom unlocks the three things real agents need: branching (choose the next step based on the situation), cycles (loop until done), and a persistent state that carries context through it all. Topics 6โ€“8 are each one of these powers.

Example: picturing the simplest graph
โ–ถ๏ธ
START
user message enters
โ†’
๐Ÿค–
chatbot node
call the model
โ†’
๐Ÿ
END
return the reply

This trivial graph behaves just like a chain. The power appears the moment we add a decision โ€” next.

Recap LangGraph models an app as a graph: nodes (steps that do work), edges (arrows choosing the next node), and a shared state (the notepad every node reads and updates). Because edges can point anywhere, graphs handle the branching, looping, and memory that linear chains can't โ€” which is why agents are built as graphs.

6 Conditional branching & routing


Explain like I'm 5

Imagine a "choose your own adventure" book. At the bottom of a page it says: "If you have the key, turn to page 40. If not, turn to page 12." A conditional edge is exactly that โ€” a fork in the road where the graph looks at what's happening and decides which page (node) to turn to next.

The first real superpower of graphs is the conditional edge (also called routing). Instead of always going to a fixed next node, a conditional edge runs a little function that looks at the current state and returns the name of the node to visit next.

How it works

You write a plain Python function โ€” the router โ€” that reads the state and returns a string. LangGraph then jumps to whichever node matches that string. This is where the agent's "decisions" live.

๐Ÿค–
model node
looks at the question
โ†’
๐Ÿ”€
router
need more info?
โ†’
๐Ÿ“š
retrieve node
if YES: fetch docs
โ†’
โœ…
answer node
if NO: just answer
Example: route to retrieval only when needed

A smart RAG agent (Sessions 4 & 5) shouldn't search your documents for "hello" โ€” that wastes time and money. So we let it decide:

from langgraph.graph import StateGraph, START, END

def route(state: State) -> str:
    # Look at the latest model message and decide where to go.
    last = state["messages"][-1]
    if last.tool_calls:            # model asked to search the docs
        return "retrieve"
    return "answer"             # it can answer directly

graph.add_conditional_edges(
    "model",          # from this node...
    route,            # ...run this function to pick the next node...
    {"retrieve": "retrieve", "answer": "answer"},  # map result -> node
)

Now the same graph behaves differently depending on the question: simple greetings go straight to answer; fact-finding questions detour through retrieve first.

Two common routing styles

Rule-based: the router checks simple conditions (Does the message contain a tool call? Is a counter over 3?). Cheap and predictable. LLM-based: you ask the model itself "which path fits best?" and route on its answer. More flexible, but it's another model call, so it costs tokens and can be less predictable (recall the temperature trade-offs from Session 1 โ€” keep routing decisions low-temperature).

Recap A conditional edge runs a router function that reads the state and returns the name of the next node, so the graph can branch โ€” e.g. "need more info? โ†’ retrieve, else โ†’ answer." Routers can be simple rules or an LLM's own decision. This is where an agent's choices live.

7 Multi-step reasoning & cycles


Explain like I'm 5

Think about solving a jigsaw puzzle. You try a piece โ€” does it fit? No? Try another. Fit? Great, keep going. You keep repeating the same "try and check" move until the picture is done. A cycle in a graph is that repeating loop: the agent does a step, checks if it's finished, and if not, loops back and tries again.

The second superpower of graphs is the cycle โ€” an edge that points back to an earlier node, creating a loop. This is impossible in a linear chain, and it's exactly what turns a one-shot model call into a real, multi-step agent (Session 3).

The ReAct loop, in a graph

The classic agent pattern from Session 3 is ReAct (Reason + Act): the model reasons about what to do, acts by calling a tool, sees the result, and reasons again โ€” over and over until it has enough to answer. In LangGraph that's just two nodes with a loop between them.

โ–ถ๏ธ
START
user asks
โ†’
๐Ÿค–
model
reason: tool or done?
โ†’
๐Ÿ› ๏ธ
tools
act: run the tool
โ†ฉ๏ธŽ
๐Ÿ”
loop back
feed result to model
โ†’
๐Ÿ
END
when no tool needed
Example: wiring the loop
def should_continue(state: State) -> str:
    # If the model asked for a tool, run it; otherwise we're done.
    if state["messages"][-1].tool_calls:
        return "tools"
    return END

graph.add_node("model", call_model)
graph.add_node("tools", run_tools)

graph.add_edge(START, "model")
graph.add_conditional_edges("model", should_continue)
graph.add_edge("tools", "model")   # THE LOOP: after a tool, go back to the model

app = graph.compile()

That single line add_edge("tools", "model") is the whole magic: after every tool run we return to the model, which looks at the new result and decides whether to call another tool or finish. The agent can take 1 step or 10 โ€” it loops as long as it needs to.

Watch out โ€” infinite loops

A cycle can spin forever if the agent never decides it's done (or keeps calling the same failing tool). Always add a brake: a step counter in the state that forces an exit after, say, 10 iterations, and/or a recursion_limit when you run the graph. Loops are powerful, but an agent with no stopping condition will happily burn tokens (and money โ€” Session 1) in circles.

Good news: a shortcut exists

This ReAct loop is so common that LangGraph ships a ready-made builder, create_react_agent(model, tools), that wires up exactly this graph for you. Knowing the pieces underneath (nodes, the conditional edge, the loop-back edge) means you can customise it when the prebuilt version isn't quite right.

Recap A cycle is an edge pointing back to an earlier node, creating a loop. It powers multi-step agents like the ReAct pattern: model reasons โ†’ calls a tool โ†’ loops back with the result โ†’ reasons again, until no tool is needed. Always add a stopping condition so the loop can't run forever.

8 Agent persistence & checkpointing


Explain like I'm 5

You know how a video game lets you save so you can turn it off and come back tomorrow right where you left off? Without saving, every time you start the game you'd begin from the very first level again. Checkpointing is the "save game" button for an agent โ€” it remembers the whole adventure so far, so the agent can pause, come back later, and keep going as if no time passed.

Remember the most important fact from Session 1: the model is stateless โ€” it has no memory between requests. The graph's state (Topic 5) gives memory during a single run, but once the run ends, that state vanishes. Persistence (saving) and checkpointing fix this by saving the state so it survives across turns, sessions, even restarts.

How checkpointing works

You attach a checkpointer when you compile the graph. After every node, LangGraph automatically saves a snapshot of the state โ€” a checkpoint. Each conversation gets a thread_id (think: a save-slot name). When you run the graph again with the same thread_id, it loads the saved state and continues from there.

from langgraph.checkpoint.memory import MemorySaver

checkpointer = MemorySaver()                 # a simple in-memory save store
app = graph.compile(checkpointer=checkpointer)

# A "save slot" name for this conversation:
config = {"configurable": {"thread_id": "user-42"}}

app.invoke({"messages": [("user", "My name is Medha.")]}, config)
# ...later, even after a restart, same thread_id:
app.invoke({"messages": [("user", "What's my name?")]}, config)
# -> "Your name is Medha."  (it loaded the earlier state!)
Key takeaway

Persistence is how the app gains memory even though the model stays stateless. The model still re-reads the whole history each turn (Session 1) โ€” but now the graph is what reliably stores and reloads that history for you, keyed by a thread_id, instead of you re-sending it by hand.

Example: what persistence unlocks
  • Multi-turn memory: the agent remembers earlier messages across separate calls โ€” real chat, not goldfish memory.
  • Pause & resume: a long task can stop and pick up later exactly where it left off.
  • Human-in-the-loop: the graph can pause before a risky step (e.g. "send this email?"), wait for a human to approve, then resume โ€” because the state is safely saved while it waits.
  • Time travel / debugging: since every step is checkpointed, you can rewind to an earlier checkpoint and see (or replay) what the agent was thinking.
In-memory vs durable saving

MemorySaver keeps checkpoints in RAM โ€” great for learning, but everything is lost when the program stops. For real apps you swap in a durable checkpointer backed by a database (e.g. SQLite or Postgres). Same code, same idea โ€” just a sturdier save store that survives restarts and serves many users.

Recap The model is stateless and graph state vanishes when a run ends โ€” so checkpointing saves a snapshot of the state after every node, keyed by a thread_id. Re-running with the same id reloads the state, giving the app durable memory, pause/resume, human-in-the-loop approvals, and debugging โ€” all without making the model itself remember anything.

โ˜… Putting it all together


You just went from "calling the API by hand" to "building looping, branching, remembering agents." Here's the one-paragraph story that connects all 8 topics:

A framework like LangChain removes the repetitive plumbing around LLMs. Its core building blocks โ€” prompt templates, models, and output parsers โ€” all share one runnable interface, which lets you snap them together with the LCEL pipe into chains (prompt | model | parser). To let the model act on the world you bind tools to it; the model only requests a tool and your code runs it, a cycle that wants to be a loop. Linear chains can't loop or branch, so LangGraph models your app as a graph of nodes and edges sharing a state. Conditional edges let it route (need info? โ†’ retrieve, else โ†’ answer), cycles let it loop for multi-step ReAct reasoning, and checkpointing saves the state by thread_id so the app gains durable memory even though the model itself stays stateless โ€” exactly the gap we first spotted in Session 1.

Quick self-check

What single interface lets LangChain pieces snap together with the | pipe?

The Runnable interface. Prompts, models, parsers, and whole chains all share the same .invoke() contract, so the output of one can flow into the next โ€” that uniformity is what makes LCEL composition work.

When the model "calls a tool," does it actually run your function?

No. It only requests the tool, returning a name and arguments. Your own code runs the function and sends the result back so the model can finish its answer. The model points; your code pulls the trigger.

Why use LangGraph instead of a plain LCEL chain for an agent?

Chains are linear โ€” A โ†’ B โ†’ C, once. Agents need to branch (decide), cycle (loop until done), and carry a shared state. LangGraph's edges can point anywhere, including back to an earlier node, which unlocks all three.

What is a conditional edge, in one sentence?

An edge that runs a small router function on the current state and returns the name of the next node to visit โ€” so the graph branches based on what's happening (e.g. "need more info? โ†’ retrieve, else โ†’ answer").

The model is stateless โ€” so how does a LangGraph agent "remember" me across turns?

Checkpointing. A checkpointer saves a snapshot of the graph's state after each node, keyed by a thread_id. Re-running with the same id reloads that state, so the app has memory even though the model never does.

๐Ÿ“š References & Further Reading


Class material

Papers, docs & deep dives