Tool Use Motivation: Why LLMs Need External Tools for Accuracy › Code Implementation: Building a Tool-Augmented System

Michael BrenndoerferFebruary 1, 20269 min read
Language AI HandbookAll chapters
Part 48 of 67Tool Use and Agents
Chapter 363Tool Use Motivation: Why LLMs Need External Tools for Accuracy › Code Implementation: Building a Tool-Augmented System
Reading section 7 of 13 · Code Implementation: Building a Tool-Augmented System · 9 minSection 7 of 13 · 9 min

Book contents

Section 7 of 13 · 9 min read

Tool Use Motivation: Why LLMs Need External Tools for Accuracy › Code Implementation: Building a Tool-Augmented SystemOne section · Sections in this chapter7 / 13 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

We implement a minimal tool-augmented system to show these concepts practically. We create a calculator tool and a simulated search tool, then build a simple orchestration system that allows an LLM (simulated here with structured logic) to invoke these tools.

First, we define our tools as Python functions with clear interfaces. Each tool is a simple function that accepts parameters and returns a string result, making the interface consistent and easy to extend.

In[7]:
Code
import math
from datetime import datetime, timedelta
from typing import Any, Callable, Dict

# Tool definitions with schemas for the LLM
TOOLS: Dict[str, Dict[str, Any]] = {
    "calculator": {
        "description": "Evaluates mathematical expressions safely",
        "parameters": {
            "expression": "string containing a mathematical expression (e.g., '2 + 2', 'sqrt(16)', '5000 * (1 + 0.045/12)**43')"
        },
    },
    "current_date": {
        "description": "Returns the current date and time",
        "parameters": {},
    },
    "weather_lookup": {
        "description": "Simulates weather lookup for a given city",
        "parameters": {
            "city": "string, name of the city",
            "days": "integer, number of days for forecast (1-5)",
        },
    },
}


def calculator(expression: str) -> str:
    """Compute mathematical expressions using Python's math module."""
    try:
        allowed_names = {
            "sqrt": math.sqrt,
            "pow": pow,
            "abs": abs,
            "round": round,
            "max": max,
            "min": min,
            "pi": math.pi,
            "e": math.e,
        }
        result = eval(expression, {"__builtins__": {}}, allowed_names)  # noqa: S307
        return str(result)
    except Exception as exc:
        return f"Error: {str(exc)}"


def current_date() -> str:
    """Return current datetime."""
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")


def weather_lookup(city: str, days: int = 1) -> str:
    """Simulated weather API (deterministic for demo)."""
    seed = sum(ord(c) for c in city.lower())
    conditions = ["sunny", "cloudy", "rainy", "partly cloudy"]
    base_temp = (seed % 30) + 5

    results = []
    for i in range(min(days, 5)):
        date = (datetime.now() + timedelta(days=i)).strftime("%Y-%m-%d")
        temp = base_temp + (i % 5) - 2
        condition = conditions[(seed + i) % len(conditions)]
        results.append(f"{date}: {condition}, {temp}°C")

    return "; ".join(results)


TOOL_REGISTRY: Dict[str, Callable] = {
    "calculator": calculator,
    "current_date": current_date,
    "weather_lookup": weather_lookup,
}

The calculator function uses a restricted namespace to limit what code can execute while still supporting the mathematical operations a user would legitimately need. The allowed_names dictionary acts as an allowlist: only the named functions and constants are accessible, and the __builtins__ key is cleared to prevent access to built-in Python functions that could perform file I/O, network calls, or other unsafe operations. This is a minimal safety mechanism suitable for a tutorial; production systems use far more reliable sandboxing such as container isolation or WebAssembly runtimes.

Next, we create a simple orchestrator that simulates how an LLM would interact with these tools. In a real system, the LLM would generate the tool calls; here we manually construct them to show the flow.

In[8]:
Code
import json
from typing import Callable, Dict, List, Tuple


class ToolAugmentedSystem:
    """
    Simulates a tool-augmented LLM system.
    In production, the LLM generates tool_calls based on the user query.
    """

    def __init__(self, tools: Dict[str, Callable], tool_schemas: Dict):
        self.tools = tools
        self.tool_schemas = tool_schemas
        self.conversation_history: List[Dict] = []

    def execute_tool(self, tool_name: str, parameters: Dict) -> str:
        """Execute a tool and return the result."""
        if tool_name not in self.tools:
            return f"Error: Tool '{tool_name}' not found"

        try:
            result = self.tools[tool_name](**parameters)
            return result
        except Exception as exc:
            return f"Error executing {tool_name}: {str(exc)}"

    def process_query(
        self, user_query: str, planned_tools: List[Tuple[str, Dict]]
    ) -> str:
        """
        Process a query by executing planned tool calls and synthesizing a response.

        Args:
            user_query: The original user question
            planned_tools: List of (tool_name, parameters) tuples representing
                          what an LLM would generate
        """
        self.conversation_history.append(
            {"role": "user", "content": user_query}
        )

        tool_results = []
        print(f"User Query: {user_query}\n")

        for i, (tool_name, params) in enumerate(planned_tools, 1):
            print(
                f"Step {i}: Calling tool '{tool_name}' with parameters {json.dumps(params)}"
            )

            result = self.execute_tool(tool_name, params)
            tool_results.append(
                {"tool": tool_name, "parameters": params, "result": result}
            )

            print(f"Result: {result}\n")

        final_response = self._synthesize_response(user_query, tool_results)
        return final_response

    def _synthesize_response(self, query: str, results: List[Dict]) -> str:
        """Synthesize final answer from tool results (simulating LLM generation)."""
        synthesis = "Based on the tool results:\n"

        if any(r["tool"] == "calculator" for r in results):
            calc_results = [r for r in results if r["tool"] == "calculator"]
            for r in calc_results:
                synthesis += f"- Calculation '{r['parameters']['expression']}' = {r['result']}\n"

        if any(r["tool"] == "current_date" for r in results):
            date_result = [r for r in results if r["tool"] == "current_date"][0]
            synthesis += f"- Current date/time: {date_result['result']}\n"

        if any(r["tool"] == "weather_lookup" for r in results):
            weather_results = [
                r for r in results if r["tool"] == "weather_lookup"
            ]
            for r in weather_results:
                city = r["parameters"]["city"]
                synthesis += f"- Weather for {city}: {r['result']}\n"

        synthesis += (
            "\n[Final natural language response would be generated by LLM here]"
        )
        return synthesis


