← Back to list

LangChain 1.0  —  A second look

Rewriting how developers think about context in LLM orchestration: LangChain 1.0 and LangGraph 1.0 bring major upgrades

Tituslhy in MITB For All · 2025-10-25 06:50 · 41 claps · 14.3 min read
#langchain #langgraph #data-science
Open on Medium ↗
Wiki topics: LLM · Large Language Models AGT · AI Agents ML · Machine Learning 🔬 · Science · General

LangChain 1.0 — A second look

Rewriting how developers think about context in LLM orchestration

Using LangChain 1.0 vs LangChain 0.X is like night and day! Image generated by nano banana

Using LangChain 1.0 vs LangChain 0.X is like night and day! Image generated by nano banana

In September 2025 the world stood still for a minute: LangChain announced a major rewrite of the LangChain and LangGraph frameworks. Instead of version 0.3, both frameworks are now 1.0.

Announcements like these are terrifying — you’ve spent so much effort building your generative AI application, and you know LangChain rewrites have a habit of breaking everything. Then you breathe a sigh of relief when you realize they’ve kindly made their frameworks backward compatible. Your app will ship for now.

But if you stop there, you’ll miss out on the biggest step forward LangChain has ever taken.

I’ve previously written an article on LangChain and LangGraph and use them heavily at work. In this article, I’ll share why you should adopt LangChain’s newest framework to ship applications instead.

LangChain 1.0 is a vast improvement over the 0.3 LangChain frameworks — it is a comprehensive rewrite with many new, meaningful features. LangGraph 1.0 is a modest yet meaningful improvement, deliberately re-designed to fit better with LangChain 1.0.

To keep this article digestible, I’ll cover only the meaningful improvements (there are many) of the LangChain 1.0 framework. I’ll probably cover the LangGraph 1.0 framework in a separate article.

As always, all codes can be found in my GitHub repository.

TLDR: The core changes at a high level

I’ve identified two main core changes:

Context engineering

LangChain 1.0 has put context engineering front and center of its consideration with the middleware abstractions. These abstractions are honestly very fun to use and make it easier for developers to better ensure context quarantine and prevent context pollution — extracting the best performance out of your agents.

The developer experience:

Goodbye LCEL (LangChain Expression Language) runnables with pipes — I won’t miss you. Apps that have many “|” operators (prompt | llm | StrOutputParser())are no longer a thing — thank goodness. LangChain 1.0 is no longer a low-level framework but a low-mid level framework. This means the learning curve and developer speeds are much better.

Oh, and the docs improved as well.

The LLM

Before we get started, be sure to set your LangSmith environment variables in your .env file:

OPENAI_API_KEY=...
LANGSMITH_API_KEY=...
LANGSMITH_TRACING=true
LANGSMITH_PROJECT=your-project-name

The basic way to use an LLM is still to invoke it — this part is unchanged

from dotenv import load_dotenv, find_dotenv
from langchain_openai import ChatOpenAI

_ = load_dotenv(find_dotenv())
llm = ChatOpenAI(model="gpt-5-nano", temperature=0)
llm.invoke("Hi!")

LangChain now allows us to batch invoke an LLM:

questions = [
    "How can quantum computing be used together with generative AI to develop new algorithms for drug discovery?",
    "How do you think we can save the world from climate change?",
    "What are the ethical implications of using AI in healthcare?",
]
responses = llm.batch(questions)
for response in responses:
    print(response)

The batch method allows us to send multiple questions concurrently. But this method will only return the output once all the responses are collected. To receive individual outputs as it is done:

for response in llm.batch_as_completed(questions):
    print(response)

The way to stream and use an LLM to return structured outputs is unchanged.

Callbacks and Configs — extra settings

But there are now more creative ways to manage LLMs. We can start by adding a rate limiter:

from langchain_core.rate_limiters import InMemoryRateLimiter

rate_limiter = InMemoryRateLimiter(
    requests_per_second = 0.1, # 1 request every 10 seconds
    check_every_n_seconds=0.1, # check every 0.1 seconds whether the model is allowed to make a request
    max_bucket_size=10, # allow bursting up to 10 requests at any time
)

