Agent Ops: orchestrate, monitor, scale and guard AI agents

A demo agent works on a laptop. A production agent copes with crashed pods, rate limits, poisoned inputs, $40k token bills and a 2 a.m. page. This track covers what changes between the two, with runnable code and the interview questions that test it.

Every code sample runsThe snippets live in examples/agent-ops with a test that executes them (pytest test_snippets.py). Demos that need a model use a scripted fake (_fake.py) so they run offline; swap in init_chat_model("provider:model") for real use.

Orchestration

Orchestration is deciding who controls the next step: your code, one LLM, or several LLMs coordinating. Most production systems mix all three.

Patterns at a glance

PatternControl flowReal-world fitWatch out for
WorkflowCode decides every stepInvoice extraction, nightly reportsBreaks on inputs you didn't anticipate
Single tool-calling agentLLM loops over toolsSupport desk with 5–10 toolsTool confusion as the toolset grows
RouterLLM classifies once, code dispatchesTriage to billing / tech / salesMisroutes need a "back to router" path
SupervisorCoordinator LLM delegates to specialistsResearch + writing + reviewExtra hop per step; supervisor bottleneck
Handoffs / swarmAgents pass control directlySales → onboarding → supportHard to trace; context lost at handoff
Plan-and-executePlanner writes steps, executors run themMulti-step data analysis, migrationsStale plans; need re-planning
Event-driven / backgroundQueue or schedule triggers agent runsTicket triage on arrival, PR review botsIdempotency, back-pressure, dead letters

Deterministic shell, agentic core

The most reliable production shape: plain code owns the workflow (load data, enforce policy, persist results) and the LLM only makes decisions inside one bounded step. You get the flexibility of an agent where you need it and the predictability of code everywhere else.

"""Deterministic shell, agentic core: code owns the workflow, the LLM only decides inside one step."""
from typing import TypedDict

from langchain.agents import create_agent
from langchain_core.messages import AIMessage
from langchain_core.tools import tool
from langgraph.graph import END, START, StateGraph

from _fake import call, script


@tool
def lookup_order(order_id: str) -> str:
    """Look up an order by id."""
    return f"{order_id}: delivered 3 days ago"


# The agentic core: a standard tool-calling agent (a compiled subgraph).
agent = create_agent(
    script(call("lookup_order", {"order_id": "A1001"}), AIMessage("Your order A1001 was delivered.")),
    tools=[lookup_order],
    system_prompt="You are a support agent. Use tools; never guess.",
)


class Ticket(TypedDict):
    customer_id: str
    question: str
    tier: str
    reply: str
    escalate: bool


def load_customer(t: Ticket):                       # deterministic: plain code, no LLM
    return {"tier": "gold" if t["customer_id"].startswith("G") else "standard"}


def run_agent(t: Ticket):                           # agentic: the LLM picks tools and order
    prompt = f"[customer tier: {t['tier']}] {t['question']}"
    out = agent.invoke({"messages": [("user", prompt)]})
    return {"reply": out["messages"][-1].text}


def policy_check(t: Ticket):                        # deterministic gate before anything is sent
    risky = any(w in t["reply"].lower() for w in ("refund approved", "guarantee"))
    return {"escalate": risky}


g = StateGraph(Ticket)
g.add_node("load_customer", load_customer)
g.add_node("agent", run_agent)
g.add_node("policy_check", policy_check)
g.add_edge(START, "load_customer")
g.add_edge("load_customer", "agent")
g.add_edge("agent", "policy_check")
g.add_edge("policy_check", END)
workflow = g.compile()

if __name__ == "__main__":
    out = workflow.invoke({"customer_id": "G-42", "question": "Where is A1001?",
                           "tier": "", "reply": "", "escalate": False})
    print(out["tier"], "|", out["reply"], "| escalate:", out["escalate"])
    # gold | Your order A1001 was delivered. | escalate: False

