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.

140
Source files
28
Sub-directories
Orchestration
Category
Python
Language

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

DecisionChoice madePlausible alternativesInferred rationale
Composition mechanismSubclass Chain, override _callLCEL `\` composition; closures; visitor patternPydantic v1 BaseModel inheritance gave free validation + serialisation; subclass override is the most discoverable extension point for 2022 users
Sync/async parityBoth _call and _acall; default _acall raisesAsync-only; sync wrapper around asyncMany integrations were sync-only in 2022 (requests-based); forcing async would block adoption
Inputs/outputs typingdict[str, Any] keyed by input_keys / output_keysDataclass; Pydantic model per chainDicts compose with RunnableMap and prompt templates without a translation layer
Memory integrationBaseMemory is a chain attribute; prep_inputs merges memory varsPass memory explicitly per callLets users plug memory into any chain without subclassing
Callback flowCallbackManager.configure(...) produces a fresh manager per callSingle global managerPer-run isolation needed for streaming + tracing trees
Document combinationStrategy classes: Stuff, MapReduce, Refine, MapRerankSingle switch-case in one classEach strategy has different prompts/state; subclassing kept them readable
Router designRouterChain (string → destination), MultiRouteChain dispatchesSingle chain with conditional promptAllows mixing destinations of different signatures (LLMChain + tool + retrieval)
LCEL bridgecreate_retrieval_chain, create_history_aware_retriever are LCEL chains exposed as a RunnableSubclass Chain for themNewer 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.

  1. Chain.__call__(inputs) (or Chain.invoke for the Runnable interface).
  2. prep_inputs(inputs) — accept str (single-key chains), validate keys.
  3. prep_inputs calls memory.load_memory_variables and merges into inputs.
  4. CallbackManager.configure builds a manager from kwargs + global handlers.
  5. manager.on_chain_start(serialized=self.dict(), inputs).
  6. try: outputs = self._call(inputs, run_manager=child_manager).
  7. except: manager.on_chain_error(e); raise.
  8. prep_outputs(inputs, outputs) — saves to memory, validates output keys.
  9. manager.on_chain_end(outputs).
  10. 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.

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.

  1. Split docs into batches whose total token count ≤ token_max.
  2. Call llm_chain on each batch (the map step), producing intermediate texts.
  3. If len(intermediate_texts) is 1 → finalise.
  4. Else: feed all intermediate texts into combine_document_chain and recurse if the combined fits in context, else split + map again. (The "collapse" step.)
  5. 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}):

  1. router_chain.route({"input": q}) returns Route(destination="sql", next_inputs={...}).
  2. destination_chains[route.destination].invoke(route.next_inputs).
  3. If destination unknown → default_chain if 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

Evolution clues

Step-by-Step Walkthrough

Follow a typical operation through the chains/ module:

  1. Caller invokes the public surface — usually .invoke(input) on a Runnable, or a legacy __call__(inputs_dict) on a Chain subclass.
  2. 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.
  3. CallbackManager.configure — a fresh manager is built per call, fans on_*_start events to all subscribers (LangSmith tracer, stdout handler, custom logging), and stamps a UUID4 run_id for tree reconstruction.
  4. 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.
  5. 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.
  6. Streaming fan-out — if the provider streams tokens, each chunk fires on_llm_new_token through the callback manager so downstream observers (UI, log, tracer) see partial output.
  7. OutputParser pass — if a parser is attached, the raw provider output is parsed into a structured Python object; OutputParserException may trigger a retry chain (RetryWithErrorOutputParser).
  8. Memory write-back — for chains/agents that have a BaseMemory attached, save_context(inputs, outputs) persists the turn before returning to the caller.
  9. on_*_end fan-out — the callback manager closes the run span; tracers serialise the Run tree and ship it to LangSmith asynchronously.
  10. 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

Resource Usage

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