编排与调度:从零到一
这是一份快照
本文的数字、常量、行数取自 2026-09-03 对 sid-code 源码的一次实读。 代码在动,这些数字会腐坏——引用其中任何一个之前,请按文中给出的命令在你自己的仓库里复跑一次。
这份文档写给谁
你知道「让大模型调工具、转个圈干活」是怎么回事,也许还开过 subagent。但你没做过 「同时开几十个 agent、让它们互相审对方的活、中途挂了还能接着跑」这件事,也没做过 「关掉终端之后任务照样在半夜三点自己跑起来」这件事。
这两件事,就是 Workflow(编排) 与 Schedule(调度)。
你想搞懂:它们分别解决什么问题、为什么不能揉成一件事、每个设计决策在赌什么、 面试问到「你会怎么设计一个多 agent 编排层」「定时任务怎么做才不会重复触发」时该答什么。
它不是摘要。 摘要会把结论抽出来变成一句正确但没用的话。本文的写法相反: 每个结论都从「为什么会有人搞错」讲起——因为面试里能拉开差距的从来不是结论本身, 是你能不能说清它的反面为什么诱人。
本文的事实来源(分三级,全文统一标注)
- 🔬 源码实读:2026-09-03 亲手读 sid-code 的
packages/core/src/workflow/(7 文件 1420 行)、packages/core/src/cron/(6 文件 980 行)、packages/core/src/daemon/(10 文件 1415 行)、packages/core/src/tool/{workflow,cron-*,schedule-wakeup}.ts(748 行)。凡带文件:行号的都属这一级。- 📄 一手 spec:Claude Code 运行时挂载的 Workflow 工具官方文档(原语契约,精确到函数签名)。
- 📰 二手资料:官方博客、工程师 X 帖、社区分析、GitHub issue。本文会明确指出二手资料错在哪(§4.1 有三处)。
⚠️ 一条重要提醒:本目录里的原始研究文档写于 2026-06,当时把很多东西列为「缺口」「待做」。 今天它们大部分已经落地了。本文的现状描述一律以 2026-09-03 的源码为准, 并在 §18 明确标出「哪些计划最终变了、为什么变」——计划与实现的差异本身就是最好的教学素材。
怎么读这份文档
按顺序读。 这是一条链,不是清单——后面每章都在用前面建立的概念。
| 章 | 讲什么 | 读完你能回答 |
|---|---|---|
| §0 | 名词地图 + 一句话心智模型 | 别人说 orchestration / barrier / fan-out 时你知道指什么 |
| §1 | 为什么单个上下文不够用 | 三大通病是什么,为什么加 prompt 治不了 |
| §2 | 编排的四个档位(谱系) | 一个任务进来,你知道该用哪一档 |
| §3 | 五个原语的精确规格 | 能看懂、能写一段 workflow 脚本 |
| §4 | ★ pipeline vs parallel:屏障的代价 | 本文最核心的一节,也是二手资料错得最多的地方 |
| §5 | 结构化输出:schema 不是「格式化」 | 为什么 schema 是拦伪阳性的关键 |
| §6 | 沙箱与确定性:为什么禁 Date.now() | 一个反直觉但必须懂的约束 |
| §7 | 断点续跑:缓存键该用什么 | 一个「后发者能直接绕开前人坑」的漂亮案例 |
| §8 | 并发 / 预算 / 规模:三道闸 | 为什么必须有独立并发池 |
| §10 | 何时不要用 + opt-in 门控 | 为什么这个功能必须默认关着 |
| §11 | —— 转场 —— 调度的三层架构 | 为什么调度和编排必须是两个子系统 |
| §12 | cron 的六个工程细节 | jitter / 心跳 / 过期 / 锁,每个都有踩坑史 |
| §13 | 动态自定步与 5 分钟缓存陷阱 | 一个「300 秒最不该选」的精妙取舍 |
| §14 | catch-up:睡了 6 天醒来补几次 | 一个语义决策,答错就是雪崩 |
| §15 | 守护进程:无人值守的三个硬问题 | 宿主替换 / 双触发 / 权限模型 |
| §16 | 编排 × 调度 的交汇 | /loop + /goal + workflow 怎么配 |
| §17 | ★ 会「绿着坏掉」的失效模式 | 这一章是本文第二值钱的部分 |
| §18 | sid-code 现状实测盘点 | 计划 vs 实现的差异,以及为什么变 |
| §20 | 动手:从零实现一个 mini 编排层 | 六阶段路线图 |
| 附 | 术语表 / 自检清单 / 可复跑命令 | 查漏 |
如果只有 20 分钟:读 §1、§4、§17。这三章是骨架,其余都是它们的展开。
如果你在准备面试:读 §1 → §4 → §9 → §14 → §17 → §19。
§0 名词地图:先把词认全,再把两件事分清
这一节是查询表,不用背。往后每章第一次用到某个词都会重新解释。
0.1 最重要的一件事:编排 ≠ 调度
这是全文的地基,也是最容易一开始就搞混的地方。先看两个句子:
- 「把这个 50 项的安全审查拆给 20 个 agent 同时干,干完互相审一遍。」 ← 这是编排
- 「每天早上 9 点自动跑一次这个安全审查。」 ← 这是调度
它们回答的是完全不同的问题:
| 编排(Workflow / Orchestration) | 调度(Schedule) | |
|---|---|---|
| 回答的问题 | 一个任务内部,工作怎么切、谁先谁后、谁审谁 | 这个任务在什么时刻被启动 |
| 关键词 | 并行、屏障、扇出、聚合、对抗验证 | cron、间隔、抖动、错过补偿、守护进程 |
| 时间尺度 | 一次运行内的几分钟到几小时 | 跨运行、跨会话、跨天 |
| 失败形态 | 覆盖不全、互相偏袒、结果对不上 | 没触发、触发两次、触发时机全撞一起 |
为什么必须分清:这两个子系统混在一起会立刻出事。一个真实的概念错误: 早期研究文档曾把「定时」写成 subagent 委托的第三种模式(前台 / 后台 / 定时), 这是错的——📄 一手 spec 里,子代理委托只有前台 / 后台两种,子代理的配置字段 (name / description / model / tools / isolation)没有任何 schedule 字段。
为什么这个错误诱人:因为两者在句子里长得很像——「派一个 agent 去干活」和 「派一个 agent 明天去干活」。但后者根本不是「派 agent」,而是「记一条待办, 到点了新起一个会话」。混进委托层的后果是:你会给 subagent 加一个
delay参数, 然后发现这个 agent 必须活着等到那个时刻——于是关掉终端它就没了, 而「关掉终端还能跑」恰恰是调度存在的全部理由。
判据一句话:如果这件事需要「当前进程活着」才成立,它就不是调度。
0.2 编排侧的词
按「一次编排从头到尾」的顺序排,不按字母序——这些词之间有位置关系。
| 词 | 中文 | 是什么 |
|---|---|---|
| harness | 挽具 / 框架 | 套在模型外面的那层工程代码:怎么给它工具、怎么组织上下文、怎么转圈。模型是马,harness 是马具 |
| agent | 智能体 | 大模型 + 工具 + 循环。有手脚,能看到自己动作的结果再决定下一步 |
| subagent | 子代理 | 由主 agent 派出去的 agent,有自己独立的上下文窗口 |
| context window | 上下文窗口 | 模型一次能「看见」的文字总量。这是编排要解决的核心稀缺资源 |
| orchestration | 编排 | 决定「哪些 agent、什么顺序、谁的输出喂给谁」 |
| fan-out | 扇出 | 把一个任务拆成 N 份,同时派 N 个 agent |
| fan-in / synthesize | 聚合 | 把 N 个结果并成一个 |
| barrier | 屏障 / 栅栏 | 「等所有人都到齐才继续」的同步点。§4 整章在讲它的代价 |
| pipeline | 流水线 | 每个东西独立穿过多道工序,不等别人 |
| thunk | 惰性调用 | () => doSomething()——一个「还没执行的函数」。传它而不传结果,才能控制何时执行 |
| schema | 结构约束 | 规定返回值必须长什么样(哪些字段、什么类型)。§5 会讲它的真实用途不是「格式化」 |
| adversarial verification | 对抗验证 | 派一个 agent 专门去推翻另一个 agent 的结论 |
| worktree | 工作树 | git 的一个特性:同一个仓库同时签出到多个目录。让多个 agent 各改各的不冲突 |
| resume / journal | 断点续跑 / 日志账本 | 记下「哪些步骤已经做完了」,重跑时跳过 |
| budget | 预算 | token 用量上限 |
| opt-in | 显式开启 | 默认关着,用户明确要求才启用 |
0.3 调度侧的词
| 词 | 中文 | 是什么 |
|---|---|---|
| cron | 定时表达式 | 5 个字段:分 时 日 月 周。0 9 * * * = 每天 9:00 |
| recurring | 循环任务 | 反复触发,直到删除或过期 |
| one-shot | 一次性任务 | 触发一次后自删。「3 点提醒我」属于这类 |
| durable | 持久任务 | 写进磁盘,跨会话存活。反面是「只活在内存里」 |
| heartbeat / tick | 心跳 | 调度器每隔多久醒来看一眼「有没有到点的」 |
| jitter | 抖动 | 故意加一个小偏移,避免所有人都卡在整点触发 |
| catch-up | 错过补偿 | 停机期间错过的触发,醒来之后补几次。§14 整章在讲这个决策 |
| daemon | 守护进程 | 常驻后台的进程,不依赖任何终端窗口 |
| headless | 无头 | 没有交互界面,跑完就出结果。-p "干这个" 这种 |
| launchd / systemd | 系统服务管理器 | macOS / Linux 上负责「开机自启、崩了自动重拉」的东西 |
| PID 探活 | 进程存活检测 | 用 kill(pid, 0) 试探一个进程还在不在,用来回收崩溃留下的锁 |
| fail-closed | 失败即关闭 | 不确定的时候不做危险动作。反面是 fail-open(不确定就放行) |
0.4 一张图把两者的位置摆清
┌─────────────────────────────────────┐
调度(什么时候开始) │ cron / 间隔 / 守护进程 / catch-up │
└──────────────┬──────────────────────┘
│ 到点了 → 启动一个任务
▼
┌─────────────────────────────────────┐
一次运行 │ agent loop:想 → 调工具 → 看结果 │
└──────────────┬──────────────────────┘
│ 任务太大 / 需要多视角
▼
┌─────────────────────────────────────┐
编排(内部怎么切) │ fan-out → 各干各的 → 对抗验证 → 聚合 │
└─────────────────────────────────────┘两个子系统只在一个点上交汇:调度触发时,它启动的那个任务内部可以是一次编排。 (§16 会展开这个交汇点的三种玩法。)
§1 为什么单个上下文不够用:三大通病
1.1 先看一个具体场景
你让一个 coding agent 干这件事:
「审查这个代码库的安全问题,我列了 50 个检查项,逐项过一遍。」
它开始干,读文件、grep、分析。二十分钟后它说「完成了,发现 3 个问题」。
你去数它实际检查了多少项:35 项。剩下 15 项没提,也没说没做。
这不是 bug,没有任何异常抛出,日志里一片正常。这就是第一个通病。
1.2 三大通病:📄 官方总结 + 🔬 我们独立复现
📄 一手 spec 和官方博客把长任务的失败归成三类。而 sid-code 在自己的真实轨迹里 逐条复现了它们——这不是照抄官方说法,是独立挖出来的证据:
通病一:智能体懒惰(Agentic Laziness)
形态:50 项审查干到 35 项就宣布完工,剩下的悄悄丢了。
为什么会这样:模型没有「进度条」这个概念。它对「完成」的判断是语义上的—— 「我已经产出了一份看起来像审查报告的东西」。当上下文里堆满了前 35 项的分析, 「还剩 15 项」这个事实在注意力分布上的权重已经很低了。
🔬 sid-code 的实测证据:某次真实轨迹里,agent 在第 34 轮做自检, 但它只检查了「11 项写全没」,没检查「对不对」——覆盖率达标即收工。
通病二:自我偏好偏差(Self-preferential Bias)
形态:让它验证自己刚才的产出,它下意识觉得自己对,下不去狠手。
为什么会这样:「验证」这个动作发生在同一个上下文里。前面那 2000 个 token 的 推理过程还在,它们构成了一个「我已经想清楚了」的强先验。要推翻它,等于要模型 承认自己前面白想了——而模型在同一上下文里最容易做的事是自我一致。
🔬 sid-code 的实测证据(这个案例很有戏剧性):某次排查里,agent 在第 25 轮 已经推出了正确结论「进程没崩」,但在第 27 轮,为了保护自己早先建立的 「可能崩溃」这个叙事,把已经推出的正确结论主动丢弃了。
这个案例值得记住,因为它比「验不出错」严重得多:它不是没找到真相, 是找到了又亲手扔掉。这说明自偏不是能力问题,是结构问题。
通病三:目标漂移(Goal Drift)
形态:聊得越久、上下文压缩(compaction)越多次,最初的「别碰 X」「注意这个边界」 越容易被磨没。
为什么会这样:上下文装不下时要压缩,而每次摘要都是有损的。 损失的优先级不是随机的——「不要做 X」这类约束比「要做 Y」这类目标更容易丢, 因为约束在摘要里看起来像细节。
🔬 sid-code 的实测证据:某次会话第 1 轮的 skill 摘要里种下了一个 「可能崩溃」的错误锚点,污染了后续 26 轮的全部推理。
1.3 关键洞察:三个病是同一个病
📄 官方的原话值得一字不改地记住:
偷懒、偏袒、漂移,本质都是一个脑子塞太多东西的并发症。那就别让一个脑子扛全部。
这句话是整个编排层存在的唯一理由。展开说:
| 通病 | 表面症状 | 根因(同一个) |
|---|---|---|
| 懒惰 | 覆盖不全 | 50 项挤在一个上下文里,后面的项注意力权重低 |
| 自偏 | 验不出自己的错 | 验证与产出在同一上下文,自我一致压过自我批判 |
| 漂移 | 约束丢失 | 上下文超限 → 有损压缩 → 约束先丢 |
都是「一个上下文承载了太多东西」。 所以解法也是同一个:多开几个干净的上下文。
1.4 为什么加 prompt 治不了(这一节是本章重点)
第一反应总是:「那我在 prompt 里写清楚不就行了?」
- 「你必须检查全部 50 项,不许提前收工」 → 治懒惰
- 「验证时请严格、不要偏袒自己」 → 治自偏
- 「始终记住不要碰 X」 → 治漂移
这三条全部无效,而且无效的原因各不相同,值得一条条说清:
第一条无效,因为它加剧了问题。 「必须检查全部 50 项」这句话本身也在上下文里占位置, 而且它是最早进上下文的(在 system prompt 或首轮),等干到第 35 项时, 它离当前注意力焦点已经隔了几万 token。你用「更多上下文」去治「上下文太满」。
第二条无效,因为它在要求模型做一件结构上做不到的事。 「严格审查自己」需要 模型同时持有两个立场,而它只有一个上下文。这不是态度问题——你没法通过要求一个人 「客观地评价自己」来消除利益冲突,你得换个人来评。
第三条无效,因为它对抗的是压缩算法而不是模型。 约束丢失发生在 compaction 那一步, 而 compaction 是 harness 干的、不是模型干的。你在 prompt 里说「始终记住」, 但决定什么被记住的是摘要器。
这三条推理是面试的高频考点。被问到「为什么需要多 agent,不能靠 prompt engineering」时, 弱答案是「多 agent 效果更好」;强答案是上面这三条——每个通病的根因决定了它只能用 结构解决,不能用措辞解决。
1.5 编排的三个解法,一一对应
| 通病 | 结构性解法 | 为什么有效 |
|---|---|---|
| 懒惰 | 扇出:50 项 → 20 个 agent 各领 2-3 项 | 每个 agent 的上下文里只有它那几项,没有「已经做了很多」的错觉 |
| 自偏 | 对抗验证:产出者和验证者是不同的 agent、不同的上下文 | 验证者看不到产出者的推理过程,没有自我一致的压力 |
| 漂移 | 上下文隔离:每个 agent 全新空白上下文,目标单一 | 上下文短到根本不需要压缩,约束不会被摘要磨掉 |
1.6 但要先说清编排治不了什么(诚实边界)
这一段必须放在这里,否则后面 19 章会给你一个错误印象。
编排治流程,不治智商。
- ✅ 能治:覆盖度(有没有查全)、证伪机制(有没有回头验)、约束保持(有没有跑偏)
- ❌ 不治:每个格子里「想得对不对」。20 个笨 agent 并行,得到的是 20 份笨结论, 而且因为数量多,看起来更有说服力。
🔬 一个实测的强证据:sid-code 用弱模型跑一次代码审查,产出里凭空造了 2 个误报 (一个把「只写进 Map 不输出任何东西」的代码判成「提前显示误导用户」, 另一个把一个 hook 局部变量当成了全局状态字段——范畴错误)。
扇出治不了这个。 多派几个 agent 只会造更多误报。 这个问题的解法在 §5(schema 强制举证)和 §9.7(跨模型验证),而不在「多开 agent」。
一句话记住:编排把输出的下限抬高(该查的查了、该验的验了), 但上限仍然由模型决定。把这句话说清楚,比背七种编排模式更能显示你懂行。
1.7 本章自检
- 三大通病分别是什么?为什么说它们是同一个病?
- 「在 prompt 里写清楚不许偷懒」为什么无效?三条通病各自的无效理由有区别吗?
- 编排能治覆盖度,为什么治不了「结论对不对」?
- 有人说「加了对抗验证,我们的准确率就上去了」——你会追问什么? (提示:验证者和产出者是同一个模型吗?见 §9.7)
§2 编排的四个档位:一条从「一个脑子」到「脚本当指挥」的谱系
上一章说「多开几个干净的上下文」。但怎么决定开几个、开哪些、谁审谁? 这件事本身也得有人做——而由谁来做这个决定,就是编排的四个档位。
这一节是全文的分类骨架。搞清这四档,你就能在任何一个 agent 产品里指出它站在哪一格。
2.1 四档一句话
| 档 | 名字 | 谁决定编排 | 一句话 |
|---|---|---|---|
| L0 | 单上下文 | 无人(不编排) | 一个 agent 从头干到尾 |
| L1 | 模型即兴派单 | 主模型逐轮决策 | 主 agent 一边干一边决定「这儿要不要开个子 agent」 |
| L2 | 静态工作流 | 人类事先写死 | 预先定义好的固定流程模板,每次照跑 |
| L3 | 动态编排 | 模型写的脚本(确定性执行) | 模型现场写一段编排脚本,然后脚本当指挥 |
先别急着觉得「L3 最好」。四档各有其位置,用错档比用低档更糟。
2.2 逐档展开:每一档解决了上一档的什么问题
L0 · 单上下文
用户 → [ 一个 agent,一个上下文,从头到尾 ] → 结果这是 99% 编程任务的正确选择。 改个 bug、加个函数、解释一段代码——上下文完全装得下, 多开 agent 纯属浪费(还会因为跨上下文传递信息而丢细节)。
它的天花板:§1 那三大通病。任务一长就犯。
L1 · 模型即兴派单(大多数产品所在的位置)
用户 → [ 主 agent ] ──派──> [ 子 agent A ]
│ ──派──> [ 子 agent B ]
└── 边看结果边决定要不要再派主 agent 手里有一个「开子代理」的工具(Claude Code 的 Agent / Task 工具, sid-code 的 SubAgentTool),它每一轮自己决定要不要用。
它解决了什么:终于有多个干净上下文了。子 agent 探查一个文件、验证一个假设, 上下文污染不回流到主会话。
它的天花板——这一条是理解 L3 存在理由的关键:
编排决策权还在那个会懒惰 / 自偏 / 漂移的模型手里。
展开说:让一个会「50 项干到 35 项就收工」的模型去决定「派几个 agent 覆盖这 50 项」, 它会派几个?大概也是覆盖 35 项的那个数量。 三大通病没有被治好, 只是被搬到了编排这一层:
| 通病 | 在 L1 编排层的表现 |
|---|---|
| 懒惰 | 该派 20 个只派 5 个,「差不多够了」 |
| 自偏 | 只派 agent 去验证支持自己叙事的那部分 |
| 漂移 | 聊到后面忘了「每个发现都要独立验证」这条自定的规矩 |
🔬 sid-code 的实测证据:同一道排查题,两套 harness 对照——
| 维度 | A 组(弱模型 + L1 能力在手) | B 组(强模型 + 主动 fan-out) |
|---|---|---|
| 子 agent 分派数 | 0 | 3 个并行 |
| 命令行核验次数 | 0 | 56 次 |
| 自我证伪机制 | 无 | 有(主线程复核子 agent,产出「误报清单」) |
| 结果里的误报 | 2 个 | 0 个 |
A 组手里有开子 agent 的工具,一次都没用。这就是 L1 的真实天花板—— 能力在手 ≠ 能力被用。工具挂上去了,但用不用取决于那个会偷懒的模型。
这个数据是面试里最有杀伤力的一条:被问「你们有 subagent 能力吗」时, 弱答案是「有」;强答案是「有,但实测某类任务里模型的实际分派数是 0 —— 所以我们把编排权从模型手里拿走了」。
L2 · 静态工作流
用户 → [ 人类事先写死的流程模板 ] → 固定的 agent 序列 → 结果用 SDK 或 -p 模式把多个 agent 串成一个固定管道:先 explore、再 plan、再 implement、再 review。
它解决了什么:编排权从模型手里拿走了,流程是确定的。三大通病治住了—— 脚本不会偷懒,它写了 20 个 agent 就是 20 个。
它的天花板:📄 官方的原话——
由于静态工作流需要适用于所有边缘情况,它们通常更加通用。
翻译一下:一个写死的模板要同时服务「改 typo」和「Zig 重写成 Rust」, 它只能取交集,于是退化成一个平庸的通用流程。该并行 200 个的时候它并行 3 个, 该串行的时候它并行。 灵活性锁死了。
L3 · 动态编排(Dynamic Workflow)
用户 → [ 模型现场写一段 JS 编排脚本 ] → 交给确定性 runtime 执行
│
├─> agent() 开一个子代理
├─> parallel() 一批并发
└─> pipeline() 逐项流水线📄 官方定义,值得一字不改:
Dynamic Workflow 是一段确定性编排脚本(JavaScript),它跨多个 subagent 组织工作—— 为了穷尽(分解 + 并行覆盖)、为了可信(独立视角 + 对抗校验后再落定)、 或为了承接单上下文装不下的规模(迁移、审计、大范围扫荡)。 脚本负责「流程」,模型只负责填每个
agent()格子里的「想」。
最后那句话是整个 L3 的精髓,把它背下来。 拆开看:
- 脚本负责流程:循环几次、并行几路、谁审谁、什么条件停 —— 全是代码,不会偷懒不会漂移。
- 模型负责想:每个
agent()格子里那段推理 —— 仍然是模型的活,仍然有它的上限。
它解决了 L2 的什么:模板不再是「预先写死的通用件」,而是为这一个任务现写的定制件。 📄 官方博客标题就是这个意思:A harness for every task(给每个任务一副专属挽具)。
2.3 L3 的关键分水岭:脚本是谁写的,又是谁执行的
这里有个极易混淆的点,值得单独拎出来:
L3 里模型仍然在做决策——但它决策的是「流程长什么样」,而不是「下一步派谁」。
这两件事的差别在决策的时机与次数:
| L1 | L3 | |
|---|---|---|
| 模型决策的次数 | 每一轮都在决策(几十次) | 只在开头决策一次(写脚本那一次) |
| 决策时的上下文 | 越到后面越脏(已经跑了 30 轮) | 最干净的时候(任务刚开始) |
| 决策之后 | 还能改主意、还能忘 | 脚本已经定了,执行是确定性的 |
这就是 L3 治漂移的机制:它把编排决策前移到上下文最干净的时刻, 并且冻结它。之后 20 个 agent 怎么跑,不再受主会话上下文变脏的影响。
一个漂亮的类比:L1 是「边走边想路线」,L3 是「出发前画好地图,然后照图走」。 地图是模型画的(所以能因地制宜),但走的时候不许临时改主意(所以不会走丢)。
2.4 四档对照表(含各自在赌什么)
| 维度 | L0 单上下文 | L1 即兴派单 | L2 静态流 | L3 动态编排 |
|---|---|---|---|---|
| 编排决策者 | — | 主模型(逐轮) | 人类(事前) | 模型(一次)+ 脚本执行 |
| 确定性 | 低 | 低 | 高 | 高 |
| 灵活性 | 高 | 高 | 低 | 高 |
| 治三大通病 | ❌ | ❌ | ✅ | ✅ |
| 因任务定制 | — | ✅ | ❌ | ✅ |
| token 成本 | 1× | 2-5× | 3-10× | 10-100× |
| 实现复杂度 | — | 低 | 中 | 高(沙箱 / 并发池 / resume) |
| 它在赌什么 | 任务足够小 | 赌模型自己会好好派单 | 赌一套流程能覆盖所有场景 | 赌模型能一次写对流程 |
最后一行是这张表的重点。 每一档都有一个赌注:
- L1 赌「模型会好好派单」→ 🔬 实测输了(分派数 0)。
- L2 赌「一套流程通吃」→ 官方明说输了(退化成通用件)。
- L3 赌「模型能一次写对流程」→ 这个赌注赢面大得多,因为写流程是在 上下文最干净的时刻做的一次性决策,而且流程比内容容易写对 (「并行 20 路然后每条验证一遍」这种结构,模型出错的概率远低于「这 20 项各自的结论对不对」)。
2.5 一个必须点破的取舍:L3 的成本是真的
上表那个 10-100× 不是虚数。📰 官方博客和工程师 X 帖都明说:
Workflow 非常废 token,深度使用时会频繁触发 5 小时用量限制。
所以 L3 不能默认开启。这直接导出了 §10 的 opt-in 门控——一个「必须默认关着的功能」 在工程上有一整套讲究,那一章会展开。
2.6 判据:手上这个任务该用哪一档
📄 一手 spec 给的判据很干脆,我按可操作性重排:
① 任务只有一个已知目标(找某个文件 / 某个符号 / 某个值)?
→ L0,直接搜。别动编排。
② 需要在几个独立的点上探查,但探查内容取决于前面看到什么?
→ L1,主 agent 边看边派。
③ 任务在多个独立项上扇出(多文件、多测试、多候选、多维度),
而且这个清单在开工前就能列出来?
→ L3,编排它。
④ 这个流程你已经跑过 10 次,每次都一样?
→ L2,固化成模板(或存成一个可复用的 L3 脚本)。📄 spec 里还有一条非常实用的补充,二手资料几乎都漏了:
混合(hybrid)是常态。 先在主会话内联侦查出工作清单(列出文件、找到调用点、 定下 diff 范围),再调 workflow 在这个清单上做流水线。 你不需要在「任务」开始前就知道形状,只需要在「编排步」开始前知道。
最后这句话解开了一个常见困惑:「我事先不知道有多少个待办,怎么写并行脚本?」 答案是——先用 L0/L1 侦查出清单,再用 L3 处理清单。 这也是为什么 §9.5 的「循环直到枯竭」模式存在:连清单本身都不确定时, 用编排去反复扩充清单。
2.7 本章自检
- L1 和 L3 都是「模型在决策」,区别在哪?(提示:决策的时机与次数)
- 为什么说 L1 没有治好三大通病,只是把它们搬到了编排层?
- L2 的天花板是什么?为什么「适用于所有边缘情况」反而是缺点?
- 有人说「我们上了 L3,成本涨了 30 倍但效果好多了」——你会追问什么? (提示:这个任务真的需要 L3 吗?见 §2.6 的四条判据和 §10)
§3 五个原语的精确规格:能看懂、能自己写一段
L3 说「模型写一段脚本」——那这段脚本里到底有什么?答案是五个原语加三个全局, 少到可以全部记住。
本章的规格全部来自 📄 一手 spec,并用 🔬 sid-code 的实现交叉验证。 凡是二手资料与一手 spec 矛盾的地方,本章会明确指出(§4.1 集中列了三处最严重的)。
3.1 先看一段完整的真脚本
先看全貌再拆零件。这是 📄 官方给的典范脚本(多阶段代码评审),我加了逐行注释:
// ① meta 必须是脚本第一件事,且必须是纯字面量
export const meta = {
name: 'review-changes',
description: 'Review changed files across dimensions, verify each finding',
phases: [{ title: 'Review' }, { title: 'Verify' }],
}
// ② 普通 JS:定义要处理的维度清单
const DIMENSIONS = [
{ key: 'bugs', prompt: '找出这些改动里的正确性问题...' },
{ key: 'perf', prompt: '找出这些改动里的性能问题...' },
]
// ③ pipeline:每个维度独立穿过两个阶段
const results = await pipeline(
DIMENSIONS,
// 阶段 1:每个维度开一个 agent 去找问题
d => agent(d.prompt, { label: `review:${d.key}`, phase: 'Review', schema: FINDINGS }),
// 阶段 2:对这个维度找到的每个问题,各开一个 agent 去对抗验证
review => parallel(review.findings.map(f => () =>
agent(`对抗性地验证这条: ${f.title}`, { phase: 'Verify', schema: VERDICT })
.then(v => ({ ...f, verdict: v }))
))
)
// ④ 普通 JS:过滤出确认为真的
const confirmed = results.flat().filter(Boolean).filter(f => f.verdict?.isReal)
return { confirmed }
// 关键效果:'bugs' 维度找到的问题已经在验证时,'perf' 维度可能还在找。零浪费墙钟。读懂这段就读懂了 80%。 注意三件事:
- 绝大部分是普通 JavaScript(
map/filter/flat/ 对象展开)。原语只有 3 个。 parallel里传的是() => agent(...),注意那个箭头 —— 这是 §3.4 的重点。- 最后那行注释描述的「零浪费墙钟」是
pipeline的灵魂 —— §4 整章在讲它。
3.2 meta:脚本的身份证
export const meta = {
name: 'find-flaky-tests', // 必填
description: 'Find flaky tests and propose fixes', // 必填,一行,权限弹窗里展示给用户
whenToUse: '当 CI 出现偶发失败时...', // 可选,workflow 列表里展示
phases: [ // 可选,每个 phase() 调用一条
{ title: 'Scan', detail: 'grep test logs for retries' },
{ title: 'Fix', detail: 'one agent per flaky test', model: 'opus' },
],
}📄 硬约束:meta 必须是纯字面量。 不准有变量、函数调用、展开(spread)、模板插值。
为什么这条约束存在? 因为
description要在执行脚本之前显示在权限弹窗里 给用户看。如果它是description: buildDescription(),那要拿到这个字符串就得先跑代码—— 而「先跑代码再问用户要不要跑」显然本末倒置。这是一个非常典型的「因为要在安全边界之前读到它,所以它必须是静态可求值的」约束。 同类设计在别处也常见(比如包管理器的 manifest 不许含逻辑)。面试时能点出这个 因果方向,比背「meta 必须是字面量」高一档。
🔬 sid-code 怎么实现这个校验(packages/core/src/workflow/sandbox.ts:191-281): 它先用花括号配平的方式从源码里抠出 meta 那段对象字面量的源码片段 (跳过字符串和注释),然后在一个空的隔离 vm context 里求值它。
纯字面量 → 在空 context 里能正常求值 → ✅ 通过
引用变量 → 空 context 里没有那个变量 → 抛 ReferenceError → ❌ 判为「非纯字面量」这个实现手法很漂亮,值得学:它没有去写一个 AST parser 来「检查有没有变量引用」, 而是利用「空环境里求值会失败」这个自然特性来实现检查。 代码量差一个数量级,而且不会漏——任何形式的外部依赖都会在空 context 里炸。
phases[].title 与脚本里 phase() 的调用按字面精确匹配;没匹配上的 phase() 调用会自成一组。
3.3 agent(prompt, opts?):开一个子代理
这是最核心的原语。其他四个都是围着它转的。
const result = await agent('grep CI 日志里的重试标记', {
schema: FLAKY_SCHEMA, // 强制结构化输出,返回已校验对象(§5 详解)
label: 'scan:ci', // 覆盖显示标签(进度树上显示什么)
phase: 'Scan', // 显式归到某进度组
model: 'sonnet', // 覆盖模型;默认省略 = 继承主循环模型
effort: 'low', // 覆盖推理强度 low|medium|high|xhigh|max
isolation: 'worktree', // 独立 git worktree(贵!仅当并行改文件会冲突时用)
agentType: 'Explore', // 用自定义 subagent 类型
})返回值语义(📄 关键,三条)
| 情况 | 返回什么 |
|---|---|
| 无 schema | 子代理的最终文本(string) |
| 有 schema | 子代理被强制调用 StructuredOutput 工具,返回已校验的对象(无需自己 parse) |
| 中途被 skip,或终态 API 错误重试后仍死 | null |
最后一条决定了一个必须养成的写法习惯:
const results = await parallel(...)
const clean = results.filter(Boolean) // ← 这一步不是可选的为什么设计成返回
null而不是抛错? 因为编排的价值在于部分成功也有价值。 20 路扇出里 1 路挂了,你想要的是剩下 19 份结果,不是整批失败。 但代价是:不 filter 就会把null当成结果往下传, 然后在result.findings那一步炸一个Cannot read property of null。🔬 sid-code 的
runtime.ts:199-215里,parallel明确写了.catch(() => null)——抛错落 null,调用本身永不 reject。
opts 的三个细节(📄 每条都有明确的「什么时候别用」)
model:默认省略。 只有你高度确信某个档位更合适才设;不确定就省略,继承会话模型。
但 §9.7 会讲一个例外:verify 阶段的 model 不是可选增强,是规定动作。 因为如果 find 和 verify 用同一个弱模型,弱模型验不出弱模型的错。
effort:廉价机械阶段用 'low',最难的 verify / judge 阶段才用高档。
isolation: 'worktree':昂贵(建一个 worktree 约 200-500ms + 磁盘开销), 仅当多个 agent 会并行改同一批文件、会冲突时才用。没有改动的 worktree 会自动移除。
🔬 sid-code 的实现(workflow/sub-agent-runner.ts:64-92)有两条值得注意的降级:
worktree 创建失败 → 打 warn,降级为非隔离执行(不是整批失败)
当前不是 git 仓库 → 打 warn,降级为非隔离执行这两条降级是对的,但要点破它的代价:降级之后,「并行改文件不冲突」这个保证 就没了——脚本以为自己在隔离环境里,实际上不是。所以那两行
log.warn很关键: 静默降级会让「并行改文件」在某些机器上悄悄变成互相踩踏。 这是 §17 那类「绿着坏掉」的典型形态。
子代理返回的是什么(📄 一个容易忽略的语义)
子代理被明确告知:「你的最终文本就是返回值(不是给人看的消息)」, 所以它返回原始数据。中间的 tool call / tool result 全留在子代理内部不返回。 主会话只见最终结果。
这句话解释了编排为什么能省上下文:一个子 agent 可能读了 30 个文件、跑了 10 条命令, 这些全部留在它自己的上下文里死掉,只有最后那个结论回到编排脚本。 上下文隔离不只是「防污染」,也是「压缩」。
3.4 parallel(thunks):并发跑一批(有屏障)
const results = await parallel(
DIMENSIONS.map(d => () => agent(d.prompt, { schema: FINDINGS }))
// ↑↑↑↑ 注意这个箭头!传的是 thunk,不是调用结果
)
const clean = results.filter(Boolean)📄 语义三条:
- 参数是
Array<() => Promise>(thunk 数组),不是 agent 调用的结果数组。 - 屏障(barrier):等所有 thunk 完成才返回。
- 某个 thunk 抛错 → 该位置 resolve 成
null,调用本身永不 reject。
为什么必须传 thunk 而不是调用结果(这是二手资料错得最多的一处)
📰 二手资料常写成 parallel([agent(...), agent(...)])。这是错的,而且错得很微妙。
看差别:
// ❌ 错:传调用结果
parallel([ agent('A'), agent('B'), agent('C') ])
// ^^^^^^^^^^ 这一行执行时,三个 agent 就已经全都发出去了!
// ✅ 对:传 thunk
parallel([ () => agent('A'), () => agent('B'), () => agent('C') ])
// ^^^^^^^^^^^^^^^^ 函数还没被调用,parallel 决定何时调JavaScript 的求值时机决定了这件事:agent('A') 一写出来就返回一个 已经在跑的 Promise。等它被放进数组、传给 parallel 时,请求早发出去了。
后果有两层:
| 后果 | 说明 |
|---|---|
| 失去并发控制 | 4096 个 item 会一瞬间全部发出去,直接打爆 provider 限流 |
| 失去背压 | parallel 想「只让 8 个同时跑,其余排队」也做不到——车都开出去了 |
这一条是绝佳的面试题:「为什么
parallel的参数是函数数组而不是 Promise 数组?」 答对的关键词是惰性求值与背压。能进一步说出「Promise 是 eager 的, 一创建就开始执行,所以想控制并发就必须推迟创建」,就说明你真懂而不是背的。
屏障的语义与代价
「等所有人都完成」听起来天经地义,但它有一个真实且经常被忽略的代价:
5 个 finder 并行,耗时分别是 1min / 1min / 1min / 1min / 3min
parallel 屏障: ├──1──┤ 空转 2min ────────┤
├──1──┤ 空转 2min ────────┤ ← 4 个 agent 白等
├──1──┤ 空转 2min ────────┤
├──1──┤ 空转 2min ────────┤
├──────────3──────────────┤
↑ 到这里才能开始下一阶段4 个快 finder 的下游工作被那 1 个慢的拖着,白白空转 2/3 的时间。
这个代价就是 §4 整章存在的理由——pipeline 就是「不要这个屏障」的那个原语。
3.5 pipeline(items, ...stages):逐项流水线(无屏障)
const results = await pipeline(
DIMENSIONS,
// stage 1
d => agent(d.prompt, { label: `review:${d.key}`, phase: 'Review', schema: FINDINGS }),
// stage 2:每个 stage 收 (上一阶段结果, 原始 item, 下标)
(review, originalItem, index) =>
parallel(review.findings.map(f => () =>
agent(`对抗性地验证: ${f.title}`, { phase: 'Verify', schema: VERDICT })
.then(v => ({ ...f, verdict: v }))
))
)📄 语义(本章最该记牢的一条):
每个 item 独立穿过所有 stage,stage 之间没有屏障。 item A 可以在 stage3,而 item B 还在 stage1。 墙钟 = 最慢的单条 item 链,不是「每阶段最慢之和」。
配套三条:
- 每个 stage 回调收
(prevResult, originalItem, index)——后面的 stage 用originalItem/index标注工作,不必把上下文一路从 stage1 串下来。 - 某 stage 抛错 → 该 item 落
null,跳过它剩余的 stage(其他 item 不受影响)。 - 📄 这是多阶段工作的默认选择。
🔬 sid-code 的实现(workflow/runtime.ts:218-252)代码短得可以整段读:
const runItemChain = async (item: unknown, index: number): Promise<unknown> => {
let prev: unknown = item;
for (let s = 0; s < stages.length; s++) {
try {
prev = await stages[s]!(prev, item, index); // 链内顺序 await
} catch {
return null; // 该 item 落 null,跳过剩余 stage
}
}
return prev;
};
return Promise.all(items.map((item, i) => runItemChain(item, i)));
// ^^^^^^^^^^^ 所有 item 链同时启动,但每条链内部顺序执行无屏障是怎么实现出来的? 就是最后那两行的结构:
Promise.all在最外层(所有链同时启动),顺序await在链内部(每条链内按序)。 如果反过来——外层按 stage 循环、内层Promise.all所有 item——那就变成有屏障了。这两种写法的代码长度几乎一样,语义完全相反。 这就是为什么 §4 说这是「头号要测对」的语义。
3.6 phase(title) 与 log(message):给人看的两个原语
phase('Review') // 开新进度组,后续 agent() 归到此组
log('已扫描 42 个文件,找到 7 个候选') // 向用户发一行进度叙述这两个原语不影响任何执行逻辑,只影响用户看到什么。但它们不是装饰:
📄 有一条硬要求与 log 直接绑定:
沉默截断禁令:如果 workflow 因为预算 / 采样 / top-N 而限制了覆盖范围, 必须
log()出丢掉了什么。静默截断会被读成「全覆盖了」,但其实没有。
这条禁令是 §17 那类失效模式的预防措施:一份「审查了 50 项」的报告和一份 「审查了 50 项中的 12 项(因预算限制)」的报告,价值差一个量级, 但如果不 log,它们看起来一模一样。
phase 有一个并发陷阱,📄 spec 明确警告:
在
pipeline/parallel内部,改用agent({phase: 'Verify'}), 不要调phase()——因为phase()改的是全局状态,并发环境下会竞态。
🔬 sid-code 的实现印证了这一点(runtime.ts:169):
const phase = opts?.phase ?? this.currentPhase; // opts.phase 优先,兜底才读全局这是个非常典型的「全局可变状态 + 并发 = 竞态」案例,值得记住它的形状:
phase()设的是this.currentPhase,而 20 个 agent 并发读它。 A 的 stage2 可能读到 B 刚设的 phase。解法不是加锁,是提供一条不走全局状态的路 (opts.phase)——并发问题最好的解法往往是「让那个共享状态变得不必要」。
3.7 三个全局:args、budget、workflow()
args:外部传进来的参数
调用 workflow 时传的 args 值,脚本里逐字可见。
📄 一个坑:传数组 / 对象要传真 JSON 值,不是 JSON 字符串。 一个被字符串化的 list 会让 args.filter / args.map 直接抛错。
budget:token 预算(§8.3 详解)
// budget = { total: number|null, spent(): number, remaining(): number }
const FLEET = budget.total ? Math.floor(budget.total / 100_000) : 5
while (budget.total && budget.remaining() > 50_000) { /* ... */ }workflow(nameOrRef, args?):内联跑另一个 workflow
📄 语义:共享本次 run 的并发上限 / agent 计数 / 中止信号 / token 预算。 嵌套仅一层——子 workflow 里再调 workflow() 抛错。
为什么只允许一层? 因为并发上限、agent 总数闸、预算都是整个 run 共享的。 允许无限嵌套的话,「单 run 最多 1000 个 agent」这道闸就要在一棵任意深的树上累计, 而任何一层写错都会让整棵树失控。限制深度是把失控半径限制成可推理的。
🔬 sid-code 的实现(tool/workflow.ts:340-364):子脚本里再调 workflow() 直接抛错。
3.8 五个原语总表
| 原语 | 签名 | 一句话语义 | 最易错的点 |
|---|---|---|---|
agent | (prompt, opts?) => Promise<结果> | 开一个独立上下文的子代理 | 失败返 null,必须 filter(Boolean) |
parallel | (thunks) => Promise<结果[]> | 并发一批,等齐才返回 | 传 thunk 不是调用结果 |
pipeline | (items, ...stages) => Promise<结果[]> | 逐项穿过多阶段,不等齐 | 无屏障(二手资料普遍搞反) |
phase | (title) => void | 切换进度组 | 并发内部改用 agent({phase}) |
log | (message) => void | 发一行进度叙述 | 截断覆盖范围时必须 log |
3.9 本章自检
parallel为什么必须传() => agent(...)而不能传agent(...)? (两个后果分别是什么?)agent()失败时返回null而不抛错——这个设计买到了什么,代价是什么?meta为什么必须是纯字面量?(提示:因果方向是「要在执行前读到它」)- 为什么
pipeline/parallel内部不该调phase()? - 「无屏障」在代码结构上是怎么实现的?把
Promise.all和顺序await的位置对调会发生什么?
§4 ★ pipeline vs parallel:屏障的代价(本文最核心的一节)
这一章单独拎出来,有两个理由:
- 它是二手资料错得最集中的地方——📰 社区博客普遍把
pipeline描述成「阶段间有屏障」, 正好搞反了,而搞反之后pipeline就退化成了一个更啰嗦的parallel。 - 它是编排层**唯一一处「同样的代码长度、完全相反的性能特征」**的地方。 这种地方在工程上永远是 bug 的重灾区。
4.1 先校正二手资料的三处硬错误
📄 一手 spec 与 📰 二手资料直接矛盾的地方有三处。这三处如果照抄进实现都会埋雷:
| 📰 二手资料的说法 | 📄 一手 spec 的事实 | 照抄的后果 |
|---|---|---|
parallel([agent(...), agent(...)]) 直接传 agent 调用 | parallel(thunks) 传的是 () => Promise 的 thunk 数组 | 传错会立即求值,失去并发控制与背压(§3.4) |
pipeline() 中前一阶段必须完全完成才能开始下一阶段(有 barrier) | pipeline() 阶段间无 barrier,每个 item 独立穿过所有 stage | 这是 pipeline 与 parallel 的本质区别,搞反就退化成串行 |
| schema 机制「官方未说明是否内置校验」 | agent({schema}) 在工具层强制校验,子代理被强制调 StructuredOutput,不合规自动重试,返回已校验对象(非文本) | schema 是确定性编排的地基,不是可选项(§5) |
教训:对标这类细节密集的 harness 功能,二手博客只能给「心智模型」, 精确契约必须以一手 spec 为准。
这条教训本身就是一个可迁移的方法论:当二手资料描述一个 API 的语义时, 它描述的往往是「作者以为的语义」而不是「实现的语义」—— 而语义差异恰恰在性能特征上体现,最难被功能测试发现。
4.2 两者的差别用一张时间轴说清
设有 3 个 item,每个要穿过 2 个阶段。耗时:
item A: stage1 = 1min, stage2 = 1min
item B: stage1 = 1min, stage2 = 1min
item C: stage1 = 3min, stage2 = 1min ← C 的 stage1 特别慢用 parallel + 屏障(两次 parallel 夹一个 transform):
时间轴 → 0 1 2 3 4 5 min
A stage1 ├─1─┤ ░░░░░░░░░░░░
B stage1 ├─1─┤ ░░░░░░░░░░░░ ░░ = 空转,等屏障
C stage1 ├────────3────────┤
┃ ← 屏障:等 C 完成
A stage2 ├─1─┤
B stage2 ├─1─┤
C stage2 ├─1─┤
↑ 总墙钟 = 4min用 pipeline(无屏障):
时间轴 → 0 1 2 3 4 5 min
A stage1 ├─1─┤
A stage2 ├─1─┤ ← A 不等任何人,直接进 stage2
B stage1 ├─1─┤
B stage2 ├─1─┤
C stage1 ├────────3────────┤
C stage2 ├─1─┤
↑ 总墙钟 = 4min咦,这个例子里两者一样是 4min? 对——因为这个例子里 C 恰好是最慢的单链 (3+1=4),而屏障时间也是 4。这正是要点破的地方:
屏障的代价不是「总时间一定更长」,而是**「快的那些 item 的下游工作被无谓推迟了」**。
改一下例子就看得清。如果 stage2 是很贵的(比如每个 item 的 stage2 要 5min):
parallel + 屏障: C 的 stage1 跑完(3min) 才开始所有 stage2 → 3 + 5 = 8min
pipeline: A/B 的 stage2 在第 1min 就开始了 → C 那条链 3+5=8min,
但 A/B 在 6min 就交付了 —— 更早拿到部分结果两个真实收益:
- 总墙钟 ≤ 屏障方案(等号成立于「最慢单链恰好也是各阶段最慢之和」)。
- 部分结果更早可用 —— 这一条在长任务里价值极高:跑 40 分钟的编排, pipeline 让你在第 5 分钟就能看到第一个完整结论,而屏障方案要等到最后。
4.3 那什么时候该用屏障?📄 三条,只有三条
📄 spec 的判据非常干脆——只在 stage N 需要 stage N-1 的全量跨项上下文时才用屏障:
| 该用屏障的情形 | 具体例子 |
|---|---|
| ① 去重 / 合并后再做昂贵的下游工作 | 20 路 finder 找出 60 条发现,其中 40 条重复;先去重成 20 条再验证,省 2/3 验证成本 |
| ② 总数为零时提前退出 | 「找到 0 个 bug → 整个验证阶段跳过」——不汇总就不知道是 0 |
| ③ stage N 的 prompt **要引用「其他发现」**做对比 | 「这 20 条发现里,哪几条其实是同一个根因?」 |
这三条的共同结构:stage N 需要的输入不是「我这一项的上一阶段结果」, 而是「所有项的上一阶段结果」。只有这时屏障才是必需的。
4.4 以下都不构成用屏障的理由(📄 逐条驳回)
这一节比上一节更重要,因为这几个理由听起来都很有道理:
| 听起来合理的理由 | 为什么不成立 | 正确做法 |
|---|---|---|
| 「我得先 flatten / map / filter 一下」 | 这些是逐项变换,不需要看到别人 | 在 pipeline 的 stage 里做:pipeline(items, stageA, r => transform([r]).flat(), stageB) |
| 「这俩阶段概念上是独立的」 | 独立 ≠ 需要同步。「概念独立」正是 pipeline 建模的东西 | 用 pipeline |
| 「代码更干净 / 更好读」 | 屏障的延迟是真的:5 个 finder,最慢的是最快的 3 倍时,屏障白白浪费快 finder 2/3 的时间 | 用 pipeline,可读性差异远小于性能差异 |
第三条是最危险的,因为「代码可读性」在 code review 里是个受尊重的理由。 但这里的取舍是不对称的:屏障换来的可读性提升很小(多一层嵌套), 而损失的是真实的墙钟与部分结果的可用性。
面试时如果有人给出「代码更清晰」作为选屏障的理由, 能指出「这是一个用真实延迟换微小可读性的坏交易」,就说明你判断力在线。
4.5 📄 一个可操作的嗅探测试
spec 给了一个非常实用的判据,直接照抄:
如果你写出了这个形状:
javascriptconst a = await parallel(...) const b = transform(a) // flatten / map / filter const c = await parallel(b.map(...))而中间那个
transform没有跨项依赖,那它不需要屏障 —— 重写成 pipeline, 把 transform 塞进 stage。拿不准就用 pipeline。
「拿不准就用 pipeline」这个默认值是对的,原因是错误代价不对称:
- 该用 pipeline 却用了屏障 → 白白慢,而且不报错,永远不会被发现。
- 该用屏障却用了 pipeline → 立刻出错(拿不到全量数据),当场暴露。
这是一个「选那个错了会立刻炸的默认值」的经典案例。 默认值的选择原则不是「哪个更常对」,而是**「哪个错法更容易被发现」**。 这个原则在很多地方通用(比如 fail-closed 优于 fail-open)。
4.6 🔬 sid-code 怎么保证这个语义不被写坏
既然「两种写法长度一样、语义相反」,那就必须有测试锁住它。 🔬 packages/core/tests/workflow/runtime.test.ts(24 个用例)里锁的是时间戳重叠, 不是「结果对不对」:
判据:item A 的 stage2 时间戳 与 item B 的 stage1 时间戳 必须重叠为什么必须测时间戳而不能测结果? 因为有屏障和无屏障产出的结果完全一样, 只有时序不同。一个只断言「结果正确」的测试,在实现被改成有屏障之后依然全绿。
这是本文第一次出现「绿着坏掉」的形态(§17 会集中讲): 测试通过、功能正确、性能悄悄退化一个量级。 要拦住它,测试必须断言那个真正被设计的属性(这里是并发时序), 而不是断言「输出对不对」。
4.7 本章自检
- 用一张时间轴说明屏障的代价。什么情况下屏障和 pipeline 的总墙钟一样?
- 📄 允许用屏障的三条理由,它们的共同结构是什么?
- 「这两个阶段概念上独立,所以我用两次 parallel」——这个推理错在哪?
- 为什么「拿不准就用 pipeline」是对的默认值?(提示:错误代价的对称性)
- 一个断言「pipeline 结果正确」的测试,为什么拦不住「实现被改成有屏障」?
§5 结构化输出:schema 的真实用途不是「格式化」
先说结论,因为这是最容易被低估的一章:
schema 看起来是「让返回值变成 JSON 方便解析」,实际上它是「拦住模型胡说」的唯一机械手段。
5.1 先看没有 schema 会发生什么
假设你扇出 20 个 agent 找 bug,每个返回一段自由文本:
agent#3 的返回:
「我仔细检查了认证模块。整体来看代码质量不错,但我注意到 session 过期处理
可能存在一些问题,建议进一步审查。另外错误处理也可以更完善一些。」这段文本的问题不是「不好解析」,是它什么都没说。
- 有几个 bug?不知道。「一些问题」是 1 个还是 3 个?
- 在哪个文件哪一行?没说。
- 「可能存在」是真的存在还是它猜的?分不清。
- 「建议进一步审查」——这是把工作推回给你,而它本来就是被派来审查的。
现在把 20 份这样的文本合并起来。你得到的是一份读起来很像审查报告、 但没有任何一条可以行动的东西。 而且它看起来「覆盖了 20 个维度」, 显得工作量很足。
这就是自由文本的真实危害:它让「没干活」和「干了活」看起来一样。
5.2 schema 做的第一件事:把「模糊」变成「不合法」
给同一个 agent 一个 schema:
const FINDINGS = {
type: 'object',
required: ['findings'],
properties: {
findings: {
type: 'array',
items: {
type: 'object',
required: ['title', 'file', 'line', 'severity'], // ← 每个字段都必填
properties: {
title: { type: 'string' },
file: { type: 'string' },
line: { type: 'number' },
severity: { enum: ['high', 'medium', 'low'] }, // ← 只能是这三个值
},
},
},
},
}现在那段话写不出来了。模型必须回答:
{ "findings": [] }或者
{ "findings": [
{ "title": "session 过期后 token 未清理", "file": "src/auth/session.ts",
"line": 142, "severity": "medium" }
]}注意发生了什么:模糊的中间态被消灭了。要么它承认「我没找到」(空数组), 要么它必须给出一个具体的文件和行号。
这是 schema 的第一层价值:它把「说了等于没说」变成了一个非法状态。
「可能存在一些问题」在这个 schema 下无处安放——没有
maybe字段, 没有general_impression字段。模型只能在「有」和「没有」之间选一个。
5.3 ★ schema 做的第二件事:强制举证(这一节是本章的核心)
上面那个 schema 已经不错了,但它还拦不住一件事:模型可以编一个行号。
🔬 sid-code 的实测证据(§1.6 提过,这里展开):某次审查里 agent 报了一条 bug:
「
history-adapter.ts:226提前显示 executing 状态,误导用户。」
这条是纯误报。 那一行只是往一个 Map 里写了个值,不输出任何东西, 蓝点是在另一个分支、在工具真实执行期渲染的。
上面那个 schema 完全拦不住它 —— 它有 file、有 line、有 severity, 格式完美合法。格式合法与内容为真是两件事。
那怎么办?答案是在 schema 里加一个「必须把证据抄出来」的字段:
const VERDICT = {
type: 'object',
required: ['verdict', 'evidence', 'refutation_attempted', 'severity_calibrated'],
properties: {
verdict: { enum: ['CONFIRMED', 'REFUTED', 'PARTIAL', 'UNVERIFIABLE'] },
// ↑ 关键:REFUTED 是一个合法答案,而且排在很前面
evidence: { type: 'string' },
// ↑ 必填:file:line + 那几行代码的实际内容。空的就判不合规、重试
refutation_attempted: { type: 'string' },
// ↑ 必填:「我是怎么尝试推翻这条结论的」
severity_calibrated: { type: 'string' },
// ↑ 校准后的严重度,专治严重度虚高
},
}为什么这个 schema 能拦住那条误报? 因为 evidence 必填, verify agent 必须真的去读 history-adapter.ts:226,并把那几行代码抄进 evidence 字段。
一旦它去读了,它就会看见那一行只是 map.set(...)。 于是它填出来的 evidence 是「226 行只写入 Map,不产生任何输出」, 而这个 evidence 与 verdict: CONFIRMED 自相矛盾——模型自己就会改成 REFUTED。
🔬 这不是理论推演,是实测发生过的事:sid-code 那次评判用的正是这个形状的 schema, 它当场抓出了 2 条误报(另一条是把一个 hook 局部变量当成全局状态字段的范畴错误)。
这一段值得反复读,因为它揭示了 schema 的真实作用机制:
schema 不是在「检查」模型的答案,它是在「改变模型必须做的动作」。
evidence字段的价值不在于让你能读到证据,而在于它强迫模型去打开那个文件。 一个不需要举证的 verify agent 会在自己的上下文里"想一想"然后盖个章; 一个必须举证的 verify agent 不得不产生一次真实的文件读取。必填字段是一种「工作强制装置」,这是它比 prompt 里写「请提供证据」强的根本原因—— prompt 是建议,schema 是结构上做不到就交不了差。
5.4 三个字段设计的讲究
上面那个 VERDICT schema 里,每个字段都有一个针对性的失效模式:
| 字段 | 拦的是什么失效 | 为什么这样设计 |
|---|---|---|
verdict 含 REFUTED | 「验证者不敢说前面的人错了」 | REFUTED 必须是一个显式的、合法的、被鼓励的答案。如果 enum 里只有 CONFIRMED / PARTIAL,模型会理解成「我的工作是确认」 |
evidence 必填 | 凭空编造(伪阳性) | 强制产生一次真实读取(上一节) |
refutation_attempted 必填 | 「走过场式验证」 | 迫使模型说出「我试过怎么推翻它」。写不出来就说明它根本没试 |
severity_calibrated | 严重度虚高 | 🔬 实测两组都有 2-3 项严重度高估。单独一个字段逼它重新评一遍 |
refutation_attempted这个字段特别值得学。 它的作用是让「有没有认真做」变得可观测。 一个只写「我检查了代码,结论正确」的 refutation_attempted 一眼就能看出是敷衍; 而如果没有这个字段,敷衍和认真产出的东西完全一样(都是一个CONFIRMED)。通用范式:想让一个软性要求变硬,就给它加一个必填的「过程字段」。
5.5 schema 的执行机制:📄 强制调工具 + 校验 + 重试
📄 一手 spec 的语义(这也是二手资料搞错的第三处,见 §4.1):
agent({schema})时,子代理被强制调用StructuredOutput工具, 工具层做校验,不合规自动重试,agent()返回已校验的对象(不是文本)。
三步链条:
① 给子代理挂一个名为 StructuredOutput 的工具,它的入参 schema 就是你给的那个 schema
② 子代理想"交差",唯一的出口就是调这个工具
③ 工具收到入参 → 校验 → 不合规就把错误信息回喂,让它再调一次这个设计的精妙处在于:它把「输出格式约束」变成了「工具调用约束」。
模型对「按格式输出文本」的遵守度一般(尤其弱模型),但对「调用工具时填对参数」 的遵守度高得多——因为工具入参校验失败会立刻得到一个错误反馈, 这是模型在训练中大量见过的循环。
一句话:把「请你按这个格式说话」改造成「请你调这个函数」,遵守率会显著上升。
🔬 sid-code 的实现(packages/core/src/tool/structured-output-tool.ts,175 行):
get hasCapturedOutput(): boolean { ... } // 是否已成功捕获合规输出
get isExhausted(): boolean { // 重试耗尽且仍未合规
return this.callCount >= this.maxRetries && !this.hasCaptured;
}主流程在 agent/sub-agent.ts:1859-1885 分三条路:
| 情况 | 处理 |
|---|---|
| 工具已捕获合规输出 | 旁路 extractFinalText,直接用工具校验过的 JSON |
| 重试耗尽仍不合规 | 返回空字符串 → workflow 层 JSON.parse 失败 → agent() 返回 null |
| 模型压根没调这个工具(弱模型会) | 文本兜底:从最终文本里试着抠 JSON 出来再校验 |
第三条那个「文本兜底」路径值得单独说,因为它是一个诚实的工程妥协:
📄 官方说「子代理被强制调用」,但现实是弱模型会无视 system prompt 直接输出文本。 如果没有兜底,接一个弱模型时所有带 schema 的 agent 全返回
null—— 编排层看起来「全部失败」,而真实原因只是格式没走对通道。但兜底也有代价:它让「模型遵守了契约」和「模型没遵守但我们救回来了」变得难以区分。 🔬 sid-code 的做法是在兜底路径上打
log.warn—— 保留能力,但留下痕迹。这比静默兜底好,也比硬失败好。
5.6 一个性能细节:schema 缓存,以及缓存键写错的代价
workflow 一次跑可能调 30-80 次同一个 schema 的 agent。 每次都重新编译 schema(构造校验器)的开销会累积。
📄 官方实现里有 schema identity 缓存(按 schema 对象引用缓存已编译的校验器):
不缓存:每次 new 校验器 + compile ≈ 1.4ms × 80 次 ≈ 110ms
缓存后:一次编译,后续查表🔬 sid-code 同样做了(structured-output-tool.ts:39-41 的 shapeCache)。
但这里有一个踩过的坑,值得当作缓存设计的通用教训:
📰 官方那边曾出过一个 bug:
StructuredOutput这个工具名字是固定的, 但不同调用的 schema 不同。如果缓存键只用工具名, 就会把 A 的校验器错配给 B 的调用。报告的错误率影响:5.4% → 51%。
教训:缓存键必须包含所有影响输出的输入。这里工具名相同但 schema 不同, 所以 schema 本身必须进缓存键。
这是一个可以举一反三的形状:凡是「名字固定、内容可变」的东西做缓存, 缓存键必须取内容而不是名字。同类坑在 HTTP 缓存(同 URL 不同 header)、 模板编译(同模板名不同 locale)里反复出现。
5.7 schema 与「沉默截断」的配合
§3.6 提过 📄 的沉默截断禁令。schema 让它有了机械落点:
const FINDINGS = {
required: ['findings', 'coverage_note'],
properties: {
findings: { type: 'array', ... },
coverage_note: { type: 'string' },
// ↑ 必填:「我实际检查了哪些、跳过了哪些、为什么跳过」
},
}有了这个必填字段,「我只查了 12 项中的 5 项」就必须被写出来, 而不是变成一份看起来完整的报告。
5.8 本章自检
- schema 的第一层价值是「方便解析」吗?如果不是,是什么?
- 一条格式完全合法的 finding(有 file、有 line、有 severity)为什么仍可能是纯误报? 加什么字段能拦住?
- 为什么说
evidence必填字段是一种「工作强制装置」?它改变了模型的什么动作? refutation_attempted这个字段拦的是哪种失效?为什么没有它就分不出敷衍与认真?- 为什么
verdict的 enum 里必须显式包含REFUTED? - schema 缓存的键为什么不能只用工具名?(错误率数据是多少?)
- 「文本兜底」路径买到了什么,代价是什么?为什么要打 warn?
§6 沙箱与确定性:为什么禁 Date.now()
L3 的定义里有一句轻描淡写的话:「模型现场写一段 JavaScript,交给 runtime 执行」。
这句话里藏着整个功能最危险的一步:你要执行一段大模型刚生成的、没人审过的代码。
这一章讲两件事,它们经常被混为一谈但目标完全不同:
| 沙箱(安全) | 确定性守卫(可恢复) | |
|---|---|---|
| 防的是 | 脚本乱碰文件 / 网络 / 进程 | 脚本每次跑结果不一样 |
| 后果 | 安全事故 | resume 失效(§7) |
| 手段 | 隔离执行环境 | 禁掉不确定的 API |
6.1 第一个问题:脚本凭什么不能碰文件
先说清设计意图 📄:
脚本本身是「纯编排」,一切副作用都通过
agent()调用有权限裁决的工具发生。
这句话的含义是一个权限收口:
❌ 脚本直接 fs.writeFile('/etc/passwd', ...) ← 绕过了整套权限体系
✅ 脚本 agent('把这段写进 config.json') ← 子代理调 write 工具 → 过权限闸门为什么必须这样收口? 因为权限系统(哪些文件可读、哪些命令要问用户)是挂在工具层的。 如果脚本能直接调 fs,那它就站在权限系统的外面 —— 一段 LLM 生成的代码获得了比 LLM 自己更高的权限,这个方向完全错了。
一句话记住:脚本的权限必须 ≤ agent 的权限。 编排层是「指挥」,不是「特权通道」。
📄 所以 spec 规定脚本环境里:
- 可用:标准 JS 内置(
JSON/Math/Array/Object/String...) - 不可用:文件系统、网络、Node.js API、
process
6.2 🔬 一个失败的第一版实现,以及它是怎么被一句话击穿的
这一节是本章最值钱的部分,因为它是真实的实测失败记录(workflow/sandbox.ts:19-25 的注释原文)。
第一版思路(听起来很对):用函数参数「影子」掉危险的全局。
// 把脚本包成一个函数,参数名占掉那些全局名字
new AsyncFunction('process', 'require', 'fs', 'agent', 'parallel', ..., 脚本源码)
// 调用时给前几个传 undefined
// → 脚本里写 process.exit() 就变成 undefined.exit(),报错看起来严密:脚本里所有 process 都被参数遮蔽了,拿不到真的。
它被这一行击穿了:
[].constructor.constructor('return process')()拆开看这行为什么有效(这是 JS 里一个经典逃逸,值得完全理解):
[] → 一个数组
[].constructor → Array(数组的构造函数)
[].constructor.constructor → Function ← 关键!所有函数的构造函数就是 Function
Function('return process')→ 编译出一个新函数,函数体是 "return process"
这个新函数在**全局作用域**里编译,
看不见你那些参数名!
() → 调用它 → 拿回真的 process根本原因:参数影子只能遮蔽当前作用域的名字。而 Function 构造器编译出的代码 在全局作用域求值,压根不在你的参数作用域里。而 Function 本身是从 Array.constructor.constructor 这条 prototype 链上拿到的 —— 你没法把它也遮蔽掉, 因为你不能不给脚本数组和对象。
🔬 实测结论原文:「param-shadow 对付不了 prototype 链上的真 Function。」
这个案例的教学价值在于它示范了一类思维错误: 第一版把「名字看不见」当成了「东西拿不到」。 但在 JS 里,能力是通过对象图(object graph)传播的,不是通过名字传播的。 只要脚本手里有任何一个普通对象,它就能沿着 prototype 链走到
Function, 然后从Function走到全局。遮名字是在改地图,不是在拆路。
6.3 🔬 第二版:用 node:vm 建一个真的隔离 context
正确的做法是换一个全局对象,而不是遮蔽当前全局里的名字:
import vm from "node:vm";
// 建一个全新的 context,它的"全局"里只有我们放进去的东西
const context = vm.createContext({ agent, parallel, pipeline, phase, log, args, budget, ... });
vm.runInContext(scriptSrc, context, { timeout: 30_000 });🔬 实测验证的四条(sandbox.ts:22-25):
| 攻击 | 结果 |
|---|---|
[].constructor.constructor('return process')() | 拿到的是 这个 context 自己的 Function,编译出的代码也只在这个 context 里跑 → process 是 undefined |
globalThis.process | undefined |
require(...) | undefined |
动态 import(...) | 被拒(vm context 没配 importModuleDynamically callback) |
死循环 while(true){} | 被 timeout 掐掉 |
为什么这次对了? 因为逃逸链的终点变了。 上一版的逃逸链是「数组 → Function → 宿主全局」; 这一版是「数组 → Function → 这个 context 自己的全局」。 链条还在,但它通向的地方已经是空房间了。
一般化的教训:隔离要做在「能力的边界」上,不是在「名字的可见性」上。 一个安全边界如果能被「换个路径拿同一个东西」绕过,它就不是边界,只是个提示。
6.4 一个必须补的洞:静态扫描三个硬逃逸
vm context 挡住了绝大部分,但有三个构造必须再加一道静态扫描 (🔬 sandbox.ts:169-186 的 scanForHardEscapes):
{ re: /\bimport\s*\(/, name: "动态 import()" },
{ re: /\brequire\s*\(/, name: "require()" },
{ re: /\beval\b/, name: "eval" },命中即抛错,报错信息还顺带解释了为什么:
「workflow 脚本是纯编排,一切副作用必须经
agent()调有权限裁决的工具。」
6.5 ★ 但静态扫描有一个自己的坑:📰 官方踩过的「误杀」
这里有一个极好的负面案例,📰 来自官方自己的 GitHub issue #63759:
官方的确定性守卫曾扫描代码里任何包含
Date.now的子串, 于是连 prompt 字符串里提到 "Date.now" 都被拒了。
也就是说,这样一段完全无害的脚本会被拒绝执行:
await agent('检查这段代码有没有误用 Date.now() 导致的时区 bug')
// ^^^^^^^^^^ 这只是一句话里的字符,不是代码!这个坑的形状值得记住:静态字符串扫描分不清「代码」和「代码里提到的字符串」。 而对一个 coding agent 来说,「在 prompt 里讨论代码」是最常见的用法 —— 所以这个误杀恰好打在最高频的路径上。
🔬 sid-code 怎么绕开它:两手。
第一手:确定性守卫不做静态扫描,改成运行时抛错(下一节详述)。
第二手:那三个硬逃逸确实需要静态扫,但先把字符串和注释剥掉再扫 (sandbox.ts:107-163 的 stripStringsAndComments):
原始源码: await agent('别用 require() 加载模块')
剥离之后: await agent( ) ← 字符串内容被换成等长空白
扫描结果: 没有 require( 命中 ✅注意实现细节:替换成等长空白而不是删掉 —— 这样行号列号全部保持不变, 报错位置还能对上。
🔬 代码注释里还坦白了一个残差:正则字面量与除法的消歧很难 100% 做对, 所以「正则字面量内恰好含
import(」这种情况会漏。 但它明确写了这是可接受残差(编排脚本里近乎不可能出现)。这种"标注已知残差"的做法值得学:它比假装完美好, 也比因为做不到 100% 就放弃这一层防线好。明确的不完美 > 隐含的不完美。
6.6 确定性守卫:为什么禁 Date.now() 和 Math.random()
现在换一个完全不同的问题。为什么这三个东西被禁:
Date.now() // ❌
new Date() // ❌(无参)
Math.random() // ❌
new Date(args.ts) // ✅(带参,确定性)它们不是安全问题 —— Date.now() 读个时间戳能有什么危害?
它们是 resume 问题。 完整推理链:
① workflow 跑到第 7 个 agent 时,用户按了 Ctrl+C(或终端崩了)
② 用户想接着跑,不想重跑前 6 个(每个可能几分钟、几万 token)
③ resume 的机制是:重跑整个脚本,但已完成的 agent() 调用直接返回缓存结果
④ 「已完成」怎么认?靠 (调用序号, prompt 指纹) 匹配(§7 详解)
⑤ 如果脚本里有:agent(`分析 ${Date.now()} 的日志`)
→ 第一次跑:prompt 是 "分析 1758... 的日志"
→ resume 重跑:prompt 是 "分析 1759... 的日志" ← 指纹变了!
⑥ 缓存全部 miss,前 6 个 agent 全部重跑。resume 白做了。这条链是本章最重要的推理。 它解释了一个乍看莫名其妙的限制: 「禁掉读时钟」不是为了安全,是为了让「重跑一遍」能得到同一个脚本行为。
一般化:任何想支持「断点续跑」的执行引擎,都必须让脚本变成 「同样的输入 → 同样的执行序列」的纯函数。 时间和随机数是纯函数的两大天敌。
这个约束在别处也常见:构建系统的可重现构建(reproducible build)禁时间戳、 数据库的确定性存储过程禁
NOW()、区块链智能合约禁随机数 —— 同一个道理的不同马甲。
6.7 🔬 影子 Date / Math:怎么禁得既准又不误杀
sid-code 的做法是在 context 里放影子对象(sandbox.ts:45-97), 而不是静态扫源码:
// 影子 Date:禁 Date.now() 与无参 new Date();带参 new Date(ts) 放行
ShadowDate.now = () => {
throw new Error(
"[workflow] Date.now() 被禁(非确定性,破坏 resume)。请从 args 传时间戳,或在 workflow 返回后再盖戳。"
);
};
// 影子 Math:禁 Math.random(),其余方法/常量原样转发
// → Math.floor / Math.max / Math.PI 全部照常可用三个设计细节,每个都有讲究:
| 细节 | 为什么这样 |
|---|---|
| 运行时抛错,不静态扫 | 彻底绕开 #63759 误杀:prompt 字符串里提 "Date.now" 完全没事,因为那不是一次调用 |
带参 new Date(ts) 放行 | new Date(args.ts) 是确定性的(同样的 ts 得到同样的日期)。只禁掉不确定的那一半,不是禁掉整个 Date |
Math 只禁 random,其余转发 | 编排脚本大量用 Math.floor / Math.min 做分片计算。一刀切禁掉整个 Math 会让脚本没法写 |
| 报错信息带解法 | 「请从 args 传时间戳,或在 workflow 返回后再盖戳」—— 模型看到这条错误能自己改对 |
最后那条特别值得强调。 这个沙箱的使用者是大模型, 而模型看到报错之后会尝试自我修复。所以报错信息的质量直接决定了成功率:
- 差的报错:
Date.now is not a function→ 模型会困惑,可能换成new Date().getTime()(还是被禁)- 好的报错:
被禁(非确定性,破坏 resume)。请从 args 传时间戳→ 模型知道该往哪改给 LLM 用的 API,报错信息是 API 的一部分。 这是 agent 时代一个很实用的设计原则。
📄 官方给的两条替代方案也是这个意思:
| 需要什么 | 替代做法 |
|---|---|
| 时间戳 | 从 args 传进来,或 workflow 返回后再盖戳 |
| 随机性 | 按下标变化 agent 的 prompt / label(`候选 #${i}`) |
6.8 还有一个 CPU 兜底:同步超时
🔬 sandbox.ts:29-38:vm 的 timeout 选项,默认 30 秒,可用 SID_WORKFLOW_SYNC_TIMEOUT_MS 覆盖。
但要看清它掐的是什么 —— 代码注释写得很明确:
只掐同步阻塞(死循环);异步 await 期间不计时,
agent()自身的超时由 SubAgent 层(默认 120s)与调度器负责。
这个区分必须搞清,否则会错判它的保护范围:
while(true){}→ 30 秒后被掐 ✅(这是它要防的:TUI 冻结)- 一个跑了 2 小时的 40-agent 编排 → 不会被掐 ✅(正常长任务,不该掐)
一个只看「timeout: 30000」就以为「workflow 最多跑 30 秒」的人会完全误解这道闸。 多层超时各管一段是常见架构,但每层管什么必须写清楚 —— 否则排查时会在错误的层找原因。
6.9 三道防线总表
| 防线 | 防什么 | 手段 | 关键教训 |
|---|---|---|---|
| ① vm 隔离 context | 脚本碰文件 / 网络 / 进程 | vm.createContext 只放受控全局 | 隔离要做在能力边界,不是名字可见性 |
| ② 静态扫硬逃逸 | import() / require() / eval | 先剥字符串注释,再扫 | 扫源码必须先剥字符串,否则误杀 prompt |
| ③ 影子 Date / Math | 非确定性破坏 resume | 运行时抛错,带解法的报错信息 | 只禁不确定的那一半;报错是给模型看的 |
| ④ 同步超时(兜底) | 死循环冻 TUI | vm timeout,默认 30s | 只掐同步,异步长任务不受影响 |
🔬 测试覆盖:packages/core/tests/workflow/sandbox.test.ts 31 个用例 —— 是这个目录里用例最多的文件,符合它作为安全边界的地位。
6.10 本章自检
- 为什么脚本不许直接调
fs?(提示:权限系统挂在哪一层) [].constructor.constructor('return process')()为什么能击穿参数影子? 把这条链的四步说出来。- 「遮名字」和「拆路」的区别是什么?为什么 vm context 能解决而参数影子不能?
- 官方 #63759 那个误杀是什么形状?为什么它恰好打在最高频的使用路径上?
- 为什么禁
Date.now()?这是安全问题还是别的问题?完整推理链是什么? - 为什么
new Date(args.ts)可以放行,new Date()不行? timeout: 30000是不是意味着 workflow 最多跑 30 秒?为什么不是?- 为什么说「给 LLM 用的 API,报错信息是 API 的一部分」?
§7 断点续跑:缓存键该用什么(一个后发者绕开前人坑的漂亮案例)
7.1 为什么这件事必须做
编排是为长任务准备的。「长」意味着:
- 一次 run 可能开 40 个 agent,跑 30-60 分钟
- 中途可能:用户按 Ctrl+C、终端关掉、机器休眠、provider 限流打断
- 用户还很可能想改一下脚本再重跑(第 5 个 agent 的 prompt 写得不好)
没有 resume 的话,这三种情况都得从头再来 —— 前 39 个 agent 的 token 全部白烧。 这与「为长任务设计」的定位直接矛盾。
7.2 机制:重跑整个脚本,但已完成的调用查表返回
resume 的实现思路可能和直觉不同。它不是「从第 7 个 agent 继续」,而是:
① 重新执行整个脚本(从第一行开始)
② 每次遇到 agent() 调用,先查账本(journal):
命中 → 直接返回上次的结果,不发请求,不花 token
未命中 → 真跑,跑完追加一条记录
③ 于是前 6 个 agent 秒过(查表),第 7 个开始真跑为什么是「重跑脚本 + 查表」而不是「保存执行位置」?
因为脚本里有大量普通 JS:
map/filter/ 变量、条件分支。要「从中间恢复」, 你得保存整个 JS 执行栈和所有局部变量 —— 那是 continuation 级别的工程,极难做对。而「重跑 + 查表」利用了一个关键前提:脚本是确定性的(这就是 §6.6 禁
Date.now()换来的东西)。确定性保证了重跑时执行路径完全一样、agent()调用序列完全一样, 于是查表能对上。这两章是因果绑定的:§6 的那条奇怪限制,买的就是 §7 这个便宜实现。
7.3 ★ 核心问题:缓存键用什么
这是本章唯一真正难的设计决策。有三个候选:
| 候选 | 怎么做 | 会出什么问题 |
|---|---|---|
| ① prompt 内容的 hash | 把 prompt 文本 hash 一下当键 | 两个不同调用点、prompt 恰好相同 → 串台(一个的结果被另一个错误复用) |
| ② 调用序号(第 1 个、第 2 个...) | 按 agent() 被调用的顺序编号 | 脚本被改过也照样命中 → 返回过时的结果 |
| ③ 序号 + 内容指纹 | 两者结合 | ✅ |
候选 ① 为什么会串台(📰 官方踩过:issue #63102)
想想这个脚本:
// 对 20 个文件,每个都问同一个问题
await pipeline(FILES,
f => agent(`审查这个文件`, { ... }), // ← 20 次调用,prompt 完全一样!
)如果键是 hash("审查这个文件"),那20 次调用的键完全相同。 第 1 次跑完写进账本,第 2-20 次全部「命中缓存」,直接复用第 1 个文件的结论。
结果:resume 之后你得到 20 份一模一样的报告,而且它看起来完全正常。
这个失效形态的可怕之处在于它静默:没有报错,20 份结果都在, 只是它们全是同一个文件的分析。这是典型的「绿着坏掉」(§17)。
候选 ② 为什么不够(脚本被改的场景)
用户改了第 5 个 agent 的 prompt,然后 resume。如果键只是序号:
callIndex=5 在账本里有记录 → 命中 → 返回旧结果用户改的那句话被完全忽略了。 他会以为「我改了 prompt 但没效果」, 实际上新 prompt 压根没被发出去。
候选 ③:🔬 sid-code 的实现
packages/core/src/workflow/journal.ts:37-53:
export function computeFingerprint(prompt, opts): string {
const relevant = {
prompt,
schema: opts?.schema ?? null,
model: opts?.model ?? null,
effort: opts?.effort ?? null,
agentType: opts?.agentType ?? null,
isolation: opts?.isolation ?? null,
};
return sha256(stableStringify(relevant)).slice(0, 16);
}键 = callIndex(调用序号,runtime 全局自增)+ fingerprint(内容指纹)。
查表逻辑(journal.ts:118-124):
lookup(callIndex, fingerprint) {
const entry = this.entries.get(callIndex);
if (!entry) return null; // 没跑过 → 真跑
if (entry.fingerprint !== fingerprint) return null; // 脚本改过 → 失效,真跑
return { result: entry.result };
}两个键各自负责一件事,缺一不可:
| 键的成分 | 负责解决 |
|---|---|
callIndex | 区分调用点 —— 20 次同 prompt 的调用序号不同,不会串台 |
fingerprint | 检测脚本改动 —— 同序号但内容变了,缓存失效 |
效果:同脚本同 args 重跑 → 100% 命中;改了第 N 个 agent → 前 N-1 命中、第 N 起真跑。
7.4 一个精细的设计细节:哪些字段不进指纹
看回上面那段代码 —— relevant 里没有 label 和 phase。 代码注释说明了原因:
只纳入影响「agent 会产出什么」的字段;
label/phase是展示用,不影响结果,排除。
为什么这个排除很重要? 想象你 resume 之前顺手改了个标签:
- agent(prompt, { label: 'scan:ci' })
+ agent(prompt, { label: '扫描 CI 日志' }) // 只是想让进度条好看点如果 label 进了指纹,这一改整个 run 的缓存全部失效,40 个 agent 全部重跑。 用户只是改了个显示文字,代价是几十万 token。
这是缓存键设计的通用原则,值得单独记住:
缓存键必须包含「所有影响输出的输入」,且只包含它们。
- 少包含 → 串台(§5.6 那个 schema 缓存 5.4%→51% 的坑,就是少包含了 schema)
- 多包含 → 无谓失效(这里的 label)
两个方向的错法都很常见,而且症状完全不同:少包含是「拿到了错的结果」, 多包含是「性能悄悄变差但结果对」。前者会被发现,后者往往永远不会。
7.5 存储:为什么用 append-only JSONL
🔬 journal.ts:128-140:每条记录一行 JSON,追加写,不改不删。
{"callIndex":0,"fingerprint":"a3f...","result":{...},"label":"review:bugs"}
{"callIndex":1,"fingerprint":"b7c...","result":{...},"label":"review:perf"}append-only 买到了三件事:
| 好处 | 说明 |
|---|---|
| 崩溃不损坏已写记录 | 追加写的最坏情况是「最后一行写了一半」,前面全部完好。如果是「读出整个 JSON、改、写回」,崩在写回中间就整个文件毁了 |
| 重跑时顺序回放即可重建 | 从头读到尾,往 Map 里塞,天然就是最新状态 |
| 同 callIndex 后写覆盖先写 | journal.ts:104 明确这个语义 —— 天然支持「重跑覆盖旧结果」 |
对最后一行「写了一半」的处理(journal.ts:105-108):
try { JSON.parse(trimmed) } catch { log.warn(...); /* 跳过这一行 */ }解析失败就跳过,不是整个账本作废。 最坏情况是丢一条缓存、多跑一个 agent。
这三条合起来是「append-only + 逐行容错」的经典组合, 在会话持久化、事件溯源、WAL 日志里反复出现。判断一个持久化设计是否靠谱, 一个快速检查就是问:「写到一半崩了,已有数据还在吗?」
7.6 一个必须点破的边界:失败不缓存
🔬 runtime.ts:186-196 的顺序值得注意:
try {
const result = await this.scheduler.run(() => this.runner.run(prompt, opts, ctx));
this.journal?.record({ callIndex, fingerprint, result, label }); // ← 只在成功后记
return result;
} catch (err) {
this.progress?.onAgentEnd?.(ctx, false);
throw err; // ← 失败不写 journal
}代码注释写明:「真跑成功后追加 journal(失败不缓存,下次重跑)」。
这个选择是对的,但要理解它的代价:
- ✅ 好处:一个因为临时原因(限流、网络抖动)失败的 agent,resume 时会重试。 如果把失败也缓存了,一次偶发的 429 会被永久记成「这个 agent 的结果是失败」。
- ⚠️ 代价:一个因为永久原因失败的 agent(prompt 本身有问题、schema 写错了), 每次 resume 都会重试一遍,每次都花掉那份 token。
判据:区分「瞬时失败」与「永久失败」需要语义理解,硬做容易做错。 选「不缓存失败」是把代价放在重复花钱上,而另一个选择把代价放在永久错误结论上。 前者可见可控,后者不可见 —— 所以这个默认值选对了(又一次「选那个错法容易被发现的」)。
7.7 一个附带能力:脚本可以迭代
resume 的机制顺手带来了一个很实用的开发流程 🔬(tool/workflow.ts:177 的工具描述原文):
返回的
script_path可编辑后用{script_path, resume_from_run_id}重跑, 已完成的 agent 走缓存。
于是:
第 1 次跑 → 40 个 agent 里前 35 个结果都不错,最后 5 个(综合阶段)写得不好
↓
编辑脚本,只改最后那 5 个 agent 的 prompt
↓
带同一个 runId 重跑 → 前 35 个秒过(查表),只重跑最后 5 个这把「调试一个编排脚本」的成本从「每次全量重跑」降到了「只重跑改动部分」。 类比一下:这就是编排层的增量编译。没有它,迭代一个 40-agent 的脚本 在经济上是不可行的(每次几十万 token)。
🔬 测试覆盖:tests/workflow/journal.test.ts 13 个用例。
7.8 本章自检
- resume 为什么是「重跑脚本 + 查表」而不是「保存执行位置」?它依赖什么前提?
- 缓存键用 prompt hash 会出什么问题?举一个具体脚本说明。
- 缓存键只用调用序号会出什么问题?
- 为什么
label和phase不进指纹?如果进了会怎样? - 缓存键设计的通用原则是什么?少包含和多包含的症状有什么区别?哪个更危险?
- append-only 买到了哪三件事?「写到一半崩了」会怎样?
- 为什么失败的 agent 不写进 journal?这个选择的代价是什么?
§8 并发、预算、规模:三道闸
编排一开就是几十个 agent。这一章讲三个必须有的限制,以及每个限制如果没有会出什么事。
8.1 第一道闸:并发上限(以及为什么必须是独立的池)
📄 spec 给的数字:
| 限制 | 值 |
|---|---|
| 并发 agent 数 | min(16, CPU核数 - 2) / 每个 workflow |
| 单次 run 的 agent 总数 | 1000(runaway-loop 后备闸) |
单个 parallel / pipeline 调用的 items | 最多 4096(超过是显式报错,不是静默截断) |
关键语义:你可以给 parallel 传 100 项,全部都会完成,只是同时只跑约 10 个, 其余排队等槽位。
🔬 为什么必须新建一个独立并发池(这一节是重点)
sid-code 的实现注释(workflow/scheduler.ts:3-13)把两个错误选项都写清了:
❌ 不复用 SubAgentTool.running(静态计数器,默认上限 3)
理由:那是给「模型即兴开 subagent」用的配额(L1 那一档)。
workflow 一次要开几十个 agent,共用会互相饿死 ——
workflow 占满 3 个槽,模型自己想开一个子代理时开不出来;
反过来模型占了槽,workflow 就跑不满。
❌ 不裸用 Promise.all(像 swarm/team.ts:93 那样)
理由:无背压。4096 个 item 会一次性全发出去,直接打爆 provider 限流。第一条是一个很好的「配额隔离」案例。 两个用途完全不同的消费者共享一个配额池时, 它们会互相饿死,而且症状会互相污染:你会看到「workflow 变慢了」和 「模型不开子代理了」这两个现象,但根因是同一个 —— 配额被对方占了。
判据:两个消费者的合理并发量差一个数量级时(这里是 3 vs 几十), 它们不该共享配额池。
实现:信号量 + FIFO 队列
🔬 workflow/scheduler.ts:47-101,代码短到可以整段读懂:
private acquire(): Promise<void> {
if (this.active < this.cap) { this.active++; return Promise.resolve(); }
// 槽位满 → 进 FIFO 队列,等 release 唤醒
return new Promise<void>((resolve) => {
this.waiters.push(() => { this.active++; resolve(); });
});
}
private release(): void {
this.active--;
const next = this.waiters.shift(); // FIFO:先进先出
if (next) next();
}
async run<T>(thunk: () => Promise<T>): Promise<T> {
await this.acquire();
try { return await thunk(); }
finally { this.release(); } // ← 关键:finally
}两个细节值得注意:
| 细节 | 为什么 |
|---|---|
finally { this.release() } | thunk 抛错时槽位一定要还。少了 finally,一个失败的 agent 会永久吃掉一个槽位 —— 跑几次之后并发度变成 0,整个编排静默挂死 |
| FIFO 而非 LIFO | 保证公平:先排队的先跑。LIFO 会让早期任务被无限推迟(饥饿) |
finally那条是并发原语里最经典的 bug 形态:泄漏的不是内存,是许可。 症状极具误导性 —— 系统一开始正常,跑一段时间后越来越慢,最后完全卡住, 而 CPU 和内存都看不出问题。🔬 sid-code 的测试(
tests/workflow/scheduler.test.ts,8 个用例)里有专门锁这一条的。
cap = min(16, CPU - 2) 这个公式在赌什么
- 2:给主进程和系统留两个核。子 agent 不只是等网络,它们还要跑工具 (读文件、grep、跑测试),这些是真吃 CPU 的。min(16, ...):即使有 64 核也不超过 16 —— 因为瓶颈在这之后就不是 CPU 了, 而是 provider 的限流。开 64 个并发只会换来一片 429。
🔬 sid-code 留了逃逸阀:SID_WORKFLOW_MAX_CONCURRENT 环境变量可覆盖(测试与调优用)。
8.2 第二道闸:agent 总数上限 1000
📄 这道闸的定位很明确:runaway-loop 后备闸,远高于任何真实 workflow。
🔬 sid-code 的报错信息(runtime.ts:95-99):
「单次 run 的 agent 调用已达上限 1000(runaway-loop 后备闸)。检查是否有未收敛的循环。」
为什么需要这道闸? 因为 §9.5 那个「循环直到枯竭」模式:
while (stillFindingNewThings) { // ← 停止条件由模型的判断决定
const found = await agent('还有没有漏掉的?')
stillFindingNewThings = found.length > 0
}如果模型每轮都说「还有」(可能是它在幻觉,也可能真的有), 这个循环永远不停。1000 这道闸把最坏情况从「烧到限流为止」变成「烧 1000 个 agent 就停」。
注意这道闸的性质:它不是业务限制,是失控半径限制。 一个正常的 workflow 用 20-100 个 agent;碰到 1000 说明脚本有 bug。 所以报错信息直接给出诊断方向(「检查是否有未收敛的循环」),而不是让你去调大这个数。
这类「后备闸」的设计原则:阈值要设得远高于正常值, 这样它一旦触发就是 100% 的 bug 信号,而不是「又要调参数了」。 如果一道闸经常被正常业务撞到,它就失去了诊断价值。
8.3 第三道闸:token 预算
📄 用户可以在 prompt 里说 use 10k tokens,系统据此设上限。 脚本里能读到一个 budget 全局:
budget.total // 本轮 token 目标;没设则 null
budget.spent() // 已花
budget.remaining() // max(0, total - spent());没设目标则 Infinity📄 spec 给了两种用法:
// 用法 1:按预算决定舰队规模
const FLEET = budget.total ? Math.floor(budget.total / 100_000) : 5
// 用法 2:循环里检查余量
while (budget.total && budget.remaining() > 50_000) { /* 再来一轮 */ }🔬 sid-code 的实现(runtime.ts:130-139 + 160-166):
// 硬门:达上限即抛(对齐 cc:spent 达 total 后 agent() 抛错)
if (this.budget.total !== null && this.budget.remaining() <= 0) {
throw new BudgetExceededError(this.budget.total, this.budget.spent());
}报错信息(runtime.ts:87):
「token 预算已耗尽(上限 X,已花 Y)。再调
agent()被拒;请收窄 workflow 规模或提高预算。」
两个设计点:
| 点 | 说明 |
|---|---|
| 软读 + 硬拦 两套并存 | budget.remaining() 让脚本主动收缩(优雅);硬门在脚本不看预算时强制拦住(兜底)。少了硬门,一个不读 budget 的脚本可以把预算当空气 |
| 池子是共享的 | 🔬 types.ts:49 注释:「本轮主循环 + 所有 workflow 的输出 token 之和(池子共享)」。子 workflow 不能靠嵌套绕开预算 |
「软读 + 硬拦」是一个值得推广的配对: 只有硬拦 → 脚本会在半路被砍断,产出一半的结果(很难用)。 只有软读 → 不合作的脚本完全无约束。 两个一起,才能既让好脚本优雅降级、又让坏脚本被拦住。
8.4 一个必须配套的规矩:截断了必须说
§3.6 和 §5.7 都提过,这里放进闸门的语境里再说一次,因为它是这三道闸的必要补充:
📄 沉默截断禁令:如果 workflow 因为预算 / 采样 / top-N 而限制了覆盖范围, 必须 log() 出丢掉了什么。
为什么这条属于闸门章节? 因为闸门必然导致截断:
4096 上限 → 5000 个文件只处理了 4096 个
budget 耗尽 → 40 个 agent 只跑了 23 个
1000 总数闸 → 循环被强行掐断每一次截断都会产生一份「看起来完整」的结果。 如果不 log, 一份基于 23 个 agent 的报告和一份基于 40 个的报告长得一模一样。
这三道闸和这一条禁令是一个整体:闸门保证系统不失控, log 保证你知道闸门生效了。只有闸门没有 log, 你得到的是一个「安全但会骗你」的系统 —— 这比不安全更糟, 因为你会基于不完整的结果做决定。
8.5 三道闸总表
| 闸 | 值 | 防的失控 | 触发时的正确反应 |
|---|---|---|---|
| 并发上限 | min(16, CPU-2) | 打爆 provider 限流 / 吃满本机 CPU | 正常,排队即可 |
| 单调用 items | 4096 | 一次性提交过大批次 | 显式报错,分批或用更粗粒度 |
| run 内 agent 总数 | 1000 | 未收敛的循环 | 查脚本 bug,不是调大阈值 |
| token 预算 | 用户指定 | 成本失控 | 收窄规模或提高预算 |
8.6 本章自检
- 为什么 workflow 必须有自己的并发池,不能复用「模型开子代理」那个配额?
- 信号量的
release()为什么必须在finally里?少了会出现什么症状? 为什么这个症状很难排查? cap = min(16, CPU - 2):- 2和min(16, ...)各在赌什么?- 1000 这道闸和 4096 那道闸性质有什么不同?(提示:一个是失控半径,一个是接口约束)
- 为什么 token 预算需要「软读 + 硬拦」两套?只有一套会怎样?
- 为什么说「有闸门但不 log 截断」比「没闸门」更糟?
§9 七种质量编排模式(带可运行代码)
前面八章讲的是机制(原语怎么用、闸门怎么设)。这一章讲策略: 拿到一个任务,你把 agent 摆成什么形状。
📄 官方给了 6 个模式,🔬 sid-code 的实测又逼出了第 7 个(§9.7,也是最重要的一个)。
读法建议:先看每个模式的「治什么病」,再看代码。模式不是花招, 每一个都在对付 §1 那三大通病里的某一条。
9.1 模式一:扇出与聚合(Fan-out-and-Synthesize)
治的病:懒惰(覆盖不全)。这是最基础也最常用的模式。
export const meta = {
name: 'security-audit',
description: '按检查项扇出安全审查,汇总为一份报告',
phases: [{ title: 'Audit' }, { title: 'Synthesize' }],
}
const CHECKS = [ /* 50 个检查项 */ ]
phase('Audit')
// 50 项 → 50 个 agent,每个只看自己那一项
const results = await parallel(
CHECKS.map(c => () => agent(`按这一项检查代码库:${c.desc}`, {
schema: FINDING_SCHEMA,
label: `audit:${c.id}`,
}))
)
phase('Synthesize')
const clean = results.filter(Boolean)
log(`50 项中 ${clean.length} 项返回有效结果,${50 - clean.length} 项失败`) // ← 沉默截断禁令
const report = await agent(`把这些发现合并成一份报告:${JSON.stringify(clean)}`)
return report为什么它治得住懒惰:每个 agent 的上下文里只有一项。 它没有「我已经做了很多了」的错觉可犯 —— 它的任务就是一项,做完就完了。 「50 项做到 35 项」这个失败模式在结构上消失了,因为 50 这个数字写在脚本里, 是个 for 循环的边界,不是模型的判断。
这里的 parallel 用得对吗? 对 —— 因为聚合阶段需要看到全部发现才能去重和排序, 符合 §4.3 允许屏障的第 ① 和 ③ 条。
9.2 模式二:对抗性验证(Adversarial Verification)
治的病:自我偏好偏差。这是七个模式里最重要的一个。
export const meta = {
name: 'verified-review',
description: '找问题,然后每条都由独立 agent 对抗性验证',
phases: [{ title: 'Find' }, { title: 'Verify' }],
}
const VERDICT = {
type: 'object',
required: ['verdict', 'evidence', 'refutation_attempted'],
properties: {
verdict: { enum: ['CONFIRMED', 'REFUTED', 'PARTIAL', 'UNVERIFIABLE'] },
evidence: { type: 'string' }, // file:line + 实际代码
refutation_attempted: { type: 'string' }, // 我是怎么尝试推翻它的
},
}
const results = await pipeline(DIMENSIONS,
// stage 1:找
d => agent(d.findPrompt, { schema: FINDINGS, phase: 'Find' }),
// stage 2:对这个维度找到的每一条,各派一个 agent 去推翻它
found => parallel(found.findings.map(f => () =>
agent(
`独立验证这条结论。你的任务不是确认它,是**尝试推翻它**。
不要信任提出者的推理,自己去读代码。
宣告 REFUTED 是有价值的产出,不是失败。
待验证:${f.title}(声称位于 ${f.file}:${f.line})`,
{ schema: VERDICT, phase: 'Verify', model: 'opus' } // ← 强模型验,见 §9.7
).then(v => ({ ...f, verdict: v }))
))
)
const confirmed = results.flat().filter(Boolean).filter(f => f.verdict?.verdict === 'CONFIRMED')
const refuted = results.flat().filter(Boolean).filter(f => f.verdict?.verdict === 'REFUTED')
log(`确认 ${confirmed.length} 条,推翻 ${refuted.length} 条误报`)
return { confirmed, refuted }三个要素缺一不可(🔬 这三条都是实测教训):
| 要素 | 少了会怎样 |
|---|---|
| 独立 agent、独立上下文 | 同上下文自验 = 自我一致压过自我批判(§1.2 通病二) |
| prompt 明确要求「尝试推翻」 | 「请验证」会被理解成「请确认」。🔬 sid-code 全仓曾 grep verify.*(refut|adversar|falsif) 零命中 —— verify 只是个没有灵魂的标签 |
| schema 强制 evidence + 允许 REFUTED | 见 §5.3:不强制举证就拦不住伪阳性 |
🔬 实测有效性证据(这段值得记住,因为它是「用编排验证编排」的活案例):
sid-code 那次评判两组 harness 的工作,本身就是用这个模式做的 —— 10 路并行 verify agent,每个负责一簇结论,用 VERDICT_SCHEMA 强制举证。 结果它当场抓出了 2 条误报,并校准了另一组 2-3 处严重度高估。
这条证据有两层含义,第二层更重要:
- 这个模式对「代码审计找 bug」类任务已被实测验证有效。
- 让它有效的不是那套 JS 引擎 / 沙箱 / 并发池 / resume, 而是两件正交的小事 ——(a)schema 强制举证且允许 REFUTED;(b)verify agent 被要求「尝试推翻」且跑在强模型上。
第 2 点直接改变了工程排期:这两件事和整个引擎地基完全解耦, 可以最先、最便宜地交付(用现有 subagent + 一段对抗 prompt 就能做, 不需要 JS 沙箱)。🔬 sid-code 因此开了一条「Skill 层快车道」, 把对抗验证提前到引擎之前交付。
教训:「治本的药不要排在地基后面。」一个工程路线图如果按依赖顺序排, 很容易把最有价值的能力排到最后 —— 而它可能根本不依赖那些地基。
9.3 模式三:分类并行动(Classify-and-Act)
治的病:用错资源(把简单任务喂给贵模型,或反之)。
// 先派一个便宜的分类器做前置调研
const kind = await agent(
'判断这个任务的复杂度:先看 auth 模块有几个文件、结构多复杂',
{ schema: { type:'object', required:['tier'], properties:{ tier:{ enum:['simple','complex'] } } },
model: 'haiku', effort: 'low' } // ← 分类器用最便宜的
)
// 再按分类路由
if (kind.tier === 'simple') {
return await agent('解释 auth 模块怎么工作', { model: 'sonnet' })
} else {
const parts = await parallel(MODULES.map(m => () => agent(`深挖 ${m}`, { model: 'opus' })))
return await agent(`综合:${JSON.stringify(parts.filter(Boolean))}`, { model: 'opus' })
}📄 官方给的场景很具体:
「解释 auth 模块是如何工作的」这一任务的最优模型,取决于 auth 模块里有多少文件、 代码库整体形态如何。分类器 agent 可以完成这项前置调研,然后按预期复杂度路由到 Sonnet 或 Opus。
这个模式的价值不在省钱,在于它承认了一件事: 「这个任务该用多大的模型」本身是一个需要看代码才能回答的问题。 静态配置(「审查任务一律用 opus」)没法做到这一点。
9.4 模式四:锦标赛(Tournament)
治的病:绝对打分不可靠(尤其是「品味」类判断)。
export const meta = { name:'name-tournament', description:'两两对比选出最佳命名', ... }
// 1. 生成候选
const candidates = (await parallel(
Array.from({ length: 16 }, (_, i) => () =>
agent(`为这个 CLI 工具想 1 个名字,风格方向 #${i}`, { schema: NAME }))
)).filter(Boolean)
// 2. 两两对比,逐轮淘汰(bracket 由脚本维护,不由模型维护)
let round = candidates
while (round.length > 1) {
const pairs = []
for (let i = 0; i < round.length; i += 2) pairs.push([round[i], round[i+1]])
const winners = await parallel(pairs.map(([a, b]) => () =>
a && b
? agent(`两个命名二选一,说明理由:A=${a.name} B=${b.name}`,
{ schema: { type:'object', required:['winner'], properties:{ winner:{enum:['A','B']} } } })
.then(r => r?.winner === 'A' ? a : b)
: Promise.resolve(a ?? b)
))
round = winners.filter(Boolean)
log(`本轮剩 ${round.length} 个候选`)
}
return round[0]为什么两两对比比绝对打分强(📄 明确指出的洞察):
「如果尝试用 1 个 prompt 对 1000 多行数据排序,质量就会下降,上下文也装不下。」 「对比性评判比绝对打分更可靠。」
原因:让模型给一个名字打 7.5 分,这个 7.5 没有稳定的锚 —— 换个上下文它可能打 6 或 9。 但「A 和 B 哪个更好」是一个有明确锚点的判断,稳定得多。
注意这个模式的关键结构(📄 原话):
每个对比都是一个独立 Agent,主循环(确定性循环)负责维护整个对战结构(bracket), 上下文里只保留当前的运行顺序。
bracket 由脚本维护是重点 —— 如果让模型记着「现在是第 3 轮、还剩 4 个」, 它会漂移。脚本记这个是零成本且零误差的。
9.5 模式五:循环直到枯竭(Loop Until Done)
治的病:固定 N 路扇出一样会漏。
这个模式的存在理由值得单独说,因为它来自一个 🔬 反例:
§9.1 那个扇出模式很好,但它假设「50 项」这个清单是完整的。 🔬 实测中,强模型(opus)也漏了一个死代码模块 —— 不是因为没铺开查, 是因为清单本身不完整。
结论:覆盖度需要的不止是「分头查」,还要「问还有什么没查」。
const found = []
let round = 0
while (round < 10) { // ← 硬上限,防不收敛(§8.2)
round++
const batch = await agent(
`继续找漏掉的问题。已经找到的这些**不要重复**:\n${found.map(f=>f.title).join('\n')}`,
{ schema: FINDINGS, label: `sweep#${round}` }
)
const fresh = (batch?.findings ?? []).filter(f => !found.some(x => x.title === f.title))
log(`第 ${round} 轮新增 ${fresh.length} 条`)
if (fresh.length === 0) break // ← 枯竭,停
found.push(...fresh)
}再加一道 completeness-critic(🔬 实测建议):
// 不问「还有没有」,而是派一个专门质疑覆盖度的 agent
const critic = await agent(
`这是已找到的清单:${JSON.stringify(found)}。
你的任务:指出**还有哪类问题根本没被查过**(不是补充同类,是指出盲区)。`,
{ schema: { type:'object', required:['blind_spots'], properties:{ blind_spots:{type:'array'} } } }
)两者的区别很关键:
while循环问的是「同类还有没有更多」, critic 问的是「有没有整类被忽略了」。前者治「查得不够深」,后者治「查得不够广」。 🔬 那个被两组都漏掉的死代码模块,属于后者。
注意这个模式必须配 §8.2 的总数闸:停止条件由模型判断, 所以它是最容易不收敛的模式。上面代码里的 round < 10 是脚本自己的闸, 1000 那道是 runtime 的兜底闸 —— 两层都要有。
9.6 模式六:生成与筛选(Generate-and-Filter)
治的病:创意类任务里「第一个想法就是最终答案」。
// 生成远超所需的数量
const ideas = (await parallel(
Array.from({length: 30}, (_, i) => () => agent(`提一个方案,角度 #${i}`, { schema: IDEA }))
)).filter(Boolean)
// 去重 → 用标准筛 → 只留通过的
const unique = dedupe(ideas) // 普通 JS
const judged = await pipeline(unique,
idea => agent(`按这套标准评估:${CRITERIA}\n方案:${idea.text}`, { schema: JUDGE })
)
return judged.filter(Boolean).filter(j => j.passes)📄 配套的一个场景(探索与品味):
让 Claude 探索一堆解决方案,并给审查 agent 一套关于「什么是好的解决方案」的标准。 当审查 agent 认为已经满足该标准时,任务即告完成。
9.7 ★ 模式七:跨模型验证(🔬 实测逼出来的,官方 6 个模式里没有)
治的病:弱模型验不出弱模型的错。
这是本章最重要的一个模式,而它不在官方那 6 个里面 —— 它是 🔬 sid-code 从实测数据里挖出来的。
问题的形状:
find 阶段:弱模型 A 产出一条误报
verify 阶段:弱模型 A 去验证它
↓
它验不出来。因为让 A 发现这条误报所需的能力,
恰好就是 A 当初缺的那个能力。
↓
结果:误报被盖上 CONFIRMED 的章,
而且现在它有「已通过独立验证」的背书 —— 比没验证更可信。「弱模型自我盖章」制造的是假信心,比没有验证更危险。
没验证的结论,读者会自己保持怀疑。带着
verdict: CONFIRMED和一段 evidence 的结论, 读者会直接采信。验证机制在这里成了误报的放大器。
解法:verify 阶段的 model 档位不是可选增强,是规定动作。
const results = await pipeline(ITEMS,
// find:便宜模型铺量
item => agent(item.findPrompt, { schema: FINDINGS, model: 'sonnet', effort: 'low' }),
// verify:必须换更强的模型
found => parallel(found.findings.map(f => () =>
agent(`尝试推翻:${f.title}`, { schema: VERDICT, model: 'opus', effort: 'high' })
))
)进一步的形态:多数表决(🔬 建议里提到的):
// 同一条结论,三个不同模型独立验,看票型
const votes = await parallel(['opus','sonnet','deepseek-v4-pro'].map(m => () =>
agent(`独立验证:${f.title}`, { schema: VERDICT, model: m })
))
const valid = votes.filter(Boolean)
const confirmCount = valid.filter(v => v.verdict === 'CONFIRMED').length
// 三票里两票以上 CONFIRMED 才算确认;分歧本身就是一个信号
const decision = confirmCount >= 2 ? 'CONFIRMED' : 'DISPUTED'
if (decision === 'DISPUTED') log(`⚠️ ${f.title}:模型间有分歧(${confirmCount}/3 确认)`)
DISPUTED这个状态很有价值,它捕捉的是「这条结论没有共识」—— 而「没有共识」既不是确认也不是推翻,它是一个需要人来看的信号。 把它折叠成 CONFIRMED 或 REFUTED 都会丢掉这个信息。一般化:投票机制的价值一半在结果,一半在分歧率。 分歧率高说明这类判断 本身就不可靠,不该自动化 —— 这是一个关于任务本身的元信息。
9.8 七个模式总表
| 模式 | 治的通病 | 关键结构 | 最易做错 |
|---|---|---|---|
| ① 扇出聚合 | 懒惰(覆盖不全) | 清单在脚本里,不由模型判断 | 忘了 log 失败/截断数 |
| ② 对抗验证 | 自偏 | 独立 agent + 要求推翻 + 强制举证 | prompt 写「请验证」而非「请推翻」 |
| ③ 分类路由 | 资源错配 | 便宜分类器 + 前置调研 | 分类器自己太贵,得不偿失 |
| ④ 锦标赛 | 绝对打分不可靠 | bracket 由脚本维护 | 让模型记比赛进度(会漂移) |
| ⑤ 循环枯竭 | 清单本身不完整 | 停止条件 + 两层上限闸 | 不收敛;只问「还有更多吗」不问「有没有盲区」 |
| ⑥ 生成筛选 | 第一个想法即答案 | 超量生成 + 标准筛 | 筛选标准太松,等于没筛 |
| ⑦ 跨模型验证 | 弱模型自我盖章 | verify 换更强的模型 | find 和 verify 同模型 → 假信心 |
9.9 组合是常态:一个真实脚本的形状
📄 官方的 /deep-research skill 内部就是多个模式的组合:
① 扇出:并发分发网络搜索(模式 ①)
② 逐项:获取每个源的内容(pipeline,无屏障)
③ 对抗:验证每条主张(模式 ②)
④ 聚合:综合成带引用的报告(屏障,需要全量)🔬 那次 10 路评判也是组合:扇出(10 簇)+ 对抗验证(强制举证)+ 跨模型(用强模型验弱模型的产出)。
实践建议:写脚本时先想「这个任务的三大通病里哪一条最痛」, 按那一条选主模式,再看要不要叠加。不要一上来就七个全上 —— 每叠一层都是成倍的 token。
9.10 十个落地场景(📄 官方给的,含非编程类)
📰 官方特别指出:「Workflow 有时在非技术性工作中甚至更为有用。」
| 场景 | 用哪些模式 | 一句话 |
|---|---|---|
| 代码迁移重构 | ①+②+worktree | Bun 用它把底层从 Zig 重写为 Rust |
| 深度研究 | ①+②+④ | 扇出搜索 → 取源 → 对抗验证 → 带引用综合 |
| 深度事实核查 | ①+② | 一个 agent 找出所有事实主张,每条派一个去核 |
| 大批量排序 | ④ | 80 份简历按岗位匹配度排序 |
| 记忆与规则沉淀 | ①+②+⑤ | 挖 50 次会话找出反复的修正 → 聚类 → 验证「这条规则能防真错吗」→ 写进 CLAUDE.md |
| 根因调查 | ①+② | 多个 agent 从互不相交的证据分别提假设,每个假设面对验证者+反驳者 |
| 大规模分流 | ①+③+隔离 | 支持队列分类去重后行动 |
| 探索与品味 | ⑥+④ | 设计 / 命名类,给审查 agent 一套「什么是好」的标准 |
| 轻量评测 | ①+worktree | 在 worktree 里跑 eval,再派对比 agent 打分 |
| 模型路由 | ③ | 分类器前置调研后决定用哪个模型 |
「大规模分流」那个场景里有一个安全模式必须单独说(📄 原话):
关键模式:隔离(Quarantine) —— 禁止读取不可信公开内容的 agent 执行高权限操作, 这些高权限操作改由另一个负责根据信息采取行动的 agent 执行。
这是 prompt injection 的结构性防御:读到恶意内容的 agent 没有执行权限, 有执行权限的 agent 没有读到原始恶意内容(只收到结构化的摘要)。
注意它和 §6 沙箱的区别:§6 防的是「脚本乱碰东西」, 这里防的是「被外部内容操纵的 agent 乱碰东西」。 前者是代码边界,后者是信任边界。两者都需要,互不替代。
这个模式的一般形式是「读的人不能写,写的人没读过原文」 —— 与传统安全里的权限分离同源,但在 agent 场景下它对付的是自然语言注入。
9.11 本章自检
- 扇出模式为什么能在结构上消灭「50 项做到 35 项」?(关键在那个 50 写在哪)
- 对抗验证的三个要素是什么?少了「prompt 明确要求推翻」会怎样?
- 🔬 那条「让 10 路评判有效的不是引擎而是两件小事」的结论, 对工程排期意味着什么?
- 为什么两两对比比绝对打分可靠?bracket 为什么必须由脚本维护?
- 「固定 N 路扇出一样会漏」——这个反例说明还需要什么?
while循环和 completeness-critic 各治什么? - 什么是「弱模型自我盖章」?为什么它比不做验证更危险?
- 多数表决里
DISPUTED这个状态为什么不该被折叠掉? - 隔离(Quarantine)模式防的是什么?它和 §6 的沙箱有什么区别?
§10 何时不要用,以及一个「必须默认关着」的功能怎么设计
前面九章都在讲怎么用。这一章讲反面 —— 而且它在工程上比前面九章加起来更容易出事。
原因很简单:编排是一个能自己烧掉几十万 token 的功能。 一个能自己花钱的功能,如果由模型自行决定何时启用,那你就把预算控制权交给了 一个会漂移的东西。
10.1 先说清成本量级
📰 官方原话(工程师 X 帖 + 博客):
Workflow 非常废 token,深度使用时会频繁触发 5 小时用量限制。 「尽管它会创造出极其出色的成果,但也会显著消耗大量 Token。」 「不是每个任务都需要使用 Workflow。」
粗算一下量级就明白了:
一个普通任务: 1 个上下文 ≈ 1×
L1 派 3 个子 agent: 1 + 3 个上下文 ≈ 3-5×
一个 20 路扇出 + 验证: 1 + 20 + 40 个 ≈ 30-60×
一个 40 路 + 跨模型三票: 1 + 40 + 120 个 ≈ 100×+而且这里有一个容易被忽略的乘数:每个子 agent 都要重新读文件、重新建立上下文。 20 个 agent 各读同一批文件,那批文件的 token 被付了 20 次。
上下文隔离的代价就是重复付费。 §3.3 说过隔离既防污染又省上下文 (中间过程不回流),这是真的;但建立那些上下文的成本是新增的。 净效果取决于「每个 agent 内部干了多少活」——干得多则摊薄, 干得少则纯亏(20 个 agent 各读 5 个文件只为回答一句话,那是最差的形态)。
10.2 📄 官方给的「不要用」清单
spec 里明确列了何时该用普通工具而不是编排:
| 不要用编排的情形 | 该用什么 |
|---|---|
| 单个已知目标的查找(知道文件 / 符号 / 值) | 直接搜。「别动 workflow」 |
| 短编程任务(改 bug、加函数) | 默认 harness 更高效 |
| 探查内容取决于前面看到什么 | L1:主 agent 边看边派 |
📄 反过来,该用的判据也很窄:
任务在多个独立项上扇出(多文件读、多测试跑、多候选验)→ 才编排。
注意「独立」这个词。如果 20 项之间互相依赖(第 2 项要用第 1 项的结论), 那它们不能并行,扇出没有意义。
10.3 ★ opt-in 门控:📄 五条允许清单
这是最该照抄的一个设计。📄 spec 把 opt-in 定义得极死 —— 只有满足以下之一,才允许调 Workflow 工具:
- 用户 prompt 里带了
ultracode关键字(有 system-reminder 确认)。 - 会话级 ultracode 开关已开(system-reminder 确认)。
- 用户用自己的话直接要求跑 workflow / 多 agent 编排 ("use a workflow"、"fan out agents"、"orchestrate this with subagents")。
- 用户调用的 skill / slash command 的指令里让你调 Workflow。
- 用户要求跑一个具名或已存的 workflow。
然后是那句最关键的话(📄 原文):
其他任何情况 —— 哪怕任务明显能从并行受益 —— 都不准调 Workflow。
第 3 条还有一个精细的补充:
必须是用户的措辞。一个「恰好适合 workflow」的任务不算。
这两句话值得反复读,因为它们违反了工程直觉。
正常的产品设计思路是「让好功能被更多地用到」。这里反过来: 即使系统判断这个任务用编排明显更好,也不准自己启用。
为什么?因为**「这个任务适合编排」的判断者是模型自己, 而这个判断的成本后果由用户承担**。让一个不付账的人决定花多少钱, 无论它判断得多准,这个结构都是错的。
这是一个「决策权必须跟着成本走」的设计原则, 而不是一个「模型判断力不够所以先限制着」的临时措施。 说清这个区别,是这一章面试价值最高的地方。
不满足这五条时,📄 规定的正确行为是:
要么用 Agent 工具开单个 subagent,要么简述「一个多 agent workflow 能做什么、 大概多少成本」,然后问用户要不要跑。
注意这个降级动作的形状:不是静默不做,也不是自己做了再说, 而是把选择权连同价格标签一起交给用户。
10.4 ultracode 的两种语义(📄 区别很重要)
| 形态 | 语义 |
|---|---|
prompt 内一次性 ultracode | 本轮强制走 workflow |
| 会话级 ultracode 开 | opt-in 变成常驻:默认对每个实质任务都编排 workflow,token 成本不再是约束,目标是最穷尽最正确的答案 |
会话级开启时还有一条 📄 值得注意的行为规定:
多阶段工作(理解 → 设计 → 实现 → 审查)往往是连跑数个 workflow(每阶段一个), 让主循环留在回路里。
「让主循环留在回路里」这句话是个重要的架构提示: 不要写一个巨型 workflow 把四个阶段全包了,而是每阶段一个 workflow, 阶段之间回到主会话。
理由:阶段之间需要人(或主循环)看一眼结果再决定下一步。 一个包办四阶段的巨型脚本,等于把「设计错了要不要重来」这个决策也交给了脚本 —— 而脚本不会重新考虑设计。
10.5 🔬 sid-code 用了一个不同的门控机制(这个对比很有教学价值)
sid-code 没有照抄 ultracode 关键字,它用的是另一套。
🔬 packages/core/src/tool/workflow.ts:105-110:
/** 延迟工具:默认不进首轮上下文,由 tool_search 按需调出(opt-in 门控) */
readonly shouldDefer = true;
readonly searchHint =
"workflow orchestrate multi-agent fan-out parallel pipeline 编排 工作流 多代理 并行 扇出 审计 迁移";机制:workflow 工具默认不出现在模型的工具列表里。 模型只有在「搜索工具」时用上面那些关键词命中,才能把它调出来。
两套机制的对比:
📄 cc 的 ultracode | 🔬 sid-code 的 shouldDefer | |
|---|---|---|
| 门控在哪 | 指令层(system prompt 规定「不准调」) | 上下文层(工具压根不在列表里) |
| 模型能违规吗 | 能(指令是软约束,模型可能无视) | 需要先主动搜索才能拿到 |
| 用户感知 | 显式:要打 ultracode | 隐式:说到「编排/扇出」这类词才可能被调出 |
| 附带好处 | — | 省首轮上下文(长尾工具不占位) |
| 代价 | 用户得知道这个关键字 | 门控强度取决于 tool_search 的召回,不是硬边界 |
这个对比的价值在于它展示了「同一个目标的两种实现层次」:
- 指令层门控:表达力强(能写「必须是用户的措辞」这种精细条件), 但强度软(依赖模型遵守)。
- 上下文层门控:强度硬(工具不在就是调不到), 但表达力弱(没法表达「用户自己说的才算」这种语义条件)。
理想是两层都上:上下文层挡住 90% 的随手调用,指令层处理剩下的精细条件。 只有其中一层都有明显缺口 —— 这是评估任何 agent 权限设计时可以直接用的一把尺子。
🔬 sid-code 另外还有三道成本硬闸(§8 讲过),它们不依赖模型遵守:
budget 预算硬门 + 单 run 1000 agent 上限 + 单调用 4096 items 上限这三道硬闸补上了
shouldDefer的软性缺口:即使模型不该调却调了, 它也烧不出天价。「软门控 + 硬成本闸」是一个比「只有软门控」健壮得多的组合 —— 因为它把「防止误用」和「限制误用的代价」分成了两件事, 而后者不依赖任何判断力。
10.6 一个附带的重要细节:workflow 的花费必须计入主会话
🔬 tool/workflow.ts:117-119 的注释点出了一个很容易漏的坑:
workflow 内每个子 agent 跑完把完整 usage 按其实际 model/provider 回写主会话 SessionState, 否则 workflow 烧的 token/费用完全不计入
/cost,costLimit 守卫对 workflow 失效。
这个坑的形状值得记住:一个开销最大的功能,如果它的计量走的是旁路 (子 agent 自己的 usage 没回流主会话),那么:
/cost显示的数字是错的(少报,而且是少报最大的那一块)costLimit这道守卫对最该守的东西失效而症状是「成本看起来很正常」—— 这是最坏的一种错法。
通用判据:任何新增的、会产生 API 调用的路径, 都必须问一句「它的 usage 回流到统一计量了吗」。 影子调用(标题生成、摘要、recall、子代理)是漏计的常发地。
10.7 本章自检
- 编排的成本量级大概是普通任务的多少倍?「重复付费」是怎么产生的?
- 什么情况下扇出没有意义?(提示:「独立」这个词)
- 📄 那句「哪怕任务明显能从并行受益也不准调」违反了什么工程直觉? 它背后的原则是什么?
- 为什么说这是「决策权跟着成本走」而不是「模型判断力不够」?
- 会话级 ultracode 开启时,为什么建议「每阶段一个 workflow」而不是一个巨型脚本?
- 指令层门控和上下文层门控各自的强项与缺口是什么?
- 「软门控 + 硬成本闸」为什么比「只有软门控」健壮?
- 如果 workflow 的 usage 不回流主会话,会出现什么症状?为什么这是最坏的错法?
第二部分 · 调度(Schedule)
到这里编排讲完了。下面换一个完全不同的子系统。
提醒一下 §0.1 的判据:如果一件事需要「当前进程活着」才成立,它就不是调度。 接下来五章讲的全部是「怎么让任务在没人看着的时候跑起来」。
§11 调度的三层架构:为什么必须分层
11.1 先看三个需求,它们看起来是一件事
- 「跑到 CI 过为止,每隔几分钟看一下。」
- 「每天早上 9 点跑一次 PR 审查。」
- 「有人提 PR 的时候自动审一遍。」
这三个都是「自动触发」,很容易被设计成同一套东西。但它们对基础设施的要求完全不同:
| 需求 1 | 需求 2 | 需求 3 | |
|---|---|---|---|
| 我的终端要开着吗 | 要(我就坐在这儿等) | 不要(我在睡觉) | 不要 |
| 我的电脑要开着吗 | 要 | 要(除非上云) | 不要(PR 什么时候提我不知道) |
| 触发源 | 时间 | 时间 | 外部事件 |
| 结果给谁看 | 我,立刻 | 我,明早 | 提 PR 的人 |
三列的答案完全不同 → 它们不能是同一套实现。
11.2 三层架构
📄 Claude Code 的调度是三套互相独立、分层清晰的机制,刻意不混杂:
┌──────────────────────────────────────────────────────────────┐
│ 主循环之上(独立调度器,不依赖任何会话) │
│ │
│ ┌────────────────────────┐ ┌────────────────────────────┐ │
│ │ 云端 Routines │ │ 本地守护进程 / Desktop Tasks │ │
│ │ 跑在服务商的云上 │ │ 跑在你自己的机器上 │ │
│ │ 触发:时间/API/GitHub │ │ 触发:仅时间 │ │
│ │ 存活:跨会话**跨机器** │ │ 存活:跨会话,**不跨机器** │ │
│ │ 最小间隔:1 小时 │ │ 最小间隔:1 分钟 │ │
│ └────────────────────────┘ └────────────────────────────┘ │
│ │ 到点时创建一个**全新的无头会话** │
└──────────────┼───────────────────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────────┐
│ 主循环之内(嵌入式调度,**需要会话开着**) │
│ /loop + cron_create / cron_list / cron_delete + ScheduleWakeup │
│ 调度器在**轮次间隙**检查 → 把 prompt 注入主循环消息队列 │
└──────────────────────────────────────────────────────────────┘11.3 逐层对照(这张表值得记住)
| 维度 | ① 会话内 | ② 本地守护进程 | ③ 云端 |
|---|---|---|---|
| 运行位置 | 当前会话进程内 | 本机常驻进程 | 服务商云 |
| 需要会话开着 | 是 | 否 | 否 |
| 需要本机开着 | 是 | 是 | 否 |
| 持久化范围 | 内存(可选写盘) | 本机磁盘 | 账户,跨机器 |
| 触发源 | cron / 动态间隔 | cron | cron / API / GitHub 事件 |
| 最小间隔 | 秒级可行 | 1 分钟 | 1 小时 |
| 触发时做什么 | 注入当前会话的消息队列 | fork 一个新的无头会话 | 在云上开一个新会话 |
| 典型用途 | 「跑到 CI 过为止」 | 「每天 9 点跑审查」 | 「有人提 PR 就审」 |
11.4 ★ 最关键的一行:触发时做什么
上表最后一行是三层的本质区别,也是最容易被忽略的地方:
① 会话内: 把 prompt 塞进**当前会话**的消息队列
→ 沿用当前上下文、当前 cwd、当前权限状态
→ 你能立刻看到它在跑
②③ 上面两层:**起一个全新的会话**
→ 空白上下文、需要显式指定 cwd、**没有人能回答权限询问**这一行差异导出了后面三章的全部难题:
| 因为要「起新会话」 | 所以需要 |
|---|---|
| 新会话没有「当前目录」 | 任务里必须显式记录 workspaceDir(§15.4) |
| 新会话没人能点「同意」 | 需要一套预授权权限模型(§15.5) |
| 新会话与旧会话可能同时在跑同一个任务 | 需要锁来防双触发(§15.3) |
| 停机期间错过的触发怎么办 | 需要 catch-up 语义(§14) |
这就是为什么「调度」不能只是「给委托加一个 delay 参数」(§0.1 那个概念错误)。 一个
delay参数解决的是「什么时候执行」, 但真正的工作量全在「在一个没有上下文、没有目录、没有人在场的环境里, 怎么把一个 agent 正确地跑起来」。时间只是触发器,宿主才是难点。
11.6 一个真实的时区坑(📰 issue #49991)
云端调度有一个已知问题值得记住:
Routines 以 UTC 执行,建议显式用 UTC 表达时间。
为什么这个坑特别容易踩:会话内调度用的是本地时区(0 9 * * * 就是你的 9 点), 云端用 UTC。同一个 cron 表达式在两层里含义不同。
一般化的教训:任何跨越「本地」与「远端」边界的时间表达, 时区必须显式写出,不能依赖默认。这个坑在 CI 配置、数据库定时任务、 报表调度里反复出现,症状永远是「差了 8 小时」。
11.7 本章自检
- 三层调度各自的「需要会话开着 / 需要本机开着」是什么组合?
- 触发时「注入当前会话」和「起一个新会话」的区别,导出了哪四个工程难题?
- 为什么说「时间只是触发器,宿主才是难点」?
- 云端调度看起来最强,它的硬约束是什么?
- 同一个
0 9 * * *在会话内层和云端层含义为什么可能不同?
§12 cron 的六个工程细节:每一个都有踩坑史
「定时执行」听起来是最没技术含量的功能:记下时间,到点了跑。 这一章讲的是那些「到点了跑」之外的东西 —— 它们占了实现的 90%。
12.1 细节一:cron 表达式与本地时区
标准 5 字段:分 时 日 月 周。
0 9 * * * 每天 9:00
*/5 * * * * 每 5 分钟
0 9 * * 1-5 工作日 9:00
57 8 * * * 每天 8:57 ← 为什么是 57?见 §12.2📄 一个明确的语义决定:cron 用用户的本地时区。 0 9 * * * 就是「你的 9 点」,不需要做时区换算。
这个决定值得点评一下,因为它和云端层不一致(§11.6)。
本地时区对用户友好(说 9 点就是 9 点),但一旦任务要迁到云上, 同一个表达式含义就变了。这是一个「局部最优但全局不一致」的设计 —— 没有完美解,但必须在文档里写清哪一层用哪个时区, 否则用户会在两层之间搬任务时踩坑。
12.2 ★ 细节二:抖动(jitter)—— 为什么不该选整点
问题:所有用户都说「每天早上 9 点」,于是全球所有任务在 09:00:00 同时发起。
08:59:59 ████ 空闲
09:00:00 ████████████████████████████████████ ← 全球峰值
09:00:01 ████ 空闲对服务商是一个瞬时尖峰,对用户则是那一刻全都变慢或被限流。
解法:确定性抖动。 🔬 sid-code 的实现(cron/parser.ts:207-232):
// 用 taskId 哈希生成 [0,1) 的确定性抖动因子
const factor = hashString(taskId) / 0xffffffff; // FNV-1a
// 周期估算:下一次与再下一次的间隔
const periodMs = nextNext - next;
// 抖动上限:周期的 10%,但不超过 15 分钟
const maxJitter = Math.min(periodMs * 0.1, 15 * 60_000);
return next + Math.floor(factor * maxJitter);三个设计点,每个都有讲究:
| 点 | 为什么 |
|---|---|
用 taskId 哈希,不用 Math.random() | 抖动必须确定性:同一个任务每次算出的偏移一样。用随机数的话,任务会在 9:00-9:15 之间乱跳,用户完全无法预期 |
| 上限是周期的 10% | 一个每 5 分钟的任务,抖动最多 30 秒(不然就乱了节奏);一个每天的任务,抖动可以到 15 分钟 |
| 绝对上限 15 分钟 | 一个每月一次的任务,10% 是 3 天 —— 那就不叫抖动了 |
还有一个「人这一侧」的抖动,📄 spec 把它写成了一条明确的行为规范:
当用户的要求是模糊的时候,选一个不是 0 或 30 的分钟。
- 「每天早上 9 点左右」 →
57 8 * * *或3 9 * * *(不是0 9 * * *)- 「每小时」 →
7 * * * *(不是0 * * * *)- 「一小时后提醒我」 → 就用算出来的那个分钟,别凑整
只有用户明确说了那个精确时刻并且确实是那个意思时(「9:00 整」「半点」、 要和会议对齐),才用 0 或 30。
这条规范很有意思,因为它是「让 LLM 承担一部分负载均衡责任」。
系统层的 jitter 已经在做打散了,但用户表达的模糊性是一个额外的信息源: 「9 点左右」这句话本身就授权了系统偏移几分钟。 把这个授权用掉,比在系统层硬打散更好 —— 因为用户完全无感(他真的不在乎 8:57 还是 9:00)。
一般化:当输入是模糊的,就把模糊度当成可用的自由度,而不是舍入成整数。 「凑整」是一个默认的坏习惯,它把所有模糊输入折叠到同一个点上。
12.3 细节三:心跳间隔的取舍
调度器每隔多久醒来看一眼?🔬 sid-code 的默认(cron/types.ts:45-48):
export const DEFAULTS = {
checkIntervalMs: 30_000, // 30 秒检查一次
maxAgeDays: 7, // 循环任务最多存活 7 天
}为什么是 30 秒(这是一个三方权衡):
| 间隔 | 触发精度 | 空转开销 |
|---|---|---|
| 1 秒 | 极准 | 每天醒 86400 次,纯浪费 |
| 30 秒 | ±30 秒 | 每天 2880 次,可忽略 |
| 5 分钟 | ±5 分钟 | 极省,但「9:00 的任务 9:04 才跑」体感差 |
关键前提:cron 的最小粒度是 1 分钟。所以检查间隔只要小于 1 分钟 就能保证不漏掉任何一分钟 —— 30 秒是「保证不漏 + 尽量少醒」的一个自然选择。
注意 ±30 秒的精度是设计内的,不是缺陷。 一个「每天 9 点跑代码审查」的任务, 9:00:15 跑起来和 9:00:00 跑起来没有任何区别。 调度器不是实时系统,把它当实时系统去优化精度是在优化错误的东西。
12.4 细节四:会话内层的注入时机(一个容易忽略的正确性问题)
会话内调度触发时要把 prompt「注入主循环」。但不能随时注入。
🔬 packages/cli/src/app.ts:5185-5195:
/**
* Cron 触发:把调度的 prompt 注入主循环。
* REPL 忙时(busy 或无注入器)先入队,待空闲再冲刷,避免污染当前轮上下文。
*/
async enqueueScheduledPrompt(prompt: string): Promise<void> {
if (this.busy || !this.promptInjector) {
this.scheduledPromptQueue.push(prompt); // 忙 → 排队
return;
}
await this.promptInjector(prompt); // 空闲 → 立刻注入
}为什么不能在忙的时候注入? 因为「忙」意味着模型正在一个多轮任务的中间。 这时插一句「跑一下 CI 检查」,会:
- 污染当前轮的上下文 —— 模型正在想 A,突然多了一句 B
- 可能让模型把两件事混起来做
- 破坏当前任务的目标专一性(正是 §1 通病三要避免的)
📄 官方对这一点的表述是「任务只在 REPL 空闲时触发(不在查询中途)」。 「轮次间隙」这个时机不是实现细节,是正确性要求。
🔬 一个已修复的静默丢失 bug(这一段很值得读)
app.ts:5197-5220 那段注释记录了一个真实修复:
/** 冲刷忙时积压的调度提示词(空闲时调用) */
private async flushScheduledPrompts(): Promise<void> {
// 静默-4:splice(0) 先把整个队列出队,若 promptInjector 中途抛错,原代码无 try/catch
// → 剩余(含当前)prompt 已脱离队列、直接丢失,且无任何日志
// (Cron 定时注入的任务凭空消失)。
const pending = this.scheduledPromptQueue.splice(0);
for (let i = 0; i < pending.length; i++) {
try {
await this.promptInjector(pending[i]);
} catch (e) {
// 把失败的这条 + 尚未处理的剩余 prompt 放回队首,避免丢失;下次 flush 重试
this.scheduledPromptQueue.unshift(...pending.slice(i));
log.warn("CRON", `定时 prompt 注入失败,已重新入队 ${pending.length - i} 条待重试`);
break;
}
}
}bug 的形状值得完整理解:
① splice(0) 把队列里 5 条 prompt 全部取出,队列现在是空的
② 循环处理,第 2 条注入时抛错
③ 异常向上传播 → 函数退出
④ 第 2、3、4、5 条 prompt 在哪?—— 它们在那个局部变量 pending 里,
而 pending 随着函数退出被 GC 了
⑤ 队列是空的,没有日志,没有报错给用户
→ 4 个定时任务凭空消失,而且永远查不出来这个 bug 的通用形状是「先出队,再处理」在异常路径下的数据丢失。 它在消息队列、任务队列、批处理里极其常见。三条防御:
- 每项独立 try/catch,一项失败不影响其他项
- 失败的项放回队列(这里
unshift到队首)- 一定要有日志 —— 静默丢失是最难排查的错法, 因为「任务没跑」和「任务从来没被创建」在事后看起来一模一样
注意那个变量名叫「静默-4」—— 说明这是一次系统性的「静默失败」排查里的第 4 项。 成体系地去找「会静默吞掉东西的地方」是一种很有效的排查方法。
12.5 细节五:过期回收(7 天)
🔬 cron/scheduler.ts:193-201:
// - 交互式会话级 / 普通循环任务:超过 maxAgeDays 自动过期删除(对齐 cc 7 天)
// - 守护进程的 durable 任务:不自动过期(无人值守场景就是要长期跑),
// 只能手动 cron_delete
const maxAgeMs = DEFAULTS.maxAgeDays * 24 * 60 * 60 * 1000;
const durableNeverExpires = this.opts.daemonMode && task.durable;
const isAged = task.recurring && !durableNeverExpires && now - task.createdAt >= maxAgeMs;为什么循环任务要自动过期? 因为「每 5 分钟检查一次 CI」这类任务, 用户创建时的心理预期是「跑到我这件事做完」,不是「跑到世界末日」。 没有过期机制的话,被遗忘的任务会一直烧钱。
📄 官方的行为也一致:「循环任务 7 天后自动过期 —— 最后触发一次,然后删除。 这限定了会话的生命周期,创建时要告知用户这个 7 天上限。」
但守护进程的 durable 任务是例外(🔬 明确拍板的一个决定): 无人值守场景的语义就是「长期跑」。「每天 9 点跑审查」这个任务, 7 天后自动消失显然不对。
这是一个「同一机制在两种宿主下需要不同默认值」的好例子。 判据是用户创建它时的意图时长: 会话内任务的语义是「陪我这一阵」,守护进程任务的语义是「长期替我值班」。
如果只实现一套,无论选哪个都有一半场景是错的 —— 而这种「看起来是同一个功能,但两种用法的合理默认值相反」的情况, 是配置项该存在的典型理由。
12.6 细节六:锁 —— 防止同一任务被重复触发
问题:你开了 3 个终端窗口,每个都是一个 sid-code 会话,每个都读同一个 scheduled_tasks.json。到点时那个任务会被触发 3 次。
🔬 cron/lock.ts 的解法:一个锁文件,记录持有者 PID。
interface LockContent { sessionId: string; pid: number; acquiredAt: number; }
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0); // 信号 0:只探活,不真发信号
return true;
} catch (err: any) {
return err?.code === "EPERM"; // ← 注意这一行
}
}两个必须理解的细节:
(1) kill(pid, 0) 是探活的标准手法
信号 0 不做任何事,只走一遍「这个进程存在吗、我有权限吗」的检查。 存在则返回,不存在则抛 ESRCH。
(2) EPERM 必须当作「存活」
这一行 return err?.code === "EPERM" 很容易写错。EPERM 意思是 「这个进程存在,但你没权限给它发信号」(比如它属于另一个用户)。
ESRCH → 进程不存在 → 锁是残留的,可以抢 ✅
EPERM → 进程存在但没权限 → 锁是有效的,不能抢 ✅如果把
EPERM也当成「不存在」,就会把一个活着的持有者的锁抢掉 → 双触发。 这是一个「异常码分类写错就导致锁失效」的经典坑, 而它只在多用户环境下才会暴露 —— 单用户开发机上永远测不出来。
(3) 🔬 锁文件为什么放在项目目录,不放 HOME
cron/lock.ts:10-19 的注释写得非常清楚,值得完整读:
锁的语义本身就是「同一项目目录下的多个会话之间协调」,因此锁必须与项目目录绑定 —— 两个不同项目的会话各自独立调度,互不阻塞。
把锁搬到
~/.sid-code/反而需要按项目哈希再隔离一遍,是多此一举且易错 (子目录启动会哈希成不同键 → 双调度器 → 持久任务被重复触发)。
括号里那个失效路径值得单独品味:如果按「项目路径的哈希」来隔离锁, 那么在
/repo和/repo/packages/core里启动的两个会话会算出不同的哈希, 于是各自拿到一把锁,各自驱动调度器 —— 同一个任务被触发两次。而把锁文件放在项目目录里,「哪个项目」这个信息是由文件位置天然携带的, 不需要计算,也就不会算错。
一般化:能用「位置」表达的身份,不要用「哈希」表达。 位置是唯一且自解释的,哈希需要一个正确的规范化步骤(这里是「找到项目根」), 而那个步骤就是 bug 的藏身处。
(4) fail-closed:不确定时不抢锁
lock.ts:44 的注释:「成功返回 true;锁被存活进程持有返回 false (fail-closed:不确定时不抢锁)」。
调度里 fail-closed 是唯一正确的方向,因为两种错法的代价不对称:
- fail-closed 错了 → 任务没跑(可以靠 catch-up 补,或者下一个周期再跑)
- fail-open 错了 → 任务跑了两次(如果任务有副作用 —— 发邮件、提 PR、 改文件 —— 这是不可撤销的)
「没做」通常可修,「做了两次」经常不可修。 这就是为什么调度、支付、 消息投递这类系统的默认姿态都是保守的。
12.7 一个补充:会话内层还有一道 in-flight 保护
🔬 cron/scheduler.ts:164 与 :185-191:
if (this.inFlight.has(task.id)) continue; // 上一次还在跑 → 本轮跳过
...
this.inFlight.add(task.id);
try { this.fireTask(task); }
finally { this.inFlight.delete(task.id); } // ← 又是 finally防的是什么:一个每分钟触发的任务,如果单次执行要 3 分钟, 那么第 2、3 分钟的触发会叠加上来 —— 三个实例同时跑同一件事。
注意这里又出现了
finally(和 §8.1 并发池那个同源): 少了 finally,一次抛错就会让task.id永久留在inFlight里, 这个任务从此再也不触发了,而且没有任何报错。「获取了某个标记,必须在 finally 里释放」是一条无例外的规矩。 无论那个标记是信号量、锁、in-flight 集合,还是一个布尔标志位。
12.8 六个细节总表
| 细节 | 核心决定 | 关键教训 |
|---|---|---|
| ① 时区 | 会话内用本地时区 | 跨本地/云边界时必须显式写时区 |
| ② 抖动 | taskId 哈希,周期 10% 且 ≤15 分钟 | 抖动必须确定性;模糊输入不要凑整 |
| ③ 心跳 | 30 秒 | 小于最小粒度即可,不必追精度 |
| ④ 注入时机 | 只在轮次间隙注入,忙则排队 | 「先出队再处理」在异常路径会静默丢数据 |
| ⑤ 过期 | 会话级 7 天,守护进程 durable 永不 | 同一机制在两种宿主下默认值相反 |
| ⑥ 锁 | 项目目录 + PID 探活 + fail-closed | EPERM 算存活;能用位置表达身份就别用哈希 |
🔬 测试覆盖:tests/cron/ 共 45 个用例(scheduler 19 / interval 10 / describe 9 / catchup 7)。
12.9 本章自检
- 为什么抖动必须用
taskId哈希而不能用Math.random()? - 为什么抖动上限要同时有「周期 10%」和「绝对 15 分钟」两个约束?
- 📄 那条「模糊输入不要选 0 或 30 分」的规范,背后的一般化原则是什么?
- 心跳 30 秒的取舍依据是什么?为什么 ±30 秒的精度不是缺陷?
- 为什么定时 prompt 不能在 REPL 忙的时候注入?
- 「先 splice 出队,再循环处理」这个写法在异常路径会怎样?三条防御分别是什么?
- 为什么守护进程的 durable 任务不自动过期,而会话级任务要 7 天过期?
kill(pid, 0)返回EPERM时该判定为存活还是死亡?判错会怎样?- 锁文件为什么放项目目录而不是 HOME + 路径哈希?那个失效路径是什么?
- 为什么调度系统的默认姿态必须是 fail-closed?
§13 动态自定步:一个「300 秒最不该选」的精妙取舍
13.1 cron 表达不了的那类需求
cron 擅长「固定节奏」:每天 9 点、每 5 分钟。但有一类需求它表达不了:
「跑到 CI 过为止。」
问题在于你不知道要等多久。CI 可能 3 分钟,可能 20 分钟,可能挂了永远不过。 用 cron 写只能瞎猜一个间隔:
*/1 * * * * 每分钟查一次 → CI 要跑 20 分钟,你白查了 19 次,每次都花 token
*/10 * * * * 每 10 分钟查 → CI 3 分钟就过了,你多等了 7 分钟真正需要的是:让模型自己决定下次什么时候看。 这就是 ScheduleWakeup (🔬 sid-code 的 schedule_wakeup 工具)。
13.2 机制:一次性的相对延迟唤醒
🔬 packages/core/src/tool/schedule-wakeup.ts:88-99:
const clamped = Math.min(MAX_DELAY_S, Math.max(MIN_DELAY_S, Math.round(params.delay_seconds)));
const task: CronTask = {
id: shortId(),
cron: "", // ← 相对延迟唤醒不使用 cron 表达式
prompt: params.prompt,
createdAt: now,
recurring: false, // ← 一次性
durable: false, // ← 会话级(动态轮询绑定当前会话)
fireAt: now + clamped * 1000, // ← 绝对触发时刻,绕过 cron 解析
};用法是「每次唤醒后自己再排下一次」:
第 1 次唤醒 → 查 CI → 还没过 → 调 schedule_wakeup(delay=270s) → 睡
第 2 次唤醒 → 查 CI → 还没过 → 调 schedule_wakeup(delay=270s) → 睡
第 3 次唤醒 → 查 CI → 过了 → 不再调 → 循环自然结束注意 recurring: false:它是一次性的,触发后自删。 「继续轮询」这件事必须由模型主动再调一次来表达。
这个设计选择很关键:如果做成
recurring: true,那「什么时候停」就变成了 一个需要显式删除任务的动作 —— 而模型很可能忘了删。做成一次性 + 主动续期,把默认值从「一直跑」改成了「跑一次就停」。 这样「忘了」的后果是循环提前结束(可修),而不是永远跑下去(烧钱)。 又是一次「选那个错法代价更小的默认值」。
三个字段的组合值得看清楚(🔬 cron/types.ts 的设计):
| 场景 | recurring | durable | fireAt | cron |
|---|---|---|---|---|
| 每天 9 点(跨会话) | true | true | — | 0 9 * * * |
| 每 5 分钟(本会话) | true | false | — | */5 * * * * |
| 动态唤醒 | false | false | 绝对时刻 | 空串 |
| 「3 点提醒我」(一次性) | false | true | — | 0 15 <日> <月> * |
13.3 ★ 那个 300 秒陷阱(本章最值钱的一节)
📄 spec 里有一段关于「怎么选 delay_seconds」的指导,它非常精妙, 而且是一个跨层的性能推理。原文的要点:
这个会话的请求用的是默认 5 分钟 prompt 缓存 TTL。 睡过 300 秒意味着下次唤醒时读整个上下文都不命中缓存 —— 更慢也更贵。所以自然的分界是:
- < 5 分钟(60s–270s):缓存还热。适合主动轮询那些系统无法通知你的外部状态 (CI、部署、远端队列)。
- 5 分钟到 1 小时(300s–3600s):付一次缓存 miss。适合「反正早看也没用」的等待。
不要选 300s。 它是两头都不占便宜的那个值:你付了缓存 miss 的代价, 却没有把它摊薄。如果你想「等 5 分钟」,要么降到 270s(留在缓存里), 要么直接提到 1200s 以上(一次 miss 换一段长得多的等待)。
不要按「整数分钟」思考,要按「缓存窗口」思考。
为什么这个推理值得单独学
先把机制说清:prompt 缓存是按前缀命中的 —— 你的 system prompt + 工具定义 + 历史消息,只要前缀一字不变,服务端就能复用已算好的 KV。它有一个 TTL(这里约 5 分钟)。
睡 270 秒醒来 → 前缀还在缓存里 → 只算新增的那点 token → 快且便宜
睡 301 秒醒来 → 缓存已过期 → 整个上下文重算一遍 → 慢且贵关键在于「重算一遍」的成本是固定的(假设上下文 50k token,那就是 50k 的 input)。 所以:
睡 300s:付 1 次 miss,换来 300 秒的等待 → 每秒等待的成本 = 50k / 300
睡 1200s:付 1 次 miss,换来 1200 秒的等待 → 每秒等待的成本 = 50k / 1200 ← 便宜 4 倍
睡 270s:付 0 次 miss → 免费这就是「摊薄」的含义:既然要付那笔固定成本,就应该用它买尽可能长的等待。 300 秒是刚刚越过收费线、却几乎没买到什么的那个点 —— 最差的选择。
一般化:任何「越过某个阈值就要付一笔固定代价」的系统里, 阈值附近的取值都是最差的。 要么留在阈值内,要么远远越过去把成本摊薄。 这个形状在缓存、批处理、连接复用、冷启动里到处都是。
面试价值:这道推理能同时展示三件事 —— 你知道 prompt 缓存是前缀匹配的、 你知道它有 TTL、你能把它折成一个可操作的选值规则。这是一个很好的深度信号。
🔬 sid-code 把这段推理写进了工具描述里(schedule-wakeup.ts:13-16 + 59-61):
取舍提示:prompt 缓存 TTL 约 5 分钟。睡过 300s 会付一次缓存 miss, 建议要么 <270s(缓存还热)、要么 ≥1200s(一次 miss 换更久等待),避开 300s 附近。
注意这个做法本身:这段知识的消费者是模型,所以它被写在工具描述里, 而不是写在给人看的文档里。 §6.7 说过「给 LLM 用的 API,报错信息是 API 的一部分」—— 这里是同一个原则的另一面:工具描述是给模型的文档,取舍指导应该写在那儿。
13.4 钳制 [60, 3600]:两端各防什么
🔬 schedule-wakeup.ts:30-32 + :89:
const MIN_DELAY_S = 60; // 下限
const MAX_DELAY_S = 3600; // 上限
const clamped = Math.min(MAX_DELAY_S, Math.max(MIN_DELAY_S, Math.round(params.delay_seconds)));| 边界 | 防什么 | 注释原文 |
|---|---|---|
| 下限 60s | 高频空转 | 「避免高频空转」—— 每 10 秒醒一次去查 CI,纯烧钱 |
| 上限 3600s | 用错工具 | 「单次唤醒最长 1 小时,更久的周期任务应走 cron_create」 |
上限那条的理由值得注意:它不是「技术上做不到更久」, 而是**「超过 1 小时说明你该用另一个工具了」**。
这是一个「用限制来引导正确用法」的例子。 如果允许
delay_seconds = 86400,模型就会用schedule_wakeup去做 「每天跑一次」这种事 —— 而那是cron_create的活(能持久化、跨会话、有 catch-up)。 一个会话级的一次性唤醒撑不起「每天」这个语义(关掉终端就没了)。把上限设在「语义开始不匹配的地方」,比设在「技术极限」更有价值。
钳制还要告知用户(🔬 :101-104):
const clampNote = clamped !== Math.round(params.delay_seconds)
? `(已从 ${Math.round(params.delay_seconds)}s 钳制到 ${clamped}s)` : "";静默钳制是个坏习惯:模型请求 10 秒、系统给了 60 秒,如果不说, 模型会以为它在每 10 秒轮询,从而对「查了几次」产生错误认知。 凡是「我改了你的输入」,都要说出来。
13.5 一个配套细节:reason 字段
🔬 工具的入参里有一个可选的 reason:
reason: z.string().optional().describe("一句话说明为何选这个延迟(如「等 CI 跑完约 4 分钟」)")它不影响任何执行逻辑。它的作用是让用户看懂系统在干什么:
没有 reason: 「已安排 270s 后唤醒一次」 ← 用户:为什么是 270?
有 reason: 「已安排 270s 后唤醒一次
理由: CI 通常跑 4 分钟,留在缓存窗口内」 ← 用户:合理这和 §5.4 那个
refutation_attempted是同一个手法: 用一个必填/可选的「理由字段」把一个内部判断变得可观测。附带的好处是它对模型也有约束力 —— 要写出理由,就得真的有个理由, 而不是随手填个数字。要求解释会提升决策质量,这在 agent 设计里反复有效。
13.6 什么时候不该用动态唤醒(一条重要的反面规则)
📄 spec 里有一条很容易被忽略的告诫:
不要用短间隔唤醒去轮询你自己启动的后台工作 —— 当框架能追踪的工作完成时, 你会被自动重新唤起,所以轮询是浪费。 应该改成排一个长的兜底(1200s 以上),以防那个工作卡住或永不通知。
例外是框架追踪不到的外部工作(一次 CI 运行、一次部署、一个远端队列)—— 那里应该按「那个状态实际变化的速度」来选延迟。
这条规则划出的边界是「谁能通知我」:
- 本地后台任务 → 有回调(进程退出会通知)→ 轮询是纯浪费,只需一个长兜底
- 外部系统 → 没有回调(GitHub 不会主动告诉你 CI 好了)→ 必须轮询
判断该不该轮询,先问「有没有人会主动告诉我」。 这条在很多地方通用 —— 有 webhook 就别 poll,有 inotify 就别扫目录。
13.7 本章自检
- 为什么 cron 表达不了「跑到 CI 过为止」?
- 动态唤醒为什么做成一次性(
recurring: false)而不是循环? 「忘了续期」和「忘了删除」哪个后果更轻? - 完整解释为什么 300s 是最差的选择。「摊薄」在这里指什么?
- prompt 缓存是按什么匹配的?为什么睡过 TTL 会「整个上下文重算」?
- 上限 3600s 的理由是「技术做不到」还是别的?
- 为什么钳制之后必须告知用户?
- 什么时候不该用短间隔轮询?判断依据是什么?
§14 catch-up:睡了 6 天醒来,补跑几次?
这一章只讲一个语义决策。它看起来很小,答错了会造成雪崩。
14.1 问题
你有一个任务:0 9 * * *(每天 9 点跑一次代码审查)。 你的守护进程因为电脑关机停了 6 天。现在你开机了。
这 6 次错过的触发,要补跑几次?
三个候选答案:
| 答案 | 后果 |
|---|---|
| ① 全部补跑(6 次) | 开机瞬间起 6 个无头会话,同时跑同一个审查。这是雪崩 |
| ② 一次都不补(0 次) | 「我周一设了每天跑」,结果周一到周六一次没跑。任务形同虚设 |
| ③ 只补最近一次(1 次) | ✅ |
14.2 📄 官方语义:只补最近一次
原文(Desktop Scheduled Tasks 文档):
starts exactly one catch-up run for the most recently missed time and discards anything older.
翻译:为最近一次错过的时刻启动恰好一次补跑,丢弃更早的所有。
为什么这是对的? 因为对于绝大多数定时任务,「补跑」的价值来自 任务本身的时效性,而不是「补齐历史」:
「每天 9 点跑代码审查」
→ 补跑的价值 = 「现在给我一份最新的审查」
→ 6 天前那次审查的内容,今天跑一次就全覆盖了
→ 补 6 次得到的是 6 份几乎一样的报告关键洞察:定时任务大多是「幂等的状态检查」,不是「必须逐笔处理的事件」。
「检查一下现在的代码有没有问题」跑 1 次和跑 6 次结果一样。 而如果一个任务真的需要「逐笔补齐」(比如「把每天的数据导出到一个按日期命名的文件」), 那它就不该用调度器的 catch-up 来保证 —— 那是数据管道的活, 需要显式的「待处理区间」概念。
这个区分很重要:调度器提供的是「大致按时跑」,不是「一次都不能少」。 需要后者的场景应该用队列 + 幂等键,而不是指望 cron。
14.3 🔬 实现:怎么算出「最近一次错过」
cron/scheduler.ts:325-361:
private runCatchUp(): void {
const now = Date.now();
for (const task of this.durableTasks.values()) {
// ① fireAt 一次性绝对唤醒:错过即触发(语义本就只跑一次)
if (task.fireAt !== undefined) {
if (task.fireAt <= now) { this.catchUpFire(task, now); }
continue;
}
const base = task.lastFiredAt ?? task.createdAt; // ← 从哪算起
if (task.recurring) {
// ② 循环任务:算出区间内最后一个错过的时刻,有就补一次
const latest = computeLatestMissedRun(task.cron, base, now);
if (latest !== null) { this.catchUpFire(task, now); }
} else {
// ③ 一次性 cron 任务:它唯一那个时刻过了就补一次,然后自删
const due = computeNextCronRun(task.cron, task.createdAt);
if (due !== null && due <= now) { this.catchUpFire(task, now); }
}
}
if (caught > 0) log.info("CRON", `catch-up 补跑 ${caught} 个错过的任务(每个只补最近一次)`);
}三条分支对应三种任务语义,值得逐个看:
| 分支 | 任务类型 | 语义 |
|---|---|---|
① fireAt | 动态唤醒(§13) | 本来就只跑一次,错过了就立刻跑 |
② recurring | 每天/每小时 | 区间内有错过 → 补一次(不管错过了几次) |
| ③ 一次性 cron | 「3 点提醒我」 | 那个时刻过了 → 补一次 → 自删 |
注意 base 的取法(:339):
const base = task.lastFiredAt ?? task.createdAt;「从哪开始算错过」= 上次实际触发时刻,如果从没触发过则用创建时刻。
这个兜底很重要:一个刚创建就赶上停机的任务,
lastFiredAt是undefined。 如果不兜底到createdAt,那base是undefined, 区间计算会得出一个错误的结果(可能是从 1970 年开始算, 于是「错过了五十年」)。凡是「上次做过的时间」这类字段,都必须处理「从来没做过」这个初始状态。
14.4 一个防溢出的细节:maxRuns 上限
🔬 测试用例(tests/cron/catchup.test.ts:26)里有一条:
it("maxRuns 上限截断超长停机")为什么需要这个? 因为 computeLatestMissedRun 要枚举区间内的触发点。 考虑一个极端情况:
任务:*/1 * * * *(每分钟)
停机:3 个月
→ 要枚举 3 × 30 × 24 × 60 ≈ 130,000 个时刻枚举 13 万次只为了得到「最后那一个」,是纯浪费,而且在极端情况下会卡住启动。 所以枚举有个上限,超过就截断。
这个细节体现了一个通用的防御思路:任何「枚举一个由外部输入决定长度的序列」 的地方都要有上限。 这里的「外部输入」是停机时长 —— 一个你完全不控制的量(用户可能一年不开这台机器)。
🔬 那 7 个 catch-up 测试用例里,有一条专门是 「日任务睡 6 天醒来只返回最近一次(丢弃更早的)」—— 这正是本章开头那个问题的直接锁定。
14.5 catch-up 之后的状态更新
catchUpFire(:364-385)在触发之后还做了一件容易漏的事:
if (task.recurring && task.fireAt === undefined) {
const newNext = jitteredNextFireMs(task.cron, now, task.id); // ← 从 now 重新算
this.nextFireAt.set(task.id, newNext ?? Infinity);
task.lastFiredAt = now;
this.persistIfDurable(task); // ← 写盘
} else {
this.removeTask(task.id); // 一次性任务:补跑后删除
}两个点:
| 点 | 为什么 |
|---|---|
从 now 重新算下一次,不是从「本该触发的那个时刻」 | 注释(:202):「循环任务:从 now 重新调度(避免快速追赶历史)」。如果从错过的时刻算,那下一次可能又是过去的时刻,于是连续触发 —— 这就退化成了「全部补跑」 |
lastFiredAt = now 并写盘 | 否则下次启动又会算出「错过了」,重复补跑同一次。写盘是必须的,因为守护进程可能马上又被关掉 |
第一点是个隐藏的雪崩入口。 如果写成
nextFireAt = computeNext(cron, missedTime),那么在停机 6 天之后:text补跑 1 次 → 下一次算出来是 5 天前 → 立刻又到点 → 补跑 → 下一次是 4 天前 → ...结果就是那个「全部补跑」的雪崩,只不过是以串行的方式发生的。 「只补最近一次」这个语义不只由
runCatchUp决定,也由这里的重算基准决定 —— 两处必须一致,否则语义在第二处被悄悄破坏。这是一个很好的「不变量分散在两处」的案例:一个语义保证需要多处代码配合时, 任何一处写错都会破坏它,而测试往往只覆盖了第一处。
14.6 三种「错过」的处理总表
| 任务类型 | 停机 6 天后醒来 | 之后 |
|---|---|---|
| 每天 9 点(recurring durable) | 补 1 次 | 从 now 重算下一次,写盘 |
| 「3 点提醒我」(一次性 cron durable) | 补 1 次 | 删除 |
| 动态唤醒(fireAt,会话级) | 会话已经没了 → 不存在 | — |
| 每 5 分钟(会话级) | 会话已经没了 → 不存在 | — |
最后两行提醒一件事:catch-up 只对 durable 任务有意义。 会话级任务随会话消失,压根没有「错过」这个概念 —— 它们和创建它们的会话同生共死。
🔬 所以
runCatchUp只遍历this.durableTasks(:327),这是对的。
14.7 本章自检
- 停机 6 天后,「全部补跑」和「一次不补」各自的后果是什么?
- 为什么「只补最近一次」对大多数定时任务是对的?什么样的任务不适用这个语义?
base = task.lastFiredAt ?? task.createdAt里那个兜底防的是什么?- 为什么枚举错过时刻需要
maxRuns上限?「外部输入」在这里是什么? - 补跑之后为什么必须从
now重算下一次,而不是从错过的那个时刻? 写错会发生什么? - 为什么 catch-up 只处理 durable 任务?
§15 守护进程:无人值守的三个硬问题
§11.4 说过「时间只是触发器,宿主才是难点」。这一章讲那个难点。
15.1 ★ 核心认知:这是「宿主替换」,不是「调度器重写」
先说一个 🔬 实测得出的结论,它直接决定了工作量估算:
缺口 C1 的本质是「宿主替换」,不是「调度器重写」。 会话内层的
Scheduler/parser/lock全部可直接复用, 唯一要换的是onFire的下游 —— 从「注入交互式 REPL 主循环」换成「fork 一个 headless 会话执行」。
为什么调度内核完全不用动? 因为它的出口从一开始就是一个回调(🔬 cron/scheduler.ts 的构造参数):
onFire: (prompt: string) => void // 宿主无关
onFireTask?: (task: CronTask) => void // 守护进程模式用这个(携带完整 task)会话内宿主: onFire → app.enqueueScheduledPrompt (注入 REPL 消息队列)
守护进程宿主:onFireTask → HeadlessExecutor (fork 一个 sid-code -p)这是一个「接口边界画对了,换宿主就是换一个回调」的漂亮案例。
反面教材是把「注入 REPL」这个动作直接写进调度器循环里 —— 那么做守护进程就得把调度逻辑(cron 解析、jitter、过期、in-flight) 全部复制一遍,然后两份代码开始各自演化、慢慢不一致。
判断一个模块的边界是否画对,一个实用检验就是问: 「换一个宿主,要改几行?」 这里的答案是「一个回调」。
🔬 sid-code 的 Scheduler 用一个 daemonMode 开关区分两种宿主(scheduler.ts:70-85), 三处行为不同:
| 交互式模式 | 守护进程模式 | |
|---|---|---|
| 加载哪些任务 | 仅当前项目 | 跨多个项目的 durable 任务 |
| 项目级锁 | 要抢 | 不抢(守护进程是 durable 任务的唯一权威驱动者) |
| 启动时 catch-up | 不做 | 做(§14) |
| durable 任务过期 | 7 天 | 永不(§12.5) |
15.2 硬问题一:双触发 —— 交互式会话与守护进程抢同一个任务
问题:守护进程在跑,你又开了一个交互式会话。两边都看到 scheduled_tasks.json 里那个「每天 9 点」的任务。9 点时它被触发两次。
🔬 sid-code 的解法(scheduler.ts:77-82,注释里标为 C1-Lock-B):
交互式模式:若本机守护进程在场,主动放弃 durable 任务驱动,
只跑自己的会话级任务,把 durable 全交给守护进程,避免双触发。注意这个方向:不是「两边抢锁,谁抢到谁跑」,而是 交互式会话主动让位。
为什么「主动让位」比「抢锁」好?
抢锁的问题是不稳定:你开了终端,交互式会话抢到了锁,于是任务在你的终端里跑; 你关掉终端,锁释放,守护进程接手。同一个任务在不同时候跑在不同宿主里, 行为不一致(一个注入 REPL、一个 fork 新会话),排查时极其困惑。
而「守护进程优先」给出的是一个稳定的归属:durable 任务永远在守护进程里跑, 会话级任务永远在会话里跑。职责按任务类型划分,不按抢锁的先后划分。
一般化:多个候选执行者时,用「稳定的归属规则」而不是「竞争」来分配, 除非你真的需要负载均衡。 竞争带来的不确定性成本通常高于它省下的协调成本。
守护进程自己也需要单例锁(🔬 daemon/lock.ts,daemon.lock): 「抢单例锁,已有守护进程则拒绝启动」。否则两个守护进程 = 双触发回来了。
15.3 硬问题二:新会话没有「当前目录」
这个问题很小,但漏了就整个功能不可用。
交互式会话: 用户在 /Users/me/myrepo 里敲的命令 → cwd 天然就是它
守护进程: 半夜三点自己醒来 → cwd 是什么?「/」?守护进程启动时的目录?🔬 解法是在任务里显式记录(cron/types.ts 的 workspaceDir 字段,注释原文):
/**
* 任务执行的工作目录(守护进程 fork headless 时 cwd)。
* 会话内创建时自动填 process.cwd();缺省回退到任务所在 scheduled_tasks.json 的项目根。
* 缺口 C1:交互式会话隐含用 process.cwd(),守护进程没有「当前目录」,必须显式记录。
*/
workspaceDir?: string;这一条的教学价值在于它示范了一类普遍的迁移陷阱: 「隐式环境依赖」在换宿主时会变成显式缺口。
交互式会话里,
cwd、当前 git 分支、环境变量、用户身份 —— 这些全都是「白拿的」,代码里根本不用提。 一旦搬到无人值守环境,每一个白拿的东西都要显式记录下来。做这类迁移时,一个有效的清单式检查是: 「这段代码用到了哪些它没有作为参数接收的东西?」 那些就是待补的缺口。
cwd只是最显眼的一个。
守护进程还需要知道有哪些项目(🔬 daemon/durable-projects.ts)—— 因为它要跨项目加载 durable 任务,而它不像交互式会话那样「就在某个项目里」。
15.4 ★ 硬问题三:没有人能回答权限询问
这是三个硬问题里最重要的一个。
问题:交互式会话里,agent 想跑 rm -rf build/,harness 弹一个框问用户「同意吗」。 半夜三点没有人在。 弹框会永远挂在那里,任务卡死。
三个候选解法:
| 解法 | 后果 |
|---|---|
| ① 全部自动同意 | 一个半夜自己跑的 agent 拥有无限权限。绝对不行 |
| ② 全部拒绝 | 任务干不了任何实事,形同虚设 |
| ③ 预授权白名单 | ✅ |
🔬 sid-code 的实现(cron/types.ts 的 allowedTools 字段,注释原文):
/**
* 该任务无头执行时允许的工具白名单(预授权)。
* 缺口 C1 §5.3:守护进程无人值守,不能交互式 stall 等批准,
* 故任务级声明放行的工具/命令;缺省走 daemon-config 全局兜底白名单。
* 空数组或缺省 = 不额外放行(默认只读)。
*/
allowedTools?: string[];注意最后那句:「空数组或缺省 = 不额外放行(默认只读)」。 这是 fail-closed(§12.6)在权限层的体现 —— 没声明就是只读, 不是「没声明就放行」。
传给 headless 进程的方式(🔬 daemon/headless-executor.ts:151-156):
if (job.allowedTools && job.allowedTools.length > 0) {
cmdArgs.push("--allowed-tools", job.allowedTools.join(","));
} else {
cmdArgs.push("--permission-mode", "plan"); // ← 没有白名单 → 只能出计划,不能动手
}最后那个
else分支设计得很好:没有白名单时不是「拒绝一切导致任务失败」, 而是降级到 plan 模式 —— 任务照跑,产出一份「我打算做什么」的计划, 但不实际改动任何东西。这把「不安全」和「没用」之间的二选一变成了一个有价值的第三选项。 用户第二天看到计划,可以决定要不要授权。
一般化:当一个动作因为权限不足无法执行时,「产出一份该动作的描述」 往往比「报错退出」有用得多。 这在 CI dry-run、terraform plan、 git 的
--dry-run里是同一个思路。
还有一条绝对禁令(🔬 headless-executor.ts:18):
「G-13 守护进程绝不 auto-commit/push。」
为什么这一条要硬编码成禁令,而不是交给白名单控制?
因为 commit/push 是对外可见且难以撤回的动作。一个半夜自己跑的 agent 推了一个错误的 commit 到共享分支,第二天全组都受影响。
判据:白名单适合「可逆的、影响范围在本机的」动作; 而不可逆、影响他人的动作应该是硬禁令,不给配置项。 因为配置项意味着某天有人会打开它,而打开它的那个人未必想清楚了后果。
15.5 守护进程的进程形态:一个合并的决定
🔬 sid-code 有一个已存在的 webhook 守护进程(接 GitHub PR 事件), 和这个新的调度守护进程功能不同但同属「无人值守」范畴。
🔬 决定(daemon/daemon.ts:8-12):合并为一个进程,内部两个触发源。
sid-code daemon(一个进程)
├── ScheduleSource:Scheduler(daemonMode) 每分钟检查 → onFireTask ─┐
├── WebhookSource(可选):Bun.serve 接 GitHub webhook ───────────┤
└── HeadlessExecutor:共用 ◄──────────────────────────────────────┘
fork `sid-code -p`,WorkspaceProvider + StorageAdapter为什么合并? 因为两者的下游完全相同 —— 都是「在无人值守环境里跑一个 headless 会话」。触发源不同(时间 vs 事件),执行器可以共用。
这个切分很清晰:「触发源」和「执行器」是两个正交的维度。
- 触发源:时间(cron)、外部事件(webhook)、将来可能有别的(文件变化、消息队列)
- 执行器:怎么在无人值守环境里正确地跑一个 agent(cwd、权限、超时、并发、日志)
执行器那部分是真正的难点,而它与触发源无关。 所以每加一个触发源,不该重写一遍执行器。
🔬 一个佐证:那个 webhook 守护进程原先的 worker 是占位实现 (自己承认「MVP:返回 diff 摘要 + 触发 Skill 的占位…真实实现需要调用主循环 headless mode」)。 也就是说,「headless 执行器」这块难点在两个功能里都缺, 合并之后一次做对,两边都受益。
15.6 守护进程的生命周期(四步)
🔬 daemon/daemon.ts:14-18:
1. 抢单例锁(daemon.lock),已有守护进程则拒绝启动
2. 注册 SessionKind="daemon"(/ps 可见) ← 可观测性
3. 启动 Scheduler(daemonMode)+ 可选 webhook server
4. 信号处理:SIGINT/SIGTERM → 优雅停机
(停调度器、关 server、释放锁、注销会话)第 2 步值得点出来:把守护进程注册成一个「会话」, 于是 /ps 能看到它。一个看不见的常驻进程是运维噩梦 —— 用户会遇到「任务莫名其妙跑了」但找不到是谁跑的。
第 4 步那四个动作的顺序也有讲究:先停调度器(不再产生新任务), 再关 server(不再接新事件),最后才释放锁和注销。 反过来做会在停机过程中产生新任务,而那时执行器已经关了。
三个默认配置(🔬 DAEMON_DEFAULTS):
scheduleCheckIntervalMs: 60_000, // 每分钟检查(对齐 cc Desktop)
maxConcurrent: 3, // 最多 3 个 headless job 并发
jobTimeoutMs: 30 * 60_000, // 单个 job 30 分钟超时注意这里的检查间隔是 60 秒,而会话内层是 30 秒(§12.3)。 为什么守护进程可以更慢?因为它服务的是「每天 9 点」这类任务, ±1 分钟完全无感;而会话内层要服务「跑到 CI 过为止」这类, 用户就坐在那儿看着。
同一个参数在两层取不同值,因为两层的用户期待不同 —— 这和 §12.5 那个「过期天数两层不同」是同一类判断。
15.7 三个硬问题总表
| 硬问题 | 解法 | 关键教训 |
|---|---|---|
| 双触发 | 守护进程优先,交互式主动让位 + 守护进程单例锁 | 用稳定归属规则而不是竞争来分配执行权 |
| 没有 cwd | 任务里显式记 workspaceDir | 隐式环境依赖在换宿主时变成显式缺口 |
| 没人批权限 | 任务级 allowedTools 白名单,缺省降级 plan 模式 | 缺省 fail-closed;不可逆动作用硬禁令而非配置项 |
🔬 测试覆盖:tests/daemon/scheduler-daemon.test.ts 17 个用例。
15.8 本章自检
- 为什么说守护进程是「宿主替换」而不是「调度器重写」? 哪个接口设计让这件事成立?
- 「换一个宿主要改几行」这个检验能说明什么?
- 双触发为什么用「交互式主动让位」而不是「两边抢锁」?抢锁会带来什么不稳定?
workspaceDir这个字段揭示了哪一类普遍的迁移陷阱? 做这类迁移时的清单式检查是什么?- 无人值守环境的权限三个候选解法各自的问题?为什么「缺省降级到 plan 模式」比 「报错退出」好?
- 为什么 auto-commit/push 要做成硬禁令而不是白名单项?判据是什么?
- 「触发源」和「执行器」为什么是正交的?合并进程的理由是什么?
- 守护进程停机时那四个动作为什么要按那个顺序?
- 为什么守护进程的检查间隔(60s)比会话内层(30s)更慢?
§16 编排 × 调度:两个子系统的交汇点
§0.1 说过它们是两件事,必须分清。这一章讲它们唯一的交汇点, 以及在这个点上有哪些实用玩法。
16.1 交汇点只有一个:调度触发的那个任务,内部可以是一次编排
调度层:每周一早上 8:57 触发
↓ fork 一个 headless 会话
会话内: 跑一个 workflow
↓ 扇出 20 路
agent × 20 → 对抗验证 → 聚合
↓
产出一份周报注意方向是单向的:调度启动编排,编排不调度。 一个 workflow 脚本里不该出现「三天后再跑一次这段」—— 那是把调度语义塞进了编排层,会立刻遇到 §11.4 那四个宿主问题 (脚本执行完进程就退出了,谁来记住三天后这件事?)。
判据:编排的生命周期不超过一次 run。跨 run 的时间语义属于调度层。
16.2 三种实用组合
组合一:/loop + 轮询(最轻,最常用)
场景:「跑到 CI 过为止。」
🔬 sid-code 的 /loop 有三种用法(packages/cli/src/command/commands/loop/loop.ts:6-11):
/loop 5m <prompt> 固定间隔 → 间隔转 cron → 本地直建循环任务(不绕模型,即时确认)
/loop <prompt> 动态间隔 → 引导模型用 schedule_wakeup 自适应轮询(§13)
/loop 空跑 → 列出当前定时任务注意「固定间隔走本地直建,不绕模型」这个设计(:12 的注释):
固定间隔走本地直建(复用 Scheduler),不绕模型,即时确认;动态间隔交给模型决策。
为什么固定间隔不该经过模型? 因为
/loop 5m <任务>里所有信息都是确定的 —— 间隔是 5 分钟,prompt 是那段话。让模型来「翻译」成一次cron_create调用, 只会带来三种新的失败:模型可能把 5m 理解错、可能改写你的 prompt、可能压根不调那个工具。一般化:当用户的输入已经完全确定时,不要经过模型。 模型的价值在于处理模糊性;输入不模糊时,它只是一个会出错的中间层。 这也是为什么
/loop 5m能「即时确认」—— 你敲完回车立刻看到任务建好了。
间隔转 cron 有一个诚实的边界(🔬 cron/interval.ts:5-7):
仅支持「整除 60 的分钟」「整除 24 的小时」这类能被 cron 精确表达的间隔;
不规则间隔(如 7m、90m)无法用纯 cron 周期表达,返回 null,由调用方降级处理。为什么
*/7 * * * *不是「每 7 分钟」? 这是一个经典误解。*/7在分钟位的含义是「在 0,7,14,21,28,35,42,49,56 这些分钟触发」—— 从 56 到下一个 0 只隔了 4 分钟,不是 7。 cron 的*/n是「在能被 n 整除的那些值上触发」,不是「每隔 n」。 只有当 n 整除 60 时,这两个说法才等价。这个坑在生产里很常见(
*/7、*/45、*/90全都不是你以为的意思)。 🔬 sid-code 的处理是返回 null 让调用方降级,而不是生成一个语义错误的表达式 —— 这是对的:宁可说"我做不到",也不要给一个看起来对但含义不同的东西。
组合二:定时跑一次编排(最有价值)
场景:「每周一早上给我一份上周代码质量报告。」
调度(守护进程,durable):57 8 * * 1
↓ fork headless 会话,cwd = workspaceDir,allowedTools = [只读工具]
编排:ultracode 关键字在 prompt 里(§10.3 第 1 条)
↓
扇出:按模块 × 10 → 各自审查
对抗:每条发现派一个 verify(强模型,§9.7)
聚合:合成周报
↓
写进 reports/2026-W36.md这个组合里有三个必须配对的设置,漏一个就不工作:
| 设置 | 为什么 |
|---|---|
workspaceDir | 守护进程没有 cwd(§15.3) |
allowedTools(含写文件) | 否则降级到 plan 模式,只出计划不写文件(§15.4) |
prompt 里带 ultracode 或明确说「用 workflow」 | 否则 opt-in 门控不放行(§10.3) |
最后一条特别容易漏:你在交互式会话里试的时候可能打了
ultracode, 但存进定时任务的 prompt 里忘了带,于是每周一跑起来的是一个没有编排的普通会话 —— 它会给你一份浅得多的报告,而且不报任何错。这是一个典型的「配置在两个子系统之间掉落」的坑: 调度层负责「跑什么 prompt」,编排层的门控读的是「prompt 里有没有那个词」, 中间没有人检查这两件事是否配套。
组合三:编排 + /goal(治「提前收工」)
🔬 sid-code 有一套 /goal 机制(packages/core/src/goal/,含 evaluator / evidence-collector / blocked-detector / budget), 它和编排治的是同一个病的两个面:
| 治什么 | 手段 | |
|---|---|---|
| 编排 | 一轮之内覆盖不全 | 结构性扇出,清单写在脚本里 |
/goal | 提前宣布完成 | 目标门禁:不满足验收条件不许收工 |
📰 官方那个示例 prompt 把两者叠在一起用:
「这个测试大约每 50 次运行中会失败 1 次。请设置一个 workflow 来复现它, 提出假设并在 worktree 中进行对抗性测试。
/goal不要停下来, 直到有一个理论被验证成功为止。」
拆开看这句 prompt 用了本文的多少东西:
「每 50 次失败 1 次」 → 需要反复跑 → 循环直到枯竭(§9.5)
「设置一个 workflow」 → 显式 opt-in(§10.3 第 3 条)
「提出假设」 → 扇出多个假设(§9.1)
「对抗性测试」 → 对抗验证(§9.2)
「在 worktree 中」 → isolation: 'worktree'(§3.3)
「/goal 不要停,直到验证成功」 → 目标门禁 + 循环终止条件这一句 prompt 恰好把编排的六个要素全用上了,而它读起来只是一句人话。 这说明一件事:好的 harness 设计让用户不需要知道这些机制的名字。 用户说「不要停下来直到验证成功」,harness 自己翻译成 「循环 + 目标门禁 + 对抗验证」。
16.3 一个必须注意的组合陷阱:定时任务里跑编排的成本
把 §10.1 和 §12.5 放在一起想:
编排成本 ≈ 普通任务的 30-100 倍
×
durable 任务在守护进程里**永不过期**(§12.5)
=
一个被遗忘的「每天跑一次编排」任务,会每天烧掉 30-100 倍的成本,永远这是本文两个子系统交汇处最大的风险点,而它不容易被发现, 因为两边各自都是合理的:
- 「durable 任务不过期」对「每天 9 点跑审查」是对的(§12.5 的论证成立)
- 「编排很贵」是编排的固有属性(§10.1)
风险出现在乘积上,而没有任何一层负责看这个乘积。
三条实用防御:
- 给定时编排任务设
budget_total—— 硬闸不依赖任何人记得(§8.3)- 周期性 review 定时任务清单(
/loop空跑就是列表,§16.2)- 确保 workflow 的 usage 回流主会话计量(§10.6)—— 否则
/cost看不到这笔钱,你连"它在烧钱"都不知道
16.4 交汇点总结
| 玩法 | 调度层 | 编排层 | 主要风险 |
|---|---|---|---|
/loop 5m 轮询 | 会话级 cron | 无 | 忘了删 → 7 天后自动过期兜底 |
/loop 动态轮询 | schedule_wakeup | 无 | 选错 delay(§13.3) |
| 定时跑编排 | 守护进程 durable | 完整 workflow | 成本乘积 + 三项配置掉落 |
编排 + /goal | 无 | workflow + 门禁 | 循环不收敛(靠 §8.2 兜底) |
16.5 本章自检
- 为什么「编排启动调度」这个方向是错的?判据是什么?
/loop 5m <任务>为什么不经过模型?一般化的原则是什么?*/7 * * * *是「每 7 分钟」吗?为什么不是?sid-code 怎么处理这种间隔?- 「定时跑一次编排」需要哪三项配套设置?漏了哪一项会静默降级?
- 那句「设置 workflow 复现偶发失败 + /goal 不要停」用到了本文哪六个要素?
- 「durable 不过期」× 「编排很贵」的乘积风险,为什么没有任何一层负责? 三条防御是什么?
§17 ★ 会「绿着坏掉」的失效模式(本文第二值钱的一章)
前面十六章里,每章都有一两处标了「这是绿着坏掉的典型形态」。这一章把它们集中起来, 并给出统一的判据。
为什么这一章值得单独存在:编排与调度这两个子系统有一个共同的恶性特征 ——
它们的失败大多不抛异常。
一个漏了 15 项的审查、一个把 20 个文件都当成第 1 个文件分析的 resume、 一个从来没触发过的定时任务 —— 它们全都「运行成功」, 日志一片正常,测试全绿,用户拿到一份看起来完整的结果。
这类失效的统一形状:
正常的 bug: 期望 A,得到异常/明显错误的 B → 立刻发现
绿着坏掉: 期望 A,得到「看起来像 A」的 B' → 永远不发现17.1 十一种形态汇总表
| # | 形态 | 表面现象 | 真实情况 | 出处 |
|---|---|---|---|---|
| 1 | 静默截断 | 一份完整的报告 | 只覆盖了 12/50 项(预算/4096/1000 闸触发) | §8.4 |
| 2 | pipeline 退化成屏障 | 结果完全正确 | 墙钟慢一个量级,部分结果延迟交付 | §4.6 |
| 3 | resume 缓存串台 | 20 份结果都在 | 全是第 1 个文件的分析(键用了 prompt hash) | §7.3 |
| 4 | resume 忽略脚本改动 | 「我改了 prompt 但没效果」 | 新 prompt 压根没发出去(键只用序号) | §7.3 |
| 5 | schema 缓存错配 | 输出格式合法 | 用了别的 schema 校验,错误率 5.4%→51% | §5.6 |
| 6 | worktree 静默降级 | 并行改文件「成功」 | 降级为非隔离,agent 互相踩踏 | §3.3 |
| 7 | 弱模型自我盖章 | 结论带 CONFIRMED 背书 | 误报被放大成"已验证" | §9.7 |
| 8 | workflow usage 漏计 | /cost 数字正常 | 少报了最大的一块,costLimit 失效 | §10.6 |
| 9 | 定时 prompt 静默丢失 | 队列空、无报错 | 4 个任务凭空消失(先出队再处理) | §12.4 |
| 10 | in-flight / 信号量泄漏 | 系统"正常"但越来越慢 | 许可漏光,最终静默挂死(少了 finally) | §8.1 / §12.7 |
| 11 | opt-in 配置掉落 | 定时任务照跑 | 跑的是没编排的普通会话,报告浅得多 | §16.2 |
17.2 它们的四个共同成因
把上面 11 种归一下类,成因只有四个。认出成因比记住形态更有用:
成因一:正确性与性能解耦(#2)
形状:两种实现产出完全相同的结果,只有时序/成本不同。
有屏障的 pipeline → 结果对
无屏障的 pipeline → 结果对
↓
一个断言"结果对"的测试,两种实现都通过唯一的解法:测那个真正被设计的属性。 🔬 sid-code 测的是「item A 的 stage2 时间戳与 item B 的 stage1 时间戳必须重叠」—— 断言的是并发时序,不是输出。
通用判据:如果一个设计的价值在于「非功能属性」(延迟、成本、并发度、 缓存命中率),那么功能测试对它一律无效,必须为那个属性单独写断言。
成因二:缓存键错(#3、#4、#5)
三个都是同一个病,只是方向不同:
键少包含了影响输出的输入 → 串台(拿到别人的结果) ← #3、#5
键多包含了不影响输出的输入 → 无谓失效(性能悄悄变差) ← (§7.4 的 label)
键漏掉了"输入变了"的信号 → 返回过时结果 ← #4统一判据(§7.4 说过,这里重申因为它覆盖了三种形态):
缓存键必须包含所有影响输出的输入,且只包含它们。
排查手法:拿到一个缓存实现,列出「所有会影响输出的东西」, 逐个检查在不在键里。#5 那个 5.4%→51% 就是「schema 影响输出但不在键里」。
成因三:降级没留痕(#6、#7、#8、#11)
形状:系统遇到问题后做了一件合理的退让,但没告诉任何人。
worktree 建不出来 → 降级非隔离 → 但脚本以为自己在隔离环境里
schema 校验没走通 → 文本兜底 → 但分不清"遵守了契约"和"救回来了"
没带 ultracode → 普通会话 → 但用户以为跑的是编排
usage 没回流 → 计量少报 → 但 /cost 看起来正常每一次降级都在系统的实际行为与调用方的预期之间打开了一道缝。
解法有两层:
| 层 | 做什么 |
|---|---|
| 必须:留日志 | 🔬 sid-code 在 worktree 降级、schema 兜底、prompt 注入失败处都打了 warn |
| 更好:让降级可见于结果 | §5.7 那个 coverage_note 必填字段就是这个思路 —— 把降级写进产出物,不只是日志 |
为什么"更好"那一层重要? 因为日志没人看。 一份报告里的
coverage_note: "只查了 12/50 项,预算耗尽"是读者一定会看到的; 一行log.warn在 10 万行日志里等于不存在。判据:降级信息应该出现在与它影响的那个产出物同一个地方。
成因四:状态未释放 / 未持久(#9、#10)
形状:某个标记被设置了但没被清掉,或某个状态改了但没写盘。
#10:inFlight.add() 后抛错 → 没 delete → 任务永久不触发
#10:acquire() 后抛错 → 没 release → 并发度归零
#9: splice 出队后抛错 → 数据在局部变量里被 GC → 任务消失
#14.5:catch-up 后没写 lastFiredAt → 下次启动重复补跑统一解法是两条无例外的规矩:
① 获取了什么,必须在 finally 里释放
② 改了什么需要跨进程/跨启动可见的状态,必须立刻持久化17.3 一个更根本的问题:这类失效为什么难测
因为它们的正确行为往往是「某件事没发生」:
| 形态 | 正确行为 | 怎么测「没发生」 |
|---|---|---|
| 不该串台 | 20 个调用没有互相复用结果 | 得断言 20 份结果互不相同 |
| 不该双触发 | 任务没有被跑第二次 | 得数触发次数 == 1 |
| 不该泄漏许可 | 跑完之后并发度没有下降 | 得在异常路径后检查 active === 0 |
| 不该静默截断 | 覆盖率没有悄悄下降 | 得断言 coverage_note 存在且内容正确 |
「断言某件坏事没发生」比「断言好事发生了」难写得多, 因为前者需要你先想到那件坏事。这就是为什么这一章值得单独读 —— 它是一份「该想到什么」的清单。
🔬 sid-code 在这些点上的测试布局印证了这个思路:
tests/workflow/sandbox.test.ts 31 个 ← 安全边界,形态最多
tests/workflow/runtime.test.ts 24 个 ← 含 pipeline 无屏障时序断言
tests/workflow/structured-output 24 个 ← schema 校验与重试
tests/cron/scheduler.test.ts 19 个
tests/daemon/scheduler-daemon 17 个 ← 双触发、catch-up、宿主切换
tests/workflow/journal.test.ts 13 个 ← 缓存键串台/失效
tests/cron/catchup.test.ts 7 个 ← 含「睡 6 天只补一次」注意 sandbox 和 runtime 的用例数最多 —— 它们恰好是失效形态最隐蔽的两块 (安全逃逸、并发时序)。测试密度应该跟着"失效的隐蔽程度"走, 而不是跟着"代码行数"走。
17.4 一份可操作的自检清单
拿到一个编排 / 调度实现,按这个顺序问:
编排侧
parallel的参数是 thunk 数组吗?(不是 → 失去背压)pipeline真的无屏障吗?有测试锁时序吗?- 缓存键包含了 prompt、schema、model、effort、agentType、isolation 吗? 不包含 label / phase 吗?
agent()返回null的地方都filter(Boolean)了吗?- 并发池是独立的吗?
release()在finally里吗? - 预算是「软读 + 硬拦」两套吗?
- 截断(预算/4096/1000)时会
log吗?会写进产出物吗? - verify 阶段的模型比 find 阶段更强吗?
- schema 里
REFUTED是合法值吗?evidence必填吗? - workflow 的 usage 回流主会话计量了吗?
调度侧
- 抖动是确定性的(哈希)而非随机的吗?
- 注入只在轮次间隙发生吗?忙时排队吗?队列在异常路径会丢数据吗?
- 锁用 PID 探活吗?
EPERM判为存活吗? - 锁的位置能唯一标识"哪个项目"吗(位置 vs 哈希)?
- catch-up 是「只补最近一次」吗?补跑后是从
now重算下一次吗? lastFiredAt补跑后写盘了吗?- 无头执行有
workspaceDir吗?没有白名单时降级 plan 而非报错吗? - auto-commit/push 是硬禁令吗?
- 双触发的归属规则稳定吗(而非抢锁)?
- 定时编排任务设了
budget_total吗?
17.5 本章自检
- 「绿着坏掉」的统一形状是什么?为什么它比普通 bug 危险?
- 四个共同成因分别是什么?
- 为什么「正确性与性能解耦」的设计一定需要专门的非功能断言?
- 缓存键的两个错法方向,症状有什么不同?哪个更容易被发现?
- 为什么「降级留日志」还不够?更好的一层是什么?判据是什么?
- 为什么「断言坏事没发生」比「断言好事发生」难写?
- 测试密度应该跟着什么走?
§18 sid-code 现状实测盘点:计划 vs 实现的差异
这一章有两个用途:
- 给出可复算的现状(2026-09-03 实读),而不是引用可能过期的计划文档。
- 对比「当初计划」与「最终实现」 —— 差异处往往是最好的教学素材, 因为每处差异背后都有一个「计划时没想到的东西」。
18.1 代码规模(可复跑)
wc -l packages/core/src/workflow/*.ts # 1420 行 / 7 文件
wc -l packages/core/src/cron/*.ts # 980 行 / 6 文件
wc -l packages/core/src/daemon/*.ts # 1415 行 / 10 文件
wc -l packages/core/src/tool/{workflow,cron-create,cron-list,cron-delete,schedule-wakeup}.ts # 748 行| 目录 | 文件 | 职责 |
|---|---|---|
workflow/sandbox.ts | 358 | vm 隔离 + 影子 Date/Math + meta 校验(§6) |
workflow/runtime.ts | 276 | 五个原语接线 + 计数 + 预算(§3、§8) |
workflow/json-schema-validator.ts | 279 | schema 校验,Ajv 风格错误回喂(§5) |
workflow/sub-agent-runner.ts | 150 | agent() 落到真实 SubAgent + worktree(§3.3) |
workflow/journal.ts | 145 | resume 账本(§7) |
workflow/types.ts | 110 | 共享类型 |
workflow/scheduler.ts | 102 | 独立并发池(§8.1) |
cron/scheduler.ts | 432 | 调度内核 + catch-up + 双宿主(§12、§14、§15) |
cron/parser.ts | 232 | cron 解析 + 确定性抖动(§12.2) |
cron/lock.ts | 101 | 项目级锁 + PID 探活(§12.6) |
cron/interval.ts | 79 | 间隔转 cron(§16.2) |
cron/describe.ts | 86 | cron 转人话 |
cron/types.ts | 50 | 数据模型 |
daemon/daemon.ts | 271 | 守护进程主体(§15) |
daemon/service.ts | 281 | 服务安装/管理 |
daemon/headless-executor.ts | 241 | 无头执行器(§15.4) |
测试:13 个测试文件,185 个用例。
workflow: sandbox 31 / runtime 24 / structured-output 24 / journal 13 /
workflow-task 9 / scheduler 8 / cwd-context 8 = 117
cron: scheduler 19 / interval 10 / describe 9 / catchup 7 = 45
daemon: scheduler-daemon 17 = 17
tool: schedule-wakeup 6 = 618.2 ★ 六处「计划 vs 实现」的差异(这一节是本章重点)
原始计划文档(2026-06)与最终实现(2026-09)有六处实质差异。每一处都有原因。
差异一:沙箱从「受限全局环境」变成 node:vm
| 计划(2026-06) | 实现(2026-09) |
|---|---|
「不裸 eval。给脚本注入一个受限全局环境——只暴露 agent/parallel/... 不暴露 fs/net/process」 | node:vm 独立 context |
为什么变:§6.2 那个 [].constructor.constructor('return process')() 一句话击穿。 计划里的「注入受限全局」在实现时的自然做法就是参数影子,而它不够。
这是本文最有价值的一处差异,因为它说明: 计划文档说的「不暴露 process」是一个目标,不是一个方案。 实现时才会发现「怎么才算真的不暴露」是个技术问题, 而第一个想到的方案(遮名字)解决不了它。
面试价值:被问「你会怎么沙箱化一段 LLM 生成的 JS」时, 如果答「注入受限全局,不给 fs 和 process」, 追问一句「那
[].constructor.constructor呢」就答不上了。 能主动说出这条逃逸链并给出 vm 方案,是一个明显的深度信号。
差异二:确定性守卫从「静态扫描」变成「运行时抛错」
| 计划 | 实现 |
|---|---|
| (未明确,但对标 cc 的做法是静态扫) | 影子 Date/Math,运行时抛错 |
为什么变:📰 官方 issue #63759 的误杀教训(§6.5)—— 静态扫字符串会把 prompt 里提到 Date.now 的脚本也拒掉。
这是一个「后发者直接绕开前人坑」的案例:因为在实现前就读到了那个 issue, 所以从一开始就没走静态扫这条路。
一般化:对标一个功能时,除了看它的设计,更要看它的 issue 列表 —— 设计告诉你「打算怎么做」,issue 告诉你「这么做会撞什么」。
差异三:缓存键从「prompt hash」变成「callIndex + 指纹」
| 计划 | 实现 |
|---|---|
| (未明确细节) | callIndex + (prompt, opts) 稳定指纹 |
为什么这样:📰 官方 issue #63102 的串台教训(§7.3)。 同样是「读了 issue 才避开」。
差异四:opt-in 门控从 ultracode 关键字变成 shouldDefer
| 计划(对标 cc) | 实现 |
|---|---|
ultracode 关键字 + 五条允许清单(指令层) | shouldDefer = true + tool_search(上下文层) |
为什么变:§10.5 分析过 —— sid-code 本身有一套「长尾工具延迟加载」机制, 复用它比新造一个关键字更自然,而且附带省首轮上下文。
代价也在 §10.5 说清了:上下文层门控强度硬但表达力弱, 表达不了「必须是用户自己的措辞」这类语义条件。 🔬 sid-code 用三道成本硬闸(budget/1000/4096)补这个缺口。
这处差异展示了「同一目标在不同架构下的最佳实现不同」 —— 照抄 cc 的关键字方案在 sid-code 里反而是多造了一套机制。
差异五:durable 任务过期策略分叉
| 计划 | 实现 |
|---|---|
| 对齐 cc 的 7 天过期 | 会话级 7 天,守护进程 durable 永不过期 |
为什么变:§12.5 论证过 —— 无人值守场景的语义就是「长期值班」, 7 天后消失显然不对。这是一处实现时才发现的语义分叉。
差异六:守护进程与 webhook 守护进程合并
| 计划 | 实现 |
|---|---|
| (C1 单独立项) | 合并为一个进程,两个触发源共用 HeadlessExecutor |
为什么变:§15.5 说过 —— 发现两者的下游完全相同, 而且那个「headless 执行器」在原 webhook 实现里还是个占位。 合并后一次做对,两边都受益。
18.3 六处差异的共同模式
把上面六处归一下,成因只有三类:
| 成因 | 差异 | 说明 |
|---|---|---|
| 计划给的是目标,实现才知道方案不够 | ① | 「不暴露 process」→ 遮名字不够 → vm |
| 读了对标对象的 issue,直接绕开 | ②③ | 静态扫误杀、缓存键串台 |
| 自身架构里已有更合适的机制 / 语义分叉 | ④⑤⑥ | 门控、过期策略、进程合并 |
第二类值得特别强调,因为它是「后发优势」的具体形态。
后发者的优势不在于「能抄到实现」(§18.4 会说明恰恰抄不到), 而在于能抄到别人的踩坑记录。两个 issue(#63759、#63102) 各省掉了一轮「上线 → 用户报障 → 排查 → 重构」的循环。
对标一个功能时的正确动作顺序: 读 spec(拿契约)→ 读 issue(拿坑) → 读源码(拿参考实现)→ 设计自己的方案。 大多数人只做第一和第三步。
18.4 一个诚实的边界:能抄到什么,抄不到什么
🔬 一次源码审计的结论(对 claude-code 开源树):
tools/WorkflowTool/WorkflowTool.ts = null(空壳)
tools/WorkflowTool/WorkflowPermissionRequest = return null
tasks/LocalWorkflowTask/LocalWorkflowTask.ts = 空接口
commands/workflows/index.js = 物理不存在
tools/WorkflowTool/bundled/index.js = 物理不存在全树搜 agent()/parallel()/pipeline()/phase() 的实现体 → 零命中。 7 处 feature('WORKFLOW_SCRIPTS') 命中全是门控判断(feature(...) ? require(...) : null)。
结论:引擎留在内部构建里,开源树只有 feature-gate 脚手架。 「能看到插座,看不到电器。」
所以「服用 cc 的能力」实际能拿到的是(分三级):
| 级别 | 能拿到什么 |
|---|---|
| 🟩 抄契约 | local_workflow task 类型、进度事件形状、worktree slug 格式、StructuredOutput 工具模式 —— 这些集成点在树里是真代码,照抄设计不用自己想 |
| 🟨 读常驻代码参考 | SyntheticOutputTool.ts(163 行真实现,非门控)—— 实现结构化输出时对着它写 |
| 🟦 必须自研 | JS 执行载体、agent/parallel/pipeline 引擎、resume/journal |
为什么 runtime 省不掉:引擎必须接到 sid-code 自己的 SubAgent / 并发池 / 权限体系上。 cc 的 agent 层是它自己的实现(命名都对不上:cc 叫 LocalWorkflowTask, sid-code 叫 LocalAgentTaskState)。
这一节的教学价值在于它示范了「对标」的正确姿态:
「照着做一个」听起来像是抄,实际上抄不到的部分才是主体。 能抄的是接口形状(省设计时间),抄不到的是接到自己基础设施上的那部分(省不掉)。
一个实用的估算方法:先搜对标对象里那个功能的核心实现体在不在。 不在(被 feature gate 剔除 / 闭源)→ 老老实实按自研估工作量, 只把「接口设计」那部分打个折。
18.5 现状对照三层调度架构
| 层 | 状态 | 位置 |
|---|---|---|
| ① 会话内 cron | ✅ 已实现 | cron/ + tool/cron-*.ts + /loop |
| ① 动态自定步 | ✅ 已实现 | tool/schedule-wakeup.ts |
| ② 本地守护进程 | ✅ 已实现 | daemon/ |
| ③ 云端 | ❌ 未实现 | —(需要账户体系与云侧执行环境) |
编排侧:五个原语、schema、沙箱、并发池、预算、journal resume、worktree 隔离、 嵌套一层 —— 全部已实现。
⚠️ 提醒:原始研究文档(2026-06)里 M0-M6 全部标为「待做」。 引用那些文档时务必注意日期 —— 那是路线图,不是现状。 这也是为什么本文所有现状结论都带
文件:行号并注明实读日期。
18.6 本章自检
- 六处「计划 vs 实现」差异的三类成因分别是什么?
- 「计划给的是目标,实现才知道方案不够」—— 沙箱那处具体是什么情况?
- 「后发优势」的具体形态是什么?对标一个功能的正确动作顺序是什么?
- 为什么照抄 cc 的
ultracode关键字在 sid-code 里反而是多造机制? - 对标一个闭源/被 gate 掉的功能,工作量该怎么估?
- 引用 2026-06 的研究文档时要注意什么?
§20 动手:从零实现一个 mini 编排 + 调度层
为什么要动手:前面十九章的每一条教训,都是在某一步被撞出来的。 这一章给出六个阶段,并明确标出你会在哪一步撞到哪个坑 —— 撞过一次,那条教训才真正属于你。
总原则:每阶段可独立验收、可单独回退。依赖关系是硬的。
阶段 1 跑起来一段脚本(地基)
└─> 阶段 2 agent() + parallel() + 独立并发池
├─> 阶段 3 结构化输出(schema)
│ └─> 阶段 4 pipeline() 无屏障
├─> 阶段 5 resume(journal)
└─> 阶段 6 换一条轨:cron 调度 + 守护进程阶段 1:跑起来一段脚本(半天)
目标:能执行一段字符串形式的 JS,脚本里能调一个假的 agent()。
// 最小版本
import vm from "node:vm";
function runScript(src, api) {
const context = vm.createContext({ ...api, JSON, Math, Array, Object, String, console });
return vm.runInContext(`(async () => { ${src} })()`, context, { timeout: 30_000 });
}
// 测试用的假 agent
await runScript(
`const r = await agent('hello'); log('got: ' + r);`,
{ agent: async (p) => `echo:${p}`, log: (m) => console.log(m) }
);这一步你会撞到的坑:
| 坑 | 现象 | 对应章节 |
|---|---|---|
用 new Function 或 eval 而不是 vm | 试一下 [].constructor.constructor('return process')() —— 你会拿回真的 process | §6.2 |
忘了 timeout | 脚本里写 while(true){} → 整个进程冻住 | §6.8 |
不知道 timeout 只掐同步 | 以为「30 秒后一定返回」,结果一个 await 挂了两小时 | §6.8 |
必做的验证(这是本阶段最有价值的动作):
// 亲手试这一行,看它在你的实现里能不能拿到 process
await runScript(`log(String([].constructor.constructor('return typeof process')()))`, api);
// vm 实现 → "undefined" ✅
// new Function 实现 → "object" ❌ 你的沙箱是纸做的验收:能跑脚本;上面那行返回 undefined;死循环 30 秒后被掐。
阶段 2:agent() + parallel() + 独立并发池(一天)
目标:agent() 真的去开一个 subagent(或先接一个真实 LLM 调用), parallel() 能并发但受限。
class Semaphore {
constructor(cap) { this.cap = cap; this.active = 0; this.waiters = []; }
async run(thunk) {
await this.acquire();
try { return await thunk(); }
finally { this.release(); } // ← 这个 finally 是本阶段的重点
}
acquire() {
if (this.active < this.cap) { this.active++; return Promise.resolve(); }
return new Promise(res => this.waiters.push(() => { this.active++; res(); }));
}
release() {
this.active--;
const next = this.waiters.shift(); // FIFO
if (next) next();
}
}
const parallel = (thunks) => Promise.all(
thunks.map(t => Promise.resolve().then(() => t()).catch(() => null))
);这一步你会撞到的坑:
| 坑 | 现象 | 对应章节 |
|---|---|---|
parallel 收 Promise 数组而不是 thunk | 传 100 项时全部瞬间发出,限流 429 一片 | §3.4 |
release() 没进 finally | 跑几次抛错后并发度归零,系统静默挂死,CPU/内存都正常 | §8.1 |
| 复用了「模型开 subagent」的配额池 | workflow 占满槽 → 模型自己开不出子代理;反之亦然,两个症状互相污染 | §8.1 |
忘了 .catch(() => null) | 一路失败 → 整批 reject → 19 份好结果全丢 | §3.3 |
必做的验证:
// ① 背压验证:cap=2 时,同时在跑的数量永远 ≤ 2
// ② 泄漏验证(关键):让一半 thunk 抛错,跑完之后断言 sem.active === 0
// ③ thunk 验证:传 Promise 数组进去,观察是否瞬间全部发出验收:cap 生效;一半抛错后 active === 0;失败位置是 null 而非整批失败。
阶段 3:结构化输出(一天)
目标:agent(prompt, {schema}) 返回已校验的对象,不合规自动重试。
实现路线(不要自己写「让模型按格式输出文本」那套):
① 给子代理挂一个工具,名叫 StructuredOutput,它的入参 schema = 你给的 schema
② 系统提示告诉子代理:交差的唯一出口是调这个工具
③ 工具收到入参 → 校验 → 不合规就把 Ajv 风格的错误列表回喂 → 让它再调一次
④ 重试上限(比如 3 次),耗尽则 agent() 返回 null
⑤ 兜底:模型压根没调工具时,从最终文本里试着抠 JSON(打 warn!)这一步你会撞到的坑:
| 坑 | 现象 | 对应章节 |
|---|---|---|
| schema 缓存键只用工具名 | 名字固定内容可变 → 校验器错配 → 错误率 5.4%→51% | §5.6 |
| 没做文本兜底 | 接弱模型时所有带 schema 的 agent 全返 null,看起来「全部失败」 | §5.5 |
| 兜底做了但没打 warn | 分不清「遵守了契约」和「没遵守但救回来了」 | §5.5 |
schema 里没有 REFUTED | 模型理解成「我的工作是确认」,验证形同虚设 | §5.4 |
必做的验证:
// 关键一条:连续用两个不同的 schema 调同一个工具,
// 断言第二次用的是第二个 schema 校验(而不是缓存里第一个的校验器)验收:不合规能重试并最终合规;两个不同 schema 不串台;兜底路径有 warn。
阶段 4:pipeline() 无屏障(半天)
目标:每个 item 独立穿过所有 stage。
const pipeline = async (items, ...stages) => {
const runChain = async (item, index) => {
let prev = item;
for (const stage of stages) {
try { prev = await stage(prev, item, index); }
catch { return null; } // 该 item 落 null,跳过剩余 stage
}
return prev;
};
return Promise.all(items.map((it, i) => runChain(it, i)));
// ^^^^^^^^^^^ 外层 Promise.all(所有链同时启动)
// + 链内顺序 await = 无屏障
};这一步你会撞到的坑:
| 坑 | 现象 | 对应章节 |
|---|---|---|
写成了有屏障(外层按 stage 循环、内层 Promise.all) | 结果完全正确,测试全绿,只是墙钟慢一个量级 | §4.6 |
| 只测「结果对不对」 | 上面那个 bug 永远发现不了 | §17.2 |
必做的验证(本阶段唯一重要的动作):
// 断言并发时序,不是断言结果:
// 让 item[0] 的 stage1 很快、item[1] 的 stage1 很慢,
// 记录每次 stage 调用的时间戳,
// 断言:item[0] 的 stage2 开始时间 < item[1] 的 stage1 结束时间验收:那条时序断言通过。把实现改成有屏障,那条断言必须失败 —— 如果改了还是绿的,说明你的断言写错了。
阶段 5:resume(journal)(一天)
目标:中断后重跑,已完成的 agent() 查表返回。
// 缓存键 = callIndex + (prompt, opts) 稳定指纹
function fingerprint(prompt, opts) {
const relevant = {
prompt,
schema: opts?.schema ?? null,
model: opts?.model ?? null,
effort: opts?.effort ?? null,
// 注意:label / phase 刻意不进指纹(展示用,不影响输出)
};
return sha256(stableStringify(relevant)).slice(0, 16);
}存储用 append-only JSONL,逐行容错(解析失败跳过那一行,不作废整个账本)。
这一步你会撞到的坑:
| 坑 | 现象 | 对应章节 |
|---|---|---|
| 键只用 prompt hash | 20 个文件同 prompt → 全部复用第 1 个的结果 → 20 份一样的报告,静默 | §7.3 |
| 键只用 callIndex | 用户改了 prompt「没效果」(新 prompt 压根没发出去) | §7.3 |
label 进了指纹 | 用户改个标签 → 整个 run 缓存失效 → 40 个 agent 重跑 | §7.4 |
脚本里有 Date.now() | 每次重跑指纹全变 → resume 100% miss,白做 | §6.6 |
| 读改写整个 JSON 文件 | 崩在写回中间 → 整个账本损坏 | §7.5 |
| 失败也写进 journal | 一次偶发 429 被永久记成「这个 agent 的结果是失败」 | §7.6 |
必做的验证:
// ① 20 个同 prompt 的调用,断言 20 份结果互不相同(防串台)
// ② 改第 5 个 agent 的 prompt 重跑:断言前 4 个命中缓存、第 5 起真跑
// ③ 只改 label 重跑:断言全部命中缓存
// ④ 脚本里调 Date.now():断言抛错(且报错信息里有解法)这时候回头加确定性守卫:影子 Date/Math,运行时抛错而非静态扫源码。 报错信息要写成「被禁(非确定性,破坏 resume)。请从 args 传时间戳」—— 因为看这条报错的是模型,它得知道往哪改。
验收:上面四条断言全过。
阶段 6:换一条轨 —— cron 调度 + 守护进程(两到三天)
注意这是一个独立子系统,不依赖前五个阶段。可以并行做。
6a. 会话内调度(一天)
① cron 解析(5 字段)+ 计算下一次触发时刻
② 确定性抖动:taskId 哈希 → 周期 10%,上限 15 分钟
③ 心跳循环:30 秒检查一次(小于 cron 最小粒度 1 分钟即可)
④ in-flight 保护:上一次还在跑就跳过本轮(release 进 finally!)
⑤ 触发出口设计成一个回调 onFire(prompt) —— 宿主无关(这一步决定了 6b 的工作量)
⑥ 注入只在「轮次间隙」发生;忙则排队
⑦ 循环任务 7 天过期会撞到的坑:
| 坑 | 现象 | 对应章节 |
|---|---|---|
抖动用 Math.random() | 任务在 9:00-9:15 之间乱跳,用户无法预期 | §12.2 |
*/7 当成「每 7 分钟」 | 实际是「0,7,14,...,56 分触发」,56→0 只隔 4 分钟 | §16.2 |
| 忙时队列「先 splice 再循环」 | 中途抛错 → 剩余 prompt 随局部变量被 GC → 任务凭空消失,无日志 | §12.4 |
| 触发出口写死成「注入 REPL」 | 做 6b 时要把调度逻辑整个复制一遍 | §15.1 |
6b. 守护进程(一到两天)
先做一个认知检查:如果 6a 的第 ⑤ 步做对了,这一步只需要换一个回调。 如果你发现要改很多地方,回去修 6a 的边界。
① 单例锁(daemon.lock)+ PID 探活(EPERM 判为存活!)
② 跨项目加载 durable 任务
③ catch-up:只补最近一次;补跑后**从 now 重算下一次**(不是从错过的时刻)
④ headless 执行器:fork 一个 -p 进程
- cwd = task.workspaceDir(必须显式记录)
- 权限 = task.allowedTools 白名单;缺省降级 plan 模式
- 硬禁 auto-commit/push
⑤ 交互式会话检测到守护进程在场 → 主动放弃 durable 驱动
⑥ 优雅停机:停调度器 → 关 server → 释放锁 → 注销会话(顺序有讲究)会撞到的坑:
| 坑 | 现象 | 对应章节 |
|---|---|---|
| catch-up 全部补跑 | 停机 6 天后开机瞬间起 6 个会话跑同一件事 | §14.1 |
| 补跑后从错过时刻重算 | 补 1 次 → 下一次算出是 5 天前 → 立刻又触发 → 串行雪崩 | §14.5 |
补跑后没写 lastFiredAt | 下次启动重复补跑同一次 | §14.5 |
EPERM 判成「进程已死」 | 抢掉活着的持有者的锁 → 双触发;只在多用户环境暴露 | §12.6 |
锁放 HOME + 路径哈希 | 子目录启动算出不同哈希 → 双调度器 | §12.6 |
忘了 workspaceDir | 半夜跑起来 cwd 是「/」,什么都找不到 | §15.3 |
| 没白名单时直接报错退出 | 任务形同虚设(应降级 plan 模式产出计划) | §15.4 |
必做的验证:
// ① 「日任务睡 6 天醒来只补一次」—— 直接锁本章最核心的语义
// ② 补跑后 nextFireAt 必须 > now(防串行雪崩)
// ③ 起两个调度器实例,断言任务只被触发一次
// ④ 枚举错过时刻要有 maxRuns 上限(模拟停机 3 个月 + 每分钟任务 = 13 万次枚举)阶段总览:你会亲手撞到的坑
| 阶段 | 时长 | 最该亲手撞一次的坑 |
|---|---|---|
| 1 脚本载体 | 半天 | [].constructor.constructor 击穿参数影子 |
| 2 并发池 | 一天 | release() 不在 finally → 静默挂死 |
| 3 结构化输出 | 一天 | schema 缓存键只用工具名 → 串台 |
| 4 pipeline | 半天 | 写成有屏障 → 测试全绿但慢一个量级 |
| 5 resume | 一天 | 键用 prompt hash → 20 份一样的报告 |
| 6a 会话内调度 | 一天 | 忙时队列先 splice 再循环 → 任务凭空消失 |
| 6b 守护进程 | 一到两天 | catch-up 全补 → 开机雪崩 |
注意这七个坑里有五个是「静默的」 —— 不抛异常、测试全绿、结果看起来正常。 这就是为什么 §17 那一章存在。
附录
A. 术语表(按首次出现顺序)
编排侧
| 术语 | 一句话 |
|---|---|
| harness | 套在模型外面的工程代码:给什么工具、怎么组织上下文、怎么转圈 |
| L0/L1/L2/L3 | 编排的四个档位:单上下文 / 模型即兴派单 / 静态工作流 / 动态编排 |
| fan-out | 扇出:一个任务拆 N 份,同时派 N 个 agent |
| barrier | 屏障:「等所有人到齐才继续」的同步点 |
| thunk | () => doSomething(),一个还没执行的函数 |
| pipeline | 每个 item 独立穿过多道工序,不等别人 |
| schema | 结构约束;真实作用是「改变模型必须做的动作」 |
| StructuredOutput | 强制结构化输出的那个工具(交差的唯一出口) |
| adversarial verification | 对抗验证:派一个 agent 专门去推翻另一个的结论 |
| journal | resume 账本,append-only JSONL |
| fingerprint | (prompt, opts) 的稳定指纹,缓存键的一半 |
| callIndex | 调用序号,缓存键的另一半(区分调用点) |
| worktree | git 特性:同仓库同时签出到多个目录 |
| budget | token 预算(软读 + 硬拦两套) |
| opt-in | 默认关着,用户明确要求才启用 |
| quarantine | 隔离模式:读不可信内容的 agent 无高权限 |
调度侧
| 术语 | 一句话 |
|---|---|
| cron | 5 字段定时表达式:分 时 日 月 周 |
| recurring / one-shot | 循环 / 一次性(触发后自删) |
| durable | 写盘,跨会话存活;反面是只活在内存里 |
| jitter | 确定性抖动,避免全球卡在整点 |
| heartbeat / tick | 心跳:调度器每隔多久醒来看一眼 |
| catch-up | 错过补偿:只补最近一次 |
| daemon | 守护进程,不依赖任何终端 |
| headless | 无头执行,-p "干这个" |
| in-flight | 「这个任务上一次还在跑」的标记 |
| PID 探活 | kill(pid, 0),EPERM 判为存活 |
| fail-closed | 不确定时不做危险动作 |
| workspaceDir | 任务显式记录的工作目录(守护进程没有 cwd) |
| allowedTools | 任务级预授权白名单;缺省 = 只读 |
B. 三十秒自检清单
概念
- [ ] 编排 ≠ 调度,判据是「需不需要当前进程活着」
- [ ] 委托只有前台/后台两种,没有「定时」这第三种
编排
- [ ]
parallel传 thunk(不是 Promise) - [ ]
pipeline无屏障,且有测试锁时序 - [ ] 缓存键 = callIndex + 指纹;排除 label/phase
- [ ]
agent()返回 null 的地方都filter(Boolean) - [ ] 并发池独立;
release()在finally - [ ] 预算「软读 + 硬拦」两套
- [ ] 截断时
log(),并写进产出物(coverage_note) - [ ] verify 的模型比 find 更强
- [ ] schema 里
REFUTED合法、evidence必填 - [ ] workflow 的 usage 回流主会话计量
- [ ] 沙箱用 vm 而非参数影子(试
[].constructor.constructor) - [ ] 确定性守卫运行时抛错,不静态扫字符串
调度
- [ ] 抖动确定性(哈希,不用随机)
- [ ] 注入只在轮次间隙;队列在异常路径不丢数据
- [ ] 锁用 PID 探活,
EPERM= 存活 - [ ] 锁的位置能唯一标识项目(位置 ≠ 哈希)
- [ ] catch-up 只补最近一次,且补跑后从
now重算 - [ ]
lastFiredAt补跑后写盘 - [ ] 无头执行有
workspaceDir;无白名单降级 plan - [ ] auto-commit/push 是硬禁令
- [ ] 双触发用稳定归属规则(不抢锁)
- [ ] 定时编排任务设了
budget_total
C. 可复跑命令(本文所有数字的来源)
# ── 代码规模 ──
cd /path/to/sid-code
wc -l packages/core/src/workflow/*.ts # 1420 行 / 7 文件
wc -l packages/core/src/cron/*.ts # 980 行 / 6 文件
wc -l packages/core/src/daemon/*.ts # 1415 行 / 10 文件
wc -l packages/core/src/tool/{workflow,cron-create,cron-list,cron-delete,schedule-wakeup}.ts
# ── 测试用例数(185 个)──
for f in packages/core/tests/{workflow,cron,daemon}/*.ts packages/core/tests/tool/schedule-wakeup.test.ts; do
printf "%-40s %s\n" "$(basename $f)" "$(grep -c '^\s*\(it\|test\)(' $f)"
done
# ── 核心语义的落点(每条都能读到本文引用的注释)──
grep -n "无屏障\|barrier" packages/core/src/workflow/runtime.ts
grep -n "param-shadow\|constructor.constructor" packages/core/src/workflow/sandbox.ts
grep -n "缓存键\|callIndex" packages/core/src/workflow/journal.ts
grep -n "只补最近一次\|catch-up" packages/core/src/cron/scheduler.ts
grep -n "EPERM\|项目目录\|fail-closed" packages/core/src/cron/lock.ts
grep -n "允许的工具白名单\|permission-mode" packages/core/src/daemon/headless-executor.ts
grep -n "缓存 miss\|300s" packages/core/src/tool/schedule-wakeup.ts
# ── 搜索铁律(三条,踩出来的)──
# ① 一律 rg -a(NUL 字节会让 grep 静默零输出)
# ② 英语常用词加 -w(agent / phase / log / task)
# ③ 定位阶段逐个关键词单独搜,不要 or 模式 + -l
# -l 不告诉你是哪个词命中的,与 -n 结果对不上时会误判为工具故障最后:这份文档想让你记住的三件事
一、编排和调度是两个子系统,判据是「需不需要当前进程活着」。 把它们混在一起的第一个症状,是你想给 subagent 加一个 delay 参数 —— 然后发现关掉终端它就没了,而「关掉终端还能跑」恰恰是调度存在的全部理由。 时间只是触发器,宿主才是难点。
二、这两个子系统的失败大多不抛异常。 一个漏了 15 项的审查、一个把 20 个文件都当成第 1 个分析的 resume、 一个从来没触发过的定时任务 —— 全都「运行成功」,日志正常,测试全绿。 所以它们的测试必须断言「某件坏事没发生」,而这需要你先想到那件坏事。 §17 那份清单就是为此存在的。
三、编排治流程,不治智商。 它把输出的下限抬高(该查的查了、该验的验了), 但上限仍由模型决定。20 个笨 agent 并行得到 20 份笨结论, 而且因为数量多,看起来更有说服力。
而且——让对抗验证真正有效的,不是那套 JS 引擎、沙箱、并发池、resume, 而是两件正交的小事:schema 强制举证且允许 REFUTED, 以及 verify agent 被要求「尝试推翻」并跑在更强的模型上。 这两件事和整个引擎地基完全解耦,可以最先、最便宜地交付。
「治本的药不要排在地基后面。」 这是本文最想留下的一句话 —— 它不只适用于编排,适用于任何一份按依赖顺序排出来的工程路线图。