Choosing a pattern

  1. Can you write the steps down in advance? → workflow. Stop here.
  2. One domain, ≤ ~15 tools? → single agent inside a deterministic shell.
  3. Distinct domains with different tools or permissions? → router to specialised agents.
  4. Tasks need several specialists to collaborate on one output? → supervisor.
  5. Takes minutes or hours, or is triggered by events? → run it in the background (queue + workers) whatever the pattern.
Interview angleInterviewers reward the candidate who argues for less autonomy. "I'd start with a router and two agents, and only add a supervisor if evals show the agents need to collaborate" is a senior answer.

Durable execution

Pods get rescheduled, deploys roll, providers time out. With a checkpointer, LangGraph saves state after every step, so a failed run resumes where it stopped instead of starting over. That matters for side effects: you must not charge a card twice because the email step after it failed.

"""Crash in the middle of a run, then resume from the last checkpoint without redoing finished steps."""
from typing import TypedDict

from langgraph.checkpoint.memory import InMemorySaver   # PostgresSaver in production
from langgraph.graph import END, START, StateGraph

calls = {"charge_card": 0, "send_receipt": 0}
receipt_service_up = False


class Order(TypedDict):
    order_id: str
    charged: bool
    receipt_sent: bool


def charge_card(s: Order):
    calls["charge_card"] += 1
    return {"charged": True}


def send_receipt(s: Order):
    calls["send_receipt"] += 1
    if not receipt_service_up:
        raise ConnectionError("email service down")      # simulated outage / pod crash
    return {"receipt_sent": True}


g = StateGraph(Order)
g.add_node("charge_card", charge_card)
g.add_node("send_receipt", send_receipt)
g.add_edge(START, "charge_card")
g.add_edge("charge_card", "send_receipt")
g.add_edge("send_receipt", END)
app = g.compile(checkpointer=InMemorySaver())

if __name__ == "__main__":
    cfg = {"configurable": {"thread_id": "order-7"}}
    try:
        # durability="sync": each checkpoint is written before the next step starts
        app.invoke({"order_id": "A7", "charged": False, "receipt_sent": False}, cfg, durability="sync")
    except ConnectionError as e:
        print("run failed:", e, "| next step:", app.get_state(cfg).next)   # ('send_receipt',)

    receipt_service_up = True
    out = app.invoke(None, cfg)          # None = resume this thread from its last checkpoint
    print(out, calls)
    # {'order_id': 'A7', 'charged': True, 'receipt_sent': True} {'charge_card': 1, 'send_receipt': 2}
    # charge_card ran ONCE: the customer was not charged twice.

State & monitoring

Designing agent state

Inspecting and fixing live threads

Because every step is checkpointed, an operator can see exactly where a conversation is, why it's waiting, and repair it — without a redeploy.

"""Operate on live threads: inspect, read history, fix bad state, resume."""
from typing import TypedDict

from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt


class Refund(TypedDict):
    order_id: str
    amount: float
    status: str


def propose(s: Refund):
    return {"amount": 1200.0, "status": "proposed"}          # bug: the agent proposed too much


def approve(s: Refund):
    ok = interrupt({"order_id": s["order_id"], "amount": s["amount"]})
    return {"status": "refunded" if ok else "denied"}


g = StateGraph(Refund)
g.add_node("propose", propose)
g.add_node("approve", approve)
g.add_edge(START, "propose")
g.add_edge("propose", "approve")
g.add_edge("approve", END)
app = g.compile(checkpointer=InMemorySaver())

if __name__ == "__main__":
    cfg = {"configurable": {"thread_id": "refund-99"}}
    app.invoke({"order_id": "A9", "amount": 0, "status": "new"}, cfg)

    snap = app.get_state(cfg)
    print("waiting at:", snap.next, "| interrupt:", snap.interrupts[0].value)
    print("values:", snap.values)
    print("history:", [(h.metadata["step"], h.next) for h in app.get_state_history(cfg)])

    # An operator corrects the amount, as if the 'propose' node had written it
    app.update_state(cfg, {"amount": 120.0}, as_node="propose")
    out = app.invoke(Command(resume=True), cfg)
    print("final:", out)   # {'order_id': 'A9', 'amount': 120.0, 'status': 'refunded'}

