AI EngineeringZero to ProductionHome·About·What’s new·Contact
LangChain & LangGraph · Chapter L2

LangChain Core — Chains, Memory & RAG

LangChain's core idea is small: everything is a Runnable, and you pipe Runnables together with |. Learn that one abstraction and prompts, models, parsers, retrievers, and memory all compose the same way. This chapter builds a chain, adds memory, then rebuilds Chapter 3's RAG in a few lines.

⏱️ ~65 min🧪 4 labs🎯 Intermediate
⚙️ To run this for realThe code here is complete and correct as written. To actually execute it you'll need an API key for whichever vendor you follow (toggle the Claude | OpenAI tabs on the code below):
  • Claude: an Anthropic API key (ANTHROPIC_API_KEY) from console.anthropic.com + pip install langchain-anthropic
  • OpenAI: an OpenAI API key (OPENAI_API_KEY) from platform.openai.com + pip install langchain-openai
LangChain hides the SDK differences — only the import and model line change between vendors; the prompt, the | pipe, and invoke are identical.Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.

Learning objectives

  • Explain the Runnable interface and LCEL (the | pipe).
  • Compose prompt → model → output-parser into a chain.
  • Get typed output with structured-output parsers (Pydantic).
  • Add conversation memory / history to a chain.
  • Build a RAG chain with a retriever and see it as Chapter 3, composed.
Versions move fast — learn the shapes, verify the importsLangChain's package layout and class names change across versions (e.g. langchain-anthropic, langchain-core, LCEL vs legacy Chain classes). The concepts here are stable; treat exact import paths as illustrative and check them against the version you install.

The one idea: Runnables + LCEL essential

People bounce off LangChain because they think it's a hundred classes to memorize. It isn't. It's essentially one idea wearing a lot of hats, and the day that idea clicks, the entire framework stops feeling like a maze and starts feeling like a single, repeatable move. Miss the idea and you'll copy-paste tutorials forever, half-understanding each one; get it and you can read any LangChain snippet at a glance. So slow down on this section — everything after it is just this same trick applied again.

Here's the analogy that unlocks it: think of the Unix command line. You don't have one giant program that fetches, filters, sorts, and counts — you have small tools (cat, grep, sort, wc) and a | that pipes the output of one straight into the next. Each tool does one job and speaks a common language (text in, text out), so you can snap them together in any order. LangChain does exactly this for LLM apps: every building block speaks a common language, and | snaps them together. A prompt, a model, a parser, a retriever — each is a small tool; the pipe is the plumbing.

The common language has a name: the Runnable. A Runnable is simply anything with an .invoke(input) method — hand it an input, get an output. That's the whole contract. (It also offers .stream() for chunk-by-chunk output, .batch() for many inputs at once, and async variants — but those all come free once a thing is a Runnable.) Because prompts, models, parsers, and retrievers all implement that same tiny interface, they're interchangeable Lego bricks: they fit together because they share one shape.

The common mistake is reading chain = prompt | model | parser and assuming | is some LangChain magic keyword. It isn't magic and it isn't special syntax — it's Python's ordinary or operator, which every Runnable overloads (via __or__) to mean "compose." a | b builds a new Runnable that runs a, feeds its output into b, and returns the result. That's why you can keep chaining: the result of a pipe is itself a Runnable, so it too can be piped, invoked, streamed, or batched. Once you see that | is just function composition dressed in a friendly operator, LCEL holds no more surprises. Now the precise version: LCEL (LangChain Expression Language) lets you connect Runnables with the | operator, so the output of the left becomes the input of the right — exactly like a Unix pipe. A Runnable is anything with an .invoke(input) method (also .stream(), .batch(), and async variants). Prompts, models, parsers, retrievers — all Runnables.

Prompt | Model | Output parser chain = prompt | model | parser  →  chain.invoke({...}) each box is a Runnable; the pipe wires output → input Compose, don't wire by hand. Where C2 had you build the request, call the API, and parse the reply as separate steps, LCEL makes that one composable object you can invoke, stream, or batch — and swap any piece without touching the rest.
🗺️ How to read this diagram

This is the single idea the whole chapter rests on. LangChain calls every building block a Runnable — a thing you can hand an input and get an output back. The | (pipe) symbol wires them in a line: whatever the left box produces is fed straight into the box on its right.

  • Read it left to right. The Prompt box turns your inputs (like a topic) into a proper message for the model. The Model box is the LLM that writes an answer. The Output parser box tidies that answer into a plain string.
  • Each | between the boxes is the pipe. It means "take the output of the box on the left and use it as the input to the box on the right" — exactly like a Unix pipe joining commands.
  • The line chain = prompt | model | parser builds the whole assembly once into a single object. chain.invoke({...}) then runs the input through all three boxes in order and hands you the final result.
  • Because each box is an independent Runnable, you can swap one (a different model, a different parser) without rewiring the others.

In short: A chain is just boxes joined by pipes: input goes in the left, flows through each box, and the finished answer comes out the right. Everything else in this chapter is variations on this one picture.

Lab L2.1 · Your first chain essential

The fastest way to believe the "one idea" is to watch three lines do what took a dozen in Chapter 2. Back then, calling a model was a little ritual: assemble the messages, call the API, then reach into the response object to dig out the text. Three chores, three chances to get the plumbing wrong. This lab collapses all three into a single object you build once and run in one line — and the payoff isn't just brevity, it's that the object is now composable.

