Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 implements invoke / batch / stream, which is why prompt | model | parser works and why you get batching and streaming for free. As of late 2026 the agent entry point is create_agent from langchain.agents, configured through middleware; the 2023 AgentExecutor/initialize_agent API and the intermediate langgraph.prebuilt.create_react_agent are 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 on langchain-core and langgraph. 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.

PackageVersion [as of 2026-10]ContainsInstalled by
langchain-core1.6.10Runnable, messages, prompts, output parsers, BaseRetriever, BaseTool, callbackstransitively
langchain1.4.5create_agent, AgentState, middleware, provider-agnostic toolingyou
langgraph1.2.15the durable runtime that create_agent compiles totransitively
langchain-<provider>independentthe actual HTTP client and message translationyou, 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:

MethodDocumented semanticsWhat 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:

“RunnableSequence invokes 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 to RunnableSequence.”

“RunnableParallel invokes runnables concurrently, providing the same input to each. Construct it using a dict literal within a sequence or by passing a dict to RunnableParallel.”

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 ChatPromptTemplate expects a dict of variables.
  • A ChatModel expects 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.

ClassRole
SystemMessage“an initial set of instructions that primes the model’s behavior”
HumanMessageuser input; text, images, audio, files
AIMessagethe model’s output; may carry tool_calls, multimodal data, usage_metadata
AIMessageChunkthe streaming increment; chunks combine into a full AIMessage
ToolMessagea 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.

EraAgent APIStatus [as of 2026-10]
2023initialize_agent + AgentExecutorremoved from the modern path; tutorials using it are dead code
2024–2025langgraph.prebuilt.create_react_agentdeprecated since v1.0.0, expected removal in v2.0.0
2026langchain.agents.create_agentcurrent

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

KnobRealistic rangeWhat moving it does
temperature0 for tool routing, 0.3–1.0 for proseat > 0 the tool-call sequence becomes non-reproducible, which makes regression tests flaky
max_retries (model)2–5bounded exponential backoff; each retry re-sends the whole context, so it multiplies input tokens
max_retries (tool)0–2only for idempotent tools — retrying issue_refund is how you refund twice
interrupt_onper toolgate the irreversible tools only; gating everything trains the human to click approve
response_formata Pydantic modelmakes the final turn structured; see the caveat in Tool / Function Calling about constraining reasoning
middleware orderretry innermostorder 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”:

  1. 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.)
  2. 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 interrupt and prompt caching interact.
  3. 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

AxisLangChainRaw provider SDKWhich is better
Provider swapchange a string ("openai:gpt-5.5")rewrite the client, message mapping, streamingframework
Batching / streaming / retriesprotocol methods, written oncehand-written per pipelineframework, mildly
Lines you can read in a debugger60–100 across 3 packages25 you wroteSDK
Dependency resolution4+ packages on independent clocks1SDK
API stabilitymoved twice in ~3 yearsprovider SDKs move too, but you chose themSDK
Integrations (retrievers, splitters, 20 providers)hundreds prebuiltyou write eachframework, 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):

MetricWhy it is the one that matters
task success ratethe only quality number
input tokens per solved taskwhere the loop’s cost lives (section 4.1)
wall-clock p50/p95the user-visible number
engineering time to add one capabilitythe actual value proposition
trace completenesswhether 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_agent build. 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.