1M55头像
Agent 面试手撕(08):几十行 Python 实现 Agent 并行工具调用封面图

Agent 面试手撕(08):几十行 Python 实现 Agent 并行工具调用

目录

​编辑

一、Agent 为什么需要并行工具调用?

二、核心知识点:asyncio.gather()

三、手撕 Agent 并行工具执行器

3.1 工具注册表

3.2 实现单个工具分发器

3.3 并行执行多个工具

3.4 为什么可以使用 zip()?

四、如果其中一个工具失败怎么办?

五、如何接入真实 Agent Loop?

六、面试官可能会怎么追问?

七、总结

Hello,大家好,我是 1M55!👋

继续更新我们的「Agent 面试高频手撕代码」系列!

前面我们已经手撕过 ReAct、Function Calling、Sub-Agent、Agent Skills、MCP Client、RAG 和混合检索。

今天来点简单但实用的。

假设用户提出一个问题:

帮我同时查询北京和上海的天气。

模型在同一轮生成了两个 tool_calls。

如果我们仍然像之前那样,用一个 for 循环依次执行工具,两个请求就只能串行等待。

但它们之间明明没有依赖关系。

既然两个工具可以同时执行,为什么非要一个接一个地等?

所以今天我们来手撕一个 Agent 并行工具执行器。

不依赖 LangChain / LangGraph,核心只用 Python 的 asyncio。


一、Agent 为什么需要并行工具调用?

先看最简单的串行执行:

for call in tool_calls:
    result = await dispatch_tool(call)

这里假设 dispatch_tool() 是异步函数。

虽然写了 await,但它仍然是串行执行。

因为每一次循环都会等待当前工具完成,然后才开始下一个工具。

假设:

  • 工具 A 耗时 2 秒。

  • 工具 B 耗时 3 秒。

那么串行执行的总耗时大约是:

工具 A:████ 2s
工具 B:        ██████ 3s

总耗时:约 5s

如果两个工具之间没有依赖关系,我们可以同时启动它们:

工具 A:████       2s
工具 B:██████     3s

总耗时:约 3s

这就是并行工具调用的价值。

严格来说,Python asyncio 在这里实现的是异步并发,特别适合网络请求、数据库访问等 I/O 密集型任务,并不意味着两个 Python 函数一定同时占用不同 CPU 核心。


二、核心知识点:asyncio.gather()

先看一段最简单的代码:

import asyncio


async def tool_a():
    await asyncio.sleep(2)
    return "工具 A 完成"


async def tool_b():
    await asyncio.sleep(3)
    return "工具 B 完成"


async def main():
    results = await asyncio.gather(
        tool_a(),
        tool_b()
    )

    print(results)


asyncio.run(main())

这里的 asyncio.sleep() 只是用来模拟工具等待外部服务的耗时,不代表真实天气 API。

重点看:

results = await asyncio.gather(
    tool_a(),
    tool_b()
)

asyncio.gather() 会让两个协程并发运行,并在它们完成后收集结果。

这里有一个非常重要的细节:

gather() 返回结果的顺序,与传入任务的顺序一致,不取决于哪个任务先完成。

例如,虽然工具 A 先完成,工具 B 后完成,最终结果仍然是:

[
    "工具 A 完成",
    "工具 B 完成"
]

这一点对 Agent 很重要,因为接下来还需要把每个工具结果对应到原来的 tool_call_id。


三、手撕 Agent 并行工具执行器

正式进入今天的核心代码。

面试官给出题目:

LLM 在一轮响应中返回多个 tool_calls,请使用 Python 并发执行这些工具,并按照 Function Calling 协议回填结果;其中一个工具执行失败,不能导致其他工具结果丢失。

我们依然沿用第二篇学习过的 tool_calls 数据结构。

3.1 工具注册表

首先假设已经有两个支持异步调用的工具:

async def get_weather(city):
    # 真实开发中,这里可以调用异步天气 API
    ...


async def get_stock(product_id):
    # 真实开发中,这里可以调用异步库存 API
    ...


TOOLS = {
    "get_weather": get_weather,
    "get_stock": get_stock
}

工具注册表没有变化。

它仍然负责根据模型生成的工具名称找到对应 Python 函数。

只是这次,我们要求工具是异步函数。

3.2 实现单个工具分发器

这部分与第二篇 Function Calling 非常接近:

import json


async def dispatch_tool(call):
    name = call["function"]["name"]

    try:
        args = json.loads(
            call["function"]["arguments"]
        )

        if not isinstance(args, dict):
            raise ValueError("工具参数必须是 JSON 对象")

        func = TOOLS.get(name)

        if func is None:
            raise ValueError(f"未知工具:{name}")

        result = await func(**args)

        return {"result": result}

    except Exception as e:
        return {"error": str(e)}

这里重点看两行:

func = TOOLS.get(name)

根据工具名称查找函数。

result = await func(**args)

异步执行对应的工具。

另外,我们使用 try/except 捕获工具错误。

这样单个工具执行失败,也能返回错误结果,而不是直接让整个工具调度过程终止。

这里捕获的是普通运行异常;取消任务等控制流程需要在更完整的执行器中单独处理。

3.3 并行执行多个工具

真正需要手撕的新逻辑来了:

import asyncio


async def run_tools(tool_calls):
    tasks = [
        dispatch_tool(call)
        for call in tool_calls
    ]

    results = await asyncio.gather(*tasks)

    tool_messages = []

    for call, result in zip(tool_calls, results):
        tool_messages.append({
            "role": "tool",
            "tool_call_id": call["id"],
            "content": json.dumps(
                result,
                ensure_ascii=False
            )
        })

    return tool_messages