Build a small internal admin page on these three calls (get_state, get_state_history, update_state). It turns "the agent is stuck for customer X" from a code investigation into a two-minute fix.

What to monitor

SignalMetricWhy it matters
OutcomeTask success / resolution rate, escalation rate, thumbs-down rateThe only metric the business cares about
Latencyp50/p95 time to first token, total run time, per-node durationUsers abandon slow chats; finds the slow node
CostTokens and $ per run, per user, per feature; cache-hit rateRunaway loops show up here first
BehaviourSteps per run, tool calls per run, tool error rate, recursion-limit hitsDrift after a prompt or model change
SafetyGuardrail blocks, PII detections, approval requests and denialsAttacks and false positives both spike here
HealthProvider 429/5xx rate, retries, queue depth, interrupted threads waiting > N hoursCapacity and stuck-work alarms

Instrumentation

Tracing tools (LangSmith, Langfuse, Arize Phoenix, OpenTelemetry) give you the full run tree. You still want structured logs and metrics in your own stack for alerting. A small wrapper around each node is enough:

"""Structured per-node telemetry: one JSON log line per node with thread, duration and outcome."""
import json
import logging
import time
from typing import TypedDict

from langchain_core.runnables import RunnableConfig
from langgraph.graph import END, START, StateGraph

logging.basicConfig(level=logging.INFO, format="%(message)s")
log = logging.getLogger("agent")


def instrumented(name, fn):
    # Not functools.wraps: LangGraph reads the signature to decide whether to inject config
    def wrapper(state, config: RunnableConfig):
        start = time.perf_counter()
        status = "ok"
        try:
            return fn(state)
        except Exception as e:
            status = f"error:{type(e).__name__}"
            raise
        finally:
            log.info(json.dumps({
                "event": "node", "node": name, "status": status,
                "thread_id": config["configurable"].get("thread_id"),
                "duration_ms": round((time.perf_counter() - start) * 1000, 1),
            }))
    return wrapper


class S(TypedDict):
    q: str
    a: str


def retrieve(s: S): time.sleep(0.02); return {"a": "3 docs"}
def answer(s: S):   return {"a": s["a"] + " -> answer"}


g = StateGraph(S)
for name, fn in [("retrieve", retrieve), ("answer", answer)]:
    g.add_node(name, instrumented(name, fn))
g.add_edge(START, "retrieve")
g.add_edge("retrieve", "answer")
g.add_edge("answer", END)
app = g.compile()

if __name__ == "__main__":
    app.invoke({"q": "refund policy?", "a": ""}, {"configurable": {"thread_id": "t-1"}})
    # {"event": "node", "node": "retrieve", "status": "ok", "thread_id": "t-1", "duration_ms": 20.3}
    # {"event": "node", "node": "answer", "status": "ok", "thread_id": "t-1", "duration_ms": 0.0}

# Token usage for cost dashboards (works with any LangChain chat model):
#   from langchain_core.callbacks import get_usage_metadata_callback
#   with get_usage_metadata_callback() as cb:
#       graph.invoke(inputs, cfg)
#   print(cb.usage_metadata)   # {'claude-sonnet-5-5': {'input_tokens': ..., 'output_tokens': ...}}

Alerts & dashboards

Scaling

Where agents actually bottleneck

Agent servers spend most of their time waiting on the LLM. CPU is rarely the limit. The real constraints, in order:

  1. Provider rate limits: requests per minute and tokens per minute per model, often per region.
  2. Cost: every extra loop iteration multiplies tokens.
  3. Database connections: every replica × every concurrent run wants a checkpoint connection.
  4. Long-lived connections: SSE streams hold a request open for the whole run, so load balancer idle timeouts and per-instance concurrency limits matter.

Capacity math (worked example)

Practise this one; it comes up in system-design rounds.

AssumptionValue
Peak traffic100 conversations started per minute
LLM calls per run5 (triage + ~3 agent loops + final answer)
Tokens per call4,000 input (of which 3,000 is a cacheable system prompt + tools), 400 output
Run duration20 seconds

Conclusion: compute scaling is easy. The plan is about quota, cost and back-pressure.

Scale-out setup

