Phase 0: LLM Fast Track · 55 min · OpenAI SDK · Anthropic SDK · Pydantic
Structured Outputs & Function Calling
An agent that can only generate text is a chatbot. An agent that can call functions is a product.
Real-world application: Function calling is asked in nearly every AI engineer technical screen — the full loop, error handling, and parallel calls.
What you will learn
- Define tools using JSON Schema and call them reliably with OpenAI and Anthropic APIs
- Handle the full function-calling loop: detect call → execute → return result → continue
- Use Pydantic to generate tool schemas automatically from Python classes
The Problem
An agent can answer "What's the weather like in Paris?" with a plausible-sounding response. But it's making that up — it has no idea what the weather is right now. Function calling bridges language models and the real world. With it, the model can detect when it needs real data, ask your code to fetch it, incorporate the result, and give you a grounded answer.
Function calling is the bridge between "I'll look that up" and an actual database query. It's what makes agents useful rather than merely convincing.
The most important misconception to clear up
The model does NOT execute code. It emits a structured JSON object saying "call function X with arguments Y." Your code detects this, runs the function, and feeds the result back. The model is an orchestrator, not an executor.
What function calling actually is
Here's the sequence that happens every time a model uses a tool:
The model never touches get_weather. It just declares its intent. Your loop handles execution and feeds the result back so the model can incorporate it into its final response.
Defining tools with JSON schema
A tool definition has three fields. The description is by far the most important:
{
"type": "function",
"function": {
"name": "get_order_status",
"description": (
"Look up the current status of a customer order by order ID. "
"Use this when the user asks about their order, shipping, or delivery. "
"For product information or pricing, use get_product_info instead."
),
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "The order ID, e.g. 'ORD-12345'"
}
},
"required": ["order_id"]
}
}
}
Good description anatomy:
- What the tool does (one sentence)
- When to use it (trigger conditions)
- When NOT to use it (disambiguation from other tools)
The description drives tool selection
The model uses the description field to decide when to call a tool. If your descriptions are vague or overlap, the model picks arbitrarily. This is the #1 cause of wrong tool calls in production.
Generating Schemas Automatically with Pydantic
Instead of writing JSON Schema by hand, use Pydantic:
from pydantic import BaseModel, Field
class GetOrderStatusArgs(BaseModel):
order_id: str = Field(description="The order ID, e.g. 'ORD-12345'")
# Auto-generate the JSON Schema
schema = GetOrderStatusArgs.model_json_schema()
Pydantic generates the same JSON Schema you'd write by hand, but with type safety and field validation as a bonus.
The model receives a user message and returns with finish_reason="tool_calls". What should your code do next?
finish_reason="tool_calls" means the model wants to use a tool but hasn't finished responding yet. You execute the function on its behalf, feed the result back into the messages array as a "tool" role message, and call the API again so the model can incorporate the result into its final response.
The function-calling loop
The full loop with error handling:
def run_agent_loop(messages, tools, max_iterations=10):
for iteration in range(max_iterations):
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
)
if response.choices[0].finish_reason == "stop":
# Model is done — return the final response
return response.choices[0].message.content
elif response.choices[0].finish_reason == "tool_calls":
# Model wants to call tools — execute them all
tool_calls = response.choices[0].message.tool_calls
messages.append(response.choices[0].message) # Add assistant message
for tc in tool_calls:
fn_name = tc.function.name
fn_args = json.loads(tc.function.arguments)
try:
result = TOOL_REGISTRY[fn_name](**fn_args)
except Exception as e:
result = {"error": str(e)}
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": json.dumps(result),
})
# Loop continues — send updated messages back to model
return "Max iterations reached."
Key details:
- Always append the assistant message (with tool_calls) before appending tool results
- Each tool result is a
"tool" role message keyed by tool_call_id - The model may call multiple tools in a single response (parallel tool calling)
- Always cap iterations to prevent infinite loops
You have 3 tools defined but the model keeps calling the wrong one. What's the most likely cause?
Tool selection is driven almost entirely by the description field. If two tools have overlapping descriptions ("get information" vs "retrieve data"), the model picks arbitrarily. Fix: rewrite descriptions to explicitly state when to use THIS tool vs. the others. E.g., "Use this tool to look up ORDER STATUS. For PRODUCT INFORMATION, use get_product_info instead."
Build
Build it
Before wiring up a real API loop, this mini simulation shows the tool-call cycle end-to-end: the "model" (a stand-in function) decides which tool to call, your code executes it, and the result feeds back into a final answer.
# Simulated model + tool-calling loop (no real API calls).
MOCK_WEATHER = {"paris": {"temp_c": 18, "condition": "partly cloudy"}}
def get_weather(city):
data = MOCK_WEATHER.get(city.lower())
if not data:
return {"error": f"No data for {city}"}
return {"city": city, **data}
TOOLS = {"get_weather": get_weather}
def fake_model_decide(user_message):
"""Stand-in for an LLM deciding to call a tool."""
if "weather" in user_message.lower():
city = user_message.split(" in ")[-1].strip("? ")
return {"tool_call": "get_weather", "args": {"city": city}}
return {"tool_call": None}
def agent_loop(user_message):
decision = fake_model_decide(user_message)
if decision["tool_call"] is None:
return "I don't have a tool for that."
fn_name = decision["tool_call"]
fn_args = decision["args"]
print(f"Model requests: {fn_name}({fn_args})")
result = TOOLS[fn_name](**fn_args)
print(f"Tool result: {result}")
if "error" in result:
return f"Sorry, I couldn't find that: {result['error']}"
return f"It's {result['temp_c']}°C and {result['condition']} in {result['city']}."
print(agent_loop("What's the weather like in Paris?"))
print(agent_loop("What's the weather like in Berlin?"))
The code artifact implements a ToolRegistry with auto-schema generation, a full AgentLoop, and three example tools (web search mock, calculator, weather mock).
"""
lesson_00_03_function_calling.py
Full function-calling loop implementation:
- Tool dataclass + ToolRegistry auto-generating JSON Schema from type hints
- AgentLoop with max iteration cap and parallel tool call support
- Three example tools: search_web, calculate, get_weather (mocked)
- Error handling when a tool raises an exception
Usage:
export OPENAI_API_KEY=sk-...
python lesson_00_03_function_calling.py
Requirements:
pip install openai pydantic
"""
import json
import math
import os
from dataclasses import dataclass
from typing import Any, Callable
import openai
from pydantic import BaseModel, Field
# ─── Tool system ──────────────────────────────────────────────────────────────
@dataclass
class Tool:
name: str
description: str
args_schema: type[BaseModel]
fn: Callable[..., Any]
def to_openai_schema(self) -> dict:
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.args_schema.model_json_schema(),
},
}
class ToolRegistry:
def __init__(self):
self._tools: dict[str, Tool] = {}
def register(self, tool: Tool) -> None:
self._tools[tool.name] = tool
def get(self, name: str) -> Tool:
if name not in self._tools:
raise KeyError(f"Unknown tool: {name!r}. Available: {list(self._tools)}")
return self._tools[name]
def schemas(self) -> list[dict]:
return [t.to_openai_schema() for t in self._tools.values()]
def call(self, name: str, args: dict) -> Any:
tool = self.get(name)
parsed = tool.args_schema(**args)
return tool.fn(**parsed.model_dump())
# ─── Tool definitions ─────────────────────────────────────────────────────────
class SearchWebArgs(BaseModel):
query: str = Field(description="The search query to look up")
class CalculateArgs(BaseModel):
expression: str = Field(
description="A Python math expression to evaluate, e.g. '(144 / 12 + 7) * 3'"
)
class GetWeatherArgs(BaseModel):
city: str = Field(description="City name, e.g. 'Paris' or 'New York'")
units: str = Field(default="celsius", description="Temperature units: 'celsius' or 'fahrenheit'")
# Mock implementations — replace with real APIs in production
MOCK_SEARCH_RESULTS = {
"tensorflow": "TensorFlow is written primarily in C++ and Python, developed by Google Brain.",
"python creator": "Python was created by Guido van Rossum, first released in 1991.",
"guido van rossum education": "Guido van Rossum studied at the University of Amsterdam, earning a master's degree in mathematics and computer science.",
}
MOCK_WEATHER = {
"paris": {"temp_c": 18, "condition": "partly cloudy", "humidity": 65},
"new york": {"temp_c": 24, "condition": "sunny", "humidity": 55},
"london": {"temp_c": 14, "condition": "overcast", "humidity": 80},
}
def search_web(query: str) -> dict:
query_lower = query.lower()
for key, result in MOCK_SEARCH_RESULTS.items():
if key in query_lower:
return {"result": result, "source": "mock_search"}
return {"result": f"No results found for: {query}", "source": "mock_search"}
def calculate(expression: str) -> dict:
# Safe eval — only allow math operations
allowed = set("0123456789 +-*/().eE")
if not all(c in allowed for c in expression):
return {"error": f"Unsafe expression: {expression!r}"}
try:
result = eval(expression, {"__builtins__": {}}, {"sqrt": math.sqrt, "pi": math.pi})
return {"result": result, "expression": expression}
except Exception as e:
return {"error": str(e), "expression": expression}
def get_weather(city: str, units: str = "celsius") -> dict:
data = MOCK_WEATHER.get(city.lower())
if not data:
return {"error": f"No weather data for {city!r}"}
temp = data["temp_c"] if units == "celsius" else data["temp_c"] * 9/5 + 32
return {"city": city, "temperature": temp, "units": units,
"condition": data["condition"], "humidity": data["humidity"]}
# ─── Agent loop ───────────────────────────────────────────────────────────────
class AgentLoop:
"""
Runs the full function-calling loop until the model produces a final answer
or the iteration cap is reached.
"""
def __init__(self, registry: ToolRegistry, model: str = "gpt-4o-mini", max_iterations: int = 10):
self.client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
self.registry = registry
self.model = model
self.max_iter = max_iterations
def run(self, user_message: str, system: str = "") -> str:
messages: list[dict] = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": user_message})
print(f"\n[AgentLoop] User: {user_message}")
for iteration in range(self.max_iter):
response = self.client.chat.completions.create(
model=self.model,
messages=messages,
tools=self.registry.schemas(),
tool_choice="auto",
)
choice = response.choices[0]
if choice.finish_reason == "stop":
answer = choice.message.content
print(f"[AgentLoop] Final answer: {answer}")
return answer
if choice.finish_reason == "tool_calls":
# Add assistant message with tool_calls
messages.append(choice.message)
# Execute all requested tool calls (may be parallel)
for tc in choice.message.tool_calls:
fn_name = tc.function.name
fn_args = json.loads(tc.function.arguments)
print(f"[AgentLoop] Tool call: {fn_name}({fn_args})")
try:
result = self.registry.call(fn_name, fn_args)
print(f"[AgentLoop] Tool result: {result}")
except Exception as exc:
result = {"error": str(exc), "tool": fn_name}
print(f"[AgentLoop] Tool error: {result}")
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": json.dumps(result),
})
# Continue loop
else:
print(f"[AgentLoop] Unexpected finish_reason: {choice.finish_reason!r}")
break
return f"[Max iterations ({self.max_iter}) reached without final answer]"
# ─── Demo ─────────────────────────────────────────────────────────────────────
def build_registry() -> ToolRegistry:
registry = ToolRegistry()
registry.register(Tool(
name="search_web",
description=(
"Search the web for factual information. Use this when you need current information "
"about people, organizations, technologies, or events. "
"For math calculations, use the calculate tool instead."
),
args_schema=SearchWebArgs,
fn=search_web,
))
registry.register(Tool(
name="calculate",
description=(
"Evaluate a mathematical expression and return the numeric result. "
"Use this for any arithmetic, algebra, or numeric computation. "
"Pass the expression as a Python math expression string."
),
args_schema=CalculateArgs,
fn=calculate,
))
registry.register(Tool(
name="get_weather",
description=(
"Get the current weather for a city. Returns temperature, conditions, and humidity. "
"Use this when the user asks about weather, temperature, or climate in a specific city."
),
args_schema=GetWeatherArgs,
fn=get_weather,
))
return registry
def main() -> None:
registry = build_registry()
agent = AgentLoop(registry, model="gpt-4o-mini", max_iterations=8)
test_queries = [
"What's the weather like in Paris right now?",
"What is ((144 / 12) + 7) * 3?",
"Who created Python and where did they study?",
"What is the hypotenuse of a right triangle with legs 3 and 4?",
]
print("\n" + "="*70)
print("AGENT LOOP DEMO — 4 test queries")
print("="*70)
for query in test_queries:
print(f"\n{'─'*70}")
result = agent.run(query)
print(f"Answer: {result}")
if __name__ == "__main__":
main()
Unlock the full lesson
You've read the first 2 sections. The rest of this lesson covers Check Yourself — plus a hands-on lab, quiz, and project artifact.
Create a free account — the opening phases of 29 of 35 courses are free, no credit card.
Browse all courses · View pricing · DeVenture Academy