Skip to content

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 to RunnableSequence(left, right).
  • RunnableParallel({"k1": chainA, "k2": chainB}) — runs branches concurrently, returns {"k1": resultA, "k2": resultB}. Often abbreviated as a plain dict.
  • RunnableBranch((cond1, chain1), (cond2, chain2), default) — first matching condition wins. Conditions are lambda 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 / SimpleSequentialChain classes. 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 step2 expects {"report": ...} but step1 outputs a plain string, insert a RunnableLambda adapter 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=true early.
  • RunnableBranch default 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 LLMChain and an LCEL chain? A: LLMChain(llm=..., prompt=...).run(...) is the old class-based API (deprecated). LCEL builds the same thing as prompt | llm | parser and gets streaming, async, batch, tracing, and parallelism for free.

  • Q: When would you use RunnableParallel vs 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, runs fn on it, and adds the result as key x — 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

# A chain conceptually: prompt | model | parser
# Here we simulate with pure functions
def step1(x): return x.upper()
def step2(x): return x
result = step2(step1("hello"))
print(result)

Order matters in a chain — apply step1 before step2

Expected: WORLD

def upper(s): return s.upper()
def keep(s): return s
result = upper(keep("world"))    # bug: order reversed if we wanted upper at the end first
# Pretend the goal is: input → keep → upper → output
print(result)

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: batch is 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.