Skip to content

LCEL Part 2: Chaining Chains, RunnableParallel, RunnableLambda and @chain

Site Console Site Console
9 min read Updated Oct 15, 2026 AI & Tools 0 comments

Beyond the Straight Line

Part 1 established the mechanism: every piece implements Runnable, the pipe builds a RunnableSequence, and the composed object inherits invoke, batch, stream and the async forms for free.

That covers prompt-to-model-to-parser, which is most of a first application. What it does not cover is a pipeline whose output feeds another pipeline, a step that needs both the original input and a derived value, or two model calls that should run at the same time. Those are the shapes in this post, and the retrieval pipeline in post 8 uses nearly all of them.


Piping Chains and the RunnablePassthrough Class

A RunnableSequence is a Runnable, so it pipes into another one. Here, one chain drafts an answer and a second rewrites it:

answer = prompt | model | StrOutputParser()
critique = critique_prompt | model | StrOutputParser()

combined = answer | (lambda text: {"draft": text}) | critique

combined.invoke({"question": "Why did the build fail?"})
# 'Stale cache.'

The lambda in the middle is doing real work. The first chain produces a string; the second chain's prompt expects a dict with a draft key. Adapting between steps is most of what the small functions in a pipeline are for.

Worth knowing what the pipe actually does with two sequences:

[type(s).__name__ for s in combined.steps]
# ['ChatPromptTemplate', 'ChatOpenAI', 'StrOutputParser',
#  'RunnableLambda',
#  'ChatPromptTemplate', 'ChatOpenAI', 'StrOutputParser']

Seven steps, flat. Piping two sequences together does not nest one inside the other — it flattens into a single sequence. Which is mostly a relief, since it means no accumulating layers of wrapper, and it is also why the graph you draw later looks like one long pipeline rather than a tree.

RunnablePassthrough is the identity step. On its own it returns its input unchanged:

from langchain_core.runnables import RunnablePassthrough

RunnablePassthrough().invoke({"question": "unchanged?"})
# {'question': 'unchanged?'}

A step that does nothing sounds useless until you need to carry a value past something. .assign is the form you will actually reach for: it runs a branch and adds the result as a new key, keeping everything already there.

threaded = RunnablePassthrough.assign(answer=answer)

result = threaded.invoke({"question": "Why did the build fail?"})
list(result.keys())
# ['question', 'answer']

The original question survives. Without this, the answer would replace the input and any later step needing the question would have nothing to read. In a retrieval pipeline this is how the user's question stays available after you have used it to fetch documents.


Graphing Runnables

Composition means the structure of your pipeline is data, so you can ask it to describe itself:

chain.get_graph().print_ascii()
     +-------------+
     | PromptInput |
     +-------------+
            *
            *
            *
  +--------------------+
  | ChatPromptTemplate |
  +--------------------+
            *
            *
            *
        +--------+
        | Lambda |
        +--------+
            *
            *
            *
   +-----------------+
   | StrOutputParser |
   +-----------------+
            *
            *
            *
+-----------------------+
| StrOutputParserOutput |
+-----------------------+

Note the two extra nodes. A three-step chain graphs as five, because the input and output schemas are nodes too — the same contract get_input_jsonschema reported in part 1.

print_ascii needs grandalf installed and tells you so if it is missing. Mermaid needs nothing:

print(chain.get_graph().draw_mermaid())
graph TD;
	PromptInput([PromptInput]):::first
	ChatPromptTemplate(ChatPromptTemplate)
	Lambda(Lambda)
	StrOutputParser(StrOutputParser)
	StrOutputParserOutput([StrOutputParserOutput]):::last
	PromptInput --> ChatPromptTemplate;
	ChatPromptTemplate --> Lambda;
	StrOutputParser --> StrOutputParserOutput;
	Lambda --> StrOutputParser;

That drops straight into a README or a pull request description, which makes it the cheapest architecture documentation you will ever write — it cannot drift from the code, because it is generated from it.

The ASCII form is genuinely useful for debugging a pipeline someone else assembled. When a chain is built across three modules with conditional branches, printing the graph answers "what is actually in here" faster than reading the code that built it.


RunnableParallel

RunnableParallel runs several branches against the same input and collects the results into a dict:

from langchain_core.runnables import RunnableParallel, RunnableLambda

fan = RunnableParallel(
    answer=answer,
    length=RunnableLambda(lambda x: len(x["question"])),
)

fan.invoke({"question": "How do I reset my password?"})
# {'answer': "Click 'Forgot password'. Revolutionary, I know.", 'length': 27}

Every branch receives the same input — not the previous branch's output. The keys you name become the keys of the result.

The branches run concurrently, which is the reason to use it rather than a dict comprehension. When two branches are both model calls, you pay one round trip instead of two. A classification call and a summarization call on the same ticket is the canonical case.