Stateless replicas behind a load balancer, a shared Postgres checkpointer through a connection pool, a client-side rate limiter so replicas don't stampede the provider, and a cap on concurrent runs per replica. This exact setup was tested with 100 concurrent conversations through a 10-connection pool.

"""Scale-out setup: shared connection pool, client-side rate limiting, bounded concurrency."""
import asyncio
import os

from langchain.chat_models import init_chat_model
from langchain_core.rate_limiters import InMemoryRateLimiter
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from psycopg_pool import AsyncConnectionPool

# 1) Rate-limit LLM calls per replica (keep replicas x rate under your provider quota)
limiter = InMemoryRateLimiter(requests_per_second=8, max_bucket_size=16)
llm = init_chat_model("anthropic:claude-sonnet-5-5", rate_limiter=limiter, timeout=60)

# 2) Bound concurrent runs per replica so a spike queues instead of failing
RUN_SLOTS = asyncio.Semaphore(int(os.getenv("MAX_CONCURRENT_RUNS", "40")))


async def main(build_graph):
    # 3) One pool per process, shared by all requests (not a connection per request)
    async with AsyncConnectionPool(
        os.environ["DATABASE_URL"], min_size=2, max_size=10,
        kwargs={"autocommit": True, "prepare_threshold": 0},   # required by the Postgres saver
    ) as pool:
        saver = AsyncPostgresSaver(pool)
        await saver.setup()           # better: run once from a migration job
        graph = build_graph(saver)

        async def handle(thread_id: str, message: str):
            async with RUN_SLOTS:
                cfg = {"configurable": {"thread_id": thread_id}, "recursion_limit": 25}
                return await graph.ainvoke({"messages": [("user", message)]}, cfg)

        return await asyncio.gather(*(handle(f"t{i}", "hi") for i in range(100)))

Long-running work: queues & workers

Anything that takes longer than a comfortable HTTP request (research reports, bulk document processing, multi-hour approvals) should be enqueued. The API returns a run id immediately; workers execute graphs; clients poll or get a webhook. Using the run id as thread_id means a crashed worker's run can be resumed from its checkpoint.

"""Long-running agents: API enqueues, workers run graphs, clients poll status."""
import asyncio
import uuid
from typing import TypedDict

from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph


class Job(TypedDict):
    topic: str
    report: str


async def research(s: Job):
    await asyncio.sleep(0.05)                      # stands in for minutes of tool calls
    return {"report": f"report on {s['topic']}"}

g = StateGraph(Job)
g.add_node("research", research)
g.add_edge(START, "research")
g.add_edge("research", END)
graph = g.compile(checkpointer=InMemorySaver())

queue: asyncio.Queue = asyncio.Queue()             # SQS / Pub/Sub / Service Bus in production
status: dict[str, str] = {}                         # a DB table in production


async def submit(topic: str) -> str:               # what POST /runs does
    run_id = str(uuid.uuid4())[:8]
    status[run_id] = "queued"
    await queue.put((run_id, topic))
    return run_id


async def worker(n: int):
    while True:
        run_id, topic = await queue.get()
        status[run_id] = "running"
        try:
            cfg = {"configurable": {"thread_id": run_id}}   # thread = run: resumable after a crash
            await graph.ainvoke({"topic": topic, "report": ""}, cfg)
            status[run_id] = "done"
        except Exception:
            status[run_id] = "failed"                       # alert + dead-letter for a human
        finally:
            queue.task_done()


async def main():
    workers = [asyncio.create_task(worker(i)) for i in range(3)]   # scale workers on queue depth
    ids = [await submit(t) for t in ("pricing", "churn", "nps", "support load")]
    await queue.join()
    print({i: status[i] for i in ids})
    for w in workers:
        w.cancel()

if __name__ == "__main__":
    asyncio.run(main())   # {'1a2b3c4d': 'done', ...}

Failures & recovery

Failure taxonomy

