详情

首页手游攻略 Agent 并行工具调用快了 0.8 秒——200 组实测发现:多花的那 3100 token 全打了水漂

Agent 并行工具调用快了 0.8 秒——200 组实测发现:多花的那 3100 token 全打了水漂

佚名 2026-07-15 18:36:53

你有没有遇到过这种情况——Agent 说要调三个工具,你心想"反正它们互相不依赖,一起发出去不香吗",于是 Promise.all 一把梭。结果也没报错,响应确实快了,但月底一看账单,token 比上个月多了四成。

Agent 并行工具调用快了 0.8 秒——200 组实测发现,多花的那 3100 token 全打了水漂

这不是你的错觉。我拿 200 组多步任务跑了一轮三轮对比,数据摆在下面。

三种策略,一张表说清楚

我搭了一个测试 Agent,配置了 6 个工具:searchDocsreadFilecheckCalendarsendEmailcreateTaskgetUserContext。200 组任务包含"根据会议纪要给参会人发邮件并建跟进任务"、"根据用户最近文档推荐日程"这类典型的多工具联动场景。

三种执行策略:

策略平均延迟平均 token/任务无效调用率任务完成率
全串行3.2s74203%94%
全并行2.4s1056027%86%
智能混合2.6s80206%93%

"无效调用"指的是工具被调用了,但返回结果对后续决策没有任何贡献——要么参数纯靠猜填错了,要么在并行窗口里调了其实完全不需要的工具。

全并行快了 0.8 秒,但平均多烧 3140 token。换算一下,就是每快 1 毫秒花 3.9 个 token。贵不贵,你心里有数。

全串行:慢,但每 1 个 token 都花了值

 复制代码async function runSerial(agent: Agent, toolCalls: ToolCall[]): Promise<ToolResult[]> {
  const results: ToolResult[] = [];
  for (const call of toolCalls) {
    const result = await agent.executeTool(call);
    results.push(result);
    // 每个工具的结果立即注入上下文,下一步的模型已包含全部前置信息
    agent.appendContext({ role: "tool", content: JSON.stringify(result), toolCallId: call.id });
  }
  return results;
}

串行没什么花活,就是一个接一个调。好处显而易见——每一步模型都看到了上一步的真实返回。createTask 的 deadline 参数可以从 checkCalendar 返回的空闲时段里精确取,而不是随便蒙一个。

代价就是慢。6 个工具串下来,网络往返时间累加,3 秒起步。

全并行:问题不在"并行"本身,在于模型在瞎猜

 复制代码async function runParallel(agent: Agent, toolCalls: ToolCall[]): Promise<ToolResult[]> {
  const results = await Promise.all(
    toolCalls.map(call => agent.executeTool(call))
  );
  // 全部拿到后再一次性注入上下文
  agent.appendContext(
    results.map((r, i) => ({
      role: "tool",
      content: JSON.stringify(r),
      toolCallId: toolCalls[i].id,
    }))
  );
  return results;
}

代码就这么几行。问题不在代码,在 LLM 生成这些 toolCalls 时发生了什么。

并行调用的前提是:模型在一次推理中同时生成所有工具调用的参数。但此时它还没有任何工具的返回结果。举例:模型需要 createTask,deadline 是"明天下午有空的时间"。这个信息得从 checkCalendar 的返回里拿。并行模式下,模型还没拿到日历数据就得把 deadline 参数填了,它只能猜。

猜的结果呢?200 组里,有 54 次生成的 deadline 是错的(比如选了已经有会议的时间段),Agent 只好再次调用 updateTask 去修正。一来一回,多烧了一轮 token,相当于并行省下来的时间又被后续的纠错调用吃回去了——而钱是实打实多花了。

说白了,并行策略赢就赢在"让 LLM 在信息不完整的情况下做决策",这种事,你猜会怎样。

一个具体的翻车例子:

 复制代码工具调用(并行):
  checkCalendar({ date: "2026-07-16" })       → 返回 [10:00-11:00 空, 14:00-15:00 空]
  createTask({ title: "...", deadline: "14:00" })  → 这个 deadline 是蒙的
  sendEmail({ to: "...", body: "明天下午两点开会" }) → body 里的时间也是蒙的

checkCalendar 明明返回了 14:00-15:00 有空,这是对的——但并行执行时这两个值被填进去的时候,模型根本没见过 checkCalendar 的结果,纯属瞎猫碰上死耗子。下一组任务里就蒙错了。

智能混合:别问能不能并行,问"有没有依赖"