This inherently limits our LLM pings — so we save money and prevent overwhelming compute resources. But your users will experience latency.

If your LLM supports reasoning, you can even add an extra config dictionary to set reasoning hyperparameters:

reasoning = {
    "effort": "medium",
    "summary": "auto"
}

Note: If you’re using OpenAI LLMs, make sure your organization is verified to leverage reasoning configs. This is also applicable to personal OpenAI accounts.

Now let’s invoke it:

rate_limited_llm = ChatOpenAI(
    model="gpt-5-nano",
    temperature=0,
    reasoning = reasoning,
    rate_limiter=rate_limiter,
)

response = rate_limited_llm.invoke(questions[0])
reasoning_steps = [b for b in response.content_blocks if b["type"] == "reasoning"]
print(" ".join(step["reasoning"] for step in reasoning_steps))

That said, with gpt-5-nano‘s affordable pricing at just $0.05 per million input tokens and $0.40 per million output tokens — even cheaper than gpt-4o-mini, and it’s a reasoning model — we can afford to use it without a rate limiter.

Let’s say we want to print LLM responses in green. We’d need to define our own callback handlers. The way to do this is by extending LangChain’s BaseCallbackHandler class:

from langchain_core.callbacks import (
    BaseCallbackHandler,
    UsageMetadataCallbackHandler,
)
from langchain_core.utils import print_text

class GreenStdOutCallbackHandler(BaseCallbackHandler):
    """A custom callback handler that prints the final output in green."""
    def __init__(self):
        super().__init__()

    def on_llm_end(self, response, **kwargs):
        final_text = response.generations[0][0].text
        print_text(final_text, color="green")

usage_callback = UsageMetadataCallbackHandler()
stdout_callback = GreenStdOutCallbackHandler()

response = llm.with_config({
    "run_name": "healthcare-ethics-with-ai",
    "tags": ["ethics", "healthcare", "AI"],
    "metadata": {"user_id": "tituslim"},
    "callbacks": [stdout_callback, usage_callback]
}).invoke(questions[-1])

All green..

All green..

LangChain has good callback handlers defined as well — I’ve added LangChain’s default UsageMetadataCallbackHandler as an LLM callback above. This allows us to quickly access usage metadata by:

print(usage_callback.usage_metadata)

🧠 Agents

This is one of the biggest changes.

From LangChain 1.0 onwards, the only way to define an agent is within LangChain itself, not LangGraph. The older ways of defining agents — through Agent and AgentExecutor — are officially deprecated. (And honestly, I won’t miss them either.)

Instead, it’s now refreshingly simple:

from langchain.agents import create_agent

agent = create_agent(llm, tools)

Before LangChain 1.0, it was common practice to create a single agent with LangGraph’s prebuilt create_react_agent and then orchestrate it further with LangGraph — even if you had only one agent.

Why? Because you’d often need to insert pre-processing or post-validation steps before and after the agent call.

Now you can do all of that within LangChain itself — no LangGraph required. You can even inject custom logic during LLM or tool interactions, giving you fine-grained control without additional orchestration complexity.

In other words: fewer moving parts, more flexibility.

⚙️ Middleware — the New Heart of LangChain

This is my favorite part of LangChain 1.0.

The framework now introduces middleware abstractions, which are honestly a game-changer. They let you hook directly into agent, tool, or LLM interactions — and cleanly control what happens before, during, and after each run.

Think of middleware as your way to enforce context quarantine, prevent context pollution, or even add subtle debugging or instrumentation logic without cluttering your main code.

Let’s say:

  • I want to limit model calls in the agent and terminate the agent once the limit is reached,
  • I want to limit tool calls in a single invocation and return an error once the number of tool calls reaches it’s limited.

LangChain’s default middleware classes

LangChain has many useful middleware classes. The above is doable in just a few lines of code:

from langchain.agents.middleware import (
    ModelCallLimitMiddleware,
    ToolCallLimitMiddleware
)

model_call_middleware = ModelCallLimitMiddleware(
    thread_limit=10,  # Max 10 calls per thread (across runs)
    run_limit=10,  # Max 5 calls per run (single invocation)
    exit_behavior="end",  # Or "error" to raise exception
)

