Skip to content

LCEL Part 1: The Pipe Operator, Batching, Streaming and the Runnable Protocol

Site Console Site Console
8 min read Updated Oct 14, 2026 AI & Tools 0 comments

Three Pieces, One Question

You now have a prompt that takes variables, a model that returns typed objects, and a parser that extracts text. Connecting them is three lines of Python:

value = prompt.invoke({"question": "How do I reset my password?"})
message = model.invoke(value)
text = parser.invoke(message)

That works. It also means that making it stream is your problem, running a hundred inputs concurrently is your problem, and going async is your problem — three times over, once per step.

LCEL is the alternative. It stands for LangChain Expression Language, and what it actually is is one shared interface with a pipe operator on top. This post covers the pipe and the three things it gives you. Part 2 covers composing shapes other than a straight line.


Piping a Prompt, a Model and an Output Parser

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain.chat_models import init_chat_model

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a sarcastic but accurate support assistant."),
    ("human", "{question}"),
])
model = init_chat_model("gpt-5.6-luna")

chain = prompt | model | StrOutputParser()

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

That is the sarcastic chatbot for the third time in this series — a string literal in post 4, a template in post 5, and now a single object you can pass around, test, and hand to something else.

Read the data flowing through it and nothing is mysterious. The dict goes into the prompt, which produces a ChatPromptValue. That goes into the model, which produces an AIMessage. That goes into the parser, which produces a string. Each step's output type is the next step's input type, which is the only contract the pipe enforces.

Order matters and the operator is not commutative. model | prompt is a type error waiting to happen, because a prompt template does not accept a message.


Batching

The chain handles many inputs with no extra code:

chain.batch([
    {"question": "How do I reset my password?"},
    {"question": "Why is my invoice wrong?"},
    {"question": "Where are the logs?"},
])
# three results, in input order

batch runs the inputs concurrently rather than in a loop, which is the point. Three sequential model calls take three round trips; batch overlaps them. The concurrency is configurable through max_concurrency when you need to respect a rate limit:

chain.batch(questions, config={"max_concurrency": 5})

Results come back in the order you supplied, regardless of which finished first — so you can zip them against your inputs without tracking anything.

This is also where the Batch API discount from post 3 does not apply. chain.batch is client-side concurrency against the normal endpoint; the provider's 50%-off batch tier is a separate submission mechanism with its own latency contract. Two different things with an unfortunately similar name.


Streaming

for piece in chain.stream({"question": "Why is the build failing?"}):
    print(piece, end="", flush=True)

Streaming does not make generation faster. It makes the wait visible, which users read as responsive rather than broken — the same point post 4 made about the raw API, except that here you did not have to write the event filtering.

Now the part that only shows up when you run it, and the most useful thing in this post.

Streaming through a chain is not automatic. Each step has to be able to pass chunks along, and whether yours can depends on how you wrote it.

A plain function receives the accumulated value. It therefore waits for everything upstream to finish, and your stream collapses to one chunk:

def upper(text: str) -> str:
    return text.upper()

list((source | upper).stream("x"))
# ['ONE TWO THREE FOUR']   <- a single chunk: it had to wait for the whole value

A generator function receives an iterator of upstream chunks, and can yield as they arrive:

def to_words(chunks):
    for chunk in chunks:
        for word in chunk.split():
            yield word + "|"

list((source | to_words).stream("x"))
# ['one|', 'two|', 'three|', 'four|']

Both produce identical results under invoke — 'one|two|three|four|' either way. The difference appears only when streaming, which is exactly the situation in which you least want to discover it.

So when you add a step to a chain and your UI stops updating token by token, this is why. A step that needs the complete value is a barrier. Sometimes that is unavoidable — you cannot sort a list you have not finished receiving — and when it is avoidable, write the step as a generator over chunks.

The async forms are all present on the composed object too: ainvoke, abatch, astream, and astream_events for a structured event stream over every step rather than just the final output.


A Note About the LangChain Documentation

This section exists because the documentation will confuse you otherwise, and it is better to know why in advance.

LangChain v1's overview page presents create_agent as the central abstraction. It does not mention LCEL, Runnables, prompt templates or output parsers at all. If you arrive at the docs looking for what this post describes, the front door will not show it to you.

Everything here lives in langchain-core, and it is documented — the API reference at reference.langchain.com has a runnables module described explicitly as covering "LangChain Runnable and the LangChain Expression Language (LCEL)". It is supported, it is current, and it is not the headline.

That leaves you reading three eras of material at once. Pre-v1 tutorials use LLMChain and friends, which now live in langchain-classic and will not import as written — post 2 covered how to date those. Mid-era material teaches LCEL as the way to build with LangChain, which overstates it today. And current material teaches agents, which is a different problem from the one you may have.

The distinction worth holding on to is who decides the control flow. LCEL is for pipelines you designed: prompt, then model, then parse — a fixed path you can read off the code. create_agent is for when the model decides what happens next: which tool to call, whether to call another, when it is done. Most production LLM features are the first kind, and a deterministic pipeline is far easier to test, cost-model and debug than an agent.

Reaching for an agent when your control flow is actually fixed is the most common over-engineering mistake in this space. If you can draw the flow as a straight line, write it as one.


The Runnable and RunnableSequence Classes

Underneath the operator there are two classes worth knowing by name.

Runnable is the interface every composable piece implements. A prompt template is one. A chat model is one. An output parser is one. with_structured_output returns one. The interface is small — invoke, batch, stream and the three async counterparts — and because every piece implements all six, anything built out of them implements all six too.

That is the whole mechanism. Composition is closed under the interface, so a three-step pipeline has exactly the same capabilities as a single step. Nobody wrote batch for your chain; your chain has batch because everything in it does.

RunnableSequence is what the pipe actually constructs, and it keeps its parts:

type(chain).__name__
# 'RunnableSequence'

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

A sequence is itself a Runnable, which is why it can be piped into another one — the subject of part 2.

It is also introspectable, which is how you validate at the edge of a service instead of discovering a missing key inside a model call:

list(chain.get_input_jsonschema()["properties"].keys())
# ['question']

The prompt declared {question}, the sequence inherited that as its input contract, and nothing in your code had to restate it.

Everything above was executed against langchain 1.4.3 and langchain-core 1.6.7 on 10 October 2026, with a RunnableLambda standing in for the chat model so that batching and streaming behaviour could be confirmed rather than assumed.


🧭 What's Next

  • Post 7b: LCEL Part 2 — a straight line is the easy case. Part 2 pipes chains into other chains, threads inputs through a pipeline with RunnablePassthrough, fans out with RunnableParallel, wraps your own functions with RunnableLambda and the @chain decorator, and draws the graph of what you assembled.

Related

Leave a comment

Sign in to leave a comment.

Comments