25.9 LangChain Expression Language (LCEL)
Right, let’s talk about LangChain Expression Language, or LCEL. You can think of this as the single best idea the LangChain team ever had. Before LCEL, building a chain was often a exercise in verbose, class-heavy Python that felt like you were assembling furniture with instructions in a language you only vaguely understood. LCEL is the antidote to that. It’s a declarative, functional way to compose chains that is not only more readable but also gives you superpowers like native async support, batch processing, and streaming out of the box. It makes the old, clunky Chain classes look like a horse and buggy.
The core idea is stupidly simple: you connect components using the pipe operator (|). It’s not magic, it’s just operator overloading done right. Each component (a Runnable) has methods like .invoke, .batch, .astream, and the pipe operator simply takes the output from one and feeds it as the input to the next. It’s function composition for the LLM world.
The Power of the Pipe
Let’s start with the simplest possible chain: a prompt template fed into an LLM. Here’s how you’d do it the old way, and then the LCEL way. You’ll immediately see the difference.
# The clunky, "old" way (you might still see this in legacy code)
from langchain.llms import OpenAI
from langchain.prompts import PromptTemplate
prompt = PromptTemplate.from_template("Tell me a joke about {topic}")
llm = OpenAI(model_name="gpt-3.5-turbo-instruct")
# You have to call them separately, handling the input/output yourself
formatted_prompt = prompt.format(topic="robots")
joke = llm(formatted_prompt)
print(joke)
# The glorious LCEL way
from langchain.schema.runnable import RunnablePassthrough
chain = prompt | llm
joke = chain.invoke({"topic": "robots"})
print(joke)
The LCEL version is cleaner. But the real benefit isn’t apparent until you start adding more steps. Want to parse the LLM’s output with an output parser? Just pipe it again.
from langchain.schema.output_parser import StrOutputParser
chain = prompt | llm | StrOutputParser()
# Now chain.invoke() returns a clean string, not a bulky LLMResult object.
joke = chain.invoke({"topic": "programming"})
This is where the beauty kicks in. Each component is a Runnable with a standard interface. The LLM Runnable outputs an LLMResult, the StrOutputParser Runnable knows how to take an LLMResult as input and output a string. They just snap together.
Going Beyond Linear Chains
LCEL isn’t just for straight lines. You can build forks, conditionals, and all sorts of logic. The RunnableParallel (often used with its shorthand dict) is your best friend for fanning out to multiple components and collecting results. This is crucial for building advanced patterns like retrieval-augmented generation (RAG).
from langchain.schema.runnable import RunnableParallel
# Imagine we have a retriever that finds relevant documents based on a question
retriever = ... # Assume this is set up elsewhere
# This chain both passes the original question onward AND retrieves context
setup_chain = RunnableParallel(
{"context": retriever, "question": RunnablePassthrough()}
)
prompt = PromptTemplate.from_template(
"Answer the question based only on this context:\n{context}\n\nQuestion: {question}"
)
llm = OpenAI()
output_parser = StrOutputParser()
# The chain: run parallel steps, then feed the combined dict to the prompt, then LLM, then parser
full_chain = setup_chain | prompt | llm | output_parser
answer = full_chain.invoke("What did the CEO say about profitability?")
Here, RunnablePassthrough() is a handy tool that just takes the input and passes it through unchanged. So the input question becomes the value for the “question” key. The retriever runs with the same input question and its output becomes the “context” key. The prompt template then receives a dictionary with both context and question available to format.
Why You Should Always Use LCEL
I’m not kidding. Always. Here’s why:
- Streaming: With one line of code, you get streaming tokens. Try doing that easily with the legacy classes.
for chunk in chain.stream({"topic":"cats"}): print(chunk, end='') - Async: Every chain you build with LCEL has
.ainvoke,.abatch, and.astreammethods. No extra work required. This is a massive win for building responsive applications. - Batch Processing: Use
.batchto process lists of inputs efficiently, which often leads to faster overall throughput with LLM APIs. - Robustness: The standard interface means everything works together predictably. It also gives you a great way to debug; you can
chain.stream_logto see the intermediate results of every step, which is a lifesaver when your prompt isn’t working as expected.
The rough edge? The error messages can sometimes be inscrutable, deep from within the LangChain stack. When you get a cryptic error, your first debugging step should be to break the chain apart and .invoke each component one by one to see where it’s failing.
The designers got this one right. LCEL is the foundation. Use it, and never look back.