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.