chains — Pre-LCEL composition primitive — Chain class + 28 chain families
Pre-LCEL composition primitive — Chain class + 28 chain families
Module langchain_classic/chains/ — Pre-LCEL composition primitive — Chain class + 28 chain families. Part of the Orchestration group.
🎯 Why This Module Exists
This module is documented from the chains_deep.md Phase 1B research artifact. See sections below for design decisions, algorithms, error philosophy, performance, and evolution clues.
Composition Pydantic v1 Pre-LCEL 28 sub-modules
Architecture
flowchart LR
subgraph Core["langchain_classic/chains/"]
BASE["Base abstraction"]
IMPLS["Concrete implementations"]
BASE --> IMPLS
end
subgraph Up["Upstream callers"]
U1["chains/"]
U2["agents/"]
end
subgraph Down["Downstream"]
D1["langchain_core"]
D2["External providers"]
end
Up --> Core
Core --> Down
Data Flow
sequenceDiagram participant Caller as Caller participant Chains as chains participant Core as langchain_core participant Ext as External Caller->>Chains: invoke(input) Note over Chains: Validate & route Chains->>Core: delegate primitive Core->>Ext: provider call Ext-->>Core: response Core-->>Chains: typed output Chains-->>Caller: result
Existence rationale
chains/ predates langchain_core.runnables (LCEL). It is the original LangChain composition primitive: a class hierarchy where each subclass overrides _call(inputs) -> outputs and the base class owns callbacks, validation, memory wiring, and verbose logging. Without chains/, every user of LLMChain, RetrievalQA, MapReduceDocumentsChain, or RouterChain (released 2022–2023) would be broken. It exists today as a stable surface for that pre-LCEL idiom while also wrapping LCEL internally for newer constructors like create_retrieval_chain.
Design decisions visible in the code
| Decision | Choice made | Plausible alternatives | Inferred rationale | |
|---|---|---|---|---|
| Composition mechanism | Subclass Chain, override _call | LCEL `\ | ` composition; closures; visitor pattern | Pydantic v1 BaseModel inheritance gave free validation + serialisation; subclass override is the most discoverable extension point for 2022 users |
| Sync/async parity | Both _call and _acall; default _acall raises | Async-only; sync wrapper around async | Many integrations were sync-only in 2022 (requests-based); forcing async would block adoption | |
| Inputs/outputs typing | dict[str, Any] keyed by input_keys / output_keys | Dataclass; Pydantic model per chain | Dicts compose with RunnableMap and prompt templates without a translation layer | |
| Memory integration | BaseMemory is a chain attribute; prep_inputs merges memory vars | Pass memory explicitly per call | Lets users plug memory into any chain without subclassing | |
| Callback flow | CallbackManager.configure(...) produces a fresh manager per call | Single global manager | Per-run isolation needed for streaming + tracing trees | |
| Document combination | Strategy classes: Stuff, MapReduce, Refine, MapRerank | Single switch-case in one class | Each strategy has different prompts/state; subclassing kept them readable | |
| Router design | RouterChain (string → destination), MultiRouteChain dispatches | Single chain with conditional prompt | Allows mixing destinations of different signatures (LLMChain + tool + retrieval) | |
| LCEL bridge | create_retrieval_chain, create_history_aware_retriever are LCEL chains exposed as a Runnable | Subclass Chain for them | Newer constructors prefer LCEL; legacy Chain API kept for old code |
Algorithm deep-dives
1. Chain.__call__ orchestration
Problem. Every chain subclass needs the same boilerplate: validate inputs, merge memory, fire on_chain_start, run the body, fire on_chain_end (or on_chain_error), persist memory, return outputs. Duplicating this in 30+ subclasses is a maintenance nightmare.
Trace.
Chain.__call__(inputs)(orChain.invokefor the Runnable interface).prep_inputs(inputs)— acceptstr(single-key chains), validate keys.prep_inputscallsmemory.load_memory_variablesand merges intoinputs.CallbackManager.configurebuilds a manager from kwargs + global handlers.manager.on_chain_start(serialized=self.dict(), inputs).try: outputs = self._call(inputs, run_manager=child_manager).except: manager.on_chain_error(e); raise.prep_outputs(inputs, outputs)— saves to memory, validates output keys.manager.on_chain_end(outputs).- Returns dict.
Complexity. O(1) overhead per call modulo the body. The string-coercion and dict-merge fast paths are O(K) in the number of keys (typically 2–4).
Why this design. Subclasses only worry about the actual computation. Tests show this saved ~120 lines of duplication per chain.
Edge cases.
- Single-input chains accept a plain string and auto-key it (
inputs={input_key: "the string"}). - If
memoryis set and the chain has 2+ input keys, the chain raises unless one is the memory-managed key. return_only_outputs=Truestrips inputs from the returned dict (stops them from being echoed).
2. MapReduceDocumentsChain
Problem. Summarise / answer over a document set too large for a single LLM context. Must (a) parallelise the per-document work and (b) recursively combine until the combined output fits.
Trace.
- Split docs into batches whose total token count ≤
token_max. - Call
llm_chainon each batch (the map step), producing intermediate texts. - If
len(intermediate_texts)is 1 → finalise. - Else: feed all intermediate texts into
combine_document_chainand recurse if the combined fits in context, else split + map again. (The "collapse" step.) - Return final text.
Complexity. Time O(N / batch_size) LLM calls at the map level, then O(log_batch_size N) more at the reduce level. Async runs map-step in parallel.
Why not "stuff"? Stuff requires n_docs * doc_tokens ≤ context_window. MapReduce removes that ceiling at the cost of parallel LLM calls + a slight quality loss on collapse rounds.
3. Router chain dispatch
MultiRouteChain.invoke({"input": q}):
router_chain.route({"input": q})returnsRoute(destination="sql", next_inputs={...}).destination_chains[route.destination].invoke(route.next_inputs).- If destination unknown →
default_chainif set, else raise.
This is a strategy pattern dispatched at runtime. The router is itself an LLM call (LLMRouterChain) with a JSON output parser, or a deterministic rule (EmbeddingRouterChain).
Error philosophy
Fail-fast at validation, propagate-up at runtime. Pydantic v1 validators reject misconfigured chains at construction time (missing output_key, wrong prompt arity). At call time, exceptions bubble up but manager.on_chain_error is called first so tracers always see the failure. There is no automatic retry — that's the user's call (or RetryOutputParser's job).
Performance characteristics
- Hot path: prompt formatting + LLM call. Chain overhead is microseconds.
- Slow path:
MapReducecollapses. Each collapse is a full LLM call. - Memory: Linear in
intermediate_stepsfor agents; documents are kept in process forStuff/MapRerankuntil the final call. - Tradeoff: dict-of-Any I/O is convenient but loses static typing — LCEL's typed Runnables are the modern answer.
Evolution clues
chains/base.pypredates Pydantic v2 — usesBaseModel-from-v1 namespace.- Recent additions (
history_aware_retriever.py,retrieval.py,combine_documents/__init__.py:create_stuff_documents_chain) are LCEL-based and bypassChain. They sit alongside the legacy classes. qa_with_sources/,summarize/,question_answering/have bothload_*factory functions (legacy) andcreate_*factory functions (LCEL).- Many
LLMChainusers are now told (via@deprecated) to migrate toprompt | llm.
Step-by-Step Walkthrough
Follow a typical operation through the chains/ module:
-
Caller invokes the public surface — usually
.invoke(input)on a Runnable, or a legacy__call__(inputs_dict)on aChainsubclass. - Pydantic validation — every entry point is a BaseModel; constructor args, input dicts, and tool args_schema are validated before any expensive work runs. Fail-fast on type errors.
-
CallbackManager.configure — a fresh manager is built per call, fans
on_*_startevents to all subscribers (LangSmith tracer, stdout handler, custom logging), and stamps a UUID4run_idfor tree reconstruction. - chains-specific logic — the module's core method runs: prompt rendering, retrieval, tool execution, memory load, document split, etc. See the deep-dive sections above.
- Provider call — typically a single HTTP request to OpenAI/Anthropic/etc., or a local SDK call. Retries are handled by tenacity with exponential backoff and a configurable max-attempts knob.
-
Streaming fan-out — if the provider streams tokens, each chunk fires
on_llm_new_tokenthrough the callback manager so downstream observers (UI, log, tracer) see partial output. -
OutputParser pass — if a parser is attached, the raw provider output is parsed into a structured Python object;
OutputParserExceptionmay trigger a retry chain (RetryWithErrorOutputParser). -
Memory write-back — for chains/agents that have a
BaseMemoryattached,save_context(inputs, outputs)persists the turn before returning to the caller. -
on_*_end fan-out — the callback manager closes the run span; tracers serialise the
Runtree and ship it to LangSmith asynchronously. - Result returned — caller receives a typed object (AIMessage, dict, BaseModel, list[Document], ...) matching the module's declared output schema.
What Could Go Wrong
| Failure Mode | Trigger | Detection | Recovery |
|---|---|---|---|
| Provider 5xx / rate-limit | OpenAI/Anthropic/etc. transient error | HTTP status from SDK | tenacity retries with exponential backoff up to max_retries; then surface to caller |
| Output parser failure | LLM returned malformed JSON / wrong schema |
OutputParserException in parser |
RetryWithErrorOutputParser re-prompts with the parser error included; or surface |
| Pydantic validation | Caller passes wrong-shaped dict |
ValidationError at __init__ / invoke
|
Surface immediately — no fallback is correct here |
| Callback handler exception | User-supplied tracer raises | Try/except around each fan-out | Logged and swallowed by default; raise_error=True to opt in to propagation |
| Provider auth missing |
OPENAI_API_KEY unset |
SDK AuthenticationError
|
Surface with a helpful message; no retry (would just fail again) |
Performance Profile
Hot Paths
- Provider HTTP round-trip dominates wall-clock time (100ms–30s depending on model and prompt length).
-
Pydantic validation is repeated on every
invoke; cached schema parsing keeps it under 1ms for typical inputs. - Callback fan-out is synchronous by default — a slow handler will slow every call. Async tracers (LangSmith) push work to a background thread to avoid blocking.
Resource Usage
-
Memory: token-history buffers (in
memory/) and document caches (in retrieval pipelines) are the largest steady-state allocations. - I/O: bounded by provider rate limits; vector store I/O for retrieval-flavored modules.
-
Concurrency: async support via
a*methods; agents and parallel chains useasyncio.gatherfor concurrent tool calls.
Directory Map
Source tree for chains
chains/
├── __init__.py
├── api/
├── base.py
├── chat_vector_db/
├── combine_documents/
├── constitutional_ai/
├── conversation/
├── conversational_retrieval/
├── elasticsearch_database/
├── ernie_functions/
├── example_generator.py
├── flare/
├── graph_qa/
├── history_aware_retriever.py
├── hyde/
├── llm.py
├── llm_bash/
├── llm_checker/
├── llm_math/
├── llm_requests.py
├── llm_summarization_checker/
├── llm_symbolic_math/
├── loading.py
├── mapreduce.py
├── moderation.py
├── natbot/
├── openai_functions/
├── openai_tools/
├── prompt_selector.py
├── qa_generation/
├── qa_with_sources/
├── query_constructor/
├── question_answering/
├── retrieval.py
├── retrieval_qa/
├── router/
├── sequential.py
├── sql_database/
├── structured_output/
├── summarize/
└── transform.py