Tool Use Motivation: Why LLMs Need External Tools for Accuracy › Code Implementation: Building a Tool-Augmented System
Language AI HandbookAll chapters 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.
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.
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:
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)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:
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)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).
