本页文字稿为音频的配套解读,内容取自小山学堂课程素材,属二次演绎版本。
行号与源码核对日期为 2026-08-13。
两件事:多步编排为什么拆成四个原语而非一个统一任务系统;以及发布文里那四种模式,为什么在源码里只是四份配置。
贯穿两块的线索:无论是拆四个原语还是把模式做成配置,背后都是同一个判断——能被声明的东西就不要写进代码。
| 东西 | 能否声明 | 归属 |
|---|---|---|
| 执行编排逻辑 | 千变万化,声明不完 | 交给模型写脚本 |
| 时间 / 姿态 / 展示 | 需要跨轮次、跨重启的确定性,声明得完 | 做成事件与快照 |
| 模式 | 本质是插件组合,组合天然可声明 | 只是四份 YAML |
| 原语 | 管什么 | 持久化形态 | 关键事实 |
|---|---|---|---|
workflow | 执行 | 一次性:跑完只留结果与展示记录,逻辑不落成持久状态机 | 模型写 JS 脚本,引擎在 node:worker_threads 的 vm 里执行;脚本里 agent() 打回宿主起子 Agent |
schedule | 时间 | 持久:create / dispatch / delete 都是 schedule/change 会话事件 | 回放日志即可重建全部提醒状态 |
plan | 协作姿态 | 最轻:一个 plan/mode 布尔事件的日志折叠 | 软性指引,不是权限 |
todo | 进度展示 | 快照:每次 todo_write 整表替换 | 纯展示,不驱动执行 |
四个原语没有共享一个任务引擎,连持久化形态都不一样。谁也不冒充谁。
"多步编排应该是模型写脚本还是框架状态机" → 两个都要,但分工明确:
| 误读 | 事实 |
|---|---|
| plan mode 是权限 | plan 不是权限。激活时只在系统提示词里加一段 plan:policy,工具目录一字不变(有意如此,为请求缓存稳定)。真正拦住写操作的是沙箱和审批,两者都不读 plan 状态,要分别配 |
| schedule 会通知我 | schedule 不出会话。提醒只以 followup 轮次回到原会话,没有推送、没有外部通知通道,冷会话不干活。交付语义是至少一次(落 dispatch 前崩溃 → 恢复会重复一次) |
| todo 驱动执行 | todo 不驱动执行。纯展示状态:整表替换、落日志、投影给 UI。没有部分更新、没有回读工具、没有稳定 id。把它当任务引擎用是最常见的误读 |
fatal: true 的 WorkflowError,parallel() 组合器直接重抛、终止整个脚本;null(workflow.zh.md 第 116 行)。写错代码和运行失败是两类错误,混在一起脚本就没法调了。
调用方拿到错误后第一件事是判断"该重试还是该改代码"。两类错误长得一样 → 只能盲目重试,把一次本该立刻修复的拼写错误变成一轮毫无意义的重跑。
为什么拼错要终止整个脚本而非跳过:脚本每一步可能依赖上一步结果,跳过一步会让后续步骤在错误前提上继续跑,产出"看起来完成了、实际全错"的结果。宁可整个终止,也不给被污染的结果。
出处:packages/schedule/schedule/src/domain.ts 第 536–543 行。
会话离线错过 N 个到期时点,恢复后不逐个补发——一次除法直接算出最新一次到期,再把记录推进到未来。不枚举、不回放、不积压。
const steps = Math.floor((acceptedAt - target) / interval)
const occurrence = target + steps * interval
/* v8 ignore next -- bounded operands and a quotient-derived product stay safe. */
if (!Number.isSafeInteger(occurrence) || occurrence < target || occurrence > acceptedAt) {
throw new ScheduleLogError('every occurrence arithmetic must stay within the accepted interval')
}
const occurrenceAt = new Date(occurrence).toISOString()
const next = occurrence + interval
三步:算跳过几步 → 得到本次应触发时点 → 算出下一次。
为什么要加护栏:这是纯算术,一旦溢出或算错,提醒会漂到很远的未来或过去,且极难被发现。在算术上加护栏,比事后调试便宜得多。
为什么"不补发"不是偷懒:每小时提醒的任务,离线 5 天后恢复,逐个补发会一次性涌进 120 条提醒。用户需要的不是这 120 条历史,而是"现在该做什么"。
凡批量补发的场景,先问一句:积压的这些里面,有多少是用户现在还需要的?大多数时候答案是只有最后一条。 合并是对用户注意力的保护。
出处:packages/plan/plan-mode/src/index.ts 第 205–218 行的 agent/pre-step 监听器。
用户在模型流式输出时点切换 → 插件不立刻写日志,先挂在进程内存的 pending 里,等下一个轮内 pre-step 边界才动手。
顺序讲究得很:
await next() 问下游这一步收不收;崩溃语义:pending 只活在进程内存,切换还没落日志时崩溃 → 重启后维持切换前的状态。
出处:packages/todo/tool-todo/src/index.ts 第 41–43 行(schema)、第 107–109 行(报错)。
allowParallelInProgress 是必填配置,schema 里写的是 z.boolean().required(),没有默认值。
为什么强制:允不允许多个任务同时进行中,取决于这个部署跑不跑并发子 Agent,工具自己观测不到,所以强制部署方表态。设成 false 后,模型多标一个进行中就吃错误("at most one task may be in_progress")。
| Claude Code | DeepSeek Harness | |
|---|---|---|
| 载体 | 提示词硬编码:"Exactly ONE task must be in_progress at any time (not less, not more)";条目要求 content + activeForm 双形态 | 必填部署配置 + 代码执行 |
| 出处 | study/chapters/14-all-prompts.md 第 1243–1293 行引 TodoWriteTool/prompt.ts | schema 第 41–43 行 |
| 条目形状 | 双形态,较丰富 | 刻意最小:只有 content + 三态 status |
一个用提示词约束模型,一个用 schema 约束部署然后让代码执行。
代价对比:提示词路线改起来快,但模型可能不听,且不听时你没有任何证据去追究;schema 路线确定性强,但部署方必须先想清楚,没法含糊过去。
为什么不给默认值:默认值就是替部署方做了一个决定,而这个决定工具观测不到。给了默认值,大多数部署方会一路回车,然后在某个场景下被这个默认决定坑到。强制表态,是把隐藏决策推到明面上。
| 产品 | 路线 | 换来什么 |
|---|---|---|
| Claude Code | 聚合:七种异步工作(shell 命令、本地子 Agent、远程 Agent、Teammate、工作流、MCP 监控、记忆整合)统一挂在一个 Task 框架下,共享 registerTask / updateTaskState / kill 生命周期(study/chapters/06-task-system.md 第 27–47 行) | 统一的进度 UI 与管理入口 |
| DeepSeek Harness | 拆分:subagent 文档明确写可继续路径"不会创建 Task,也不会创建承载中间结果的包装层";四个编排原语各有各的持久化形态 | 每个原语能把自己的语义说到底 |
schedule 的错过合并、plan 的 pending 切换,塞进统一框架里都得妥协——因为统一框架要求所有工作共享一套状态模型,而这两处语义偏偏都是特例。
这道选择题的实质:你要不要为统一的进度界面,牺牲每个原语的语义深度。
思路同源、落点不同:workflow 的 meta 字段词汇与 Claude Code 的 dynamic workflows 对齐(workflow.zh.md 第 41、49 行)——对齐词汇表意味着两边写出来的东西能互相理解。
先解发布文的悬念:标准 / 代码 / 极简 / 创造四种模式,在源码里找不到一行模式分支。apps/cli/config/agent-presets/ 下就是四个目录,每个目录一份 agent.cordis.yml——一份文件描述一种插件组合,给一个会话挂载。
| 模式 | 构成 |
|---|---|
极简 minimal | 全文 62 行:persona 一句 + complete: true(拒绝后续拼装往提示词里加料),工具只有持久 bash 与编辑器,连压缩都没有——跑基准测试的配置 |
创造 cordis | standard 原封不动 + 自指工具集 tool-cordis + 教写组合的 skill + 教模型分清两个平面的 persona |
| 平面 | 放什么 |
|---|---|
| HOST | 跨会话共享:持久化、沙箱与审批、模型路由、subagent 注册表 |
| AGENT PRESET | 一个会话贡献给这些注册表的东西:它的工具、persona、提示词段落 |
边界画得多细:preset 里发布服务的行,要么归 host,要么包进 isolate realm。极简模式想用不带沙箱的本地文件系统,就把 fs-local 包在自己的 realm 里,只遮蔽自己这个会话,别的会话照旧走沙箱(minimal/agent.cordis.yml 第 46–57 行)。
出处:packages/skill/skill/src/index.ts 第 552–566 行的 collectFresh。
实现细节:把所有层排成一列(全局层最前,preset 作用域链按远祖先在前、本层最后依次跟上),按顺序把每层条目灌进同一个 Map,键是 skill 名字。Map 的天性就是后写的覆盖先写的——遮蔽就是一次 Map.set,没有任何跨层权重比较。
为什么坚持遮蔽要干脆、不做跨层合并排序:一旦模型看到两个同名 skill 就无所适从。宁可让近层直接赢,也不要搞出一套谁也记不住的权重规则。
设计记录里专门否决过跨层合并排序的方案(.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.zh.md)。
流程(依据 packages/extensions/tool-cordis/src/index.ts 第 148–259 行工具描述整理):
cordis_inspect_query → 读 Service 与 Builtin 的精确签名;cordis_define kind:"new" → 返回 pluginId / packageId;cordis_run mode:"run" → v1 激活,新工具进目录;cordis_define kind:"existing" 追加 v2(v1 原样保留)→ cordis_run mode:"update" 切到 v2,失败可回滚 v1。| 纪律 | 内容 |
|---|---|
| 1. define 不执行 | cordis_define 只校验参数与语法、把源码记成不可变 Package;不申请审批、不执行、不动 currentPackageId。要跑起来必须再调 cordis_run。定义与激活分开,改坏了才有得回滚 |
| 2. 失败不动指针 | cordis_run 只在完全成功后才切 currentPackageId;启动失败时旧 current 原地不动 |
| 3. 旧版本永远保留 | 改版本用 kind:"existing" 追加新 Package,旧版本永不被覆盖。回滚就是 run 一个旧 ID |
创造模式 YAML 文件头原话(cordis/agent.cordis.yml 第 1–12 行):
TRUST: cordis_mount evaluates model-written JavaScript against the live runtime, and a composition this agent writes becomes a preset other sessions mount. Treat a session on this preset as shell access — the toolset's own documentation makes the same statement.
模型写的 JS 贴着活运行时跑,没有沙箱兜底。防线画在"谁能用这个 preset"上,代码本身不设围栏。给谁开创造模式,等于给谁开 shell。
.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md)。否则回放时工具目录与当时不一致,回放就不是回放了。cordis/agent.cordis.yml 第 27 行):升级会整个覆盖它,且改坏 cordis preset 等于亲手关掉自己所在的模式。要改就复制出去改副本。| Claude Code | DeepSeek Harness | |
|---|---|---|
| 载体 | 带 frontmatter 的 markdown:description、可用工具、model;AgentTool 参数可临时覆盖 model(study/chapters/05-multi-agent.md 第 32–38 行)。Skills 同理,一个目录一份 SKILL.md | 整包插件组合:persona 只是组合里普通的一行插件,和工具、压缩策略、subagent 后端平起平坐 |
| 差异化深度 | 取决于 frontmatter 开放了哪些字段 | 极简模式可换掉整个文件系统实现、关掉压缩——这种深度在 frontmatter 里表达不出来 |
| 门槛 | 低:写一份 markdown | 高:要懂两个平面和 isolate realm;所以创造模式随身带教学 skill 与 cordis_inspect_query |
一边是低门槛的角色卡,一边是全功率的组合语言。 两家对"写角色的人是谁"想得很不一样。
编排原语:
模式与自我修改:
deepseek-harness-master 本地仓库核对。活一次运行的用脚本式编排,活到会话重启之后的用持久事件,只是给人看的用快照。状态需要活多久,决定了它该用什么形态存。
一类是你的问题,一类是环境的问题。混在一起,调用方就只能盲目重试。
姿态只是姿态,真正拦住动作的是沙箱和审批,两者要分别配。别以为开了某个模式就安全了。
提示词靠模型自觉,schema 加代码执行靠机制。强制必填、不给默认值,是把隐藏决策推到明面上的最好方式。
定义不执行、失败不动指针、旧版本永远保留——三条缺一条,让智能体改写自己就是一场赌博。改版本是追加,不是覆盖。
其一:模型正在流式输出一大段方案,用户此刻点了"进入 plan mode"。这个选择什么时候真正写进日志、什么时候开始影响模型请求?若这一轮结束前进程崩了,重启后 plan mode 是开还是关?
答:切换先挂在进程内存的 pending 里,不立刻写日志;到下一个轮内 agent/pre-step 边界,且下游接受这一步、信号未取消时,才追加进日志 → 从下一轮开始影响模型请求。若这一轮结束前崩溃,pending 随进程内存消失 → 重启后维持切换前的状态(关)。
其二:一条 every_seconds: 3600 的提醒,会话离线 5 小时后恢复,恢复瞬间会触发几次提醒、下一次目标定在哪?
答:steps = floor((acceptedAt - target) / interval),离线 5 小时 → steps = 5;occurrence = target + 5 * interval → 只触发 1 次(最新一次到期),不逐个补发;next = occurrence + interval → 下一次目标定在本次之后 1 小时。
其三:创造模式的 Agent 直接编辑了发行版 cordis/agent.cordis.yml 并改坏,下一个想用创造模式的会话会怎样?
答:该 preset 已损坏 → 下一个挂载它的会话会失败或行为异常。且升级会整个覆盖它,改坏 cordis preset 等于亲手关掉自己所在的模式。铁律:复制出去改副本。
来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)
本内容改编自小山学堂课程素材,为二次演绎版本。