学 AI 产品 · 专业 AI 产品经理播客第 4 章 · T4 解剖 DeepSeek Harness:一切皆插件的 Agent 底座 · EP 14
第 4 章 · EP 14

workflow / schedule / plan / todo:编排原语的取舍 等 2 节

时长 16:47音色 云健 · 男声

同步字幕

章节导航(点击跳转)

0:00开场 · 四个原语,各管一摊1:53
1:53workflow 管执行1:58
3:52schedule 管时间2:17
6:09plan 管姿态,而且是软的1:21
7:30todo 管展示,不驱动执行2:10
9:41统一框架还是四个原语1:04
10:45模式只是一份配置2:20
13:06自我修改的三条纪律2:10
15:17可带走的原则1:29
解读全文

dsh14 · workflow / schedule / plan / todo:编排原语的取舍 · Skill、Preset 与自我修改

  • 模块:T4 解剖 DeepSeek Harness:一切皆插件的 Agent 底座
  • 集页:https://xueai-podcast.pages.dev/t/dsh14/
  • 取材课节:workflow / schedule / plan / todo:编排原语的取舍、Skill、Preset 与自我修改(2 节,素材 8358 字)
  • 来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)
本页文字稿为音频的配套解读,内容取自小山学堂课程素材,属二次演绎版本。
行号与源码核对日期为 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。把它当任务引擎用是最常见的误读

workflow 的错误分类纪律

  • 脚本里拼错选项 → 抛 fatal: true 的 WorkflowError,parallel() 组合器直接重抛、终止整个脚本;
  • 子 Agent 真实运行失败 → 映射成逐项的 null(workflow.zh.md 第 116 行)。
写错代码和运行失败是两类错误,混在一起脚本就没法调了。
调用方拿到错误后第一件事是判断"该重试还是该改代码"。两类错误长得一样 → 只能盲目重试,把一次本该立刻修复的拼写错误变成一轮毫无意义的重跑。

为什么拼错要终止整个脚本而非跳过:脚本每一步可能依赖上一步结果,跳过一步会让后续步骤在错误前提上继续跑,产出"看起来完成了、实际全错"的结果。宁可整个终止,也不给被污染的结果。


四、关键证据 · 错过合并与 pending 切换

1. schedule 的固定速率决策(唯一值得整段看的代码)

出处: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 条历史,而是"现在该做什么"。

凡批量补发的场景,先问一句:积压的这些里面,有多少是用户现在还需要的?大多数时候答案是只有最后一条。 合并是对用户注意力的保护。

2. plan mode 的 pending 切换

出处:packages/plan/plan-mode/src/index.ts 第 205–218 行的 agent/pre-step 监听器。

用户在模型流式输出时点切换 → 插件不立刻写日志,先挂在进程内存的 pending 里,等下一个轮内 pre-step 边界才动手。

顺序讲究得很:

  1. 监听器先 await next() 问下游这一步收不收;
  2. 下游拒绝 / 信号已取消 / 没有 pending → 都原样放行;
  3. 三关都过 → 才把选择追加进日志;
  4. 追加万一失败 → 只记一条 warn 然后放行这一步,绝不因一次姿态切换失败而阻塞整个轮次。

崩溃语义:pending 只活在进程内存,切换还没落日志时崩溃 → 重启后维持切换前的状态。


五、todo 上最有态度的设计

出处: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 CodeDeepSeek 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.tsschema 第 41–43 行
条目形状双形态,较丰富刻意最小:只有 content + 三态 status
一个用提示词约束模型,一个用 schema 约束部署然后让代码执行。

代价对比:提示词路线改起来快,但模型可能不听,且不听时你没有任何证据去追究;schema 路线确定性强,但部署方必须先想清楚,没法含糊过去。

为什么不给默认值:默认值就是替部署方做了一个决定,而这个决定工具观测不到。给了默认值,大多数部署方会一路回车,然后在某个场景下被这个默认决定坑到。强制表态,是把隐藏决策推到明面上。


六、横向对比 · 统一 Task 框架 vs 四个独立原语

产品路线换来什么
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 行)——对齐词汇表意味着两边写出来的东西能互相理解。


七、模式只是一份 YAML

先解发布文的悬念:标准 / 代码 / 极简 / 创造四种模式,在源码里找不到一行模式分支。apps/cli/config/agent-presets/ 下就是四个目录,每个目录一份 agent.cordis.yml——一份文件描述一种插件组合,给一个会话挂载。

模式构成
极简 minimal全文 62 行:persona 一句 + complete: true(拒绝后续拼装往提示词里加料),工具只有持久 bash 与编辑器,连压缩都没有——跑基准测试的配置
创造 cordisstandard 原封不动 + 自指工具集 tool-cordis + 教写组合的 skill + 教模型分清两个平面的 persona

两个平面(坐标系)

