Function Calling Patterns: Enabling LLMs to Take Real Actions

Introduction: Function calling transforms LLMs from text generators into action-taking agents. Instead of just describing what to do, the model can invoke actual functions with structured arguments. This enables powerful integrations: querying databases, calling APIs, executing code, and orchestrating complex workflows. But function calling requires careful design—poorly defined functions confuse the model, missing validation causes runtime errors, and uncontrolled execution creates security risks. This guide covers practical function calling patterns: defining clear function schemas, validating and executing calls safely, handling multi-step tool use, and building robust systems that let LLMs take real actions.

Function Calling
Function Calling: Function Selection, Argument Extraction, Function Execution

Function Definition

from dataclasses import dataclass, field
from typing import Any, Optional, Callable, get_type_hints
import json
import inspect

@dataclass
class FunctionParameter:
    """A function parameter definition."""
    
    name: str
    type: str
    description: str
    required: bool = True
    enum: list[str] = None
    default: Any = None

@dataclass
class FunctionDefinition:
    """A function definition for LLM tool use."""
    
    name: str
    description: str
    parameters: list[FunctionParameter]
    handler: Callable = None
    
    def to_openai_schema(self) -> dict:
        """Convert to OpenAI function schema."""
        
        properties = {}
        required = []
        
        for param in self.parameters:
            prop = {
                "type": param.type,
                "description": param.description
            }
            
            if param.enum:
                prop["enum"] = param.enum
            
            properties[param.name] = prop
            
            if param.required:
                required.append(param.name)
        
        return {
            "type": "function",
            "function": {
                "name": self.name,
                "description": self.description,
                "parameters": {
                    "type": "object",
                    "properties": properties,
                    "required": required
                }
            }
        }

def function_tool(description: str = None):
    """Decorator to create function definition from Python function."""
    
    def decorator(func: Callable) -> FunctionDefinition:
        hints = get_type_hints(func)
        sig = inspect.signature(func)
        doc = func.__doc__ or ""
        
        # Extract parameter descriptions from docstring
        param_docs = {}
        for line in doc.split('\n'):
            line = line.strip()
            if line.startswith(':param'):
                parts = line.split(':', 2)
                if len(parts) >= 3:
                    param_name = parts[1].replace('param ', '').strip()
                    param_desc = parts[2].strip()
                    param_docs[param_name] = param_desc
        
        parameters = []
        for name, param in sig.parameters.items():
            if name == 'self':
                continue
            
            # Get type
            type_hint = hints.get(name, str)
            json_type = _python_type_to_json(type_hint)
            
            # Get description
            param_desc = param_docs.get(name, f"The {name} parameter")
            
            # Check if required
            required = param.default == inspect.Parameter.empty
            default = None if required else param.default
            
            parameters.append(FunctionParameter(
                name=name,
                type=json_type,
                description=param_desc,
                required=required,
                default=default
            ))
        
        return FunctionDefinition(
            name=func.__name__,
            description=description or doc.split('\n')[0],
            parameters=parameters,
            handler=func
        )
    
    return decorator

def _python_type_to_json(python_type) -> str:
    """Convert Python type to JSON schema type."""
    
    type_map = {
        str: "string",
        int: "integer",
        float: "number",
        bool: "boolean",
        list: "array",
        dict: "object"
    }
    
    return type_map.get(python_type, "string")

# Example function definitions
@function_tool("Get the current weather for a location")
def get_weather(
    location: str,
    unit: str = "celsius"
) -> dict:
    """
    Get current weather information.
    
    :param location: The city and country, e.g., "London, UK"
    :param unit: Temperature unit, either "celsius" or "fahrenheit"
    """
    # Implementation would call weather API
    return {"temperature": 20, "condition": "sunny", "unit": unit}

@function_tool("Search for information in a knowledge base")
def search_knowledge(
    query: str,
    max_results: int = 5
) -> list:
    """
    Search the knowledge base for relevant information.
    
    :param query: The search query
    :param max_results: Maximum number of results to return
    """
    # Implementation would search vector database
    return [{"title": "Result 1", "content": "..."}]

Function Execution

from dataclasses import dataclass
from typing import Any, Optional, Callable
import json
import asyncio

@dataclass
class FunctionCall:
    """A function call from the LLM."""
    
    name: str
    arguments: dict
    call_id: str = None

@dataclass
class FunctionResult:
    """Result of function execution."""
    
    call_id: str
    name: str
    result: Any
    success: bool
    error: str = None

