← Back to Learning Hub

LangChain

LangChainLangGraphIntermediate12 min

By: Anacodic Team

TL;DR

LangChain is a "batteries-included" toolkit for wiring LLMs into applications. Its killer feature is LCEL (LangChain Expression Language) — the | pipe operator that composes small pieces (prompt | llm | parser) into a chain: a declarative, streamable, batchable, async-by-default pipeline. Everything in LangChain implements one interface — Runnable — so any two components snap together. Reach for LangChain when you want fast, linear-ish workflows (RAG, chat, extraction) with model connectors, prompt templates, output parsers, memory, and retrievers already built. When you need loops, branching, and shared mutable state, you graduate to LangGraph (its sibling — separate article).


Simple explanation + analogy

Think of LCEL like Unix pipes. In a shell you write cat file | grep error | wc -l — each command does one thing, and | streams the output of one into the input of the next. LangChain does the same for LLM apps:

prompt | llm | output_parser
  • prompt turns your variables into a formatted message
  • llm sends that message to the model and returns a response
  • output_parser extracts the useful bit (a string, JSON, a Pydantic object)

Each stage is a Runnable — a component with a standard set of methods (.invoke(), .batch(), .stream(), .ainvoke()). Because they all speak the same interface, you can reorder, swap, or nest them like Lego bricks. "Batteries included" means you rarely write the plumbing yourself: connectors for OpenAI/Anthropic/Groq/Bedrock, prompt templates, output parsers, conversation memory, and vector-store retrievers all ship in the box.


Diagram

                        LCEL Chain:  prompt | llm | parser
   ┌──────────┐        ┌───────────────┐      ┌──────────┐      ┌──────────────┐
   │  input   │ ─────▶ │ ChatPrompt    │ ───▶ │  ChatLLM │ ───▶ │ OutputParser │ ─▶ result
   │  {dict}  │        │ Template      │ msgs │ (Groq)   │ AIMsg│ (Str / JSON) │
   └──────────┘        └───────────────┘      └──────────┘      └──────────────┘
                              │                     │                   │
                       every box is a Runnable  →  .invoke() .batch() .stream() .ainvoke()

   The `|` operator calls Runnable.__or__ → builds a RunnableSequence.
   Output of stage N is the input of stage N+1.

How it works (deep)

1. The Runnable interface — the whole abstraction

LCEL is built on one protocol. A Runnable exposes:

MethodPurpose
.invoke(input)Run once, sync
.ainvoke(input)Run once, async
.batch([inputs])Run many, with built-in concurrency
.stream(input)Yield output token-by-token
.astream(input)Async streaming

When you write a | b, Python calls a.__or__(b), which LangChain overloads to return a RunnableSequence. That sequence is itself a Runnable, so you get streaming, batching, and async for free across the whole pipeline — you implement none of it. This is the core insight interviewers probe: LCEL is not magic syntax, it's operator overloading over a uniform interface.

2. Composition primitives

  • RunnableSequence (a | b | c) — sequential, output→input.
  • RunnableParallel (a dict {"x": chain1, "y": chain2}) — runs branches concurrently, returns a dict. Used to fan out (e.g. retrieve context and pass the question through at the same time).
  • RunnablePassthrough — forwards input unchanged; .assign() adds keys without dropping the rest. This is how RAG chains carry the original question alongside retrieved docs.
  • RunnableLambda — wraps any plain function into a Runnable so custom logic joins the chain.
  • .with_fallbacks() / .with_retry() — resilience without try/except sprawl.

3. "Batteries included" — what ships in the box

  • Model connectors: ChatOpenAI, ChatAnthropic, ChatGroq, ChatBedrock… — a uniform BaseChatModel interface, so swapping providers is a one-line change.
  • Prompt templates: ChatPromptTemplate.from_messages([...]) with {variable} slots.
  • Output parsers: StrOutputParser (get the text), PydanticOutputParser / JsonOutputParser (get typed structured data — and inject format instructions into the prompt).
  • Memory: chat message history stores (InMemoryChatMessageHistory, RedisChatMessageHistory) for multi-turn context.
  • Retrievers: any vector store (Pinecone, FAISS, Chroma) exposes .as_retriever(), itself a Runnable, so retrieval drops straight into a chain.

4. Chains = workflows

A chain is a fixed workflow: data flows one direction through a known sequence of steps. That is exactly the strength and the limit. LCEL chains are perfect for prompt-chaining, routing, and RAG. They cannot easily loop ("keep researching until confident") or maintain a shared mutable state object across cyclic steps — that is when you move to LangGraph.


The math

LCEL has no probabilistic math of its own, but two ideas are worth stating precisely.

