Structured outputs and function calling explained
Structured output means the model returns data in a format your code can use, usually JSON that matches a schema you define, instead of free text. Function calling (also called tool calling) means you describe functions to the model, the model decides when one should be called and returns the function name and arguments as structured data, and your code runs the function and sends the result back. The model never runs anything itself. Both are the bridge between a language model and the rest of your software, and both need validation, because the model can still produce wrong values in a correct format.
Why plain text isn't enough
A model's natural output is text, which is perfect when a person reads it. When your code reads it, text is a problem.
If you ask a model to classify a support ticket and it answers "This seems like a billing issue, probably urgent", your code has to guess which word is the category and what "probably urgent" means as a priority. If the next ticket's answer is phrased differently, your parsing breaks.
Structured output fixes that by agreeing on a format in advance: {"category": "billing", "priority": 1}, every time.
Three levels of structured output
There are three ways to get structured data from a model, from weakest to strongest.
Ask for JSON in the prompt
You describe the format in the prompt and hope the model follows it. Modern models usually do, but not always: they add a sentence before the JSON, wrap it in a markdown code block, miss a field or use a value you didn't allow.
JSON mode
Many APIs have a setting that guarantees the output is valid JSON. It guarantees the syntax, not the shape, so the JSON can still have the wrong fields.
Schema-constrained structured output
You give the API a JSON schema, and the provider constrains generation so that the output matches it. At each step, tokens that would break the schema are not allowed, so the result always has the fields and types you defined. This is the most reliable option, and most major providers support it for at least some models, though each supports a different subset of JSON schema features.
Even at this level, the content can still be wrong.
A schema can force priority to be an integer from 1 to 3, but it can't force the model to choose the right priority.
So you still validate on your side, as the Pydantic page shows, and still evaluate whether the values are correct.
What function calling is
LLMs can't do many things on their own. They don't have calculators, so they are unreliable at exact arithmetic. They don't have databases, so they don't know your customer's order status. They don't have web browsers, so they don't know what happened today.
Function calling gives the model a way to ask for those things. You tell the model which functions exist and what they do, and when the model decides it needs one, instead of answering, it returns a request: call this function with these arguments.
The important point is that the model only writes the request. Your code decides whether to run it, runs it, and sends the result back.
How function calling works, step by step
- You define the tools. For each one: a name, a description of what it does and when to use it, and a JSON schema for its arguments.
- You send the user's message along with the tool definitions.
- The model decides. It either answers directly, or returns one or more tool calls, each with a tool name and arguments as JSON.
- Your code validates the arguments, checks that the call is allowed, and runs the function.
- You send the result back to the model as a tool result message, along with the conversation so far.
- The model uses the result to answer, or asks for another tool call, and the loop continues until it answers.
When that loop runs for several steps, deciding which tool to call next based on what it saw, you have an agent.
A tool definition
This is what a tool looks like to the model, a name, a description and a schema for the arguments.
{
"name": "get_order_status",
"description": "Get the current status and expected delivery date of a customer's order. Use this when the customer asks where their order is.",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "Order ID in the format ORD-123456"
}
},
"required": ["order_id"]
}
}
The description matters more than most people expect. The model chooses tools by reading their names and descriptions, so a vague description leads to the wrong tool being called, or the right tool never being called. Write it the way you would explain the function to a new colleague: what it does, when to use it, when not to, and what the arguments mean.
Handling a tool call in your code
Different SDKs format tool calls slightly differently, but the handling always looks like this: look up the function, validate the arguments, run it, and return the result.
import json
from pydantic import BaseModel, Field, ValidationError
class OrderStatusArgs(BaseModel):
order_id: str = Field(pattern=r"^ORD-\d{6}$")
def get_order_status(order_id: str) -> dict:
return {"order_id": order_id, "status": "shipped", "expected_delivery": "2026-10-18"}
TOOLS = {"get_order_status": (get_order_status, OrderStatusArgs)}
def run_tool_call(name: str, arguments_json: str) -> str:
if name not in TOOLS:
return json.dumps({"error": f"unknown tool {name}"})
func, schema = TOOLS[name]
try:
args = schema.model_validate_json(arguments_json)
except ValidationError as error:
return json.dumps({"error": "invalid arguments", "details": error.errors(include_url=False)})
return json.dumps(func(**args.model_dump()))
print(run_tool_call("get_order_status", '{"order_id": "ORD-123456"}'))
print(run_tool_call("get_order_status", '{"order_id": "123"}'))
Two details are worth copying. Unknown tools and invalid arguments return an error message to the model instead of crashing, so the model can correct itself. And the arguments are validated before the function runs, because they were written by a model and are untrusted input.
Choosing when the model uses tools
Most APIs let you control tool use per request:
- Automatic: the model decides whether to call a tool or answer directly. This is the usual default.
- Required: the model must call some tool.
- A specific tool: the model must call this tool. This is a reliable way to get structured output, by defining the output format as a tool's arguments.
- None: tools are described but not allowed this time.
Models can also request several tool calls at once when they don't depend on each other, like checking the weather in two cities, and your code can run those in parallel.
Doing it safely
Function calling connects a model to real systems, so the same rules apply as for any other input from outside.
- Never execute code or expressions from the model directly with something like Python's
eval. Parse and validate them, or use a safe evaluator. - Validate every argument against a schema and against business rules, for example that the order belongs to the logged-in user.
- Give each tool the smallest permissions it needs. A tool that reads orders shouldn't be able to cancel them.
- Require a person to approve actions that are expensive, irreversible or sensitive, like sending money, deleting data or emailing a customer.
- Treat tool results as untrusted too. A web page or document returned by a tool can contain text written to manipulate the model, which is called prompt injection. See security and governance.
- Limit the number of tool calls per request and log every call with its arguments and result.
Here is a calculator tool that is safe to expose, because it only evaluates arithmetic instead of running whatever the model writes.
import ast
import operator
OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Pow: operator.pow,
ast.USub: operator.neg,
}
def safe_eval(node: ast.AST) -> float:
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in OPS:
return OPS[type(node.op)](safe_eval(node.left), safe_eval(node.right))
if isinstance(node, ast.UnaryOp) and type(node.op) in OPS:
return OPS[type(node.op)](safe_eval(node.operand))
raise ValueError("only arithmetic is allowed")
def calculator(expression: str) -> str:
try:
return str(safe_eval(ast.parse(expression, mode="eval").body))
except (ValueError, SyntaxError, ZeroDivisionError) as error:
return f"error: {error}"
print(calculator("18.75 * 3 * 1.15"))
print(calculator("__import__('os').system('ls')"))
Function calling and MCP
Function calling is how one application describes its tools to one model. The Model Context Protocol (MCP) is an open standard for packaging tools, data and prompts as servers that any compatible application and model can connect to, so a tool built once can be reused across applications. Under the hood, the model still sees tool definitions and makes tool calls the same way. See MCP vs function calling.
Common problems
- The model calls the wrong tool: improve the names and descriptions, and reduce overlapping tools.
- The model never calls the tool: say in the description and system prompt when it should be used.
- Arguments are wrong: add descriptions and formats to each argument, and return clear validation errors.
- Too many tools: every tool definition costs tokens on every call and gives the model more ways to choose wrong, so give each agent only the tools it needs.
- Long tool results: trim them to what the model needs before sending them back.
From the floor
When we started building agents in the bootcamp, the first tool was a calculator. The problem I gave the agent was: if a pizza costs $18.75, I buy three, and I add a 15% tip, what is the total? A language model doesn't have a calculator, so it can get this wrong, and the agent's job is to reason that it needs to multiply, call the calculator tool, observe the result, and continue, the reason, act and observe loop of the ReAct framework.
In that first version, the calculator tool used Python's eval to evaluate the expression, which is fine for a demo on your own machine and dangerous anywhere else, because eval runs any Python code the model sends it.
The safe calculator above is how I would write that tool for anything that leaves my laptop.
In the next session, we moved from a simple tool to structured tools with defined arguments for a research agent that searches the web with DuckDuckGo. I kept that agent to one specific job, searching and pulling a few results, because every extra task means more loops of model calls, a bigger context, and more chances for the agent to lose track of earlier steps.
Related
- Pydantic and structured data
- Tool design
- MCP vs function calling
- Agents and tool use