teachyou.ai academy
← All posts
LangChain

LangChain Runnable Interface: The Core Abstraction Explained

Ira Menon · Jun 28, 2026 · 13 min read

Why every LangChain component suddenly looks the same

If you've spent time in LangChain over the last year, you've probably noticed something odd: prompts, models, output parsers, retrievers, and even entire chains all expose the same handful of methods. You call .invoke() on a prompt template the same way you call it on a chat model or a retriever. You can .batch() a chain of five components exactly like you'd .batch() a single LLM call. You can pipe them together with | and the result still behaves the same way.

That's not an accident, and it's not just clever API design for its own sake. It's a single abstraction called the Runnable interface, and it is arguably the most important concept to understand if you want to actually reason about how LangChain works rather than just copy-pasting chain examples from tutorials.

Most people learn LangChain backwards. They see prompt | model | parser, they see it produce output, and they move on without asking what that pipe operator is actually doing. Then six months later they're debugging a production pipeline with retries, fallbacks, and streaming, and none of it makes sense because they never learned the contract underneath. This article fixes that. We're going to open up the Runnable interface, understand exactly what it guarantees, and write real code against invoke, batch, stream, and the composition primitives that make LCEL (LangChain Expression Language) work.

By the end, you'll be able to look at any LangChain chain and know precisely what will happen when you call it, without guessing.

What a Runnable actually is

At its core, a Runnable is a protocol — a shared contract — that any component in LangChain can implement. If an object implements this protocol, it guarantees it supports a standard set of methods, regardless of what it does internally. The three methods you'll use constantly are:

  • invoke(input, config=None) — run the component once, synchronously, on a single input, and return a single output.
  • batch(inputs, config=None) — run the component on a list of inputs, ideally in parallel, and return a list of outputs in the same order.
  • stream(input, config=None) — run the component and yield output incrementally as it becomes available, instead of waiting for the whole result.

Each of these also has an async twin: ainvoke, abatch, astream. So under the hood, a Runnable actually promises six methods, not three. There's also astream_events, which we'll touch on later, for fine-grained observability into nested chains.

Here's the part that matters most: prompts, chat models, LLMs, output parsers, retrievers, tools, and entire composed chains are all Runnables. That means you can treat a five-step pipeline exactly like you treat a single call to a chat model. The interface doesn't care about complexity — it only cares about the contract.

Let's see this with actual code before we go further into theory.

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
prompt = ChatPromptTemplate.from_template(
    "Explain {concept} to a {audience} in two sentences."
)
parser = StrOutputParser()

chain = prompt | model | parser

result = chain.invoke({"concept": "gradient descent", "audience": "beginner"})
print(result)

Notice that chain here is not a special "Chain" class with its own bespoke API. It's a RunnableSequence, which is itself a Runnable. It has .invoke(), .batch(), and .stream() because every piece that went into building it — the prompt, the model, the parser — was a Runnable, and combining Runnables produces another Runnable. This is the recursive property that makes LCEL so composable: you can nest chains inside chains, and the outer chain still exposes the exact same interface as the innermost component.

The pipe operator is not magic, it's `__or__`

When you write prompt | model | parser, Python is calling prompt.__or__(model), which LangChain's Runnable base class implements to return a RunnableSequence. That sequence object stores the list [prompt, model, parser] and, when you call .invoke(input) on it, it runs each step in order, feeding the output of one as the input to the next.

You can write the exact same thing without the pipe syntax, which is useful for understanding what's actually happening:

from langchain_core.runnables import RunnableSequence

chain = RunnableSequence(first=prompt, middle=[model], last=parser)

result = chain.invoke({"concept": "backpropagation", "audience": "high schooler"})
print(result)

Both versions are functionally identical. The | syntax is sugar over explicit composition — it exists so chains read left-to-right like a data pipeline, which is genuinely easier to scan than nested function calls like parser(model(prompt(input))).

This matters because once you know RunnableSequence is just "run these steps in order, passing output to input," you stop treating LCEL like a DSL you have to memorize and start treating it like ordinary object composition. There's no hidden control flow. If step two throws an exception, the chain throws that exception. If step one returns a dict, step two receives that dict.

invoke, batch, and stream: the same chain, three different execution shapes

The reason the Runnable interface pays for itself is that you rarely change your chain definition when you change how you want to run it. Consider the same chain called three different ways.

invoke — single input, single output, blocks until done:

response = chain.invoke({"concept": "attention mechanisms", "audience": "engineer"})
print(response)

batch — multiple inputs processed concurrently, results returned in matching order:

inputs = [
    {"concept": "vector databases", "audience": "PM"},
    {"concept": "transformers", "audience": "CEO"},
    {"concept": "RAG", "audience": "junior developer"},
]

results = chain.batch(inputs, config={"max_concurrency": 3})
for concept_input, output in zip(inputs, results):
    print(f"{concept_input['concept']}: {output}\n")

