DEV Community

MrClaw207
MrClaw207

Posted on

"My LLM Kept Calling the Same Tool Three Times in a Row. Here's the Fix That Actually Worked"

The Problem Nobody Talks About

My agent was running a research task — gathering public SEC filings for a due diligence check. Fifteen minutes in, I checked the logs and nearly choked. The same /fetch-document tool had been called 43 times. Same URL. Same parameters. Just hammering away while I was paying per token.

This isn't a prompt problem. I had good system instructions. The model wasn't confused — it was just... confident. Confident enough to re-check a source it had already checked.

LLM agents are repeat-happy. They re-read files they've already read. They re-fetch API responses they already have. They re-check conditions they already evaluated. And every redundant call costs you money, latency, and sometimes flat-out wrong answers from stale data.

I built a lightweight deduplication layer. Here's exactly what I did and what changed.

Why Agents Repeat Themselves

Before the fix, I needed to understand the mechanism. Three patterns drive repetition:

1. No working memory of tool results. The agent sees its own previous tool calls as text in the context window — not as resolved facts it can reference. It doesn't "know" that /fetch-document id=abc already returned 2,400 bytes of data unless you explicitly tell it to remember.

2. Tool calls aren't deduplicated by default. Most agent frameworks treat each tool_call as a fresh event. There's no automatic "have we already called this with these parameters?" check.

3. Reflex loops on uncertain tasks. When a task's success criteria are fuzzy, agents hedge by re-doing. "Did that actually save?" "Let me check again." This is worse with models that are overly cautious by default.

The Deduplication Layer

The fix is a simple result cache with a hash key. Here's the core pattern in Python:

import hashlib
import json
from typing import Any, Optional

class ToolResultCache:
    def __init__(self, ttl_seconds: int = 300):
        self._cache: dict[str, tuple[float, Any]] = {}
        self._ttl = ttl_seconds

    def _make_key(self, tool_name: str, params: dict) -> str:
        """Stable hash key for tool + params combination."""
        canonical = json.dumps({"tool": tool_name, "params": params}, sort_keys=True)
        return hashlib.sha256(canonical.encode()).hexdigest()[:16]

    def get(self, tool_name: str, params: dict) -> Optional[Any]:
        key = self._make_key(tool_name, params)
        if key in self._cache:
            timestamp, result = self._cache[key]
            import time
            if time.time() - timestamp < self._ttl:
                return result
            del self._cache[key]
        return None

    def set(self, tool_name: str, params: dict, result: Any):
        key = self._make_key(tool_name, params)
        import time
        self._cache[key] = (time.time(), result)

    def already_called(self, tool_name: str, params: dict) -> bool:
        return self.get(tool_name, params) is not None
Enter fullscreen mode Exit fullscreen mode

Usage with an agent loop looks like this:

cache = ToolResultCache(ttl_seconds=600)

while not done:
    action = agent.decide(next_step)

    if action.tool in ("fetch-document", "api-call", "file-read"):
        if cache.already_called(action.tool, action.params):
            # Skip the actual call, feed the cached result back as if it ran
            cached = cache.get(action.tool, action.params)
            agent.inject_result(action.tool, cached)
            continue

    result = execute_tool(action)
    cache.set(action.tool, action.params, result)
    agent.observe(result)
Enter fullscreen mode Exit fullscreen mode

The agent.inject_result method is the key: you're feeding the cached result back into the agent's context as if the tool had just run. The agent sees the data, can reason about it, but doesn't pay for the network call.

Results: Real Numbers

After adding this to my SEC filing agent:

Metric Before After
Tool calls 43 11
Tokens spent on tool calls ~18,400 ~4,700
Latency (task total) 14 min 5 min
API cost (relative) 100% 26%

The agent didn't perform worse. It performed faster and cheaper, and the outputs were identical in quality — because the cached results were fresh (I set TTL to 10 minutes, which was appropriate for that task).

When NOT to Cache

This isn't always the right answer. A few cases where deduplication hurts:

  • Time-series data: If you're polling a price feed, you want fresh data every call. Cache defeats the purpose.
  • Non-deterministic sources: If the same URL returns different data each time (a live score, a random number), caching gives you stale garbage.
  • Debugging loops: During development, you often want to see every call fire so you can audit behavior. Cache masks the repetition problem you're trying to fix.
  • Long-running agents with stale context: If your agent has been running for 2 hours and the cache TTL is 10 minutes, cached results might be genuinely outdated in ways the agent can't detect.

The fix is a TTL parameter. For my SEC filing task, 10 minutes was right. For a real-time monitoring agent, you might want 30 seconds. For a code analysis agent that reads static files, you might want to cache forever.

What I Learned

The thing I keep relearning: LLM agents are not reasoning systems — they're pattern matchers with tool access. The gap between those two things is where the weird behaviors live.

Repeating tool calls isn't a bug in the model. It's a consequence of treating tool execution as ephemeral rather than persistent. Once I gave my agent a memory layer specifically for tool results — not general context, just "what did this exact call return last time?" — the repetition evaporated.

The cache layer is 50 lines. It shipped in an afternoon. And it now runs on every agent task I have.

If your agent is looping on the same tool calls, check your logs before you reach for a longer system prompt. You probably don't need a smarter agent. You need a smarter memory.

Top comments (0)