整个并行调度的核心就是:

results = await asyncio.gather(*tasks)

它把原本串行执行的多个异步工具调用,改成并发执行。

后面的代码只负责将执行结果重新包装成模型需要的消息格式。

3.4 为什么可以使用 zip()?

注意这里:

for call, result in zip(tool_calls, results):

我们可以这样写,是因为 asyncio.gather() 保证结果顺序与输入顺序一致。

例如:

tool_calls:
    call_001 -> get_weather
    call_002 -> get_stock

results:
    result_001
    result_002

即使 get_stock 比 get_weather 更早完成,results 中的顺序仍然与提交顺序对应。

因此可以安全地通过 zip() 配对。

最终生成:

[
    {
        "role": "tool",
        "tool_call_id": "call_001",
        "content": "..."
    },
    {
        "role": "tool",
        "tool_call_id": "call_002",
        "content": "..."
    }
]

这就是多工具执行结果的回填过程。


四、如果其中一个工具失败怎么办?

这也是面试官可能追问的地方。

假设模型同时请求了三个工具:

工具 A:执行成功
工具 B:执行失败
工具 C:执行成功

我们不希望因为工具 B 抛异常,就导致工具 A、C 的结果无法回填。

前面的 dispatch_tool() 已经捕获了工具执行过程中的普通异常:

try:
    result = await func(**args)
    return {"result": result}

except Exception as e:
    return {"error": str(e)}

因此工具 B 的错误会被转换成普通结果:

{
    "error": "工具执行失败"
}

另外两个工具仍然可以返回正常结果。

最终三条工具结果都能够生成对应的 tool 消息。

这里的原则是:单个工具失败,不应该自动导致其他独立工具的结果丢失。

但需要注意,并不是所有工具都适合并发执行。

比如两个工具同时修改同一份文件,或者后一个工具依赖前一个工具的输出,就不能简单地全部放进 gather()。


五、如何接入真实 Agent Loop?

这一部分很简单。

假设我们已经有一个 Agent Loop,模型返回:

reply = response.choices[0].message

如果包含多个工具调用:

if reply.tool_calls:
    ...

我们只需要将它们转换成前面定义的字典格式:

calls = [
    {
        "id": call.id,
        "function": {
            "name": call.function.name,
            "arguments": call.function.arguments
        }
    }
    for call in reply.tool_calls
]

然后并发执行:

tool_messages = await run_tools(calls)

最后回填:

messages.append(
    reply.model_dump(exclude_none=True)
)

messages.extend(tool_messages)

这里仍然遵守 Chat Completions 的工具调用消息协议:

先保存 assistant 发起的工具调用消息,再追加所有对应的 tool 结果消息。

模型接收到执行结果后,就可以继续生成最终回答。

到这里,我们就完成了从串行工具调用到异步并发工具调用的升级。


六、面试官可能会怎么追问?

Q1:为什么使用 asyncio.gather()?

因为多个独立的 I/O 工具调用可以并发等待。

相比串行执行,并发通常可以减少整体等待时间。

Q2:asyncio.gather() 会保证结果顺序吗?

会。返回结果的顺序与传入协程或任务的顺序一致,与实际完成顺序无关。

因此可以使用 zip(tool_calls, results) 将结果对应回原始调用。

Q3:asyncio 适合所有工具吗?

不是。

它主要适合异步 I/O。

如果工具内部执行大量 CPU 密集型计算,仅仅使用 asyncio 不一定能提升性能。

如果是同步阻塞 I/O,可以考虑通过 asyncio.to_thread() 在线程中执行,或者直接使用支持异步的客户端库。

Q4:某个工具执行失败怎么办?

可以在单工具执行器中捕获普通异常,将错误转换为工具结果,避免影响其他独立工具的结果回填。

但超时、取消任务和敏感操作失败还需要更明确的处理策略。

Q5:是不是所有 tool_calls 都应该并发执行?

不是。

只有彼此独立、允许同时执行的工具才适合并发。

涉及共享状态、执行顺序或有副作用的工具,需要先分析依赖与并发安全性。

Q6:为什么要保留 tool_call_id?

因为一轮模型响应可能包含多个工具调用。

每条工具结果必须与原始调用建立对应关系,模型才能知道哪个结果属于哪个请求。


七、总结

这一篇没有引入复杂框架,而是对第二篇 Function Calling 做了一个很实用的升级。

串行工具调用:

for call in tool_calls:
    result = await dispatch_tool(call)

并发工具调用:

results = await asyncio.gather(
    *(dispatch_tool(call) for call in tool_calls)
)

核心的区别就在于:独立的异步工具不再依次等待,而是并发执行。

同时,我们还保留了三个工程上重要的能力:

  • 通过 tool_call_id 正确关联执行结果。

  • 单个工具失败时,不丢失其他独立工具的结果。

  • 按照模型工具调用协议,将结果交还给 Agent。

回顾整个系列:

01 · ReAct:循环决策。

02 · Function Calling:工具调用。

03 · Sub-Agent:任务委派。

04 · Agent Skills:技能加载。

05 · MCP Client:外部工具接入。

06 · RAG:检索增强生成。

07 · Hybrid Search:混合检索。

08 · Parallel Tool Calling:并行工具调用。

我是 1M55,我们下篇见!

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2501_94225300/article/details/169364731

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--