DEV Community

Phil IPS
Phil IPS

Posted on Edited on

Building Production AI Applications with LangGraph: State, Graphs & Deployment

Building Production AI Applications with LangGraph: State, Graphs & Deployment

Master LangGraph for building production AI applications — stateful graphs, persistence, streaming, human-in-the-loop, and deployment patterns.

Introduction

LangGraph transforms LangChain chains into stateful, cyclic graphs — enabling agents, multi-step workflows, and complex state management. This guide covers production patterns.

Why LangGraph?

LangChain (Chains) LangGraph (Graphs)
Linear, acyclic Cyclic, branching
Stateless Full state management
Single-threaded Concurrent execution
Hard to debug Visualizable, inspectable
Limited human-in-the-loop Built-in interruption

Part 1: Core Concepts

State & Graph Definition

# langgraph/core.py
from typing import TypedDict, Annotated, List, Optional
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage

class AgentState(TypedDict):
    messages: Annotated[List[BaseMessage], add_messages]
    user_id: str
    session_id: str
    context: dict
    current_step: str
    retry_count: int
    metadata: dict

def create_basic_graph():
    workflow = StateGraph(AgentState)

    # Add nodes
    workflow.add_node("process_input", process_input_node)
    workflow.add_node("reason", reason_node)
    workflow.add_node("act", act_node)
    workflow.add_node("respond", respond_node)

    # Define edges
    workflow.add_edge(START, "process_input")
    workflow.add_edge("process_input", "reason")
    workflow.add_conditional_edges("reason", route_after_reason, {
        "act": "act",
        "respond": "respond"
    })
    workflow.add_edge("act", "reason")
    workflow.add_edge("respond", END)

    return workflow.compile()

async def process_input_node(state: AgentState):
    # Preprocess, validate, enrich
    return {"current_step": "input_processed"}

async def reason_node(state: AgentState):
    # LLM reasoning
    return {"current_step": "reasoned"}

def route_after_reason(state: AgentState) -> str:
    # Decide next action
    return "act" if needs_action(state) else "respond"

async def act_node(state: AgentState):
    # Tool execution
    return {"current_step": "acted"}

async def respond_node(state: AgentState):
    # Generate final response
    return {"current_step": "responded"}
Enter fullscreen mode Exit fullscreen mode

Checkpointers (Persistence)

# langgraph/persistence.py
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.checkpoint.redis import RedisSaver
import sqlite3

# SQLite (development)
sqlite_conn = sqlite3.connect("checkpoints.db", check_same_thread=False)
sqlite_checkpointer = SqliteSaver(sqlite_conn)

# PostgreSQL (production)
postgres_checkpointer = PostgresSaver.from_conn_string(
    "postgresql://user:pass@localhost/langgraph"
)

# Redis (high-throughput)
redis_checkpointer = RedisSaver.from_conn_string(
    "redis://localhost:6379"
)

# Usage with checkpointer
app = workflow.compile(checkpointer=sqlite_checkpointer)

# Run with thread_id for persistence
config = {"configurable": {"thread_id": "user-123-session-456"}}
result = await app.ainvoke({"messages": [HumanMessage(content="Hello")]}, config)

# Resume later with same thread_id
result2 = await app.ainvoke({"messages": [HumanMessage(content="Continue")]}, config)
Enter fullscreen mode Exit fullscreen mode

Part 2: Advanced Graph Patterns

Conditional Routing

# langgraph/routing.py
from typing import Literal

def route_by_intent(state: AgentState) -> Literal["research", "code", "write", "error"]:
    last_msg = state["messages"][-1].content.lower()

    if any(kw in last_msg for kw in ["research", "find", "search", "lookup"]):
        return "research"
    elif any(kw in last_msg for kw in ["code", "implement", "build", "function"]):
        return "code"
    elif any(kw in last_msg for kw in ["write", "document", "explain", "summarize"]):
        return "write"
    return "error"

def route_by_confidence(state: AgentState) -> Literal["proceed", "clarify", "escalate"]:
    confidence = state.get("confidence", 1.0)

    if confidence > 0.8:
        return "proceed"
    elif confidence > 0.5:
        return "clarify"
    return "escalate"

# Multi-condition routing
def complex_router(state: AgentState) -> dict:
    """Return multiple routing decisions."""
    return {
        "next_node": "process",
        "parallel_nodes": ["validate", "enrich"],
        "skip_nodes": [] if state["retry_count"] < 3 else ["optional_step"]
    }
