← Back to list

From Prototype to Production: What Changes When You Ship an AI Agent

Your demo worked beautifully. Then you deployed it. Here’s everything that falls apart — and how to fix it before your users find out.

Amit.Kumar · 2026-05-21 18:00 · 1 claps · 13.8 min read
#agentic-ai #production #system-design-interview #agent-evaluation #ai-production
Open on Medium ↗
Wiki topics: AGT · AI Agents EVAL · Evaluation & Benchmarks UX · UI/UX Design

From Prototype to Production: What Changes When You Ship an AI Agent

Your demo worked beautifully. Then you deployed it. Here’s everything that falls apart — and how to fix it before your users find out.

The Demo Always Works

There’s a specific kind of confidence that comes from watching your AI agent nail a demo. It calls the right tools, reasons clearly, produces a great answer. You think: we’re ready to ship.

Then you deploy it. And within 48 hours, the cracks start showing. A user hits an edge case your happy-path testing never touched. The model times out under load. Someone’s prompt injection breaks the system prompt. Costs spike because a loop didn’t terminate properly. You have no idea which request failed or why, because you have no logs worth reading.

This is the prototype-to-production gap — and it’s wider for AI agents than for almost any other kind of software, because agents are non-deterministic, stateful, expensive, and slow all at once.

Figure 1: The prototype checklist and the production checklist are almost completely different. Passing the prototype checklist does not mean you’re ready to ship.

This article covers the six things that change most dramatically — and most painfully — when you move an AI agent from demo to production: observability, evaluation, prompt versioning, reliability, cost management, and human-in-the-loop design.

“A demo proves your agent can work. Production proves your agent keeps working.”

1. Observability: You Can’t Fix What You Can’t See

In a normal web app, debugging is annoying but tractable. You have stack traces, deterministic code paths, and error messages that point to a line number. With an AI agent, the failure mode is different: the agent runs successfully but produces a wrong answer, calls a tool with bad arguments, or gets stuck in a loop — and your logs show nothing useful because you were only logging inputs and outputs.

Production agent observability means instrumenting every layer of the agent’s execution, not just the entry and exit points.

What You Actually Need to Log

Figure 2: Production agent observability requires instrumentation at every layer — not just logging the final output. Each layer answers different debugging questions.

Building a Structured Agent Tracer

Here’s a tracer class you can drop into any agent. The key idea is that every LLM call and every tool invocation gets a structured log entry with a shared run_id, so you can reconstruct the full execution timeline for any request.

