Runnable composition

LangChain · Practical · 14 min

Real pipelines fan out, pass data through, call custom code and must survive flaky APIs.

Beyond linear | chains, langchain_core.runnables offers building blocks:

  • RunnableParallel, or simply a dict inside a chain, runs branches concurrently on the same input and returns a dict: {"context": retriever, "question": RunnablePassthrough()}.
  • RunnablePassthrough forwards its input unchanged; RunnablePassthrough.assign(key=runnable) adds keys to an input dict while keeping the rest.
  • RunnableLambda(fn) wraps a function explicitly (needed when it is the first step, or to add a name).
  • itemgetter("question") from operator is a handy way to pick a field.

Every runnable also offers wrappers:

  • .with_retry(stop_after_attempt=3) retries on exceptions with exponential backoff.
  • .with_fallbacks([backup_chain]) tries alternatives, e.g. another provider, when the primary fails.
  • .with_config(run_name=..., tags=[...]) names steps for tracing.
  • .batch(inputs, config={"max_concurrency": 5}) limits parallelism to respect rate limits.

The config dict travels through the whole chain: callbacks, tags, metadata and configurable values reach every step. Use .configurable_fields or init_chat_model(configurable_fields=...) to choose the model per call.

Inspect any chain with chain.get_graph().print_ascii(); it shows exactly which steps run in parallel and in sequence.

Example

from operator import itemgetter
from langchain_core.runnables import RunnableLambda, RunnableParallel, RunnablePassthrough

def flaky(x):                                  # fails on purpose
    raise TimeoutError("primary down")

stats = RunnableParallel(
    length=RunnableLambda(lambda d: len(d["text"])),
    words=RunnableLambda(lambda d: len(d["text"].split())),
)
chain = (
    RunnablePassthrough.assign(stats=stats)     # keep input, add a key
    | RunnablePassthrough.assign(shout=itemgetter("text") | RunnableLambda(str.upper))
)
print(chain.invoke({"text": "hello lcel world"}))
# {'text': 'hello lcel world', 'stats': {'length': 16, 'words': 3}, 'shout': 'HELLO LCEL WORLD'}

robust = RunnableLambda(flaky).with_fallbacks([RunnableLambda(lambda x: f"backup: {x}")])
print(robust.invoke("q"))                      # backup: q

double = RunnableLambda(lambda x: x * 2)
print(double.batch([1, 2, 3], config={"max_concurrency": 2}))   # [2, 4, 6]

Remember

  • Dicts and RunnableParallel fan out; RunnablePassthrough.assign adds keys
  • with_retry and with_fallbacks add resilience to any step
  • Config (callbacks, tags, max_concurrency) flows through the whole chain

Watch out for

  • Wrapping an entire chain in a single huge lambda, which hides steps from tracing and loses streaming.

Check yourself

  1. What does RunnablePassthrough.assign(n=f) return when invoked with {'q': 'hi'}?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: reference.langchain.com/python/langchain_core/runnables/