Picture an assembly line with three stations. The first station (the prompt) takes your raw inputs — a topic, an audience — and stamps them into a properly formatted message. The second (the model) takes that message and produces a reply. The third (the parser) trims the reply down to just the plain text you actually wanted. You don't carry the part between stations by hand; the conveyor belt — the | pipe — does it. Build the line once, then feed it inputs all day.

The mistake to avoid here is treating each piece as glued permanently to the others. Because prompt, model, and parser are independent Runnables joined only by pipes, any one is a drop-in swap: point at a different model, or replace the string parser with a typed one (that's the very next lab), and the other two stations don't notice. The values flowing through change; the wiring doesn't. That separation — stable structure, swappable parts — is exactly the maintainability win the whole chapter is selling, and you'll feel it first right here in the smallest possible chain.

build the line once, then feed it inputs all day inputs {topic,audience} prompt fill the blanks model write a reply parser strip to text → "…" the | pipe is the conveyor belt — it carries each part to the next swap any one station; the wiring never changes Three stations, one conveyor belt. Your input dict is stamped into a message by the prompt, answered by the model, and trimmed to plain text by the parser — and the | pipe moves it between them so you never carry the part by hand.
🗺️ How to read this diagram

Read it left-to-right, following the arrows — the same order a part travels down an assembly line.

  • The leftmost inputs box is the dict you pass to invoke ({topic, audience}) — the raw parts entering the line.
  • The three middle boxes are the stations: the green prompt fills the blanks into a message, the blue model writes the reply, the amber parser strips it down to a plain string (the green "…" coming out the right).
  • Each arrow is a | pipe — the conveyor belt that hands one station's output to the next, so you never move the part by hand. The dashed line underneath is the payoff: any single station can be swapped without disturbing the belt or the other stations.

In short: Build the three-station line once with prompt | model | parser, then feed it inputs all day — and swap any station without rewiring the rest.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Lab L2.1

Your first chain — in Claude or OpenAI

LangChain gives both vendors the same Runnable interface, so the prompt, the | pipe, and invoke are identical — only the model line changes: ChatAnthropic (from langchain_anthropic) or ChatOpenAI (from langchain_openai). Your choice applies across the whole page.

shellpip install langchain langchain-core langchain-anthropic
first_chain.pyfrom langchain_anthropic import ChatAnthropic
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

model = ChatAnthropic(model="claude-opus-4-8", max_tokens=512)
prompt = ChatPromptTemplate.from_template(
    "Explain {topic} to a {audience} in two sentences."
)

chain = prompt | model | StrOutputParser()   # LCEL: three Runnables piped

print(chain.invoke({"topic": "vector databases", "audience": "new engineer"}))
▶ How this works

This is your first complete chain. In plain Anthropic code (Chapter 2) you built the request, called the API, then dug the text out of the reply as three separate steps. Here those steps become three Runnables joined by pipes — one object you can run in a single line.

  1. The three import lines bring in the three pieces: ChatAnthropic (the model), ChatPromptTemplate (turns your inputs into a message), and StrOutputParser (pulls the plain text out of the model's reply).
  2. ChatPromptTemplate.from_template("Explain {topic} to a {audience}...") makes a reusable prompt with blanks. The {topic} and {audience} in curly braces are placeholders you fill in when you run the chain.
  3. chain = prompt | model | StrOutputParser() is the key line. The | pipes wire the three together: your inputs → filled-in prompt → model reply → plain string.
  4. chain.invoke({"topic": ..., "audience": ...}) runs it. The dictionary fills the two blanks, and the chain returns a finished sentence as a string you can print.

What the output means: You get a two-sentence explanation of vector databases aimed at a new engineer — returned as a plain string, because StrOutputParser stripped away the message wrapper the model normally returns.

Try this: Change "vector databases" to "recursion" and "new engineer" to "5-year-old", then re-run. Same chain, brand new answer — you only changed the inputs, not the wiring.

What each piece doesChatPromptTemplate turns your dict into messages. ChatAnthropic is the model Runnable (wrapping the same claude-opus-4-8 from C2). StrOutputParser pulls the plain text out of the response object so the chain returns a string, not a message. Swap any one without touching the others.

Lab L2.2 · Structured output essential

The moment code — not a human — reads the model's answer, prose becomes a landmine. A person shrugs off "Sure! This looks like a billing issue, probably high priority." Your if ticket.priority == "high": line does not: it needs the word high sitting in a known field, spelled exactly, every single time. The instant you try to parse a free-text answer with string-slicing or a regex, you've signed up for a bug that shows up only when the model phrases things a little differently — which it will, tomorrow, in production, on the input you didn't test.

Think of it as the difference between a handwritten note and a form with checkboxes. The note might contain the answer, but you have to read and interpret it, and two notes never look quite alike. A form constrains the writer: category must be one of these four boxes, priority one of these three, summary goes on this line. Structured output turns the model from a note-writer into a form-filler. You hand it a schema — a Pydantic model listing the exact fields and their allowed values — and you get back a real Python object with typed attributes, not a paragraph you have to decode.

The common mistake is thinking this is a LangChain invention you're now locked into. It isn't — it's the same structured-output guarantee you met with the raw SDK in Chapter 2 (with_structured_output is just a thin wrapper over the provider's native feature), and the Literal[...] types become an enum the model is constrained to satisfy, so a surprise value like "urgent" simply can't come back. What LangChain adds is that the wrapped model is still a Runnable, so this typed step pipes into a chain exactly like everything else. Now the precise version: free text is a liability once code consumes it (Chapter 2), so LangChain gives you a typed path — bind a schema to the model and it returns a validated object.

without a schema · a note "Sure! looks like a billing issue, probably high priority" ticket.priority == "high"? ✗ regex & guessing phrasing shifts → parse breaks with a schema · a form category: bug|feature|billing|other priority: low|medium|high summary: str Ticket(billing, high…) ✓ typed attributes t.priority — always valid Note vs. form. Free prose (left, red) forces downstream code to guess and breaks the day the model rephrases; a schema (right) constrains the model to a fixed Ticket form, so t.priority is a real, validated field every time.
🗺️ How to read this diagram

Compare the two halves side by side: the red left column is the fragile "read the prose" path; the green right column is the "fill the form" path.

  • The left (red) side shows the model returning a sentence, which your code then tries to test with ticket.priority == "high". Because the wording changes, that check is guessing — it breaks the moment the model rephrases.
  • The right (green) side starts from a schema: the fields and their allowed values (Literal[...]). The model is constrained to fill exactly that shape, producing a Ticket object.
  • The payoff is the bottom-right line: t.priority is a real typed attribute you read directly — no regex, no surprise value like "urgent" can come back.

In short: Turn the model from a note-writer (left, must be interpreted) into a form-filler (right, ready to use) by binding a schema with with_structured_output.

Lab L2.2
structured.pyfrom pydantic import BaseModel, Field
from typing import Literal
from langchain_anthropic import ChatAnthropic

class Ticket(BaseModel):
    category: Literal["bug","feature","billing","other"]
    priority: Literal["low","medium","high"]
    summary: str = Field(description="one-line summary")

model = ChatAnthropic(model="claude-opus-4-8", max_tokens=512)
structured = model.with_structured_output(Ticket)   # returns a Ticket instance

t = structured.invoke("I was charged twice and support hasn't replied in 3 days!")
print(t.category, t.priority)   # billing high
▶ How this works

Sometimes you don't want a paragraph — you want clean, labelled data your code can act on. This lab makes the model fill in a fixed form (a support ticket) and hands you back a real Python object with typed fields, not free text you'd have to parse by hand.

  1. class Ticket(BaseModel) uses Pydantic to describe the exact shape you want back. Literal["bug","feature",...] means that field must be one of those listed words — the model isn't allowed to invent a different value.
  2. model.with_structured_output(Ticket) wraps the model so that, instead of prose, it returns something matching your Ticket form. This one call is doing the heavy work.
  3. structured.invoke("I was charged twice...") sends the complaint. The model reads it, decides the category, priority, and summary, and returns a filled-in Ticket.
  4. t.category and t.priority read the fields off that object like any normal Python attribute — no string-slicing, no guessing.

What the output means: Prints billing high — the model classified the double-charge complaint as a billing issue of high priority, delivered as typed data you could store or branch on directly.

Try this: Feed it a different message like "The dark-mode button is missing" and print t.summary too. Watch the category flip to feature or bug while the shape of the result stays identical.

Same guarantee as C2's messages.parse()with_structured_output is LangChain's wrapper over the provider's structured-output feature. The Literal types become an enum the model must satisfy — no regex, no surprise values. It's Chapter 2's lesson with a framework interface on top.

Lab L2.3 · Adding memory intermediate

Here's the uncomfortable truth that surprises everyone building their first chatbot: the model has no memory at all. None. Each API call is a clean slate — the model that answered your last question has already forgotten it existed. If a chatbot seems to "remember" your name, that's an illusion the application maintains, not something the model does. Get this wrong and you'll ship an assistant that cheerfully asks "What's your name?" three turns after you told it, and you'll waste an afternoon hunting for a bug that isn't in your code — it's in your mental model.

So how does any chatbot remember anything? By cheating, honestly: it keeps a transcript on the side and resends the whole conversation with every new message. The model looks omniscient because on turn five it's handed turns one through four all over again, as if reading a script from the top each time. Picture an actor with total amnesia who nonetheless nails every scene — because a stagehand hands them the entire play-so-far before each line. The model is the amnesiac actor; the memory object is the stagehand.

Doing that stagehand work by hand is tedious and error-prone: you'd maintain a list, append each user turn and each reply, splice it into the prompt in the right spot, and keep separate lists for separate users. LangChain's memory Runnables automate exactly this — you tag each conversation with a session_id, and the wrapper loads that session's history into a placeholder before the call and appends the new exchange after it. The common mistake is to then believe memory is something clever or persistent: it is neither. It is the resend-the-list pattern with a tidy interface, which is why (as the note after the lab warns) it's unbounded, grows your token bill every turn, and vanishes on restart unless you back it with a real store. Now the precise version: the API is stateless (C2) — every call resends the history — and LangChain wraps that pattern so a chain "remembers" a conversation by session id, without you managing the list by hand.

store[session_id] — the transcript "My name is Alice." (user) "Hi Alice!" (ai) ① load history prompt history + new input model (stateless) ③ append the new user+ai turn back to the store ② send the whole transcript every call The stagehand, drawn. The model is a stateless amnesiac; the wrapper loads the session's transcript into the prompt (①), sends the whole thing (②), then appends the new exchange back to the store (③) — so "memory" is just resend-the-list.
🗺️ How to read this diagram

Follow the numbered steps as a loop that repeats every turn: load, send, append.

  • The purple store box (top-left) is the saved transcript for one session_id — the stagehand's copy of the play so far.
  • Step ① loads that history into the green prompt, which combines it with the new user input; step ② sends the entire transcript to the blue model, which is stateless and remembers nothing on its own.
  • The amber step ③ arrow is the payoff: after the reply, the new user+ai exchange is appended back into the store, so next turn's load already contains it — that closed loop is the whole illusion of memory.

In short: Memory = load the session's transcript → send it all → append the new turn. Different session_id, different store, different memory.

Lab L2.3
memory_chain.pyfrom langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_anthropic import ChatAnthropic

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a concise assistant."),
    MessagesPlaceholder("history"),        # prior turns slot in here
    ("human", "{input}"),
])
chain = prompt | ChatAnthropic(model="claude-opus-4-8", max_tokens=512)