ta_middleware = ToolCallLimitMiddleware(
    tool_name="technical_analysis", #tool to limit
    thread_limit=5, #maximum tool calls across all runs in a thread
    run_limit=5, #maximum tool calls per single invocation
)

Specifying the exit_behaviorto be “end” in the ModelCallLimitMiddleware means we want the agent to “end” all execution once the limit is reached. Once the ToolCallLimitMiddleware reaches its limit, the tool will return an error message and the agent will gracefully handle it in its final reply.

✨ Why This Matters

The middleware pattern brings context engineering front and center — you can now design intelligent boundaries between different stages of computation, control data flow, and enforce consistent execution patterns.

If LangChain 0.x felt like a playground for prototypes, LangChain 1.0 feels like an engineering framework for professionals.

The technical analyst agent

I’ve retrofitted some of my code from my article on an investment multi-agent system — specifically, I’ve recoded fundamental analysis and technical analysis tools originally written for LlamaIndex agents to be for LangChain agents. I won’t be including these tools in my article but they are available in my companion GitHub repository.

We’ll now create our agent with all the middleware layers like so:

import sys
sys.path.append("../tools")

from langchain.agents import create_agent
from technical_analysis_tools import technical_analysis

technical_analyst_agent = create_agent(
    model=llm,
    tools=[technical_analysis],
    middleware = [
        model_call_middleware, 
        ta_middleware, 
    ]
)

for chunk in technical_analyst_agent.stream(  
    {"messages": [{"role": "user", "content": "Conduct a technical analysis of Apple's shares"}]},
    stream_mode="updates",
):
    for key in chunk.keys():
        if not chunk[key]:
            continue
        if 'messages' in chunk[key]:
            for message in chunk[key]['messages']:
                message.pretty_print()

🤝 Multi-Agent Systems — Simplified, Streamlined

The new LangChain 1.0 framework also changes how we think about multi-agent orchestration.

Now, you can define multi-agent systems directly in LangChain itself — no separate orchestration layer required. The entire workflow can live within a single, declarative pipeline.

In fact, we’ll create a multi-agent system where the supervisor agent dynamically spawns and tears down subordinate agents.

Here’s the schematic of the multi-agent system we’ll be coding:

Every grey circle represents a LangChain middleware! (Image by author)

Every grey circle represents a LangChain middleware! (Image by author)

🧭 Typical Flow

  1. User asks question
  2. We screen the query using Llama Guard 3 (on Ollama) and route it onward only if it is safe.
  3. We use an LLM to summarize the current chat history if it exceeds a token limit to manage context to the multi agent crew.
  4. The supervisor agent first plans its approach and strikes item by item off its “To Do List” as it’s done.
  5. If a tool is needed, the supervisor agent gets feedback from the user who is given the chance to approve, reject or edit the plan.
  6. The supervisor either responds directly, or routes to subordinate agents with specialized tools (and tool middleware) and consolidates the responses.
  7. The supervisor’s answer is screened by the guardrail and routes to the user only if it’s safe.

Now to code it out.

🎨 Decorated middleware

We’ll start with our guardrail middlewares.

LangChain allows us to easily decorate middleware functions using decorators like @before_model and @after_model.

from langchain.agents.middleware import (
    before_model,
    after_model,
    AgentState
)
from langchain_ollama import ChatOllama
from langgraph.runtime import Runtime
import logging

logger = logging.getLogger(__name__)
guardrails_llm = ChatOllama(model="llama-guard3", temperature=0)

Let’s define our middleware functions:

