Originally published on tamiz.pro.
The initial euphoria surrounding Large Language Models (LLMs) was largely driven by their ability to generate code. We saw demos of developers asking an LLM to write a React component, a Python data pipeline, or a SQL query, and watching it appear instantly. This capability, while impressive, is fundamentally different from the engineering challenges posed by AI Agents. A code generator is a stateless tool; an AI Agent is a stateful, autonomous entity that interacts with external systems, makes decisions, and executes actions over time.
Treating an AI Agent like a code generator is the primary reason most AI projects fail to reach production. When you move from generating static artifacts to orchestrating dynamic, multi-step workflows, the complexity shifts from syntax correctness to systemic reliability. In this deep dive, we explore why production-grade AI Agents require a paradigm shift in engineering, focusing on three critical pillars: rigorous state management, comprehensive observability, and deterministic control flows.
To understand the engineering gap, we must first distinguish between what a code generator does and what an agent does.
A Code Generator operates in a Request -> Response
loop. The input is a prompt; the output is a snippet of code. The LLM does not retain memory between calls, does not modify external state (like a database), and does not decide its own next steps based on runtime errors. It is a function with high entropy.
An AI Agent is a loop. It observes the environment, reasons about the next best action, executes that action (often via tools or APIs), and observes the result. This creates a feedback loop:
Observe -> Think -> Act -> Observe -> Think -> ...
This loop introduces several engineering complexities that static code generation does not:
In traditional software engineering, state is managed via variables, database records, or session stores. In AI Agents, state is fragmented between the LLM’s context window and external systems. This fragmentation is a major source of bugs.
Consider a simple agent tasked with "Research a topic and write a report." A naive implementation might pass the entire conversation history to the LLM at every step. As the conversation grows, the context window fills up, leading to:
Production agents should not rely solely on the LLM’s memory. Instead, they should use an explicit state machine or a structured data store to track progress.
Instead of dumping raw text into the context, maintain a structured JSON object that represents the current state of the task. This state should include:
interface AgentState {
taskId: string;
status: 'initializing' | 'researching' | 'drafting' | 'reviewing' | 'completed' | 'failed';
progress: {
researchSteps: number;
totalResearchSteps: number;
sourcesConsulted: string[];
};
context: {
keyFacts: string[];
userPreferences: Record<string, any>;
};
metadata: {
createdAt: Date;
updatedAt: Date;
lastError?: string;
};
}
If an agent crashes or times out, you need to resume from the last known good state, not start over. This requires persisting the AgentState
to a database (e.g., PostgreSQL, Redis) at critical checkpoints.
When the agent resumes, it loads the state, reconstructs the context from the key facts and progress, and continues from where it left off. This is crucial for long-running tasks.
Use techniques like context summarization or vector retrieval to manage the LLM’s context window. Instead of passing the entire history, pass:
AgentState
.This reduces token usage and keeps the LLM focused on relevant information.
You cannot improve what you cannot measure. In traditional software, we use logs, metrics, and traces. With AI Agents, these must be adapted to handle non-deterministic, multi-step workflows.
Traditional logs are linear and event-based. An AI Agent’s execution is a graph of nodes and edges. A simple log line like "Tool called: search_api"
is insufficient because it doesn’t capture:
Adopt distributed tracing frameworks (like OpenTelemetry) that are LLM-aware. Each step in the agent’s workflow should be a span in the trace.
Here’s how you might instrument an agent step using OpenTelemetry:
const tracer = opentelemetry.trace.getTracer('ai-agent-tracer');
async function executeResearchStep(agentState, toolClient) {
return await tracer.startActiveSpan('research.step', async (span) => {
try {
// Log the prompt sent to the LLM
span.setAttribute('llm.prompt', agentState.researchPrompt);
// Call the LLM
const response = await llmClient.generate({
prompt: agentState.researchPrompt,
model: 'gpt-4-turbo'
});
// Log token usage
span.setAttribute('llm.usage.total_tokens', response.usage.total_tokens);
span.setAttribute('llm.usage.prompt_tokens', response.usage.prompt_tokens);
span.setAttribute('llm.usage.completion_tokens', response.usage.completion_tokens);
// Parse the response to extract tool calls
const toolCalls = parseToolCalls(response.text);
// Execute tool calls
for (const call of toolCalls) {
const toolResult = await toolClient.execute(call);
span.setAttribute(`tool.${call.name}.result`, toolResult);
}
span.setStatus({ code: opentelemetry.SpanStatusCode.OK });
return response;
} catch (error) {
span.recordException(error);
span.setStatus({ code: opentelemetry.SpanStatusCode.ERROR, message: error.message });
throw error;
} finally {
span.end();
}
});
}
Use dashboards (like LangSmith, Phoenix, or Arize) to visualize traces. This allows you to:
LLMs are probabilistic. They are great at creative tasks but terrible at deterministic logic. Production agents must separate the "thinking" part (handled by the LLM) from the "acting" part (handled by deterministic code).
Do not let the LLM control the entire workflow. Instead, use the LLM for:
Use deterministic code for:
Never trust the LLM’s output blindly. Always validate it against a schema.
import { z } from 'zod';
const ToolCallSchema = z.object({
tool: z.enum(['search', 'extract', 'summarize']),
params: z.object({
query: z.string(),
filters: z.object({ dateRange: z.string().optional() }).optional()
})
});
async function safeToolCall(llmResponse: string) {
try {
// Parse the LLM's response
const parsed = JSON.parse(llmResponse);
// Validate against the schema
const validated = ToolCallSchema.parse(parsed);
// Execute the tool
return await executeTool(validated.tool, validated.params);
} catch (error) {
// Handle validation errors gracefully
if (error instanceof z.ZodError) {
logger.warn('Invalid tool call structure', { error: error.errors });
return { error: 'Invalid tool call structure' };
}
throw error;
}
}
Since LLM outputs are non-deterministic, you need strategies to handle variability:
Do not build a complex, multi-agent system from day one. Start with a single-agent workflow that solves a specific problem. Get the state management, observability, and control flows right for that one agent. Then, gradually add complexity.
AI Agents can be vulnerable to injection attacks, prompt injection, and data leakage. Always:
AI is not "set and forget." Continuously monitor your agents’ performance. Look for:
Use this data to refine prompts, optimize state management, and improve observability.
Moving from a code generator to a production AI Agent is not just a scaling problem; it is a fundamental engineering challenge. It requires a shift in mindset from writing static code to orchestrating dynamic, stateful workflows.
By implementing rigorous state management, comprehensive observability, and deterministic control flows, you can build AI Agents that are reliable, cost-effective, and secure. These pillars are not optional extras; they are the foundation of any production-grade AI system.
The future of AI engineering is not just about better models; it’s about better systems. Master these engineering principles, and you will be well-equipped to build the next generation of intelligent applications.
A: Yes, but with caution. These frameworks are excellent for prototyping and providing abstractions for state and observability. However, for production, you often need to customize or extend them to meet specific requirements for security, performance, and cost. Avoid treating them as "black boxes"; understand the underlying mechanics.
A: Use persistent state storage (like a database) to save the agent’s state at regular intervals. Implement a mechanism to resume the agent from the last checkpoint. Consider using event-driven architectures (like AWS Lambda or Azure Functions) to trigger agent steps asynchronously.
A: Use distributed tracing to visualize the agent’s execution flow. Look for steps where the LLM made unexpected decisions or where tool calls failed. Analyze the prompts and responses for those steps to identify issues. Use A/B testing to compare different prompts or models.
For more insights on building production-ready AI systems, visit Tamiz's Insights.