store = {}
def get_history(session_id):
    return store.setdefault(session_id, InMemoryChatMessageHistory())

chat = RunnableWithMessageHistory(chain, get_history,
    input_messages_key="input", history_messages_key="history")

cfg = {"configurable": {"session_id": "user-1"}}
chat.invoke({"input": "My name is Alice."}, config=cfg)
print(chat.invoke({"input": "What's my name?"}, config=cfg))  # "Alice"
▶ How this works

The model has no memory — each call is independent, so on its own it can't recall your name from an earlier message. This lab wraps a chain so it automatically remembers a conversation, tracked by a session_id, without you juggling the message list yourself.

  1. The prompt is built from three parts: a fixed system instruction, a MessagesPlaceholder("history") — an empty slot where past turns get inserted — and the new {input} from the user.
  2. store = {} plus get_history keep one history object per session id. setdefault creates a fresh empty history the first time it sees a new session, then reuses it after that.
  3. RunnableWithMessageHistory(chain, get_history, ...) is the wrapper that does the magic: before each call it drops the saved history into the placeholder, and after each call it appends the new exchange back into the store.
  4. The two chat.invoke(...) calls share the same cfg (session user-1). The first tells it "My name is Alice"; the second asks "What's my name?" — and because the first turn was remembered, it can answer.