import uuid
import time
import json
import logging
from dataclasses import dataclass, field, asdict
from typing import Optional, Any
from datetime import datetime, timezone
# Structured JSON logger — ship this to Datadog, CloudWatch, etc.
logging.basicConfig(
    level=logging.INFO,
    format='%(message)s'  # raw JSON — no extra prefix
)
logger = logging.getLogger("agent.tracer")
@dataclass
class AgentTrace:
    """One trace per agent run. Accumulates events across all steps."""
    run_id: str = field(default_factory=lambda: str(uuid.uuid4()))
    session_id: str = ""
    user_id: str = ""
    started_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
    input: str = ""
    steps: list = field(default_factory=list)
    total_input_tokens: int = 0
    total_output_tokens: int = 0
    total_cache_read_tokens: int = 0
    total_cost_usd: float = 0.0
    step_count: int = 0
    stop_reason: str = ""
    output: str = ""
    error: Optional[str] = None
    duration_ms: int = 0
    def log_llm_call(self, step: int, model: str, usage, latency_ms: int, finish_reason: str):
        self.step_count = step
        self.total_input_tokens += usage.input_tokens
        self.total_output_tokens += usage.output_tokens
        self.total_cache_read_tokens += usage.cache_read_input_tokens
        # Approximate cost (Claude Opus 4)
        cost = (
            (usage.input_tokens / 1e6) * 3.00 +
            (usage.cache_read_input_tokens / 1e6) * 0.30 +
            (usage.output_tokens / 1e6) * 15.00
        )
        self.total_cost_usd += cost
        step_event = {
            "event": "llm_call",
            "run_id": self.run_id,
            "step": step,
            "model": model,
            "input_tokens": usage.input_tokens,
            "output_tokens": usage.output_tokens,
            "cache_read_tokens": usage.cache_read_input_tokens,
            "latency_ms": latency_ms,
            "finish_reason": finish_reason,
            "step_cost_usd": round(cost, 6)
        }
        self.steps.append(step_event)
        logger.info(json.dumps(step_event))
    def log_tool_call(self, step: int, tool_name: str, tool_input: dict,
                      result: str, success: bool, duration_ms: int, error: str = None):
        tool_event = {
            "event": "tool_call",
            "run_id": self.run_id,
            "step": step,
            "tool_name": tool_name,
            "tool_input": tool_input,
            "success": success,
            "duration_ms": duration_ms,
            "error": error,
            # Truncate large outputs — log a hash for deduplication
            "output_preview": result[:200] + "..." if len(result) > 200 else result
        }
        self.steps.append(tool_event)
        logger.info(json.dumps(tool_event))
    def finish(self, output: str, stop_reason: str, started_ts: float):
        self.output = output
        self.stop_reason = stop_reason
        self.duration_ms = int((time.time() - started_ts) * 1000)
        summary = {
            "event": "agent_run_complete",
            "run_id": self.run_id,
            "user_id": self.user_id,
            "step_count": self.step_count,
            "total_input_tokens": self.total_input_tokens,
            "total_output_tokens": self.total_output_tokens,
            "total_cache_read_tokens": self.total_cache_read_tokens,
            "total_cost_usd": round(self.total_cost_usd, 6),
            "duration_ms": self.duration_ms,
            "stop_reason": stop_reason,
            "error": self.error
        }
        logger.info(json.dumps(summary))
        return summary
# ── Usage inside your agent loop ──
def run_agent(user_input: str, user_id: str) -> str:
    trace = AgentTrace(user_id=user_id, input=user_input)
    start = time.time()
    step = 0
    try:
        while step < 10:
            step += 1
            t0 = time.time()
            response = client.messages.create(...)
            latency = int((time.time() - t0) * 1000)
            trace.log_llm_call(
                step=step, model=response.model,
                usage=response.usage,
                latency_ms=latency,
                finish_reason=response.stop_reason
            )
            if response.stop_reason == "end_turn":
                return trace.finish(response.content[0].text, "end_turn", start)
            for tool_block in [b for b in response.content if b.type == "tool_use"]:
                t1 = time.time()
                try:
                    result = execute_tool(tool_block.name, tool_block.input)
                    trace.log_tool_call(step, tool_block.name, tool_block.input,
                                          result, success=True,
                                          duration_ms=int((time.time()-t1)*1000))
                except Exception as e:
                    trace.log_tool_call(step, tool_block.name, tool_block.input,
                                          "", success=False,
                                          duration_ms=int((time.time()-t1)*1000),
                                          error=str(e))
    except Exception as e:
        trace.error = str(e)
        return trace.finish("", "error", start)

Ship logs to a structured backend. JSON logs to stdout are great for local dev, but in production you want these flowing into Datadog, CloudWatch, or Grafana Loki so you can query: “all runs where step_count > 7 and total_cost > $0.50” or “all tool_call failures for web_search in the last 24h.” That query is impossible without structured logs.

2. Evaluation: How Do You Know It’s Still Working?

This is the hardest production problem unique to AI agents. With normal software, a test either passes or fails. With an agent, the output might be technically correct but subtly worse than last week — and you won’t notice until users complain.

Production evaluation for agents has three layers, each catching different kinds of failures.

Figure 3: The three eval layers run at different cadences and costs. Layer 1 is a gate on every deploy. Layer 3 catches slow quality drift before users notice.

Layer 1: Deterministic Checks (Run on Every Request)

