LCEL Part 1: The Pipe Operator, Batching, Streaming and the Runnable Protocol
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 orderbatch 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 valueA 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 withRunnableParallel, wraps your own functions withRunnableLambdaand the@chaindecorator, and draws the graph of what you assembled.
Related
Structured Output: with_structured_output and When You Still Need a Parser
Free text is unusable in a pipeline. This post covers getting validated objects back with Pydantic, and the narrower cases where an output parser is still the right tool.
Model Inputs in LangChain: Messages, Prompt Templates and Few-Shot Examples
LangChain's first real abstraction is the input side: typed messages, reusable prompt templates, and few-shot examples that teach a model by showing instead of telling.
The OpenAI API Before LangChain: Roles, Temperature, Max Tokens and Streaming
Before reaching for a framework, call the API directly. System, user and assistant roles, a sarcastic chatbot, and what temperature and streaming actually control.
Comments