Prompts, LCEL, and the Pipe That Composes Them

Prompt templates, the Runnable interface every LangChain piece implements, and LCEL — the | operator that composes prompt, model, and parser into one chain that streams, batches, retries, and falls back for free. Plus where LCEL stops and LangGraph begins.

You now have a model that takes messages and returns a message. In real code you almost never hand it a fixed list of messages — you have a template with holes (“you are a {tone} assistant”), a value to drop in each hole, and usually a step before (fill the template) and a step after (shape the result). That’s a small pipeline: fill, call, parse. The question this chapter answers is how you wire pipeline steps together cleanly, and LangChain’s answer is a single operator — the pipe, |. It’s the last building block before the graph, and understanding it makes the graph itself feel familiar, because a graph is what you get when a pipeline needs to branch and loop. Run against langchain-core 1.5.1.

Prompt templates

Hard-coding a prompt string is fine for a demo and wrong for an application, where the instructions are fixed but the user’s input varies. A ChatPromptTemplate is a message list with named variables you fill at call time:

from langchain_core.prompts import ChatPromptTemplate

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a {tone} assistant."),
    ("human", "{question}"),
])
prompt.invoke({"tone": "terse", "question": "Capital of France?"})
# ChatPromptValue -> [SystemMessage('You are a terse assistant.'), HumanMessage('Capital of France?')]

Notice the template itself has an invoke — filling it is a call that returns messages, the same shape a model consumes. That’s not a coincidence; it’s the design, and the next section names it.

For the common case of splicing in a running conversation, MessagesPlaceholder reserves a slot for a whole list of messages:

from langchain_core.prompts import MessagesPlaceholder

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a {tone} assistant."),
    MessagesPlaceholder("history"),
    ("human", "{question}"),
])
prompt.invoke({"tone": "terse", "history": past_messages, "question": "and its population?"})

history takes a list of Message objects (the growing conversation from the last chapter) and drops them in verbatim, between the system instructions and the new question. This is how a chat keeps context while your code supplies only the new turn — and it’s exactly where saved history comes back in once persistence starts reloading it.

Everything is a Runnable

Here’s the unifying idea, and it’s the one to hold. The prompt has invoke. The model has invoke. So does an output parser. They all implement one interface — Runnable — which guarantees invoke, stream, batch, and their async twins on every piece. Because they share that interface, they compose, and the operator that composes them is |:

chain = prompt | model

chain is itself a Runnable. chain.invoke({...}) feeds the dict to the prompt, passes the resulting messages to the model, and returns the model’s AIMessage. Read | as “pipe the output of the left into the right” — the same mental model as a Unix shell pipe, and the reason this composition is called LCEL, the LangChain Expression Language. That name sounds like a new language to learn; it isn’t. It’s operator overloading on Runnable. Once you see that, “expression language” demystifies into “objects with an invoke, chained with a pipe,” and the intimidation drains out of it.

Adding a parser

The model returns an AIMessage. Often you want just its text. StrOutputParser is a Runnable that pulls .content out, so it clips onto the end of the pipe:

from langchain_core.output_parsers import StrOutputParser

chain = prompt | model | StrOutputParser()
chain.invoke({"tone": "terse", "question": "Capital of France?"})
# 'Paris.'

Now the chain returns a string. (A precise note, because this series checks such things: the value’s concrete type is TextAccessor, a str subclass — it is a string for every practical purpose, so .upper(), slicing, and concatenation all work.) For typed output you don’t add a parser at all — you use with_structured_output on the model, from the previous chapter, which enforces a Pydantic schema through the provider instead of parsing text and hoping. StrOutputParser for “just the text,” with_structured_output for “these fields,” and the old PydanticOutputParser for neither, since it’s superseded.

The payoff: stream, batch, retry, fall back — free

Why compose with | instead of calling three functions by hand? Because the composed chain is still a Runnable, so it inherits the entire interface. Build the chain once and every capability comes with it:

chain.invoke({...})           # run it
chain.batch([{...}, {...}])   # run many concurrently
for tok in chain.stream({...}):
    print(tok, end="")         # stream tokens through the whole chain