What the output means: Prints Alice. The second question had no name in it, but the remembered first turn was resent along with it, so the model had the context to answer.

Try this: Add a third call asking "And what did I say first?" with the same cfg. Then change the session id to "user-2" and ask the name again — the fresh session won't know it, proving memory is per-session.

Memory is just managed history — mind the window & costInMemoryChatMessageHistory is fine for a demo but lost on restart and unbounded — every turn resends the full history, growing tokens and cost (C2). For production, back it with a real store and trim/summarize old turns. Memory isn't magic; it's the resend-the-list pattern with a nicer interface. For durable, checkpointed state you'll want LangGraph (L5).

Lab L2.4 · RAG, composed intermediate

This is the lab that pays off the whole chapter. Chapter 3 had you build Retrieval-Augmented Generation the long way — chunk the documents, embed them, store the vectors, retrieve the closest ones, stuff them into a prompt, generate. That was a page of careful code. Watch what happens when each of those stages is already a Runnable: the entire pipeline collapses into a handful of piped lines. If the "one idea" ever felt abstract, this is where it turns into leverage you can feel.

Why does RAG exist at all? Because a model only knows what it was trained on — it has never seen your refund policy, your runbooks, last week's incident notes. Ask it directly and it will either admit ignorance or, worse, confidently invent a plausible-sounding answer. RAG fixes this by doing what a good research assistant does: look it up first, then answer from what you found. Fetch the relevant passages from your own documents, hand them to the model as context, and instruct it to answer only from that. The model stops being an oracle and becomes a skilled paraphraser of facts you supplied.

The one new wrinkle here is that the chain doesn't start with a single value flowing down a line — it starts by doing two things at once. It needs the retrieved context and the original question, side by side, to fill the prompt. LCEL expresses that with a dictionary of Runnables ({"context": retriever | format_docs, "question": RunnablePassthrough()}): a parallel step where each key runs its own branch — one fetches and formats documents, the other forwards the question untouched — and the results merge into a dict the prompt consumes. So pipes aren't only for straight lines; a dict runs branches in parallel just as naturally.

The mistake this section wants to inoculate you against is blaming the framework when answers are bad. Wrapping RAG in LCEL changes none of the retrieval physics: if your chunks are too big, or your top-k grabs the wrong passages, the model is grounded in garbage and answers accordingly — same failure modes as Chapter 3, same fixes. The framework assembles the pipeline elegantly; it does not make the retrieval smart. Now the precise version: Chapter 3 built RAG from scratch (chunk, embed, store, retrieve, generate), and LangChain gives you each stage as a component that LCEL wires into one chain.

Retriever question Prompt Model retrieved context + question → grounded answer Chapter 3, as a chain. The retriever fetches relevant chunks; a prompt template stitches context + question; the model answers grounded in the context. Same pipeline, same failure modes (bad chunking, bad top-k) — assembled from components.
🗺️ How to read this diagram