import re
from pydantic import BaseModel, validator
from typing import Optional
class AgentOutputValidator(BaseModel):
    """Validate agent output structure before returning to user."""
    answer: str
    sources: list[str]
    confidence: str  # "high" | "medium" | "low"
    tool_calls_made: int
    @validator("answer")
    def answer_not_empty(cls, v):
        if not v.strip():
            raise ValueError("Answer cannot be empty")
        return v
    @validator("answer")
    def no_hallucination_markers(cls, v):
        # Flag phrases that often signal fabrication
        danger_phrases = [
            "I don't have access to", "as of my knowledge cutoff",
            "I cannot browse", "I'm unable to verify"
        ]
        for phrase in danger_phrases:
            if phrase.lower() in v.lower():
                raise ValueError(f"Potential hallucination marker: '{phrase}'")
        return v
    @validator("confidence")
    def valid_confidence(cls, v):
        if v not in ["high", "medium", "low"]:
            raise ValueError(f"Invalid confidence value: {v}")
        return v
def validate_agent_output(raw_output: str) -> tuple[Optional[AgentOutputValidator], Optional[str]]:
    try:
        parsed = json.loads(raw_output)
        validated = AgentOutputValidator(**parsed)
        return validated, None
    except json.JSONDecodeError as e:
        return None, f"Invalid JSON: {e}"
    except ValueError as e:
        return None, f"Validation failed: {e}"

Layer 2: Regression Tests (Run on Every Prompt Change)

import anthropic
from sentence_transformers import SentenceTransformer
import numpy as np
# Golden test cases — curated, human-reviewed expected outputs
GOLDEN_TESTS = [
    {
        "id": "gt_001",
        "input": "What is the capital of France?",
        "expected_output": "Paris is the capital of France.",
        "expected_tool_calls": 0,   # Should answer from knowledge, not search
        "max_steps": 1
    },
    {
        "id": "gt_002",
        "input": "What was the NASDAQ closing price yesterday?",
        "expected_output": "[any number] points",  # Must use web_search tool
        "expected_tool_calls": 1,
        "max_steps": 2
    },
]
embedder = SentenceTransformer("all-MiniLM-L6-v2")
def semantic_similarity(text_a: str, text_b: str) -> float:
    """Returns cosine similarity (0-1). Above 0.85 = semantically equivalent."""
    emb = embedder.encode([text_a, text_b])
    return float(np.dot(emb[0], emb[1]) /
                 (np.linalg.norm(emb[0]) * np.linalg.norm(emb[1])))
def run_regression_suite(agent_fn, threshold: float = 0.85) -> dict:
    results = []
    passed = 0
    for test in GOLDEN_TESTS:
        output, trace = agent_fn(test["input"])
        similarity = semantic_similarity(output, test["expected_output"])
        tool_calls_ok = trace.step_count <= test["max_steps"]
        passed_test = similarity >= threshold and tool_calls_ok
        if passed_test: passed += 1
        results.append({
            "test_id": test["id"],
            "passed": passed_test,
            "similarity_score": round(similarity, 3),
            "tool_calls_ok": tool_calls_ok,
            "actual_output": output[:100]
        })
    return {
        "passed": passed,
        "total": len(GOLDEN_TESTS),
        "pass_rate": passed / len(GOLDEN_TESTS),
        "results": results
    }
# Gate deploys on this — if pass_rate drops below 0.9, block the release
suite_results = run_regression_suite(my_agent)
if suite_results["pass_rate"] < 0.90:
    raise RuntimeError(f"Regression suite failed: {suite_results['pass_rate']:.0%} pass rate")

Layer 3: LLM-as-Judge (Weekly Production Sampling)

