AgentDepthAgent 工程学习指南
输入概念或问题,检索正文和待编写目录。
第一次学习?从 L0 总览开始 →

并行工具调用:同时等待,按编号收回结果

我的学习 →

正在读取学习记录…

1. 哪些调用可以同时开始

并行工具调用是把多个互相独立的请求一起发出,让等待时间重叠。例如行程助手同时查询上海天气和汇率,这两项都已有完整输入,天气结果也不会改变汇率参数,可以一起开始。

相反,“先查订单编号,再用编号查物流”有数据依赖,需要先得到编号。并行前先问:每项输入是否已经齐全、是否会修改同一份数据、结果是否依赖另一项?

天气A与汇率B同时发出,B先返回,但分别绑定A与B编号,最后合并
完成顺序可以变化,结果归属由调用编号确定。

异步程序在等待时把执行机会交给其他任务。信号量是控制同时工作的“名额”:设为2,最多两项一起进入工具调用;名额满时,后续任务等待。这里的并发主要让等待重叠,示例里的工具结果来自固定字典。

2. 运行天气和汇率两个请求

在学习文件夹新建 parallel_lab.py,保存下面完整程序。从该文件夹打开 PowerShell,运行代码后的命令。

先在 PowerShell 创建本篇的独立目录并进入;路径可换成你自己的非系统盘位置:

Bash
mkdir -Force "F:\学习\agentdepth-l2-parallel-tools"
cd "F:\学习\agentdepth-l2-parallel-tools"

将本篇的 parallel_lab.py、test_parallel_lab.py 保存到这个目录。保存全部文件后运行 dir *.py,核对上述文件名,再执行下文命令;新开窗口时先回到该目录,并按 Python 环境篇核对 python 的解释器路径。

Python
"""限制并发数量,按调用编号收集固定工具结果。"""

import asyncio
import json
from dataclasses import dataclass
from typing import List


@dataclass(frozen=True)
class Call:
    call_id: str
    tool: str
    argument: str
    delay: float


_RESULTS = {("weather", "上海"): "上海:晴", ("fx", "CNY"): "CNY:1.00"}
_ALLOWED = {"weather", "fx"}


async def run_calls(calls: List[Call], *, limit: int = 2, timeout: float = 0.1) -> dict:
    """调度独立读取,将每项最终结果绑定到它的调用编号。"""
    if type(limit) is not int or limit < 1:
        raise ValueError("invalid_limit")
    if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or timeout <= 0:
        raise ValueError("invalid_timeout")
    ids = [call.call_id for call in calls]
    if any(not isinstance(call_id, str) or not call_id.strip() for call_id in ids):
        raise ValueError("missing_call_id")
    if len(ids) != len(set(ids)):
        raise ValueError("duplicate_call_id")
    for call in calls:
        if call.tool not in _ALLOWED:
            raise ValueError("tool_not_allowed")
        if not isinstance(call.argument, str) or isinstance(call.delay, bool) or not isinstance(call.delay, (int, float)) or call.delay < 0:
            raise ValueError("invalid_call")

    semaphore = asyncio.Semaphore(limit)
    completion_order = []
    active = 0
    peak = 0

    async def one(call: Call):
        nonlocal active, peak
        async with semaphore:
            active += 1
            peak = max(peak, active)
            try:
                await asyncio.wait_for(asyncio.sleep(call.delay), timeout=timeout)
                value = _RESULTS.get((call.tool, call.argument))
                result = {"status": "ok", "value": value} if value is not None else {
                    "status": "error", "code": "bad_argument"}
            except asyncio.TimeoutError:
                result = {"status": "timeout"}
            finally:
                active -= 1
            completion_order.append(call.call_id)
            return call.call_id, result

    completed = await asyncio.gather(*(one(call) for call in calls))
    return {"completion_order": completion_order,
            "by_call_id": dict(completed), "peak": peak}


if __name__ == "__main__":
    sample = [Call("A", "weather", "上海", 0.03), Call("B", "fx", "CNY", 0.0)]
    print(json.dumps(asyncio.run(run_calls(sample)), ensure_ascii=False, indent=2))
Bash
python parallel_lab.py

输出:

TXT
{
  "completion_order": [
    "B",
    "A"
  ],
  "by_call_id": {
    "A": {
      "status": "ok",
      "value": "上海:晴"
    },
    "B": {
      "status": "ok",
      "value": "CNY:1.00"
    }
  },
  "peak": 2
}

A模拟等待0.03秒,B模拟等待0秒,因此完成顺序为B、A。by_call_id 仍把天气放在A下、汇率放在B下。peak: 2 表示运行中同时工作的数量最多为2。

Call 保存编号、工具、参数和等待时间。run_calls 先检查编号唯一、工具允许和参数有效,随后建立信号量。one 为每项请求执行等待、组装结果或记录超时,最后归还工作名额。gather 等待这些任务全部结束,再按编号合并。

每项结果带状态,所以一项超时后,另一项成功的结果仍可保存。应用程序可以据用户目标决定展示部分结果、重试读取或结束。

3. 改并发数和超时值

把末尾调用改为 run_calls(sample, limit=1),再运行。峰值变1,A先占名额,因此完成顺序为A、B。结果内容保持相同。

