Tool Calling / Function Calling in Python
Same Concept, Different Language
Same mental model as the Spring AI tools page. The LLM doesn't call APIs directly — it asks the app to call them, gets the result back, and then formulates a response. The model orchestrates; the code executes.
sequenceDiagram
participant User
participant App
participant LLM
participant Tool
User->>App: "What's the weather in Denver?"
App->>LLM: prompt + tool definitions
LLM->>App: tool_call: get_weather("Denver")
App->>Tool: execute get_weather("Denver")
Tool->>App: 72°F, sunny
App->>LLM: tool result: 72°F, sunny
LLM->>App: "It's 72°F and sunny in Denver!"
App->>User: response
Level 1: OpenAI Native Function Calling
Define your tools as JSON schemas, pass them to the API, handle the tool calls:
import json
from openai import OpenAI
client = OpenAI()
# Define the tool
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a location",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "City name, e.g. 'Denver, CO'"
}
},
"required": ["location"]
}
}
}
]
# Your actual function
def get_weather(location: str) -> str:
# In production, call a real weather API
return json.dumps({"temp": 72, "condition": "sunny", "location": location})
# Make the call
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "What's the weather in Denver?"}],
tools=tools
)
message = response.choices[0].message
Now here's the key part — you have to check if the model wants to call a tool and execute it:
if message.tool_calls:
tool_call = message.tool_calls[0]
args = json.loads(tool_call.function.arguments)
# Execute the function
result = get_weather(**args)
# Send the result back to the model
messages = [
{"role": "user", "content": "What's the weather in Denver?"},
message, # the assistant's tool_call message
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": result
}
]
final_response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools
)
print(final_response.choices[0].message.content)
# "It's currently 72°F and sunny in Denver!"
More boilerplate than Spring AI's @Tool annotation? Yes. But you get full control over the execution loop.
Level 2: The Tool Execution Loop
In practice, the model might call multiple tools or chain them. You need a loop:
def run_with_tools(user_message: str, tools: list, tool_functions: dict) -> str:
messages = [{"role": "user", "content": user_message}]
while True:
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools
)
message = response.choices[0].message
messages.append(message)
# If no tool calls, we're done
if not message.tool_calls:
return message.content
# Execute each tool call
for tool_call in message.tool_calls:
fn_name = tool_call.function.name
fn_args = json.loads(tool_call.function.arguments)
result = tool_functions[fn_name](**fn_args)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": str(result)
})
Usage:
tool_functions = {
"get_weather": get_weather,
"get_time": get_current_time,
}
answer = run_with_tools(
"What's the weather in Denver and what time is it there?",
tools=tools,
tool_functions=tool_functions
)
The model calls both tools in parallel (if it can), you execute them, send results back, and the model synthesizes a final answer.
Level 3: LangChain Tools (Less Boilerplate)
LangChain's @tool decorator cuts down the verbose JSON schema definition:
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
@tool
def get_weather(location: str) -> str:
"""Get the current weather for a location."""
return f"72°F and sunny in {location}"
@tool
def search_database(query: str) -> str:
"""Search the internal database for information."""
return f"Found 3 results for: {query}"
llm = ChatOpenAI(model="gpt-4o")
llm_with_tools = llm.bind_tools([get_weather, search_database])
response = llm_with_tools.invoke("What's the weather in Denver?")
The docstring becomes the tool description. The type hints become the parameter schema. LangChain handles the JSON schema generation for you.
Executing Tool Calls with LangChain
from langchain_core.messages import HumanMessage, ToolMessage
messages = [HumanMessage("What's the weather in Denver?")]
response = llm_with_tools.invoke(messages)
messages.append(response)
# Execute tool calls
for tool_call in response.tool_calls:
tool_map = {"get_weather": get_weather, "search_database": search_database}
result = tool_map[tool_call["name"]].invoke(tool_call["args"])
messages.append(ToolMessage(content=result, tool_call_id=tool_call["id"]))
# Get final response
final = llm_with_tools.invoke(messages)
print(final.content)
Level 4: Pydantic-Based Tools
For complex input schemas, use Pydantic models:
from pydantic import BaseModel, Field
from langchain_core.tools import tool
class SearchParams(BaseModel):
query: str = Field(description="The search query")
max_results: int = Field(default=5, description="Maximum results to return")
category: str = Field(default="all", description="Category filter")
@tool(args_schema=SearchParams)
def advanced_search(query: str, max_results: int = 5, category: str = "all") -> str:
"""Search with advanced filtering options."""
return f"Found {max_results} results for '{query}' in {category}"
This gives the model a detailed schema to work with — better tool selection, better argument generation.
Parallel Tool Calls
OpenAI models can request multiple tool calls in a single response. Both the raw SDK and LangChain handle this:
# The model might return multiple tool_calls in one response:
# tool_calls = [
# {"name": "get_weather", "args": {"location": "Denver"}},
# {"name": "get_weather", "args": {"location": "Seattle"}}
# ]
# Execute them all, send all results back in one round trip
This is the Parallelization Pattern — the model decides to parallelize, not the application.
What to Remember
- OpenAI SDK gives you full control — more code, but you see exactly what's happening
- LangChain's
@toolcuts boilerplate — docstrings and type hints become the schema - Always handle the tool loop — the model might call tools multiple times
- Tool descriptions matter a lot — the model picks tools based on descriptions, so make them clear and specific
- Don't trust tool arguments blindly — validate inputs at the boundary, especially for database queries or API calls