Parallel Function Calling
Handle responses where the model calls multiple functions simultaneously, execute them in parallel with asyncio, and batch the results into a single follow-up API call.
Parallel Function Calling is a free AI Engineering Academy lesson on CoddyKit — lesson 3 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the AI Engineering Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
What Is Parallel Function Calling?
OpenAI's models can call multiple functions simultaneously in a single response when the answer requires information from several independent sources. Instead of chaining tool calls sequentially — each waiting for the previous one — the model emits multiple tool calls at once. Your application executes them in parallel and sends all results back together, dramatically reducing latency.
Recognizing Parallel Tool Calls
When the model issues parallel tool calls, the response message contains a tool_calls list with more than one entry. Each entry has a unique id, function name, and arguments. You must process all of them before making the follow-up API call — the model expects results for every tool call it issued.
from openai import OpenAI
import json
client = OpenAI()
# A question that naturally requires two independent lookups
response = client.chat.completions.create(
model='gpt-4o',
messages=[{'role': 'user', 'content': 'Compare the weather in London and Tokyo right now.'}],
tools=tools
)
message = response.choices[0].message
print('Number of tool calls:', len(message.tool_calls))
# Might print: Number of tool calls: 2
for tc in message.tool_calls:
print(f' {tc.function.name}({tc.function.arguments})')
# get_current_weather({"location": "London"})
# get_current_weather({"location": "Tokyo"})Executing Tool Calls with asyncio
Run multiple tool calls concurrently using asyncio.gather(). Each tool function runs in a separate coroutine, and all results are collected when all coroutines complete. This is far faster than sequential execution when each tool call makes a network request.
import asyncio
import json
async def execute_tool_call_async(tool_call) -> tuple:
'''Execute a single tool call and return (tool_call_id, result).'''
name = tool_call.function.name
args = json.loads(tool_call.function.arguments)
# Async version of your tool (uses httpx, aiohttp, etc.)
if name == 'get_current_weather':
result = await async_get_weather(**args)
elif name == 'get_stock_price':
result = await async_get_stock(**args)
else:
result = f'Unknown tool: {name}'
return tool_call.id, str(result)
async def execute_all_parallel(tool_calls) -> list:
'''Execute all tool calls concurrently.'''
tasks = [execute_tool_call_async(tc) for tc in tool_calls]
results = await asyncio.gather(*tasks, return_exceptions=True)
return resultsSending All Results Back Together
After executing all parallel tool calls, add every result as a separate role='tool' message to the conversation. Each message must include its matching tool_call_id. Send all of them in a single follow-up API call so the model can synthesize all results into one coherent answer.
async def run_parallel_tool_calls(user_message: str) -> str:
messages = [{'role': 'user', 'content': user_message}]
response = client.chat.completions.create(
model='gpt-4o', messages=messages, tools=tools
)
assistant_message = response.choices[0].message
messages.append(assistant_message) # Add assistant's tool_calls
if response.choices[0].finish_reason == 'tool_calls':
# Execute all tool calls in parallel
results = await execute_all_parallel(assistant_message.tool_calls)
# Add all results to conversation
for tool_call_id, result in results:
messages.append({
'role': 'tool',
'tool_call_id': tool_call_id,
'content': result
})
# One more API call to synthesize results
final = client.chat.completions.create(model='gpt-4o', messages=messages)
return final.choices[0].message.content
return assistant_message.contentUsing ThreadPoolExecutor for Sync Tools
If your tool functions are synchronous (using requests rather than httpx async), you can still run them in parallel using concurrent.futures.ThreadPoolExecutor. This is simpler to set up but slightly less efficient than pure async code.
from concurrent.futures import ThreadPoolExecutor, as_completed
import json
def execute_all_with_threads(tool_calls) -> list:
results = []
with ThreadPoolExecutor(max_workers=len(tool_calls)) as executor:
future_to_id = {
executor.submit(execute_tool_call, tc): tc.id
for tc in tool_calls
}
for future in as_completed(future_to_id):
tool_call_id = future_to_id[future]
try:
result = future.result(timeout=15)
except Exception as e:
result = f'Tool failed: {str(e)}'
results.append((tool_call_id, str(result)))
return resultsHandling Individual Tool Failures
When executing tool calls in parallel, one may fail while others succeed. Never let one failure block the others. Use return_exceptions=True in asyncio.gather() to collect all results even if some raise exceptions. Convert exceptions to error strings so the model receives all results and can reason about partial failures.
async def safe_execute_all(tool_calls) -> list:
tasks = [execute_tool_call_async(tc) for tc in tool_calls]
raw_results = await asyncio.gather(*tasks, return_exceptions=True)
results = []
for tc, result in zip(tool_calls, raw_results):
if isinstance(result, Exception):
results.append((tc.id, f'Tool error: {str(result)}'))
else:
tool_call_id, output = result
results.append((tool_call_id, output))
return resultsWhen Models Use Parallel Calls
The model issues parallel tool calls when it determines the required information can be gathered independently — one result doesn't depend on another. Examples: fetching stock prices for multiple tickers, getting weather in multiple cities, or querying multiple database tables. Sequential calls happen when results are dependent — first look up a user ID, then fetch orders for that ID.
Rate Limiting Parallel Calls
Running many tool calls in parallel can overwhelm external APIs with simultaneous requests. Use a semaphore to cap the maximum number of concurrent tool calls. This respects API rate limits while still executing more efficiently than pure sequential processing.
import asyncio
async def rate_limited_execute_all(tool_calls, max_concurrent: int = 5) -> list:
semaphore = asyncio.Semaphore(max_concurrent)
async def limited_call(tc):
async with semaphore:
return await execute_tool_call_async(tc)
tasks = [limited_call(tc) for tc in tool_calls]
results = await asyncio.gather(*tasks, return_exceptions=True)
return [
(tc.id, str(r) if not isinstance(r, Exception) else f'Error: {r}')
for tc, r in zip(tool_calls, results)
]Chaining Sequential and Parallel Calls
Real agentic workflows often mix sequential and parallel calls. The model might first call lookup_user(email), then — using the returned user_id — call get_orders(user_id) and get_preferences(user_id) in parallel. Implement the outer loop to detect which calls can be parallelized (independent) and which must be sequential (dependent).
async def multi_round_agent(user_message: str) -> str:
messages = [{'role': 'user', 'content': user_message}]
MAX_ROUNDS = 5
for _ in range(MAX_ROUNDS):
response = client.chat.completions.create(
model='gpt-4o', messages=messages, tools=tools
)
choice = response.choices[0]
messages.append(choice.message)
if choice.finish_reason == 'stop':
return choice.message.content # Done
if choice.finish_reason == 'tool_calls':
# Execute all tool calls in parallel (may be 1 or many)
results = await safe_execute_all(choice.message.tool_calls)
for tc_id, result in results:
messages.append({'role': 'tool', 'tool_call_id': tc_id, 'content': result})
# Loop continues for potentially sequential next call
return 'Max rounds reached.'Measuring Latency Improvement
Parallel tool calls can dramatically reduce latency. If each of three tool calls takes 500ms sequentially, the total is 1500ms. Running them in parallel reduces it to ~500ms — a 3x speedup. Always measure and compare sequential vs parallel execution in your specific scenario, accounting for concurrency overhead and rate limit constraints.
import time
import asyncio
async def benchmark_parallel_vs_sequential():
tool_calls = [...] # 5 independent tool calls
# Sequential
start = time.time()
for tc in tool_calls:
await execute_tool_call_async(tc)
sequential_time = time.time() - start
# Parallel
start = time.time()
await asyncio.gather(*[execute_tool_call_async(tc) for tc in tool_calls])
parallel_time = time.time() - start
print(f'Sequential: {sequential_time:.2f}s')
print(f'Parallel: {parallel_time:.2f}s')
print(f'Speedup: {sequential_time/parallel_time:.1f}x')Debugging Parallel Tool Calls
When debugging parallel tool call issues, log the full request-response cycle: the model's message with all tool_calls, each result added to the conversation, and the final model response. If a tool result is missing or out of order, the model may produce inconsistent answers. Structured logging with tool_call_id as a correlation key makes tracing issues much easier.
Quick Check
Test your understanding of parallel function calling with OpenAI.
Lesson Recap
In this lesson you learned: parallel tool calls appear as multiple entries in the tool_calls list, asyncio.gather executes them concurrently for maximum speed, and each result needs its matching tool_call_id when sent back to the model. Next up we build a natural language database interface using function calling to translate plain English queries into SQL.
Frequently asked questions
Is the “Parallel Function Calling” lesson free?
Yes — the full text of “Parallel Function Calling” is free to read here on the web, and the AI Engineering Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the AI Engineering Academy course, upgrade to CoddyKit PRO.
What will I learn in “Parallel Function Calling”?
Handle responses where the model calls multiple functions simultaneously, execute them in parallel with asyncio, and batch the results into a single follow-up API call. You practise AI Engineering Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start AI Engineering Academy?
No prior experience is required. AI Engineering Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 3 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Parallel Function Calling” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this AI Engineering Academy lesson?
Yes. Every AI Engineering Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.