A prototype agent is a while loop wrapped around a model call. A production agent is that same loop wrapped in schema validation, budget ceilings, retries, traces and a test suite that runs before every deploy. The loop is the small part.
I have shipped agents that screen CVs at volume, qualify B2B leads from natural-language queries, and drive branching voice call flows. The failures were never clever model failures. They were a tool called twice with the same order ID, a 429 retried instantly in a tight loop, a JSON blob that parsed fine and meant nothing. This post is the Python scaffolding that stops all three.
What makes an AI agent production-ready in Python?
An agent is production-ready when every failure mode has a defined behaviour: bad tool arguments are rejected before execution, transient errors are retried with backoff, runaway loops hit a ceiling, and every step is traceable after the fact. Model quality is the last variable I tune, not the first.
The same agent, prototype versus production, concern by concern:
| Concern | Prototype | Production |
|---|---|---|
| Tool arguments | Dict passed straight into the function | Pydantic model validated before any side effect |
| Loop control | while True until it stops | Max steps, max spend, wall-clock deadline |
| Failures | Traceback in the terminal | Typed retries with backoff, then fail closed |
| Output shape | Free text parsed with a regex | JSON Schema in strict mode, then re-validated |
| Visibility | Print statements | One trace per task, one span per step, cost attached |
| Change safety | It looked fine yesterday | Eval set with a pass threshold wired into CI |
In my experience that right-hand column is roughly a week of engineering on top of a one-day prototype. Teams that skip it do not save the week, they spend it in production with users watching. If you want the wider delivery view around this, I wrote about it in shipping AI projects as a full-stack Python developer.
How do you define typed tools an agent cannot misuse?
Define the tool schema once as a Pydantic model, export it as JSON Schema for the model, and re-validate the arguments in your own code before touching the database. The model proposes; your validator decides.
from typing import Literal
from pydantic import BaseModel, Field
class RefundOrder(BaseModel):
order_id: str = Field(pattern='^ORD-[0-9]{6}#x27;)
amount_cents: int = Field(gt=0, le=50_000)
reason: Literal['damaged', 'late', 'wrong_item']
TOOLS = [{
'type': 'function',
'function': {
'name': 'refund_order',
'description': 'Refund one shipped order. Never call twice for the same order_id.',
'parameters': RefundOrder.model_json_schema(),
'strict': True,
},
}]
def refund_order(raw: dict) -> dict:
args = RefundOrder.model_validate(raw) # raises before money moves
return ledger.refund(
args.order_id,
args.amount_cents,
idempotency_key=f'refund:{args.order_id}',
)Three details there matter more than the model you pick. The regex on order_id blocks hallucinated identifiers. The upper bound on amount_cents caps the blast radius of a bad call. The idempotency key makes a duplicate call a no-op instead of a second refund.
Rules I apply to every tool definition:
- Three to seven tools per agent. Beyond that, selection accuracy falls and I split the agent.
- One verb per tool. A tool called manage_order is a bug waiting for a name.
- Enums over free strings wherever the domain is closed.
- Write the misuse warning into the description field. The model reads it; your linter does not.
- Return errors as structured data the agent can act on, not as raised exceptions that kill the run.
This is exactly the discipline behind AgentFlow, my visual AI agent builder: each block on the drag-and-drop canvas stands for one narrow, well-defined step, which is why non-engineers can wire pipelines without inventing new failure modes.
What does the agent loop actually look like?
The loop is call the model, check for tool calls, execute them, append the observations, repeat until the model answers or a limit trips. Everything interesting is in the limits.
One iteration, in order:
- 1Call the model with the message history and the tool schemas, under a hard request timeout.
- 2Add the call cost to the running total and stop if it crosses the per-task budget.
- 3If there are no tool calls, the agent is done: validate the final output and return it.
- 4Otherwise validate each tool call, execute it, and append the result as a tool message.
- 5Increment the step counter and stop at the ceiling with an explicit status, never silently.
import random, time
def run_agent(messages, tools, max_steps=8, max_cost_usd=0.50):
spent = 0.0
for step in range(max_steps):
reply, cost = call_model(messages, tools)
spent += cost
if spent > max_cost_usd:
return {'status': 'budget_exceeded', 'steps': step + 1}
if not reply.tool_calls:
return {'status': 'ok', 'output': reply.content, 'steps': step + 1}
messages.append(reply)
for call in reply.tool_calls:
messages.append(dispatch(call)) # validates, executes, traces
return {'status': 'step_ceiling', 'steps': max_steps}
def call_model(messages, tools, attempts=4):
for i in range(attempts):
try:
return client.chat(messages=messages, tools=tools, timeout=30)
except (RateLimitError, APITimeoutError, InternalServerError):
if i == attempts - 1:
raise
time.sleep(min(2 ** i, 8) + random.random())Note that budget_exceeded and step_ceiling are returned, not raised. A stuck agent should surface as a countable status in your dashboard, because the rate of those two statuses is the single best early warning that a prompt change went wrong.
"An unbounded agent loop is a credit card with no limit handed to a system that cannot explain itself."
How do you get structured outputs you can trust?
Use the provider's schema-constrained output mode, then validate the result again with your own Pydantic model. Schema adherence and semantic correctness are different problems, and only the first one is the vendor's job.
OpenAI's structured outputs guide describes strict mode as guaranteeing that responses conform to the JSON Schema you supply, which removes the parse-and-pray layer most early agent code is built on. Anthropic's tool use documentation takes the same shape: you hand Claude JSON Schema tool definitions and it returns typed calls against them. Both are worth wiring through your OpenAI API integration layer rather than reimplementing per project.
Three layers I keep separate, because collapsing them hides bugs:
- Shape: enforced by the provider through JSON Schema and strict mode.
- Type and range: enforced by Pydantic in your process, with domain constraints the schema cannot express.
- Meaning: enforced by business checks, for example that the invoice total equals the sum of the line items.
When layer three fails, do not retry blindly. Feed the validation error back as a tool result and give the agent exactly one repair attempt. If the repair fails, escalate to a human queue with the full trace attached.
How should retries and backoff work in production AI agents in Python?
Retry only transient failures, with exponential backoff plus random jitter, capped at three or four attempts. Retrying a validation error is not resilience, it is the same wrong request sent again at full price.
How I classify and handle each failure class:
| Failure | Retry? | Handling |
|---|---|---|
| 429 rate limit | Yes | Exponential backoff with jitter; honour retry-after when present |
| 5xx or connection reset | Yes | Up to four attempts, then fail closed with a status |
| Request timeout | Yes | Retry once at a longer timeout, then escalate |
| Schema validation error | No | One agent-side repair attempt, then human queue |
| Tool business error | No | Return as a structured observation so the agent can adapt |
| Budget or step ceiling | No | Terminate, log, alert on the rate |
Jitter is not optional. Without it, every worker that got rate-limited in the same second retries in the same second, and you rebuild the spike you were trying to escape.
How do you trace and debug production AI agents in Python?
Emit one trace per task and one span per step, with the model, token counts, cost, latency, tool name and validation outcome attached. Without per-step traces, debugging an agent is guesswork with a paid API attached to it.
The fields I record on every step span, whether the sink is OpenTelemetry, Langfuse or a Postgres table:
- trace_id and tenant_id, so multi-tenant runs stay separable.
- Model name and version, prompt version hash, and the temperature actually used.
- Input and output token counts, plus computed cost for that call.
- Tool name, validated arguments, and whether validation passed.
- Terminal status: ok, budget_exceeded, step_ceiling, escalated or error.
Store the prompt version hash. The first question after any regression is which prompt produced the bad run, and a hash answers it in one query. If your traces land in a warehouse for analysis, that is ordinary data engineering work, and it pays for itself the first time you diff two prompt versions on real traffic.
How do you build an eval harness before you ship?
Build a small labelled set from real inputs, score each case with a deterministic check where possible, and fail the build when the pass rate drops below your threshold. Fifty real cases beat five hundred synthetic ones.
The harness I put in place before any agent takes live traffic:
- 1Collect 40 to 100 real inputs, including the ugly ones people actually send.
- 2Label the expected outcome: the tool that should fire, the field that should be extracted, or the correct escalation.
- 3Score deterministically first with exact match, numeric tolerance or a set comparison. Reserve model-graded scoring for free-text quality.
- 4Run the suite as a pytest job on every prompt, model or tool change.
- 5Track pass rate, cost per case and step count per case together, because a fix that doubles cost is not a fix.
- 6Add every production failure to the set as a new case before you patch it.
That last step is what compounds. On the CV screening platform, the eval set was mostly grown from the cases that had already embarrassed us, which is why later prompt changes could be shipped in an afternoon instead of debated for a week.
How do you deploy and cost-control production AI agents in Python?
Run the loop inside a FastAPI service, push anything longer than a few seconds onto a queue, and put an idempotency key on every side effect. The API returns a task ID immediately; the worker owns the loop.
The deployment checklist I run through before go-live:
- Queue plus worker for long runs, so no HTTP request holds a socket open for two minutes.
- Idempotency keys on every write, keyed to the business entity rather than to the request.
- Per-tenant rate limits and per-tenant daily spend caps enforced in code, not in a spreadsheet.
- Secrets in the platform secret store; no provider key ever reaches the client.
- A kill switch that disables the agent per tenant and falls back to the human queue.
- Alerts on step-ceiling rate, escalation rate and cost per task, not just on 500s.
Cost structure is simple to reason about and easy to get wrong. You pay per input token and per output token, so cost per task equals tokens per step multiplied by steps per task multiplied by task volume, with cached prompt prefixes billed at a lower rate on the major providers. Rates change often, so check the current OpenAI and Anthropic pricing pages before you model anything.
The two levers that actually move that number are step count and context size, in that order. Cutting a loop from six steps to four saves more than any model swap, and trimming a retrieved-context blob usually saves more than shortening the system prompt. If you want this built and handed over rather than assembled in-house, that is the core of my full-stack Python development work, and it is the same architecture behind connecting the OpenAI API to a CRM.
Key takeaways
- Production AI agents in Python are 40 lines of loop and several hundred lines of validation, retry, tracing and eval code.
- Pydantic models are the contract: export them as JSON Schema for the model, then re-validate before any side effect runs.
- Bound every run by max steps, max spend and a wall-clock deadline, and return those terminations as statuses you can count.
- Retry only timeouts, 429s and 5xx errors, always with exponential backoff plus jitter.
- An eval set built from real failures is the only thing that makes prompt changes safe to ship.
- Cost per task is tokens per step times steps per task times volume, so cutting steps beats swapping models.
Frequently asked questions
- What is the difference between an AI agent and a normal Python script?
- A script executes a sequence you wrote in advance. An agent decides the sequence at runtime by choosing tools in a loop until the goal is met or a limit trips. If you can draw the flowchart before the request arrives, write the script instead. It is cheaper, faster and far easier to debug.
- Do I need a framework like LangChain or LangGraph to build production AI agents in Python?
- No. The core loop is roughly 40 lines against the OpenAI or Anthropic SDK directly, and owning it makes tracing and retries easier to reason about. Frameworks earn their place once you need durable multi-step state, human-in-the-loop pauses or complex graph branching. Start plain, adopt a framework when the pain is real.
- How much does it cost to run a production AI agent?
- Cost is tokens per step multiplied by steps per task multiplied by task volume, plus your hosting. Input and output tokens are priced differently and cached prompt prefixes are cheaper on major providers. Because rates change frequently, model your token maths first and read the current OpenAI or Anthropic pricing page before committing to numbers.
- Is it worth building an eval harness for a small agent?
- Yes, and small agents are where it is cheapest to start. Forty labelled real cases in a pytest suite takes under a day and turns every future prompt change from a debate into a test run. Without it, you cannot tell whether a model upgrade improved your system or quietly broke one input class.
- Can I use Pydantic for tool schemas with both OpenAI and Anthropic Claude?
- Yes. Both providers accept JSON Schema tool definitions, and Pydantic emits JSON Schema from a model class, so one Python definition serves both. Keep the model as the single source of truth, generate the schema at import time, and validate incoming arguments with the same class before executing anything.
- How many tools should one agent have?
- Three to seven in my experience. Past that, tool-selection accuracy degrades and the descriptions start competing with each other. When an agent genuinely needs more surface area, split it into several narrow agents behind a router, or collapse related tools into one with a validated enum parameter.
- How do I stop an agent from repeating a side effect like a duplicate refund?
- Use idempotency keys derived from the business entity rather than the request, so a repeated call for the same order becomes a no-op. Enforce it in the tool function, not in the prompt. Prompts reduce the frequency of duplicate calls; only the database constraint prevents the duplicate charge.
- What should I monitor after an agent goes live?
- Track cost per task, step count distribution, tool error rate, escalation rate and eval pass rate against your baseline. Alert on step-ceiling hits and budget terminations, since a rising rate there usually means a prompt or tool change has pushed the agent into looping before any user complains.
Sources
Written by Syed Husnain Haider Bukhari
AI engineer, data scientist, and founder of Revolutionary Technologies LLC. Ships production AI agents, automations, and data platforms for teams in the US, UK, and UAE — including AgentFlow, AI Walay, and ProLeads.
Get in touch →Related pages
Want this built instead of researched?
Book a 30-minute scoping call. You get a one-page plan and a fixed-scope quote within 48 hours.
Start a conversation