协作工具补遗:如何核验调度边界

团队里有人提了个方案:用 Paseo 把编码任务分给多个执行者并行跑,说“这样肯定更快”。小A心动了,但想起老Z的老规矩:先别急着信结论。什么任务?谁来分?冲突了怎么办?失败了算谁的?这些细节没搞清之前,“更快”只是一个乐观的猜测。他拿着方案去找老Z。
老Z点头:“有这个警惕就对了。并行是优化手段,不是默认状态。单执行者没有调度问题;一旦分出去,所有权、状态、冲突、失败四个问题同时出现。本附录就是把‘要不要并行’翻译成‘能不能核验’:一个方案能不能上,就看这些细节能不能逐条跑出证据。”
“还有一层口径要先对齐。‘并行更快’说的通常是墙钟时间,但团队真正关心的往往是总成本:调度、合并、排障、重跑加在一起是多少。”老Z接着说,“墙钟省了,总成本涨了,这笔账谁算?所以要量化的不只是‘快多少’,还有‘贵多少’,两者都拿数字说话,才叫核验。”
本附录服务源码篇和实现篇,团队考虑把编码任务交给多个执行者时可以查阅。本仓库快照未包含可验证的 Paseo 实现、配置或一手说明,因此这里不陈述 Paseo 的具体命令、模型支持或自动化行为,要讲的是核验协作工具的通用方法,不是某个产品的功能清单。
“调度边界”这个词,老Z画了条线:它划在“调度器管得到的事”和“调度器管不到的事”之间。管得到的是任务队列、执行者分配、状态跟踪;管不到的是任务内容的正确性、共享状态的冲突、副作用的回滚。本附录的核验方法就围绕这条边界展开:先知道哪些归调度器管,再知道剩下的归谁管。
“分出去就会自动完成”是错觉
小A猜:“既然叫调度工具,任务分出去就会自动完成吧?”
老Z摇头:“不能这样推断。‘调度’只解决‘谁来做’,不解决‘做对了没有’。你把这个词拆开看,它本质是一张任务清单外加一个分发器:把任务 A 给执行者 X,任务 B 给执行者 Y,然后等结果回来。清单和分发器本身不携带‘正确性’,它们只负责把工作挪到不同的执行体上,结果对不对、合并不合并、回滚不回滚,全部要调度器之外的东西来回答。所以任何协作调度都要先回答四个问题:任务所有权归谁、共享状态怎么管、权限范围多大、验收标准由谁定。”
“那所有权和验收标准,谁来定?”
“通常是宿主,也就是你的编排代码。”老Z说,“任务 A 归执行者 X,这个‘归’字有两层:一层是 X 有权限动它,一层是 X 对它负责到验收为止。很多调度工具只有第一层没有第二层,它能限制谁写,‘写成什么样算好’却管不了。验收标准尤其容易悬空:调度器能记录‘任务完成’,但它不会定义‘完成’是命令退出码为 0,还是测试真的通过。这两个定义差着十万八千里,而工具只报前者。”
老Z让小A想一个最简单的场景:两个执行者同时改同一个文件。
- 如果两个执行者都基于文件的 v1 版本开始改,各自产出 v2-a 和 v2-b,合并时冲突谁来解决?调度器不会自动合并语义冲突:文本上能拼回去的改动,语义上可能互相打架(一个加了参数,另一个删了调用它的函数)。
- 如果执行者 A 改了
config.ts,执行者 B 正在读旧版config.ts做决策,B 的决策基于过时信息,这个错误算谁的?A 的,因为 A 没通知?B 的,因为 B 没重新读?调度器不会替你回答。- 如果一个执行者失败了,它的部分副作用(已写的文件、已发的请求)要不要回滚?调度器知道它做了什么吗?如果它写了一半就被中止,留下的半截文件比没有更糟。
- 如果两个执行者都向同一个外部 API 发请求,速率限制算谁的 quota?调度器通常按执行者记账,不会把它折算成一个整体的预算。
小A把这四个问题抄下来:“它们其实是同一件事的四个侧面,问所有权、问共享状态、问副作用、问配额。共同点是,看起来都不是调度器会替我回答的。”
“对,四问对应四份调度器之外的约定:谁有权限动这份文件,谁读到的一定是最新版本,出事的边界画在哪里,资源耗尽时算谁的账。一些示例调度器只给 pending/running/done/failed 四档状态;具体状态模型、冲突策略和回滚能力以目标工具版本为准。这四份约定没人写,并行就只是把问题放大到更多执行者身上。”
老Z又指了一个容易被忽略的点:“调度器还有一层隐形假设,它假设执行者是同质的。实际上执行者可能连的是不同的模型、不同的 API key、不同的上下文窗口。同一个任务,执行者 A 能跑,执行者 B 跑不动,调度器不会知道,也不会因此调整分配。‘谁来做’在它眼里是等价的,落到结果上不是。这层差异,任务设计时也要想清楚。”
小A盯着第三条:“失败回滚这块,我原以为调度器会自动管。”
“不会。”老Z说,“大多数调度器只认那四档状态,不跟踪任务的副作用。写了哪些文件、发了哪些网络请求、改了哪些数据库行,这些都在执行者体内,它看不到。要回滚,得靠执行者自己写补偿逻辑,或者靠外面的工作区快照,调度器只是触发它,不是实现它。”
“补偿逻辑长什么样?”
“两个层面。第一层是执行者自己声明‘我会产生这些副作用,失败时这样回滚’,比如先写临时文件,全部成功后再原子改名;或者把‘记日志失败’当成整体失败处理,不让它单独成功。第二层是宿主提供工作区快照和重放能力,出事后把工作区恢复到任务开始前的状态。但这两层都不是默认就有的:第一层要执行者配合写,第二层要宿主配合建。拿到一个新调度工具,别假设它一定支持回滚,要当成‘默认不回滚,除非明确声明’来核验。”
“还有一点,”老Z说,“并行只在任务真正独立时才更快。编码任务之间往往有隐含依赖:共享文件、共享配置、共享测试。把有依赖的任务硬并行,得到的不止是冲突,还有更难定位的偶发 bug:串行时第 5 步能稳定复现的错误,并行时只有当第 3 步和第 4 步的时序撞在一起才冒头,而且每次时序都不一样。”
小A抢答:“这个我会,看共享资源。”
“对,这是最便宜的办法。”老Z说,“两个任务都读写 config.ts,这是文件交集,画一下就能看见。难缠的是时序交集:一个任务跑 lint、另一个任务改代码,看似没有交集,但 lint 读到的是改到一半的文件,比文件交集难发现得多。核验时序交集有个土办法,把两个任务的执行顺序对调,各跑一遍,结果不同就有隐式依赖,顺序敏感本身就是依赖的证据。”
小A:“那把文件交集和时序交集都排掉,就安全了?”
“还差一层。”老Z说,“数据竞争可以不在文件层,在外部服务层。两个执行者都调同一个外部服务,服务端的状态就是共享的,比如两个任务都往同一个 Git 远程 push,或者都更新同一个数据库行。文件层交集你画得出,外部服务层的交集画不出来,但冲突后果一样真实。核验它只能靠运行时观察:把两个任务并行跑,盯外部服务的状态变化,看有没有互相覆盖。”
待核验案例的阅读法
老Z说:“不是不能并行,而是核验之后再并行。把 Paseo(或任何协作调度工具)当作待核验案例时,按五步收集一手资料。”
- 确认来源:版本号、发布日期、官方文档地址。没有一手文档的功能,不要当真。
- 找到真实接口:任务创建、依赖声明、取消、重试、结果汇总的具体 API 或命令。口头描述的“它能做”不算。
- 核验权限边界:每个执行者的工作区、文件所有权、凭据范围。两个执行者能否写到同一路径?
- 构造冲突测试:故意创建两个修改同一文件的任务,观察冲突策略(报错?静默覆盖?锁定?)。不要假定自动合并。
- 量化对比:记录调用次数、耗时、失败率、人工介入次数,与单 Agent 基线比较。更快要拿数字证明,不是凭感觉。
“顺序也别乱。”老Z说,“前两步确定它是什么,中间两步把它放到压力下看实际怎么动,最后一步回到业务目标问它值不值。跳过前两步直接做对比,你对比的对象可能根本不是你要用的版本。”
小A:“第二步怎么才算找到接口?”
“标准是你能写出一个完整的调用序列,从任务创建到结果汇总,每一跳都有具体的 API 名或命令。写不出来的地方,就是还没核验过的地方。”老Z说,“很多工具的宣传页会画一张漂亮的架构图,但你把图上的箭头换成实际的调用代码时,会发现有些箭头根本没有对应物。那些箭头就是‘宣传的功能’,不是可用的功能。”
“第五步的基线怎么取?”
“基线是单 Agent 串行跑同样任务的数字,别拿‘现在团队怎么做的’当基线,那可能本身就很慢。”老Z说,“先把同一批任务串行跑一遍,记下耗时和失败率,再并行跑一遍,两个数字摆在一起比。‘更快’的定义就是并行耗时小于串行耗时;如果并行省下的那点时间被合并、排障和重跑吃掉了,那‘更快’就是账面上的,不是真实里的。”
小A指着第一步:“那文档写得很漂亮、却没有 API 可核验的功能呢?”
“那是宣传话术。”老Z说,“一个功能至少要有三样东西才算数:入口,怎么调;参数,调的时候传什么;可观察的结果,跑完能看到什么。三样缺一样,你就没法验证,也没法在出问题时区分‘我用错了’还是‘它坏了’。把‘文档里描述的’和‘能跑出证据的’分开记账,是核验的第一步。”
“这几步里哪一步最容易翻车?”
“第四步,构造冲突测试。工具文档几乎不会主动写‘我处理冲突的方式是静默覆盖’,但很多工具的默认行为恰恰如此。”老Z让小A在脑子里推演一下三种可能的默认策略:
- 报错并保留两者:调度器把冲突抛出来,让人(或上层 Agent)裁决。最安全,但需要额外的合并流程。
- 排队/锁定:同一资源同时只能被一个执行者持有,另一个等待。安全但会退化成串行,所谓“并行更快”就此打折。
- 后写覆盖:谁最后写谁说了算,中间版本丢失。最快也最危险,它不会报错,只会让改动凭空消失。
“看文档你可能以为是第一种,跑冲突测试才发现是第三种。”老Z说,“所以冲突测试不是为了找 bug,是为了搞清楚工具到底处在哪一档。”
小A又想到一层:“除了两个都写,还有没有别的读起来安全、跑起来危险的情形?”
“有,最常见的是读-改-写竞态。”老Z说,“两个执行者都先读同一份文件,各自在内存里改,再写回去。两者都没碰同一个字节,但后提交的改动把先提交的整体覆盖了。并发写测试测的是两个都写同一路径,读-改-写要单独测:让两个任务基于同一个 v1 各自修改、各自提交,看 v2-a 和 v2-b 是不是最后只剩一个。单看每次写入不冲突没有用,合并之后不冲突才是调度边界要验证的。”小A把这个测试记进了清单。
“如果文档不全,或者我没法跑冲突测试呢?”
“那就保留‘待核验’,不要补全产品故事。”老Z说,“最危险的做法是文档没写,你就用想象填上,然后基于想象下结论。一手资料和实际运行证据不足时,‘我不知道’比‘我猜它能’更负责任。”
老Z又补了一条实操:“如果你能跑测试,优先测三件事:一是同路径并发写,看冲突策略;二是执行者中途被取消,看残留;三是执行者失败后重试,看是否重放副作用,比如重复发邮件、重复扣款。这三类在文档里往往语焉不详,但跑一次就能看见真实行为。”
“跑完测试,结果怎么留?”
“留一份核验记录。”老Z说,“写清楚核对了哪个版本、核对日期、做了什么测试、观察到什么行为、结论是什么,比如‘截至 Paseo vX.Y / 2026-08 核对,同路径并发写为排队策略’。这条记录就是你下次升级工具时的对照基线,工具一升级,所有结论作废重验;没有记录,你连上次是怎么验的都想不起来。”
多 Agent 并行的真正前提
这与概念篇“多 Agent”那章的结论一致:并行只在三个条件同时满足时才有价值。
- 任务独立:一个任务的输入不依赖另一个的输出,或依赖关系可以被显式建模。
- 交付可合并:各执行者的产出合到一起不冲突,或有明确的冲突解决机制。
- 失败可归属:某个执行者失败了,你能定位是它的责任,而不是整批报废。
老Z把这三条当成“可否拿掉”的判据:任意一条不满足,并行就退化为串行或更糟。
- 任务不独立却硬并行:A 的输出是 B 的输入,但你让它们同时跑,结果 B 用了一个不存在的输入,产出一份看似合理实则错乱的结果。这比串行更慢,因为还要回头查“到底是 A 错了还是 B 错了”。
- 交付不可合并:三个执行者各改一个模块,改完发现互相调用的接口对不上,合并不了,等于三份独立废稿。
- 失败不可归属:某个执行者出错,但你分不清是它的问题还是共享状态被别人改坏了,只能整批重跑。
老Z让小A把三条对应到三个工程动作:“任务独立,就做依赖分析;交付可合并,就定契约与合并流程;失败可归属,就建隔离与日志。前两个是任务设计阶段的事,第三个是运行时环境的事。很多人只做第一个,把任务切成小块就并行,结果合并和排障两件苦活全在后面等着。”
“这三条里有没有哪一条可以先缓一缓?”
“没有。但有一条最容易‘看起来满足、实际不满足’,就是交付可合并。”老Z说,“三个执行者各改一个模块,git 上三个分支合并不报冲突,这只是‘能拼回去’;合并后编译通过、测试通过,才是真的可合并。前者把标准定在文本没打架,后者定在语义没打架。并行任务交付时,验收标准要设在语义那一档。”
“还有一条容易被忽略的边界:合并本身也是要核验的工作,不是免费的。”老Z接着说,“并行省下的时间要是被合并吃回去,这单就是亏的。所以项目开始前,先估一下合并成本:产出物能机械合并,还是需要人一条条看?机械合并成本低,并行划算;人工合并成本高,并行未必划算。”
小A:“能不能把‘任务独立’变成可核对的表格?”
“能,而且很便宜。”老Z说,“拿张表,行和列都填任务名,一格一格标三种记号:两个任务共享同一文件,标‘共享’;一个任务的输出是另一个的输入,标‘依赖’;完全不相干,留空。只要存在一个非空的‘共享’或‘依赖’格子,这两个任务就不能无脑并行。这张表就是‘并行可行’的原始证据:画完它,你才知道调度器要替你管多少条边界,也才知道哪些边界必须靠自己的约定来兜。”
“第三条‘失败可归属’听起来最难。”
“对,因为它要求可观察的执行边界。”老Z说,“每个执行者要有独立的日志、独立的工作区、可回放的输入。如果执行者之间共享内存、共享文件、共享连接池,失败就会顺着共享状态传染,谁都说‘不是我干的’。这就是为什么隔离(独立工作区、独立凭据)不只是安全要求,也是排障要求。”
“独立日志这条,具体怎么落地?”
“一个执行者一个目录、一套日志、一份可重放的输入副本。”老Z说,“代价是磁盘和复杂度,换来的是出事时能指着某一行说‘就是这里,它从这里开始错的’。很多团队为了省这一步,把执行者混进同一份日志,并发事故一出现,谁也说不清先后顺序,而顺序恰恰是并发事故里最重要的信息。日志混用省下的那点空间,会在排障时几十倍地还回去。”
“调度器管理的是队列和分发,”老Z说,“它不会自动消除相关错误、权限扩大或验收缺失。这三个风险是并行的固有代价,调度工具能减轻,但不能消除。把调度器当成‘我只要把任务丢进去,它就会自己变对’,那是把队列管理器当成了正确性裁判。”
协作工具是运行时边界,不是银弹
小A最后问:“那协作工具到底有没有用?”
“有用,但它是一个可验证的运行时边界,不是银弹。”老Z说,“它适合那些你已经证明串行太慢、且任务确实独立的场景,比如对 100 个独立文件各跑一次 lint,或者把 50 个互不相关的 issue 各交给一个执行者去 triage。它不适合那些看起来能并行、但隐含共享状态的场景,比如多人改同一个模块,或者几个执行者都要更新同一份配置。”
老Z又给了一个判断标准:看任务改的是“写集合”还是“跑集合”。各写各的文件,是写集合独立,适合并行;共享同一个运行环境、同一个测试套件、同一个依赖树,是跑集合共享,这种任务并行时,冲突不在你写的代码里,而在环境的搅动里。
“所以适合并行的编码任务,往往是各写各的新文件、各自跑各自的验证、最后集中合入这种形状?”
“对。至于怎么判断一个任务是真独立还是假装独立,拿三个问题去问:它读什么?它写什么?它的成功依赖什么状态?”老Z说,“两个任务的写集合和读集合有交集,就不真独立;一个的成功依赖另一个的产出,就是有依赖。这个判断不需要调度器,拿张纸画出来交集就能看出来。”
“纸上画完交集还不够,”老Z说,“还要把‘成功依赖什么状态’问到底。任务回报 lint 通过,但它跑在旧版代码上,这是假装成功。核验的办法是看它的验收产物:任务完成时留下了什么可观察的结果?这个结果是不是基于最新输入算出来的?没有可观察产物的成功,既不能被合并,也不能被追溯,跟没做没什么两样。”
“假装成功在并行场景里是不是特别常见?”
“常见,而且比串行更容易藏。”老Z说,“串行时你盯着每一步,跑偏了马上看见;并行时你只能看到各执行者的最终汇报,汇报之间有没有互相踩脚,要靠合并阶段才发现。所以合并阶段不是把结果拼起来,而是一次二次核验:每个结果都要能对回它的输入、它的工作区、它的日志。对不回来的结果,优先级要排在‘看起来没问题’的后面。”
小A又问:“那 Paseo 这种具体工具,我该怎么引用?”
“外部工具的版本、功能、调度行为会随发布而变化,本附录不替 Paseo 的任何版本背书。”老Z说,“凡是引用它的具体调度行为,比如‘它对并发写是排队的’,都要标注核对日期和版本,比如‘截至 vX.Y / 2026-08 核对,默认冲突策略为报错’。一旦跨过核对日期,这条结论就要重新验证。把外部工具的引用当成有时效的证据,而不是常量。”
“既然所有行为都要待核验,那核验记录的格式,能不能给个模板?”
“能,五栏就够:对象(工具名加版本)、核对日期、测了什么、观察到什么、结论与有效期。”老Z说,“‘观察到什么’那栏别写结论,写事实,比如‘两个任务同时写 config.ts,先后成功,前者的改动被后者的覆盖,无报错’。事实写清楚了,结论谁看了都能复现,这里是后写覆盖。记录的目的是让别人不重复你的实验,所以别省观察那一栏。”
“还有一条最容易被漏掉的,”老Z补充,“更新工具时,核验记录比文档还重要。很多人升级工具只读 release notes,而 release notes 恰恰不会告诉你‘冲突策略改了’这种默认行为的变化。”
核验协作工具的两条底线
- 冲突策略必须可观察:两个任务撞了同一资源时,工具行为要被实测并记录版本;它可能是报错、排队或锁定,但不能停留在文档之外的假设。所谓“未定义”往往就是“静默覆盖”,是数据丢失的另一种说法。
- 失败必须可归属:某个执行者失败后,你能从日志追溯到它,而不是面对一堆混在一起的结果无从下手。这要求每个执行者有独立的日志流和可回放的输入。
“这两条底线,”老Z说,“分别是‘不丢数据’和‘能查事故’。第一条没满足,你的并行在丢东西而不自知;第二条没满足,你的并行一出事就变成悬案。别的都可以先放着,这两条不行,它们是你敢把任务分出去的地基。”
“再回看开头那个‘并行更快’的方案,”老Z说,“现在你知道怎么接它了:先让它回答四问,谁拥有、谁共享、谁回滚、谁的配额;再跑冲突测试和失败归属测试;最后拿串行基线对比。四问答不上、两条底线测不出来的方案,它的‘快’就是悬空的。”
回到全书目标:任何协作工具都应被视为一个可验证的运行时边界,并行不是默认选项,而是证明独立之后的优化手段。这个证明得是你自己跑出来的,不能从工具的营销页抄;一手资料不够时,宁可把工具标成待核验,也别用想象补全它的故事。
小A把核验记录的五栏模板贴在了工位墙上。下次再有人提“用 Paseo 并行一下”,他知道自己要先问那句已经在嘴边的老问题:“你测过冲突策略吗?”
