Demystifying Runnables in LangChain: Architectural Evolution and Implementation from Scratch
LangChain’s internal mechanics transitioned fundamentally from ad-hoc, rigid chain implementations to a unified, component-based abstraction known as Runnables. Understanding Runnables is essential for mastering modern LangChain (LCEL) because every contemporary chain, tool, agent, and model inherits directly from the Runnable protocol.
1. The Historical Context: The Emergence of LLM Tooling
In late 2022, OpenAI released ChatGPT and subsequently made their APIs available to developers worldwide. This sparked an immediate demand for LLM-powered software, ranging from enterprise chatbots and automated document QA engines to multi-step autonomous agents.
As the landscape diversified with providers like Anthropic, Google, and Mistral entering the market, developers faced their first major hurdle: API fragmentation. Every vendor implemented disparate endpoint schemas, authentication mechanisms, and parameter structures.
The LangChain team initially solved this problem by providing standardized wrapper classes around model APIs. A developer could switch from OpenAI to Anthropic with minimal code adjustments. This abstraction drove LangChain's initial widespread adoption.
2. Anatomy of a Production LLM Pipeline (Beyond the Prompt)
Interacting directly with an LLM represents only a fraction of an enterprise application. The LangChain team realized that modern architectures—such as Retrieval-Augmented Generation (RAG)—require an extensive pipeline of distinct operational stages.
To support this multi-stage workflow, LangChain expanded from a basic model wrapper library into an extensive ecosystem of decoupled components:
- Document Loaders: Ingest diverse formats (PDFs, Markdown, HTML, CSVs).
- Text Splitters: Break massive texts into overlapping semantic windows.
- Embedding Models: Convert textual passages into high-dimensional vector representations.
- Vector Stores: Store embeddings and run nearest-neighbor similarity searches.
- Retrievers: Query vector indices and return relevant context documents.
- Output Parsers: Extract and validate structured types (JSON, Pydantic, strings) from raw model output.
Developers initially had to stitch these modular components together manually using custom procedural glue code.
3. The First Abstraction: The Rise of Legacy Chains
When observing how engineers built applications using these components, the LangChain team identified a recurring pattern: every application repeatedly formatted a prompt and dispatched it to an LLM.
In early LangChain code, this required manual instantiation and formatting:
# Manual procedural execution
prompt_text = prompt_template.format(topic="Quantum Computing")
response = llm.predict(text=prompt_text)
The "Eureka" Moment: Encapsulated Chains
To eliminate this boilerplate, LangChain introduced the concept of Chains: pre-packaged helper functions and classes that bundled multiple components into a single callable pipeline.
The simplest chain was the LLMChain:
Instead of manually formatting strings and managing predictions, developers passed an llm and a prompt instance to LLMChain:
# Legacy LangChain v0.1 approach
from langchain.chains import LLMChain
chain = LLMChain(llm=llm, prompt=prompt_template)
result = chain.run(topic="Quantum Computing")
Encouraged by this success, the team extended the pattern to complex tasks like RAG, yielding higher-order chains like RetrievalQA:
# Legacy RAG Chain approach
from langchain.chains import RetrievalQA
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff",
retriever=vector_store.as_retriever()
)
response = qa_chain.run("What are the assumptions of linear regression?")
Specialized chains proliferated across the library:
SimpleSequentialChain: Piped single-input, single-output chains sequentially.SequentialChain: Handled multi-variable inputs and outputs across step boundaries.SQLDatabaseChain: Translated natural language into SQL queries.APIChain: Queried external REST APIs based on LLM decisions.LLMMathChain: Evaluated mathematical prompts via Python execution.
4. The Architectural Crisis: The Combinatorial Explosion of Chains
While specialized chains simplified common tasks, this design quickly produced critical architectural bottlenecks:
- Codebase Bloat & Maintenance Debt: A new bespoke class was introduced for every novel workflow permutation. Maintaining hundreds of specialized chain classes became unmanageable.
- A Punishing Learning Curve: New developers were confronted with dozens of specialized chains with little architectural clarity on when or how to combine them.
- Complete Inflexibility: The moment a developer's real-world requirements deviated from the assumptions baked into a chain class (e.g., needing an intermediate safety filter or a dynamic branch), the pre-built chain had to be abandoned for manual glue code.
The Root Cause: Interface Fragmentation
The core architectural flaw was that early LangChain components lacked a standardized interface. Every component was designed with custom method signatures:
Because these components could not communicate natively, developers had to write custom glue code—and LangChain had to author custom chain classes—to bridge the mismatched method signatures.
5. The Solution: The Runnable Protocol and the Lego Mental Model
To resolve interface fragmentation, the LangChain team completely redesigned the core framework around a unified interface: the Runnable Protocol.
The Four Tenets of Runnables
- Isolated Unit of Work: Every Runnable encapsulates a single step in a pipeline. It receives an input, executes an internal transformation, and produces a structured output.
- Standardized Common Interface: Every component implements identical method signatures:
invoke(): Synchronously executes a single input.batch(): Concurrently processes an iterable of inputs.stream(): Yields responses token-by-token or chunk-by-chunk.
- Universal Composability: Because every Runnable accepts an input and emits an output via the same signature, the output of Runnable automatically becomes the input of Runnable :
Structure & Concept Flow
- Recursive Closure (Runnables Compose into Runnables): A chain composed of multiple Runnables is itself a Runnable. It exposes the same
invoke(),batch(), andstream()methods, allowing pipelines to be nested inside larger pipelines.
The Real-Life Analogy: Lego Bricks
The Lego Mental Model:
Think of Runnables as Lego bricks. Individual bricks vary in geometry, color, and functional intent (some are thin plates, others are thick structural blocks, and some have axle holes). Despite these internal variations, every brick shares an identical top stud and bottom socket interface.
- You can snap any brick directly into any other brick without specialized glue.
- A sub-assembly of three connected Lego bricks functions as a new, larger Lego brick with the same standard stud-and-socket interface on its exterior.
6. Implementation from Scratch: The Legacy Paradigm (Naive Approach)
To understand how the Runnable interface fixes these structural issues, we will build both architectures from scratch using raw Python.
Step 1: Naive Components with Incompatible Interfaces
First, consider a mock LLM that requires a .predict() method and a mock Prompt Template that requires a .format() method:
import random
class FakeLLM:
"""Mock LLM adhering to legacy LangChain interface standards."""
def __init__(self):
print("FakeLLM initialized.")
self._responses = [
"Delhi is the capital of India.",
"IPL is an international cricket league.",
"AI stands for Artificial Intelligence."
]
def predict(self, prompt: str) -> dict:
"""Prediction method with custom return signature."""
return {"response": random.choice(self._responses)}
class DummyPromptTemplate:
"""Mock PromptTemplate adhering to legacy string-formatting standards."""
def __init__(self, template: str, input_variables: list[str]):
self.template = template
self.input_variables = input_variables
def format(self, **kwargs) -> str:
"""Formatting method requiring unpacked keyword arguments."""
return self.template.format(**kwargs)
Step 2: The Inflexible Custom Chain
To link these two components, early LangChain required writing a bespoke glue class:
class MockLLMChain:
"""Rigid custom chain designed specifically to glue DummyPromptTemplate to FakeLLM."""
def __init__(self, llm: FakeLLM, prompt: DummyPromptTemplate):
self.llm = llm
self.prompt = prompt
def run(self, input_data: dict) -> str:
# Hardcoded procedural coordination
formatted_prompt = self.prompt.format(**input_data)
raw_result = self.llm.predict(prompt=formatted_prompt)
return raw_result["response"]
# Executing the legacy-style chain
template = DummyPromptTemplate(
template="Write a {length} poem about {topic}",
input_variables=["length", "topic"]
)
llm = FakeLLM()
chain = MockLLMChain(llm=llm, prompt=template)
result = chain.run({"length": "short", "topic": "India"})
print(f"Result: {result}")
The Architectural Limitation of this Design
If you need to extend this pipeline—for instance, to pass the model output through an extraction parser or invoke a secondary model for summarization—MockLLMChain cannot accommodate the change. It is hardcoded to accept exactly one prompt and one model, forcing you to write yet another custom chain class (e.g., MockTwoStepSequentialChain).
7. Implementation from Scratch: The Runnable Architecture
To solve this rigidity, we replace ad-hoc method names with an Abstract Base Class that establishes a unified protocol.
Step 1: The Base Runnable Protocol
from abc import ABC, abstractmethod
import warnings
class Runnable(ABC):
"""Abstract Base Class enforcing the unified Runnable interface."""
@abstractmethod
def invoke(self, input_data):
"""Standard entry point for processing inputs."""
pass
Step 2: Standardizing Components via Inheritance
Each component inherits from Runnable and implements invoke(). Deprecated methods are retained with warnings to preserve backward compatibility.
class FakeLLM(Runnable):
def __init__(self):
self._responses = [
"Delhi is the capital of India.",
"IPL is a professional cricket league.",
"AI stands for Artificial Intelligence."
]
def invoke(self, input_data: str) -> dict:
"""Standardized processing method."""
return {"response": random.choice(self._responses)}
def predict(self, prompt: str) -> dict:
warnings.warn(
"predict() is deprecated. Use invoke() instead.",
DeprecationWarning,
stacklevel=2
)
return self.invoke(prompt)
class DummyPromptTemplate(Runnable):
def __init__(self, template: str, input_variables: list[str]):
self.template = template
self.input_variables = input_variables
def invoke(self, input_data: dict) -> str:
"""Formats the internal template using values from the input dictionary."""
return self.template.format(**input_data)
def format(self, **kwargs) -> str:
warnings.warn(
"format() is deprecated. Use invoke() instead.",
DeprecationWarning,
stacklevel=2
)
return self.invoke(kwargs)
8. Building the Dynamic Pipeline Engine: RunnableConnector
Now that every component adheres to the Runnable interface, we can replace bespoke chain classes with a generic composition engine: RunnableConnector.
class RunnableConnector(Runnable):
"""
Executes a sequential list of Runnables.
Because it inherits from Runnable, the connector itself is a Runnable.
"""
def __init__(self, runnable_list: list[Runnable]):
self.runnable_list = runnable_list
def invoke(self, input_data):
current_data = input_data
for step in self.runnable_list:
# Output of step N becomes the input of step N + 1
current_data = step.invoke(current_data)
return current_data
Testing Arbitrary Component Chains
To demonstrate the flexibility of this approach, we can define an output parser and insert it into the pipeline without writing any new chain glue code:
class DummyStrOutputParser(Runnable):
"""Extracts a target string from a structured model dictionary."""
def invoke(self, input_data: dict) -> str:
if isinstance(input_data, dict) and "response" in input_data:
return input_data["response"]
return str(input_data)
# Composing a three-stage pipeline dynamically:
# PromptTemplate -> FakeLLM -> DummyStrOutputParser
prompt = DummyPromptTemplate(
template="Provide details regarding {topic} in a {length} sentence.",
input_variables=["topic", "length"]
)
model = FakeLLM()
parser = DummyStrOutputParser()
# The RunnableConnector handles arbitrary component lengths seamlessly
modern_pipeline = RunnableConnector([prompt, model, parser])
output = modern_pipeline.invoke({"topic": "Computer Science", "length": "short"})
print(f"Pipeline Result: {output}")
9. Recursive Composition: Chaining Chains Together
Because RunnableConnector inherits from Runnable, any assembled chain can be nested within another chain just like a primitive component.
Implementation: Joke Generation and Explanation Pipeline
# Setup Chain 1: Generates a Joke based on a topic
template_joke = DummyPromptTemplate(
template="Tell me a joke about {topic}",
input_variables=["topic"]
)
chain_one = RunnableConnector([template_joke, FakeLLM()])
# Setup Chain 2: Explains an incoming joke and cleans the output
class JokeToExplanationAdapter(Runnable):
"""Bridges the output dictionary of Chain 1 into the input format of Chain 2."""
def invoke(self, input_data: dict) -> dict:
return {"joke_text": input_data["response"]}
template_explanation = DummyPromptTemplate(
template="Explain the humor in the following joke: {joke_text}",
input_variables=["joke_text"]
)
chain_two = RunnableConnector([
JokeToExplanationAdapter(),
template_explanation,
FakeLLM(),
DummyStrOutputParser()
])
# Hierarchical Composition: Chain 1 -> Chain 2
master_chain = RunnableConnector([chain_one, chain_two])
final_output = master_chain.invoke({"topic": "Software Engineering"})
print(f"Master Pipeline Output: {final_output}")
10. LangChain Source Code Anatomy: Real-World Class Hierarchy
The architecture we just implemented from scratch matches LangChain's production design. Inspecting modern model classes like ChatOpenAI reveals that they inherit from the core Runnable interface through a deep class hierarchy:
At the top of this inheritance tree sits Runnable, defining the same abstract invoke() contract we implemented above:
# Direct extraction pattern from langchain_core.runnables.base
class Runnable(ABC):
@abstractmethod
def invoke(self, input: Input, config: Optional[RunnableConfig] = None) -> Output:
"""Transform a single input into an output."""
pass
Because every core component (prompts, models, retrievers, parsers) inherits from Runnable, modern LangChain can compose them dynamically using pipe operators (|) via LangChain Expression Language (LCEL).
11. Architectural Comparison: Legacy Chains vs. Modern Runnables
12. Summary and Key Takeaways
- The Interface Dilemma: Early LangChain wrapped multiple LLM provider APIs, but its internal components lacked standardized method signatures, relying on
.predict(),.format(),.get_relevant_documents(), and.parse(). - The Chain Explosion Anti-Pattern: To connect these incompatible components, LangChain introduced dozens of specialized chain classes (
LLMChain,RetrievalQA,APIChain). This created massive codebase bloat, heavy maintenance costs, and a steep learning curve. - Runnables as the Unifying Protocol: A
Runnableis a standardized unit of work that provides a common execution contract (invoke,batch,stream), enabling arbitrary components to snap together like Lego blocks. - Sequential Pipeline Mechanics: Because every component adheres to the
invoke()contract, data flows cleanly through dynamic connectors where automatically becomes . - Recursive Composability: Workflows constructed from Runnables are themselves Runnables, meaning complex sub-pipelines can be nested inside larger pipelines with zero custom glue code.
- Production Architecture: Core production classes like
ChatOpenAIandPromptTemplateinherit directly fromRunnable, forming the unified backbone of modern LangChain Expression Language (LCEL).