尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Claude 并行工具调用:一次问完 4 个香港数据源,少回一条 tool_result 直接报错

Claude 并行工具调用:一次问完 4 个香港数据源,少回一条 tool_result 直接报错 文章目录1. 先说结论一批调用三条硬契约2. 环境与数据源3. 并发到底省多少时间4. 批次执行器分组、并发、回填5. 契约一一个都不能少6. 契约二result 必须在 text 之前7. 契约三没跑的也要回填8. 把三条契约变成一次本地校验9. 什么时候不能并发一条真实的依赖链10. 更隐蔽的坑HTTP 200但 data 是空的11. 踩坑清单与几条结论12. 参考链接1. 先说结论一批调用三条硬契约Claude 默认可能在一次回应里同时调用多个工具。响应里stop_reason是tool_usecontent数组里可以躺着好几个tool_use区块——这一点很多人知道。真正容易翻车的是回填那一步。官方规范把这个动作写得很死归纳成三条#契约原文1每个tool_use都要拿到一个tool_result全部放在下一条 user 消息里return onetool_resultfor eachtool_useblock, all together in the next user message2每个tool_result必须排在该消息中任何 text 之前put everytool_resultblock before any text content in that message3没执行的那个调用也要回填带is_error: truestill return atool_resultfor it withis_error: true第 3 条最反直觉一个调用因为上游失败而根本没跑很多人会顺手跳过它——跳过就等着报错。官方给的错误信息长这样tool_use ids were found without tool_result blocks immediately after还有一条不在契约里、但同样吃时间的事实并发不是万能药。它只在整批调用都没有长尾时成立。我实测了一批香港官方端点4 个独立只读接口串行 0.62 秒、并发 0.23 秒×2.75但同一批里只要混进一个慢接口加速比立刻掉到 ×1.20。下面把数据源、执行器代码、三条契约的本地校验、以及一条真实的依赖链完整拆开。2. 环境与数据源Python 3.13.12 (macOS) matplotlib 3.11.1 只用标准库json / subprocess / time / concurrent.futures工具层挂了4 个零鉴权香港公开端点都是日常真在用的工具名数据端点hk_weather_now实时天气data.weather.gov.hk/weatherAPI/opendata/weather.php?dataTyperhrreadhk_public_holidays公众假期www.1823.gov.hk/common/ical/tc.jsonhk_aqhi_now空气质量健康指数dashboard.data.gov.hk/api/aqhi-individualhk_forecast_9day九天预报data.weather.gov.hk/weatherAPI/opendata/weather.php?dataTypefnd选它们的理由很直接互相独立、只读、无顺序要求——正是官方说的通常可以安全并行那一类。反例见第 9 节。3. 并发到底省多少时间左半组是 4 个独立只读端点串行 3 轮中位数0.624s并发0.227s加速×2.75。差距来自网络往返被叠在了一起。右半组是同一批再加一个慢接口小巴路线清单串行中位24.883s并发中位20.781s加速只有×1.20——整批耗时被最慢的那个工具吃掉了并发只是把其余 4 个 0.1–1.2 秒的等待藏进了它的影子里。这里还有个更值得记的细节混合组串行的 3 轮原始值是1.43 / 24.88 / 25.50 秒。同一个端点前后两分钟差 17 倍。所以在这种批次里平均值没有意义必须看中位数和分布——这也解释了为什么并发节省是有时 3 倍、有时 1.2 倍取决于那一轮有没有踩到慢态。4. 批次执行器分组、并发、回填先写工具层。http_json统一返回(payload, error)二元组让异常在边界收口is_silent_empty专门盯一种假成功第 10 节会讲它为什么必要。importjson,subprocess,timefromconcurrent.futuresimportThreadPoolExecutor TOOLS{hk_weather_now:{url:https://data.weather.gov.hk/weatherAPI/opendata/weather.php,query:{dataType:rhrread,lang:tc},parallel_safe:True,depends_on:[]},hk_public_holidays:{url:https://www.1823.gov.hk/common/ical/tc.json,query:{},parallel_safe:True,depends_on:[]},hk_aqhi_now:{url:https://dashboard.data.gov.hk/api/aqhi-individual,query:{format:json},parallel_safe:True,depends_on:[]},hk_forecast_9day:{url:https://data.weather.gov.hk/weatherAPI/opendata/weather.php,query:{dataType:fnd,lang:tc},parallel_safe:True,depends_on:[]},}defhttp_json(tool,args,timeout40):返回 (payload, error)error 非 None 时 payload 恒为 None。specTOOLS[tool]urlspec[url].format(**args)ifspec[query]:url?.join(f{k}{v}fork,vinspec[query].items())outsubprocess.run([curl,-sL,-m,str(timeout),-w,\n%{http_code},url],capture_outputTrue,textTrue).stdout body,_,codeout.rpartition(\n)ifcode.strip()!200:returnNone,fHTTP{code.strip()or000}try:returnjson.loads(body.lstrip(\ufeff)),None# 这批接口有带 BOM 的exceptValueErrorasexc:returnNone,fnot-json:{exc}defis_silent_empty(payload):HTTP 200但业务数据是空的。工具层不报错只能在这一层判定。ifnotisinstance(payload,dict):returnFalseifpayload.get(data)in({},[]):returnTrueinnerpayload.get(data)ifisinstance(inner,dict):forkeyin(routes,route_stops):ifkeyininnerandnotinner[key]:returnTruereturnFalse执行器本身不长关键在最后两行断言——契约是可以被代码强制的不用靠人记def_wrap(tu,payload,err):iferr:return{type:tool_result,tool_use_id:tu[id],is_error:True,content:f{tu[name]}failed:{err}}ifis_silent_empty(payload):return{type:tool_result,tool_use_id:tu[id],is_error:True,content:f{tu[name]}returned HTTP 200 but no business data.}return{type:tool_result,tool_use_id:tu[id],is_error:False,content:json.dumps(payload,ensure_asciiFalse)[:800]}defrun_batch(tool_uses):执行一批 tool_use返回带契约保证的 tool_results。order[tu[id]fortuintool_uses]group[tufortuintool_usesifTOOLS.get(tu[name],{}).get(parallel_safe)andnotTOOLS.get(tu[name],{}).get(depends_on)]chained[tufortuintool_usesiftunotingroup]results,done{},{}# done 按工具名记成功过的调用ifgroup:withThreadPoolExecutor(max_workerslen(group))asex:fortu,payload,errinex.map(lambdat:(t,*http_json(t[name],t.get(input)or{})),group):results[tu[id]]_wrap(tu,payload,err)ifnotresults[tu[id]][is_error]:done[tu[name]]Truefortuinchained:# 有依赖的按序走依赖没满足也照样回填depsTOOLS.get(tu[name],{}).get(depends_on,[])if[dfordindepsifdnotindone]:results[tu[id]]{type:tool_result,tool_use_id:tu[id],is_error:True,content:Not executed: the preceding call did not return usable data.}continuepayload,errhttp_json(tu[name],tu.get(input)or{})results[tu[id]]_wrap(tu,payload,err)ordered[results[i]foriinorder]# 按输入顺序重建一个都不能少assertlen(ordered)len(tool_uses)assert[r[tool_use_id]forrinordered]orderreturn{tool_results:ordered,n_error:sum(1forrinorderedifr[is_error])}这套执行器可以直接拿走用建议收藏备用——换成任何一批工具只要改TOOLS里那张表parallel_safe与depends_on两个字段就是分组依据其余代码不用动。5. 契约一一个都不能少ordered [results[i] for i in order]这一行看起来多余——results里本来就有全部结果。但如果某条分支忘了写入results字典取值会直接KeyError在本地就炸而不是等 API 返回 400。这就是把契约写进代码的价值错误暴露在成本最低的那一层。原因很实在tool_result 必须全部塞进同一条 user 消息。拆成两条、每条回一个模型就没法把这些结果和上一轮的调用对上。Troubleshooting 页里「并行调用不生效」这一栏写的正是这条Send multipletool_resultblocks in ONE user message, not one per turn.6. 契约二result 必须在 text 之前这条最容易被忽略因为写完tool_result顺手补一句以上是结果是很自然的动作——但这句话必须放在所有tool_result之后defbuild_user_message(tool_results,textNone):构造回填消息所有 tool_result 排在任何 text 之前。contentlist(tool_results)iftext:content.append({type:text,text:text})return{role:user,content:content}顺序错了不会静默降级而是直接失败——解析器只认result 在前这一种形态。7. 契约三没跑的也要回填这条是三条里最反直觉、也最容易漏的。场景很常见一批调用里有依赖关系第 1 步失败了第 2 步压根没跑。直觉是没跑就没有结果跳过。但官方的要求是照样回填一条标记is_error: true并说明为什么没跑{type:tool_result,tool_use_id:toolu_02,is_error:true,content:Not executed: the preceding write_file call failed.}道理也顺模型只能通过 tool_result 了解每次调用的下场。跳过等于留一个永远不会兑现的悬空 id。没执行本身就是结果而且是模型下一步决策必须知道的结果——它要据此判断是重试、换参数还是放弃整条链。8. 把三条契约变成一次本地校验四处散着记三条规则容易漏写成一个校验器更省事OFFICIAL_ERRtool_use ids were found without tool_result blocks immediately afterdefvalidate_history(history):检查每个 assistant 回合的 tool_use 是否都拿到了回填。problems[]fori,msginenumerate(history):ifmsg.get(role)!assistantornotisinstance(msg.get(content),list):continueids[b[id]forbinmsg[content]ifisinstance(b,dict)andb.get(type)tool_use]ifnotids:continuenxthistory[i1]ifi1len(history)elseNoneifnotnxtornxt.get(role)!user:problems.append({at:i,kind:no_next_user_message,official_error:OFFICIAL_ERR})continueblocksnxt.get(content)ifisinstance(nxt.get(content),list)else[]returned[b.get(tool_use_id)forbinblocksifisinstance(b,dict)andb.get(type)tool_result]missing[xforxinidsifxnotinreturned]ifmissing:problems.append({at:i,kind:missing_tool_result,detail:missing,official_error:OFFICIAL_ERR})forj,binenumerate(blocks):# text 之后不得再出现 tool_resultifb.get(type)textandany(x.get(type)tool_resultforxinblocks[j1:]):problems.append({at:i,kind:text_before_tool_result,official_error:OFFICIAL_ERR})breakreturnproblems这段校验器建议收藏任何多工具编排都能直接复用。拿四种历史形态喂进去结果是这样四种写法里只有第一种通过。注意第 2 种和第 3 种报的是同一个类别——“拆成两条消息各回一个在结构上等价于只回了一个”因为它们都让某个tool_use_id在自己的下一条消息里没拿到结果。9. 什么时候不能并发一条真实的依赖链parallel_safe这个标记不是随便打的。反例用香港小巴的真实接口来演示——它的数据是三段链式的# 第 1 步拿到路线清单108 条# GET /route/HKI - {data: {routes: [1, 10, 10P, ...]}}# 第 2 步用清单里的路线代号取详情才拿得到 route_id# GET /route/HKI/1 - {data: [{route_id: 2006408, ...}]}# 第 3 步route_id 只能来自第 2 步# GET /route-stop/{route_id}/{route_seq} - 沿途站点实测这条链走完是64.5 秒20.8 21.0 22.7每一步的参数都来自上一步的返回第 2 步的route_code取自第 1 步返回的 108 条清单第 3 步的route_id2006408由第 2 步给出模型不可能凭空猜对。这类调用没有办法并发——不是并发会慢一点而是并发时第 2 步根本没有参数可用。把它和前面那批只读接口放进同一个tool_uses列表时执行器靠depends_on字段把它们分到串行组并发组照常并行两者互不拖累。判据一句话参数是否来自同批次其他调用的返回。是就必须串行否就可以并行。官方对这条的表述是有副作用、共享状态或顺序要求的工具更适合按顺序执行并且指出 computer use / browser use 这类工具更严格——必须按出现顺序串行且在第一次失败处停止。10. 更隐蔽的坑HTTP 200但 data 是空的最后一个坑和第 3 条契约是配套的。我用一个不存在的路线去请求城巴接口得到的是HTTP 200 99 字节 {type: Route, version: 2.0, generated_timestamp: ..., data: {}}状态码是 200响应是合法 JSON只是data里什么都没有。如果工具层只判断status 200这次调用会被记成成功交给模型的是一句查到了——而实际什么都没查到。这正是is_silent_empty存在的理由接口正常返回和业务上有数据是两件事前者查状态码后者得看结构。这类假成功在并行批次里更危险因为一个静默失败会被另外几条成功结果盖住你不去逐条看根本发现不了。11. 踩坑清单与几条结论坑现象处理只回了部分结果报tool_use ids were found without tool_result blocks immediately after每个tool_use都要一条全部放进同一条 user 消息把回填拆成多个回合并行失效模型对不上结果一条 user 消息装完全部tool_resulttext 排在 result 之前消息被判定为格式错误tool_result全部前置正文放最后跳过错过的调用历史里留下悬空 id也要回填is_error: true并写明原因以为并发一定更快混入慢接口后加速比从 ×2.75 掉到 ×1.20先看该批有没有长尾再决定并发还是串行depends_on漏标并发时后续调用拿不到参数参数来自同批其他返回的一律进串行组只看 HTTP 状态码200 {data: {}}被当成成功加一层空结构判定判成is_error值得记住的三条契约要写进代码不要写在文档里。三条规则里两条都可以用两行断言兜住——assert len(ordered) len(tool_uses)和一条必须写进results的取值漏了就在本地炸。并发是长尾的函数不是调用数的函数。这一批里hk_gmb_routes单次要 21 秒它一个人决定了整批的墙钟把另外 4 个并发起来省下的 0.6 秒在大数面前没有意义。没执行和没数据都是结果。前者要回填is_error说明原因后者要在工具层判空——两者都不能让模型收到一句含糊的查到了。可复用的三块批次执行器parallel_safedepends_on分组、历史契约校验器validate_history、空结构判定is_silent_empty。如果这篇对你有用收藏 点赞——下次接一批数据源时可以直接把这三块搬过去。这类多工具编排的实测会继续更新关注不迷路。12. 参考链接https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/parallel-tool-usehttps://docs.anthropic.com/en/docs/agents-and-tools/tool-use/implement-tool-usehttps://data.gov.hk/原创声明本文为原创技术实践。延迟与依赖链数据为 2026-09-13 的真实网络测量并发/串行对照各 3 轮、依赖链 3 段、空结构样本 1 例契约条款引自官方文档原文模型部分零调用、零计费执行器与校验器全部逻辑可离线复现。接口延迟随网关状态变化请以自测数据为准。
返回列表