This shows RAG (Retrieval-Augmented Generation) drawn as a chain. RAG means: before the model answers, fetch relevant reference text and hand it to the model so the answer is grounded in your documents instead of the model's memory. Chapter 3 built this by hand; here it's the same idea as connected boxes.

  • There are two inputs on the left that run at the same time: the Retriever (which searches your documents for chunks related to the question) and the question itself (passed through unchanged).
  • Both arrows feed into the Prompt box, which stitches them together: "here is the context I found, and here is the question — answer using only this."
  • The Model box then writes the answer, but constrained to the fetched context. The final arrow off the right is that grounded answer coming out.
  • The caption's point: this is the same pipeline as building RAG from scratch, so it has the same weak spots — if the retriever fetches the wrong chunks, the answer suffers no matter how good the model is.

In short: RAG in one line: look things up first, then answer using what you found. The retriever supplies the facts; the model just phrases them.

Lab L2.4

Requires: pip install langchain-anthropic langchain-core (Claude) or langchain-openai langchain-core (OpenAI)

rag_chain.pyfrom langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
from langchain_anthropic import ChatAnthropic

# retriever = vectorstore.as_retriever()  # built from your embedded docs (Ch 3 / A7)

prompt = ChatPromptTemplate.from_template(
    "Answer using ONLY this context. If it's not there, say you don't know.\n\n"
    "Context:\n{context}\n\nQuestion: {question}"
)
model = ChatAnthropic(model="claude-opus-4-8", max_tokens=512)

def format_docs(docs): return "\n\n".join(d.page_content for d in docs)

rag = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt | model | StrOutputParser()
)
print(rag.invoke("What is our refund window?"))
▶ How this works

This builds the RAG diagram in code. It's the fullest chain in the chapter because it does two things at once — look up context and keep the question — before prompting the model. Read it bottom-up: the rag = (...) block is where the whole assembly comes together.

  1. The commented retriever = vectorstore.as_retriever() line is where your document search would come from (built in Chapter 3 / A7). It's commented out so the snippet stays focused on the wiring.
  2. The prompt template has two blanks, {context} and {question}, and firmly instructs the model to answer only from the supplied context — the guardrail that stops it from making things up.
  3. format_docs(docs) is a small helper: the retriever returns a list of document objects, and this glues their text together into one string the prompt can drop into {context}.
  4. The rag = ({"context": retriever | format_docs, "question": RunnablePassthrough()} | prompt | model | StrOutputParser()) block is the chain. The opening dictionary runs two branches in parallel — one fetches and formats context, the other (RunnablePassthrough) just forwards the question — then pipes the pair through prompt → model → parser.

What the output means: rag.invoke("What is our refund window?") returns a plain-text answer built only from the retrieved context — or an honest "I don't know" if the context doesn't cover it.

Try this: Notice the dictionary at the start: that's how LCEL runs steps side by side, not just in a line. Try imagining a third key, e.g. "user", added to that dict — it would flow through to the prompt too.

The dict is a parallel RunnableThat opening {"context": ..., "question": ...} runs both branches and passes a dict downstream — the retriever fetches+formats context while RunnablePassthrough forwards the raw question. LCEL composes parallel steps as easily as sequential ones. Everything you learned in Chapter 3 about chunking and retrieval quality still governs the result.

The LangChain ecosystem, briefly intermediate

"LangChain" is one word for what is really a small family of packages, and confusing them is a common early stumble. People install langchain, follow a tutorial that imports from langchain-core, hit a class that lives in langchain-anthropic, and conclude the whole thing is broken. It isn't — it's just that the project split into focused pieces so you only pull in what you use, and knowing which piece owns what saves you a lot of confused import errors.

The mental model is a kitchen. langchain-core is the countertop and the standard fittings — the Runnable/LCEL primitives every recipe shares. The provider packages (langchain-anthropic and its siblings) are appliances you plug into those fittings: swap the appliance, keep the counter, and your recipes barely change — that's how you switch model vendors by changing one import. Retrievers and vector stores are the pantry (your RAG components from Chapter 3 and A7). And two bigger tools sit alongside for when a straight chain isn't enough: LangGraph for stateful, looping agent graphs (L4/L5), and LangSmith for tracing and evaluation across everything you build. The table below is your map of which piece is which.

the packages that make up "LangChain" langchain-anthropic provider · swappable retrievers / vector stores (RAG) langchain-core Runnable / LCEL primitives — the shared counter LangGraph stateful/cyclic graphs (L4/L5) LangSmith tracing & eval, across all all of it plugs into the core interface bigger tools alongside One kitchen, focused pieces. langchain-core is the countertop every recipe shares; provider packages and RAG components plug into it (swap the appliance, keep the counter), while LangGraph and LangSmith sit alongside for stateful graphs and tracing.
🗺️ How to read this diagram

Read it as a stack: the wide box in the middle is the foundation, and the pieces above plug into it, while two larger tools stand to the right.

  • The central blue langchain-core box is the countertop — the Runnable/LCEL primitives every other piece depends on.
  • The two boxes above it, joined by arrows, are what you plug in: the green provider package (langchain-anthropic — swap it to change model vendor) and the amber retrievers / vector stores that carry your RAG components.
  • The two purple boxes on the right (LangGraph, LangSmith) are the bigger tools that sit alongside for when a straight chain isn't enough — stateful graphs and tracing/eval.

