并行工具调用与冲突处理
"查北京和上海两个城市的天气"这类问题,模型可能一次返回两条 tool_calls。默认串行执行会白白浪费一半时间。本章让工具调用并发执行:ThreadPoolExecutor 跑批量、结果按原顺序回填,并处理三个实战难点——同资源写冲突、重复调用幂等、部分失败重试。
模型一次返回多个 tool_calls
带 tools 调用时(第 8 章 run_agent 用的就是这套协议),只要服务商支持,模型可在一个回复里给出多条 tool_calls(OpenAI 兼容参数 parallel_tool_calls 默认开启):
from llm_client import LLMClient, get_message # 沿用第 2 章
from tools import TOOL_SCHEMAS # 沿用第 5 章
llm = LLMClient()
resp = llm.chat(messages, tools=TOOL_SCHEMAS,
parallel_tool_calls=True) # 允许并行(默认开启,可显式写出)
msg = get_message(resp)
for tc in msg.tool_calls or []:
print(tc.id, tc.function.name, tc.function.arguments)
# 输出(示例):
# call_bj get_weather {"city": "北京"}
# call_sh get_weather {"city": "上海"}
个别服务商不识别 parallel_tool_calls 参数时删掉即可(不传默认也允许并行)。每个 tool_call 有三个字段:id(回填时一一对应)、function.name、function.arguments(JSON 字符串)。
并发执行 + 顺序回填
执行层用 ThreadPoolExecutor 并发跑,pool.map 保证结果顺序与输入一致;单条失败自动重试,不影响其他条:
# agent_demo/parallel_tools.py —— 第 15 章
import json
import time
from concurrent.futures import ThreadPoolExecutor
def execute_batch(tool_calls, tools, retry_times: int = 3) -> list:
"""并发执行一批 tool_calls。
tools: {函数名: 函数};返回 [(call_id, content)],与输入顺序一致。"""
def run(call):
fn = tools[call.function.name]
args = json.loads(call.function.arguments or "{}")
for attempt in range(retry_times):
try:
return call.id, fn(**args)
except Exception as e: # 失败自动退避重试
if attempt == retry_times - 1:
return call.id, f"执行失败(重试 {retry_times} 次):{e}"
time.sleep(0.3 * (attempt + 1))
with ThreadPoolExecutor(max_workers=8) as pool:
return list(pool.map(run, tool_calls)) # map 保持输入顺序
def fill_back(messages, results) -> list:
"""把执行结果按原顺序回填成 tool 消息(id 与工具调用一一对应)。"""
for call_id, content in results:
messages.append({"role": "tool", "tool_call_id": call_id,
"content": str(content)})
return messages
离线试跑:并发比串行快一倍
模型响应拿不到也能验证执行层:用 SimpleNamespace 伪造 tool_calls(字段与 SDK 对象同构),配两个各睡 1.5 秒的假接口:
from types import SimpleNamespace
def slow_job(name: str) -> str: # 模拟耗时接口,离线可跑
time.sleep(1.5)
return f"{name} 完成"
calls = [SimpleNamespace(id="c1", function=SimpleNamespace(
name="slow_job", arguments='{"name": "任务A"}')),
SimpleNamespace(id="c2", function=SimpleNamespace(
name="slow_job", arguments='{"name": "任务B"}'))]
t0 = time.time()
results = execute_batch(calls, {"slow_job": slow_job})
print(results)
# 输出:[('c1', '任务A 完成'), ('c2', '任务B 完成')]
print(f"总耗时 {time.time() - t0:.1f}s(串行约 3s)")
# 输出:总耗时 1.6s(串行约 3s)
冲突处理一:同资源写冲突要加锁
两个工具并发写同一个文件/表会交错或互相覆盖。文件类操作在函数内部加 threading.Lock,把"读-改-写"变成原子段:
import threading
_append_lock = threading.Lock() # 模块级:保护同一资源的所有写方
def append_note(name: str, line: str) -> str:
"""给会议纪要追加一行;加锁保证并发下不交错。"""
with _append_lock:
with open(f"notes/{name}", "a", encoding="utf-8") as f:
f.write(line + "\n")
return f"已写入 {name}:{line}"
并发调用两次后文件里两行都完整存在,不会出现"两行各写一半"的脏数据。数据库同理:sqlite 短连接 + 事务本身串行化写,第 10 章的 _exec 已天然安全。
冲突处理二:重复调用要幂等
模型偶发重复调用同一个工具。分两类处理:只读类(如查天气)加结果缓存,同参数直接返回上次结果:
_cache = {} # tools 为外层维护的 {函数名: 函数} 字典
def cached_execute(name: str, args: dict) -> str:
key = (name, json.dumps(args, sort_keys=True))
if key not in _cache:
_cache[key] = tools[name](**args) # 只读工具:重复调用无副作用
return _cache[key]
写类(如转账、下单)绝不能用结果缓存糊弄,要用"业务唯一键 + 数据库约束"保证只生效一次:
def transfer(biz_id: str, amount: float) -> str:
"""同 biz_id 重复提交只生效一次。"""
conn = sqlite3.connect("ledger.db")
try:
conn.execute("CREATE TABLE IF NOT EXISTS tx("
"biz_id TEXT PRIMARY KEY, amount REAL)")
conn.execute("INSERT INTO tx(biz_id, amount) VALUES(?, ?)",
(biz_id, amount))
conn.commit()
return f"转账 {amount} 元成功(流水号 {biz_id})"
except sqlite3.IntegrityError: # 主键冲突 = 重复请求
return f"流水号 {biz_id} 已处理过,重复请求已忽略"
finally:
conn.close()
冲突处理三:部分失败自动重试
execute_batch 里已内置退避重试。注意两点:重试只对"暂时性"错误有意义(网络抖动、超时),参数写错、业务规则拒绝这类错误重试只会浪费 token;重试仍失败时把错误当普通工具结果回填,让模型自己决定换参数还是放弃:
attempts = {"n": 0}
def flaky() -> str: # 前两次抛错,第三次成功
attempts["n"] += 1
if attempts["n"] < 3:
raise TimeoutError("模拟网络抖动")
return "第 3 次调用成功"
print(execute_batch([SimpleNamespace(id="r1", function=SimpleNamespace(
name="flaky", arguments="{}"))], {"flaky": flaky}))
# 输出:[('r1', '第 3 次调用成功')]
接入第 8 章 run_agent
SDK 返回的对象与上面的 SimpleNamespace 字段同构。把第 8 章 agent.py 的 run_agent 里"逐条执行 tool_calls"的 for 段替换成批量执行即可(tools_map 直接用第 5 章的 TOOL_REGISTRY——它正是"名字 → 函数"的字典):
# 替换 run_agent 内部处理 tool_calls 的那一段
if msg.tool_calls: # msg = get_message(resp)
results = execute_batch(msg.tool_calls, TOOL_REGISTRY) # ① 并发执行
for call_id, content in results: # ② 按原顺序回填 tool 消息
conv.append({"role": "tool", "tool_call_id": call_id,
"content": content})
continue # ③ 继续下一轮,让模型总结/再调
注意两点:回填顺序必须与 tool_calls 一致(execute_batch 用 pool.map 保证,模型靠"第几条结果对第几个 id"来对应);run_agent 原本的"死循环计数"逻辑在并行版里对每个 call 独立计数即可,其余保险原样保留。
小结:并行工具调用 = parallel_tool_calls 让模型一次给多条 + ThreadPoolExecutor 并发执行 + 按原顺序回填 tool 消息;冲突处理三件套——写冲突加锁、重复调用靠缓存或业务唯一键幂等、暂时性失败退避重试并把最终错误当结果交还模型判断。