n4nAI

Parallel function calling: running multiple tools at once

Parallel function calling multiple tools lets LLM agents invoke several functions in one turn. Learn how it works, why it matters, and common pitfalls.

n4n Team6 min read1,223 words

Audio narration

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

Parallel function calling is the model’s ability to return multiple tool invocations in a single inference response, letting the client execute them at the same time instead of serially. With parallel function calling multiple tools, an agent can ask for a stock quote, a calendar lookup, and a web search in one turn, and your code can run those independent operations concurrently to cut end-to-end latency. It is a protocol-level feature of chat completions APIs that support tools, not a separate endpoint.

What parallel function calling actually is

In the OpenAI-compatible tool calling contract, the assistant message can contain a tool_calls array. Each element is a structured request: an id, a type (always function), and a function object with name and arguments (a JSON string). When the model predicts more than one element in that array, it is proposing parallel work.

Sequential tool use emits one tool_call, waits for the client to return a tool message, then generates the next. Parallel function calling multiple tools collapses that loop: the model decides upfront which independent functions to invoke, and the client schedules them together.

The model does not execute the functions. It never touches your database or HTTP endpoints. It only emits strings that your runtime interprets. This distinction matters when engineers blame the LLM for slow tool execution.

How it works under the hood

A typical request sends a tools list and sets tool_choice to auto (or a specific function). The response object contains:

{
  "choices": [
    {
      "message": {
        "role": "assistant",
        "content": null,
        "tool_calls": [
          {
            "id": "call_abc",
            "type": "function",
            "function": {
              "name": "get_weather",
              "arguments": "{\"city\": \"SF\"}"
            }
          },
          {
            "id": "call_def",
            "type": "function",
            "function": {
              "name": "get_stock",
              "arguments": "{\"symbol\": \"NVDA\"}"
            }
          }
        ]
      }
    }
  ]
}

Your client must parse each tool_calls[i].function.arguments (JSON decode), dispatch the call, collect results, and append role: "tool" messages with matching tool_call_id values. The conversation then continues with another assistant turn.

A minimal async dispatcher in Python:

import asyncio, json
from openai import AsyncOpenAI

client = AsyncOpenAI()

async def run_tool(name, args):
    if name == "get_weather":
        return await fetch_weather(args["city"])
    if name == "get_stock":
        return await fetch_stock(args["symbol"])
    raise ValueError(f"unknown tool {name}")

async def execute_parallel(tool_calls):
    tasks = [
        run_tool(tc.function.name, json.loads(tc.function.arguments))
        for tc in tool_calls
    ]
    results = await asyncio.gather(*tasks, return_exceptions=True)
    return [
        {"tool_call_id": tc.id, "role": "tool", "content": str(r)}
        for tc, r in zip(tool_calls, results)
    ]

The key is asyncio.gather: it launches all coroutines without awaiting each individually. You preserve the order via zip with the original tool_calls list, because gather returns in the same order as the input sequence.

How models decide to emit multiple calls

The behavior is learned, not signaled by a special token. During supervised fine-tuning, models are exposed to dialogues where the assistant responds with several tool_calls at once because the user request had multiple independent intents (“What’s the weather in SF and the price of NVDA?”). The model learns to batch when it detects non-overlapping information needs. It will not emit parallel calls if it believes one argument depends on another’s result. That belief is heuristic; it can be wrong.

Why it matters for agents

Latency is the obvious win. If three tools each take 200 ms of network I/O, serial execution costs ~600 ms plus model round-trips; parallel function calling multiple tools costs ~200 ms plus one model round-trip. For user-facing agents, that is the difference between snappy and sluggish.

It also simplifies orchestration for independent data gathering. A research agent that needs to pull from three APIs does not need a custom DAG when the model already knows the calls are unrelated. You avoid prompt engineering tricks that force step-by-step reasoning.

But parallelism is not free. You multiply outbound request rate, which can trip provider rate limits or your own downstream quotas. When you fire parallel function calling multiple tools at scale, a gateway that honors client routing directives and automatically falls back on degraded providers becomes useful infrastructure. For example, n4n.ai exposes one OpenAI-compatible endpoint addressing 240+ models with automatic fallback when a provider is rate-limited, which keeps a burst of tool calls from failing outright.

Concrete example: travel planner

Suppose a user asks: “Plan my trip to Tokyo next week: flight price, hotel availability, and weather.” The model should emit three tool calls.

Tool schema:

[
  {
    "type": "function",
    "function": {
      "name": "search_flights",
      "description": "Search flight prices",
      "parameters": {
        "type": "object",
        "properties": {
          "dest": {"type": "string"},
          "date": {"type": "string"}
        },
        "required": ["dest", "date"]
      }
    }
  },
  {
    "type": "function",
    "function": {
      "name": "check_hotels",
      "description": "Check hotel rooms",
      "parameters": {
        "type": "object",
        "properties": {
          "city": {"type": "string"},
          "checkin": {"type": "string"}
        },
        "required": ["city", "checkin"]
      }
    }
  },
  {
    "type": "function",
    "function": {
      "name": "get_weather_forecast",
      "description": "Get weather for dates",
      "parameters": {
        "type": "object",
        "properties": {
          "location": {"type": "string"},
          "days": {"type": "integer"}
        },
        "required": ["location", "days"]
      }
    }
  }
]

