LLM Function Calling & Tool Use Patterns

Agentic AI Systems advanced Building with AI ~35 min read

Overview

Function calling — sometimes called tool use — is the mechanism by which large language models break out of pure text generation and interact with the outside world. Rather than producing a natural language answer, the model outputs a structured JSON object naming a function and its arguments, which the host application executes and feeds back as context. This capability transforms LLMs from knowledge engines into orchestration layers that can query databases, call APIs, trigger workflows, and chain multi-step operations.

In a typical AI search system, a semantic search platform demonstrates function calling patterns with both Gemini and Ollama models. Defining precise function schemas, parsing the structured responses, validating arguments, and executing tool chains reliably are the core engineering challenges. A poorly defined schema leads to hallucinated parameters; a brittle parser leads to silent failures; a missing validation step leads to dangerous operations being executed with wrong arguments.

This topic covers the end-to-end lifecycle of function calling: schema definition with JSON Schema, model invocation with tool declarations, response parsing and argument extraction, tool execution with error handling, and multi-turn tool chains where the result of one call becomes context for the next. These patterns apply across model providers and languages, though the examples focus on Go with Gemini and Ollama.

Core Concepts

Function Schema Definition

A function schema tells the model what tools are available, what each one does, and what arguments it accepts. Schemas follow JSON Schema conventions: each parameter has a name, type, description, and whether it’s required. The quality of the description directly impacts how reliably the model selects and parameterizes the function. Vague descriptions (“process the data”) produce unreliable calls; precise descriptions (“search the products table for items matching the given query, returning at most limit results sorted by relevance”) produce consistent, correct invocations.

Structured Output Parsing

When a model decides to call a function, it emits a structured response instead of (or alongside) text. The response contains the function name and a JSON object of arguments. Parsing this requires handling multiple formats: Gemini returns a FunctionCall object with typed fields; Ollama returns a JSON string that must be unmarshaled; some models embed function calls within markdown code fences. A robust parser normalizes across these formats and validates that the returned arguments match the declared schema before execution.

Tool Chain Execution

Complex queries require multiple function calls in sequence. The model calls function A, receives the result, reasons about it, then calls function B with derived arguments. Each step is a full model invocation with the growing conversation history. The host application manages this loop: send the conversation to the model, check if the response contains a function call, execute it, append the result as a “tool” message, and loop until the model produces a final text response. This is the agentic loop at the heart of all LLM agent frameworks.

Parallel vs Sequential Tool Calls

Some models support parallel function calling — emitting multiple function calls in a single response when the calls are independent. For example, simultaneously fetching weather data for three cities rather than querying them one at a time. The host application must detect parallel calls, execute them concurrently, and return all results in the correct order. This reduces latency and round trips but adds complexity to the execution loop and error handling.

Argument Validation and Safety

The model’s generated arguments are untrusted input — they can contain values outside expected ranges, wrong types, or even injection attempts. Every function call must validate its arguments before execution. For database queries, this means parameterized queries rather than string interpolation. For file operations, path validation and sandboxing. For API calls, rate limiting and scope checking. The function calling interface is a trust boundary, and treating it otherwise creates security vulnerabilities.

How It Works

The function calling lifecycle involves four phases: declaration, invocation, execution, and continuation.

Declaration Phase

Before any model interaction, the application defines its available tools as function declarations. Each declaration includes a name, description, and parameter schema. These declarations are sent with every model request so the model knows what tools are available. Good declarations act as few-shot examples: the description tells the model when to use the tool, and the parameter descriptions guide argument generation. Models perform significantly better when enum values are listed explicitly and when parameter descriptions include example values.

Invocation Phase

When the model determines that a function call is the best way to answer a query, it generates a structured response containing the function name and arguments. The model may also generate explanatory text alongside the function call (“I’ll look that up for you”). Different providers format this differently: Gemini uses a dedicated FunctionCall part type, while Ollama wraps the call in a tool-specific message role. The host application inspects the response to determine whether it contains a function call or a final text answer.

Execution Phase

The host application looks up the named function in its tool registry, validates the arguments against the schema, and executes the function. The result — whether successful data or an error message — is formatted as a function response and appended to the conversation. Error messages should be descriptive enough for the model to self-correct: “no results found for query ‘xyz’, try broadening the search terms” is more useful than “error: empty result set”.

Continuation Phase

The conversation (now including the function call and its result) is sent back to the model. The model reads the result and either generates a final answer, calls another function, or requests clarification. This loop continues until the model produces a text-only response. A maximum iteration limit prevents infinite loops where the model repeatedly calls functions without converging on an answer.

Implementation Patterns

Defining Function Schemas in Go

Function schemas are declared as structured types that map to JSON Schema for the model provider.

package tools

import "github.com/google/generative-ai-go/genai"

func SearchToolDeclaration() *genai.Tool {
    return &genai.Tool{
        FunctionDeclarations: []*genai.FunctionDeclaration{
            {
                Name:        "search_documents",
                Description: "Search the document database for entries matching a natural language query. Returns the top matching documents with relevance scores.",
                Parameters: &genai.Schema{
                    Type: genai.TypeObject,
                    Properties: map[string]*genai.Schema{
                        "query": {
                            Type:        genai.TypeString,
                            Description: "Natural language search query describing what to find",
                        },
                        "limit": {
                            Type:        genai.TypeInteger,
                            Description: "Maximum number of results to return (1-100, default 10)",
                        },
                        "filter_category": {
                            Type:        genai.TypeString,
                            Description: "Optional category to filter results. One of: 'docs', 'api', 'tutorial', 'reference'",
                            Enum:        []string{"docs", "api", "tutorial", "reference"},
                        },
                    },
                    Required: []string{"query"},
                },
            },
        },
    }
}

