Function calling is the mechanism that turns a language model into an agent capable of doing things in the real world. Without it, you have a chatbot. With it, you have a system that can query databases, call APIs, write files, send messages, and execute business logic — all driven by natural language instructions.
The concept is straightforward. The implementation details are where most production systems run into trouble.
What function calling actually is
Function calling is structured LLM output that triggers application code. The LLM does not execute code directly. It produces a structured response (typically JSON) that says: "I need to call this function with these arguments." Your application code receives that, executes the actual function, and feeds the result back to the LLM.
The flow:
- You send the LLM a message plus a list of available tool definitions
- The LLM responds with either a final answer or a tool call request
- If a tool call: your code executes it and returns the result to the LLM
- The LLM processes the result and either answers or makes another tool call
- Repeat until done
The LLM never directly interacts with your database, API, or file system. It only reads and produces text. That boundary is important for security — you control what the model can ask for, and your code controls what actually executes.
Tool definition design
The single biggest factor in tool use reliability is how well you define your tools. A vague tool definition produces wrong tool choices, bad argument values, and failed executions.
Name tools precisely
get_data is a bad tool name. search_customer_orders_by_date_range is a good tool name. The model chooses which tool to call based partly on the name — specificity reduces ambiguity and wrong choices.
Naming conventions that work:
verb_nounpattern:create_ticket,search_orders,send_email- Include domain context:
crm_search_contactsnot justsearch_contactswhen you have multiple search tools - Match the user's mental model: if users say "look up" not "search," consider
lookup_prefix
Write descriptions for the model, not for humans
The description is injected into the LLM's context as part of the tool schema. It needs to tell the model: what this tool does, when to use it, and what it returns.
Bad description: "Searches orders"
Good description: "Search the order management system for orders matching the given criteria. Use this when the user asks about past orders, order status, delivery dates, or order history. Returns a list of order objects with status, total, and tracking information. Requires at least one filter parameter."
Type and constrain every parameter
{
"name": "search_orders",
"description": "Search customer orders...",
"parameters": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["pending", "shipped", "delivered", "cancelled"],
"description": "Filter by order status. Omit to return all statuses."
},
"days_back": {
"type": "integer",
"minimum": 1,
"maximum": 365,
"description": "Return orders from the last N days. Default: 30."
}
},
"required": []
}
}
Notice: status uses an enum. The LLM cannot pass "processing" as a status because it's not in the enum — it will only pass valid values. This eliminates a whole category of runtime errors.
Use enums wherever a parameter has a bounded value set. Use minimum/maximum on integers. Use pattern for string formats (ISO dates, email addresses, etc.).
Parallel vs sequential tool calls
Most LLMs now support requesting multiple tool calls in a single response. When you see something like:
"tool_calls": [
{"id": "call_1", "function": {"name": "get_customer", "arguments": ...}},
{"id": "call_2", "function": {"name": "get_recent_orders", "arguments": ...}}
]
The model is asking for both results simultaneously. Your application code should execute these in parallel, not sequentially. Running them in series doubles your latency for no reason.
The rule: if two tool calls don't depend on each other's outputs, they can run in parallel. Design your tool set to maximize independent calls.
Where parallel calls don't work: when the output of tool A is the input to tool B. A lookup by email → then a lookup by the returned customer ID must be sequential. The model understands this and will make sequential calls when necessary.
Handling tool errors gracefully
Tools fail in production. APIs time out. Database queries return no results. The user asks for something that doesn't exist. Your tool execution layer needs to handle all of these and return structured error information back to the LLM.
Do not return a generic Python exception to the LLM context. Return a structured error that the model can reason about:
def execute_tool(tool_call):
try:
result = run_function(tool_call)
return {"status": "success", "data": result}
except NotFoundError:
return {"status": "error", "error_type": "not_found",
"message": "No records matched the query parameters"}
except RateLimitError:
return {"status": "error", "error_type": "rate_limited",
"message": "External API rate limit hit, retry in 60 seconds"}
except Exception as e:
return {"status": "error", "error_type": "unknown",
"message": "An unexpected error occurred"}
When the LLM receives a structured error, it can decide what to do: try different parameters, try a different tool, or tell the user something useful. When it receives an unhandled exception, it usually produces confused output.
Injecting tool results back into context
The tool result goes back into the message list as a tool response, attributed to the tool call ID:
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(tool_result)
})
A few things to get right:
Keep results concise. If your API returns a 10KB response, don't inject all 10KB. Extract the fields the model needs, discard the rest. Context tokens cost money and full API responses are full of noise the model doesn't need.
Format results readably. The model reads the tool result to extract information. JSON with clear keys is better than a raw string. Include enough context that the model can interpret the result without needing the original query.
Tag results with the source. For RAG-style tools, include document title and metadata. For API results, include the timestamp. When the model attributes its response to source material, it produces more accurate answers.
When to use tool use vs just prompting
Not every capability needs to be a tool. Use tool definitions when:
- The operation requires real-time or external data (databases, APIs, current state)
- The operation has side effects (writes, sends, creates)
- The operation is complex enough that you don't want the model to attempt it in text (calculations, data transformations)
- You need structured, typed outputs that downstream code will parse
Use prompting (without tools) when:
- The model has the required knowledge in its training data
- The output is text that a human will read, not code that will parse it
- You need flexibility in how the model reasons through the problem
The overhead of tool call round-trips adds latency. For tasks that don't require external data, a well-prompted response without tool use is both faster and cheaper.
Common mistakes in production tool use
| Mistake | Symptom | Fix | |---|---|---| | Too many tools in a single call | Model picks wrong tool or combines tools incorrectly | Scope tool sets to the task; use specialized agents | | No error handling in tool executor | Model sees Python tracebacks, produces confused output | Catch all exceptions, return structured errors | | Running parallel calls sequentially | 3x latency on multi-step tasks | Execute independent tool calls concurrently | | Giant API responses injected raw | Context bloat, expensive calls, noisy answers | Extract and inject only relevant fields | | Vague tool descriptions | Wrong tool selected, wrong arguments passed | Rewrite descriptions with explicit use-case guidance |
The teams that get AI agent development right aren't using more tools — they're using fewer, better-defined tools. Five well-scoped tools with precise definitions outperform fifteen loosely defined ones almost every time.