Chains¶
1. Why this matters¶
Real apps aren't one prompt → one response. They're:
- Generate a report → summarize it.
- Take a query → run translate + sentiment + entity extraction in parallel → combine.
- Classify the user's intent → if it's a complaint, route to the apology chain; if it's a question, route to the Q&A chain.
Chains are how you express these flows declaratively. LCEL chains get streaming, async, retries, parallelism, and tracing for free — features the old class-based chains didn't have.
2. Mental model¶
Three shapes — that's it:
flowchart TB
subgraph Sequential [Sequential — output of A feeds B]
A1[A] --> B1[B] --> C1[C]
end
subgraph Parallel [Parallel — all run, results merged into a dict]
A2[input] --> B2[B]
A2 --> C2[C]
A2 --> D2[D]
B2 --> M2[b: ..., c: ..., d: ...]
C2 --> M2
D2 --> M2
end
subgraph Conditional [Conditional — route based on input]
A3[input] --> R{router}
R -->|cond 1| B3[chain 1]
R -->|cond 2| C3[chain 2]
R -->|else| D3[default]
end
Build these with: | (sequential), RunnableParallel (parallel), RunnableBranch (conditional).
3. Architecture / Flow¶
flowchart LR
I[Input dict] --> P1[Prompt 1]
P1 --> M1[Model]
M1 --> O1[Parser]
O1 --> P2[Prompt 2<br/>uses output of step 1]
P2 --> M2[Model]
M2 --> O2[Parser]
O2 --> R[Final answer]
Two-stage example: generate a detailed report, then summarize it. The output of stage 1 becomes input to stage 2's prompt.
4. Core concepts¶
|(pipe) — sequential composition. Equivalent toRunnableSequence(left, right).RunnableParallel({"k1": chainA, "k2": chainB})— runs branches concurrently, returns{"k1": resultA, "k2": resultB}. Often abbreviated as a plaindict.RunnableBranch((cond1, chain1), (cond2, chain2), default)— first matching condition wins. Conditions arelambda x: bool.RunnablePassthrough()— identity. Passes the input through unchanged (useful in parallel maps to keep the original input alongside computed values).RunnableLambda(fn)— wraps any Python callable as a Runnable, so it slots into a chain.
5. Code — minimal working example¶
Sequential — generate then summarize:
from langchain_openai import ChatOpenAI
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
model = ChatOpenAI(model="gpt-4o-mini")
parser = StrOutputParser()
p1 = PromptTemplate.from_template("Write a 200-word report on {topic}.")
p2 = PromptTemplate.from_template("Summarize this in one sentence:\n\n{report}")
# Stage 1 produces a string; we feed it into stage 2's `report` variable
stage1 = p1 | model | parser
stage2 = (lambda report: {"report": report}) | p2 | model | parser
chain = stage1 | stage2
print(chain.invoke({"topic": "vector databases"}))
6. Code — real-world pattern¶
Parallel — three perspectives on the same input, then combined:
from langchain_core.runnables import RunnableParallel
from langchain_anthropic import ChatAnthropic
openai_model = ChatOpenAI(model="gpt-4o-mini")
anthropic_model = ChatAnthropic(model="claude-haiku-4-5-20251001")
summary_chain = (
PromptTemplate.from_template("Summarize in 2 sentences:\n{text}")
| openai_model | parser
)
sentiment_chain = (
PromptTemplate.from_template("Sentiment (positive/negative/neutral):\n{text}")
| openai_model | parser
)
critique_chain = (
PromptTemplate.from_template("Critique writing style:\n{text}")
| anthropic_model | parser
)
analyze = RunnableParallel({
"summary": summary_chain,
"sentiment": sentiment_chain,
"critique": critique_chain,
})
result = analyze.invoke({"text": "The product is acceptable but shipping was slow..."})
# result = {"summary": "...", "sentiment": "...", "critique": "..."}
Conditional — classify feedback, then route to the right response chain:
from langchain_core.runnables import RunnableBranch, RunnableLambda
from pydantic import BaseModel, Field
from typing import Literal
class Feedback(BaseModel):
sentiment: Literal["positive", "negative"] = Field(description="sentiment")
classifier_model = ChatOpenAI(model="gpt-4o-mini").with_structured_output(Feedback)
classifier = PromptTemplate.from_template("Classify: {feedback}") | classifier_model
positive_reply = (
PromptTemplate.from_template("Write a warm thank-you to:\n{feedback}")
| openai_model | parser
)
negative_reply = (
PromptTemplate.from_template("Write an apology + offer help for:\n{feedback}")
| openai_model | parser
)
# Branch chooses based on classifier output
respond = RunnableBranch(
(lambda x: x["sentiment"] == "positive", positive_reply),
(lambda x: x["sentiment"] == "negative", negative_reply),
RunnableLambda(lambda x: "Could not classify."), # default
)
# Combine: classify → carry both classification + original text → respond
def merge(d):
return {"sentiment": d["cls"].sentiment, "feedback": d["feedback"]}
pipeline = (
RunnableParallel({"cls": classifier, "feedback": lambda x: x["feedback"]})
| RunnableLambda(merge)
| respond
)
print(pipeline.invoke({"feedback": "Best purchase this year!"}))
7. Common pitfalls¶
- ❗ Using the deprecated
LLMChain/SequentialChain/SimpleSequentialChainclasses. They still work, but you lose streaming/async/tracing and the docs are deprecating them. Always use LCEL. - ❗ Forgetting that the input type must match. If
step2expects{"report": ...}butstep1outputs a plain string, insert aRunnableLambdaadapter or a dict literal in between. - ❗ Parallel branches sharing mutable state. Each branch runs concurrently — never write to a shared list / dict from inside branch functions.
- ❗ Big chains with no tracing. Past ~3 steps, debugging without LangSmith is painful. Set
LANGCHAIN_TRACING_V2=trueearly. - ❗
RunnableBranchdefault missing. The last positional arg is the default fallback — forget it and you'll get an error when no condition matches.
8. When to use vs not use¶
| Use this | When |
|---|---|
LCEL (a | b | c) |
Always — default to this |
RunnableParallel |
Independent computations on the same input |
RunnableBranch |
Routing based on input shape / classification |
| LangGraph instead | You need loops, retries with state, human-in-the-loop, or > ~10 nodes |
Old LLMChain |
Never in new code — only when maintaining legacy |
When the graph gets complex (cycles, conditional re-entry, persistent state) — that's the signal to switch to LangGraph.
9. Cheatsheet¶
from langchain_core.runnables import (
RunnableParallel,
RunnableBranch,
RunnableLambda,
RunnablePassthrough,
RunnableSequence,
)
# Sequential
a | b | c # preferred
RunnableSequence(a, b, c) # equivalent
# Parallel
RunnableParallel({"x": chainA, "y": chainB}) # dict-style
{"x": chainA, "y": chainB} | next_step # dict literal works too
# Branching
RunnableBranch(
(lambda x: x["type"] == "q", qa_chain),
(lambda x: x["type"] == "c", complaint_chain),
default_chain, # last arg = fallback
)
# Wrap any function
RunnableLambda(lambda x: x.upper())
# Pass input through
RunnablePassthrough()
RunnablePassthrough.assign(extra=lambda x: x["a"] + x["b"]) # add a field
# Inspect a chain
chain.get_graph().print_ascii()
10. Q&A — recall test¶
-
Q: Difference between
LLMChainand an LCEL chain? A:LLMChain(llm=..., prompt=...).run(...)is the old class-based API (deprecated). LCEL builds the same thing asprompt | llm | parserand gets streaming, async, batch, tracing, and parallelism for free. -
Q: When would you use
RunnableParallelvs sequential? A: Parallel when the branches are independent (don't need each other's outputs). Sequential when each step depends on the previous one's output. -
Q: What does
RunnablePassthrough.assign(x=fn)do? A: Takes the input dict, runsfnon it, and adds the result as keyx— preserving all existing keys. Common in retriever chains:{"question": passthrough, "context": retriever}. -
Q: When should you stop using LangChain chains and switch to LangGraph? A: When you need loops (re-try with feedback), branching that re-enters earlier nodes, durable state across runs, or > ~10 steps. LCEL is for DAGs; LangGraph is for graphs.
-
Q: How do you visualize a chain? A:
chain.get_graph().print_ascii()(ASCII) or.draw_mermaid()(Mermaid string). Or just trace it in LangSmith.
Practice¶
What does this print?
Expected: HELLO
Order matters in a chain — apply step1 before step2
Expected: WORLD
Quiz — Quick check¶
What you remember
Q1. What does prompt | model | parser do?
- Builds a pipeline: input → prompt template → LLM → output parser
- Lazy evaluation
- Same as
+ - Throws an error
Why: The
|operator is overloaded in LangChain Expression Language (LCEL) to mean "pipe". Composes Runnables into a chain. Each step's output is the next step's input.
Q2. What's the LCEL equivalent of a Python for loop over inputs?
-
for x in inputs: chain.invoke(x) -
chain.batch(inputs)— runs all inputs concurrently -
chain.loop(inputs) -
chain.apply(inputs)
Why:
batchis concurrent — much faster than serial loops because LLM calls are I/O-bound. Use it whenever you have multiple independent inputs.
Q3. When should you use RunnableParallel?
- When two chains can run on the same input independently and you want both results
- When chains have to be sequential
- Required for batching
- For error handling
Why:
RunnableParallel({"a": chain_a, "b": chain_b})runs both chains in parallel and returns{"a": result_a, "b": result_b}. Common pattern: generate a question and answer both ways simultaneously.
Common doubts¶
Why does LCEL use | instead of method calls?
Readability. prompt | model | parser reads top-to-bottom (left-to-right) showing data flow. parser(model(prompt(input))) reads inside-out — harder to follow with 5+ steps. The | is just operator overloading.
Can I mix LCEL chains with plain Python functions?
Yes — wrap any function with RunnableLambda(my_function) and pipe it: chain | RunnableLambda(my_function). Lets you insert arbitrary logic between LangChain components.
How do I debug a complex chain?
Three approaches: (1) print intermediate values with RunnableLambda(lambda x: (print(x), x)[1]) between steps, (2) call .get_graph().print_ascii() to see the structure, (3) use LangSmith — automatically traces every step with inputs, outputs, and timing.