跪拜 Guibai
← Back to the summary

When an LLM Returns Multiple Tool Calls, Parallel Execution Is Not the Default Answer

When an Agent is connected to multiple tools, a single model response may return more than one tool_call.

For example, if a user asks to analyze two files at the same time, the model might return:

read_file(a.js)
read_file(b.js)

If each file takes 2 seconds to read, sequential execution would take about 4 seconds, while parallel execution might take only about 2 seconds.

It seems like simply putting all tools into Promise.all() would improve performance.

But consider a different task:

Create project
Install dependencies
Start dev server

If all three steps run simultaneously, dependencies might be installed before the project is created, and the server might start before dependencies are ready.

Therefore, the key question for multiple Tool Calls is not "do you know how to use Promise.all()?" but rather:

Is there a dependency relationship between these calls?

This article compares two real Agent Loops from a project:

It examines which tasks each approach suits and how tool results are accurately mapped back to their original tool_call_id.

The Model Returns a Tool Call Array

When the model decides to use a tool, it returns tool_calls inside an AIMessage:

response.tool_calls

Each item typically contains:

{
  id: 'call_xxx',
  name: 'read_file',
  args: {
    filePath: './src/app.js'
  }
}

When a single response requires multiple tools, tool_calls is an array:

[
  {
    id: 'call_1',
    name: 'read_file',
    args: { filePath: './src/app.js' }
  },
  {
    id: 'call_2',
    name: 'read_file',
    args: { filePath: './src/config.js' }
  }
]

The model is only responsible for generating the invocation intent and parameters. The Runtime is what actually decides how to schedule these tasks.

The Runtime can choose:

Sequential execution: complete one before starting the next
Parallel execution: start all simultaneously, wait for all to complete

Approach 1: Parallel Execution with Promise.all()

tool.mjs uses Promise.all() to handle all Tool Calls in one round:

const toolResults = await Promise.all(
  response.tool_calls.map(async (toolCall) => {
    const tool = tools.find(
      item => item.name === toolCall.name
    );

    if (!tool) {
      return `Error: tool ${toolCall.name} not found`;
    }

    try {
      return await tool.invoke(toolCall.args);
    } catch (err) {
      return `Error: ${err.message}`;
    }
  })
);

map() converts each Tool Call into a Promise, and Promise.all() waits for these asynchronous tasks simultaneously.

The execution process can be understood as:

tool_call 1 ──start────────complete
tool_call 2 ──start────complete
tool_call 3 ──start──────────complete
                         ↓
              Continue after all tasks finish

It is best suited for independent I/O tasks, such as:

These tasks do not need to wait for a previous result and do not modify the same shared state.

Will Promise.all() Result Order Get Messed Up?

No.

Even if the second tool finishes executing first, the result array returned by Promise.all() is still arranged in the order of the input Promises.

Input order: call_1, call_2, call_3
Completion order: call_2, call_1, call_3
Result array: result_1, result_2, result_3

Therefore, within the project, results can be mapped back to the original calls using the same index:

response.tool_calls.forEach((toolCall, index) => {
  messages.push(
    new ToolMessage({
      content: toolResults[index],
      tool_call_id: toolCall.id,
    })
  );
});

Two correspondences exist here:

toolResults[index] ↔ response.tool_calls[index]
tool_call_id       ↔ toolCall.id

The array index helps the Runtime fetch the correct result, and tool_call_id helps the model confirm which call a result belongs to.

Parallel execution does not mean tool_call_id can be omitted.

Why Does Promise.all() Often Fail All at Once?

Standard Promise.all() has an important characteristic: if any single Promise enters a rejected state, the outer await immediately rejects.

await Promise.all([
  taskA(),
  taskB(), // rejected
  taskC(),
]);

Note that the outer failure does not automatically cancel the other asynchronous tasks that have already started. taskA and taskC may still continue executing, but the current code no longer waits for their full results.

The project's approach is to catch errors inside each tool:

try {
  return await tool.invoke(toolCall.args);
} catch (err) {
  return `Error: ${err.message}`;
}

When a tool fails, it returns an error string instead of continuing to throw an exception.

This way, Promise.all() can still obtain a complete result array:

[
  'Content of file A',
  'Error: File not found',
  'Content of file C'
]

In the next round, the model can see both successful results and failure reasons, then decide whether to retry or handle the situation differently.

Approach 2: Sequential Execution with for...of

mini-cursor.mjs uses a different approach:

for (const toolCall of response.tool_calls) {
  const foundTool = tools.find(
    tool => tool.name === toolCall.name
  );

  if (foundTool) {
    const toolResult = await foundTool.invoke(
      toolCall.args
    );

    messages.push(
      new ToolMessage({
        content: toolResult,
        tool_call_id: toolCall.id,
      })
    );
  }
}

