n4nAI

Debugging slow LangChain chains step by step

A practical step-by-step guide to profiling and fixing latency in LangChain apps: trace calls, find bottlenecks, cache, batch, and verify gains.

n4n Team4 min read912 words

Audio narration

Coming soon — every post will get a voice note here.

Slow langchain chain debugging starts with measurement, not intuition. When a chain that should return in two seconds blows past ten, the framework is rarely the culprit—model latency, serial calls, and missing caches are. This guide gives you an ordered workflow to profile, isolate, and remove latency from LangChain pipelines, with runnable snippets at each stage.

Step 1: Enable granular tracing and timing

LangChain ships with callback hooks that expose every LLM, retriever, and parser duration. The default set_debug(True) dumps noisy logs that mix prompts with timings, making it hard to see which call ate three seconds. Build a custom handler to record wall-clock time per run id.

from langchain.callbacks.base import BaseCallbackHandler
import time

class TimingHandler(BaseCallbackHandler):
    def __init__(self):
        self.timings = {}
    def on_llm_start(self, serialized, prompts, **kwargs):
        self.timings[kwargs.get("run_id")] = time.perf_counter()
    def on_llm_end(self, response, **kwargs):
        start = self.timings.pop(kwargs.get("run_id"), None)
        if start:
            print(f"LLM call took {time.perf_counter()-start:.2f}s")

handler = TimingHandler()
# Pass to your chain's invoke: chain.invoke({"input": "..."}, config={"callbacks": [handler]})

This isolates LLM time from parsing or retriever time. If you already use LangSmith, the same data appears in the trace waterfall, but the handler works offline and adds zero infra.

Verify: run one chain invocation and confirm you see per-call durations in stdout. No durations printed means your chain never hit an LLM (e.g., cached or stubbed).

Step 2: Reproduce in a bare script

Framework overhead from web servers, async event loops, or notebook kernels hides the real cost. Extract the chain construction and invocation into a plain Python file under a if __name__ == "__main__": guard.

from langchain.chains import LLMChain
from langchain.chat_models import ChatOpenAI
from langchain.prompts import PromptTemplate

llm = ChatOpenAI(model="gpt-4o-mini")
prompt = PromptTemplate.from_template("Summarize: {text}")
chain = LLMChain(llm=llm, prompt=prompt)

if __name__ == "__main__":
    import time
    t0 = time.perf_counter()
    chain.run(text="long document ...")
    print(f"Total: {time.perf_counter()-t0:.2f}s")

Slow langchain chain debugging demands this isolation. If the bare script is fast but your FastAPI endpoint is slow, the bottleneck is request parsing, middleware, or connection pooling—not LangChain.

Verify: the script runs to completion and prints a total time. Run it three times to absorb cold-start variance.

Step 3: Measure token throughput, not just wall time

A three-second call generating 10 tokens is broken; a three-second call streaming 2,000 tokens is normal. Count output tokens to compute tokens/sec, using tiktoken for OpenAI models.

import tiktoken
enc = tiktoken.get_encoding("cl100k_base")

def tok_count(s): return len(enc.encode(s))

# after chain.run(...)
out = chain.run(text="...")
elapsed = time.perf_counter() - t0
print(f"{tok_count(out)} tokens, {tok_count(out)/elapsed:.1f} tok/s")

If throughput is under 20 tok/s on a modern model, suspect network hops or a degraded provider. Routing through an OpenAI-compatible gateway that automatically falls back when a provider is rate-limited can mask those stalls—n4n.ai does this on a single endpoint covering 240+ models, so a slow upstream doesn’t block your chain.

Verify: compare tok/s against provider docs. Big gaps indicate transport or retry issues, not model speed.

Step 4: Map serial versus parallel steps

LangChain’s LCEL makes parallel branches explicit, but many legacy chains use SequentialChain or pipe two runnables blindly. A common latency trap is chaining two independent LLM calls when they could run concurrently.

from langchain_core.runnables import RunnableParallel, RunnableLambda

# Bad: serial – second call waits for first
chain = extract_chain | summarize_chain

# Good: parallel independent work
parallel = RunnableParallel({
    "extract": extract_chain,
    "classify": classify_chain,
})
merged = parallel | combine_chain