Enter fullscreen mode Exit fullscreen mode

Parallel Execution

# langgraph/parallel.py
from langgraph.graph import StateGraph
from typing: Annotated, List
import operator

class ParallelState(TypedDict):
    messages: Annotated[List[BaseMessage], add_messages]
    research_results: Annotated[List[dict], operator.add]  # Accumulate
    code_results: Annotated[List[dict], operator.add]
    write_results: Annotated[List[dict], operator.add]

async def research_node(state: ParallelState):
    # Run multiple searches in parallel
    import asyncio

    async def search(query):
        return {"query": query, "results": await web_search(query)}

    queries = extract_queries(state["messages"][-1].content)
    results = await asyncio.gather(*[search(q) for q in queries])

    return {"research_results": results}

async def code_node(state: ParallelState):
    # Parallel code generation for different components
    tasks = extract_code_tasks(state["messages"][-1].content)
    results = await asyncio.gather(*[generate_code(t) for t in tasks])
    return {"code_results": results}

# Fan-out/fan-in pattern
workflow = StateGraph(ParallelState)
workflow.add_node("research", research_node)
workflow.add_node("code", code_node)
workflow.add_node("synthesize", synthesize_node)

workflow.add_edge(START, "research")
workflow.add_edge(START, "code")  # Both start from START
workflow.add_edge("research", "synthesize")
workflow.add_edge("code", "synthesize")
workflow.add_edge("synthesize", END)
Enter fullscreen mode Exit fullscreen mode

Human-in-the-Loop

# langgraph/human_in_loop.py
from langgraph.types import interrupt, Command
from dataclasses import dataclass

@dataclass
class HumanReview:
    approved: bool
    feedback: str
    modifications: dict

async def human_review_node(state: AgentState):
    # Present to human for review
    review_request = {
        "task": state["messages"][-1].content,
        "proposed_action": state.get("proposed_action"),
        "context": state.get("context")
    }

    # This pauses execution and waits for human input
    human_response = interrupt(review_request)

    # Resume with human decision
    review = HumanReview(**human_response)

    if review.approved:
        return {"human_approved": True, "modifications": review.modifications}
    else:
        return {
            "human_approved": False,
            "feedback": review.feedback,
            "retry_count": state["retry_count"] + 1
        }

def route_after_review(state: AgentState) -> str:
    if state.get("human_approved"):
        return "execute"
    elif state["retry_count"] >= 3:
        return "escalate"
    return "revise"

# Usage
app = workflow.compile(checkpointer=checkpointer, interrupt_before=["human_review"])

# First invocation - pauses at human_review
result = await app.ainvoke(input_data, config)

# Check if interrupted
if "__interrupt__" in result:
    interrupt_info = result["__interrupt__"]
    # Present to human, get response
    human_decision = await get_human_decision(interrupt_info)

    # Resume with human input
    result = await app.ainvoke(Command(resume=human_decision), config)
Enter fullscreen mode Exit fullscreen mode

Subgraphs (Modularity)

# langgraph/subgraphs.py
from langgraph.graph import StateGraph

# Subgraph for research
research_graph = StateGraph(AgentState)
research_graph.add_node("search", search_node)
research_graph.add_node("summarize", summarize_node)
research_graph.add_edge(START, "search")
research_graph.add_edge("search", "summarize")
research_graph.add_edge("summarize", END)

research_app = research_graph.compile()

# Subgraph for code generation
code_graph = StateGraph(AgentState)
code_graph.add_node("plan", plan_code_node)
code_graph.add_node("implement", implement_node)
code_graph.add_node("test", test_node)
code_graph.add_edge(START, "plan")
code_graph.add_edge("plan", "implement")
code_graph.add_edge("implement", "test")
code_graph.add_edge("test", END)

code_app = code_graph.compile()

# Main graph using subgraphs
main_graph = StateGraph(AgentState)
main_graph.add_node("research", research_app)
main_graph.add_node("code", code_app)
main_graph.add_node("integrate", integrate_node)

main_graph.add_edge(START, "research")
main_graph.add_edge("research", "code")
main_graph.add_edge("code", "integrate")
main_graph.add_edge("integrate", END)

