n4nAI

Handling parallel tool call results in a single response

Learn how to execute multiple LLM tool calls concurrently and return all results in one response, with runnable Python code for OpenAI-compatible APIs.

n4n Team3 min read632 words

Audio narration

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

Handling parallel tool call results is a routine hurdle when you let a model invoke multiple functions in one turn. When an LLM returns a single assistant message containing several tool_calls, you must run each function, collect the outputs, and feed them back as separate tool messages so the model can synthesize one final answer. This how-to gives a complete, runnable pattern using the OpenAI-compatible request shape.

Step 1: Configure the client and request parallel calls

Point the OpenAI Python client at any OpenAI-compatible endpoint. For example, n4n.ai exposes one endpoint covering 240+ models with automatic fallback when a provider is degraded, but the code below works against any compliant server.

from openai import OpenAI

client = OpenAI(
    base_url="https://api.n4n.ai/v1",  # swap for your gateway
    api_key="sk-your-key",
)

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "Get current weather for a city",
            "parameters": {
                "type": "object",
                "properties": {"city": {"type": "string"}},
                "required": ["city"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "get_stock_price",
            "description": "Get stock price for a ticker",
            "parameters": {
                "type": "object",
                "properties": {"ticker": {"type": "string"}},
                "required": ["ticker"],
            },
        },
    },
]

response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": "What's the weather in Oslo and the price of AAPL?"}],
    tools=tools,
    parallel_tool_calls=True,  # explicit; default is True but be deliberate
)
assistant_msg = response.choices[0].message

The parallel_tool_calls=True directive tells the model it may emit multiple calls. The returned assistant_msg.tool_calls will be a list if the model complied.

Step 2: Detect and extract the tool calls

Inspect the message. If tool_calls is None, the model answered directly and you are done. Otherwise, you have a list of call objects, each with a unique id, a function.name, and a function.arguments string.

import json

if not assistant_msg.tool_calls:
    print("No tool calls:", assistant_msg.content)
    exit()

calls = []
for tc in assistant_msg.tool_calls:
    calls.append({
        "id": tc.id,
        "name": tc.function.name,
        "args": json.loads(tc.function.arguments),
    })
print(f"Model requested {len(calls)} parallel calls")

Handling parallel tool call results starts with this extraction: you cannot batch the responses into one tool message. Each call needs its own reply.

Step 3: Execute the functions concurrently

Spin up the actual work. Use asyncio if your tools are I/O bound, or a thread pool if they are blocking SDK calls. The key is to launch all of them without waiting for the previous to finish.

import asyncio

async def run_tool(name, args):
    # stub implementations; replace with real HTTP/DB calls
    if name == "get_weather":
        return {"temp_c": 12, "city": args["city"]}
    if name == "get_stock_price":
        return {"price": 227.34, "ticker": args["ticker"]}
    raise ValueError(f"Unknown tool {name}")

async def dispatch(calls):
    tasks = [run_tool(c["name"], c["args"]) for c in calls]
    return await asyncio.gather(*tasks, return_exceptions=True)

results = asyncio.run(dispatch(calls))

Wrapping with return_exceptions=True keeps one failing tool from killing the whole batch. You still need to map each result back to its call by index.

Step 4: Format results as individual tool messages

The API requires a message with role: "tool" for every tool_call_id. The content must be a string, typically JSON.

tool_messages = []
for call, res in zip(calls, results):
    if isinstance(res, Exception):
        content = json.dumps({"error": str(res)})
    else:
        content = json.dumps(res)
    tool_messages.append({
        "role": "tool",
        "tool_call_id": call["id"],
        "content": content,
    })

A common mistake in handling parallel tool call results is reusing a single tool_call_id or omitting one. The server will reject the request if the IDs do not match the assistant message exactly.

Step 5: Send the consolidated context back

Append the original assistant message (with its tool_calls) and all tool messages to your conversation history, then call the model again. Do not set tools unless you want it to call again; usually you omit or keep them but the model should now answer.

follow_up = [{"role": "user", "content": "What's the weather in Oslo and the price of AAPL?"}]
follow_up.append(assistant_msg.model_dump())  # includes tool_calls
follow_up.extend(tool_messages)

final = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=follow_up,
)
print(final.choices[0].message.content)

The model now has all parallel outputs in context and can produce a single natural-language response that references both the weather and the stock price.

Step 6: Verify the round trip

Success means the final message contains no tool_calls and non-empty content. Add a quick assertion in tests:

assert final.choices[0].message.tool_calls is None
assert len(final.choices[0].message.content) > 0
# optional: check usage metering
print(final.usage.model_dump())

If you are using a gateway that meters per-token usage, the usage object reflects both the initial and follow-up calls separately. Run the script end to end; you should see the stubbed weather and stock data merged into one sentence.

Pitfalls when handling parallel tool call results

Streaming

If you stream the first response, tool_calls arrive in fragments. Accumulate delta.tool_calls across chunks before executing. The same ID-matching rule applies.

Partial failures

Returning an error string in a tool message is valid. The model can recover or explain the failure. Do not raise from the dispatch loop; surface it as content.

Order independence

Tool messages do not need to be in the same order as tool_calls, but all must be present. Some SDKs will silently drop a tool message with an unmatched ID.

Cache hints

If your gateway forwards provider cache-control hints, mark static tool schemas with cache_control to cut token cost on the second call. n4n.ai honors client routing directives and forwards provider cache-control hints, which helps when you repeat large tool definitions across multi-step turns.

Closing

The pattern is stable: extract, execute concurrently, map by ID, return all, and let the model merge. Once this loop is abstracted into a small runner, handling parallel tool call results becomes a few lines of orchestration rather than a source of bugs.

Tagsparallel-function-callingtool-resultsfunction-callingapi

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 →