Verify: re-run with TimingHandler. Parallel branches should show overlapping start times in logs. If on_llm_start for the second call fires only after the first on_llm_end, you are still serial.

Step 5: Audit retries and rate-limit backoff

The OpenAI client defaults to multiple retries with exponential backoff. In a chain firing 10 sub-calls, one 429 can add seconds of silent sleep. Tighten max_retries during debugging and add explicit timeouts.

from langchain.chat_models import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini", max_retries=1, request_timeout=10)

Also check your API key quota and region. If you see long pauses before errors, backoff is the cause. Using a gateway that honors client routing directives and forwards provider cache-control hints lets you pin to a healthy region or model without code changes.

Verify: set max_retries=0 and re-run. If latency drops sharply and you get errors instead of slow successes, retries were your bottleneck.

Step 6: Add caching at the right layer

LangChain supports LLM caching out of the box. For deterministic prompts, an in-memory or Redis cache eliminates repeat calls entirely.

from langchain.cache import InMemoryCache
import langchain
langchain.set_llm_cache(InMemoryCache())

For semantic similarity, use CacheBackedEmbeddings with a vector store so near-duplicate retrievals skip the embedding model. Be careful: caching non-deterministic chains (high temperature) will serve stale nonsense.

Verify: invoke the same input twice; the second run should show near-zero LLM time in TimingHandler. Cache hits print no LLM call took line.

Step 7: Batch embeddings and document transforms

Looping over documents to embed them one by one is a classic slowdown. Always batch embedding calls into a single request.

# Bad
for doc in docs:
    emb = embeddings.embed_query(doc)

# Good
embs = embeddings.embed_documents(docs)  # single request

Same rule applies to any map step: use RunnableParallel or the batch() method on the chain to send multiple inputs in one client round-trip where the provider supports it.

results = chain.batch([{"input": d} for d in docs])

Verify: watch outbound HTTP requests (e.g., with httpx logging). Batch mode should show one request per N items, not N requests.

Step 8: Use smaller models for sub-tasks

A chain that calls GPT-4 to decide “is this email spam?” wastes 500ms. Route classification, routing, or extraction to a fast model and reserve heavy reasoning for a larger one.

from langchain.chat_models import ChatOpenAI
from langchain.prompts import PromptTemplate
from langchain_core.runnables import RunnableBranch

router_llm = ChatOpenAI(model="gpt-3.5-turbo")
heavy_llm = ChatOpenAI(model="gpt-4o")

router = PromptTemplate.from_template("Classify intent: {text}") | router_llm
# branch based on router output elsewhere

Slow langchain chain debugging often ends here: split cognitive load by cost and latency tier. A 100-token routing call on a small model is invisible next to a 2,000-token generation.

Verify: replace one sub-chain with a smaller model and re-benchmark (Step 9). Quality should hold on that sub-task; if not, scope the small model tighter.

Step 9: Benchmark before and after

Write a tiny harness that runs N representative inputs and reports p50/p95. Averages lie; one slow call dominates user pain.

import statistics, time

def bench(chain, inputs):
    ts = []
    for i in inputs:
        t0 = time.perf_counter()
        chain.run(text=i)
        ts.append(time.perf_counter() - t0)
    ts.sort()
    p50 = ts[len(ts)//2]
    p95 = ts[int(len(ts)*0.95)]
    print(f"p50={p50:.2f}s p95={p95:.2f}s")

If your chain is async, use asyncio.run and chain.ainvoke in the loop. Keep the input set fixed across runs so comparisons are valid.

Verify: compare p95 from Step 2 baseline to post-fix. A real win is p95 cut by at least 30% without quality regression on a held-out sample.

Step 10: Keep observability in production

Ship the TimingHandler or LangSmith tracer behind a flag so you can reproduce slowness from production traffic. If you route through n4n.ai, per-token usage metering shows which sub-call drives cost and latency post-deploy. Alert on p95 regression, not averages—users feel the tail.

Slow langchain chain debugging is never a one-shot fix; it’s a loop of measure, change, verify. Follow these steps and you’ll turn a 15-second chain into something users don’t notice.

Tagslangchainperformancedebugginglatency

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All langchain debugging & observability posts →