Reliability, tools, and observability for agents
Retries, timeouts, tool error handling, LangSmith tracing, and the runbook every production agent needs.
Why this matters
Demo agents work 70% of the time. Production agents need to work 99%+ of the time, fail safely the other 1%, and let you debug what happened. The patterns in this lesson β circuit breakers, idempotency, structured logs, tracing β are the boring expensive skills that make you unfireable.
Learning objectives
- Design tools that are safe to retry.
- Add retries, timeouts, circuit breakers, rate limits.
- Detect and break infinite agent loops.
- Trace every run with LangSmith (or LangFuse).
- Emit OpenTelemetry spans for vendor-neutral observability.
1. Tool design rules (the senior version)
The contract you give an LLM must be enforceable. Bake these into every tool you write:
from pydantic import BaseModel, Field
from langchain_core.tools import tool
from typing import Literal
class CreateRefundArgs(BaseModel):
invoice_id: str = Field(..., pattern=r"^INV-[0-9]{4}-[0-9]{4,6}$")
amount_inr: float = Field(..., gt=0, le=100_000)
reason: Literal["duplicate", "fraud", "service-failure", "goodwill"]
confirm: bool = Field(..., description="MUST be true to actually run")
@tool
def create_refund(args: CreateRefundArgs) -> dict:
"""Refund a customer. Requires confirm=True. Use only after explicit user approval."""
if not args.confirm:
return {"status": "preview", "message": "Set confirm=True to actually create."}
# actual call to billing API with idempotency key
return billing.refund(invoice_id=args.invoice_id, amount=args.amount_inr,
reason=args.reason,
idempotency_key=f"agent-{args.invoice_id}")What this gives you:
- Strict types and regex prevent garbage IDs from the LLM.
confirmflag is a free "are you sure?" gate.- Idempotency key makes retries safe.
- Bounded amount caps blast radius if the model goes wild.
A model that calls create_refund(invoice_id="INV-2026-7842", amount_inr=999_999, confirm=True, reason="fraud") is rejected at the schema level before reaching billing.
2. Retries, timeouts, and circuit breakers
# uv add tenacity httpx
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
import httpx
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(min=1, max=8),
retry=retry_if_exception_type((httpx.TimeoutException, httpx.HTTPStatusError)),
reraise=True,
)
def call_billing(payload: dict) -> dict:
with httpx.Client(timeout=httpx.Timeout(10.0, connect=3.0)) as c:
r = c.post("https://billing/api/refund", json=payload)
r.raise_for_status()
return r.json()Timeouts: every external call. Always. Especially LLM calls and tool HTTP requests.
Circuit breaker: if a downstream API fails repeatedly, stop calling it for a window.
# uv add purgatory # or pybreaker
from purgatory import SyncCircuitBreakerFactory
cb = SyncCircuitBreakerFactory(
default_threshold=5, # failures to open
default_ttl=30, # cool-down seconds
)
with cb.get_breaker("billing"):
return call_billing(payload)Circuit-breaker states:
- Closed β calls go through.
- Open β calls fail fast for
ttlseconds. - Half-open β one trial call to see if it works again.
This is what keeps a flaky downstream from poisoning your whole agent latency.
3. Loop detection and step caps
LLMs sometimes get stuck calling the same tool with the same args. Detect and break.
seen = set()
def tool_with_loop_guard(name, args, run):
sig = (name, json.dumps(args, sort_keys=True))
if sig in seen:
return {"error": "loop detected: same tool+args called twice"}
seen.add(sig)
return run()Plus a hard step cap β max_steps=8 for complex agents, max_steps=5 for simple ones. Always.
4. Rate limiting (do not annoy your provider)
# uv add aiolimiter
from aiolimiter import AsyncLimiter
limiter = AsyncLimiter(max_rate=10, time_period=1) # 10 calls / sec
async def call_llm(prompt):
async with limiter:
return await client.responses.create(model="gpt-4.1-mini", input=prompt)For multi-tenant apps, key-scope the limiter (one per user_id) so a noisy customer cannot hog quota.
5. Logging for agents (structured, never print)
# uv add structlog
import structlog
log = structlog.get_logger()
log.info("tool.call",
tool="create_refund",
args={"invoice_id": "INV-...","amount": 340},
user_id=user_id, request_id=req_id, duration_ms=42)Hard rules:
- Always include
user_id,request_id,agent_name,node,model. - Never log raw prompts/answers in production unless redacted (PII risk).
- JSON output so structured log shippers (Loki, Datadog) can index.
Modern stacks ship logs to Loki + Grafana or Datadog. Your job is to emit them in a clean shape.
6. LangSmith tracing (the easy 80%)
LangSmith automatically traces LangChain and LangGraph runs when you set two env vars:
LANGSMITH_TRACING=true
LANGSMITH_API_KEY=ls__...
LANGSMITH_PROJECT=my-agentFor non-LangChain code, use the @traceable decorator and the wrap_openai helper.
# uv add langsmith
from langsmith import traceable
from langsmith.wrappers import wrap_openai
from openai import OpenAI
oa = wrap_openai(OpenAI()) # wraps so every LLM call is auto-traced
@traceable(run_type="tool")
def get_context(question: str) -> str:
# imagine a vector retrieval
return "Some context"
@traceable
def assistant(question: str) -> str:
ctx = get_context(question)
r = oa.chat.completions.create(
model="gpt-4.1-mini",
messages=[
{"role": "system", "content": f"Answer using context.\n\n{ctx}"},
{"role": "user", "content": question},
],
)
return r.choices[0].message.content
print(assistant("What is RAG?"))You will see:
- A trace tree (
assistant > get_context,assistant > openai). - Latency per span, token cost, full inputs/outputs.
- Errors highlighted.
In LangGraph, the entire graph plus every node is traced automatically when env vars are set. No code changes needed. That alone is a magnificent quality-of-life upgrade.
7. LangFuse and others (open-source / self-hostable)
If you cannot send data to a managed service:
- LangFuse β open source, self-hostable, similar UX to LangSmith.
- Helicone β proxy-style.
- Phoenix (Arize) β best for embedding drift and RAG monitoring.
- PromptLayer β simple.
# uv add langfuse
from langfuse.openai import openai # drop-in
r = openai.chat.completions.create(model="gpt-4.1-mini", messages=[...])8. OpenTelemetry β vendor-neutral and the way enterprise wants it
For enterprises that already have a tracing platform (Tempo / Jaeger / Datadog APM), instrument your agent with OpenTelemetry. LangChain has an OTel integration.
# uv add openinference-instrumentation-openai openinference-instrumentation-langchain opentelemetry-sdk opentelemetry-exporter-otlp
from openinference.instrumentation.openai import OpenAIInstrumentor
from openinference.instrumentation.langchain import LangChainInstrumentor
OpenAIInstrumentor().instrument()
LangChainInstrumentor().instrument()Now every OpenAI/LangChain call emits OTel spans. Set OTEL_EXPORTER_OTLP_ENDPOINT=https://your-collector and you are wired into the rest of the company stack.
OpenInference is the open semantic conventions for LLM tracing β backed by Arize, Microsoft, OpenAI, and the LF AI & Data Foundation.
9. Cost and latency dashboards (one Grafana panel = senior signal)
Track these metrics per agent:
- Requests / min.
- p50, p95, p99 latency.
- Tokens in / out per request.
- USD cost per request.
- Error rate by node / tool.
- Cache hit rate.
- Avg steps per agent run.
Send via Prometheus client (prometheus_client library) or a managed APM. Building one Grafana dashboard for your project β and screenshotting it in your README β is one of the strongest portfolio moves there is.
Hands-on lab (5 hours)
Take your LangGraph agent from Lesson 3.3.
- Add
tenacityretry +purgatorycircuit breaker around every external HTTP call. - Add a loop guard inside the tool dispatcher.
- Add structured logging (
structlog) withuser_id,request_id,node. - Turn on LangSmith tracing (env vars only).
- Add OpenInference + OTel instrumentation, exporting to a local Jaeger (
docker run jaegertracing/all-in-one). - Add Prometheus metrics: counters for
agent_steps_total, gauges foragent_in_flight, histograms foragent_latency_seconds. Expose/metricson FastAPI. - Build a Grafana dashboard panel reading those metrics. Screenshot.
Acceptance:
- A simulated downstream outage opens the breaker; agent fails fast within 30s.
- LangSmith trace is shareable via URL.
- Jaeger shows the OTel waterfall.
- README has Grafana screenshot.
Common pitfalls
- No timeouts. A hanging tool freezes your event loop.
- Retrying non-idempotent operations. Add idempotency keys.
- Catching
Exception. Be specific β let bugs surface. print()in production code. Use structlog/JSON.- Tracing PII. Add a redactor before sending to managed observability tools.
Self-check
- What does a circuit breaker's "half-open" state do?
- Why does idempotency matter for refund tools?
- What is the simplest way to enable LangSmith tracing for a LangGraph app?
- Why would you choose OpenTelemetry over LangSmith in some companies?
- What three metrics would you alarm on for an agent service?
References
Sign in to save your progress and earn badges.