class FunctionRegistry:
    """Registry of available functions."""
    
    def __init__(self):
        self._functions: dict[str, FunctionDefinition] = {}
    
    def register(self, func_def: FunctionDefinition):
        """Register a function."""
        self._functions[func_def.name] = func_def
    
    def get(self, name: str) -> Optional[FunctionDefinition]:
        """Get a function by name."""
        return self._functions.get(name)
    
    def get_schemas(self) -> list[dict]:
        """Get OpenAI schemas for all functions."""
        return [f.to_openai_schema() for f in self._functions.values()]
    
    def list_functions(self) -> list[str]:
        """List all registered function names."""
        return list(self._functions.keys())

class FunctionExecutor:
    """Execute function calls safely."""
    
    def __init__(self, registry: FunctionRegistry):
        self.registry = registry
    
    async def execute(self, call: FunctionCall) -> FunctionResult:
        """Execute a function call."""
        
        # Get function definition
        func_def = self.registry.get(call.name)
        
        if func_def is None:
            return FunctionResult(
                call_id=call.call_id,
                name=call.name,
                result=None,
                success=False,
                error=f"Unknown function: {call.name}"
            )
        
        # Validate arguments
        validation_error = self._validate_arguments(func_def, call.arguments)
        if validation_error:
            return FunctionResult(
                call_id=call.call_id,
                name=call.name,
                result=None,
                success=False,
                error=validation_error
            )
        
        # Execute
        try:
            if asyncio.iscoroutinefunction(func_def.handler):
                result = await func_def.handler(**call.arguments)
            else:
                result = func_def.handler(**call.arguments)
            
            return FunctionResult(
                call_id=call.call_id,
                name=call.name,
                result=result,
                success=True
            )
            
        except Exception as e:
            return FunctionResult(
                call_id=call.call_id,
                name=call.name,
                result=None,
                success=False,
                error=str(e)
            )
    
    def _validate_arguments(
        self,
        func_def: FunctionDefinition,
        arguments: dict
    ) -> Optional[str]:
        """Validate function arguments."""
        
        # Check required parameters
        for param in func_def.parameters:
            if param.required and param.name not in arguments:
                return f"Missing required parameter: {param.name}"
        
        # Check for unknown parameters
        known_params = {p.name for p in func_def.parameters}
        for arg_name in arguments:
            if arg_name not in known_params:
                return f"Unknown parameter: {arg_name}"
        
        # Type validation could be added here
        
        return None

class SafeExecutor(FunctionExecutor):
    """Executor with additional safety measures."""
    
    def __init__(
        self,
        registry: FunctionRegistry,
        allowed_functions: list[str] = None,
        timeout_seconds: float = 30.0
    ):
        super().__init__(registry)
        self.allowed_functions = set(allowed_functions) if allowed_functions else None
        self.timeout_seconds = timeout_seconds
    
    async def execute(self, call: FunctionCall) -> FunctionResult:
        """Execute with safety checks."""
        
        # Check if function is allowed
        if self.allowed_functions and call.name not in self.allowed_functions:
            return FunctionResult(
                call_id=call.call_id,
                name=call.name,
                result=None,
                success=False,
                error=f"Function not allowed: {call.name}"
            )
        
        # Execute with timeout
        try:
            result = await asyncio.wait_for(
                super().execute(call),
                timeout=self.timeout_seconds
            )
            return result
            
        except asyncio.TimeoutError:
            return FunctionResult(
                call_id=call.call_id,
                name=call.name,
                result=None,
                success=False,
                error=f"Function execution timed out after {self.timeout_seconds}s"
            )

Tool Use Loop

from dataclasses import dataclass
from typing import Any, Optional
import json

@dataclass
class ToolUseResult:
    """Result of tool use conversation."""
    
    final_response: str
    tool_calls: list[FunctionCall]
    tool_results: list[FunctionResult]
    iterations: int