平面放什么
HOST跨会话共享:持久化、沙箱与审批、模型路由、subagent 注册表
AGENT PRESET一个会话贡献给这些注册表的东西:它的工具、persona、提示词段落

边界画得多细:preset 里发布服务的行,要么归 host,要么包进 isolate realm。极简模式想用不带沙箱的本地文件系统,就把 fs-local 包在自己的 realm 里,只遮蔽自己这个会话,别的会话照旧走沙箱(minimal/agent.cordis.yml 第 46–57 行)。

skill 分层注册表

  • 全局层:部署级注册的(仓库插件);
  • preset 层:随 preset 走的;
  • 读取规则:近层同名直接赢,排序权重只在同一层内起作用。

出处: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 行工具描述整理):

  1. cordis_inspect_query → 读 Service 与 Builtin 的精确签名;
  2. cordis_define kind:"new" → 返回 pluginId / packageId;
  3. cordis_run mode:"run" → v1 激活,新工具进目录;
  4. 不满意 → cordis_define kind:"existing" 追加 v2(v1 原样保留)→ cordis_run mode:"update" 切到 v2,失败可回滚 v1。

让"Agent 写 Agent"有得后悔的三条纪律

纪律内容
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。

两条易忽略规则

  1. 工具集中途变了形状时,会话日志记录变更后的完整请求头,维持"模型看到的 ⟺ 日志里的"不变量(.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md)。否则回放时工具目录与当时不一致,回放就不是回放了。
  2. persona 明令绝不许编辑发行版 preset 目录(cordis/agent.cordis.yml 第 27 行):升级会整个覆盖它,且改坏 cordis preset 等于亲手关掉自己所在的模式。要改就复制出去改副本。

横向对比 · 一份配置描述角色 vs 一整包插件组合角色

Claude CodeDeepSeek 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
一边是低门槛的角色卡,一边是全功率的组合语言。 两家对"写角色的人是谁"想得很不一样。

九、审查清单

编排原语:

  1. 状态需要活多久?活一次 run / 活到重启后 / 只是给人看 —— 是否与所选原语的持久化形态匹配?
  2. 执行编排是否交给了模型写脚本?时间 / 姿态 / 展示是否交给了确定性更强的机制?
  3. 写错代码与运行失败是否分开报错?
  4. 姿态(plan)是否被误当成权限?沙箱与审批是否分别配置?
  5. 批量补发场景是否做了合并而非逐个重放?
  6. 算术是否有溢出 / 边界护栏?
  7. 纪律是靠提示词、schema 还是代码执行?能用 schema 的是否用了 schema?

模式与自我修改:

  1. 模式是代码分支还是声明式组合?
  2. 两个平面(HOST / PRESET)边界是否清晰?服务发布是否要么归 host 要么包 realm?
  3. skill 分层遮蔽是否干脆(近层直接赢),有无跨层权重比较?
  4. 自我修改是否做到:define 不执行、失败不动指针、旧版本永留?
  5. 信任边界是否写在配置注释里并被当作 shell 级别对待?

十、约束说明

  • 行号口径:基于 2026-08-13 对 deepseek-harness-master 本地仓库核对。
  • 演示口径:课程交互演示中的场景与流程为教学化模拟;边界事实出自四篇子系统文档与对应源码。
  • 证据边界:关于 CC 的 Task 框架、TodoWrite 提示词纪律、角色卡路线,基于已公开书稿/还原源码证据。
  • 本页用途:文字稿仅供阅读,音频以集页播放器为准;题目页内容不进入音频。

十一、实践提示

提示一 · 先问状态要活多久

活一次运行的用脚本式编排,活到会话重启之后的用持久事件,只是给人看的用快照。状态需要活多久,决定了它该用什么形态存。

提示二 · 写错代码和运行失败要分开报

一类是你的问题,一类是环境的问题。混在一起,调用方就只能盲目重试。

提示三 · 软性指引不要冒充权限

姿态只是姿态,真正拦住动作的是沙箱和审批,两者要分别配。别以为开了某个模式就安全了。

提示四 · 能用 schema 约束的纪律,别写成提示词

提示词靠模型自觉,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 等于亲手关掉自己所在的模式。铁律:复制出去改副本。


十三、Takeaway

  • 执行编排交给模型写脚本,时间 / 姿态 / 展示交给框架状态机;四个原语四种持久化形态,谁也不冒充谁;
  • 挑原语先问一句:这件事的状态需要活多久? 活一次 run 的用 workflow,活到会话重启之后的用 schedule 与 plan,只是给人看的用 todo;
  • 模式 = 一份 YAML 描述的插件组合,自我修改 = 在运行时往组合里加行减行;
  • define 只记录、run 才激活、失败不动指针,三条纪律让"Agent 写 Agent"有得后悔;
  • 安全不靠沙箱靠信任边界:给谁开创造模式,等于给谁开 shell。

来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)

本内容改编自小山学堂课程素材,为二次演绎版本。