TIL - Choosing Between Code, an LLM Call, and an AI Agent A developer exploring the definition of AI agents built a Go service with four endpoints of increasing complexity, from a stateless LLM call to a multi-tool agent loop. The project reveals that an agent is simply a loop that sends tool descriptions to the model and processes tool-call responses, and that most agent challenges—tool dispatch, idempotency, runaway loops—map to familiar distributed-systems problems like RPC, sagas, and circuit breakers. Two questions sent me down this path. Question one: what is an "AI agent," really? Most job posts mention them. I had not looked into it deeply, and from the outside I could not tell what it referred to. Is an agent a different endpoint? A different model? A library we install? Or is it just a name for calling an LLM API in some particular way? Question two came later, and it stopped me cold. Once I understood the mechanics and sat down to pick something to build, I could not come up with a single idea that I could not also write as ordinary Go: a function that calls some APIs, hits a database, and returns a result. If plain code can do it, why pay a model to do it worse? This is what I found. There is a real answer to both, and the second one has a trap in it. Code: github.com/Arkoes07/llm https://github.com/Arkoes07/llm One Go service, one interface, four endpoints of increasing complexity: POST /chat/no-memory one stateless LLM call POST /chat multi-turn, history per session POST /chat/agent/weather tool-calling loop, one tool POST /chat/agent/log-triage tool-calling loop, three tools No framework. Hand-rolled net/http first, then a second implementation behind the same interface using an SDK, so I could see exactly what the SDK was doing for me. Answer: it removed boilerplate, not thinking. The loop logic was identical in both . Worth noting: Groq's official SDKs are Python and JS only, so in Go we are on community libraries or an OpenAI-compatible client. Starting at a plain call and walking up to an agent one step at a time turned out to be the whole answer to question one. Here is the version with no vocabulary. A plain LLM call is a stateless HTTP request. We send messages, we get text back. The model remembers nothing. "Conversation history" is state we store and resend every time. That is /chat/no-memory and /chat above, and the only difference between them is who keeps the array. An agent adds one thing: we also send a list of tools, described as JSON schemas. Now the model can respond with "call get metrics with {"service":"checkout"} " instead of a final answer. So we loop: That is the difference. Same endpoint, same model, about 150 lines of extra Go. Every agent framework is this loop plus state persistence, retries, and tracing. What I realized while building it is that most of the hard parts are things we already know under different names: | Agent problem | What it actually is | |---|---| | Tool dispatch + schema validation | RPC dispatch | | Model calls the same tool twice | Idempotency | | Multi-step work that must not half-complete | Saga / compensation | | Context window overflow | Bounded cache with an eviction policy | | Untrusted text in the prompt issuing instructions | An authz boundary | | Runaway loop | Circuit breaker | | "Did this actually work?" | Regression testing, for non-deterministic output | There is a reason the table lines up like that, and the loop hides it. From inside the code it looks like one API call repeated. But we call the model, the model asks for a tool, we call that tool in a real system, another service: a logging backend, a metrics API, a runbook store , then we call the model again. One request fans out across several independent services, in an order nobody fixed in advance. That is orchestration, and we already have a name for it: a distributed system. The difference is which part we cannot trust. Normally the orchestrator is our own code, the most reliable thing in the system, and we save our suspicion for the dependencies. In an agent, the orchestrator is the model. The component deciding what to call next is now the least reliable one: it picks a different sequence on identical input, it can emit a malformed request, and when it fails it usually fails plausibly instead of loudly. So the instincts still apply, we just point them somewhere new. Validate every argument. Make writes idempotent, because the caller may repeat them. Bound the loop. Treat anything a tool returns as data, never as instructions. What we already do at the edge of a system, applied to the orchestration we are used to trusting. What is actually new is a short list: prompting, structured output, evals, and retrieval. Question one, answered. Which is when question two showed up. I needed something to build. And every idea I had, I could immediately picture as normal Go. "Generate a study plan" is one LLM call with a structured output, wrapped in a function. No loop. "Classify support tickets" is one call, then a switch on the result. "Summarize a document" is one call. Every time I reached for the loop, plain code was cheaper, faster, deterministic, and testable. That is not a small objection. It is the whole question. The loop costs us money tokens , latency seconds per iteration , determinism same input, different output , and debuggability behavior lives in prose, not code . What are we buying with all that? Exactly one thing: the model chooses the next step at runtime, using information that did not exist when we wrote the code. Not intelligence, and not language. Those come from a single call. The loop buys runtime branch selection and nothing else. So the question becomes: when do we actually need that? The common framing is "simple problems go to code, complex problems go to an agent." Too vague to act on. Here is the version I would use: Can we draw the flowchart before we see the input? If yes, the decision points are enumerable and we can write the branches, so we should write them. Code is deterministic, testable, debuggable, and roughly free. An agent there is pure overhead, and the LLM's job, if it has one, is a single call inside one box: classify, extract, summarize. No loop. If no, meaning the path depends on what each step reveals and the real tree has thousands of branches we would only discover in the moment, that is the narrow case where the loop earns its cost. That criterion is why I picked log triage . Consider: if latencyHigh { checkDatabase if dbSlow { checkConnectionPool } else { checkDownstream // which one? there are 30 } } We can write this. But which downstream service? Depends on what the logs said. What if the DB check reveals a deploy changed a query plan? We did not have a branch for that. The order we investigate depends on what each step returns. That is the trigger. Not "it is complicated," but "I cannot enumerate the branches at code-writing time." Keep this criterion in mind. The rest of the post is about what happens when we pass it and then slowly undo our own answer. A fake incident with a deliberate lie in it: checkout p99 spiked at 14:03. But checkout is fine. Its DB pool is healthy 4/20 and traffic is normal. It calls payment , whose max pool size was quietly lowered 20 → 5 by a config reload at13:58. Payment saturated its pool and started timing out. Checkout is the victim, not the cause. Three tools over hardcoded data: query logs , get metrics , search runbook . Getting it right means ruling out checkout's own resources, hopping to the downstream service, and the trap widening the time window past the incident to catch that 13:58 config change. Mocked data is a feature, not a shortcut. Every failure is then unambiguously the loop's, not flaky infrastructure. iteration 1: query logs checkout, 13:55-14:05 iteration 2: get metrics checkout, latency iteration 3: get metrics checkout, error rate iteration 4: get metrics payment, latency iteration 5: get metrics checkout, request rate iteration 6: query logs payment, 13:55-14:05 iteration 7: get metrics payment, db pool iteration 8: get metrics checkout, db pool Note what is missing: search runbook , never called. Now the answer it produced: ...These symptoms match the known runbook pattern for "Database connection-pool exhaustion". Remediation per the runbook - Restore / increase the DB pool size for the payment service - Restart the payment service to clear lingering connection-wait states The root cause was correct. The provenance was invented. It attributed general knowledge to an internal document it never opened, and step 2 was not in my runbook at all. Nothing in the output looked wrong. Plausible content, authoritative formatting, correct conclusion. The only way to catch it was diffing the answer against the trace. Now imagine a real runbook saying "do NOT restart, drain connections first, page the DBA." Fix system prompt : "Only cite the runbook if you called search runbook. Otherwise say the recommendation is based on general knowledge." The lesson: an agent can reach a correct answer through invalid reasoning and give us no signal that it did. That is the argument for evals, and it is checkable. "Every source cited must appear in the tool trace" is an assertion we can write. iteration 1: query logs checkout, 13:55-14:05 iteration 2: get metrics checkout, error rate iteration 3: search runbook "payment.authorize timeout" Three calls, then it concluded: tune the client timeout, add a circuit breaker. Plausible, and wrong. It never checked whether checkout's own DB was healthy or whether traffic was abnormal. It gathered evidence for its first hypothesis instead of testing alternatives. A human had to type "continue, check the downstream." Fix system prompt : "Do not stop at the first plausible explanation. Before concluding, rule out alternatives: check whether the service's own resources are healthy and whether traffic is abnormal." iteration 6: search runbook "payment.Authorize timeout" ← searched here iteration 7: get metrics payment, latency iteration 8: query logs payment, 13:55-14:05 ← found the cause here It searched using the symptom it had at iteration 6, found the actual root cause at iteration 8, and never searched again. A human does this reflexively: it is pool exhaustion, so pull up that runbook. The loop does not re-plan by default. Fix system prompt : "After identifying a root cause, search the runbook again using the root-cause terms, not the original symptom." {"error":{"code":"tool use failed","failed generation": "