Composition as function composition. A chain f | g | h is the function composition h(g(f(x))). If f: A→B, g: B→C, h: C→D, the chain is a Runnable A→D. The type contract matters: the output type of each stage must match the input type of the next. A common bug is piping an LLM (which returns an AIMessage) directly into a retriever (which expects a str) — you need a parser or lambda in between.

Batch latency. For n independent inputs, .batch() issues them with bounded concurrency c. Wall-clock time is roughly:

$$T_{\text{batch}} \approx \left\lceil \frac{n}{c} \right\rceil \times T_{\text{single}}$$

where T_single is one call's latency and c is max_concurrency. Sequential .invoke() in a loop gives n × T_single; batching cuts it by up to . This is why "just loop over .invoke()" is the wrong answer in an interview — LCEL gives you concurrency for free.


Real code

From a trip-planning app (trip_planner.py), the chat pipeline. Note how prompt, LLM, and parser compose with |, and how the same pattern builds three specialized sub-chains:

from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_groq import ChatGroq

_RECOMMEND_PROMPT = ChatPromptTemplate.from_messages([
    ("system", """You are a friendly, knowledgeable travel assistant.
User preferences:
- Trip style: {trip_style}
- Constraints: {constraints}
Destinations from our database:
{context}
Rules:
- 2-4 sentences max. Conversational, not corporate."""),
    ("human", "{query}"),
])

class ChatChain:
    def __init__(self) -> None:
        self._llm = ChatGroq(model=GROQ_MODEL, temperature=0.75, api_key=GROQ_API_KEY)
        self._str_parser = StrOutputParser()
        # The LCEL pipe: prompt | llm | parser  — three chains from one pattern
        self._recommend_chain = _RECOMMEND_PROMPT | self._llm | self._str_parser
        self._greeting_chain  = _GREETING_PROMPT  | self._llm | self._str_parser
        self._followup_chain  = _FOLLOWUP_PROMPT  | self._llm | self._str_parser

    async def invoke(self, query, chat_id, user_profile=None):
        # ...retrieval happens here...
        text = await asyncio.to_thread(
            self._recommend_chain.invoke,
            {
                "query": search_query,
                "trip_style": effective_style,
                "constraints": ", ".join(effective_constraints) or "none",
                "context": _docs_to_context(docs),
                "chat_history": history_str or "No prior conversation.",
            },
        )
        add_exchange(chat_id, query, text)
        return {"response": {"text": text, "destinations": [...]}, "chat_id": chat_id}

And the structured-output variant (query_parser.py) — a chain whose last stage is a Pydantic parser, so the rest of the app works with typed data instead of raw strings:

from langchain_core.output_parsers import PydanticOutputParser
from pydantic import BaseModel, Field

class ParsedQuery(BaseModel):
    intent: str = Field(description="One of: greeting, trip_query, off_topic, follow_up")
    location: Optional[str] = Field(None, description="City or area mentioned")
    is_relative: bool = Field(False, description="References prior conversation?")
    cleaned_query: str = Field(description="Normalised, standalone version")

_parser = PydanticOutputParser(pydantic_object=ParsedQuery)

def get_query_parser_chain():
    llm = ChatGroq(model=GROQ_MODEL, temperature=0, api_key=GROQ_API_KEY)
    # format_instructions are injected into the prompt so the LLM emits valid JSON
    return (
        _PROMPT.partial(format_instructions=_parser.get_format_instructions())
        | llm
        | _parser              # → returns a typed ParsedQuery object
    )

Two things to notice for interviews: (1) .partial() pre-fills a template variable so the runtime chain only needs {query}; (2) the parser's get_format_instructions() is fed back into the prompt — the parser both shapes the prompt and validates the output.


Real-world example (a trip-planning app)

The app's /api/chat endpoint is served entirely by the LCEL chain above. When a user types "cheap 5-day beach trip near Barcelona":

  1. query_parser chain (prompt | llm | PydanticParser) extracts intent="trip_query", location="Barcelona", trip_style="beach" as a typed ParsedQuery.
  2. A retriever (Pinecone → flight-search fallback, itself a Runnable) pulls matching destinations.
  3. The recommend_chain (prompt | ChatGroq | StrOutputParser) synthesizes a warm 2–4 sentence recommendation from the retrieved context.
  4. Conversation memory persists the exchange so "make it cheaper" on the next turn resolves correctly.

The whole path is linear and stateless-per-request — a textbook LCEL fit. The app's team chat feature, which needs branching (new suggestion vs. follow-up vs. booking) and loops, is built with LangGraph instead, not LCEL — a clean illustration of where the chain abstraction ends.


Interview questions companies actually ask

