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"}]))
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_retrymethod to the from-scratchRunnablethat retriesinvokeup to 3 times. - Add a step to the from-scratch chain that uppercases the parser's output, using only
|. - Rewrite the RAG
answerfunction as one LCEL chain. Hint: a dict of Runnables runs them in parallel.
Next: 2. LlamaIndex Back to: Frameworks overview · Notes overview