The Agentic Tool Loop

The core loop that drives multi-turn function calling conversations with automatic tool execution.

package agent

import (
    "context"
    "fmt"

    "github.com/google/generative-ai-go/genai"
)

type ToolRegistry map[string]func(args map[string]any) (any, error)

func RunToolLoop(
    ctx context.Context,
    model *genai.GenerativeModel,
    session *genai.ChatSession,
    registry ToolRegistry,
    userMessage string,
    maxIterations int,
) (string, error) {
    // Send the initial user message
    resp, err := session.SendMessage(ctx, genai.Text(userMessage))
    if err != nil {
        return "", fmt.Errorf("send message: %w", err)
    }

    for i := 0; i < maxIterations; i++ {
        // Check if the response contains function calls
        funcCalls := extractFunctionCalls(resp)
        if len(funcCalls) == 0 {
            // No function calls — extract and return the text response
            return extractText(resp), nil
        }

        // Execute each function call and collect responses
        var funcResponses []genai.Part
        for _, fc := range funcCalls {
            handler, ok := registry[fc.Name]
            if !ok {
                funcResponses = append(funcResponses, genai.FunctionResponse{
                    Name:     fc.Name,
                    Response: map[string]any{"error": "unknown function: " + fc.Name},
                })
                continue
            }

            result, execErr := handler(fc.Args)
            if execErr != nil {
                funcResponses = append(funcResponses, genai.FunctionResponse{
                    Name:     fc.Name,
                    Response: map[string]any{"error": execErr.Error()},
                })
                continue
            }

            funcResponses = append(funcResponses, genai.FunctionResponse{
                Name:     fc.Name,
                Response: map[string]any{"result": result},
            })
        }

        // Send function responses back to the model
        resp, err = session.SendMessage(ctx, funcResponses...)
        if err != nil {
            return "", fmt.Errorf("send function response: %w", err)
        }
    }

    return "", fmt.Errorf("max iterations (%d) exceeded without final response", maxIterations)
}

func extractFunctionCalls(resp *genai.GenerateContentResponse) []*genai.FunctionCall {
    var calls []*genai.FunctionCall
    for _, cand := range resp.Candidates {
        for _, part := range cand.Content.Parts {
            if fc, ok := part.(genai.FunctionCall); ok {
                calls = append(calls, &fc)
            }
        }
    }
    return calls
}

func extractText(resp *genai.GenerateContentResponse) string {
    for _, cand := range resp.Candidates {
        for _, part := range cand.Content.Parts {
            if text, ok := part.(genai.Text); ok {
                return string(text)
            }
        }
    }
    return ""
}

Ollama Function Calling with Manual Parsing

When using Ollama models that support tool use, the function call format differs and requires manual JSON parsing.

package ollama

import (
    "encoding/json"
    "fmt"
    "strings"
)

type ToolCall struct {
    Function struct {
        Name      string          `json:"name"`
        Arguments json.RawMessage `json:"arguments"`
    } `json:"function"`
}

type OllamaResponse struct {
    Message struct {
        Role      string     `json:"role"`
        Content   string     `json:"content"`
        ToolCalls []ToolCall `json:"tool_calls,omitempty"`
    } `json:"message"`
}

func ParseToolCalls(resp OllamaResponse) ([]ToolCall, string) {
    if len(resp.Message.ToolCalls) > 0 {
        return resp.Message.ToolCalls, resp.Message.Content
    }

    // Fallback: some models embed tool calls in content as JSON
    content := strings.TrimSpace(resp.Message.Content)
    if strings.HasPrefix(content, "{") && strings.Contains(content, "\"name\"") {
        var tc ToolCall
        if err := json.Unmarshal([]byte(content), &tc.Function); err == nil {
            return []ToolCall{tc}, ""
        }
    }

    return nil, resp.Message.Content
}

func ValidateArgs(call ToolCall, schema map[string]string) (map[string]any, error) {
    var args map[string]any
    if err := json.Unmarshal(call.Function.Arguments, &args); err != nil {
        return nil, fmt.Errorf("invalid arguments JSON: %w", err)
    }

    for param, typ := range schema {
        val, ok := args[param]
        if !ok {
            continue
        }
        switch typ {
        case "string":
            if _, ok := val.(string); !ok {
                return nil, fmt.Errorf("param %q: expected string, got %T", param, val)
            }
        case "integer":
            if num, ok := val.(float64); !ok || num != float64(int(num)) {
                return nil, fmt.Errorf("param %q: expected integer, got %v", param, val)
            }
        }
    }

    return args, nil
}

Practical Applications

Natural Language Database Interface

As an example, a semantic search platform can use function calling to let users query databases with natural language. The model receives a set of tool declarations describing available search operations (vector search, keyword search, filtered queries) and selects the appropriate combination based on the user’s question. A question like “find all Go tutorials about concurrency from the last month” triggers a keyword search with date and category filters, while “find documents similar to this error message” triggers a vector similarity search.

Multi-Step Data Retrieval Pipelines

Complex analytical questions require chaining multiple tool calls. “What’s the average response time for our top 5 most-queried endpoints?” requires: (1) a function call to list endpoints sorted by query count, (2) a function call to fetch latency metrics for each of the top 5, and (3) a computation step. The model orchestrates this pipeline autonomously, calling the right tools in sequence and synthesizing the final answer from the combined results.

Schema-Aware Query Generation

By including table schemas in the tool descriptions, the model generates syntactically correct queries without being fine-tuned on the specific database. The function schema acts as the model’s API documentation, and clear descriptions of column semantics enable the model to correctly map natural language concepts to database fields. This is especially powerful when combined with few-shot examples embedded in the function description.

Common Pitfalls

Further Reading