LangChain in Production: LCEL, Retrievers, and the Traps
LangChain is easy to demo and easy to over-use. Here's the mental model that keeps it useful — the pipe operator, retriever chains, structured output, and the places it quietly bites you.
LangChain gets a bad reputation, and half of it is deserved — early versions buried a two-line idea under ten layers of abstraction. But the modern core, the LangChain Expression Language (LCEL), is genuinely good once you see what it actually is: function composition for LLM steps.
I used it heavily on CodeSprint for hints and code analysis. This is the version of LangChain I'd actually reach for.
LCEL is just a pipeline
The whole idea is the pipe operator. You connect a prompt, a model, and an output parser into one runnable, and data flows left to right:
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
prompt = ChatPromptTemplate.from_template("Explain {topic} in one paragraph.")
chain = prompt | model | StrOutputParser()
chain.invoke({"topic": "backpressure"})Every piece is a Runnable, and every runnable gives you .invoke(), .stream(), and .batch() for free — sync or async. That uniformity is the actual payoff. You write the chain once and it streams, batches, and runs concurrently without rewrites.
A retriever chain, end to end
RAG in LCEL is where it clicks. You run retrieval and pass-through in parallel, feed both into the prompt, then the model:
from langchain_core.runnables import RunnableParallel, RunnablePassthrough
chain = (
RunnableParallel(
context=retriever, # question → relevant docs
question=RunnablePassthrough(), # question → itself, untouched
)
| prompt
| model
| StrOutputParser()
)
chain.invoke("How does the submission queue work?")RunnableParallel runs both branches concurrently; RunnablePassthrough forwards the input unchanged so the prompt sees both the retrieved context and the original question. Clean, and there's no glue code holding it together.
Structured output instead of string-parsing
The trap I see most: getting text back and regexing it into fields. Don't. Bind a schema and let the model fill it:
from pydantic import BaseModel
class Hint(BaseModel):
concept: str
nudge: str
reveal_solution: bool
structured = model.with_structured_output(Hint)
structured.invoke("Give a hint for the two-sum problem, don't reveal the answer.")Now you get a typed object, validated, every time — no brittle parsing, no "sometimes the model adds a preamble" bugs.
Routing with branches
When one chain isn't enough — say, code questions go one way and general questions another — RunnableBranch picks the sub-chain at runtime based on a condition, so you don't hand-roll if/else around .invoke() calls.
The traps
A few things that cost me time:
- Abstraction tax. If your task is one prompt and one model call, you don't need a framework — call the SDK. LangChain earns its weight when you have retrieval, routing, tools, and streaming to coordinate.
- Version churn. The ecosystem moves fast and splits into packages (
langchain-core, provider packages). Pin versions and read the migration notes before upgrading, or a minor bump will break imports. - Hidden token cost. Composed chains can stuff more into the context than you realize. Log the final prompt at least once — the bill and the latency both live there.
- Observability isn't optional. Once a chain has three-plus steps, you can't reason about failures by reading code. Wire in tracing (LangSmith or your own logging) early.
Where it fits
I treat LCEL as the tool for linear LLM pipelines: prompt, retrieve, structure, parse. The moment I need loops, branching state, or an agent that calls tools and comes back, I reach for LangGraph instead — which is the subject of the next post. Right tool, right shape.