Streaming threads through the pipe — tokens flow from the model, through the parser, out to you, with no wiring on your part. Batching runs inputs concurrently across the whole chain. You wrote a three-stage pipeline and got streaming and concurrent execution of it for nothing, because every stage already spoke Runnable.

The same inheritance gives you two production essentials as one-liners. Models and APIs fail intermittently; a Runnable can retry itself with backoff, and fall back to an alternative when it’s exhausted:

robust = (prompt | model).with_retry(stop_after_attempt=3)          # retry transient failures
robust = (prompt | model).with_fallbacks([prompt | backup_model])   # or switch models on failure

Verified: .with_retry(...) returns a RunnableRetry and .with_fallbacks([...]) a RunnableWithFallbacks, each still a Runnable you keep piping. This is the cheap, correct way to make a chain resilient — a rate-limited model retries instead of erroring, and a flaky primary falls back to a backup — and it’s available on any Runnable, chain or single step, because resilience is part of the interface too.

The glue Runnables

Real chains reshape data between stages, and three small Runnables cover almost all of it:

from langchain_core.runnables import RunnableLambda, RunnableParallel, RunnablePassthrough

double = RunnableLambda(lambda x: x * 2)                 # wrap any function; double.invoke(21) -> 42

RunnableParallel(shout=str.upper, size=len).invoke("hi") # run branches at once, collect a dict
# {'shout': 'HI', 'size': 2}

RunnablePassthrough.assign(upper=lambda d: d["text"].upper()).invoke({"text": "ada"})
# {'text': 'ada', 'upper': 'ADA'}  — pass input through, add a computed key

RunnableLambda lifts a plain function into the pipe, so any Python you need mid-chain becomes a stage. RunnableParallel runs several Runnables on the same input concurrently and gathers a dict — the exact shape a RAG chain uses to fetch context and forward the question together. RunnablePassthrough.assign augments a dict without dropping what’s already there. Put them together and a two-step chain reads cleanly:

# summarize a review, then classify the summary's sentiment — two model calls, one expression
chain = (
    RunnablePassthrough.assign(summary=summarize_chain)   # add a 'summary' key
    | classify_chain                                       # classify using it
)

These glue pieces are what make the RAG chain two chapters from now a single expression rather than a tangle of intermediate variables.

A note on config

Every invoke/stream/batch takes an optional config — a RunnableConfig dict — that rides through the whole chain. It carries a run name and tags (useful when you turn on tracing and want to find a specific run), a concurrency limit for batches, and, importantly later, the configurable values LangGraph uses for a graph’s thread_id. You’ll mostly ignore it now and lean on it once graphs and persistence arrive; it’s worth knowing the seam exists.

Where LCEL stops

LCEL is a straight pipe: data flows left to right through a fixed sequence, with optional parallel branches that rejoin. That covers an enormous amount — any transform-and-call pipeline — and you should reach for it whenever the flow is linear. What it cannot express is control flow: looping until a condition holds, branching to different next steps based on what the model said, pausing for a human, running an agent that chooses its own path. The instant your flow has a cycle or a genuine decision in it, you’ve outgrown the pipe.

That’s not a flaw in LCEL; it’s the line where LangGraph begins. A pipe is a graph with no branches and no loops — LangGraph is what you use when you need the branches and loops. Every chain you build with | is a special case of a graph, which is why the two compose so cleanly: LCEL chains routinely appear as nodes inside graphs. Keep LCEL for the linear stretches, and reach for the graph the moment the arrows stop all pointing the same way. Knowing where the pipe ends is knowing when you’ve arrived at the real subject of this series.

Final thoughts

Prompt templates parameterize the fixed-instruction/variable-input split; every piece — prompt, model, parser, glue — implements Runnable, so | chains them into a pipeline that streams, batches, retries, and falls back for free; and the glue Runnables reshape data between stages. LCEL isn’t a language, it’s invoke plus operator overloading, and its one hard boundary — no loops, no real branching — is exactly the boundary that hands you off to the graph. You’ve now met every primitive there is: messages, models, prompts, composition. Two chapters give the model hands and knowledge, and then we build the graph those primitives run inside.

Next: tools — how you hand a model a Python function it can decide to call, and what actually comes back when it does.

Comments