aregmi.net
Resume

How to let your LLM call real functions — with OpenAI SDK native tools and LangChain tool wrappers

tools function-calling openai langchain python

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

  1. OpenAI SDK gives you full control — more code, but you see exactly what's happening
  2. LangChain's @tool cuts boilerplate — docstrings and type hints become the schema
  3. Always handle the tool loop — the model might call tools multiple times
  4. Tool descriptions matter a lot — the model picks tools based on descriptions, so make them clear and specific
  5. Don't trust tool arguments blindly — validate inputs at the boundary, especially for database queries or API calls