# Sample 50 real production runs per week and have Claude score them
JUDGE_PROMPT = """
You are an expert evaluator for AI agent outputs. Score the following
agent response on a scale of 1-5 for each criterion.
User question: {question}
Agent response: {response}
Rate on these dimensions:
- Accuracy (1-5): Is the information factually correct?
- Completeness (1-5): Does it fully address the question?
- Reasoning (1-5): Is the logic/reasoning sound and visible?
- Conciseness (1-5): Is it appropriately brief without losing quality?
Return JSON: {{"accuracy": N, "completeness": N, "reasoning": N, "conciseness": N, "overall_notes": "..."}}
"""
async def judge_production_sample(sample_runs: list) -> dict:
    scores = []
    judge_client = anthropic.AsyncAnthropic()
    for run in sample_runs:
        response = await judge_client.messages.create(
            model="claude-sonnet-4-6",  # cheaper judge model is fine
            max_tokens=256,
            messages=[{
                "role": "user",
                "content": JUDGE_PROMPT.format(
                    question=run["input"],
                    response=run["output"]
                )
            }]
        )
        score = json.loads(response.content[0].text)
        score["run_id"] = run["run_id"]
        scores.append(score)
    avg = lambda k: sum(s[k] for s in scores) / len(scores)
    return {
        "sample_size": len(scores),
        "avg_accuracy": avg("accuracy"),
        "avg_completeness": avg("completeness"),
        "avg_reasoning": avg("reasoning"),
        "avg_conciseness": avg("conciseness")
    }

3. Prompt Versioning: Treat Prompts Like Code

Here’s a scenario that happens constantly in production: someone tweaks a phrase in the system prompt to fix one edge case. The change looks harmless. Three days later, a different part of the agent’s behaviour quietly breaks — and nobody connects the dots because the prompt change wasn’t logged, reviewed, or tested.

Prompts are code. They need version control, review processes, and rollback capability just like any other code change.

Figure 4: Every prompt change goes through the same workflow as a code change — edit, eval, merge or block, deploy. Rollback is instant by changing the version pointer.

Building a Prompt Registry

import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
class PromptRegistry:
    """
    File-based prompt registry. In production, back this with DynamoDB or
    a database. The interface stays the same.
    """
    def __init__(self, registry_path: str = "./prompt_registry"):
        self.path = Path(registry_path)
        self.path.mkdir(exist_ok=True)
    def save(self, name: str, text: str, author: str, notes: str = "") -> str:
        """Save a prompt version. Returns the version hash."""
        version_hash = hashlib.sha256(text.encode()).hexdigest()[:12]
        entry = {
            "name": name,
            "version": version_hash,
            "text": text,
            "author": author,
            "notes": notes,
            "created_at": datetime.now(timezone.utc).isoformat(),
            "eval_scores": None  # filled in after eval run
        }
        version_file = self.path / f"{name}_{version_hash}.json"
        version_file.write_text(json.dumps(entry, indent=2))
        print(f"Saved prompt '{name}' as version {version_hash}")
        return version_hash
    def promote_to_prod(self, name: str, version_hash: str, eval_scores: dict):
        """Set a specific version as the active production prompt."""
        version_file = self.path / f"{name}_{version_hash}.json"
        if not version_file.exists():
            raise FileNotFoundError(f"Version {version_hash} not found")
        entry = json.loads(version_file.read_text())
        entry["eval_scores"] = eval_scores
        # Write "active" pointer
        active_file = self.path / f"{name}_ACTIVE.json"
        active_file.write_text(json.dumps(entry, indent=2))
        print(f"✅ Promoted {name} v{version_hash} to production")
    def get_active(self, name: str) -> str:
        """Load the current production prompt text."""
        active_file = self.path / f"{name}_ACTIVE.json"
        if not active_file.exists():
            raise FileNotFoundError(f"No active version for prompt '{name}'")
        return json.loads(active_file.read_text())["text"]
    def rollback(self, name: str, to_version: str):
        """Instant rollback — just change the active pointer."""
        self.promote_to_prod(name, to_version, eval_scores={"rollback": True})
        print(f"⏮ Rolled back '{name}' to version {to_version}")
# ── Usage ──
registry = PromptRegistry()
# Save and evaluate a new version
NEW_PROMPT = """You are an expert research agent..."""
v_hash = registry.save("research_agent", NEW_PROMPT, author="amit", notes="Improved tool-calling instructions")
# Run eval suite on the new version, then promote if it passes
results = run_regression_suite(lambda q: agent_with_prompt(NEW_PROMPT, q))
if results["pass_rate"] >= 0.90:
    registry.promote_to_prod("research_agent", v_hash, eval_scores=results)