关键不在"能不能并行",而在"参数来源是什么"。

 复制代码function detectDependencies(toolCalls: ToolCall[]): {
  independent: ToolCall[];
  dependent: ToolCall[];
} {
  const invoked = new Set<string>();
  const independent: ToolCall[] = [];
  const dependent: ToolCall[] = [];  for (const call of toolCalls) {
    const paramValues = Object.values(call.arguments).join(" ");
    // 检查参数是否引用了其他工具的输出(通过工具名或占位符标记)
    const hasRef = toolCalls.some(
      other => other !== call && paramValues.includes(`${${other.name}}`)
    );
    if (hasRef) {
      dependent.push(call);
    } else {
      independent.push(call);
    }
  }
  return { independent, dependent };
}async function runHybrid(agent: Agent, toolCalls: ToolCall[]): Promise<ToolResult[]> {
  const results: ToolResult[] = [];
  let remaining = [...toolCalls];  while (remaining.length > 0) {
    const { independent, dependent } = detectDependencies(remaining);    // 无依赖的工具并行执行
    if (independent.length > 0) {
      const batchResults = await Promise.all(
        independent.map(call => agent.executeTool(call))
      );
      batchResults.forEach((r, i) => {
        results.push(r);
        agent.appendContext({
          role: "tool",
          content: JSON.stringify(r),
          toolCallId: independent[i].id,
        });
      });
      remaining = dependent;
    } else {
      // 全都有依赖,串行执行
      const call = remaining[0];
      const r = await agent.executeTool(call);
      results.push(r);
      agent.appendContext({ role: "tool", content: JSON.stringify(r), toolCallId: call.id });
      remaining = remaining.slice(1);
    }
  }
  return results;
}

思路不复杂:先扫一遍所有工具调用,看参数里有没有引用其他工具的输出(比如 ${checkCalendar}),能分开的就并行,有依赖的等一等。

实测数据证明这个简单判断比盲开并行靠谱得多。无效调用从 27% 降到 6%,不是因为这算法多聪明,是因为它直接避免了"信息没到就填参数"的根本问题。

你可能想问:参数里没有显式的 ${toolName} 占位符怎么办?确实,大部分 LLM 生成的 tool_arguments 不会带这种标记。实际项目中我用了一层包装:在 system prompt 里要求模型在依赖其他工具返回值的参数处加 "depends_on": "toolName" 字段,执行层在路由前做依赖分析。

 复制代码// system prompt 片段
const SYSTEM_PROMPT_DEPENDENCY = `
当你调用工具时,如果某个参数的值依赖于另一个工具的返回结果,
在该参数旁添加 "depends_on" 字段,值为被依赖的工具名称。
示例:
{
  "name": "createTask",
  "arguments": {
    "title": "跟进客户需求",
    "deadline": "需要从 checkCalendar 获取",
    "depends_on": "checkCalendar"
  }
}
`;// 依赖解析升级版
function resolveHybrid(
  calls: ToolCall[],
  context: Map<string, ToolResult>
): { batch: ToolCall[]; wait: ToolCall[] } {
  const batch: ToolCall[] = [];
  const wait: ToolCall[] = [];
  for (const call of calls) {
    const dep = call.arguments["depends_on"] as string | undefined;
    if (dep && !context.has(dep)) {
      wait.push(call);  // 依赖还没产生,等等
    } else {
      batch.push(call); // 可以直接跑
    }
  }
  return { batch, wait };
}

模型配合度比预想的高。200 组任务里,LLM 在 86% 的情况下正确标记了依赖关系(剩下的要么漏标,要么标错,但不会导致崩溃,只是退化为串行)。顺便说一句,你要是觉得让 LLM 自己标记自己的依赖有点魔幻,实话说,我也觉得——但它确实能用,而且比我在代码里用 AST 分析参数活得多了。

什么时候可以无脑并行?

有一种情况并行基本不会翻车:所有工具都是"纯查询",不产生副作用,且参数完全独立、不依赖任何上下文动态内容。比如同时搜文档、查日历、拉配置——这三个工具的 input 完全来自用户原始问题,不存在互相依赖。

一旦涉及写操作(create/update/delete/send),或者参数里出现了从其他工具"应该"返回的值,老老实实用混合策略。

给你个结论

你的场景用什么
全查询、参数独立并行,省钱又省时间
有写操作、参数可能互依赖混合策略
预算极紧、延迟不敏感串行

这三种策略的代码我在雷达鸭的客服 Agent 上跑了两周,混合策略的效果跟测试数据一致——搜文档、查用户信息、发邮件、建工单这几件事儿,延迟比全串行少了一截,token 也没多烧多少。如果你也在做类似的多工具 Agent,建议拿自己的场景跑一遍,别看我数据就直接拍。


作者:老三,10 年以上软件开发经验,软件设计师、人工智能应用工程师,专注鸿蒙 ArkTS 北向开发与 Web 前端,业余折腾 AI 自动化。不定期在 CSDN 分享鸿蒙和 AI 方向的技术文章。

点击查看更多
推荐专题
热门阅读