恢复并发2,把调用改为 run_calls(sample, timeout=0.01)。A等待0.03秒,超过0.01秒上限,状态为 timeout;B仍为 ok。这两个改动分别展示“调度数量”和“每项等待上限”。

条件完成顺序峰值结果
limit=2,默认超时B、A2两项成功
limit=1,默认超时A、B1两项成功
limit=2,timeout=0.01B后A超时2B成功,A超时

4. 运行检查用例

把下方测试文件保存到程序所在目录:

下面的测试检查同时运行的任务数量、结果编号和超时后的收尾。

测试代码:test_parallel_lab.py
Python
# 核对并行工具调用的正常结果和错误分支;断言失败时会显示对应的测试。
import asyncio
import unittest

from parallel_lab import Call, run_calls


class ParallelToolsTests(unittest.TestCase):
    def run_lab(self, calls, *, limit=2, timeout=0.1):
        return asyncio.run(run_calls(calls, limit=limit, timeout=timeout))

    def test_fast_second_call_finishes_first_but_ids_stay_bound(self):
        report = self.run_lab([Call("A", "weather", "上海", 0.03),
                               Call("B", "fx", "CNY", 0.0)])
        self.assertEqual(report["completion_order"], ["B", "A"])
        self.assertEqual(report["by_call_id"]["A"], {"status": "ok", "value": "上海:晴"})
        self.assertEqual(report["by_call_id"]["B"], {"status": "ok", "value": "CNY:1.00"})
        self.assertEqual(report["peak"], 2)

    def test_one_call_fails_without_corrupting_another_result(self):
        report = self.run_lab([Call("A", "weather", "未知城市", 0.01),
                               Call("B", "fx", "CNY", 0.0)])
        self.assertEqual(report["by_call_id"]["A"], {"status": "error", "code": "bad_argument"})
        self.assertEqual(report["by_call_id"]["B"], {"status": "ok", "value": "CNY:1.00"})

    def test_limit_one_serializes_even_if_second_delay_is_shorter(self):
        report = self.run_lab([Call("A", "weather", "上海", 0.02),
                               Call("B", "fx", "CNY", 0.0)], limit=1)
        self.assertEqual(report["completion_order"], ["A", "B"])
        self.assertEqual(report["peak"], 1)

    def test_timeout_is_recorded_for_its_own_id(self):
        report = self.run_lab([Call("A", "weather", "上海", 0.04),
                               Call("B", "fx", "CNY", 0.0)], timeout=0.005)
        self.assertEqual(report["by_call_id"]["A"], {"status": "timeout"})
        self.assertEqual(report["by_call_id"]["B"], {"status": "ok", "value": "CNY:1.00"})

    def test_duplicate_id_and_unauthorized_tool_are_rejected_before_dispatch(self):
        with self.assertRaisesRegex(ValueError, "duplicate_call_id"):
            self.run_lab([Call("A", "weather", "上海", 0), Call("A", "fx", "CNY", 0)])
        with self.assertRaisesRegex(ValueError, "tool_not_allowed"):
            self.run_lab([Call("X", "delete_account", "any", 0)])


if __name__ == "__main__":
    unittest.main()
Bash
python -m unittest test_parallel_lab -v

测试同时检查正常和失败路径。先恢复刚才的改动,再运行;若出现导入错误,核对文件位置与运行目录。

5. 结果合并与并发代价

调用编号由应用程序生成,用于配对请求和返回。重复编号会覆盖结果,示例在调度前检查;多个工具同名也需要不同调用编号,因为名称只说明做哪类操作。

真实服务有连接数和速率限制,同时发十个请求可能触发限流。并发数还应结合工具延迟、服务额度和任务总时间选择。可先从低并发开始,观察完成时间、超时数和错误率,再调整。

写工具并行时需要额外检查共享资源:两个更新可能相互覆盖,查询和删除可能产生竞态。依赖明确的写入适合顺序执行,独立只读请求通常更容易合并。结果部分成功时,保留每项状态和编号,让用户看清哪一项需要补查。

asyncio 的任务说明介绍 gather 和取消,信号量说明介绍并发名额。总结:先找独立任务,限制同时数量,按编号收回,逐项保留状态。下一篇 幂等处理重复写入。

课后练习展开习题收起习题

4 道练习 · 客观题规则评分 · 其余题目待批改

正在读取本地记录…

不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。

练习 1 / 4 · v1.0.1

选择题 · 先判断边界

关于“并行工具调用与结果回注”,哪种做法更稳妥?

对应知识点:L2-parallel-tools

选择一个答案
练习 2 / 4 · v1.0.0

简答题 · 解释失败

写出一个失败输入并说明记录字段。

对应知识点:L2-parallel-tools

练习 3 / 4 · v1.0.0

代码题 · 边界测试

补充正常、边界、失败三组输入和预期结果。

对应知识点:L2-parallel-tools

练习 4 / 4 · v1.0.0

场景题 · 结果未知

证据不足时如何设计状态和人工接管?

对应知识点:L2-parallel-tools

图片查看器
100%

文章配图 · 按 Esc 关闭