Quick Takeaways
What you'll learn in this article
- 1
Triage incoming support tickets by intent and severity
- 2
Investigate issues by querying the CRM, billing system, and product logs
- 3
Resolve common issues autonomously (password resets, billing adjustments under a threshold, plan upgrades)
- 4
Draft responses with citations for higher-complexity tickets
- 5
Escalate edge cases to human agents with full context packets
Keep reading for detailed implementation, code examples, and real-world results
There is a graveyard of AI agent demos that never made it to production. The demos looked incredible โ a GPT-4-class model autonomously triaging support tickets, pulling from a CRM, drafting refund responses, and escalating edge cases. The engineering team celebrated. Then they tried to deploy it, and the whole thing fell apart under the weight of real-world conditions: flaky tool calls, runaway token costs, infinite reasoning loops, zero observability, and zero recovery paths when the model hallucinated a refund policy that didn't exist.
This is the tutorial that graveyard deserves. We are going to build a production-ready AI agent stack from the ground up, using a realistic multi-agent customer operations pipeline as our running example. We will cover every layer of the stack: orchestration frameworks, tool-use patterns, memory and state management, distributed tracing with OpenTelemetry, failure recovery loops, human-in-the-loop escalation design, and hard cost guardrails. By the end, you will have a concrete architectural blueprint you can adapt to your own domain.
This is not a tutorial about prompting. This is a tutorial about engineering.
The State of Agentic AI in Production
Before we write a single line of code, we need to be honest about where the industry actually is. The term "agentic AI" has been stretched to cover everything from a simple chain-of-thought prompt to a fully autonomous multi-agent system managing mission-critical workflows. For the purposes of this tutorial, we define an AI agent as a system that:
- Receives a high-level goal rather than a prescriptive instruction
- Autonomously plans a sequence of steps to achieve that goal
- Invokes external tools or sub-agents to gather information or take action
- Maintains state across multiple reasoning cycles
- Can escalate to a human when confidence falls below a threshold
That definition immediately surfaces the engineering challenges. Each of those five properties introduces a failure mode that simply does not exist in a stateless, single-turn LLM call.
Agent Failure Rate
67%
Percentage of agent deployments that fail or are rolled back within 90 days due to reliability issues
Avg. Token Overspend
4.2x
How much more production agents spend vs. initial estimates when deployed without cost guardrails
Agentic Framework Adoption Among Production Teams (2026)
| framework | adoption |
|---|---|
| LangGraph | 41 |
| AutoGen | 28 |
| CrewAI | 18 |
| Custom | 13 |
The adoption numbers above tell a clear story: teams that ship to production gravitate toward LangGraph and AutoGen, both of which offer explicit state machines and observable execution graphs. CrewAI is popular for prototyping but struggles at production scale due to its opaque runtime. Custom frameworks are maintained by teams large enough to absorb the engineering overhead.
Let's build on LangGraph as our primary orchestration layer, with AutoGen for specific multi-agent delegation patterns, and we'll make the architecture framework-agnostic at the boundaries.
The Running Example: A Multi-Agent Customer Operations Pipeline
Throughout this tutorial, we'll build OperationsOS โ a multi-agent pipeline that handles customer support operations for a mid-size SaaS company. The pipeline needs to:
- Triage incoming support tickets by intent and severity
- Investigate issues by querying the CRM, billing system, and product logs
- Resolve common issues autonomously (password resets, billing adjustments under a threshold, plan upgrades)
- Draft responses with citations for higher-complexity tickets
- Escalate edge cases to human agents with full context packets
- Learn from resolution outcomes to improve future routing decisions
This is a realistic scope. It's not a toy. Teams at companies like Intercom, Zendesk, and Salesforce have shipped variants of this exact pipeline, and the hard-won lessons from those deployments inform every architectural decision in this tutorial.
Ticket Ingestion
Raw ticket arrives via webhook from support platform. Normalized into canonical schema.
Triage Agent
Classifies intent, severity, and required data sources. Routes to appropriate specialist agent.
Investigation Agent
Queries CRM, billing, and product logs via tool calls. Builds evidence context package.
Resolution Agent
Attempts autonomous resolution for in-scope issues. Invokes action tools with guardrails.
Response Draft Agent
Drafts customer-facing response with policy citations. Runs tone and compliance checks.
Quality Gate
Confidence scoring and policy validation. Triggers human escalation if below threshold.
Delivery & Learning
Response delivered. Outcome tracked. Feedback loop updates routing heuristics.
Layer 1: Orchestration Architecture
Choosing the Right Orchestration Pattern
There are three dominant orchestration patterns for multi-agent systems, and picking the wrong one is the single most common architectural mistake we see teams make.
Pattern A: Linear Chain Agents execute in a fixed sequence. Simple, predictable, but brittle. A failure in step 3 of 7 terminates the entire pipeline. No branching, no recovery. Good for prototypes; dangerous in production.
Pattern B: Supervisor-Worker (Hub and Spoke) A supervisor agent dynamically routes tasks to specialist workers. This is the AutoGen model. The supervisor maintains a task queue and can retry failed delegations. Substantially more resilient, but the supervisor becomes a single point of failure and the hardest component to debug.
Pattern C: Graph-Based State Machine (LangGraph Model) Agents are nodes in a directed graph. Edges represent conditional transitions based on state. Every state transition is explicit, inspectable, and replayable. This is the correct pattern for production systems.
Orchestration Pattern Trade-offs
Linear Chain
Graph State Machine
Building the LangGraph Orchestration Layer
Let's build the core orchestration graph for OperationsOS. We define the state schema first โ this is non-negotiable. The state is the contract between all agents.
from typing import TypedDict, Literal, Optional, List
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.postgres import PostgresSaver
import uuid
class TicketState(TypedDict):
# Core identifiers
ticket_id: str
session_id: str
# Raw input
raw_ticket: dict
# Triage output
intent: Optional[str]
severity: Literal["P1", "P2", "P3", "P4"] | None
required_data_sources: List[str]
# Investigation output
crm_data: Optional[dict]
billing_data: Optional[dict]
product_logs: Optional[List[dict]]
evidence_package: Optional[str]
# Resolution output
resolution_action: Optional[str]
resolution_applied: bool
resolution_confidence: float
# Response
draft_response: Optional[str]
policy_citations: List[str]
compliance_pass: bool
# Control flow
escalate_to_human: bool
escalation_reason: Optional[str]
retry_count: int
error_log: List[str]
# Observability
trace_id: str
span_ids: dict
token_usage: dict
total_cost_usd: float
Notice what is in the state: not just business data, but observability primitives (trace IDs, span IDs, token usage) and control flow primitives (retry count, error log, escalation reason). This is the first major lesson from production teams: embed your operational metadata in the state from day one, not as an afterthought.
Now we define the graph:
def build_operations_graph(checkpointer) -> StateGraph:
graph = StateGraph(TicketState)
# Add all agent nodes
graph.add_node("triage", triage_agent)
graph.add_node("investigate", investigation_agent)
graph.add_node("resolve", resolution_agent)
graph.add_node("draft_response", response_draft_agent)
graph.add_node("quality_gate", quality_gate_node)
graph.add_node("escalate", escalation_node)
graph.add_node("deliver", delivery_node)
graph.add_node("error_recovery", error_recovery_node)
# Entry point
graph.set_entry_point("triage")
# Conditional routing from triage
graph.add_conditional_edges(
"triage",
route_from_triage,
{
"investigate": "investigate",
"escalate": "escalate",
"error": "error_recovery"
}
)
# Investigation routes to resolution or draft
graph.add_conditional_edges(
"investigate",
route_from_investigation,
{
"resolve": "resolve",
"draft_only": "draft_response",
"escalate": "escalate",
"error": "error_recovery"
}
)
# Resolution routes to draft or escalate
graph.add_conditional_edges(
"resolve",
route_from_resolution,
{
"draft_response": "draft_response",
"escalate": "escalate",
"error": "error_recovery"
}
)
# Draft response always hits quality gate
graph.add_edge("draft_response", "quality_gate")
# Quality gate routes to delivery or escalation
graph.add_conditional_edges(
"quality_gate",
route_from_quality_gate,
{
"deliver": "deliver",
"escalate": "escalate",
"retry": "draft_response"
}
)
# Terminal nodes
graph.add_edge("deliver", END)
graph.add_edge("escalate", END)
# Error recovery can retry or escalate
graph.add_conditional_edges(
"error_recovery",
route_from_error,
{
"retry_triage": "triage",
"retry_investigate": "investigate",
"escalate": "escalate",
"hard_fail": END
}
)
return graph.compile(checkpointer=checkpointer)
The PostgresSaver checkpointer is critical. Every state transition is persisted. If the process crashes mid-execution, we can replay from the last checkpoint. This is not optional in production.
State Persistence and the Checkpoint Strategy
Every production agent system needs a durable checkpoint store. The choice matters:
Checkpoint Store Latency vs. Durability Trade-offs
| store | latency_ms |
|---|---|
| PostgreSQL | 12 |
| Redis | 2 |
| SQLite | 5 |
| In-Memory | 0.5 |
For OperationsOS, we use PostgreSQL for its durability and the ability to query checkpoint state for debugging. We add a Redis layer in front for sub-millisecond reads on hot sessions.
from langgraph.checkpoint.postgres import PostgresSaver
from psycopg import Connection
DB_URI = "postgresql://ops_agent:password@db:5432/agent_state"
with Connection.connect(DB_URI) as conn:
checkpointer = PostgresSaver(conn)
checkpointer.setup() # Creates tables if not exists
app = build_operations_graph(checkpointer)
Layer 2: Tool-Use Patterns That Don't Break in Production
Tool calls are where most agent systems fall apart. An agent that can call any function with any arguments is an agent that will eventually call the wrong function with the wrong arguments at exactly the worst moment.
The Tool Registry Pattern
Never register tools directly on the LLM call. Use a central tool registry that enforces schemas, rate limits, and access controls:
from pydantic import BaseModel, Field
from typing import Any, Callable, Optional
import asyncio
from functools import wraps
class ToolDefinition(BaseModel):
name: str
description: str
input_schema: dict
output_schema: dict
rate_limit_per_minute: int = 60
timeout_seconds: int = 30
retry_attempts: int = 3
requires_human_approval: bool = False
max_cost_per_call_usd: Optional[float] = None
class ToolRegistry:
def __init__(self):
self._tools: dict[str, tuple[ToolDefinition, Callable]] = {}
self._call_counts: dict[str, list] = {}
def register(self, definition: ToolDefinition):
def decorator(func: Callable):
self._tools[definition.name] = (definition, func)
self._call_counts[definition.name] = []
return func
return decorator
def get_tool_schemas(self) -> list[dict]:
"""Returns OpenAI-compatible tool schemas for LLM context."""
schemas = []
for name, (defn, _) in self._tools.items():
schemas.append({
"type": "function",
"function": {
"name": name,
"description": defn.description,
"parameters": defn.input_schema
}
})
return schemas
async def invoke(
self,
tool_name: str,
args: dict,
trace_context: dict,
agent_id: str
) -> dict:
if tool_name not in self._tools:
raise ValueError(f"Tool '{tool_name}' not registered")
defn, func = self._tools[tool_name]
# Rate limiting
await self._check_rate_limit(tool_name, defn.rate_limit_per_minute)
# Human approval gate
if defn.requires_human_approval:
approved = await self._request_human_approval(
tool_name, args, agent_id
)
if not approved:
return {"status": "rejected", "reason": "human_approval_denied"}
# Execute with timeout and retry
for attempt in range(defn.retry_attempts):
try:
result = await asyncio.wait_for(
func(**args),
timeout=defn.timeout_seconds
)
return {"status": "success", "data": result}
except asyncio.TimeoutError:
if attempt == defn.retry_attempts - 1:
raise
await asyncio.sleep(2 ** attempt) # Exponential backoff
except Exception as e:
if attempt == defn.retry_attempts - 1:
raise
await asyncio.sleep(2 ** attempt)
# Global registry
tool_registry = ToolRegistry()
Defining Production-Grade Tools
Let's define the actual tools for OperationsOS with proper guardrails:
@tool_registry.register(ToolDefinition(
name="get_customer_profile",
description="Retrieves full customer profile from CRM including account tier, history, and open issues",
input_schema={
"type": "object",
"properties": {
"customer_id": {"type": "string", "description": "CRM customer UUID"},
"include_history_days": {"type": "integer", "default": 90, "maximum": 365}
},
"required": ["customer_id"]
},
output_schema={"type": "object"},
rate_limit_per_minute=120,
timeout_seconds=10
))
async def get_customer_profile(customer_id: str, include_history_days: int = 90) -> dict:
async with crm_client.session() as session:
return await session.get_profile(
customer_id,
history_days=include_history_days
)
@tool_registry.register(ToolDefinition(
name="apply_billing_credit",
description="Applies a billing credit to a customer account. ONLY use for credits under $50 USD.",
input_schema={
"type": "object",
"properties": {
"customer_id": {"type": "string"},
"amount_usd": {"type": "number", "minimum": 0.01, "maximum": 50.00},
"reason": {"type": "string", "maxLength": 200}
},
"required": ["customer_id", "amount_usd", "reason"]
},
output_schema={"type": "object"},
rate_limit_per_minute=10,
requires_human_approval=False, # Under $50 is autonomous
max_cost_per_call_usd=50.0
))
async def apply_billing_credit(
customer_id: str,
amount_usd: float,
reason: str
) -> dict:
# Hard enforcement of the $50 limit at the tool level, not just the LLM level
if amount_usd greater than 50.0:
raise ValueError("Credit exceeds autonomous limit. Requires human approval.")
async with billing_client.session() as session:
result = await session.apply_credit(customer_id, amount_usd, reason)
# Emit audit event
await audit_log.record(
action="billing_credit_applied",
actor="resolution_agent",
target=customer_id,
amount=amount_usd,
reason=reason
)
return result
Notice the pattern: enforce business rules at the tool level, not just in the agent prompt. An LLM that ignores the $50 limit in its prompt will still be caught by the tool's own validation. Defense in depth applies to AI systems too.
Tool Call Failure Modes in Production (% of failures)
| Name | Value |
|---|---|
| Tool Timeout | 34 |
| Schema Validation Failure | 28 |
| Rate Limit Hit | 19 |
| Auth/Permission Error | 11 |
| Unexpected Output Format | 8 |
Layer 3: Memory and State Management
Memory is the most misunderstood component in agentic systems. Engineers conflate four distinct memory types, implement only one, and then wonder why their agent repeats itself, forgets context, or makes contradictory decisions.
The Four Memory Tiers
Tier 1: In-Context Memory (Working Memory) The active content of the current LLM context window. Ephemeral. Expires when the context is cleared. This is not memory โ this is RAM.
Tier 2: Episode Memory (Short-Term) The record of what happened in the current session. Stored in the checkpoint state. Persists across reasoning cycles within a single ticket workflow.
Tier 3: Semantic Memory (Long-Term Knowledge) Accumulated knowledge about customers, products, and resolutions. Stored in a vector database. Retrieved via semantic search at relevant reasoning steps.
Tier 4: Procedural Memory (Policy and Skills) How to do things. Stored as structured documents or fine-tuned model weights. Retrieved via keyword or semantic search when an agent needs to follow a policy.
class MemoryManager:
def __init__(
self,
vector_store, # e.g., pgvector, Pinecone, Weaviate
semantic_cache,
episode_store # PostgreSQL via checkpointer
):
self.vector_store = vector_store
self.semantic_cache = semantic_cache
self.episode_store = episode_store
async def retrieve_customer_context(
self,
customer_id: str,
current_issue: str,
top_k: int = 5
) -> list[dict]:
"""
Retrieves relevant past interactions for a customer.
Uses semantic similarity to find contextually relevant episodes.
"""
# Check semantic cache first
cache_key = f"customer:{customer_id}:{hash(current_issue)}"
if cached := await self.semantic_cache.get(cache_key):
return cached
# Vector search over customer episode store
query_embedding = await embed(current_issue)
results = await self.vector_store.similarity_search(
query_embedding,
filter={"customer_id": customer_id},
top_k=top_k,
score_threshold=0.72 # Only retrieve meaningfully similar episodes
)
# Cache for 5 minutes
await self.semantic_cache.setex(cache_key, 300, results)
return results
async def retrieve_policy(
self,
policy_domain: str,
specific_question: str
) -> list[dict]:
"""
Retrieves relevant policy documents for a given domain.
"""
query = f"{policy_domain}: {specific_question}"
query_embedding = await embed(query)
return await self.vector_store.similarity_search(
query_embedding,
filter={"document_type": "policy", "domain": policy_domain},
top_k=3
)
async def store_resolution_episode(
self,
state: TicketState,
outcome: str
) -> None:
"""
Stores a completed resolution as a retrievable episode.
"""
episode = {
"ticket_id": state["ticket_id"],
"customer_id": state["raw_ticket"]["customer_id"],
"intent": state["intent"],
"severity": state["severity"],
"resolution_action": state["resolution_action"],
"outcome": outcome,
"draft_response": state["draft_response"],
"timestamp": datetime.utcnow().isoformat()
}
# Generate embedding for semantic retrieval
searchable_text = f"{state['intent']} {state['evidence_package']} {outcome}"
embedding = await embed(searchable_text)
await self.vector_store.upsert(
id=state["ticket_id"],
embedding=embedding,
metadata=episode
)
Context Window Management
One of the most insidious production failure modes is context window overflow. As investigation agents gather evidence, the accumulated context grows, eventually exceeding the model's context limit and causing truncation, degraded reasoning, or outright API errors.
class ContextWindowManager:
# Token budgets per model (with 20% safety margin)
MODEL_BUDGETS = {
"gpt-4o": 100_000,
"claude-3-7-sonnet": 160_000,
"gemini-2.0-flash": 800_000
}
def __init__(self, model: str, tokenizer):
self.budget = self.MODEL_BUDGETS.get(model, 50_000)
self.tokenizer = tokenizer
self.safety_margin = 0.80 # Use only 80% of budget
def available_budget(self) -> int:
return int(self.budget * self.safety_margin)
def build_evidence_context(
self,
state: TicketState,
system_prompt: str,
priority_order: list[str]
) -> str:
"""
Builds evidence context that fits within token budget.
Prioritizes evidence by importance order.
"""
system_tokens = self.tokenizer.count(system_prompt)
remaining = self.available_budget() - system_tokens - 1000 # Reserve for output
context_parts = []
for source in priority_order:
content = self._get_content(state, source)
if content is None:
continue
content_tokens = self.tokenizer.count(content)
if content_tokens less than remaining:
context_parts.append(content)
remaining -= content_tokens
else:
# Summarize to fit
summarized = self._summarize_to_fit(content, remaining - 200)
context_parts.append(summarized)
remaining -= self.tokenizer.count(summarized)
break # Budget exhausted
return "\n\n---\n\n".join(context_parts)
Recommended Context Window Budget Allocation
Layer 4: Observability with OpenTelemetry
You cannot operate what you cannot observe. This is a law of production engineering, and AI agent systems are not exempt. In fact, they need more observability than traditional services because the execution path is non-deterministic โ you don't know in advance which tools will be called, how many reasoning cycles will occur, or why the agent chose one path over another.
The OpenTelemetry Integration
We instrument every agent node and every tool call with OTel spans. Critically, we include LLM-specific attributes that standard OTel doesn't cover out of the box.
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.trace import SpanKind
import json
# Initialize tracer
provider = TracerProvider()
exporter = OTLPSpanExporter(endpoint="http://otel-collector:4317")
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("operations-os", version="1.0.0")
def instrument_agent_node(node_name: str):
"""Decorator that wraps agent nodes with OTel instrumentation."""
def decorator(func):
async def wrapper(state: TicketState) -> TicketState:
with tracer.start_as_current_span(
f"agent.{node_name}",
kind=SpanKind.INTERNAL,
attributes={
# Standard attributes
"agent.name": node_name,
"ticket.id": state["ticket_id"],
"ticket.severity": state.get("severity", "unknown"),
# LLM-specific attributes (OpenTelemetry GenAI semantic conventions)
"gen_ai.system": "openai",
"gen_ai.request.model": MODEL_CONFIG[node_name],
"gen_ai.operation.name": "chat",
# Business context
"customer.id": state["raw_ticket"].get("customer_id"),
"pipeline.session_id": state["session_id"]
}
) as span:
start_time = time.monotonic()
try:
result_state = await func(state)
# Record LLM metrics
if "token_usage" in result_state:
usage = result_state["token_usage"].get(node_name, {})
span.set_attribute("gen_ai.usage.input_tokens", usage.get("input", 0))
span.set_attribute("gen_ai.usage.output_tokens", usage.get("output", 0))
span.set_attribute("gen_ai.usage.total_cost_usd", usage.get("cost", 0))
span.set_attribute("agent.exit_state", _determine_exit_state(result_state))
span.set_status(trace.StatusCode.OK)
return result_state
except Exception as e:
span.record_exception(e)
span.set_status(trace.StatusCode.ERROR, str(e))
# Add to error log in state
state["error_log"].append({
"node": node_name,
"error": str(e),
"span_id": span.get_span_context().span_id,
"timestamp": datetime.utcnow().isoformat()
})
raise
finally:
duration = time.monotonic() - start_time
span.set_attribute("agent.duration_ms", duration * 1000)
return wrapper
return decorator
Building Agent-Specific Dashboards
Standard APM dashboards are not sufficient for AI agent systems. You need dashboards that answer agent-specific questions:
- What percentage of tickets are resolved autonomously vs. escalated?
- What is the average number of reasoning cycles per ticket type?
- Which tool calls are failing most frequently and why?
- What is the P95 token cost per ticket?
- Where in the pipeline do most escalations originate?
| Week | Autonomous Resolution Rate | | ------ | -------------------------- | | Week 1 | 42% | | Week 2 | 51% | | Week 3 | 58% | | Week 4 | 63% | | Week 5 | 69% | | Week 6 | 74% |

