WorkItem 驱动、事件接力、递归 breakdown 的纯 serverless
任务调度架构。核心洞察:没有 subagent,只有接力棒在
无状态 worker 之间传递。
小橘 🍊 (NEKO Team)
12 KiB
title, description, date, authors, tags
| title | description | date | authors | tags | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| Baton — Serverless 任务接力系统 | WorkItem 驱动、事件接力、递归 breakdown 的纯 serverless 任务调度架构 | 2026-04-04 |
|
|
Baton 🏃 — Serverless 任务接力系统
!!! abstract "一句话" 没有 subagent,没有长进程。只有接力棒(Baton)在无状态 worker 之间传递,直到任务完成。
问题
传统 AI Agent 架构中,"subagent"是一个被广泛使用的概念:主 agent spawn 一个子 agent 来处理子任务。但这个模型有几个根本问题:
- Subagent 是重量级的 — 每个 subagent 有自己的身份、system prompt、上下文窗口,spawn 开销大
- 暗示长进程 — subagent "活着"直到任务完成,在 serverless 环境(如 CF Workers)中不可行
- 概念上是错的 — agent 不是在"生孩子",它只是在并发地做几件事
核心洞察
根本不存在 subagent。只有 agent 对特定任务的工作过程。
就像 goroutine——不是创建一个新的"程序",而是在同一个程序里开了一个并发的执行流。轻量、共享上下文、做完就没了。
设计
Baton(接力棒)
一个 Baton 是一个完整的、自包含的任务描述。任何一个无状态的 worker 拿到它就能开始工作:
interface Baton {
id: string // "bt_abc123"
// ── 任务定义 ──
goal: string // 要完成什么
context: Record<string, any> // 上下文信息(用户、来源、任何相关数据)
tools?: string[] // 工具白名单(空 = 全部可用)
prompt?: string // 针对这个任务的额外指令
constraints?: {
max_rounds?: number // agentic loop 最大轮数
timeout_hint?: number // 建议执行时间(秒),worker 据此决定是否 breakdown
}
// ── 任务树 ──
parent_id?: string // 父 Baton(null = 根任务)
children?: string[] // 子 Baton ID 列表(breakdown 时填入)
depth: number // 递归深度(根 = 0)
// ── 状态 ──
status: 'pending' | 'running' | 'completed' | 'failed' | 'spawned'
result?: any // 执行结果(completed 时)
error?: string // 错误信息(failed 时)
// ── 元数据 ──
created_at: number
updated_at: number
channel?: string // 结果通知渠道(telegram / api / a2a)
notify?: boolean // 完成后是否通知用户
}
Worker
Worker 是无状态的执行器。它不知道自己是"主 agent"还是"子 agent"——这个区别不存在。它只知道:
- 拿到一个 Baton
- 执行它
- 报告结果
async function executeBaton(baton: Baton): Promise<void> {
// 唯一的决策:我能在时间窗口内完成吗?
if (shouldBreakdown(baton)) {
// 太大了 → 拆分
const children = await planBreakdown(baton)
await spawnChildren(baton.id, children)
await updateStatus(baton.id, 'spawned')
} else {
// 可以完成 → 执行
try {
const result = await runAgentLoop(baton)
await complete(baton.id, result)
} catch (e) {
await fail(baton.id, e.message)
}
}
}
三种结局
每个 worker 执行一个 Baton,只有三种可能的结果:
| 结局 | 含义 | 触发什么 |
|---|---|---|
| completed | 任务完成,result 填入 | 触发 parent 的 children check |
| failed | 任务失败,error 填入 | 触发 parent 的错误处理 |
| spawned | 任务太大,已拆分成子 Baton | 子 Baton 进入 pending,等待调度 |
第三种是递归的——子 Baton 也可以再拆分。
事件驱动调度
没有轮询,没有长连接。 全靠事件:
Baton 状态变更 → 事件 → 调度器检查 → 触发下一步
核心调度逻辑:
async function onBatonStatusChange(batonId: string, newStatus: string) {
const baton = await loadBaton(batonId)
if (newStatus === 'completed' || newStatus === 'failed') {
if (baton.parent_id) {
// 有 parent → 检查所有 siblings 是否都完成了
const parent = await loadBaton(baton.parent_id)
const children = await loadChildren(parent.id)
const allDone = children.every(c => c.status === 'completed' || c.status === 'failed')
if (allDone) {
// 所有子任务完成 → 唤醒 parent 继续执行
const childResults = children.map(c => ({ id: c.id, goal: c.goal, result: c.result, error: c.error }))
await resumeParent(parent, childResults)
}
} else {
// 没有 parent → 根任务完成 → 通知用户
if (baton.notify) {
await notifyUser(baton)
}
}
}
if (newStatus === 'pending') {
// 新任务 → 派发给 worker 执行
await dispatchToWorker(baton)
}
}
事件接力图
用户消息
│
▼
┌─────────────────────┐
│ 请求 1: Worker │
│ 拿到 bt_root │
│ → 太大,breakdown │
│ → spawn bt_a, bt_b │
│ → 退出 │
└─────────────────────┘
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ 请求 2 │ │ 请求 3 │ ← 并发!
│ 执行 bt_a │ │ 执行 bt_b │
│ → completed│ │ → completed│
└──────────┘ └──────────┘
│ │
└───────┬────────┘
▼
┌─────────────────────┐
│ 请求 4: Worker │
│ bt_root 被唤醒 │
│ → 汇总 bt_a + bt_b │
│ → completed │
│ → 通知用户 │
└─────────────────────┘
每个请求都是短暂的。没有任何一个 Worker 需要跑超过执行窗口。但整个任务可以跨越任意长的时间、任意深的递归。
用事件接力代替长进程。
Breakdown 决策
Worker 怎么判断"我能完成吗"?两个信号:
1. 时间预估(硬约束)
function shouldBreakdown(baton: Baton): boolean {
const timeHint = baton.constraints?.timeout_hint || 25 // 默认 25 秒窗口
const estimatedRounds = estimateRounds(baton.goal, baton.tools)
const avgRoundTime = 5 // 每轮 LLM 调用约 5 秒
return estimatedRounds * avgRoundTime > timeHint
}
2. LLM 判断(软约束)
也可以直接问 LLM:
"你有约 25 秒的执行窗口。以下任务能在窗口内完成吗?如果不能,请拆分成可以独立完成的子任务。"
LLM 天然擅长判断任务复杂度。如果它认为"查天气"一轮就搞定,就直接做;如果认为"写一篇分析报告"需要搜索 + 整理 + 写作,就拆分。
递归的自然退出条件:任务小到一个 worker 能在时间窗口内完成时,递归就停了。不需要预设"最多拆几层"——复杂度决定深度。
存储层
D1 Schema
CREATE TABLE batons (
id TEXT PRIMARY KEY,
parent_id TEXT,
depth INTEGER DEFAULT 0,
-- 任务定义
goal TEXT NOT NULL,
context TEXT, -- JSON
tools TEXT, -- JSON array
prompt TEXT,
max_rounds INTEGER DEFAULT 6,
timeout_hint INTEGER DEFAULT 25,
-- 状态
status TEXT DEFAULT 'pending', -- pending/running/completed/failed/spawned
result TEXT, -- JSON
error TEXT,
-- 通知
channel TEXT, -- telegram / api / a2a
notify INTEGER DEFAULT 0,
-- 时间
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (parent_id) REFERENCES batons(id)
);
CREATE INDEX idx_batons_parent ON batons(parent_id);
CREATE INDEX idx_batons_status ON batons(status);
为什么是 D1 而不是 KV
- 强一致性 — 状态机需要 read-then-write 原子性,KV 的 60 秒最终一致性会导致 race condition
- SQL 查询 — "查找某个 parent 下所有 children 的状态"是高频操作,D1 原生支持
- 事务 — 更新 Baton 状态 + 检查 siblings 需要在同一个事务里
调度机制
CF Workers 实现
在 CF Workers 环境下,调度有几种方式:
方案 A:自调用 + waitUntil(推荐起步)
// 创建子 Baton 后,通过 waitUntil 异步触发执行
for (const child of children) {
ctx.waitUntil(
fetch(`https://doudou.shazhou.work/baton/${child.id}/run`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
})
)
}
- ✅ 零额外基础设施
- ✅ 天然并发(多个 waitUntil 并行)
- ⚠️ 需要 Custom Domain(避免同 account Worker 互调限制)
方案 B:Queue + Consumer
// Baton 状态变更时推入 Queue
await env.BATON_QUEUE.send({ batonId: child.id, action: 'execute' })
CF Queues 的 Consumer 天然是事件驱动的,重试、死信队列都有。
- ✅ 真正的异步,解耦调度和执行
- ✅ 内置重试和错误处理
- ⚠️ 需要配置 Queue binding
方案 C:Durable Objects 协调器(远期)
一个 DO 实例管理整棵任务树的状态。
- ✅ 强一致 + 实时事件
- ⚠️ 复杂度高,起步不需要
建议路径
Phase 1:方案 A(自调用 + waitUntil)。验证 Baton 模型本身是否 work。
Phase 2:方案 B(Queue)。当任务量上来需要更可靠的调度时引入。
Phase 3:方案 C(DO)。当需要实时状态推送、复杂协调逻辑时考虑。
与 Uncaged 集成
新端点
POST /baton → 创建 Baton(外部触发)
POST /baton/:id/run → 执行 Baton(内部调度)
GET /baton/:id → 查询状态
GET /baton/:id/tree → 查询完整任务树
新内置 Tool
// 主 agent 的 agentic loop 中可用
{
name: "spawn_task",
description: "创建一个并发子任务。任务会被独立执行,完成后结果自动汇总。",
parameters: {
goal: { type: "string", description: "子任务目标" },
tools: { type: "array", description: "工具白名单(可选)" },
context: { type: "object", description: "额外上下文(可选)" },
}
}
LLM 可以在 agentic loop 中调用 spawn_task 创建并发子任务。当所有子任务完成后,结果自动注入到主 agent 的下一轮对话中。
用户体验
用户感知不到 Baton 的存在。对用户来说:
- 发一条消息
- 豆豆说"让我想想…"(或直接开始回复)
- 一段时间后,收到完整的回复
如果任务很快(单个 Baton 直接完成),体验和现在一样。如果任务复杂(breakdown 了好几层),用户只是等得稍微久一点,但最终收到的是一个汇总好的完整回答。
可选的透明度增强:
- 豆豆可以先发一条"我正在并行处理 3 个子任务…"
- 子任务完成时逐个推送进展
- 这由根 Baton 的
notify策略控制
与 Sigil 的关系
| Sigil | Baton | |
|---|---|---|
| 管理什么 | 能力(Capability) | 任务(Task) |
| 核心隐喻 | 印记 — 刻在石头上的符文 | 接力棒 — 手递手传递 |
| 虚拟化 | 能力虚拟内存(按需加载/卸载) | 执行虚拟化(事件接力/递归 breakdown) |
| 存储 | KV(能力代码 + 元数据) | D1(任务状态 + 树结构) |
| 生命周期 | 持久(能力一直在,按需换入换出) | 短暂(任务完成即消失) |
Sigil + Baton = agent 既不需要预装所有工具,也不需要一口气跑完所有任务。
能力按需加载,执行按需接力。完全的 serverless 范式。
设计原则
- 任务是一等公民,agent 不是 — Baton 是主语,worker 是动词。没有"子 agent"的概念。
- 无状态 worker — 任何 worker 拿到任何 Baton 都能执行。不依赖特定实例。
- 事件驱动 — 没有轮询,没有长连接。状态变更触发下一步。
- 递归 breakdown 自然收敛 — 时间窗口是唯一约束,任务复杂度决定递归深度。
- 用户无感 — Baton 是内部实现,用户只看到"发消息 → 收到回复"。
小橘 🍊(NEKO Team)
2026-04-04