n4nAI

Function calling 101: how LLM tool use actually works

Explains how does llm function calling work: the JSON schema contract, the inference and execution loop, and the misconceptions engineers hit building tools.

n4n Team4 min read932 words

Audio narration

Coming soon — every post will get a voice note here.

LLM function calling is a structured protocol where a model emits a machine-readable request to invoke a named function with typed arguments, instead of free text. It lets you bridge generative text output to deterministic code execution. Understanding how does llm function calling work means tracing the JSON schema contract, the inference loop, and the dispatcher that runs your code.

The schema contract

Function calling is not prompt engineering. You declare functions as JSON Schema objects passed in the tools parameter of a chat completion request. The model sees the schema as part of its context and is trained to output a matching call when the user intent requires it.

{
  "type": "function",
  "function": {
    "name": "get_weather",
    "description": "Fetch current conditions for a city",
    "parameters": {
      "type": "object",
      "properties": {
        "location": { "type": "string" },
        "unit": { "type": "string", "enum": ["celsius", "fahrenheit"] }
      },
      "required": ["location"]
    }
  }
}

The schema is the only guarantee you get. If your description is ambiguous, the model will guess. If your required list is wrong, you will get runtime errors in your own code, not the model’s. Write the schema like you would an external API doc: precise nouns, constrained enums, explicit optionality.

The inference loop

A function call is a multi-turn conversation. The client sends the user message plus tools. The model returns either normal content or a tool_calls array.

resp = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": "Weather in Berlin?"}],
    tools=[weather_tool],
)
msg = resp.choices[0].message
if msg.tool_calls:
    print(msg.tool_calls[0].function.name)
    print(msg.tool_calls[0].function.arguments)

The arguments are a JSON string, not a parsed object. You must deserialize and validate. The model does not execute anything. Your process must.

After execution, you return the result as a tool role message, referencing the tool_call_id. The model then synthesizes a final answer.

result = get_weather(**json.loads(msg.tool_calls[0].function.arguments))
followup = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[
        {"role": "user", "content": "Weather in Berlin?"},
        msg,
        {"role": "tool", "tool_call_id": msg.tool_calls[0].id, "content": json.dumps(result)},
    ],
    tools=[weather_tool],
)

This loop is the core of how does llm function calling work in practice: generate, dispatch, observe, generate. Each turn resends the schema, so token cost grows linearly with rounds.

Training and decoding: what the model actually sees

To fully grasp how does llm function calling work, you need to know what the model sees at decode time. During supervised fine-tuning, call examples are formatted with special delimiter tokens that mark function name and arguments. The base model learns a conditional distribution: given the schema and the user prompt, emit those tokens.

At inference, many providers apply constrained decoding or logit masks so the generated span is valid JSON matching the schema. That is why arguments arrive as a string rather than native objects—the decoder terminated the span at the closing brace. It is not the model “understanding” Python; it is a grammar constraint.

Why it matters in production

Free-text parsing breaks. Function calling gives you a typed boundary. That boundary lets you:

  • Enforce input shapes with JSON Schema.
  • Compose multiple tools without rewriting prompts.
  • Cache deterministic outputs (weather, DB reads) separately from generation.

If you front your traffic with a gateway such as n4n.ai, the same OpenAI-compatible tools payload addresses 240+ models and automatic fallback kicks in when a provider is rate-limited. Your calling code stays identical; only the model string changes. The gateway also honors client routing directives and forwards provider cache-control hints, so repeated tool schemas benefit from provider-side caching instead of being re-billed every turn.

A concrete end-to-end example

Suppose you build a support bot that can refund orders. The function schema:

{
  "type": "function",
  "function": {
    "name": "issue_refund",
    "description": "Refund an order by ID",
    "parameters": {
      "type": "object",
      "properties": {
        "order_id": { "type": "string" },
        "reason": { "type": "string" }
      },
      "required": ["order_id"]
    }
  }
}

User says: “Refund order A123, the item arrived broken.” The model returns:

{
  "tool_calls": [
    {
      "id": "call_1",
      "type": "function",
      "function": {
        "name": "issue_refund",
        "arguments": "{\"order_id\": \"A123\", \"reason\": \"item broken\"}"
      }
    }
  ]
}

Your code validates order_id against a regex, calls Stripe, and returns {"status": "refunded", "txn": "ch_123"}. The model then tells the user politely. No natural language parsing of “A123” required.

Common misconceptions

The model executes the function

It never does. The model emits a request. If your dispatcher crashes, the model doesn’t know unless you return an error tool message. Treat tool calls as untrusted input from a very confident intern.

Function calling is RAG

Retrieval augments the prompt with context. Function calling invokes code. You can combine them—retrieve docs via a search tool—but they solve different problems.

You need an agent framework

Frameworks hide the loop behind abstractions. The loop is 20 lines of Python. Until you need parallel tool calls, persistence, or planning, a plain while loop with tool_calls is enough.

Schemas are optional

Omitting description or using type: string for everything pushes all disambiguation into the model’s weights. That works in demos and fails in audits. Write tight schemas.

Failure modes and guards

Validate arguments with pydantic or jsonschema before execution. Set timeouts on the tool side; a hanging HTTP call blocks the whole turn. Rate-limit tools independently—models will call get_weather five times if the user asks about five cities.

from pydantic import BaseModel, ValidationError

class RefundArgs(BaseModel):
    order_id: str
    reason: str | None = None

try:
    args = RefundArgs(**json.loads(tool_call.function.arguments))
except ValidationError as e:
    # return error as tool message
    content = f"invalid args: {e}"

The model can often self-correct from a well-formed error message. That is cheaper than a new conversation.

Streaming and parallel calls

Modern endpoints support stream: true with tool calls delivered as deltas. Parallel calls arrive as multiple objects in tool_calls. Execute them concurrently with asyncio.gather, but preserve order in the response messages.

# pseudo-async dispatch
tasks = [run_tool(tc) for tc in msg.tool_calls]
results = await asyncio.gather(*tasks)

Understanding how does llm function calling work at the streaming layer prevents race conditions where tool results interleave.

Security surface

Tool calls are an attack vector. A prompt injection in a webpage the user pasted can coerce the model into calling issue_refund with attacker-chosen arguments. Never let the model invoke privileged operations without a human confirmation step or a policy check in your dispatcher. Function calling expands your app’s blast radius; treat the schema as an attack surface, not a convenience.

Cost and latency tradeoffs

Each tool round trip adds latency and tokens for schema resend. Mitigate by caching system prompt and tools at the provider level. Keep schemas compact—every enum value and description word is paid for on every call. If you only need one function, send one function.

Closing notes

Function calling is a contract, not a feature. The moment you treat the schema as an API specification and the loop as a state machine, your LLM integrations become testable. Write the schema first, mock the tool, then wire the model. That discipline is the difference between a demo and a system.

Tagsfunction-callingtool-usellm-basicsfundamentals

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All function calling fundamentals posts →