The Gap Between a Demo and Production
An AI agent that works in a demo and one that runs reliably in production are two very different things. The demo needs to succeed once, on a friendly input, in front of an audience. The production agent needs to succeed thousands of times a day, on messy real-world inputs, when nobody is watching. As an AI Agents & Data Specialist, most of my work lives in that gap.
This article distills the patterns I keep coming back to when turning a promising prototype into an agent that a team can actually depend on — from how you shape the tools to how you stop the loop from running forever.
Start With the Tools, Not the Prompt
The most common mistake I see is spending days polishing the system prompt while the tools stay an afterthought. An agent is only as capable as the tools you give it, and a well-designed tool removes the need for a paragraph of instructions. Good tools share a few traits:
- Narrow and specific: One tool does one thing. A
search_orderstool beats a genericrun_querythat expects the model to write SQL. - Self-describing: The name, parameter names, and description tell the model exactly when to use it — no external context required.
- Forgiving inputs, strict outputs: Accept loosely typed arguments, but always return a predictable, structured result.
- Honest errors: When a tool fails, it returns a message the model can act on, not a stack trace it will hallucinate around.
# A well-shaped tool: narrow, typed, and self-describing
from pydantic import BaseModel, Field
class SearchOrdersInput(BaseModel):
customer_email: str = Field(description="Exact email of the customer")
status: str | None = Field(
default=None,
description="Filter by status: pending, shipped, delivered, or cancelled",
)
limit: int = Field(default=10, ge=1, le=50)
def search_orders(args: SearchOrdersInput) -> dict:
"""Look up a customer's recent orders by email and optional status."""
try:
rows = db.query_orders(args.customer_email, args.status, args.limit)
return {"ok": True, "count": len(rows), "orders": rows}
except CustomerNotFound:
# An error the model can reason about, not a raw exception
return {"ok": False, "error": "No customer exists with that email"}
The Planning Loop
At its core, an agent is a loop: the model looks at the current state, decides on an action, the action runs, and the result feeds back in. The loop continues until the model produces a final answer or hits a stopping condition. Keeping that loop explicit and observable is what makes an agent debuggable.
# The agent loop, stripped to its essentials
def run_agent(user_goal: str, tools: dict, max_steps: int = 8) -> str:
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_goal},
]
for step in range(max_steps):
response = llm.chat(messages, tools=tool_schemas(tools))
# No tool call means the agent is done
if not response.tool_calls:
return response.content
messages.append(response.as_message())
# Execute every requested tool and feed results back
for call in response.tool_calls:
result = tools[call.name](call.arguments)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result),
})
# Ran out of steps — fail loudly, don't return a half-answer
return "I couldn't complete this within the step budget."
The max_steps budget is not optional. Without it, a confused agent will
loop indefinitely, burning tokens and money. I treat hitting the step limit as a
first-class failure to log and alert on, not an edge case to ignore.
Memory: What the Agent Should and Shouldn't Remember
Agents need memory, but stuffing the entire history into every request is expensive and eventually overflows the context window. I split memory into three tiers, and being deliberate about each one is what keeps long-running agents coherent:
- Working memory: The current conversation and tool results — kept in full while the task is active.
- Episodic memory: Summaries of past interactions, retrieved when relevant instead of always present.
- Semantic memory: Durable facts (a customer's plan, their preferences) stored in a real database, not in the prompt.
When working memory grows too large, I summarize the oldest turns into a compact note and drop the raw messages. The agent keeps the gist without paying for every token of history on every call.
# Compress history once it crosses a token threshold
def maybe_compress(messages: list, threshold: int = 6000) -> list:
if estimate_tokens(messages) < threshold:
return messages
old, recent = messages[1:-4], messages[-4:]
summary = llm.summarize(
old,
instruction="Summarize the key facts and decisions so far in under 200 words.",
)
return [
messages[0], # keep the system prompt
{"role": "user", "content": f"Context so far: {summary}"},
*recent, # keep the most recent turns verbatim
]
Guardrails Are Not Optional
An autonomous agent with access to real tools can do real damage. Every agent I ship has guardrails at three layers, and skipping any one of them has burned me before:
- Input validation: Reject or sanitize prompts before they reach the model — this is your first line against prompt injection.
- Tool-level permissions: Destructive actions (refunds, deletions, emails) require explicit confirmation or run in a dry-run mode by default.
- Output review: Validate the final response against a schema and check for policy violations before it reaches the user.
# Gate destructive tools behind an explicit confirmation
DESTRUCTIVE = {"issue_refund", "delete_account", "send_email"}
def execute_tool(call, tools, confirmed: set[str]) -> dict:
if call.name in DESTRUCTIVE and call.id not in confirmed:
# Don't run it — ask a human to approve first
return {
"ok": False,
"requires_confirmation": True,
"preview": describe_action(call),
}
return tools[call.name](call.arguments)
Observability: You Can't Fix What You Can't See
The same principle I apply to data pipelines applies to agents: if you can't observe it, you can't trust it. For every agent run, I capture the full trace — each step, the tool calls, their arguments, the results, and the token cost. When something goes wrong in production, that trace is the difference between a five-minute fix and a five-hour investigation.
- Step traces: Every decision the agent made, in order, with inputs and outputs.
- Token and cost tracking: Per-run and per-step, so a runaway loop shows up on a dashboard before it shows up on a bill.
- Success signals: Did the agent reach a final answer, hit the step limit, or error out?
- Latency breakdown: Time spent in the model versus time spent in tools — they need very different fixes.
Evaluating Agents Before They Ship
You cannot eyeball your way to a reliable agent. I maintain a suite of test cases — real inputs paired with expected behaviors — and run the agent against them on every change. The evaluation isn't about matching exact wording; it's about checking whether the agent took the right actions and stayed within its guardrails.
# A behavioral eval: did the agent do the right things?
test_cases = [
{
"goal": "Where is my order? My email is jane@example.com",
"must_call": ["search_orders"],
"must_not_call": ["issue_refund"],
},
{
"goal": "Refund my last order",
"must_call": ["search_orders"],
"requires_confirmation": True, # never auto-refund
},
]
def evaluate(agent, cases) -> float:
passed = 0
for case in cases:
trace = agent.run(case["goal"])
called = {step.tool for step in trace.steps}
ok = set(case.get("must_call", [])) <= called
ok &= not (set(case.get("must_not_call", [])) & called)
if case.get("requires_confirmation"):
ok &= trace.paused_for_confirmation
passed += ok
return passed / len(cases)
Lessons From Production
After building and running agents across several projects, these are the lessons that stuck:
- Constrain, then loosen. Start with a tightly scoped agent and expand its autonomy only once you trust it. The reverse is how you end up debugging in production.
- Tools over prompting. When the agent misbehaves, the fix is usually a better tool, not a longer prompt.
- Always have a human off-ramp. The best agents know when to stop and hand off to a person rather than guess.
- Cheaper models, more often. A fast model in the loop with good tools frequently beats a slow, expensive one asked to do everything at once.
- Log everything. The trace you didn't capture is always the one you needed.
Agentic AI is moving fast, but the fundamentals are stable: give the model good tools, keep the loop observable, enforce guardrails, and measure behavior instead of hoping for it. Build on those, and you get agents that don't just demo well — they hold up when it counts.