In short: Learn langchain-core once; everything else either plugs into it (providers, retrievers) or sits beside it (LangGraph, LangSmith).

PieceWhat it is
langchain-coreThe Runnable/LCEL primitives and base interfaces
langchain-anthropic etc.Provider integrations — swap models by swapping the package
Retrievers / vector storesThe RAG components (Chapter 3, A7)
LangGraphStateful, cyclic agent graphs — L4/L5 of this module
LangSmithTracing & eval across chains/agents (later module)
Chains are for known paths; agents/graphs are for decisionsA chain is a fixed pipeline — great when you know the steps (the "workflow" from L1). When the path must be decided at runtime (which tool? loop again?), you need an agent (L3) or a LangGraph graph (L4). Pick the simplest that fits.

Common pitfalls advanced

Almost every LangChain headache a beginner hits is one of a handful of predictable mistakes — and you've already met most of them in this chapter. The framework's smooth surface hides the fact that it's still just prompts, tokens, and retrieval underneath, so the traps aren't exotic; they're the same old LLM realities wearing a new coat. Reading this list before you hit them turns a lost afternoon into an "oh, right, I was warned about that."

Notice the theme running through the table: nearly every fix is "the framework didn't change the underlying reality, so treat it as you would raw." Old tutorials break because packages get reorganized (verify your imports). Memory balloons because it's still resending the transcript (trim it). Bad RAG answers are still a chunking-and-top-k problem, not a LangChain problem. And the two judgment calls at the bottom — reaching for a chain when you actually need runtime decisions, or wrapping a one-off script in a framework it doesn't need — are really the same lesson stated twice: pick the simplest tool that fits the job. A chain is a fixed pipeline; when the path must be decided on the fly you want an agent (L3) or a LangGraph graph (L4), and for a single call the raw SDK (C2) is often the clearer choice.

the framework is a coat over the same old realities LangChain surface — smooth, composable …but underneath, nothing changed: imports move → verify versions history resent every turn → trim / cost grows RAG = chunk+top-k → tune, not blame every fix: treat it as you would raw; pick the simplest tool Same realities, new coat. LangChain's smooth top layer hides prompts, tokens, and retrieval that behave exactly as they do raw — so nearly every pitfall's fix is "the framework didn't change the underlying reality, so treat it that way."
🗺️ How to read this diagram