# Compile with shared checkpointer
app = main_graph.compile(checkpointer=checkpointer)
Enter fullscreen mode Exit fullscreen mode

Part 3: Streaming & Real-Time

Token Streaming

# langgraph/streaming.py
from langgraph.graph import StateGraph
from typing: AsyncGenerator

async def stream_graph(app, input_data: dict, config: dict) -> AsyncGenerator[dict, None]:
    """Stream graph execution events."""

    async for event in app.astream_events(input_data, config, version="v2"):
        event_type = event["event"]

        if event_type == "on_chain_start":
            yield {"type": "node_start", "node": event["name"]}
        elif event_type == "on_chain_end":
            yield {"type": "node_end", "node": event["name"], "output": event["data"]["output"]}
        elif event_type == "on_chat_model_stream":
            yield {"type": "token", "content": event["data"]["chunk"].content}
        elif event_type == "on_tool_start":
            yield {"type": "tool_start", "tool": event["name"], "input": event["data"]["input"]}
        elif event_type == "on_tool_end":
            yield {"type": "tool_end", "tool": event["name"], "output": event["data"]["output"]}
        elif event_type == "on_error":
            yield {"type": "error", "error": str(event["data"]["error"])}

# Usage in FastAPI
@app.post("/stream")
async def stream_endpoint(request: QueryRequest):
    async def event_generator():
        async for event in stream_graph(app, request.dict(), config):
            yield f"data: {json.dumps(event)}\n\n"

    return StreamingResponse(event_generator(), media_type="text/event-stream")
Enter fullscreen mode Exit fullscreen mode

Progress Tracking

# langgraph/progress.py
from dataclasses import dataclass
from typing: Dict
import asyncio

@dataclass
class ProgressState:
    current_step: str
    total_steps: int
    completed_steps: int
    step_details: Dict[str, any]

class ProgressTracker:
    def __init__(self):
        self.subscribers: Dict[str, asyncio.Queue] = {}

    async def track(self, thread_id: str, state: AgentState):
        if thread_id not in self.subscribers:
            self.subscribers[thread_id] = asyncio.Queue()

        progress = ProgressState(
            current_step=state.get("current_step", "unknown"),
            total_steps=state.get("total_steps", 10),
            completed_steps=state.get("completed_steps", 0),
            step_details=state.get("step_details", {})
        )

        await self.subscribers[thread_id].put(progress)

    async def subscribe(self, thread_id: str) -> AsyncGenerator[ProgressState, None]:
        queue = asyncio.Queue()
        self.subscribers[thread_id] = queue

        try:
            while True:
                progress = await queue.get()
                yield progress
        finally:
            del self.subscribers[thread_id]

# Inject into graph nodes
async def tracked_node(state: AgentState, tracker: ProgressTracker):
    result = await actual_node_logic(state)
    await tracker.track(state["configurable"]["thread_id"], result)
    return result
Enter fullscreen mode Exit fullscreen mode

Part 4: Error Handling & Resilience

Retry with Exponential Backoff

# langgraph/resilience.py
from langgraph.graph import StateGraph
from typing: Callable
import asyncio
import random

def with_retry(max_retries: int = 3, base_delay: float = 1.0):
    def decorator(func: Callable):
        async def wrapper(state: AgentState):
            for attempt in range(max_retries + 1):
                try:
                    return await func(state)
                except Exception as e:
                    if attempt == max_retries:
                        raise

                    delay = base_delay * (2 ** attempt) + random.uniform(0, 0.1)
                    await asyncio.sleep(delay)

                    # Update retry count
                    return {
                        **state,
                        "retry_count": state.get("retry_count", 0) + 1,
                        "last_error": str(e)
                    }
        return wrapper
    return decorator

@with_retry(max_retries=3)
async def unreliable_tool_node(state: AgentState):
    # Tool that might fail
    return await call_external_api(state["input"])

# Circuit breaker pattern
class CircuitBreaker:
    def __init__(self, failure_threshold: int = 5, timeout: float = 60):
        self.failure_threshold = failure_threshold
        self.timeout = timeout
        self.failures = 0
        self.last_failure = 0
        self.state = "closed"

    async def call(self, func, *args, **kwargs):
        if self.state == "open":
            if time.time() - self.last_failure > self.timeout:
                self.state = "half-open"
            else:
                raise Exception("Circuit breaker open")

        try:
            result = await func(*args, **kwargs)
            if self.state == "half-open":
                self.state = "closed"
                self.failures = 0
            return result
        except Exception as e:
            self.failures += 1
            self.last_failure = time.time()
            if self.failures >= self.failure_threshold:
                self.state = "open"
            raise
