Chat completions alone make fluent text. Production systems need side effects and facts: prices, tickets, calendars, SQL. Tool calling is the bridge — the model proposes a typed function call; your server executes it; the model reads the result and answers the user. Skip that loop and you are asking the weights to pretend they are your database.
Treat the LLM as a router with language skills, not as a system of record:
If step 3 is missing, “What’s Alice’s balance?” becomes creative writing.
Multi-tool turns are normal: search, then fetch, then answer. Cap the hop count so a confused model cannot loop forever.
Tools should be narrow, typed, and boring:
{
"name": "get_weather",
"description": "Current weather for a city.",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string", "description": "City name"},
"units": {"type": "string", "enum": ["metric", "imperial"]}
},
"required": ["location"]
}
}
Descriptions are part of the prompt. Vague tool names (“do_stuff”) cause wrong calls. Overlapping tools cause thrash — prefer one clear verb per capability.
The model still might invent an argument, but the payload of truth comes from your tool. Grounded answers cite returned fields; empty tool results should produce an honest “not found,” not a fabricated row.
If the user asks for “weather in two cities,” two independent get_weather calls can run together. If the second tool needs an ID from the first, force a sequence and feed intermediate results back through the model or through your orchestrator. Blind parallelism on dependent steps causes empty IDs and wasted hops.
Illustrative client (requests-style, no real key):
# Pseudocode — teaches the shape; do not commit secrets
payload = {
"model": "chat-mid",
"messages": [
{"role": "system", "content": "Use tools for facts. Do not invent IDs."},
{"role": "user", "content": "Weather in Bengaluru?"},
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Current weather for a city.",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string"},
},
"required": ["location"],
},
},
}
],
}
# resp = requests.post(URL, json=payload, headers={"Authorization": f"Bearer {KEY}"})
Local dispatcher with allowlist + validation:
import json
from typing import Any, Callable
TOOLS: dict[str, Callable[..., Any]] = {
"get_weather": lambda location, units="metric": {
"location": location,
"temp_c": 28,
"units": units,
},
}
def run_tool_call(name: str, args_json: str) -> dict:
if name not in TOOLS:
return {"error": "tool_not_allowed", "name": name}
try:
args = json.loads(args_json)
except json.JSONDecodeError:
return {"error": "invalid_json_args"}
if "location" not in args:
return {"error": "missing_location"}
return {"ok": True, "data": TOOLS[name](**args)}
print(run_tool_call("get_weather", '{"location": "Bengaluru"}'))
Append tool results as structured messages (shape varies by vendor; concept is stable):
def after_tool(messages: list, call_id: str, result: dict) -> list:
messages = list(messages)
messages.append(
{
"role": "tool",
"tool_call_id": call_id,
"content": json.dumps(result),
}
)
return messages
Hop limit:
def agent_loop(max_hops: int = 3):
for hop in range(max_hops):
# call model → if tool_calls: execute → continue; else return text
pass
return {"error": "max_tool_hops_exceeded"}
transfer_funds; app runs it without authz.run_sql(query) with no guardrails is a breach waiting for a prompt.LLM APIs expose chat plus optional tools — the model proposes typed calls, your app executes and returns results, and answers stay grounded in real systems.