class ToolUseAgent:
    """Agent that uses tools to complete tasks."""
    
    def __init__(
        self,
        client: Any,
        executor: FunctionExecutor,
        model: str = "gpt-4o",
        max_iterations: int = 10
    ):
        self.client = client
        self.executor = executor
        self.model = model
        self.max_iterations = max_iterations
    
    async def run(
        self,
        messages: list[dict],
        tools: list[dict] = None
    ) -> ToolUseResult:
        """Run tool use loop until completion."""
        
        tools = tools or self.executor.registry.get_schemas()
        
        all_tool_calls = []
        all_tool_results = []
        iterations = 0
        
        while iterations < self.max_iterations:
            iterations += 1
            
            # Call LLM
            response = await self.client.chat.completions.create(
                model=self.model,
                messages=messages,
                tools=tools,
                tool_choice="auto"
            )
            
            message = response.choices[0].message
            
            # Check if done (no tool calls)
            if not message.tool_calls:
                return ToolUseResult(
                    final_response=message.content or "",
                    tool_calls=all_tool_calls,
                    tool_results=all_tool_results,
                    iterations=iterations
                )
            
            # Add assistant message to history
            messages.append({
                "role": "assistant",
                "content": message.content,
                "tool_calls": [
                    {
                        "id": tc.id,
                        "type": "function",
                        "function": {
                            "name": tc.function.name,
                            "arguments": tc.function.arguments
                        }
                    }
                    for tc in message.tool_calls
                ]
            })
            
            # Execute tool calls
            for tool_call in message.tool_calls:
                call = FunctionCall(
                    name=tool_call.function.name,
                    arguments=json.loads(tool_call.function.arguments),
                    call_id=tool_call.id
                )
                
                all_tool_calls.append(call)
                
                result = await self.executor.execute(call)
                all_tool_results.append(result)
                
                # Add tool result to messages
                messages.append({
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "content": json.dumps(result.result) if result.success else result.error
                })
        
        # Max iterations reached
        return ToolUseResult(
            final_response="Maximum iterations reached without completion",
            tool_calls=all_tool_calls,
            tool_results=all_tool_results,
            iterations=iterations
        )

class ParallelToolAgent(ToolUseAgent):
    """Agent that executes multiple tool calls in parallel."""
    
    async def run(
        self,
        messages: list[dict],
        tools: list[dict] = None
    ) -> ToolUseResult:
        """Run with parallel tool execution."""
        
        tools = tools or self.executor.registry.get_schemas()
        
        all_tool_calls = []
        all_tool_results = []
        iterations = 0
        
        while iterations < self.max_iterations:
            iterations += 1
            
            response = await self.client.chat.completions.create(
                model=self.model,
                messages=messages,
                tools=tools,
                tool_choice="auto"
            )
            
            message = response.choices[0].message
            
            if not message.tool_calls:
                return ToolUseResult(
                    final_response=message.content or "",
                    tool_calls=all_tool_calls,
                    tool_results=all_tool_results,
                    iterations=iterations
                )
            
            messages.append({
                "role": "assistant",
                "content": message.content,
                "tool_calls": [
                    {
                        "id": tc.id,
                        "type": "function",
                        "function": {
                            "name": tc.function.name,
                            "arguments": tc.function.arguments
                        }
                    }
                    for tc in message.tool_calls
                ]
            })
            
            # Execute all tool calls in parallel
            calls = [
                FunctionCall(
                    name=tc.function.name,
                    arguments=json.loads(tc.function.arguments),
                    call_id=tc.id
                )
                for tc in message.tool_calls
            ]
            
            all_tool_calls.extend(calls)
            
            results = await asyncio.gather(*[
                self.executor.execute(call)
                for call in calls
            ])
            
            all_tool_results.extend(results)
            
            # Add all results to messages
            for tool_call, result in zip(message.tool_calls, results):
                messages.append({
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "content": json.dumps(result.result) if result.success else result.error
                })
        
        return ToolUseResult(
            final_response="Maximum iterations reached",
            tool_calls=all_tool_calls,
            tool_results=all_tool_results,
            iterations=iterations
        )

Structured Output

from dataclasses import dataclass
from typing import Any, Optional, Type
from pydantic import BaseModel
import json

class StructuredOutputExtractor:
    """Extract structured output using function calling."""
    
    def __init__(self, client: Any, model: str = "gpt-4o"):
        self.client = client
        self.model = model
    
    async def extract(
        self,
        text: str,
        schema: Type[BaseModel],
        instructions: str = None
    ) -> BaseModel:
        """Extract structured data from text."""
        
        # Create function schema from Pydantic model
        json_schema = schema.model_json_schema()
        
        function = {
            "type": "function",
            "function": {
                "name": "extract_data",
                "description": instructions or f"Extract {schema.__name__} from the text",
                "parameters": json_schema
            }
        }
        
        messages = [
            {
                "role": "system",
                "content": "Extract the requested information from the provided text. Call the extract_data function with the extracted values."
            },
            {
                "role": "user",
                "content": text
            }
        ]
        
        response = await self.client.chat.completions.create(
            model=self.model,
            messages=messages,
            tools=[function],
            tool_choice={"type": "function", "function": {"name": "extract_data"}}
        )
        
        # Parse the function call arguments
        tool_call = response.choices[0].message.tool_calls[0]
        arguments = json.loads(tool_call.function.arguments)
        
        return schema(**arguments)

# Example usage
class ContactInfo(BaseModel):
    name: str
    email: str
    phone: Optional[str] = None
    company: Optional[str] = None

class OrderDetails(BaseModel):
    product_name: str
    quantity: int
    unit_price: float
    shipping_address: str

