loading

Loading

探索与发现

首页 Agent 工程

并行工具调用后,副作用合并怎么做才安全

分类:Agent 工程
字数: (2748)
阅读: (1)
0
摘要:并行工具调用提升了速度,但合并副作用时,顺序执行藏起来的耦合会以静默失效的方式暴露。把只读批次和写批次拆开,在结果提交层做幂等与意图对齐,比直接上依赖图务实。

并行带来的不只是延迟收益

大多数团队开启并行工具调用是为了速度。在多数 Agent 里,工具执行占端到端延迟的 35%–60%,编码类任务偏高端,深度研究类任务偏中端。三个独立的 200 ms 调用并发跑,墙上时钟就从 600 ms 压到 200 ms,收益很直接。

但一旦并发跑起来,工具设计里原本被顺序执行掩盖的假设就暴露了。有篇文章把这个过程叫“非自愿的架构审计”。我认同这个判断:并行不是性能开关,而是对副作用模型的强制检查。问题不在“能不能并行”,而在“并行结果返回之后,怎么把副作用合并成一致的下一轮状态”。

先把执行机制说清楚。并行是模型决定的,不是编排层决定的:模型在单次响应里回多个 tool_use 块,运行器分派全部调用,等全部完成,再把整批结果一次性交回去。模型看不到中间结果。OpenAI 的接口有 parallel_tool_calls 参数且默认为 true,推理模型(o3、o4-mini)遇到显式设置会返回 400;Anthropic 没有直接的开关,Claude 根据自己的判断决定并行与否。这让不同 provider、不同版本之间的并行行为并不稳定,编排层不能假设它一直是串行或一直是并行。

三类不会报错的故障

顺序执行自带宽容:A 依赖 B 时,历史执行顺序刚好总是让 B 先跑。并行撤销了这份宽容。失效往往不伴随异常。

  • 上下文依赖:工具 A 从一个共享上下文变量读物,这变量本该由工具 B 填入。并行时 A 在新数据就位前就跑了,读完或读旧,返回一个看着合法的结果。不报错。
  • 共享状态变更:典型读-改-写竞态。A 读 100、决定 +10、写回 110;B 同时读 100、+20、写回 120。最终 120,预期 130。两边都没抛错。
  • 执行时序依赖:B 不读 A 的输出,但 B 逻辑上依赖 A 前置执行了。创建数据库记录和写相关记录、建会话和加会话数据、取锁和锁上操作,时序一并发就没了。不报错。

这三类的共同点是:失败静默,且轨迹迅速深入。阶段已经在错误输出上推进,等错误可见,副作用已污染了十几轮上下文,追溯不到该批次。

先分类,再决定能不能并发

落地时,最省事的护栏栏先把库里的工具过三层筛:它够不够原子(会不会留被并发的可观察中间态)、幂不按相领输入多次执行后对下游世界状态影响是否一致、独不直够不依赖同批次其他工具的产物或副作用。三条全过才配进并发集。只读搜索、列表、配置读属通常第一批能过筛的。文件重命名不原子——旧名消失、新名未现的窗口是真实存在的写工具必须先放一面——大部分发通知工具既不幂等也已耦合。

由此可生出一条可落地的规则:读批次并行,写批次串行。存读批次并行到底能把墙钟砍 40%–50%,风险几乎为零。而批次里一旦混入写,整组撤回到串行,因为出可能等读回的果或与同伴读扭互相干。

一个容易忽略的工程细节:并行返回的结果数组必须按“请求顺序”排列,不用“完成顺序”。同时每个调用带唯一的 tool_use_id 靠这个 ID 才能配对;把同轮所有结果合并在一条 user message 里否则模型以后学会“一个个等结果”,并行退化。一条用户消息对应的就是一次原子结果批。

副作用合并的核心,是别把并发当合流

这里最常见的操作错误,是把“多个并行调用的结果照回”当成“副作用已等价线性执行”。事实不是:没折够真的创建好且用初始过的,写工具的副作需要显式合。

我给团队的建议,是把副作用按“发件箱模式”保守合并:工具在执行层先把准副作用写在批次草稿里,其中检查器按规范串行审看这批次里的读中间前,再提交。这样并发冲突不再依赖写锁,也不再希望在工具文件一级就去保证幂等性相反,它有所有命一层的“影子意图”意图对齐后再单调串并入以世界状态

有几件事没那么幸运:网络有并发的不够快,你不知发商报效一直定。重试为副作用买单时,你得手动试一遍幂已试并发合并。我的观察是个简单的“精确一次提交登记 + 去重指纹(工具名、加参指纹、会话与工作流标)”打配套,它的边界比锁在工具层强很多。

不急着上 DAG

既然遇到这类事务,最陡的是上 DAG 显式用显式推图编排起来。LangGraph 是用显式的模型用关系代表对话消息,可以表达“两个独立的读记点能同时跑、写入节点等到两个读依赖门都完成之后才能开”。LLMCompiler 的路子就是编译执行计划、按同样并行表达。公开里报在好几任务的步多执行上去到了 2.2 倍收益且不破坏正确性;解这类需要显式子已关系很明确的公开码很相符合,可观测性很好,有节点的失败链路、节点收到多输入、同层次均等旁支几点并发写入状态,全部能检查。而对于那种还只有几个十几行的服务制、每个行为又很少现的工程,上显式关系实这运维成本未免值。以读写分类规则做简单的执行前拦截就够够用了这也是架构选择的取舍。

一套跑在生产前的 Check List

在生产中开启并行调用前,我会做以下几条排查,算是老工程保底闭缺后的固定卡口:

  • 把全部工具按只读、只写、读修-写三类标注清楚,标不清的先不得进入执行批次。
  • 对每写工具逐一析出内频次最多的目标资源登记表,标出可能也在同一批里出现的不可变对象名,不要建里扫。
  • 查工具有没隐声取共享内存、会话变量、文件指向等,别信签名那部分,要看实现部分。
  • 在调试环境强上早为并行条件下的重入测。此前顺序环境太一直在序去“验证”了。典型落点是原序性。
    class SideEffect:
        def __init__(self, resource, op, payload, fingerprint):
            self.resource  = resource      # 被打档的对象
            self.op        = op
            self.payload   = payload
            self.fingerprint  =  fingerprint  # hash(工具名, 参数,请求代)
    
    def commit(side_off)
    :
        1. 按 resource 分组
        2. 每组先审读中间值再行写
        3. 按工具声明确定性两两一组“竞写区”入合并口径。 
    

    有几点是这篇文章的局限:如果把工具里的副作用当成幂等就完了;建议给的还是一个保守结构(拆读-合并-放行),并发工具结果做单批次替配、边对比物可复制替代去担中的一致性等仍算是目前够不彻底的。宁可并行晚一年放、从工具把发布严管节,也不要抱着侥幸心理只在顶层加一把全局锁。是分发还是合并分不清的模批次最危险——因到那时候,你会以为是调度问题了,但你真正发现的是语义不该在那里,它一直没在你的架构存在过。

转载请注明出处: 皮哈皮阿

本文的链接地址: https://blog.phpat.com/agent-engineering/633.html

手机扫码阅读

微信或手机浏览器扫一扫,随时随地随心阅读与分享

您可能对以下文章感兴趣
1 / 1
预览图