Skip to main content

Module 3 — Composing chains

Module 2 gave you three atoms: prompt, model, parser. This module wires them into chains and gives you the two decisions every chain designer meets — parallel or sequential, stream or batch — plus the branching primitives you need before the retrieval chain of module 5.

The pipe operator, in one line

The composition operator is |. Left-to-right, it means "pass the output of the previous runnable to the next one".

from langchain_core.output_parsers import StrOutputParser

chain = prompt | model | StrOutputParser()
chain.invoke({"line": "Dinner Berlin, 42.30 EUR"})

prompt | model | parser is not a Python trick. Under the hood it builds a RunnableSequence that itself is a Runnable, which means it exposes invoke, stream, batch and ainvoke unchanged. A chain of chains is a chain.

Two habits pay off. Give every chain a stable name (policy_chain, receipt_chain) rather than reassigning chain everywhere; when a trace of module 9 lights up, the name is what you read. And compose small pieces: three chains of five steps are easier to test and re-order than one chain of fifteen.

RunnablePassthrough: keeping the input around

A chain replaces its input with its output. The moment a downstream step needs both the original question and the retrieved passages, the input is gone. RunnablePassthrough fixes that.

from langchain_core.runnables import RunnablePassthrough

def look_up_policy(question: str) -> str:
# placeholder for the retriever of module 5
return "Per diem meal ceiling: 25 EUR."

grounded = (
{"context": look_up_policy, "question": RunnablePassthrough()}
| prompt_with_context
| model
| StrOutputParser()
)
grounded.invoke("Is a 42 EUR dinner reimbursable?")

The dict form assembles the input for the next step from several sources. The RunnablePassthrough() value forwards the original question untouched. This is the exact shape a retrieval-augmented chain will take in module 5.

RunnableParallel: fan out, then join

Some steps are independent and can run at the same time. RunnableParallel runs its values in parallel and returns a dict.

from langchain_core.runnables import RunnableParallel

triage = RunnableParallel(
category=category_chain, # classify: meal / transport / lodging
amount=amount_extraction_chain,
currency=currency_extraction_chain,
)

triage.invoke("Dinner Berlin, 42.30 EUR")
# -> {"category": "meal", "amount": 42.3, "currency": "EUR"}

The wall-clock time is the slowest branch, not the sum. On a receipt that requires three independent extractions this cuts latency by roughly 3x. Do it only when the branches truly do not depend on each other — chaining a parallel step behind another parallel step for cosmetic reasons adds coordination without a benefit.

The comparison every designer meets:

ShapeTime costUse when
Sequential `ABC`
Parallel RunnableParallel(a=A, b=B, c=C)slowest of stepsSteps are independent, results combined at the end

Conditional branching

A single-shot router is RunnableBranch. Each branch has a boolean condition; the first match wins, otherwise the default runs.

from langchain_core.runnables import RunnableBranch

router = RunnableBranch(
(lambda x: x["category"] == "meal", meal_chain),
(lambda x: x["category"] == "transport", transport_chain),
default_chain,
)

Two rules save headaches. Conditions are Python predicates, not natural language; the model classifies upstream, the branch dispatches downstream. And keep the branches type-compatible: if one branch returns a dict and another a str, the caller has to remember which — bugs live in that gap.

Streaming vs batching

Both are one method call.

stream yields chunks as the model produces them. Use it for chat UIs where the user watches tokens appear.

for chunk in chain.stream({"line": receipt_line}):
print(chunk, end="", flush=True)

batch sends a list of inputs and runs them in parallel — inside the provider, when it supports it, and across HTTP requests otherwise.

chain.batch([{"line": r} for r in receipts])   # 200 receipts, one call

Batching is the single fastest optimisation in a data-processing chain. A loop of 200 sequential calls at 800 ms each is 160 seconds; a batch with a concurrency of 10 is under 20. Do not batch across users who see partial results, do batch across background jobs.

When a chain feels slow, look at the shape first

Before rewriting a prompt, draw the chain. If two independent steps run sequentially, put them in a RunnableParallel. If 200 items go through a loop, use batch. Model latency is often not what dominates — the shape of the chain is.

In summary

  • The pipe operator builds a RunnableSequence that itself is a Runnable; a chain of chains is a chain.
  • RunnablePassthrough keeps the original input available for downstream steps — the exact shape used by retrieval-augmented chains.
  • RunnableParallel runs independent steps concurrently and returns a dict; wall time is the slowest branch, not the sum.
  • Streaming feeds a chat UI; batching cuts throughput cost on background jobs; choosing the right shape often beats prompt tuning.

Next module: loading documents — PDFs, web pages and office files — and splitting them into chunks that the retriever of module 5 can index.