Parallel Tool Calls in Function Calling

AILLMAI AgentsArchitecture
Share on LinkedIn

The agent needed weather in Tokyo, exchange rates for JPY, and the user's saved travel preferences. The model correctly returned three tool calls in one response. Your orchestrator ran them sequentially — 2.4 seconds total. Parallel execution finished in 0.8 seconds. Same answer, three times faster. Parallel tool calling is one of the highest-ROI latency optimizations in LLM agents, and most teams leave it on the table because their tool runner is a for-loop.

Provider support

OpenAI and Anthropic both support multiple tool calls per response:

response = await client.chat.completions.create(
    model="gpt-4o",
    messages=messages,
    tools=TOOLS,
    parallel_tool_calls=True,  # OpenAI default since 2024
)
tool_calls = response.choices[0].message.tool_calls
# May contain 1-N calls

The model decides how many tools to invoke. Your job is executing them efficiently.

Parallel execution pattern

async def execute_tool_calls(
    tool_calls: list[ToolCall],
    registry: ToolRegistry,
) -> list[ToolResult]:
    async def run_one(call: ToolCall) -> ToolResult:
        handler = registry.get(call.function.name)
        try:
            args = json.loads(call.function.arguments)
            result = await asyncio.wait_for(
                handler(**args),
                timeout=handler.timeout,
            )
            return ToolResult(tool_call_id=call.id, content=result, error=None)
        except Exception as e:
            return ToolResult(tool_call_id=call.id, content=None, error=str(e))

    return await asyncio.gather(*[run_one(c) for c in tool_calls])

Return all results in one message:

tool_messages = [
    {"role": "tool", "tool_call_id": r.tool_call_id,
     "content": r.content if r.error is None else f"Error: {r.error}"}
    for r in results
]
messages.extend(tool_messages)

Dependency-aware scheduling

Not everything can parallelize. Build a dependency graph when the model chains tools:

@dataclass
class ToolCall:
    id: str
    name: str
    args: dict
    depends_on: list[str] = field(default_factory=list)

async def execute_dag(calls: list[ToolCall]) -> list[ToolResult]:
    results = {}
    remaining = list(calls)
    while remaining:
        ready = [c for c in remaining if all(d in results for d in c.depends_on)]
        if not ready:
            raise CyclicDependency(remaining)
        batch_results = await asyncio.gather(*[run(c, results) for c in ready])
        for call, result in zip(ready, batch_results):
            results[call.id] = result
        remaining = [c for c in remaining if c.id not in results]
    return list(results.values())

Most model-generated parallel calls are independent — the DAG path handles explicit chains.

Rate limiting parallel calls

Three parallel calls to the same API can hit rate limits:

class RateLimitedExecutor:
    def __init__(self):
        self.semaphores = defaultdict(lambda: asyncio.Semaphore(5))

    async def run(self, call: ToolCall) -> ToolResult:
        async with self.semaphores[call.name]:
            return await execute(call)

Per-tool semaphores prevent thundering herd on shared backends.

Idempotency and side effects

Parallelizing mutating tools is risky:

Tool type Parallel safe?
Read/search Yes
Create (different entities) Usually yes
Update same resource No — race condition
Delete No
Payment/charge Never parallel

Tag tools with read_only: true in schema. Only parallelize read-only by default; require explicit opt-in for writes.

READ_ONLY = {"search", "get_weather", "fetch_url", "list_orders"}

async def execute_tool_calls(calls: list[ToolCall]) -> list[ToolResult]:
    reads = [c for c in calls if c.name in READ_ONLY]
    writes = [c for c in calls if c.name not in READ_ONLY]
    read_results = await asyncio.gather(*[run(c) for c in reads])
    write_results = []
    for w in writes:  # sequential for writes
        write_results.append(await run(w))
    return read_results + write_results

Timeouts and partial results

Set per-tool timeouts shorter than the overall request timeout:

TOOL_TIMEOUTS = {
    "search": 5,
    "fetch_url": 10,
    "database_query": 15,
}

If one of three parallel calls times out, return the other two results with an error for the third. The model can often answer with partial data.

Observability

Trace each tool call as a separate span under the LLM turn span:

llm.turn (800ms)
  ├── tool.search (120ms)
  ├── tool.get_weather (450ms)  ← bottleneck
  └── tool.get_preferences (80ms)

Parallel wall-clock = max(individual), not sum. Dashboards should show both per-tool latency and batch latency.

Provider API differences

Provider Parallel support Notes
OpenAI Yes, parallel_tool_calls Default true
Anthropic Yes, multiple tool_use blocks Same turn
Gemini Function calling parallel Check model version
Local models Varies Often sequential only

Disable parallel when tools have implicit ordering:

response = client.chat.completions.create(
    model="gpt-4o",
    messages=messages,
    tools=tools,
    parallel_tool_calls=False,  # force sequential
)

Dependency-aware parallelization

Build execution DAG when tools depend on each other:

# Model returned: search_orders(user), get_order_details(order_id from ???)
# Can't parallelize — second depends on first result

async def execute_with_dependencies(calls, prior_results):
    independent = [c for c in calls if not c.depends_on(prior_results)]
    if not independent:
        return sequential_execute(calls)
    return await asyncio.gather(*[run(c) for c in independent])

Most LLMs don't declare dependencies — your orchestrator detects from argument references or executes writes sequentially by default.

Pair with agent parallel tool execution and tool use error recovery for production agent tool pipelines.

Common production mistakes

Teams get function calling parallel tools wrong in predictable ways:

LLM features around function calling parallel tools break in production when prompts assume deterministic output, context windows are sized for dev datasets, or token costs are never budgeted per user session. Always log prompt hash, model version, and latency—not raw prompts with PII.

Resources

Frequently asked questions

When can tool calls run in parallel?

When they have no data dependencies — looking up weather and stock price for the same user query, searching two databases, fetching multiple URLs. Sequential is required when tool B needs tool A's output (get user ID, then fetch orders). The model often requests parallel calls correctly if your tool descriptions are clear about independence.

Does parallel execution confuse the model?

No — you execute in parallel on the backend but return all results in one tool response message. The model receives structured results together and synthesizes. What confuses the model is partial failures without clear error messages per tool call.

How do I handle partial failures in parallel tool execution?

Return per-tool success/failure in the tool response message. Include error details for failed tools and results for successful ones. Let the model decide whether to retry, proceed with partial data, or tell the user. Never fail the entire batch because one tool timed out.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →