Skip to content

1. LangChain

Intermediate · 13 min read

LangChain is the most widely used LLM framework, and the most argued about. In 2023 it was a sprawling collection of "chains". Today it's a much leaner core: a standard interface to hundreds of models, vector stores and tools, a small composition syntax (LCEL), and a ready-made agent built on LangGraph. The value is the integrations and the shared interfaces. The cost is an abstraction layer between you and the API.

1.1 What LangChain is today

Package What it contains
langchain-core The base interfaces: chat models, messages, prompts, tools, retrievers, and the Runnable protocol behind LCEL
langchain High-level pieces: create_agent, middleware, init_chat_model
langchain-openai, langchain-anthropic, langchain-google-genai ... One package per model provider
langchain-chroma, langchain-pinecone, langchain-postgres ... Vector store integrations
langchain-text-splitters Chunkers: recursive, Markdown, token-based, code
langchain-community Community-maintained integrations: hundreds of document loaders, older tools
LangSmith (separate product) Tracing, evaluation and prompt management, which works with or without LangChain

Install only what you use: pip install langchain langchain-openai covers most of this page.

1.2 Chat models: one interface, any provider

The core idea is that every provider's model has the same methods: invoke, stream, batch and their async versions. Switching providers becomes a one-line change.

# no-run — needs an API key
from langchain.chat_models import init_chat_model

model = init_chat_model("openai:gpt-4o-mini", temperature=0)        # or "anthropic:claude-...", "google_genai:gemini-..."

reply = model.invoke([
    {"role": "system", "content": "You are a concise assistant."},
    {"role": "user", "content": "What is RAG in one sentence?"},
])
print(reply.content)            # an AIMessage: .content, .tool_calls, .usage_metadata

for chunk in model.stream("Explain HyDE in two sentences."):
    print(chunk.content, end="", flush=True)

Compare this with calling LLM APIs directly. The messages are the same idea, wrapped in LangChain's HumanMessage / AIMessage / ToolMessage classes (plain dicts are accepted too).

1.3 Prompts and LCEL, built from scratch first

LCEL (LangChain Expression Language) composes steps with the | operator: prompt | model | parser. Each step is a Runnable, meaning anything with invoke, batch and stream. The pipe passes one step's output to the next step's input. There's no magic in that. Here it is in plain Python:

class Runnable:
    def __init__(self, fn, name: str):
        self.fn, self.name = fn, name

    def invoke(self, value):
        return self.fn(value)

    def batch(self, values: list) -> list:
        return [self.invoke(v) for v in values]          # the real one runs these concurrently

    def __or__(self, other: "Runnable") -> "Runnable":   # a | b  ->  a new Runnable that runs a, then b
        return Runnable(lambda value: other.invoke(self.invoke(value)), f"{self.name} | {other.name}")

def prompt_template(template: str) -> Runnable:
    return Runnable(lambda variables: [
        {"role": "system", "content": "You are a concise assistant."},
        {"role": "user", "content": template.format(**variables)},
    ], "prompt")

fake_model = Runnable(lambda messages: {"role": "assistant",
                                        "content": f" (reply to: {messages[-1]['content']}) "}, "model")
parser = Runnable(lambda message: message["content"].strip(), "parser")

chain = prompt_template("Explain {topic} in one sentence.") | fake_model | parser
print(chain.name)
print(chain.invoke({"topic": "RAG"}))
print(chain.batch([{"topic": "HyDE"}, {"topic": "reranking"}]))
Output
prompt | model | parser
(reply to: Explain RAG in one sentence.)
['(reply to: Explain HyDE in one sentence.)', '(reply to: Explain reranking in one sentence.)']

The real version reads the same way:

# no-run
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a concise assistant."),
    ("user", "Explain {topic} in one sentence."),
])
chain = prompt | model | StrOutputParser()

chain.invoke({"topic": "RAG"})
chain.batch([{"topic": "HyDE"}, {"topic": "reranking"}])        # runs concurrently
for token in chain.stream({"topic": "MMR"}):                     # streaming flows through the whole pipe
    print(token, end="")

What the real Runnables add on top of the 15-line version: concurrent batch, streaming through every step, async versions, retries and fallbacks (model.with_retry(), model.with_fallbacks([backup_model])), and automatic tracing of every step in LangSmith.

1.4 Structured output

with_structured_output takes a Pydantic model (or a JSON schema) and uses the provider's native structured-output or tool-calling feature. It returns validated objects instead of text:

# no-run
from pydantic import BaseModel, Field

class TicketTriage(BaseModel):
    category: str = Field(description="billing, shipping, technical or other")
    urgent: bool
    summary: str = Field(description="one sentence")

triage = model.with_structured_output(TicketTriage)
result = triage.invoke("My card was charged twice and I need the money back today!")
print(result)       # TicketTriage(category='billing', urgent=True, summary='Customer was double-charged...')

That's the same idea as structured output with Pydantic, with the provider-specific details handled for you.

1.5 Tools and agents

A tool is a typed Python function. The @tool decorator builds the JSON schema from the type hints and docstring. create_agent runs the tool loop (call the model, run the requested tools, send back the results, repeat) on top of LangGraph:

# no-run
from langchain.agents import create_agent
from langchain.tools import tool

@tool
def get_order_status(order_id: str) -> str:
    """Look up the shipping status of an order by its ID."""
    return {"A100": "Shipped, arriving Friday", "A101": "Processing"}.get(order_id, "Order not found")

@tool
def search_policies(query: str) -> str:
    """Search the company policy documents."""
    return "Refunds are processed within 5 working days of approval."

agent = create_agent(
    model="openai:gpt-4o-mini",
    tools=[get_order_status, search_policies],
    system_prompt="You are a support assistant. Use tools; never guess order details.",
)
result = agent.invoke({"messages": [{"role": "user", "content": "Where is order A100, and how long do refunds take?"}]})
print(result["messages"][-1].content)

create_agent also supports middleware: hooks that run before or after model and tool calls. They're used for summarising long histories, human approval of risky tools, PII redaction, or switching models. When you need custom control flow, drop down to LangGraph.

1.6 RAG in LangChain

Here is the pipeline from the RAG series, with each hand-written step swapped for a LangChain component:

# no-run — pip install langchain langchain-openai langchain-chroma langchain-community langchain-text-splitters pypdf
from langchain_chroma import Chroma
from langchain_community.document_loaders import PyPDFLoader
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import OpenAIEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter

# Indexing
docs = PyPDFLoader("refund-policy.pdf").load()                       # one Document per page, with page metadata
chunks = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=150).split_documents(docs)
store = Chroma.from_documents(chunks, OpenAIEmbeddings(model="text-embedding-3-small"), persist_directory="db")
retriever = store.as_retriever(search_kwargs={"k": 4})

# Query
prompt = ChatPromptTemplate.from_messages([
    ("system", "Answer ONLY from the sources. Cite as [page N]. If the answer isn't there, say you don't know.\n\n"
               "{context}"),
    ("user", "{question}"),
])

def format_docs(docs) -> str:
    return "\n\n".join(f"[page {d.metadata.get('page', '?')}]\n{d.page_content}" for d in docs)

def answer(question: str) -> str:
    hits = retriever.invoke(question)
    return (prompt | model).invoke({"context": format_docs(hits), "question": question}).content
RAG step (hand-written in the RAG series) LangChain component
Loaders document_loaders: PyPDF, web, Notion, Confluence, S3 and hundreds more
Chunking langchain_text_splitters
Embeddings + vector DB OpenAIEmbeddings, Chroma, PineconeVectorStore, PGVector ...
Retrieval, MMR store.as_retriever(search_type="mmr")
Grounded prompt ChatPromptTemplate, which you still write yourself
Citations, evaluation Mostly still yours. LangSmith for traces and evals

The framework saves the most time on loaders and integrations. The parts that decide quality (prompt, chunking choices, citations, evals) are still your code.

1.7 Strengths and criticisms

Strengths Criticisms
Huge integration library: models, loaders, vector stores, tools Abstraction layers make stack traces and debugging harder
Provider-agnostic interface, so switching models is easy The API has changed often. Old tutorials and Stack Overflow answers break
Streaming, batching, retries and fallbacks built in Hides the actual prompt and API request unless you trace them
First-class tracing with LangSmith Many dependencies. Heavyweight for a simple app
create_agent and middleware for common agent patterns Easy to use a component without understanding what it sends to the model

A common team pattern: use LangChain's integrations (loaders, splitters, vector-store clients) and plain code or LangGraph for the core logic. Page 4 covers that trade-off.

Interview questions

What is LCEL, and why does it exist?

LangChain Expression Language composes Runnables, meaning components with invoke, batch, stream and async methods, using the pipe operator: prompt | model | parser. Each step's output feeds the next. It gives every chain streaming, concurrent batching, async, retries, fallbacks and tracing without extra code. Underneath, it's function composition.

How do you switch LLM providers in LangChain?

All chat models implement the same BaseChatModel interface, so you change the model object, for example with init_chat_model("anthropic:...") instead of "openai:...", and the rest of the chain stays the same. You still need to re-test prompts, because models behave differently, and check provider-specific features like structured-output support.

What are the main criticisms of LangChain, and how do you mitigate them?

Heavy abstraction, frequent API changes, many dependencies, and hidden prompts that make debugging hard. Mitigate by pinning versions, tracing every call (LangSmith or OpenTelemetry), using only the pieces that add value (often integrations), writing prompts explicitly, and keeping core business logic in plain code you control.

Practice

  • Add a with_retry method to the from-scratch Runnable that retries invoke up to 3 times.
  • Add a step to the from-scratch chain that uppercases the parser's output, using only |.
  • Rewrite the RAG answer function as one LCEL chain. Hint: a dict of Runnables runs them in parallel.

Next: 2. LlamaIndex Back to: Frameworks overview · Notes overview