batch is not a for loop with invoke called repeatedly, even though it looks like it should be from the outside. Under the hood, most Runnables implement batching so that network-bound steps (like calling a chat model's API) run concurrently via a thread pool or async event loop, rather than sequentially. That max_concurrency config value caps how many run at once — critical when you're hitting a rate-limited API and don't want to blow through your requests-per-minute limit.

stream — same chain, but you get tokens as they're generated instead of waiting for the full response:

for chunk in chain.stream({"concept": "diffusion models", "audience": "artist"}):
    print(chunk, end="", flush=True)

This is the same chain object, same prompt | model | parser definition. Nothing about the chain changed — only which method we called on it. That's the entire point of the Runnable contract: you design your pipeline once, and the execution mode is a decision you make at call time, not at chain-construction time.

Streaming through a chain with an output parser deserves a specific callout. StrOutputParser supports streaming because it implements transform, which lets it operate on a stream of chunks incrementally rather than waiting for the full string. If you build a custom parser that only implements a blocking parse method, streaming through it will silently fall back to buffering everything and emitting one final chunk — which defeats the purpose. If token-by-token output matters for your use case (say, a chat UI), verify your custom components actually support streaming rather than assuming the Runnable interface makes it automatic.

Async versions: ainvoke, abatch, astream

Every synchronous method has an async counterpart with an a prefix. This isn't a separate API surface bolted on — it's part of the same Runnable protocol, and it matters the moment you're serving a chain from a web backend like FastAPI, where you don't want a slow LLM call blocking your event loop.

import asyncio

async def run_chain():
    result = await chain.ainvoke({"concept": "tool calling", "audience": "developer"})
    print(result)

    results = await chain.abatch([
        {"concept": "embeddings", "audience": "student"},
        {"concept": "fine-tuning", "audience": "manager"},
    ])
    print(results)

    async for chunk in chain.astream({"concept": "agents", "audience": "founder"}):
        print(chunk, end="", flush=True)

asyncio.run(run_chain())

A practical rule of thumb: if you're writing a script, notebook, or CLI tool, stick with the synchronous methods — they're simpler to reason about and debug. If you're building a server that needs to handle multiple concurrent requests without dedicating a thread to each one, use the async versions throughout your request path. Mixing them (calling .invoke() inside an async route handler) works but blocks the event loop, which quietly kills your server's concurrency under load — a bug that's easy to miss in dev and painful in production.

RunnableLambda: turning any function into a Runnable

Sometimes you need custom logic between steps — reshaping a dictionary, adding a timestamp, calling an internal API — that doesn't exist as a prebuilt LangChain component. RunnableLambda wraps any Python function so it participates in the same pipe-and-invoke contract as everything else.

from langchain_core.runnables import RunnableLambda

def add_word_count(text: str) -> dict:
    return {"summary": text, "word_count": len(text.split())}

word_counter = RunnableLambda(add_word_count)

full_chain = prompt | model | parser | word_counter

output = full_chain.invoke({"concept": "reinforcement learning", "audience": "student"})
print(output)
# {'summary': '...', 'word_count': 24}

Because word_counter is now a Runnable, it inherits batch and stream behavior automatically (streaming falls back to running the function on the fully-assembled input unless you implement a custom transform, since arbitrary Python functions can't meaningfully operate on partial text chunks in general). This is the escape hatch that keeps LCEL from feeling like a walled garden — anything you can write as a function, you can drop into a chain.

RunnableParallel: running steps side by side

Not every pipeline is a straight line. Often you want to run two or more Runnables against the *same* input and merge their outputs — for example, retrieving context documents while also passing the raw question through untouched for the prompt template.

from langchain_core.runnables import RunnableParallel, RunnablePassthrough

def fake_retrieve(question: str) -> str:
    # stand-in for a real vector store retriever
    return f"Relevant context for: {question}"

retrieval_chain = RunnableParallel(
    context=RunnableLambda(fake_retrieve),
    question=RunnablePassthrough(),
)

rag_prompt = ChatPromptTemplate.from_template(
    "Answer using only this context:\n{context}\n\nQuestion: {question}"
)

rag_chain = retrieval_chain | rag_prompt | model | parser

answer = rag_chain.invoke("What is the capital of France?")
print(answer)

RunnableParallel takes a dict of Runnables, runs each one against the same input concurrently, and returns a dict of their outputs keyed by name. RunnablePassthrough is a small but useful companion — it's a Runnable that just returns its input unchanged, which is exactly what you want when part of your pipeline needs the original input untouched while another branch transforms it. This pattern is the backbone of most retrieval-augmented generation (RAG) chains you'll see in LangChain documentation, and now you know it's not special-cased — it's just Runnable composition with a dict instead of a list.

Adding resilience: retry and fallbacks

Because every Runnable shares the same interface, LangChain can attach cross-cutting behavior — retries, fallbacks, timeouts — to *any* Runnable without needing to know what's inside it. This is where the abstraction earns its keep in production systems.

from langchain_openai import ChatOpenAI

primary_model = ChatOpenAI(model="gpt-4o", temperature=0)
backup_model = ChatOpenAI(model="gpt-4o-mini", temperature=0)

resilient_model = primary_model.with_retry(
    stop_after_attempt=3,
).with_fallbacks([backup_model])

chain = prompt | resilient_model | parser

result = chain.invoke({"concept": "quantization", "audience": "engineer"})
print(result)

with_retry wraps the model so transient errors (rate limits, timeouts, flaky network calls) get retried automatically with backoff, up to the attempt limit you specify. with_fallbacks goes further: if the primary model still fails after retries, the chain transparently switches to the backup model instead of raising an exception up to your application code. Both of these methods exist on the base Runnable class, which means you could just as easily call .with_retry() on an entire chain, a retriever, or a custom RunnableLambda. You're not learning a new API for reliability — you're using the same Runnable contract with a couple of extra methods bolted onto it.

Configuring runs: tags, metadata, and per-call overrides

Every invoke, batch, and stream call accepts an optional config dictionary. This is how you attach tracing metadata, set concurrency limits, or override a component's parameters at call time without redefining the chain.

result = chain.invoke(
    {"concept": "LoRA fine-tuning", "audience": "ML engineer"},
    config={
        "tags": ["blog-demo", "production"],
        "metadata": {"user_id": "u_42", "session": "sess_9"},
        "run_name": "explain-concept-chain",
    },
)

If you're using LangSmith for tracing (LangChain's observability platform), these tags and metadata show up attached to the trace, letting you filter and debug specific runs later. This is also where max_concurrency (shown earlier in the batch example) and recursion limits get set. The key insight is that config is part of the Runnable contract too — every implementation is expected to accept and respect it, which is why you can pass the same config shape to a single model call or an entire multi-step chain.

A quick word on RunnableBranch for conditional logic

Real pipelines often need branching: route simple questions to a cheap model, complex ones to a stronger one. RunnableBranch (and its more modern replacement, a RunnableLambda that returns a Runnable) lets you express that without leaving the Runnable world.

from langchain_core.runnables import RunnableBranch

def is_complex(input_dict: dict) -> bool:
    return len(input_dict["question"]) > 200

branch = RunnableBranch(
    (lambda x: is_complex(x), prompt | primary_model | parser),
    prompt | backup_model | parser,  # default branch
)

output = branch.invoke({"question": "What is 2+2?", "concept": "arithmetic", "audience": "kid"})
print(output)

Each condition is a (predicate, runnable) pair, evaluated in order, with the final argument acting as the default. Because both branches are themselves full Runnables (in this case, entire sub-chains), the branch object is still just a Runnable — .batch() and .stream() work on it exactly as you'd expect, routing each input independently.

Debugging chains with astream_events

One practical pain point with composed chains is visibility: when chain.invoke() returns a single final answer, you can't see what happened at each intermediate step. astream_events solves this by emitting a structured event for every step's start, streamed output, and end, across the entire nested chain.

async def debug_chain():
    async for event in chain.astream_events(
        {"concept": "self-attention", "audience": "beginner"},
        version="v2",
    ):
        kind = event["event"]
        if kind == "on_chat_model_stream":
            print(event["data"]["chunk"].content, end="")
        elif kind in ("on_chain_start", "on_chain_end"):
            print(f"\n[{kind}] {event['name']}")

asyncio.run(debug_chain())

This is invaluable once chains get nested three or four levels deep — retrieval inside a branch inside a sequence — because it lets you see exactly which sub-component produced which output, without adding manual print statements everywhere. It's also built entirely on the same Runnable contract: any component that supports streaming automatically surfaces its events here.

Putting it together: why this abstraction is worth learning properly

The temptation with LangChain is to treat LCEL syntax as something to memorize by pattern-matching against examples — see prompt | model | parser, copy it, move on. But once you understand that this is all just the Runnable interface being implemented consistently across prompts, models, parsers, retrievers, and your own custom functions, the framework stops feeling like a black box. You can predict that .batch() will run concurrently. You can predict that .stream() will yield incrementally if every component in the chain supports it, and silently buffer if one doesn't. You can predict that .with_retry() and .with_fallbacks() work on anything, because they're methods on the base class, not special cases bolted onto specific components.

That predictability is what lets you debug production LLM pipelines instead of guessing at them. When a chain misbehaves, you check which Runnable in the sequence is responsible, inspect its invoke behavior in isolation, and trace it with astream_events if it's buried in a larger composition. None of that is possible if you've only ever learned the pipe syntax without the contract underneath it.

If you want to go deeper — building real multi-step agents, wiring up retrieval pipelines with production-grade error handling, and understanding how tool calling and memory fit into this same Runnable model — that's exactly what we cover hands-on in the LangChain Tutorial 2026 course, where we build these patterns from scratch instead of just reading about them.