Enter fullscreen mode Exit fullscreen mode

Fallback Strategies

# langgraph/fallbacks.py
from typing: Optional

async def node_with_fallback(state: AgentState) -> dict:
    """Try primary, fallback to secondary on failure."""

    try:
        return await primary_llm_call(state)
    except Exception as e:
        # Log failure
        logger.warning(f"Primary failed: {e}, trying fallback")

        try:
            return await fallback_llm_call(state)
        except Exception as e2:
            # Final fallback: structured error response
            return {
                "error": "All models failed",
                "fallback_response": generate_cached_response(state["input"]),
                "details": {"primary": str(e), "fallback": str(e2)}
            }

# Model cascade
async def cascade_models(state: AgentState) -> dict:
    models = ["gpt-4o", "gpt-4o-mini", "claude-3-sonnet", "local-llama"]

    for model in models:
        try:
            result = await call_model(model, state["input"])
            return {"model_used": model, "result": result}
        except Exception as e:
            continue

    raise Exception("All models in cascade failed")
Enter fullscreen mode Exit fullscreen mode

Part 5: Production Deployment

FastAPI Server

# langgraph/server.py
from fastapi import FastAPI, HTTPException, BackgroundTasks
from pydantic import BaseModel
from typing: Optional, Dict, Any
import uuid

app = FastAPI(title="LangGraph Agent API")

class InvokeRequest(BaseModel):
    input: dict
    thread_id: Optional[str] = None
    config: Optional[Dict[str, Any]] = None

class StreamRequest(BaseModel):
    input: dict
    thread_id: Optional[str] = None

@app.post("/invoke")
async def invoke(request: InvokeRequest):
    thread_id = request.thread_id or str(uuid.uuid4())
    config = {"configurable": {"thread_id": thread_id}}
    if request.config:
        config.update(request.config)

    try:
        result = await app.state.graph.ainvoke(request.input, config)
        return {"thread_id": thread_id, "result": result}
    except Exception as e:
        raise HTTPException(500, str(e))

@app.post("/stream")
async def stream(request: StreamRequest):
    thread_id = request.thread_id or str(uuid.uuid4())
    config = {"configurable": {"thread_id": thread_id}}

    async def generate():
        async for event in app.state.graph.astream_events(
            request.input, config, version="v2"
        ):
            yield f"data: {json.dumps(event)}\n\n"

    return StreamingResponse(generate(), media_type="text/event-stream")

@app.get("/state/{thread_id}")
async def get_state(thread_id: str):
    config = {"configurable": {"thread_id": thread_id}}
    state = await app.state.graph.aget_state(config)
    return state.values if state else {}

@app.post("/resume/{thread_id}")
async def resume(thread_id: str, human_input: dict):
    config = {"configurable": {"thread_id": thread_id}}
    result = await app.state.graph.ainvoke(Command(resume=human_input), config)
    return {"result": result}

@app.get("/health")
async def health():
    return {"status": "healthy", "graph": app.state.graph.name}

# Initialize on startup
@app.on_event("startup")
async def startup():
    app.state.graph = create_production_graph()
Enter fullscreen mode Exit fullscreen mode

Docker Deployment

# Dockerfile
FROM python:3.11-slim

WORKDIR /app