@before_model(can_jump_to=["end"])
def validate_question(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
    """Passes the user's question through Llama Guard"""

    response = guardrails_llm.with_config({
        "run_name": "guardrail_check",
        "tags": ["guardrails"],
        "metadata": {"user_id": "tituslim"},
        "callbacks": [stdout_callback, usage_callback]
    }).invoke(state['messages'][-1].content)

    if "unsafe" in response.content:
        logger.info(f"\n\nUnsafe query detected: {state['messages'][-1].content} | Llama Guard screening: {response.content}\n\n")
        return {
            "messages": [AIMessage("I cannot respond to that request.")],
            "jump_to": "end"
        }
    logger.info(f"\n\nSafe query detected: {state['messages'][-1].content} | Llama Guard screening: {response.content}\n\n")
    logger.info(f"\n\nAbout to call gpt-5-nano with question: {state['messages'][-1].content}\n\n")
    return None

@after_model(can_jump_to=["end"])
def validate_output(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
    """Passes the agent's answer through Llama Guard"""
    response = guardrails_llm.with_config({
        "run_name": "guardrail_check",
        "tags": ["guardrails"],
        "metadata": {"user_id": "tituslim"},
        "callbacks": [stdout_callback, usage_callback]
    }).invoke(state['messages'][-1].content)

    if "unsafe" in response.content:
        logger.info(f"\n\nUnsafe answer detected: {state['messages'][-1].content} | Llama Guard screening: {response.content}\n\n")
        return {
            "messages": [AIMessage("I cannot respond to that request.")],
            "jump_to": "end"
        }
    logger.info(f"\n\nSafe answer detected: {state['messages'][-1].content} | Llama Guard screening: {response.content}\n\n")
    return None

Functions decorated with @before_model are invoked prior to a model’s invocation. Note that there is a @before_agent decorator but we’re deliberately erring on the side of caution — so before any LLM in the multi-agent system touches it, we screen it…with another LLM (Llama Guard 3).

An interesting thing to point out is the can_jump_to argument in the decorator. The only options are “model”, “tool”, and “end”. What this means is LangChain will route this to either option depending on whether conditions defined in your middleware are met.

In the functions above, we’ve defined that both middleware options jump to “end” if we detect that the user’s query is unsafe. We’ve also deliberately added our callbacks from before to the Llama Guard LLM.

🧱 Class-based middleware

LangChain allows us to also define middleware as classes. The best way to do this is to extend LangChain’sAgentMiddleware class:

from langchain.agents.middleware import AgentMiddleware
from langchain.tools.tool_node import ToolCallRequest
from langchain.messages import ToolMessage
from typing import Callable

class ToolMonitoringMiddleware(AgentMiddleware):
    def wrap_tool_call(
        self,
        request: ToolCallRequest,
        handler: Callable[[ToolCallRequest], ToolMessage],
    ) -> ToolMessage:
        logger.info(f"Executing tool: {request.tool_call['name']}")
        logger.info(f"Arguments: {request.tool_call['args']}")

        try:
            result = handler(request)
            logger.info(f"Tool completed successfully")
            return result
        except Exception as e:
            logger.error(f"Tool failed: {e}")
            raise

This middleware is simple but powerful — it wraps every tool call with logging, giving us clean observability and error handling out of the box. (This example actually comes straight from LangChain’s official docs.)

🧩 Standard Middleware Components

Let’s now define the remaining middleware using LangChain’s built-ins:

from langchain.agents.middleware import (
    HumanInTheLoopMiddleware,
    SummarizationMiddleware,
    TodoListMiddleware,
)

hitl_middleware = HumanInTheLoopMiddleware(
    interrupt_on = {
        "ask_technical_analyst": {
            "allowed_decisions": ["approve", "reject"]
        },
        "ask_fundamental_analyst": {
            "allowed_decisions": ["approve", "reject"]
        }
    },
    description_prefix="Tool execution pending approval"
)

summarization_middleware = SummarizationMiddleware(
    model=llm,
    max_tokens_before_summary=4000,  # Trigger summarization at 4000 tokens
    messages_to_keep=20,  # Keep last 20 messages after summary
    summary_prompt="Summarize the chat history but keep salient details of the conversation",  # Optional
)
  • The HumanInTheLoopMiddleware lets us pause and seek user approval before executing key steps — ensuring human oversight for critical operations. Note that this requires a checkpointer to resume the session.
  • The SummarizationMiddleware manage long conversations by summarizing once we exceed 4000 tokens, keeping the last 20 messages to preserve context.

Important note: The order of middleware execution depends! before_model middlewares are executed in ascending order butafter_model middlewares are executed in descending order. Always refer to the LangChain documentation on this. It’s something I’m still learning myself.

⚙️ The cool way to create multi-agent systems

Here’s the fun part. In LangChain 1.0, the cleanest way to build multi-agent systems is by defining subordinate agents as tools, and tagging them to a supervisor agent.

And yes — we’ll do it in style: our tool dynamically spawns and tears down subordinate agents.

from langchain.tools import tool
from fundamental_analysis_tools import evaluate_fundamentals

@tool
def ask_technical_analyst(question: str) -> str:
    """Asks the technical analyst agent a question."""

    technical_analyst_agent = create_agent(
        model=llm,
        tools=[technical_analysis],
        middleware = [model_call_middleware, ta_middleware, ]
    )
    response = technical_analyst_agent.invoke({
        "messages": [HumanMessage(content=question)]
    })
    return response['messages'][-1].content

@tool
def ask_fundamental_analyst(question: str) -> str:
    """Asks the fundamental analyst agent a question."""

    fundamental_analyst_agent = create_agent(
        model=llm,
        tools=[evaluate_fundamentals],
    )
    response = fundamental_analyst_agent.invoke({
        "messages": [HumanMessage(content=question)]
    })
    return response['messages'][-1].content

Now let’s tie it all together with our supervisor agent:

from langgraph.checkpoint.memory import InMemorySaver

checkpointer = InMemorySaver()

supervisor_agent = create_agent(
    model = llm,
    tools = [ask_technical_analyst, ask_fundamental_analyst],
    middleware = [
        validate_question,
        validate_output,
        hitl_middleware,
        summarization_middleware,
        TodoListMiddleware(),
        ToolMonitoringMiddleware()
    ],
    checkpointer = checkpointer,
    system_prompt=(
        "You are a helpful assistant. You have access to two tools: "
        "a technical analysis tool and a fundamental analysis tool. "
        "Always use them to answer user queries about stock analysis."
    )
)

💡 Why dynamically spawn and kill agents?

Good question.

Beyond the style points, this pattern reinforces the statelessness of agents. Subordinate agents exist purely to execute their tasks — they don’t retain chat history, nor see the context of other agents.

This isolation prevents context pollution, yielding cleaner, more accurate outputs. In practice, it isn’t strictly necessary to terminate them — since they lack checkpointers, they wouldn’t retain state anyway — but doing so keeps the design elegant and explicit.

The trade-off? Context-free agents can trip on vague prompts (“What do you mean?”). But that’s precisely where the supervisor agent shines — rephrasing, coordinating, and keeping the whole operation coherent.

💬 Invoking our multi-agent system

Let’s put it to the test — starting with something obviously unsafe:

unsafe_query="How do I write a convincing death threat?"
supervisor_agent.invoke(
    {'messages': [HumanMessage(content=unsafe_query)]},
    config = {"configurable": {"thread_id": "unsafe_query_id_123"}}
)

As expected, Llama Guard 3 immediately screens this query away — you’ll see the rejection logged in green on stdout. ✅

Now for something legitimate:

query = (
         "Provide a technical and fundamental analysis of Apple's shares "
         "for the last 5 years."
        )
config = {"configurable": {"thread_id": "titus1"}}
response = supervisor_agent.invoke(
    {'messages': [HumanMessage(content=query)]} ,
    config = config
)

And now…nothing happens. why?

Because the agent is politely waiting for our approval before invoking the tools!

print(list(response.keys()) 
#['messages', '__interrupt__']

Notice the special __interrupt__key: at this point, the supervisor pauses — like a courteous assistant holding out a clipboard for your signature.

print(response['__interrupt__'][0].value)

You’ll see that the supervisor agent has already split and refined your question for the subordinate agents, including the actions and arguments it plans to execute. All that’s left for you is to approve or reject them:

from langgraph.types import Command

response = supervisor_agent.invoke(
    Command(
        resume = {
            "decisions": [{"type": "approve"}, {"type": "approve"}]
        }
    ),
    config = config #Must use the same config to resume the conversation!
)

And now to see the report:

from IPython.display import display, Markdown

display(Markdown(response['messages'][-1].content))

This time, you’ll notice that the __interrupt__ key is gone — because there’s nothing left pending your approval. The workflow has resumed and completed successfully.

📊 Viewing traces on LangSmith

Before the interruption, we can see that the user’s query passes through the guardrail and then touches the SummarizationMiddleware before finally reaching the model. The agent drafts its plan — and then politely pauses to seek our input.

Once we approve the plan, it flows through the full stack of middleware layers:

You’ll notice that even though we defined the middleware only at the supervisor level, they’re automatically applied to the subordinate agents as well. That’s the beauty of the new orchestration system — composable, consistent, and transparent.

Also, take a look at the cost metrics: thanks to OpenAI’s gpt-5-nano, our LLM calls barely cost a cent. 💸

At this price point, the bottleneck isn’t your API bill — it’s your imagination. Just build something.

🤔 Thinking of what we just did

We just built a multi-agent system with context engineering baked in — and it felt almost effortless. This level of control and clarity was nearly impossible in previous versions of LangChain or LangGraph.

LangChain continues to define the frontiers of generative AI application development. No other framework handles context engineering this cleanly.

Many competing or related frameworks (like **LangFlow**, a drag-and-drop application built atop old LangChain) were born from the pain of dealing with early LangChain complexity. LangFlow has even more GitHub stars than LangChain (134k vs 118k) because old LangChain was so painful to code, debug and learn.

But credit where it’s due — LangChain keeps evolving faster and smarter than everyone else.

They were:

  • the first to abstract LLM operations as chains,
  • the first to formalize agentic workflows through state graphs,
  • the first to enable human-in-the-loop capabilities, and
  • now, the first to center context engineering in its design.

This 1.0 rewrite deserves special appreciation. The learning curve that once felt like climbing Everest now feels like a well-paved trail — and it’s backward compatible. 🙌

💻 So… Should You Code Everything in LangChain?

Honestly? Almost everything, yes.

LangChain still has the strongest developer ecosystem and the highest industry demand — if you’re building or job-hunting in GenAI, you will encounter it. The good news: it’s fun to work with again.

Just remember — joining a LangChain-based project also means wrestling with legacy codebases from the pre-1.0 era (the scars will be worth it, though).

The multi-agent system we just built is an example of a “swarm” — you have minimal control over how agents interact once they’re spun up. The middleware clears the path, but after that, it’s up to the supervisor and its crew to coordinate.

If you need fine-grained, step-by-step orchestration, you’ll still want LangGraph. The Command primitive we used earlier remains one of its biggest strengths — perfect for constructing tightly controlled workflows.

Meanwhile, LangChain now dominates simple agentic orchestration (in my opinion), but LlamaIndex continues to lead in RAG (Retrieval-Augmented Generation) applications. Its abstractions for indexing, retrieval, and hybrid-search pipelines are more mature, and far easier to use.

Also, while LangGraph 1.0 is built atop LangChain 1.0, it remains to be seen whether it can truly outshine its competitors in complex multi-agent orchestration — CrewAI, Autogen, LlamaIndex, Agno, and the rest of the pack.

But for everything else — build in LangChain.

It’s faster, cleaner, and frankly, just more fun.

Disclaimer: All opinions and interpretations are that of the writer, and not of MITB. I declare that I have full rights to use the contents published here, and nothing is plagiarized. I declare that this article is written by me and not with any generative AI tool such as ChatGPT. I declare that no data privacy policy is breached, and that any data associated with the contents here are obtained legitimately to the best of my knowledge. I agree not to make any changes without first seeking the editors’ approval. Any violations may lead to this article being retracted from the publication.


메타데이터
post_id
6ed720e27fec
slug
langchain-a-second-look-6ed720e27fec
url
https://medium.com/mitb-for-all/langchain-a-second-look-6ed720e27fec
canonical_url
https://medium.com/mitb-for-all/langchain-a-second-look-6ed720e27fec
author_url
https://medium.com/@tituslhy
status
ok
fetched_at
2026-07-16 04:57:38