Lab 01 — LCEL & the Runnable Protocol From Scratch

Phase 29 · Lab 01 · Phase README · Warmup

The problem

Every idiomatic LangChain snippet you have ever seen — prompt | model | parser, retriever | format | prompt | model — is built on one abstraction: the Runnable. A prompt template, a chat model, an output parser, a retriever, and a plain Python function are all Runnables, which means they all expose the same interface: invoke (one input → one output), batch (many inputs), and stream (yield output in chunks). Because they share that interface, the | pipe operator (LCEL — the LangChain Expression Language) can wire any two together, and the result is itself a Runnable with the same interface. That closure property is the whole reason LCEL composes.

Build the Runnable algebra by hand and LangChain stops being magic: you will know exactly what | does, why a bare function or dict can appear in a pipe, how streaming flows through a chain, and why RunnablePassthrough.assign is the backbone of every RAG chain (Lab 02).

What you build

PieceWhat it doesThe lesson
Runnablethe base interface: invoke / batch / stream, plus | and the .with_* combinatorsone interface, shared by everything, is what makes LCEL compose
RunnableLambdawrap a plain function as a Runnablethe coercion that lets a def drop into a pipe
RunnableGeneratora genuinely streaming Runnable (stream primitive, invoke folds chunks)invoke equals its stream folded back together
RunnableSequence (|, .pipe)thread each output into the next input; stream the last stepa chain is left-to-right data flow, and only the final component streams
RunnableParallelrun several Runnables on the SAME input → a dictthe fan-out primitive ({"context": retriever, "question": passthrough})
RunnablePassthrough (+ .assign)pass input through, or ADD computed keys to a dictthe RAG workhorse — carry the question while you fetch context
RunnableBranchfirst matching predicate wins, else a defaultdeclarative routing without a graph
.with_fallbacks / .with_retry / .with_configresilience + config bindinggraceful degradation is a wrapper, not a rewrite

File map

FileRole
lab.pyyour implementation (fill the # TODOs)
solution.pyreference + a main() that demos prompt | model | parser, parallel, assign, branch, fallbacks
test_lab.py29 tests: the interface, sequence/parallel/branch/passthrough algebra, streaming, fallbacks/retry, determinism
requirements.txtpytest only

Run it

pytest test_lab.py -v                       # your lab.py (red until you implement)
LAB_MODULE=solution pytest test_lab.py -v   # the reference (green)
python solution.py                          # worked example

Success criteria

  • Every Runnable exposes invoke, batch, and stream, and folding stream reproduces invoke (the core invariant — proved for both a plain lambda and a streaming generator).
  • a | b | c threads output into input; a bare function and a bare dict can be dropped into the pipe (coercion to RunnableLambda / RunnableParallel).
  • RunnableSequence.stream runs the upstream steps to completion and streams only the last.
  • RunnableParallel fans out over one input; RunnablePassthrough.assign adds keys while keeping the originals, and each mapper sees the original input.
  • RunnableBranch routes to the first matching predicate (or the default), and raises with no match and no default.
  • with_fallbacks recovers from a failing Runnable in order; with_retry bounds the attempts.
  • All 29 tests pass under both lab and solution.

How this maps to the real stack

This is the engine underneath langchain_core.runnables. In real LangChain:

from langchain_core.runnables import RunnableParallel, RunnablePassthrough
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI

prompt = ChatPromptTemplate.from_template("Answer: {question}")
chain = (
    {"question": RunnablePassthrough()}     # a dict coerces to RunnableParallel
    | prompt                                # a Runnable
    | ChatOpenAI(model="gpt-4o-mini")       # a Runnable
    | StrOutputParser()                     # a Runnable
)
chain.invoke("what is LCEL?")               # the same invoke/batch/stream interface you built

Your Runnable, RunnableSequence, RunnableParallel, RunnablePassthrough, and RunnableBranch are the real classes' miniatures; the real | operator, the dict/function coercion, and the "stream the last component" behavior are exactly what you implemented. Your _add-based fold is the real chunk __add__ that lets a streamed AIMessageChunk accumulate into a full message.

Limits. Real LCEL adds async (ainvoke/astream/astream_events), true-threadpool batch, typed input/output schemas, .bind/configurable_fields, and deep LangSmith tracing hooks on every Runnable. The composition model — one interface, closed under | — is what you built.

LangChain vs LangGraph. LCEL composes components into a mostly-linear/branching pipeline (a DAG of data flow). The moment you need a real loop with durable state and human-in-the-loop, that is LangGraph's stateful graph runtime (Phase 18), not LCEL. Knowing this boundary — and building both engines — is the point of this phase.

Extensions (your own machine)

  • Add ainvoke/astream with asyncio, mirroring LCEL's async interface.
  • Make batch run on a ThreadPoolExecutor and prove results stay input-ordered.
  • Add RunnableBranch-free routing with a RunnableLambda that returns a Runnable and is then invoked — the dynamic-routing pattern real LCEL supports.
  • Rebuild the same prompt | model | parser in real LangChain and diff the behavior.

Interview / resume signal

"Reimplemented LangChain's Runnable/LCEL core from scratch — the invoke/batch/stream interface, the | operator with function/dict coercion, RunnableParallel/Passthrough.assign fan-out, RunnableBranch routing, and with_fallbacks/with_retry — so I can explain what a chain actually does (and where LCEL ends and LangGraph begins) rather than just calling the API."