LangChain
TL;DR — LangChain is a set of abstractions over chat models, prompts, tools, and retrievers, plus one agent factory. The load-bearing abstraction is
Runnable: everything that can be invoked implementsinvoke/batch/stream, which is whyprompt | model | parserworks and why you get batching and streaming for free. As of late 2026 the agent entry point iscreate_agentfromlangchain.agents, configured through middleware; the 2023AgentExecutor/initialize_agentAPI and the intermediatelanggraph.prebuilt.create_react_agentare both dead or deprecating[as of 2026-10]. The honest trade: you buy provider portability and a lot of prebuilt plumbing, and you pay with an extra layer between you and the prompt, a two-to-four-package dependency surface, and API churn that invalidates any tutorial older than about a year. For a single-provider, single-loop agent, the provider SDK is usually the better engineering choice.
1. Why this exists
Suppose you ship a support assistant on OpenAI. Streaming works, tool calls work, structured output works. Six weeks later a customer contract says the model must run in their Azure tenant. You swap the client and discover that:
- Azure returns tool calls in a slightly different message shape, so your transcript replay breaks.
- The streaming chunks carry usage metadata at a different cadence, so your cost meter silently reads zero for one provider.
- “Two tool calls in one turn” is supported by one provider and must be
simulated by the other, so your
while tool_calls:loop executes them in a different order.
This is the N by M problem, and it is the same one that motivated every database driver, every HTTP client, and every ORM. There are N providers and M capabilities (chat, streaming, tool calling, structured output, embeddings, vision, retries, batching, tracing); without a shared interface, each capability crosses each provider with its own adapter code, and the adapter code is the part you never wanted to own.
LangChain’s answer is a base abstraction package plus independently
versioned provider integrations. langchain-core “contains the base
abstractions that power the LangChain ecosystem … any provider can implement
the required interface and then easily be used in the rest of the LangChain
ecosystem”
(langchain-core on PyPI,
[as of 2026-10]). langchain-openai implements that interface once; so does
langchain-anthropic; so do twenty-odd others (langchain 1.4.5 ships extras
for anthropic, aws, azure-ai, google-genai, groq, mistralai, ollama, openai,
perplexity, together, xai and more, [as of 2026-10]).
The second motivation is the one interviewers actually probe. A chat application is a pipeline: format a prompt, call a model, parse the output, retry on a bad parse. Written by hand, each stage is a function and the composition is ad hoc. LangChain’s contribution is a protocol that makes the composition generic — so that batching, streaming, retries, fallbacks, and tracing are implemented once against the protocol rather than once per pipeline. Whether that is worth the indirection is the trade this chapter is about.
Where the name stopped meaning one thing. “LangChain” in 2023 meant one monolithic package that also contained agents, chains, memory, and hundreds of integrations. In 2026 it means a small orchestration package (
langchain) built onlangchain-coreandlanggraph. If you describe the 2023 package in an interview, you are describing a library that no longer exists.
2. How it works
2.1 Four packages, three of which you install
The monolith was split, and the split is the source of most of the ecosystem’s practical problems — including its version conflicts.
| Package | Version [as of 2026-10] | Contains | Installed by |
|---|---|---|---|
langchain-core | 1.6.10 | Runnable, messages, prompts, output parsers, BaseRetriever, BaseTool, callbacks | transitively |
langchain | 1.4.5 | create_agent, AgentState, middleware, provider-agnostic tooling | you |
langgraph | 1.2.15 | the durable runtime that create_agent compiles to | transitively |
langchain-<provider> | independent | the actual HTTP client and message translation | you, per provider |
langchain 1.4.5 pins langchain-core>=1.6.10,<2.0.0 and
langgraph>=1.2.11,<1.3.0; langgraph 1.2.15 independently pins
langchain-core>=1.4.7,<2 (requires_dist, PyPI metadata, [as of 2026-10]).
Those constraints are compatible today. They are the reason a pip install langchain-community of unknown vintage can pin you back two minor versions of
core and break code that was working — the integration packages are released on
their own clocks.
2.2 Runnable: the protocol that everything implements
A Runnable is documented as “a unit of work that can be invoked, batched,
streamed, transformed and composed”
(Runnable API reference,
[as of 2026-10]). Five methods matter:
| Method | Documented semantics | What it buys you |
|---|---|---|
invoke / ainvoke | “Transforms a single input into an output.” | the obvious case |
batch / abatch | “Efficiently transforms multiple inputs into outputs.” | concurrency you did not write; by default batch “runs invoke() in parallel using a thread pool executor” |
stream / astream | “Streams output from a single input as it’s produced.” | time-to-first-token |
astream_log | “Streams output and selected intermediate results from an input.” | per-step observability without a tracer |
The depth here is in the why, not the names. invoke alone would be a
function call; the protocol exists because batch and stream must be
implementable by composition. A Runnable that knows only how to stream can
have batch derived for it; a RunnableSequence can stream by streaming each
stage; and an astream_log consumer can see every intermediate stage without
the pipeline having been written with logging in mind. Remove the protocol and
each of those becomes a per-pipeline feature you write by hand — which is
exactly the state a raw-SDK codebase is in.
Composition is two primitives, quoted from the reference:
“
RunnableSequenceinvokes a series of runnables sequentially, with one Runnable’s output serving as the next’s input. Construct using the|operator or by passing a list of runnables toRunnableSequence.”“
RunnableParallelinvokes runnables concurrently, providing the same input to each. Construct it using a dict literal within a sequence or by passing a dict toRunnableParallel.”
from langchain_core.runnables import RunnableLambda
sequence = RunnableLambda(lambda x: x + 1) | RunnableLambda(lambda x: x * 2)
sequence.invoke(1) # 4
sequence.batch([1, 2, 3]) # [4, 6, 8]
sequence = RunnableLambda(lambda x: x + 1) | { # dict literal -> RunnableParallel
"mul_2": RunnableLambda(lambda x: x * 2),
"mul_5": RunnableLambda(lambda x: x * 5),
}
sequence.invoke(1) # {'mul_2': 4, 'mul_5': 10}
LCEL (the LangChain Expression Language) is nothing more than these two
operators plus the type conventions around them: | is RunnableSequence, and
a dict literal in an input position is RunnableParallel. The claim that
makes it worth a language is that “any chain constructed this way will
automatically have sync, async, batch, and streaming support” — you write
invoke semantics once and inherit the rest.
2.3 The one type rule, and where LCEL bites
RunnableSequence feeds one value from stage to stage. That single rule is
the source of the most common LCEL error:
- A
ChatPromptTemplateexpects adictof variables. - A
ChatModelexpects a list of messages. - A parser expects a message.
So a naive prompt | model | parser works because each stage’s output happens
to be the next stage’s sanctioned input. The moment you want to pass more than
one value forward — a retrieved context and the original question — you need a
RunnableParallel to build a dict, and the stages after it must consume a dict.
When people say “LCEL is confusing”, they usually mean they violated the
one-value rule and got a TypeError three stages downstream from the mistake.
flowchart LR
IN["input dict {question, context}"] --> P["ChatPromptTemplate"]
P --> M["ChatModel"]
M --> PR["StrOutputParser"]
PR --> OUT["str"]
R["Retriever"] --> PAR["RunnableParallel (dict)"]
IN --> PAR
PAR --> P
2.4 Messages
Messages are the wire format, and their module path moved: the current import is
from langchain.messages import ... ([as of 2026-10]); older code imports the
same classes from langchain_core.messages.
| Class | Role |
|---|---|
SystemMessage | “an initial set of instructions that primes the model’s behavior” |
HumanMessage | user input; text, images, audio, files |
AIMessage | the model’s output; may carry tool_calls, multimodal data, usage_metadata |
AIMessageChunk | the streaming increment; chunks combine into a full AIMessage |
ToolMessage | a tool result; must answer a preceding assistant message with a matching tool_call_id |
Two details that are worth more than the table:
content_blocks is the current accessor. AIMessage exposes
content_blocks, a provider-normalised list of typed blocks; reading .content
is the legacy path and gives you “provider-specific shapes” (docs.langchain.com,
[as of 2026-10]). If you have ever written a if isinstance(content, list) and content[0]["type"] == "text" dance, this is the fix.
The tool-call/ToolMessage pairing is an API invariant, not a convention.
You cannot append a ToolMessage that has no matching tool_calls entry in the
preceding assistant message — the provider rejects the request. See
Tool / Function Calling for why, and what the error looks
like.
2.5 Tools
from langchain.tools import tool
@tool
def get_weather(city: str) -> str:
"""Get weather for a given city."""
return f"It's always sunny in {city}!"
The decorator derives the JSON Schema from the signature and, by default, “the
function’s docstring becomes the tool’s description that helps the model
understand when to use it”. @tool("web_search") overrides the name;
@tool(args_schema=WeatherInput) takes a Pydantic model or a raw dict when you
want a schema the signature cannot express.
The current delta to be aware of [as of 2026-10]: context injection was
unified into ToolRuntime. Code that passes InjectedState,
InjectedStore, get_runtime(), or InjectedToolCallId is on the migration
path. This matters because injected state was the standard way to give a tool
access to the agent’s own memory, and interviewers who learned the framework in
2024 will ask about InjectedState by name.
2.6 The agent factory, and why middleware replaced subclasses
The current entry point is create_agent, and the mental model is stated
plainly in the docs: “Agent = Model + Harness”, where “[t]he harness is
everything around the model loop: the prompt, the tools, and any middleware that
shapes behavior.”
from langchain.agents import create_agent
agent = create_agent(
model="openai:gpt-5.5",
tools=[get_weather],
system_prompt="You are a helpful assistant.",
)
result = agent.invoke({"messages": [{"role": "user", "content": "Weather in Oslo?"}]})
create_agent accepts, among others: tools, system_prompt,
response_format (structured output for the whole turn), state_schema
(a subclass of AgentState adding your own fields), checkpointer, and
middleware. AgentState is a TypedDict with three built-in fields: the
messages list, plus the NotRequired keys jump_to (routing control) and
structured_response (the parsed response_format output). messages is
described as “append-only: new messages are added, never replaced” — the same
reducer semantics that LangGraph applies to any message-list
channel.
Middleware is the extension mechanism, and its existence is the architectural
statement: instead of subclassing an agent class to change behaviour, you wrap
the model loop with composable interceptors. The prebuilt set [as of 2026-10]
includes ToolRetryMiddleware, ModelRetryMiddleware, ModelFallbackMiddleware,
SummarizationMiddleware, HumanInTheLoopMiddleware, ContextEditingMiddleware,
ModelCallLimitMiddleware, ToolCallLimitMiddleware, PIIMiddleware, and
LLMToolSelectorMiddleware. Concretely, a human approval gate is:
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langgraph.checkpoint.memory import InMemorySaver
agent = create_agent(
model="gpt-5.5",
tools=[your_read_email_tool, your_send_email_tool],
checkpointer=InMemorySaver(), # required for interrupts
middleware=[
HumanInTheLoopMiddleware(
interrupt_on={
"your_send_email_tool": {"allowed_decisions": ["approve", "edit", "reject"]},
"your_read_email_tool": False,
}
),
],
)
Note what is not here: no AgentExecutor, no max_iterations argument, no
agent="chat-zero-shot-react-description" string. Note also that the
checkpointer is passed straight to the factory — the agent is a compiled
LangGraph graph, and every durability property of LangGraph is available to it.
2.7 The API churn is the cost of the abstraction
This is the single most important version fact in the chapter.
| Era | Agent API | Status [as of 2026-10] |
|---|---|---|
| 2023 | initialize_agent + AgentExecutor | removed from the modern path; tutorials using it are dead code |
| 2024–2025 | langgraph.prebuilt.create_react_agent | deprecated since v1.0.0, expected removal in v2.0.0 |
| 2026 | langchain.agents.create_agent | current |
The deprecation is literal, in the LangGraph source:
@deprecated(
"create_react_agent has been moved to `langchain.agents`. "
"Please update your import to `from langchain.agents import create_agent`.",
category=LangGraphDeprecatedSinceV10,
)
(langgraph/libs/prebuilt/langgraph/prebuilt/chat_agent_executor.py,
[as of 2026-10]; LangGraphDeprecatedSinceV10 documents
since=(1, 0), expected_removal=(2, 0).)
Interview consequence: a 2023-era description of LangChain agents is not
merely dated, it is wrong in a way an interviewer will notice. “I’d use
AgentExecutor with a ReAct agent” is the answer of someone who has not looked
at the library in three years.
2.8 Retrievers and the rest of the historical surface
BaseRetriever lives in langchain-core, and VectorStore.as_retriever() adapts
a vector store into a Runnable, which is what makes
retriever | prompt | model | parser a single chain. Text splitting moved out to
its own package, langchain-text-splitters. astream_log streams “output and
selected intermediate results”; for anything agentic, pair it with a real trace
store, because an agent is “non-deterministic between runs, even with identical
prompts” (Anthropic). See Memory and Context Management
for what to do with the trace, and The RAG Pipeline
for the retrieval half.
3. Implementation
A complete, runnable example against langchain==1.4.5 / langchain-core==1.6.10
[as of 2026-10]. It does three things in the order you would actually build
them: a plain LCEL pipeline, an agent, and an agent with a retry and an approval
gate.
# pip install "langchain==1.4.5" "langchain-openai" "langgraph==1.2.15"
from langchain.agents import AgentState, create_agent
from langchain.agents.middleware import (
HumanInTheLoopMiddleware,
ModelRetryMiddleware,
ToolRetryMiddleware,
)
from langchain.chat_models import init_chat_model
from langchain.messages import HumanMessage
from langchain.tools import tool
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langgraph.checkpoint.memory import InMemorySaver
from typing_extensions import NotRequired
# --- 1. LCEL: the composition layer, no agent involved ---------------------
model = init_chat_model("openai:gpt-5.5", temperature=0)
prompt = ChatPromptTemplate.from_template("Summarise in one sentence:\n\n{doc}")
chain = prompt | model | StrOutputParser()
documents = ["...", "..."] # your raw texts
# `batch` runs invoke() concurrently (thread pool by default), so this is the
# one line that replaces a hand-written asyncio.gather with a semaphore.
summaries = chain.batch([{"doc": d} for d in documents], config={"max_concurrency": 8})
# --- 2. A tool, with an explicit schema -----------------------------------
@tool
def issue_refund(order_id: str, amount_cents: int) -> str:
"""Refund a given amount, in cents, to an order. Irreversible."""
return f"refunded {amount_cents} cents to {order_id}"
# --- 3. State: extend AgentState rather than passing hidden kwargs --------
class SupportState(AgentState):
order_id: NotRequired[str]
# --- 4. The agent: harness + middleware -----------------------------------
agent = create_agent(
model="openai:gpt-5.5",
tools=[issue_refund],
system_prompt=(
"You are a support agent. You may only discuss the order in context. "
"Never guess an order id; ask for it."
),
state_schema=SupportState,
checkpointer=InMemorySaver(),
middleware=[
ModelRetryMiddleware(max_retries=3), # transient model errors
ToolRetryMiddleware(max_retries=2), # idempotent tools only
HumanInTheLoopMiddleware(
interrupt_on={
"issue_refund": {"allowed_decisions": ["approve", "reject"]},
}
),
],
)
config = {"configurable": {"thread_id": "ticket-418"}}
result = agent.invoke(
{"messages": [HumanMessage("Refund 2500 cents on order A-99")]},
config,
)
The knobs that matter
| Knob | Realistic range | What moving it does |
|---|---|---|
temperature | 0 for tool routing, 0.3–1.0 for prose | at > 0 the tool-call sequence becomes non-reproducible, which makes regression tests flaky |
max_retries (model) | 2–5 | bounded exponential backoff; each retry re-sends the whole context, so it multiplies input tokens |
max_retries (tool) | 0–2 | only for idempotent tools — retrying issue_refund is how you refund twice |
interrupt_on | per tool | gate the irreversible tools only; gating everything trains the human to click approve |
response_format | a Pydantic model | makes the final turn structured; see the caveat in Tool / Function Calling about constraining reasoning |
| middleware order | retry innermost | order is execution order; a retry wrapper around a HITL interrupt re-asks the human on every retry |
4. Numbers and trade-offs
4.1 The cost model of the loop the framework wraps
Every hop of an agent re-sends the whole context. That fact, not the framework, dominates your bill. Model it before you argue about libraries.
Let be the tokens that are present before the first hop — system prompt, tool schemas, user request. Let be the assistant tokens emitted at hop (the thought plus the tool-call arguments), and the observation tokens returned by the tool at hop . At hop the context is the prefix:
- — the fixed prefix. It is large in exactly the agent case, because a dozen tool schemas is a thousand to several thousand tokens. It is charged on every hop.
- — the per-step growth. The assistant turn and the tool result both persist, so they are re-read on every subsequent hop. This is the term that makes agent cost superlinear rather than linear in step count.
- — the accumulation. Removing it (i.e. sending only the last observation) is what plain tool-calling without history does, and it is why such agents lose the thread on task 3.
Total input tokens over hops is the sum of all prefixes:
For constant per-step sizes and :
- — the linear term: the fixed prefix paid once per hop. Halve the tool list and you halve this.
- — the quadratic term. It is , the number of (step, later-hop) pairs in which a step’s tokens are re-read. Removing the loop removes this term entirely, which is why a single-shot chat call is so much cheaper than the same task run agentically.
Concrete arithmetic (example). Take (system prompt plus 12 tool schemas), (thought plus arguments), (a JSON tool result), :
against roughly input tokens for a single chat turn answering the same question from parametric knowledge. That is a factor of about on input, with only assistant turns actually generated. Agentic cost is a context phenomenon, not a model-choice phenomenon: swapping to a model half the price buys less than cutting the tool set or the loop length.
4.2 Why a stable prefix inverts that arithmetic
Prompt caching changes the picture, and it is the single highest-leverage thing
you can do to an agent’s economics. Anthropic’s published multipliers
([as of 2026-10]): cache writes cost 1.25x base input price, cache reads
cost 0.1x, TTL is 5 minutes refreshed on hit (1 hour at 2x write price), and
the minimum cacheable prefix is 1,024 tokens for Sonnet / 4,096 for Opus and
Haiku 4.5.
Apply it to the same loop. Across the 10 hops, plus nine steps’ worth of growth is new tokens — — and the remaining tokens are prefix re-reads:
So caching cuts the effective input bill by about 3.6x on this loop, and the saving grows with because the quadratic term is exactly the part that becomes a cheap read. Three engineering consequences follow, and none of them is “write a shorter prompt”:
- A stable long prefix beats a short unstable one. Anything that mutates early context — a timestamp at the top of the system prompt, a per-user greeting placed before the tool list, tools re-ordered between calls — invalidates everything after it and you pay 1.0x instead of 0.1x. (The MCP 2026-07-28 revision makes tool ordering a SHOULD-level requirement for this reason; see MCP.)
- A human approval that takes five minutes is cache-hostile. The 5-minute
TTL expires mid-thread; the 1-hour TTL at 2x write price is the documented
remedy. This is a cost argument for designing approvals to be fast or cheap,
and it is why LangGraph’s
interruptand prompt caching interact. - Compaction resets the cache. Summarising the thread rewrites the prefix wholesale, so the next hop pays full price for a shorter context. Usually still a win, but the win is smaller than the token reduction suggests. See Memory and Context Management.
4.3 What the framework itself costs
| Axis | LangChain | Raw provider SDK | Which is better |
|---|---|---|---|
| Provider swap | change a string ("openai:gpt-5.5") | rewrite the client, message mapping, streaming | framework |
| Batching / streaming / retries | protocol methods, written once | hand-written per pipeline | framework, mildly |
| Lines you can read in a debugger | 60–100 across 3 packages | 25 you wrote | SDK |
| Dependency resolution | 4+ packages on independent clocks | 1 | SDK |
| API stability | moved twice in ~3 years | provider SDKs move too, but you chose them | SDK |
| Integrations (retrievers, splitters, 20 providers) | hundreds prebuilt | you write each | framework, decisively |
The counter-argument is Anthropic’s, and it is unusually strong because the company has no product competing with LangChain. Frameworks “often create extra layers of abstraction that can obscure the underlying prompts and responses, making them harder to debug. They can also make it tempting to add complexity when a simpler setup would suffice. We suggest that developers start by using LLM APIs directly… Incorrect assumptions about what’s under the hood are a common source of customer error” (Building effective agents, 2024-12-19). The same post observes that “the most successful implementations weren’t using complex frameworks or specialized libraries.”
The honest 2026 default. For a single provider and a single loop, write the loop against the provider SDK and use its structured-output support. Adopt a framework for the parts you have explicitly decided not to own — persistence, human-in-the-loop, tracing, and multi-provider routing are the usual first four. This is a decision about ownership, not about quality.
5. Pitfalls
Treating AgentExecutor as the API. Symptom: your code runs on
langchain==0.1.x and every 2026 example fails to import. The class is gone from
the modern path; the replacement is create_agent.
Class-name collision on create_agent. Symptom: ImportError or, worse,
deprecation warnings from langgraph.prebuilt. If you import
create_react_agent, you are on an API with an announced removal version
(v2.0.0). Fix the import to langchain.agents.
Message ordering violations. Symptom: an HTTP 400 along the lines of
“messages with role ‘tool’ must be a response to a preceding message with
‘tool_calls’”. This is a provider invariant; a ToolMessage synthesised without
its matching assistant tool call is rejected. It shows up when you replay,
truncate, or summarise a transcript and keep the tool results but drop the
assistant turn that requested them.
A RunnableParallel output fed to a stage expecting a string. Symptom: a
TypeError or a prompt containing a Python repr of a dict. The dict literal
in LCEL produces a dict; the next stage must expect one. Insert a
RunnableLambda that picks the key.
Output-parser failures surfacing as loop failures. Symptom: the agent “hangs” or retries forever and the trace shows the parser raising. A parser that throws inside a chain does not automatically become a model-visible error; see the “return errors as observations” rule in Tool / Function Calling.
Version skew across the four packages. Symptom:
ImportError: cannot import name 'X' from 'langchain_core', or a
pydantic-version conflict at install time. The fix is to pin langchain and
let its requires_dist pick core and langgraph, rather than pinning all four by
hand.
Assuming the framework is transparent. Symptom: you cannot explain why the
prompt sent to the model differs from the string you wrote. Every abstraction
between you and the request is a place where a template, a message
normalisation, or a tool-schema injection can change it. Use astream_log or a
tracer to look at the actual payload at least once per project.
Putting the retry middleware outside the approval middleware. Symptom: the human is asked to approve the same refund repeatedly. Middleware wraps the model loop in order; a retry outside an interrupt re-enters it.
Retrying a non-idempotent tool. Symptom: two refunds for one request, or two
emails sent. ToolRetryMiddleware cannot know your tool is not idempotent. See
the idempotency discussion in LangGraph — the runtime will
re-run a node from the top, so this is not hypothetical.
6. Using it in a project
Reach for LangChain when
- You are genuinely multi-provider — a routing table, a fallback chain, or a customer-by-customer model choice. The abstraction earns its keep the moment you have two implementations behind it.
- You want the integration shelf. Hundreds of retrievers, splitters, loaders, and vector-store adapters. Writing them yourself is not a good use of a week.
- You already want LangGraph’s runtime and want the prebuilt agent on top of it rather than hand-writing the graph.
- You want one tracing story across model calls, tool calls, and state transitions. Retrofitting tracing onto a hand-rolled loop is fine; doing it for a team is not free.
Do not use it when
- You have one provider and one loop under ~150 lines. You will spend more time reading LangChain’s source than writing your loop. This is the common case, and the provider SDK is the correct answer.
- You are debugging a subtle prompt or parse bug. Every abstraction between you and the wire is a suspect. Reproduce against the provider SDK first; re-add the framework once you know what is wrong.
- You need to read and reason about every request. Regulated environments, or prompt-injection-critical surfaces (see Guardrails), reward an auditable 40-line loop over a declarative chain.
- Latency is the product. The framework’s overhead is small next to a network call, but the complexity it enables is not: an abstraction makes it easy to add a summarisation hop or a second model call that you would have noticed adding by hand.
- Your team cannot absorb the upgrade cadence. If nobody is watching LangChain releases, pin versions and treat upgrades as projects.
The decision in one line
Adopt a framework for a capability you have decided not to own; do not adopt one to avoid writing a loop you already understand.
7. How you would prove it worked
The framework has no product metric. What you prove is that a choice about abstraction paid off, or did not. The experiment is a same-task comparison with a fixed eval set:
Baseline. Hand-written loop against the provider SDK: system prompt, tool
schemas, while response.tool_calls:. Same model, same tools, same prompt text.
Treatment. The LangChain create_agent version of the same agent.
Metrics, per solved task (not per call):
| Metric | Why it is the one that matters |
|---|---|
| task success rate | the only quality number |
| input tokens per solved task | where the loop’s cost lives (section 4.1) |
| wall-clock p50/p95 | the user-visible number |
| engineering time to add one capability | the actual value proposition |
| trace completeness | whether you can debug a failure six weeks later |
The ablation that makes it honest. Hold the token budget fixed and compare: if the framework version spends 30% more tokens and wins 2 points of success, some of the “win” is just spend. Anthropic’s finding that “token usage by itself explains 80% of the variance” in their BrowseComp evaluation implies that token-matched comparisons are mandatory before crediting any architecture (vendor-internal evaluation; see Multi-Agent Patterns).
Reporting template (example — replace with your own numbers). “We ran the same 300-task support eval against a raw-SDK loop and a
create_agentbuild. Success was 71.0% vs 71.7% (example) — inside the noise. The framework build cost 12% more input tokens (example) and cut the time to add a human-approval gate from about two days to about two hours (example). We kept it for the approval gate and tracing, and kept the raw loop for the high-volume path.”
8. Interview drill
“What does LangChain actually give you?”
Strong answer: a provider-agnostic Runnable protocol so that batching,
streaming, retries, and tracing are implemented once, plus a large integration
shelf and an agent factory (create_agent) that compiles to LangGraph. The
value is concentrated in the integration and durability layers, not in the
composition syntax.
Common wrong answer: “it makes building LLM apps easier” — true of nothing in
particular, and it invites the follow-up you cannot answer.
“What is LCEL?”
Strong answer: | for RunnableSequence and a dict literal for
RunnableParallel, nothing more; the payoff is that a chain built this way
inherits sync, async, batch, and streaming support.
Common wrong answer: “a prompt language” or “a DSL for chains”. It is Python
operator overloading over two primitives.
“Why did the agent API change twice?”
Strong answer: the 2023 AgentExecutor was a fixed taxonomy of prompt formats
that could not express control flow; create_react_agent moved the loop into
the LangGraph runtime; create_agent moved the factory into langchain and made
middleware the extension point, so behaviour is composed instead of
subclassed. create_react_agent is deprecated since v1.0.0 with removal
expected in v2.0.0.
Common wrong answer: “they renamed it for marketing” — misses that the
extension model changed from inheritance to composition.
“When would you not use LangChain?” Strong answer: one provider, one loop, and a debugging burden. Cite Anthropic’s guidance to “start by using LLM APIs directly” and to “reduce abstraction layers … as you move to production”. Follow-up: “so is it badly engineered?” → No: the abstractions are sound and the integration surface is enormous; the failure mode is using a wide library for a narrow job. Note that the strongest published critique comes from a vendor with no competing framework, and that a broad “LangChain is over-abstracted” claim is not well sourced — frame it as a trade.
“How do you make an agent stop?”
Strong answer: deterministic harness bounds, not model judgement: a model-call
limit (ModelCallLimitMiddleware), a recursion limit on the underlying graph,
a wall-clock budget, and a loop detector for repeated thought/action pairs.
ReAct’s own backoff was 7 steps (HotpotQA) / 5 (FEVER).
Common wrong answer: “the prompt tells it to stop when done” — that is a hope,
not a control.
“Where does the money go in an agent?” Strong answer: input tokens re-sent per hop, growing quadratically with step count: . Cutting the tool list cuts the linear term; cutting the loop length cuts the quadratic one; prompt caching converts most of the quadratic term into 0.1x reads. Common wrong answer: “output tokens, because the model is generating” — outputs are the small part.
Next: LangGraph — the durable runtime underneath
create_agent, and why an explicit state graph beats an implicit loop when a
task outlives a single HTTP request.