else:
    print(f"❌ Eval failed ({results['pass_rate']:.0%}). Not promoting.")
# In your agent — always load from registry, never hardcode
system_prompt = registry.get_active("research_agent")
# Something broke? Rollback in one line.
registry.rollback("research_agent", to_version="a3f9b2c1d8e4")

⚠️ Log the prompt version hash on every request. When something goes wrong in production, the first question you’ll ask is: “which prompt version was active for this run?” Without logging the version hash in your traces, you can’t answer that question. Add it to your AgentTrace from section 1.

4. Reliability: When Things Go Wrong at 3am

AI agents fail in ways normal software doesn’t. The model API returns a 429 rate limit error. A tool call times out mid-loop. The model generates a response that fails your output validator — and now what? You need explicit strategies for all of these, because they will happen in production.

Retry Logic with Exponential Backoff

import asyncio
import anthropic
from typing import Callable, TypeVar
T = TypeVar("T")
async def with_retry(
    fn: Callable,
    max_attempts: int = 3,
    base_delay: float = 1.0,
    max_delay: float = 30.0,
    retryable_errors: tuple = (
        anthropic.RateLimitError,
        anthropic.APITimeoutError,
        anthropic.InternalServerError,
    )
):
    """
    Exponential backoff with jitter.
    Retries on transient API errors; raises immediately on others.
    """
    for attempt in range(max_attempts):
        try:
            return await fn()
        except retryable_errors as e:
            if attempt == max_attempts - 1:
                print(f"❌ All {max_attempts} attempts failed: {e}")
                raise
            # Exponential backoff with ±30% jitter
            delay = min(base_delay * (2 ** attempt), max_delay)
            jitter = delay * 0.3 * (random.random() * 2 - 1)
            wait = delay + jitter
            print(f"⚠ Attempt {attempt+1} failed ({type(e).__name__}). Retrying in {wait:.1f}s...")
            await asyncio.sleep(wait)
        except Exception as e:
            # Don't retry authentication errors, malformed requests, etc.
            print(f"❌ Non-retryable error: {type(e).__name__}: {e}")
            raise
# ── Wrap your LLM calls ──
response = await with_retry(
    lambda: client.messages.create(
        model="claude-opus-4-6",
        max_tokens=2048,
        messages=messages
    )
)

Circuit Breaker: Stop Hammering a Failing Service

from enum import Enum
import time
class CircuitState(Enum):
    CLOSED = "closed"      # Normal operation
    OPEN = "open"          # Failing — reject requests immediately
    HALF_OPEN = "half_open" # Testing — let one through to probe recovery
class CircuitBreaker:
    def __init__(self, failure_threshold=5, recovery_timeout=60):
        self.state = CircuitState.CLOSED
        self.failure_count = 0
        self.failure_threshold = failure_threshold  # failures before opening
        self.recovery_timeout = recovery_timeout    # seconds before trying again
        self.last_failure_time = None
    def call(self, fn):
        if self.state == CircuitState.OPEN:
            if time.time() - self.last_failure_time > self.recovery_timeout:
                self.state = CircuitState.HALF_OPEN
                print("Circuit HALF-OPEN — probing recovery...")
            else:
                raise RuntimeError("Circuit OPEN — service unavailable, try later")
        try:
            result = fn()
            self._on_success()
            return result
        except Exception as e:
            self._on_failure()
            raise
    def _on_success(self):
        self.failure_count = 0
        self.state = CircuitState.CLOSED
    def _on_failure(self):
        self.failure_count += 1
        self.last_failure_time = time.time()
        if self.failure_count >= self.failure_threshold:
            self.state = CircuitState.OPEN
            print(f"⚡ Circuit OPEN after {self.failure_count} failures")