FailureHow you noticeMitigation
Provider 429 / 5xx / timeoutError-rate metric, retriesBackoff with jitter, rate limiter, fallback model/region
Tool error (API down, bad input)Tool error rateReturn the error to the model as text; retry transient errors; fallback path
Hallucinated tool or argumentsValidation failuresSchema validation, enums, clear descriptions, fewer tools
Infinite loop / no progressRecursion-limit hits, steps per runrecursion_limit, call limits, repeated-call detection, escalate
Context overflowToken-count errors, rising cost per runTrimming, summarisation, storing large outputs outside state
Malformed structured outputParse errorsNative structured output, validate, one repair retry
Crash mid-runRuns stuck "running"Checkpointer + resume; idempotent nodes
Partial side effectsInconsistent recordsIdempotency keys, saga compensation
Bad but confident answerThumbs-down, evals, samplingGrounding checks, citations, output guardrails, eval gates on release
Silent regression after model/prompt changeEval scores, behaviour metricsPinned model versions, offline evals in CI, canary releases

Retries vs. fallbacks

Retry what might succeed next time (rate limits, timeouts). Route around what never will (404, validation errors). Retrying a permanent error just burns time and money.

"""Transient vs. permanent failures: retry the first, route the second to a fallback."""
from typing import Literal, TypedDict

from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, RetryPolicy

attempts = {"crm": 0}


class TransientError(Exception): ...   # 429, 503, timeouts
class PermanentError(Exception): ...   # 400, 404, validation errors


class S(TypedDict):
    customer_id: str
    profile: str
    reply: str


def fetch_profile(s: S) -> Command[Literal["answer", "fallback"]]:
    attempts["crm"] += 1
    if attempts["crm"] < 3:
        raise TransientError("CRM returned 503")            # retried by RetryPolicy
    if s["customer_id"] == "unknown":
        # don't retry what can't succeed: route around it instead
        return Command(goto="fallback", update={"profile": ""})
    return Command(goto="answer", update={"profile": "gold, 3 open orders"})


def answer(s: S):   return {"reply": f"Personalised reply using: {s['profile']}"}
def fallback(s: S): return {"reply": "Generic reply + ticket created for follow-up"}


g = StateGraph(S)
g.add_node("fetch_profile", fetch_profile,
           retry_policy=RetryPolicy(max_attempts=4, initial_interval=0.05, retry_on=TransientError))
g.add_node("answer", answer)
g.add_node("fallback", fallback)
g.add_edge(START, "fetch_profile")
g.add_edge("answer", END)
g.add_edge("fallback", END)
app = g.compile()

if __name__ == "__main__":
    print(app.invoke({"customer_id": "C1", "profile": "", "reply": ""})["reply"], "| attempts:", attempts["crm"])
    # Personalised reply using: gold, 3 open orders | attempts: 3

Model calls get the same treatment: llm.with_retry(...) for transient errors and llm.with_fallbacks([backup]) or ModelFallbackMiddleware to switch provider.

Side effects & compensation

When one run makes several real-world changes, a failure halfway through leaves things inconsistent. The saga pattern pairs every action with an undo and runs the undos in reverse on failure. Combine it with idempotency keys so retries and resumes are safe.

"""Saga pattern: multi-step side effects that undo themselves if a later step fails."""
log: list[str] = []


def reserve_stock(order):  log.append("stock reserved")
def release_stock(order):  log.append("stock released")
def charge_card(order):    log.append("card charged")
def refund_card(order):    log.append("card refunded")
def book_courier(order):
    raise RuntimeError("courier API rejected the address")


STEPS = [                                   # (action, compensation)
    (reserve_stock, release_stock),
    (charge_card, refund_card),
    (book_courier, None),
]


def run_saga(order) -> bool:
    done = []
    for action, undo in STEPS:
        try:
            action(order)
            done.append(undo)
        except Exception as e:
            log.append(f"FAILED: {e}")
            for compensate in reversed(done):          # undo in reverse order
                if compensate:
                    compensate(order)
            return False
    return True


if __name__ == "__main__":
    print(run_saga({"id": "A1"}), log)
    # False ['stock reserved', 'card charged', 'FAILED: courier API rejected the address',
    #        'card refunded', 'stock released']