# System deps
RUN apt-get update && apt-get install -y \
    gcc postgresql-client \
    && rm -rf /var/lib/apt/lists/*

# Python deps
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# App
COPY . .

# Non-root
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
    CMD curl -f http://localhost:8000/health || exit 1

CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
Enter fullscreen mode Exit fullscreen mode

Kubernetes Deployment

# k8s/langgraph-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: langgraph-agent
spec:
  replicas: 3
  selector:
    matchLabels:
      app: langgraph-agent
  template:
    metadata:
      labels:
        app: langgraph-agent
    spec:
      containers:
      - name: agent
        image: your-registry/langgraph-agent:v1.0
        ports:
        - containerPort: 8000
        env:
        - name: DATABASE_URL
          valueFrom:
            secretKeyRef:
              name: langgraph-secrets
              key: database-url
        - name: REDIS_URL
          valueFrom:
            secretKeyRef:
              name: langgraph-secrets
              key: redis-url
        - name: OPENAI_API_KEY
          valueFrom:
            secretKeyRef:
              name: langgraph-secrets
              key: openai-key
        resources:
          requests:
            memory: "1Gi"
            cpu: "500m"
          limits:
            memory: "2Gi"
            cpu: "1000m"
        livenessProbe:
          httpGet:
            path: /health
            port: 8000
          initialDelaySeconds: 30
        readinessProbe:
          httpGet:
            path: /health
            port: 8000
          initialDelaySeconds: 10
---
apiVersion: v1
kind: Service
metadata:
  name: langgraph-agent
spec:
  selector:
    app: langgraph-agent
  ports:
  - port: 80
    targetPort: 8000
  type: ClusterIP
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: langgraph-agent-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: langgraph-agent
  minReplicas: 3
  maxReplicas: 20
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
Enter fullscreen mode Exit fullscreen mode

Part 6: Testing & Evaluation

Graph Testing

# langgraph/testing.py
import pytest
from langgraph.graph import StateGraph

class GraphTestHarness:
    def __init__(self, graph_fn):
        self.graph_fn = graph_fn

    def create_test_app(self, checkpointer=None):
        return self.graph_fn().compile(checkpointer=checkpointer)

    async def run_test(self, input_data: dict, expected: dict = None):
        app = self.create_test_app()
        config = {"configurable": {"thread_id": "test-" + str(uuid.uuid4())}}

        result = await app.ainvoke(input_data, config)

        if expected:
            assert self._match_result(result, expected), f"Expected {expected}, got {result}"

        return result

    def _match_result(self, actual: dict, expected: dict) -> bool:
        for key, value in expected.items():
            if key not in actual:
                return False
            if isinstance(value, dict):
                if not self._match_result(actual[key], value):
                    return False
            elif actual[key] != value:
                return False
        return True

# Test fixtures
@pytest.fixture
def test_graph():
    return create_test_graph()

@pytest.mark.asyncio
async def test_basic_flow(test_graph):
    harness = GraphTestHarness(lambda: test_graph)
    result = await harness.run_test(
        {"messages": [HumanMessage(content="Hello")]},
        {"current_step": "responded"}
    )
    assert "messages" in result
Enter fullscreen mode Exit fullscreen mode

Property-Based Testing

# langgraph/property_testing.py
from hypothesis import given, strategies as st
import pytest

@given(st.text(min_size=1, max_size=1000))
async def test_input_sanitization(input_text):
    """Graph should handle any input without crashing."""
    app = create_test_graph().compile()
    config = {"configurable": {"thread_id": "property-test"}}

    result = await app.ainvoke({"messages": [HumanMessage(content=input_text)]}, config)

    # Should always produce valid state
    assert "messages" in result
    assert isinstance(result["messages"], list)

@given(st.integers(min_value=0, max_value=10))
async def test_retry_behavior(retry_count):
    """Graph should respect retry limits."""
    state = {"retry_count": retry_count, "messages": []}
    result = await retry_limited_node(state)

    if retry_count >= 3:
        assert result.get("escalated") == True
    else:
        assert result.get("retry_count") == retry_count + 1
Enter fullscreen mode Exit fullscreen mode

Part 7: Observability

LangSmith Integration

# langgraph/observability.py
from langsmith import traceable
from langsmith.run_helpers import get_current_run_tree

@traceable(name="agent_reasoning")
async def reason_node(state: AgentState):
    run_tree = get_current_run_tree()
    if run_tree:
        run_tree.add_metadata({"user_id": state.get("user_id")})

    # Your logic here
    return result

# Custom callbacks
from langgraph.callbacks import BaseCallbackHandler

class ObservabilityCallback(BaseCallbackHandler):
    def on_node_start(self, node_name: str, state: dict):
        # Send to monitoring
        pass

    def on_node_end(self, node_name: str, output: dict):
        # Record metrics
        pass

    def on_error(self, node_name: str, error: Exception):
        # Alert
        pass

app = workflow.compile(
    checkpointer=checkpointer,
    callbacks=[ObservabilityCallback()]
)
Enter fullscreen mode Exit fullscreen mode

Metrics Collection

# langgraph/metrics.py
from dataclasses import dataclass
from datetime import datetime
import time

@dataclass
class NodeMetrics:
    node_name: str
    start_time: float
    end_time: float
    duration_ms: float
    input_tokens: int
    output_tokens: int
    success: bool
    error: str = ""

class MetricsCollector:
    def __init__(self):
        self.metrics: List[NodeMetrics] = []

    def record(self, metric: NodeMetrics):
        self.metrics.append(metric)

    def get_node_stats(self, node_name: str) -> dict:
        node_metrics = [m for m in self.metrics if m.node_name == node_name]
        if not node_metrics:
            return {}

        durations = [m.duration_ms for m in node_metrics]
        return {
            "count": len(node_metrics),
            "avg_duration_ms": sum(durations) / len(durations),
            "p95_duration_ms": sorted(durations)[int(len(durations) * 0.95)],
            "success_rate": sum(1 for m in node_metrics if m.success) / len(node_metrics),
            "total_tokens": sum(m.input_tokens + m.output_tokens for m in node_metrics)
        }

    def get_graph_summary(self) -> dict:
        return {
            "total_nodes": len(set(m.node_name for m in self.metrics)),
            "total_executions": len(self.metrics),
            "overall_success_rate": sum(1 for m in self.metrics if m.success) / len(self.metrics),
            "by_node": {n: self.get_node_stats(n) for n in set(m.node_name for m in self.metrics)}
        }
Enter fullscreen mode Exit fullscreen mode

Decision Framework

LANGGRAPH PATTERN SELECTION:

Simple linear workflow?
├─→ Yes → Basic StateGraph with linear edges
└─→ No
    ├─→ Need cycles/loops?
    │   ├─→ Yes → Conditional edges with retry logic
    │   └─→ No
    │       ├─→ Parallel independent tasks?
    │       │   └─→ Yes → Fan-out/fan-in with Annotated accumulation
    │       │
    │       ├─→ Human review needed?
    │       │   └─→ Yes → interrupt() + Command(resume=...)
    │       │
    │       ├─→ Complex sub-workflows?
    │       │   └─→ Yes → Subgraphs (compile separately, compose)
    │       │
    │       └─→ Production deployment?
    │           └─→ Yes → Checkpointer + Streaming + Metrics + Health checks
Enter fullscreen mode Exit fullscreen mode

Production Checklist

Graph Design

  • [ ] State schema defined with TypedDict
  • [ ] All nodes are async for concurrency
  • [ ] Conditional edges cover all paths
  • [ ] No unreachable nodes
  • [ ] Retry limits on all external calls
  • [ ] Circuit breakers for external APIs

Persistence

  • [ ] Checkpointer configured (Postgres/Redis for prod)
  • [ ] Thread ID strategy defined (user/session)
  • [ ] State migration plan for schema changes
  • [ ] Checkpoint TTL/cleanup policy

Reliability

  • [ ] Timeout on all node executions
  • [ ] Fallback models for LLM calls
  • [ ] Graceful degradation on tool failures
  • [ ] Idempotent operations where possible
  • [ ] Dead letter queue for failed executions

Observability

  • [ ] LangSmith or custom tracing
  • [ ] Node-level metrics (latency, tokens, errors)
  • [ ] Business metrics (task completion, user satisfaction)
  • [ ] Alerts on error rates, latency spikes
  • [ ] Distributed tracing across services

Deployment

  • [ ] Health endpoint returns graph status
  • [ ] Graceful shutdown (finish in-flight)
  • [ ] Horizontal scaling with sticky sessions (thread_id)
  • [ ] Blue-green deployment support
  • [ ] Rollback procedure tested

Testing

  • [ ] Unit tests for each node
  • [ ] Integration tests for full flows
  • [ ] Property-based tests for edge cases
  • [ ] Chaos testing (node failures, timeouts)
  • [ ] Load testing with realistic traffic

Resources


LangGraph makes complex AI workflows manageable. Start simple, add persistence early, instrument everything, and test failure paths as thoroughly as success paths.


📦 Want the production-ready version?

This article covers the fundamentals. The Multi-Agent Orchestration Patterns pack goes further — multi-agent architectures, communication and coordination patterns, plus full source code and deployment configs.

→ Get Multi-Agent Orchestration Patterns

Extracted from real production systems, not toy examples.

Top comments (0)