Read it top-to-bottom: the single wide bar is the polished surface; the row beneath it is what that surface is actually resting on.

  • The top blue bar is the LangChain surface — smooth and composable, which is exactly what lulls beginners into forgetting what's underneath.
  • The three amber boxes are the unchanged realities and their fixes: imports get reorganized (verify versions), memory keeps resending the transcript (trim it, watch cost), and bad RAG is still a chunking/top-k problem (tune it, don't blame the framework).
  • The bottom blue line is the single lesson the whole table repeats: treat each layer as you would raw, and pick the simplest tool that fits the job.

In short: The coat is new; the body underneath — prompts, tokens, retrieval — is the same, so the fixes are the same too.

PitfallFix
Copying import paths from old tutorialsVerify against your installed version; packages get reorganized
Unbounded in-memory historyTrim/summarize; back with a real store for production
Parsing free text downstreamUse with_structured_output for typed results
Blaming LangChain for bad RAG answersIt's usually chunking/top-k — same tuning as Chapter 3
Using a chain where you need runtime decisionsMove to an agent (L3) or LangGraph (L4)
Over-abstracting a one-off scriptFor a single call, the raw SDK (C2) is simpler

Exercises advanced

Exercise L2.1 — Swap the parser

Context: The composability payoff is that swapping a chain's tail is a one-line change.

Your task: Take a string-output chain and replace the StrOutputParser with a structured parser so the chain returns a typed object — confirm only one line changed.

Requirements:

  • Start from a working chain ending in a string parser
  • Swap in a structured/typed parser
  • The chain now returns a typed object instead of a string
  • Verify the rest of the chain was untouched

💡 Hint: Only the final Runnable in the pipe changes — that's the whole point of composition.

Exercise L2.2 — Cited RAG

Context: Grounding gets stronger when the chain also returns which documents it used — and comparing that to a from-scratch version shows the framework earning its keep.

Your task: Extend the RAG chain to also return which documents it relied on, by tagging each chunk with its source and asking the model to cite the tag.

Requirements:

  • Prefix each chunk with a source tag in the document-formatting step
  • Instruct the prompt to cite the source tag it used
  • Return (or surface) the sources alongside the answer
  • Compare grounding quality to a from-scratch RAG

💡 Hint: Add [source: filename] in format_docs and ask the model to cite it; for true structured citations, return a model with an answer plus a sources list.

Show hint

Prefix each chunk with [source: filename] in format_docs, and add "cite the source tag" to the prompt. For true structured citations, return a Pydantic model with an answer and a sources list.

Exercise L2.3 — Chain vs raw

Context: Knowing when the framework wins and when the raw SDK is clearer is the judgment the chapter is really teaching.

Your task: Rebuild a simple LCEL chain using only the raw Anthropic SDK, then write two sentences on which is shorter and which makes swapping the model easier.

Requirements:

  • Reproduce the chain's behavior with the raw SDK only
  • Compare line count against the LCEL version
  • Compare how easy it is to swap the model in each
  • State when each approach wins

💡 Hint: The raw SDK is often shorter for one call; the framework wins as soon as you want to swap parts or reuse the structure.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Model the LCEL pipe with __or__Beginner

Context: LangChain wires Runnables with the | operator: the left's output feeds the right. Under the hood, LCEL's pipe is just function composition.

Your task: Model a tiny Runnable class whose | composes callables, exactly like LCEL.

Requirements:

  • Wrap a callable and expose an invoke(x) method
  • Implement __or__ so a | b feeds a's output into b
  • Composing prompt | model | parser produces a single runnable
  • Show chain.invoke(...) running the whole pipe

💡 Hint: __or__ returns a new Runnable whose function calls self then the other — that composition is the pipe.

Show solution

LCEL's pipe is function composition. Model it in stdlib:

class Runnable:
    def __init__(self, fn):
        self.fn = fn
    def invoke(self, x):
        return self.fn(x)
    def __or__(self, other):        # self | other
        return Runnable(lambda x: other.invoke(self.invoke(x)))

prompt = Runnable(lambda topic: f"Write about {topic}.")
model  = Runnable(lambda p: f"[answer to] {p}")
parser = Runnable(lambda a: a.upper())

chain = prompt | model | parser
print(chain.invoke("cats"))   # [ANSWER TO] WRITE ABOUT CATS.

Everything in LangChain Core is a Runnable, and | is just "feed left's output into right" — this class is the whole idea.

Exercise 2 · Structured output parsing (Pydantic-style)Intermediate

Context: Chains often end in a parser that turns free model text into a typed, validated object downstream code can trust — the role of PydanticOutputParser in real LCEL.

Your task: Model a parser that extracts fields from a text reply into a validated object, raising on a bad shape.

Requirements:

  • Parse specific fields out of the reply text
  • Coerce into a typed object (e.g. a dataclass with the right field types)
  • Raise a clear error when the text doesn't match the expected shape
  • Show it succeeding on good text

💡 Hint: A regex to pull the fields plus a dataclass to hold them is enough; the job is coercing untrusted text into a trusted, typed value.

Show solution

The parser is the last Runnable in the chain. Model it with stdlib:

import re
from dataclasses import dataclass

@dataclass
class Person:
    name: str
    age: int

def parse_person(text):
    m = re.search(r"name=(\w+)\s+age=(\d+)", text)
    if not m:
        raise ValueError(f"cannot parse: {text!r}")
    return Person(name=m.group(1), age=int(m.group(2)))

print(parse_person("name=Ada age=36"))   # Person(name='Ada', age=36)

Real LCEL uses PydanticOutputParser; the job is identical — coerce free text into a typed, validated object so downstream code can trust it (needs langchain installed).

Exercise 3 · Conversation memory as a Runnable stepAdvanced

Context: Memory is state threaded into the prompt: load prior turns, prepend them, save the new turn — the mechanic behind LangChain's message-history Runnables.

Your task: Model a memory object that stores history and a chat step that injects it, so the model appears to remember across invocations.

Requirements:

  • A memory object stores turns and can render them as history
  • The chat step prepends the loaded history to the prompt
  • After replying, the new user/AI turn is saved
  • Demonstrate a follow-up that depends on an earlier turn

💡 Hint: Three moves per turn: load history, prepend it to the prompt, save the new exchange — the model stays stateless while the memory carries the state.

Show solution

Memory is state threaded into the prompt. Runnable stdlib:

class Memory:
    def __init__(self):
        self.turns = []
    def load(self):
        return "\n".join(f"{r}: {t}" for r, t in self.turns)
    def save(self, user, ai):
        self.turns += [("user", user), ("ai", ai)]

mem = Memory()
def chat(user_msg):
    prompt = f"History:\n{mem.load()}\nUser: {user_msg}"
    reply = f"(reply to '{user_msg}')"      # stand-in for the model
    mem.save(user_msg, reply)
    return reply

chat("my name is Ada")
print(chat("what's my name?"))
print("---history---"); print(mem.load())

LangChain wraps this as message-history Runnables; the mechanic is: load history, prepend it, save the new turn.

Exercise 4 · A RAG chain: retrieve -> stuff -> answerExpert

Context: RAG is just another chain — retriever | prompt | model — which is why Chapter 3's from-scratch pipeline collapses into a few composed Runnables.

Your task: Rebuild RAG as a composed chain: a retriever returns top-k chunks, they're stuffed into the prompt, then the model answers. Model it offline end-to-end.

Requirements:

  • A retriever returns the top-k most relevant chunks for a query
  • The retrieved chunks are stuffed into the prompt as context
  • A (stand-in) model answers from that context
  • Wire it so the retriever could be swapped for a real vector store without touching the rest
  • Show a query returning a grounded answer

💡 Hint: Keep retrieve / prompt-build / answer as separate composable steps — the retriever is the only piece that changes when you move to a real store.

Show solution

RAG is just another chain: retriever | prompt | model. Runnable stdlib:

DOCS = [
    "Refunds are processed within 30 days.",
    "Support hours are 9am to 5pm.",
    "Passwords reset under Settings > Security.",
]
def retrieve(query, k=1):
    # toy relevance: overlap of query words with the doc
    q = set(query.lower().split())
    scored = sorted(DOCS, key=lambda d: len(q & set(d.lower().split())), reverse=True)
    return scored[:k]

def rag(query):
    ctx = "\n".join(retrieve(query))
    prompt = f"Context:\n{ctx}\nQ: {query}\nAnswer from context only."
    return f"[grounded answer] {retrieve(query)[0]}"   # stand-in model

print(rag("how long for a refund?"))   # cites the 30-day doc

Chapter 3's from-scratch RAG becomes a few composed Runnables — retriever swaps in for a real vector store without touching the rest (needs langchain installed).

Exercise 5 · invoke vs batch vs streamProfessional

Context: Because every piece is a Runnable, you get three call shapes for free: invoke (one), batch (many), stream (chunks) — and callers pick the shape they need.

Your task: Model all three call shapes over the same underlying function.

Requirements:

  • invoke(x) returns one result
  • batch(xs) maps the function over many inputs
  • stream(x) yields the output chunk by chunk (e.g. word by word)
  • All three share one underlying function — no duplicated logic
  • Demonstrate each

💡 Hint: One stored callable, three methods over it; the point is that swapping one part of a chain never forces callers to change how they call it.

Show solution

One abstraction, three call shapes. Runnable stdlib:

class Runnable:
    def __init__(self, fn):
        self.fn = fn
    def invoke(self, x):
        return self.fn(x)
    def batch(self, xs):
        return [self.fn(x) for x in xs]
    def stream(self, x):
        for word in self.fn(x).split():
            yield word            # emit chunk by chunk

r = Runnable(lambda t: f"answer for {t} here")
print(r.invoke("a"))
print(r.batch(["a", "b"]))
print(list(r.stream("a")))       # ['answer', 'for', 'a', 'here']

Because every piece is a Runnable, you get invoke/batch/stream for free at every layer — swap one part without rewriting the callers.

Exercise 6 · Build the real chain (needs langchain installed)Industry scenario

Context: The production version of prompt | model | parser uses the real langchain-core / langchain-anthropic classes — and the same LCEL pipe gives batch and stream for free.

Your task: Write the real LCEL chain with a chat prompt template, a Claude chat model, and an output parser, structured to be memory-ready. (Needs the libraries installed.)

Requirements:

  • Use documented classes: a ChatPromptTemplate, a chat model (ChatAnthropic or ChatOpenAI), and an output parser
  • Compose them with the | pipe
  • Invoke the chain on a templated input
  • Note that batch and stream come for free from the same chain
  • Label it as needing langchain-core/langchain-anthropic and an API key

💡 Hint: It's the same shape as the offline model — the framework just supplies real templating, the Claude client, and streaming; verify import paths against your installed version.

Show solution

Correct LCEL using documented classes. Works with either vendor — only the import and model line change (pip install langchain-core langchain-anthropic + ANTHROPIC_API_KEY, or langchain-openai + OPENAI_API_KEY):

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_anthropic import ChatAnthropic      # Claude
# from langchain_openai import ChatOpenAI          # OpenAI

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a concise assistant."),
    ("human", "Explain {topic} in one sentence."),
])
model  = ChatAnthropic(model="claude-opus-4-8", max_tokens=256)   # Claude
# model = ChatOpenAI(model="gpt-5.5", max_tokens=256)             # OpenAI
parser = StrOutputParser()