# Extract contact info from text
async def extract_contact(text: str) -> ContactInfo:
    extractor = StructuredOutputExtractor(client)
    return await extractor.extract(
        text,
        ContactInfo,
        "Extract contact information from the text"
    )

class MultiSchemaExtractor:
    """Extract multiple schemas from text."""
    
    def __init__(self, client: Any, model: str = "gpt-4o"):
        self.client = client
        self.model = model
    
    async def extract_all(
        self,
        text: str,
        schemas: dict[str, Type[BaseModel]]
    ) -> dict[str, BaseModel]:
        """Extract multiple schemas from text."""
        
        # Create functions for each schema
        functions = []
        for name, schema in schemas.items():
            functions.append({
                "type": "function",
                "function": {
                    "name": f"extract_{name}",
                    "description": f"Extract {name} information",
                    "parameters": schema.model_json_schema()
                }
            })
        
        messages = [
            {
                "role": "system",
                "content": "Extract all requested information from the text. Call the appropriate extraction functions."
            },
            {
                "role": "user",
                "content": text
            }
        ]
        
        response = await self.client.chat.completions.create(
            model=self.model,
            messages=messages,
            tools=functions,
            tool_choice="auto"
        )
        
        results = {}
        
        for tool_call in response.choices[0].message.tool_calls or []:
            func_name = tool_call.function.name
            schema_name = func_name.replace("extract_", "")
            
            if schema_name in schemas:
                arguments = json.loads(tool_call.function.arguments)
                results[schema_name] = schemas[schema_name](**arguments)
        
        return results

Production Function Service

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Optional, Any

app = FastAPI()

# Initialize components
registry = FunctionRegistry()
executor = SafeExecutor(registry)
agent = ParallelToolAgent(client=None, executor=executor)

# Register example functions
registry.register(get_weather)
registry.register(search_knowledge)

class ExecuteRequest(BaseModel):
    function_name: str
    arguments: dict

class AgentRequest(BaseModel):
    messages: list[dict]
    max_iterations: int = 10

class ExtractRequest(BaseModel):
    text: str
    schema_name: str

@app.post("/v1/functions/execute")
async def execute_function(request: ExecuteRequest):
    """Execute a single function."""
    
    call = FunctionCall(
        name=request.function_name,
        arguments=request.arguments,
        call_id="direct"
    )
    
    result = await executor.execute(call)
    
    if not result.success:
        raise HTTPException(status_code=400, detail=result.error)
    
    return {
        "result": result.result,
        "function": result.name
    }

@app.post("/v1/agent/run")
async def run_agent(request: AgentRequest):
    """Run agent with tool use."""
    
    agent.max_iterations = request.max_iterations
    
    result = await agent.run(request.messages)
    
    return {
        "response": result.final_response,
        "iterations": result.iterations,
        "tool_calls": [
            {"name": tc.name, "arguments": tc.arguments}
            for tc in result.tool_calls
        ],
        "tool_results": [
            {"name": tr.name, "success": tr.success, "result": tr.result}
            for tr in result.tool_results
        ]
    }

@app.get("/v1/functions")
async def list_functions():
    """List available functions."""
    
    return {
        "functions": [
            {
                "name": func.name,
                "description": func.description,
                "parameters": [
                    {
                        "name": p.name,
                        "type": p.type,
                        "description": p.description,
                        "required": p.required
                    }
                    for p in func.parameters
                ]
            }
            for func in registry._functions.values()
        ]
    }

@app.get("/v1/functions/{name}/schema")
async def get_function_schema(name: str):
    """Get OpenAI schema for a function."""
    
    func = registry.get(name)
    
    if func is None:
        raise HTTPException(status_code=404, detail="Function not found")
    
    return func.to_openai_schema()

@app.get("/health")
async def health():
    return {"status": "healthy"}

References

Conclusion

Function calling unlocks the full potential of LLMs by letting them take real actions. Start with clear function definitions—good descriptions and parameter documentation help the model choose the right function and extract correct arguments. Use a registry to manage available functions and generate schemas automatically. Implement safe execution with validation, timeouts, and allowlists to prevent misuse. Build a tool use loop that handles multi-step interactions where the model calls functions, receives results, and decides what to do next. Execute independent function calls in parallel to reduce latency. Use function calling for structured extraction when you need reliable data formats. The key insight is that function calling is about giving LLMs agency—the ability to affect the world, not just describe it. Design your functions carefully, validate inputs thoroughly, and monitor execution closely. Well-designed function calling transforms chatbots into capable agents.


Discover more from Code, Cloud & Context

Subscribe to get the latest posts sent to your email.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.