system = ToolAugmentedSystem(TOOL_REGISTRY, TOOLS)

The ToolAugmentedSystem class separates concerns cleanly: the orchestrator handles routing and history tracking, individual tool functions handle execution, and the synthesis step (which in a real system would be an LLM call) handles turning raw results into natural language. This separation makes it easy to swap out individual components. Replacing the simulated weather function with a real API call requires changing only the weather_lookup function and its entry in TOOL_REGISTRY, with no changes to the orchestrator or synthesis logic. This modularity is a key design principle for maintainable tool-augmented systems.

Now we test the system with the compound interest example, specifying the tool calls that an LLM would generate:

In[9]:
Code
query = "If I invest $5000 at 4.5% annual interest compounded monthly, how much will I have after 3 years and 7 months?"

planned_tools = [
    ("calculator", {"expression": "3 * 12 + 7"}),
    (
        "calculator",
        {"expression": "5000 * (1 + 0.045/12) ** 43"},
    ),
]

response = system.process_query(query, planned_tools)
Out[10]:
Console
Based on the tool results:
- Calculation '3 * 12 + 7' = 43
- Calculation '5000 * (1 + 0.045/12) ** 43' = 5873.121841838194

[Final natural language response would be generated by LLM here]

The system correctly executes both calculation steps. The first call computes the total number of months (43), and the second computes the final amount using the compound interest formula. The precise result avoids the rounding errors common in pure LLM calculations. The model now knows the exact answer before it writes its response, which means its final synthesis can state a verified number rather than an approximation.

We examine a multi-tool scenario that combines temporal and computational tools:

In[11]:
Code
system.conversation_history = []

query = "What's the current date, and what's the weather in Tokyo today? Should I bring an umbrella if it's raining?"

planned_tools = [
    ("current_date", {}),
    ("weather_lookup", {"city": "Tokyo", "days": 1}),
]

response = system.process_query(query, planned_tools)
Out[12]:
Console
Based on the tool results:
- Current date/time: 2026-08-15 03:51:26
- Weather for Tokyo: 2026-08-15: rainy, 29°C

[Final natural language response would be generated by LLM here]

The output shows the combination of temporal and environmental data retrieval. The current date gives needed context for the query, while the weather lookup returns simulated meteorological data for Tokyo. The model can now offer specific, current advice rather than speaking in generalities about Tokyo's typical weather patterns. Even though the weather here is simulated, the architecture is identical to what you would use with a real weather API: the model calls the tool, receives structured data, and incorporates that data into a grounded response.

Visualizing the Tool Use Flow

To understand how information flows through a tool-augmented system, consider the cyclic relationship between three components: the LLM reasoning engine, the tool executor, and the collection of external tools. User queries enter at the LLM, which interprets the intent and determines whether external information or computation is needed. When it is, the LLM generates a structured tool call, typically a JSON object specifying the tool name and its parameters. The tool executor receives this call, validates the parameters against the tool's schema, and invokes the appropriate tool function. The tool executes and returns a result, which the executor passes back to the LLM as additional context. The LLM then decides whether it has enough information to answer the user's question or whether it needs to make another tool call. This cycle continues until the LLM is ready to synthesize a final response, which flows back to the user.

What makes this architecture powerful is that the LLM retains full control of the reasoning process while offloading the execution of specific, well-defined tasks to reliable external systems. The LLM does not execute arbitrary code or make arbitrary network calls; it generates structured requests that a trusted executor interprets and runs. This separation maintains both flexibility (the LLM can decide dynamically which tools to call in which order) and safety (the executor can validate and limit what tools can do).

Out[13]:
Visualization
Diagram showing cyclic flow between LLM, Tool Executor, and External Tools with arrows for tool calls and results.
Information flow architecture in a tool-augmented LLM system. The LLM generates structured JSON tool calls that the Tool Executor validates and routes to the appropriate external tool. Results flow back through the executor to the LLM, which incorporates them into its reasoning before deciding on further tool calls or generating the final response to the user.