callbacks — Observer pattern for tracing, logging, streaming, metrics
Observer pattern for tracing, logging, streaming, metrics
Module langchain_classic/callbacks/ — Observer pattern for tracing, logging, streaming, metrics. Part of the Cross-cutting group.
🎯 Why This Module Exists
This module is documented from the callbacks_deep.md Phase 1B research artifact. See sections below for design decisions, algorithms, error philosophy, performance, and evolution clues.
Observer Tracing LangSmith
Architecture
flowchart LR
subgraph Core["langchain_classic/callbacks/"]
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 Callback as callbacks participant Core as langchain_core participant Ext as External Caller->>Callback: invoke(input) Note over Callback: Validate & route Callback->>Core: delegate primitive Core->>Ext: provider call Ext-->>Core: response Core-->>Callback: typed output Callback-->>Caller: result
Existence rationale
callbacks/ implements the observer pattern that lets every chain, agent, retriever, LLM call, and tool emit lifecycle events (on_*_start, on_*_end, on_*_error, on_*_new_token) to N subscribers without coupling. Without it, streaming UIs, distributed tracing (LangSmith), token cost accounting, and verbose=True debug printing would each need bespoke instrumentation in every component. By centralising on BaseCallbackHandler, langchain_classic lets a single CallbackManager fan an event out to a stdout printer, a file logger, a LangSmith tracer, and a streaming WebSocket — concurrently.
Design decisions
| Decision | Choice made | Plausible alternatives | Rationale |
|---|---|---|---|
| Event surface | One method per (event × component): on_chain_start, on_llm_new_token, … | Generic on_event(name, payload) | Static type + IDE autocomplete trump flexibility; payload shapes differ |
| Manager-per-call | CallbackManager.configure(...) builds a fresh manager from local + global handlers | Singleton manager | Per-run isolation + nested run trees with parent IDs |
| Inheritance for tracers | Run trees: each child manager carries parent_run_id | Flat list | Lets tracers reconstruct chain-of-thought call graph |
| Sync + async parity | BaseCallbackHandler (sync) + AsyncCallbackHandler | Async only | Streaming UIs in sync code need callbacks too |
ignore_* flags | Each handler has ignore_chain, ignore_llm, … | Subclass-time pruning | Lets users wire one handler for one event class without subclassing |
| Verbose handler is global | set_verbose(True) adds StdOutCallbackHandler to globals | Pass per-call | Mirrors logging.basicConfig idiom; matches user mental model |
| Tracers as handlers | LangChainTracer is a BaseCallbackHandler | Custom hook system | Single dispatch path; tracers are just another subscriber |
Algorithm deep-dives
1. Manager configuration & dispatch
Trace.
- Caller calls
manager = CallbackManager.configure(inheritable_callbacks, local_callbacks, verbose, inheritable_tags, …). configuremerges global handlers (langchain_classic.globals), inherited handlers from a parent run, and call-local handlers, dedup-by-id.- If
verboseand noConsoleCallbackHandler→ adds one. - If env
LANGCHAIN_TRACING_V2=trueand noLangChainTracer→ adds one. - Returned manager carries
run_id,parent_run_id,tags,metadata.
On on_chain_start(...):
- Generates a new
run_id(UUID4). - For each handler with
not handler.ignore_chain: tryhandler.on_chain_start(serialized, inputs, run_id, parent_run_id, tags, metadata). - Exceptions in handlers are caught + logged, never propagated — handler bugs must not crash the run.
- Returns a
CallbackManagerForChainRunwhoseparent_run_id = run_idso children form a tree.
Why per-call. Streaming UIs need a different handler set than batch processing; tracers need fresh run IDs per top-level call. A single global manager would either leak handlers across runs or require explicit teardown.
2. Streaming token dispatch
AsyncIteratorCallbackHandler is the bridge between LLM on_llm_new_token and an async generator the application consumes:
- Maintains a
asyncio.Queue. on_llm_new_token(token)→queue.put_nowait(token).on_llm_end→queue.put_nowait(SENTINEL).- The user's async loop iterates
await queue.get()until SENTINEL.
This decouples LLM emission cadence from the consumer's read cadence — the LLM streams as fast as the network allows, the UI catches up on its own clock.
Error philosophy
Handler exceptions are quarantined. BaseCallbackManager._handle_event swallows handler exceptions and logs them — by design, a broken tracer must not break a working chain. Tools and chains, by contrast, propagate up — the application decides on retry.
Performance characteristics
- Hot-path cost: ~1 µs per handler per event (function call + ignore-flag check). With 3 handlers and 10 events per chain → ~30 µs of overhead on a multi-second LLM call. Negligible.
- Tracer cost:
LangChainTracerposts to LangSmith asynchronously (fire-and-forget). Default queue is bounded; over capacity it drops events with a warning. - Streaming: queue throughput is the bottleneck — tens of thousands of tokens/sec are easy.
Step-by-Step Walkthrough
Follow a typical operation through the callbacks/ 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. - callbacks-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 callbacks
callbacks/
├── __init__.py
├── aim_callback.py
├── argilla_callback.py
├── arize_callback.py
├── arthur_callback.py
├── base.py
├── clearml_callback.py
├── comet_ml_callback.py
├── confident_callback.py
├── context_callback.py
├── file.py
├── flyte_callback.py
├── human.py
├── infino_callback.py
├── labelstudio_callback.py
├── llmonitor_callback.py
├── manager.py
├── mlflow_callback.py
├── openai_info.py
├── promptlayer_callback.py
├── sagemaker_callback.py
├── stdout.py
├── streaming_aiter.py
├── streaming_aiter_final_only.py
├── streaming_stdout.py
├── streaming_stdout_final_only.py
├── streamlit/
├── tracers/
├── trubrics_callback.py
├── utils.py
├── wandb_callback.py
└── whylabs_callback.py