# One circuit breaker per external dependency
llm_breaker  = CircuitBreaker(failure_threshold=5, recovery_timeout=60)
tool_breaker = CircuitBreaker(failure_threshold=3, recovery_timeout=30)

5. Cost Management: Budget Controls Before You Need Them

In prototype, you might be spending $5/day. In production, a single runaway loop or a sudden traffic spike can burn $500 before anyone notices. You need hard spending controls — not dashboards you check manually, but code that enforces limits automatically.

import redis
from datetime import date
class CostGuard:
    """
    Per-user and global daily spend limits enforced with Redis counters.
    Uses atomic increments to handle concurrent requests safely.
    """
    def __init__(self,
                 user_daily_limit_usd: float = 5.0,
                 global_daily_limit_usd: float = 500.0):
        self.r = redis.Redis()
        self.user_limit = user_daily_limit_usd
        self.global_limit = global_daily_limit_usd
        self.today = str(date.today())
    def check_and_reserve(self, user_id: str, estimated_cost: float) -> bool:
        """
        Returns True if budget is available. Reserves the estimated cost.
        Call this BEFORE making the LLM request.
        """
        user_key   = f"cost:user:{user_id}:{self.today}"
        global_key = f"cost:global:{self.today}"
        # Use a pipeline for atomic read-then-increment
        with self.r.pipeline() as pipe:
            pipe.incrbyfloat(user_key,   estimated_cost)
            pipe.incrbyfloat(global_key, estimated_cost)
            pipe.expire(user_key,   86400)  # TTL = 24h
            pipe.expire(global_key, 86400)
            user_spend, global_spend, *_ = pipe.execute()
        if float(user_spend) > self.user_limit:
            # Roll back the reservation
            self.r.incrbyfloat(user_key,   -estimated_cost)
            self.r.incrbyfloat(global_key, -estimated_cost)
            print(f"⛔ User {user_id} daily budget exceeded")
            return False
        if float(global_spend) > self.global_limit:
            self.r.incrbyfloat(user_key,   -estimated_cost)
            self.r.incrbyfloat(global_key, -estimated_cost)
            print(f"⛔ Global daily budget exceeded — alerting team")
            self._alert_team(global_spend)
            return False
        return True
    def reconcile(self, user_id: str, estimated: float, actual: float):
        """Adjust the reservation after you know the real cost."""
        delta = actual - estimated
        self.r.incrbyfloat(f"cost:user:{user_id}:{self.today}",   delta)
        self.r.incrbyfloat(f"cost:global:{self.today}", delta)
    def _alert_team(self, current_spend: float):
        # Send to Slack, PagerDuty, email — whatever your team uses
        print(f"🚨 ALERT: Global spend ${current_spend:.2f} — circuit opened")
# ── Usage ──
guard = CostGuard(user_daily_limit_usd=2.0, global_daily_limit_usd=200.0)
def run_with_budget_check(user_id: str, query: str):
    estimated_cost = 0.05  # Pessimistic estimate per request
    if not guard.check_and_reserve(user_id, estimated_cost):
        return "You've reached your daily usage limit. Try again tomorrow."
    result, trace = run_agent(query, user_id)
    guard.reconcile(user_id, estimated_cost, trace.total_cost_usd)
    return result

6. Human-in-the-Loop: Know When Not to Trust the Agent

The hardest design decision in production agentic systems isn’t technical — it’s knowing which actions the agent should be allowed to take autonomously, and which ones require a human to sign off first.

Get this wrong in one direction and the agent is too slow to be useful. Get it wrong in the other and it does something irreversible — deletes a file, sends an email to 10,000 people, charges a customer twice.

Figure 5: Map every action your agent can take to a quadrant. The quadrant determines whether it runs freely, gets spot-checked, requires approval, or is blocked entirely.

Implementing an Approval Gate

from enum import Enum
from dataclasses import dataclass
from typing import Optional, Callable
class ActionRisk(Enum):
    LOW    = "low"      # Always auto-approve
    MEDIUM = "medium"   # Auto-approve within limits
    HIGH   = "high"     # Always require human approval
