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
| Piece | What it does | The lesson |
|---|---|---|
Runnable | the base interface: invoke / batch / stream, plus | and the .with_* combinators | one interface, shared by everything, is what makes LCEL compose |
RunnableLambda | wrap a plain function as a Runnable | the coercion that lets a def drop into a pipe |
RunnableGenerator | a 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 step | a chain is left-to-right data flow, and only the final component streams |
RunnableParallel | run several Runnables on the SAME input → a dict | the fan-out primitive ({"context": retriever, "question": passthrough}) |
RunnablePassthrough (+ .assign) | pass input through, or ADD computed keys to a dict | the RAG workhorse — carry the question while you fetch context |
RunnableBranch | first matching predicate wins, else a default | declarative routing without a graph |
.with_fallbacks / .with_retry / .with_config | resilience + config binding | graceful degradation is a wrapper, not a rewrite |
File map
| File | Role |
|---|---|
lab.py | your implementation (fill the # TODOs) |
solution.py | reference + a main() that demos prompt | model | parser, parallel, assign, branch, fallbacks |
test_lab.py | 29 tests: the interface, sequence/parallel/branch/passthrough algebra, streaming, fallbacks/retry, determinism |
requirements.txt | pytest 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, andstream, and foldingstreamreproducesinvoke(the core invariant — proved for both a plain lambda and a streaming generator). -
a | b | cthreads output into input; a bare function and a bare dict can be dropped into the pipe (coercion toRunnableLambda/RunnableParallel). -
RunnableSequence.streamruns the upstream steps to completion and streams only the last. -
RunnableParallelfans out over one input;RunnablePassthrough.assignadds keys while keeping the originals, and each mapper sees the original input. -
RunnableBranchroutes to the first matching predicate (or the default), and raises with no match and no default. -
with_fallbacksrecovers from a failing Runnable in order;with_retrybounds the attempts. -
All 29 tests pass under both
labandsolution.
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/astreamwithasyncio, mirroring LCEL's async interface. - Make
batchrun on aThreadPoolExecutorand prove results stay input-ordered. - Add
RunnableBranch-free routing with aRunnableLambdathat returns a Runnable and is then invoked — the dynamic-routing pattern real LCEL supports. - Rebuild the same
prompt | model | parserin real LangChain and diff the behavior.
Interview / resume signal
"Reimplemented LangChain's Runnable/LCEL core from scratch — the
invoke/batch/streaminterface, the|operator with function/dict coercion,RunnableParallel/Passthrough.assignfan-out,RunnableBranchrouting, andwith_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."