The assistant response might contain all three calls. Your executor runs them concurrently:

tool_calls = response.choices[0].message.tool_calls
messages = [user_msg, response.choices[0].message]
tool_msgs = await execute_parallel(tool_calls)
messages.extend(tool_msgs)

followup = await client.chat.completions.create(
    model="gpt-4o-mini",
    messages=messages,
    tools=tools
)

The final assistant message synthesizes the three results into a plan. No intermediate model calls were needed to decide between tools.

Context window and token accounting

Each parallel call’s arguments are serialized into the assistant message, consuming context tokens. When you return results, every tool message adds more tokens. If you fire ten parallel calls with verbose arguments, you can blow a small context window before the synthesis turn. Track usage via the usage field in the response. Gateways with per-token usage metering make it easier to attribute cost to specific agent turns, but the math is the same anywhere.

Common misconceptions

The model runs the tools

It does not. The tool_calls array is text. Your process must implement search_flights, check_hotels, and get_weather_forecast. Blaming the LLM for a timeout in your PostgreSQL query is a category error.

All parallel calls are independent

The model tries to guess independence, but it can be wrong. If one tool’s arguments depend on another’s output (e.g., “get user ID then get orders for that ID”), parallel function calling multiple tools will produce a broken call because both fire before either result exists. You must validate argument completeness before dispatch, or fall back to sequential mode when dependency keywords appear.

Parallel is always faster

For two calls that hit the same rate-limited endpoint, concurrency may trigger 429s and require retries, netting slower total time. Also, asyncio.gather does not magically speed up CPU-bound local functions; you need process pools for those. Measure before assuming.

Tool calls are ordered or prioritized

The array order is not a dependency order. Do not assume tool_calls[0] must finish before tool_calls[1]. They are peers. If you need ordering, encode it in your own orchestration layer, not in the call array.

You must use streaming

Streaming is unrelated. Parallel function calling multiple tools works with a single non-streamed response because the full tool_calls array arrives at once. Streaming only helps you show incremental text, not tool scheduling.

Error handling and partial failure

When asyncio.gather(..., return_exceptions=True) is used, a failed tool returns an exception object instead of crashing the batch. You should map each exception back to its tool_call_id and decide whether to send an error string as the tool result or abort the turn.

for tc, r in zip(tool_calls, results):
    if isinstance(r, Exception):
        content = f"ERROR: {type(r).__name__}: {r}"
    else:
        content = r
    messages.append({"role": "tool", "tool_call_id": tc.id, "content": content})

The model can often recover from a tool error if you return a descriptive message rather than dropping the message entirely. But if a critical call fails (e.g., auth), you may need to short-circuit and inform the user.

Testing your executor

Treat the dispatcher as critical infrastructure. A pytest case with mocked coroutines verifies concurrency and ID mapping:

import pytest, asyncio

@pytest.mark.asyncio
async def test_execute_parallel(monkeypatch):
    calls = [make_fake_tc("a", "{}"), make_fake_tc("b", "{}")]
    async def fake_run(name, args):
        await asyncio.sleep(0.01)
        return f"ran {name}"
    monkeypatch.setattr("__main__.run_tool", fake_run)
    out = await execute_parallel(calls)
    assert len(out) == 2
    assert out[0]["tool_call_id"] == "a"
    assert out[1]["tool_call_id"] == "b"

This catches regressions where you accidentally await calls in a loop.

When to avoid parallel function calling

If your tools have side effects (posting orders, sending emails), firing them concurrently is risky. A partial failure could leave one side effect committed and another not. Use parallel function calling multiple tools only for read-only or idempotent operations, or implement a transactional wrapper.

Also avoid it when the model is likely to hallucinate dependencies. In tightly coupled workflows, force tool_choice to a single function and iterate.

Provider caveats

Not every model supports multiple tool_calls. Older checkpoints or some open-weight models emit at most one call even when the prompt implies several. Verify against the model card before designing your agent around parallelism. If you route across providers, ensure the gateway forwards the same tools schema without mutation.

Summary of the contract

  • Model returns tool_calls array.
  • Client decodes, schedules concurrent execution.
  • Results returned as role: "tool" with matching tool_call_id.
  • No guarantee of independence; validate.
  • Latency win for I/O-bound independent tools.

Parallel function calling multiple tools is a straightforward extension of the standard tool calling protocol. The complexity lives in your executor, not the model. Write a robust async dispatcher, handle failures per call, and reserve parallelism for genuinely independent work.

Tagsparallel-function-callingtool-usellm-agentsfundamentals

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 parallel & multi-step tool use posts →