Incident playbook

  1. Contain: feature-flag the agent off or route to the fallback (ticket / human queue). Have this switch before you need it.
  2. Find the step: open failing traces; is it retrieval, routing, a tool, or the model?
  3. Check what changed: prompt version, model version, tool API, data source, traffic mix.
  4. Repair threads: resume, update or close affected threads with the state APIs.
  5. Prevent: add the failing cases to the eval set, fix the root cause, add a monitor that would have caught it sooner.

Guardrails

Layered defence

InputPII · injection · size Modelprompt · call limits Toolspolicy · approval Outputgrounding · PII · tone Monitoraudit · alerts · evals No single layer is enough: prompts can be bypassed, so permissions and approvals are enforced in code.
LayerGuardrailReal-world example
InputSize limits, PII redaction, injection heuristics/classifiers, topic filtersMask card numbers before they reach the model or the logs
ModelSystem-prompt rules, untrusted-content delimiters, model/tool call limitsMax 10 model calls per run; web content wrapped as data
ToolsLeast-privilege credentials, role-based allow-lists, approval thresholds, idempotencyRefunds over $100 need a supervisor; the agent's DB user is read-only
OutputGrounding/citation checks, PII scan, forbidden promises, schema validationBlock "we guarantee" from a support bot; require a cited source
MonitorAudit log of every tool call, block-rate alerts, red-team evalsWeekly report of denied tool calls by user

Built-in middleware

LangChain 1.x agents accept middleware that wraps model and tool calls, with ready-made guardrails. This demo redacts emails, blocks card numbers, caps model calls, allows at most one refund per run, and pauses refunds for approval:

"""Layered guardrails on a LangChain 1.x agent using built-in middleware."""
from langchain.agents import create_agent
from langchain.agents.middleware import (
    HumanInTheLoopMiddleware,
    ModelCallLimitMiddleware,
    PIIMiddleware,
    ToolCallLimitMiddleware,
)
from langchain_core.messages import AIMessage
from langchain_core.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command

from _fake import call, script


@tool
def lookup_order(order_id: str) -> str:
    """Look up an order."""
    return f"{order_id}: delivered"


@tool
def issue_refund(order_id: str, amount: float) -> str:
    """Refund an order."""
    return f"refunded {amount} for {order_id}"


agent = create_agent(
    script(call("issue_refund", {"order_id": "A1", "amount": 80.0}), AIMessage("Refund done.")),
    tools=[lookup_order, issue_refund],
    middleware=[
        PIIMiddleware("email", strategy="redact"),                   # input: mask emails
        PIIMiddleware("credit_card", strategy="block"),              # input: refuse card numbers
        ModelCallLimitMiddleware(run_limit=10),                      # budget: stop runaway loops
        ToolCallLimitMiddleware(tool_name="issue_refund", run_limit=1),  # at most one refund per run
        HumanInTheLoopMiddleware(interrupt_on={"issue_refund": True, "lookup_order": False}),
    ],
    checkpointer=InMemorySaver(),       # needed to pause for approval
)

if __name__ == "__main__":
    cfg = {"configurable": {"thread_id": "g-1"}}
    out = agent.invoke({"messages": [("user", "Refund A1, my email is priya@example.com")]}, cfg)
    print("user message stored as:", out["messages"][0].content)
    print("paused for approval:", out["__interrupt__"][0].value["action_requests"][0]["name"])

    out = agent.invoke(Command(resume={"decisions": [{"type": "approve"}]}), cfg)
    print("after approval:", out["messages"][-2].content, "|", out["messages"][-1].content)

Running it prints the stored message as Refund A1, my email is [REDACTED_EMAIL], pauses on issue_refund, and only executes the refund after the approve decision. Other built-ins worth knowing: ModelRetryMiddleware, ToolRetryMiddleware, ModelFallbackMiddleware, SummarizationMiddleware and LLMToolSelectorMiddleware.

Tool policy as data

Hard-coding permissions inside each tool doesn't scale. Keep one default-deny policy table that security can review, and enforce it where tool calls are routed (allow → run, approve → interrupt(), deny → error message back to the model).