1. What is LCEL and what problem does the | operator solve? [easy] LCEL (LangChain Expression Language) is a declarative way to compose LLM pipelines. The | operator overloads Python's __or__ to build a RunnableSequence, chaining components so each one's output feeds the next. It solves the boilerplate of manually calling each step and gives you streaming, batching, and async across the whole chain for free. (GeeksforGeeks: LCEL)

2. What is the Runnable interface and why does it matter? [medium] Runnable is the universal protocol every LCEL component implements: .invoke(), .batch(), .stream(), and their async variants. Because everything speaks it, any two components compose, and a composed chain is itself a Runnable — so composition is uniform and infinitely nestable. (LCEL beginner's guide)

3. Difference between RunnableParallel, RunnablePassthrough, and RunnableLambda? [medium] RunnableParallel runs multiple branches concurrently and returns a dict of their outputs. RunnablePassthrough forwards input unchanged (.assign() adds keys) — essential in RAG to carry the original question alongside retrieved docs. RunnableLambda wraps a plain function into a Runnable so custom logic joins the pipe. (50 LangChain Q&A)

4. How do you get structured (JSON/typed) output from a chain? [medium] End the chain with a PydanticOutputParser or JsonOutputParser. Call parser.get_format_instructions() and inject them into the prompt so the LLM emits conformant JSON; the parser then validates and deserializes into your model. Modern LangChain also offers .with_structured_output(schema) on chat models, which uses native tool/function-calling for stronger guarantees.

5. Why is looping over .invoke() for many inputs a bad idea? [medium] It's sequential: n × T_single latency. Use .batch() with max_concurrency — LangChain fires them concurrently, cutting wall-clock to roughly ⌈n/c⌉ × T_single. You get this for free because batching is part of the Runnable interface.

6. What is a "chain" and where do chains fall short? [medium] A chain is a fixed, mostly linear workflow — data flows one way through known steps. Chains excel at RAG, extraction, and prompt-chaining. They fall short when you need loops, dynamic branching, or a shared mutable state carried across cyclic steps — that's LangGraph's job. (Real Python: LangGraph)

7. How does streaming work end-to-end in LCEL? [hard] Because the composed sequence implements .stream(), LangChain propagates streaming: the LLM yields tokens, and downstream Runnables that support incremental processing pass them through. A transparent parser like StrOutputParser streams cleanly, so .stream() on prompt | llm | StrOutputParser() yields tokens as they arrive. (A parser that must see the whole output — e.g. JSON — buffers instead.)

8. How do you make an LCEL chain resilient to a flaky provider? [hard] Attach .with_retry() for transient errors and .with_fallbacks([backup_chain]) to route to a different model or provider on failure — both return new Runnables, so resilience composes into the pipe without try/except scattered through your code.

9. LangChain vs LangGraph — one sentence each. [easy] LangChain gives you the components (models, prompts, parsers, retrievers, memory) and LCEL to chain them linearly; LangGraph is the orchestration layer on top that adds explicit shared state, branching, loops, and checkpointing. You typically use LangChain components inside LangGraph nodes. (250 LangGraph Q&A)


When to use / tradeoffs

Use LangChain (LCEL) when:

  • The workflow is linear or lightly branched: RAG, chat, extraction, summarization, routing.
  • You want provider-agnostic connectors and to swap models with one line.
  • You need streaming/batch/async without writing that plumbing.

Avoid / graduate away when:

  • You need cyclic control flow ("loop until good enough"), multiple agents messaging each other, or a shared mutable state object → use LangGraph.
  • You want maximum transparency and minimal dependencies → Anthropic's guidance is that the best agents often use simple, composable patterns (direct SDK calls), not heavy frameworks. LangChain's abstractions can obscure what's actually sent to the model.

Tradeoffs: LangChain trades a learning curve and abstraction overhead for speed of development and a huge ecosystem. Debugging a deep chain can be opaque (mitigate with LangSmith tracing). Version churn has historically been high — pin your versions.


LangChain's core is LCEL: a Runnable interface plus the | operator that composes prompts, models, and parsers into streamable, batchable, async chains — with model connectors, memory, and retrievers included. Chains are ideal linear workflows; the moment you need loops and shared state, move up to graphs.

Related articles:

  • LangGraph — stateful graphs, conditional edges, loops (the next step up from chains).
  • Framework Comparison — LangChain vs LangGraph vs Strands vs AutoGen vs CrewAI.
  • RAG Pipelines — retrievers as Runnables inside an LCEL chain.
  • Structured Output & Output Parsers — getting typed data out of LLMs.

Runnable notebook

Run it end to end — the mock model needs no API key; add your own key for the real Claude section.

Open In Colab