A dict literal in a pipe position coerces automatically, which is why you will see this written inline:

implicit = {"answer": answer, "asked": RunnablePassthrough()} | some_next_step

type(implicit.steps[0]).__name__
# 'RunnableParallel'

That shape — one branch doing work, one branch passing the input through — is the single most common idiom in LangChain retrieval code, and post 8 uses exactly it.


Piping a RunnableParallel Into Other Runnables

A parallel block is a Runnable, so it pipes onward. Its dict output becomes the next step's input:

fan = RunnableParallel(answer=answer, asked=RunnablePassthrough())

report = fan | (lambda d: f"Q: {d['asked']['question']}\nA: {d['answer']}")

print(report.invoke({"question": "Why did the build fail?"}))
# Q: Why did the build fail?
# A: because the cache was stale

This is the fan-out-then-rejoin pattern, and it is how most non-trivial pipelines are shaped: split the input across several branches, then combine their results in a step that sees all of them.

The combining step is often a prompt template, since a template taking {context} and {question} is precisely a function of a two-key dict. That is the whole structure of a retrieval chain, and now you have seen both halves of it.


RunnableLambda

RunnableLambda wraps an ordinary Python function so it satisfies the interface:

from langchain_core.runnables import RunnableLambda

normalise = RunnableLambda(lambda text: text.strip().lower())

Most of the time you do not need to write it, because a bare function in a pipe position is coerced for you:

chain = model | StrOutputParser() | (lambda text: text.upper())

[type(s).__name__ for s in chain.steps]
# ['ChatOpenAI', 'StrOutputParser', 'RunnableLambda']

The wrapping is what gives your function batch, stream and the async forms — which is also the trap from part 1. A plain function is a streaming barrier; a generator function over chunks is not.

Explicit RunnableLambda earns its place when you want to attach something to the step, since the wrapper is where with_retry, with_fallbacks and with_config live. Which leads to the part that matters most in production.


Retries and Fallbacks on Any Pipeline

Because everything is a Runnable, production concerns attach uniformly. Both of these were verified by running them.

A provider fallback:

safe = primary.with_fallbacks([backup])
safe.invoke("x")
# 'served by the backup'   <- primary raised, backup answered

A retry policy:

retried = flaky_step.with_retry(stop_after_attempt=3)
retried.invoke("x")
# 'succeeded on attempt 3'

In practice that is a cheap model with the flagship behind it, or a retry on a step touching a rate-limited provider — expressed as a method call on the pipeline rather than a wrapper you maintain yourself. with_config, with_listeners and bind sit alongside them, and all of them return a new Runnable, so they compose like everything else.


The @chain Decorator

The decorator turns a function into a Runnable without the wrapper call:

from langchain_core.runnables import chain

@chain
def sarcastic_length(payload: dict) -> str:
    return f"{len(payload['question'])} characters, and not one of them necessary."

sarcastic_length.invoke({"question": "How do I reset my password?"})
# '27 characters, and not one of them necessary.'

What you get back is a RunnableLambda, with the full interface — batch, stream, ainvoke and the rest.

Its real use is a function that itself calls other Runnables. Written with @chain, that function becomes a composable unit while keeping ordinary Python control flow inside it:

@chain
def answer_with_fallback(payload: dict) -> str:
    draft = answer.invoke(payload)
    if len(draft) < 20:
        return elaborate.invoke({"draft": draft})
    return draft

An if statement inside a pipeline, without building the branching out of Runnable primitives. When your logic is genuinely conditional and you do not need the model to decide the branch, this is the readable answer — and it still slots into a larger chain, because the decorated function is a Runnable like any other.


When Not to Build a Chain

A chain of one step is a function call with extra vocabulary. If you are invoking a model once and reading the text, model.invoke(...) is clearer than composing a pipeline, and you lose nothing by writing it that way.

The threshold is roughly: more than one step, and you want streaming, batching or async across all of them. If you are doing three sequential things, calling it once per request, with no streaming, then ordinary Python is more readable and every engineer on your team already knows how to debug it.

The framework is not a tax you owe on every line. It earns its place where the interface buys you something — and in the next post, where a retrieval pipeline needs fan-out, threading and a combining step all at once, it very clearly does.

Everything above was executed against langchain 1.4.3 and langchain-core 1.6.7 on 10 October 2026. The graph output is real output, not a redrawing.


🧭 What's Next

  • Post 8: Your First RAG Pipeline — the last gap from post 4. A model cannot answer from documents it has never seen, so the final post loads real documents, chunks them, embeds them into a vector store, and wires retrieval into a chain using the fan-out and threading patterns from this post. It also ends by being honest about where the naive version breaks.

Related

Leave a comment

Sign in to leave a comment.

Comments