"""Policy as data: which roles may use which tools, and when a human must approve."""
from dataclasses import dataclass
from typing import Callable


@dataclass(frozen=True)
class Rule:
    roles: frozenset[str]
    approve_if: Callable[[dict], bool] | None = None    # predicate over the tool args


POLICY = {
    "lookup_order":  Rule(frozenset({"customer", "agent", "supervisor"})),
    "issue_refund":  Rule(frozenset({"agent", "supervisor"}), approve_if=lambda a: a["amount"] > 100),
    "delete_account": Rule(frozenset({"supervisor"}), approve_if=lambda a: True),
}


def decide(tool: str, args: dict, role: str) -> str:
    """Return 'allow', 'approve' or 'deny'. Unknown tools are denied (default-deny)."""
    rule = POLICY.get(tool)
    if rule is None or role not in rule.roles:
        return "deny"
    if rule.approve_if and rule.approve_if(args):
        return "approve"
    return "allow"


if __name__ == "__main__":
    for t, a, r in [("lookup_order", {"order_id": "A1"}, "customer"),
                    ("issue_refund", {"amount": 40}, "agent"),
                    ("issue_refund", {"amount": 400}, "agent"),
                    ("issue_refund", {"amount": 40}, "customer"),
                    ("run_shell", {}, "supervisor")]:
        print(f"{t:14} {str(a):18} {r:10} -> {decide(t, a, r)}")
    # The router sends 'approve' calls to an interrupt() node and turns 'deny' into a ToolMessage error.

Output checks

Check the reply before the user sees it. Cheap deterministic checks catch a lot; add an LLM judge for groundedness where stakes are high. Always bound the repair loop and have a safe fallback.

"""Check the agent's reply BEFORE it reaches the user; repair or fall back if it fails."""
import re
from typing import Literal, TypedDict

from langgraph.graph import END, START, StateGraph
from langgraph.types import Command

EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+")
PROMISES = ("guarantee", "we will refund", "legal advice")


class S(TypedDict):
    draft: str
    sources: list[str]
    reply: str
    attempts: int


def draft(s: S):
    # stands in for the agent; first draft leaks an email and has no citation
    if s["attempts"] == 0:
        return {"draft": "Contact lee@corp.com, we guarantee a refund.", "attempts": 1}
    return {"draft": "Refunds are possible within 30 days [refund-policy.md].", "attempts": s["attempts"] + 1}


def check(s: S) -> Command[Literal["draft", "__end__"]]:
    problems = []
    if EMAIL.search(s["draft"]):
        problems.append("contains an email address")
    if any(p in s["draft"].lower() for p in PROMISES):
        problems.append("makes a promise policy forbids")
    if not any(f"[{src}]" in s["draft"] for src in s["sources"]):
        problems.append("no citation")
    if not problems:
        return Command(goto=END, update={"reply": s["draft"]})
    if s["attempts"] >= 2:                               # bounded repair, then safe fallback
        return Command(goto=END, update={"reply": "Let me connect you with a teammate."})
    return Command(goto="draft")                          # in real code: pass problems back to the LLM


g = StateGraph(S)
g.add_node("draft", draft)
g.add_node("check", check)
g.add_edge(START, "draft")
g.add_edge("draft", "check")
app = g.compile()

if __name__ == "__main__":
    print(app.invoke({"draft": "", "sources": ["refund-policy.md"], "reply": "", "attempts": 0})["reply"])
    # Refunds are possible within 30 days [refund-policy.md].

Testing guardrails

Real-world scenarios

Composite scenarios based on common production patterns. Each one is also a strong interview story.

Customer-support agent at scale

IT-ops runbook agent

Document-processing pipeline

Interview corner

Questions to practise

How would you orchestrate an agent system for a new use case? Walk me through your decision.medium

Start from the task, not the framework: list the steps and whether they're knowable in advance. Use a workflow for fixed steps and add agentic steps only where the path varies. Wrap agents in a deterministic shell (load context → agent → policy check → persist). Use a router if domains differ, and a supervisor only if specialists must collaborate. Run anything long in the background. Justify each added layer of autonomy with an eval result.

