1 Why use a framework at all?
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 hand | What 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). |
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.
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.
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.
2 LangChain's core building blocks
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.")
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.
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.
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.
.invoke()
contract, which lets them all click together.
3 Chains & LCEL โ composing with the pipe |
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.
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.
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.
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.
| 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
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': '...'}]
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.
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.
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
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
| Piece | What it is | Board-game analogy |
|---|---|---|
| State | A 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. |
| Nodes | The 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. |
| Edges | The 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!")]})
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.
This trivial graph behaves just like a chain. The power appears the moment we add a decision โ next.
6 Conditional branching & routing
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.
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.
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).
7 Multi-step reasoning & cycles
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.
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.
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.
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.
8 Agent persistence & checkpointing
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!)
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.
- 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.
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.
โ 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
- ๐ Original course notes / handout (source sheet) โ open the shared GenAI class material for this session.
- Class handout: "LangChain Fundamentals & LangGraph".
Papers, docs & deep dives
- ๐ LangChain documentation โ Introduction โ the framework, core building blocks, and getting started.
- ๐ LCEL (LangChain Expression Language) โ the pipe operator and composing runnables (Topic 3).
- ๐ LangGraph documentation โ state, nodes, edges, routing, cycles, and checkpointing (Topics 5โ8).