chain = prompt | model | parser          # the LCEL pipe, for real
print(chain.invoke({"topic": "prompt caching"}))
# batch and stream come for free:
# chain.batch([{"topic": "RAG"}, {"topic": "agents"}])
# for chunk in chain.stream({"topic": "LCEL"}): print(chunk, end="")

Same shape as the offline model above — the framework supplies real prompt templating, the Claude client, and streaming. Import paths shift across versions; verify against what you install.

✓ Checkpoint — you can move on when you can…

  • Explain Runnables and the LCEL pipe in one sentence.
  • Compose prompt → model → parser and invoke it.
  • Get a typed object with with_structured_output.
  • Add session memory to a chain and explain what it really does.
  • Build a RAG chain and map each piece to Chapter 3.
🏗️ Toward the capstoneThe capstone's RAG-onboarding step — index the runbooks, retrieve the relevant procedure for an incident — is exactly this RAG chain. Whether you express it as a LangChain chain, a Flowise flow (N4), or hand-rolled (Chapter 3) is an engineering choice; the retrieval quality that decides whether the agent grounds its fix is the same either way. See the RAG + safety-gate build →

Knowledge check check yourself

✓ Knowledge check

What is a Runnable, and what does the LCEL | pipe do when you write chain = prompt | model | parser?

Show answer
A Runnable is anything with an .invoke() method (plus .stream()/.batch()) — prompts, models, parsers, retrievers all qualify. The | pipe wires them so the output of the left becomes the input of the right (like a Unix pipe), composing them into one object you can invoke, stream, or batch, and swap any piece without touching the rest.
✓ Knowledge check

In the RAG chain, what does the opening dictionary {"context": retriever | format_docs, "question": RunnablePassthrough()} accomplish?

Show answer
It's a parallel Runnable: it runs two branches at once — one fetches and formats context via the retriever, the other forwards the raw question unchanged with RunnablePassthrough — then passes the resulting dict downstream to the prompt. LCEL composes parallel steps as easily as sequential ones.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in