A pod dies halfway through an agent run that has already charged the customer. What happens?hard

With a durable checkpointer, the completed "charge" step is saved. Resuming the thread (invoke(None, config)) continues from the next step, so the charge isn't repeated. The node that was running when the pod died re-runs, so it must be idempotent: use an idempotency key derived from the order id. For multi-step side effects, add saga compensations. Mention durability="sync" for critical flows.

How do you monitor an agent in production? What's on your dashboard?medium

Outcome (success, escalation, feedback), latency (TTFT, p95 run time, per-node), cost (tokens and $ per run, cache hits), behaviour (steps and tool calls per run, tool errors, recursion-limit hits), safety (guardrail blocks, approvals) and health (provider errors, queue depth, stuck interrupts). Everything is tagged with thread, tenant, prompt and model version. Full traces in LangSmith/OTel, plus weekly human review of a sample.

Users say the agent is "stuck". How do you debug a specific conversation?medium

Look up the thread: get_state shows the next node and any pending interrupt (often it's waiting for an approval nobody saw). get_state_history and the trace show how it got there. Fix by resuming, correcting state with update_state, or closing it. Then add an alert for interrupts waiting longer than the SLA.

Estimate the capacity needed for 100 conversations per minute.hard

State assumptions (5 LLM calls per run, 4k input / 400 output tokens, 20 s runs). That gives 500 RPM, 2M input TPM and 200k output TPM, so check the provider quota. Little's law gives ~34 concurrent runs, which 1–3 replicas at 40 concurrency handle. Checkpoint writes are ~17/s, trivial. The constraints are quota and cost, so propose prompt caching, model routing, rate limiting and back-pressure.

What's the difference between retrying and falling back? Give examples.easy

Retry transient failures with exponential backoff and jitter (429, 503, timeouts), with a cap. Fall back on permanent failures or exhausted retries: a different model or provider, a cached answer, a simpler path, or a human ticket. Never retry validation errors or 404s, and make sure retries can't double side effects.

How do you stop an agent from running up a huge bill?medium

Hard limits: recursion_limit, model-call and tool-call limits, max tokens per call, timeouts. Soft controls: per-user/tenant quotas, model routing, prompt caching, context trimming. Detection: cost-per-run metric with anomaly alerts. Containment: a kill switch or feature flag.

Which guardrails would you put on an agent that can issue refunds?medium

Identity from auth, not the model. Policy table (roles, limits) enforced in code. Amount validation against the order. Human approval above a threshold via interrupt(). At most one refund per run, idempotency keys, an audit log, and an output check that the reply doesn't promise more than was done. Monitor refund rate and approval denials, and red-team with injection attempts.

Your agent reads emails and can send emails. What's the security concern?hard

That's the "lethal trifecta": private data, untrusted input and an exfiltration channel. A malicious email can instruct the agent to forward sensitive data. Break a leg: separate the reading agent from the sending capability, require approval for outbound messages, restrict recipients to an allow-list, and treat email content as data. Prompts alone are not a defence.

How do you scale a LangGraph app horizontally?medium

Stateless replicas with a shared durable checkpointer (Postgres) through a connection pool, so any replica serves any thread and no sticky sessions are needed. Autoscale on concurrent requests or queue depth. Rate-limit LLM calls per replica within the provider quota. Raise load-balancer timeouts for streaming. Move long runs to queue workers. Run checkpoint migrations once from a job, not on every start-up.

How do you release a new prompt or model without breaking production?medium

Version prompts and pin model ids. Run offline evals (quality + adversarial) in CI as a gate, then canary a small share of traffic. Compare success, cost, latency and guardrail metrics against the old version, and keep instant rollback through config. Treat a model upgrade like a dependency upgrade.

Tell me about a production incident with an agent.medium

Use one of the scenarios above in STAR form: the situation (traffic spike / injection risk / duplicate processing), how you detected it (which metric or alert), how you contained it (kill switch, fallback), the root cause, the fix, and what you added to prevent recurrence (eval cases, monitors, guardrails). Quantify where you can.

How to talk about production experience