# Define risk level per tool
TOOL_RISK_MAP = {
    "web_search":       ActionRisk.LOW,
    "read_file":        ActionRisk.LOW,
    "summarise":        ActionRisk.LOW,
    "send_email":       ActionRisk.HIGH,
    "delete_record":    ActionRisk.HIGH,
    "execute_sql":      ActionRisk.HIGH,
    "create_invoice":   ActionRisk.MEDIUM,  # conditional on amount
}
def get_approval(tool_name: str, tool_input: dict, run_id: str) -> bool:
    """
    In production, this sends a Slack message / webhook to an approver
    and waits for their response (with a timeout).
    Here we stub it with a CLI prompt.
    """
    risk = TOOL_RISK_MAP.get(tool_name, ActionRisk.MEDIUM)
    if risk == ActionRisk.LOW:
        return True  # auto-approve
    if risk == ActionRisk.MEDIUM:
        # Apply conditional limits
        if tool_name == "create_invoice" and tool_input.get("amount", 0) < 100:
            return True
    # HIGH risk or MEDIUM over limit — request human approval
    print(f"\n🔔 APPROVAL REQUIRED [run_id: {run_id}]")
    print(f"   Tool: {tool_name}")
    print(f"   Input: {json.dumps(tool_input, indent=2)}")
    response = input("   Approve? (y/n): ").strip().lower()
    return response == "y"
# ── Insert into your tool execution path ──
def execute_tool_with_approval(tool_name: str, tool_input: dict, run_id: str) -> str:
    approved = get_approval(tool_name, tool_input, run_id)
    if not approved:
        return "Action rejected by human reviewer. Please choose a different approach."
    return execute_tool(tool_name, tool_input)

Putting It All Together: The Production Agent Architecture

These six concerns don’t live in isolation — they wire together into a single production-grade agent execution path. Here’s what that looks like end to end:

Figure 6: The full production execution path. Every request flows through budget check → prompt load → tracing → circuit breaker → agent loop → finalize → reconcile. Evals run async, off the critical path.

The Production Readiness Checklist

Before you ship an AI agent to real users, run through this. If you can’t check everything, at least be deliberate about which gaps you’re accepting and why.

Conclusion: The Gap Is Solvable

None of this is magic — it’s just engineering discipline applied to a new class of system. The challenge is that most AI tutorials stop at “it works on my machine.” They show you the agent loop, the tool calls, the nice output. They don’t show you what happens when the API is rate-limited, when a prompt change silently breaks behaviour six days later, or when a user manages to spend $200 in a single session.

The good news: the gap between prototype and production is well-defined and solvable. It’s six things — observability, evaluation, prompt versioning, reliability, cost management, and human oversight. Each one has a clear implementation pattern. None of them require exotic infrastructure.

“The difference between a demo and a product isn’t the AI. It’s everything around the AI.”

Start with observability — you can’t improve what you can’t see. Then add a regression suite before you make your first prompt change. The rest follows naturally once you can see what your agent is actually doing in the wild.

If you want to build this kind of production thinking into your workflow from day one — not as an afterthought — the ***Agentic AI System Design course by Educosys*** is built around exactly this. It covers how to architect, build, and ship real production AI agents — not just prototype them. If you’re a software developer who wants to make the jump to production AI engineering, that’s the gap it’s designed to close.

Ship the observability first. Everything else gets easier once you can see what’s actually happening.


메타데이터
post_id
2ac5367ae419
slug
from-prototype-to-production-what-changes-when-you-ship-an-ai-agent-2ac5367ae419
url
https://medium.com/@unscriptedcoding/from-prototype-to-production-what-changes-when-you-ship-an-ai-agent-2ac5367ae419
canonical_url
https://medium.com/@unscriptedcoding/from-prototype-to-production-what-changes-when-you-ship-an-ai-agent-2ac5367ae419
author_url
https://medium.com/@unscriptedcoding
status
ok
fetched_at
2026-06-09 15:37:30