AI Agents Explained — The 3am Incident That Broke Our Multi-Agent Orchestrator
Learn how AI agents work under the hood, avoid the 3am pager from a runaway agent loop, and build production-grade autonomous systems with Python and LangGraph..
20+ years shipping production ML systems and the infrastructure behind them. Lessons pulled from things that broke in production.
- ✓Basic programming fundamentals
- ✓A computer with internet access
- ✓Willingness to follow along with examples
- Agent Loop The core loop that calls an LLM, parses the response, and executes a tool — if not bounded, it can spin forever, burning $400/hr in tokens.
- Tool Execution Each tool call is a side effect; a buggy tool can corrupt state or trigger cascading failures across agents.
- Memory Window Agents with finite context windows will silently drop old messages, causing hallucinations or task abandonment.
- Orchestrator Pattern A single orchestrator agent managing sub-agents creates a single point of failure; a crash in the orchestrator loses all sub-agent progress.
- Structured Output Using pydantic models for agent responses prevents parsing errors that crash the pipeline at 2am.
- Observability Without tracing every LLM call and tool execution, debugging a multi-agent system is impossible.
AI agents are autonomous software systems that use large language models (LLMs) as their reasoning engine to plan, execute, and iterate on tasks without step-by-step human instructions. Unlike a simple chatbot that responds to a single prompt, an agent maintains state, calls external tools (APIs, databases, code interpreters), and loops through a 'think-act-observe' cycle until it achieves a goal or hits a termination condition.
Under the hood, this is typically implemented as a state machine: the LLM generates structured output (e.g., JSON with a 'tool_call' field), which triggers a function, whose result is fed back into the next LLM call. Frameworks like LangGraph, CrewAI, and AutoGen formalize this as directed graphs of nodes (LLM calls, tool executions, human-in-the-loop gates) connected by edges that define control flow.
Multi-agent orchestrators extend this by running several specialized agents concurrently, each with its own prompt, tools, and memory, coordinated by a supervisor agent or a shared message bus. The 3am incident referenced in the article — where agents entered an infinite retry loop, burning through $200 in API credits while logging contradictory decisions — is a classic failure mode: agents hallucinate tool outputs, misinterpret state, or deadlock when their goals conflict.
This is why production systems require strict guardrails: max iteration limits, idempotent tool calls, and observability pipelines that log every state transition with timestamps and token counts.
AI agents are not a silver bullet. They are overkill for deterministic workflows (use a script or RPA), for simple Q&A over static documents (use RAG), or for tasks requiring high precision with zero hallucination tolerance (use fine-tuned models with constrained decoding).
Agents shine in open-ended, multi-step scenarios like automated code debugging, complex data extraction across APIs, or dynamic research synthesis. The key trade-off is latency and cost: each agent loop consumes tokens for reasoning and tool calls, so you must cache identical LLM responses, rate-limit external APIs, and set hard budget caps.
Tools like LangSmith or Weights & Biases provide the observability to catch the 3am spiral before it drains your account.
Think of an AI agent like a very eager intern who can use any tool in the office but has no sense of time. You give them a task, they start making phone calls, sending emails, and searching the web. If you don't give them a strict deadline and a way to report back, they'll keep working forever, burning through your budget and never telling you they're stuck. A production AI agent is that intern with a stopwatch, a notepad, and a manager who checks in every 30 seconds.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
We rolled out a multi-agent system to handle customer support tickets. Three agents: one for triage, one for knowledge base lookup, one for escalation. The first week was magic — 80% of tickets resolved without human touch. Then the pager went off at 3am. A single ticket about 'printer not working' had triggered a 47-minute agent loop, called the knowledge base API 1,200 times, and racked up $340 in OpenAI costs. The agent was stuck in a loop: look up 'printer', get vague answer, ask for clarification, look up 'printer troubleshooting', get another vague answer, repeat. No timeout, no max retries, no circuit breaker.
How AI Agents Actually Work Under the Hood
An AI agent is not magic — it's a loop. The loop calls an LLM, gets a structured response (usually a JSON with 'action' and 'action_input'), executes the action (a function call), appends the result to the message history, and repeats. The LLM decides when to stop by returning a 'final_answer' action. The tricky part is that the LLM has no inherent concept of time or cost. It will keep generating actions until it thinks the task is done, which may be never. The abstraction you should care about is the context window. Every loop iteration adds tokens. After ~10 iterations with tool results, you can easily hit 8k tokens. If your LLM's max context is 4k, older messages get silently dropped, causing the agent to 'forget' the original task. This is why you need to explicitly manage the context window — either by summarizing old messages or using a sliding window.
import json from openai import OpenAI from pydantic import BaseModel, Field from typing import Literal # Define the structured output schema for the agent's response class AgentAction(BaseModel): action: Literal["search_knowledge_base", "calculate", "final_answer"] action_input: str = Field(description="Input for the action") reasoning: str = Field(description="Why this action was chosen") client = OpenAI() # Tool implementations def search_knowledge_base(query: str) -> str: # In production, this would call a real API return f"Results for '{query}': No relevant documents found." def calculate(expression: str) -> str: try: return str(eval(expression)) except Exception as e: return f"Error: {e}" def execute_tool(action: AgentAction) -> str: if action.action == "search_knowledge_base": return search_knowledge_base(action.action_input) elif action.action == "calculate": return calculate(action.action_input) else: raise ValueError(f"Unknown action: {action.action}") # Agent loop with bounded iterations def run_agent(task: str, max_iterations: int = 10) -> str: messages = [{"role": "user", "content": task}] for i in range(max_iterations): response = client.beta.chat.completions.parse( model="gpt-4o", messages=messages, response_format=AgentAction, ) action = response.choices[0].message.parsed if action.action == "final_answer": return action.action_input tool_result = execute_tool(action) messages.append({"role": "assistant", "content": f"Action: {action.action}\nInput: {action.action_input}\nReasoning: {action.reasoning}"}) messages.append({"role": "tool", "content": tool_result, "tool_call_id": str(i)}) return "Max iterations reached without final answer." print(run_agent("What is 2 + 2?"))
response_format with pydantic models to guarantee a parseable response. We learned this when 2% of our agent calls crashed with JSONDecodeError at 2am.Practical Implementation: Building a Multi-Agent Orchestrator with LangGraph
LangGraph is the de facto framework for building multi-agent systems in production. It models agents as nodes in a directed graph, with edges defining the flow. The key insight is that each node is a function that takes state and returns state. The graph's executor runs the nodes in order, handling branching and cycles. The gotcha is state management. Each node can modify the shared state, and if two nodes modify the same key concurrently, you get race conditions. LangGraph handles this with a reducer pattern — you define how to merge updates to each state key. In production, we use a single reducer that appends to a list, so no data is lost. Another gotcha: the graph's recursion limit. By default, LangGraph limits recursion to 25 steps. If your agent needs more, you must increase it explicitly. We hit this when a complex workflow required 30 steps, and the graph silently stopped at 25.
from langgraph.graph import StateGraph, END from typing import TypedDict, Annotated, List import operator # Define the shared state class AgentState(TypedDict): messages: Annotated[List[dict], operator.add] # Reducer appends to list next_agent: str final_output: str # Node functions def triage_agent(state: AgentState) -> AgentState: # In production, this would call an LLM to classify the task if "math" in state["messages"][-1]["content"]: return {"next_agent": "math_agent"} else: return {"next_agent": "general_agent"} def math_agent(state: AgentState) -> AgentState: # Simple math logic result = eval(state["messages"][-1]["content"]) return {"messages": [{"role": "assistant", "content": f"Result: {result}"}], "final_output": str(result)} def general_agent(state: AgentState) -> AgentState: return {"messages": [{"role": "assistant", "content": "I can't help with that yet."}], "final_output": "Unsupported"} def router(state: AgentState) -> str: # Decide which node to go to next if state["next_agent"] == "math_agent": return "math_agent" elif state["next_agent"] == "general_agent": return "general_agent" else: return END # Build the graph builder = StateGraph(AgentState) builder.add_node("triage_agent", triage_agent) builder.add_node("math_agent", math_agent) builder.add_node("general_agent", general_agent) builder.set_entry_point("triage_agent") builder.add_conditional_edges("triage_agent", router) builder.add_edge("math_agent", END) builder.add_edge("general_agent", END) graph = builder.compile() # Run the graph result = graph.invoke({"messages": [{"role": "user", "content": "What is 3 * 7?"}], "next_agent": "", "final_output": ""}) print(result["final_output"])
graph.compile(recursion_limit=100) explicitly. We learned this when a complex customer support flow silently failed after 25 steps.When NOT to Use AI Agents
AI agents are not the right tool for every problem. If your task is a simple, deterministic workflow (e.g., 'if this, then that'), use a rules engine or a simple script. Agents add latency, cost, and failure modes. Specifically, avoid agents when: 1) The decision logic is deterministic and well-defined. 2) The cost of a wrong action is high (e.g., deleting a database record). 3) You need guaranteed response times — LLM calls have unpredictable latency. 4) The task requires no external tools or data. A simple LLM call with a prompt is cheaper and faster. We made this mistake with a password reset flow. We used an agent to decide whether to send a reset email. The agent sometimes decided to 'call the user' instead, which was not implemented. The fix was to replace the agent with a simple if-else statement.
# Bad: Using an agent for a deterministic decision # This is over-engineered and fragile import openai response = openai.chat.completions.create( model="gpt-4o", messages=[{"role": "user", "content": "Should we send a password reset email to user@example.com?"}] ) # The LLM might say 'yes', 'no', or 'call them' # Good: Use a simple rule if user_exists and user_requested_reset: send_reset_email(user_email) else: log_error("Invalid reset request")
Production Patterns & Scale: Caching, Rate Limiting, and Observability
At scale, AI agents consume a lot of resources. A single agent doing 10 tool calls per session, with 1,000 sessions per hour, generates 10,000 tool calls per hour. If each tool call takes 500ms, that's 5,000 seconds of compute time per hour. You need caching for repeated tool calls (e.g., knowledge base lookups for the same query). You need rate limiting to protect downstream APIs. And you need observability to debug failures. The most important metric is token usage per session. Set alerts for sessions that exceed 10,000 tokens. Also track tool call latency and error rates. We use OpenTelemetry to trace every LLM call and tool execution. The trace includes the input, output, latency, and token count. This allows us to replay any session for debugging.
import hashlib import time from functools import lru_cache # Simple in-memory cache for tool results @lru_cache(maxsize=1000) def cached_search(query: str) -> str: # Simulate a slow API call time.sleep(0.5) return f"Result for {query}" # Rate limiter using a token bucket class RateLimiter: def __init__(self, max_calls: int, period: float): self.max_calls = max_calls self.period = period self.tokens = max_calls self.last_refill = time.monotonic() def acquire(self) -> bool: now = time.monotonic() elapsed = now - self.last_refill self.tokens = min(self.max_calls, self.tokens + elapsed * (self.max_calls / self.period)) self.last_refill = now if self.tokens >= 1: self.tokens -= 1 return True return False limiter = RateLimiter(max_calls=10, period=1.0) # 10 calls per second def rate_limited_search(query: str) -> str: if not limiter.acquire(): raise Exception("Rate limit exceeded. Retry later.") return cached_search(query) # Production agent loop with observability def run_agent_with_telemetry(task: str) -> str: start_time = time.monotonic() token_count = 0 try: result = run_agent(task) # From earlier example token_count = len(task) + len(result) # Simplified return result finally: elapsed = time.monotonic() - start_time # In production, send to OpenTelemetry print(f"TRACE: task={task[:50]}, duration={elapsed:.2f}s, tokens={token_count}")
Common Mistakes with Specific Examples
Mistake 1: Not validating tool inputs. An agent might call a tool with a SQL injection payload if you're not careful. Always sanitize inputs. Mistake 2: Ignoring the context window. If the agent's context exceeds the model's limit, older messages are dropped silently. This causes the agent to 'forget' the original task. Mistake 3: Using a single agent for everything. A single agent with too many tools becomes confused. Split responsibilities across specialized agents. Mistake 4: Not handling tool failures gracefully. If a tool returns an error, the agent might retry indefinitely or crash. Implement retries with backoff and a max retry count. Mistake 5: Not testing with real-world data. Synthetic tests don't capture the ambiguity of real user queries. We once tested with 'What is the weather?' and deployed, only to find that real users asked 'What's the weather like in Tokyo next Tuesday?' which required a date parser the agent didn't have.
# Mistake 1: Not validating tool inputs # Bad: Agent can call search with any input def search(query: str): return requests.get(f"https://api.example.com/search?q={query}").json() # SQL injection risk # Good: Validate and sanitize import re def safe_search(query: str): sanitized = re.sub(r'[^a-zA-Z0-9 ]', '', query) return requests.get(f"https://api.example.com/search?q={sanitized}").json() # Mistake 2: Not handling context window # Bad: Appending all messages indefinitely messages.append(tool_result) # Good: Summarize or truncate old messages def summarize_messages(messages: list, max_tokens: int = 3000): # In production, use an LLM to summarize if len(str(messages)) > max_tokens: return [{"role": "system", "content": f"Previous context: {messages[0]['content'][:100]}..."}] return messages
Comparison vs Alternatives: Agents vs RAG vs Fine-Tuning
Agents are not always the best solution. For question-answering over a fixed knowledge base, RAG (Retrieval-Augmented Generation) is simpler and more reliable. For specialized tasks with fixed output formats, fine-tuning a model is cheaper and faster. Agents are best when the task requires multiple steps, tool use, and adaptation. The trade-off is complexity and cost. A RAG system costs ~$0.01 per query. An agent costs ~$0.10 per query. But an agent can handle tasks a RAG system cannot, like booking a flight or debugging code. The decision matrix: if the task is a single-turn Q&A, use RAG. If the task is multi-turn with tool use, use an agent. If the task is a fixed, repetitive pattern, fine-tune a model.
# RAG example: Simple and cheap from openai import OpenAI client = OpenAI() def rag_query(query: str, context: str) -> str: response = client.chat.completions.create( model="gpt-4o-mini", # Cheaper model messages=[ {"role": "system", "content": f"You are a helpful assistant. Use this context to answer: {context}"}, {"role": "user", "content": query} ] ) return response.choices[0].message.content # Agent example: More capable but expensive def agent_query(query: str) -> str: # Agent loop with tool calls return run_agent(query) # From earlier example # When to use what: # - RAG: "What is the capital of France?" (single-turn, factual) # - Agent: "Book a flight to Paris for next Tuesday, then email me the confirmation" (multi-step, tool use)
Debugging and Monitoring AI Agents in Production
Debugging an agent in production is hard because the behavior is non-deterministic. The same input can produce different outputs. You need to log everything: the LLM response, the tool inputs and outputs, the state at each step, and the final output. Use a trace ID to correlate all logs for a single session. The most common debugging scenario is 'the agent returned the wrong answer'. You need to replay the session step by step. We built a replay tool that takes a trace ID and re-executes the agent with the same inputs, printing each step. This allows us to see exactly where the agent went wrong. Another common issue is 'the agent is slow'. Profile each step: LLM call latency, tool call latency, and state processing time. We found that 80% of latency was from LLM calls, and 20% from tool calls.
import json import time # Trace decorator to log every step def trace_step(func): def wrapper(*args, **kwargs): start = time.monotonic() result = func(*args, **kwargs) elapsed = time.monotonic() - start trace = { "function": func.__name__, "args": args, "kwargs": kwargs, "result": str(result)[:200], "duration": elapsed } print(f"TRACE: {json.dumps(trace)}") return result return wrapper @trace_step def llm_call(prompt: str) -> str: # Simulate LLM call time.sleep(0.5) return f"Response to: {prompt[:50]}" @trace_step def tool_call(name: str, input: str) -> str: # Simulate tool call time.sleep(0.2) return f"Result from {name}" # Replay a session from a trace with open("trace_123.json", "r") as f: trace_data = json.load(f) for step in trace_data["steps"]: print(f"Step {step['step']}: {step['function']} called with {step['args']}") print(f" Result: {step['result']}") print(f" Duration: {step['duration']:.2f}s")
The Agent's Nervous System: Memory, Context, and State Management
Here's what I learned rebuilding a customer support agent after it kept forgetting the user's name mid-conversation. Without explicit state management, your agent isn't intelligent — it's just a fancy autocomplete with amnesia.
LLMs are stateless. Every call is a fresh start. Your job is to rebuild the world for them each time. You need three types of memory: short-term (recent messages), working (current task context), and long-term (user preferences, past interactions).
Store short-term memory in a sliding window buffer — don't dump the entire 200-message history. Use vector embeddings for long-term memory retrieval. For working memory, serialize the agent's current goal stack and partial results into the system prompt.
Why this matters in production: Without proper memory management, your agent will ask the same question twice, lose track of multi-step workflows, and frustrate users who expect continuity. We burned 48 hours debugging a 'loop' that was actually the agent forgetting it already tried a solution.
# io.thecodeforge from typing import List, Dict import json class AgentMemory: def __init__(self, window_size: int = 10): self.short_term: List[Dict] = [] # sliding window self.long_term: Dict[str, List[float]] = {} # user_id -> embedding self.window_size = window_size def add_interaction(self, role: str, content: str): self.short_term.append({"role": role, "content": content}) if len(self.short_term) > self.window_size: self.short_term.pop(0) def get_context(self) -> str: conversation = "" for msg in self.short_term: conversation += f"{msg['role']}: {msg['content']}\n" return conversation
Tool Calling: Why Your Agent Keeps Inventing APIs That Don't Exist
You've seen it happen. You give your agent a calculator tool, and it decides to call a def method you never wrote. That's hallucination at the orchestration layer.calculate_taxes()
Tool calling is the agent's ability to decide when to call external functions. The LLM outputs a structured JSON requesting a tool call, then your runtime intercepts that, executes the real function, and feeds the result back. Simple in theory.
The failure mode: The LLM invents function names or arguments because your descriptions are ambiguous. Fix this by using strict JSON schema definitions. Each tool needs a clear name, parameter types, and a one-line description of what it does and when to use it.
Production rule: Never let the agent see the source code. Expose only the schema. We learned this after an agent tried to import a Python module that didn't exist because it 'read' a fictional import statement from its training data.
Another hard-learned lesson: Implement mandatory confirmation for destructive actions. If the agent calls 'delete_user', your code should always ask the human first. Our agent once deleted a test database because the tool description said 'removes records'.
# io.thecodeforge from pydantic import BaseModel from typing import Literal class CalculatorTool(BaseModel): name: Literal["calculator"] = "calculator" parameters: dict = { "type": "object", "properties": { "expression": { "type": "string", "description": "Mathematical expression to evaluate (e.g., '2 + 2')" } }, "required": ["expression"] } class Config: extra = "forbid" # prevents made-up parameters
The Runaway Agent: $340 in 47 Minutes
while loop in the orchestrator ran until the LLM returned a 'final_answer' action. The LLM kept generating 'search_knowledge_base' actions because the results were always ambiguous.max_iterations=10 parameter to the agent loop.
2. Implemented a timeout of 120 seconds per agent session.
3. Added a circuit breaker that kills the agent after 5 consecutive failed tool calls.
4. Logged all tool call inputs and outputs for post-mortem analysis.
``python
# Before:
while action.type != "final_answer":
action = llm.invoke(messages)
result = execute_tool(action)
messages.append(result)
# After:
for i in range(MAX_ITERATIONS):
if time.monotonic() - start_time > TIMEOUT_SECONDS:
raise TimeoutError("Agent exceeded timeout")
action = llm.invoke(messages)
if action.type == "error":
consecutive_errors += 1
if consecutive_errors >= 5:
raise CircuitBreakerError("Too many consecutive errors")
else:
consecutive_errors = 0
result = execute_tool(action)
messages.append(result)
``- Always set a hard limit on agent iterations and wall-clock time before deploying to production.
- Monitor token usage per session and alert on anomalies — not just total cost.
- Implement circuit breakers for tool calls; a flaky API should not crash the entire agent.
kubectl logs <pod> | grep 'iteration' | tail -20 to see if it's stuck in a loop. If iteration count is > 10, you have a loop.SELECT session_id, COUNT(*) as calls, SUM(token_count) as tokens FROM agent_traces WHERE timestamp > NOW() - INTERVAL '1 hour' GROUP BY session_id ORDER BY tokens DESC LIMIT 5; — find the runaway session.python -c "import json; data=json.load(open('agent_messages.json')); print(json.dumps(data[-5:], indent=2))" to see if the context was truncated or corrupted.curl -s -o /dev/null -w "%{http_code}" https://api.example.com/health to verify the tool is up. If rate limited, add exponential backoff to the tool executor.kubectl exec <pod> -- cat /proc/<pid>/fd/1 | grep 'iteration' | tail -5python -c "import json; traces=json.load(open('traces.json')); print([t for t in traces if t['iterations'] > 10])"max_iterations=10 in the agent config and restart the pod.python -c "import json; sessions=json.load(open('sessions.json')); print(max(sessions, key=lambda s: s['total_tokens']))"curl -X POST https://api.openai.com/v1/usage -H 'Authorization: Bearer $OPENAI_API_KEY' | jq '.data | sort_by(.amount) | reverse | .[0]'max_tokens=4096 and kill the session if exceeded.python -c "import json; msgs=json.load(open('messages.json')); print([m['content'][:200] for m in msgs[-3:]])"curl -X POST https://api.openai.com/v1/chat/completions -H 'Authorization: Bearer $OPENAI_API_KEY' -d '{"model":"gpt-4","messages":[{"role":"user","content":"test"}]}' | jq '.choices[0].message.content'content is null or empty, retry with a different prompt or model.curl -s -D - https://api.example.com/endpoint | grep -i 'x-ratelimit'python -c "import time; [print(f'Wait {2**i}s') for i in range(5)]"time.sleep(min(2**retry_count, 60)) before retrying the tool call.| Concern | AI Agents | RAG | Fine-Tuning | Recommendation |
|---|---|---|---|---|
| Latency | High (multiple LLM calls + tool execution) | Medium (single LLM call + retrieval) | Low (single LLM call) | Use RAG or fine-tuning for real-time apps |
| Cost per query | High (multiple tokens + tool API costs) | Medium (retrieval + LLM call) | Low (single LLM call) | Use agents only when dynamic tool use is required |
| Accuracy on dynamic data | High (can fetch live data) | High (retrieves from updated index) | Low (static knowledge cutoff) | Use agents or RAG for live data |
| Complexity to implement | High (state machine, tool registry, error handling) | Medium (embedding pipeline, retriever) | Medium (data prep, training pipeline) | Start with RAG, add agents only if needed |
| Debugging difficulty | Very high (multi-step, non-deterministic) | Medium (retrieval quality issues) | Low (model outputs only) | Avoid agents unless you have dedicated observability |
| Best use case | Multi-step tasks requiring tool use (e.g., booking flights) | Question answering over a knowledge base | Consistent style or domain-specific tasks | Match to your primary requirement |
| File | Command / Code | Purpose |
|---|---|---|
| agent_loop.py | from openai import OpenAI | How AI Agents Actually Work Under the Hood |
| multi_agent_graph.py | from langgraph.graph import StateGraph, END | Practical Implementation |
| dont_use_agent.py | response = openai.chat.completions.create( | When NOT to Use AI Agents |
| production_agent.py | from functools import lru_cache | Production Patterns & Scale |
| common_mistakes.py | def search(query: str): | Common Mistakes with Specific Examples |
| rag_vs_agent.py | from openai import OpenAI | Comparison vs Alternatives |
| debug_agent.py | def trace_step(func): | Debugging and Monitoring AI Agents in Production |
| memory_manager.py | from typing import List, Dict | The Agent's Nervous System |
| tool_schema.py | from pydantic import BaseModel | Tool Calling |
Key takeaways
Common mistakes to avoid
4 patternsNo max iterations on agent loop
add_condition_edges to route to an END node after N steps.Allowing agents to call themselves or other agents without a circuit breaker
No caching of tool outputs
functools.lru_cache or Redis) keyed by (tool_name, hash(input)). Invalidate on conversation reset. Our fix: 98% cache hit rate, reduced average tool latency from 3s to 15ms.No per-step observability on agent state
Interview Questions on This Topic
Explain how an AI agent works under the hood. What is the core loop?
How would you design a multi-agent orchestrator that prevents infinite loops and runaway costs?
What are the trade-offs between using a single agent with many tools vs multiple specialized agents?
How do you debug a multi-agent system that produces inconsistent results?
Design a caching strategy for tool outputs in a multi-agent system. What are the edge cases?
Frequently Asked Questions
A simple LLM call is a single request-response. An agent is a loop: LLM decides which tool to call → executes tool → feeds result back to LLM → repeats until a stop condition (e.g., final answer, max steps). The loop is the key differentiator.
Use a constrained output format (e.g., JSON schema with Pydantic), validate tool names against a whitelist before execution, and set temperature=0 for tool-calling decisions. Never let the agent parse free-form text as a tool call.
Use LangGraph when you need a state machine with multiple agents, branching, and conditional edges. Build your own loop only for a single-agent, single-tool scenario — otherwise you'll reimplement LangGraph's StateGraph and debugging tools.
Track per-step token usage (input + output), multiply by model cost per token, and log to a metrics dashboard. Set a budget per conversation (e.g., $0.50 max) and kill the agent if exceeded. Our 3am incident cost $2,300 because we had no per-conversation budget.
Only if you set aggressive timeouts (e.g., 5 seconds total) and limit tool calls to 1-2 steps. Agents are inherently slower than RAG because of the loop overhead. For sub-second responses, use RAG or a fine-tuned model instead.
20+ years shipping production ML systems and the infrastructure behind them. Lessons pulled from things that broke in production.
That's AI Agents. Mark it forged?
5 min read · try the examples if you haven't