await is inside the loop, so the program only executes the next tool after the current one completes.

tool_call 1: start → complete
tool_call 2:        start → complete
tool_call 3:               start → complete

Sequential execution may take longer but is more suitable for tasks that produce side effects or share state:

These tasks cannot just look at "how many Tool Calls" there are; the sequence of relationships must also be considered.

Can Sequential Execution Solve All Dependencies?

No.

Multiple Tool Calls within the same AIMessage are planned by the model all at once, before seeing any tool results.

Even if the Runtime executes them sequentially, the parameters for the later call have already been determined in advance:

Model generates call_1 and call_2 simultaneously
→ Runtime executes call_1
→ Runtime executes call_2

After executing call_1, the model has no opportunity to regenerate the parameters for call_2 based on the actual result.

For example, a browser task:

Open search page
→ Capture page snapshot
→ Find hotel button based on snapshot
→ Click button

The model must first see the page snapshot to know which element to click.

Tasks that truly depend on the result of a previous step are better broken into multiple Agent Loops:

Round 1: Model calls open webpage
→ ToolMessage returns page state

Round 2: Model calls page snapshot based on state
→ ToolMessage returns snapshot content

Round 3: Model decides which element to click based on snapshot

So it's necessary to distinguish between two types of "sequential":

Sequential execution within the same round: only guarantees the order of side effects
Execution across model rounds: the next step can truly re-plan based on the previous step's result

Which Tools Cannot Be Easily Parallelized?

1. Modifying the Same Resource

If two tools write to the same file simultaneously, the final result may depend on which one finishes last:

write_file(app.js, content A)
write_file(app.js, content B)

This creates a race condition and cannot simply be put into Promise.all().

2. Browser Operations

Clicks, tab switches, input, and screenshots depend on the current page state.

If two elements are clicked simultaneously or a screenshot is taken before the page loads, the result becomes unpredictable.

3. Command Line Tasks

The following three steps have clear dependencies:

pnpm install
pnpm build
pnpm preview

The build depends on the installation result, and the preview depends on the build artifact.

4. Shared Database State

Simultaneously deducting inventory, creating an order, and updating payment status can cause data consistency issues.

Whether concurrency is allowed should be determined by business transactions and idempotent design, not solely by model judgment.

Which Tools Are Suitable for Parallel Execution?

Tasks meeting the following conditions are generally more suitable for parallel execution:

For example:

Read a.js, b.js, c.js
Query weather for Beijing, Shanghai, Guangzhou
Check three independent configuration files
Fetch data from multiple unrelated public APIs

Before parallelizing, API rate limits, connection counts, and machine resources must still be considered.

If the model returns dozens of calls at once, launching them all directly might also trigger API rate limits or exhaust file handles. Real-world projects typically add a concurrency cap rather than using Promise.all() without limits.

How Should the Runtime Decide?

The decision of "parallel or sequential" cannot be entirely left to the LLM's natural language reasoning.

A more robust approach is to let the Runtime judge based on tool properties and resource conflicts.

Start with four questions:

  1. Does a later call need the return value of a previous call?
  2. Will multiple calls modify the same file, page, or database record?
  3. Can the tool be safely retried after failure?
  4. Does the external service have concurrency or rate limits?

A simple decision framework can be summarized as:

Scenario Suggested Approach
Multiple independent read-only queries Parallel execution
Modifying the same state Sequential execution
Later step needs previous step's result Execute across Agent rounds
Browser clicks, waits, screenshots Sequential and split across rounds
Large number of independent calls Limit concurrency count

ToolMessage Order Must Also Remain Stable

Regardless of how tools are scheduled, each result must ultimately be placed back into messages:

AIMessage(tool_calls)
→ Runtime executes tools
→ ToolMessage(tool_call_id)
→ Call model again

During parallel execution, the correspondence must not be lost by arbitrarily inserting results in the order they finish.

The project uses the result order from Promise.all() and the original tool_calls indices to complete the mapping, then preserves the tool_call_id for each message. This is key to making parallel tool execution work stably.

Summary

An Agent returning multiple Tool Calls at once does not mean all tools should be parallelized.

Parallelism solves waiting time
Sequential execution guarantees state and dependencies
Cross-round execution solves re-planning based on actual results

The selection principle can be remembered as:

Independent, read-only, no shared state → Promise.all()
Side effects or resource conflicts exist → for...of + await
Later step depends on previous step's result → Return ToolMessage, let model enter next round

The model is responsible for proposing the call plan; the Runtime should be responsible for concurrency control, error isolation, and execution order.

This is also an engineering capability that must be added for an Agent to move from "being able to call tools" to "reliably completing complex tasks."