跳转到内容

bg-worker:重 IO 从主 turn 剥离

孩子说:“给我生成一个奥特曼跟怪兽打架的小视频。”

agent 想了 2 秒,决定调 fal-cli text-to-video。fal 那边的模型平均生成时间 30-60 秒,长的能 120 秒。

如果直接同步等:

[T+0s] 用户消息进 agent
[T+2s] agent thinking 完成,决定调 fal-cli
[T+2s] fal-cli 子进程启动
[T+62s] fal 返回视频 URL
[T+62s] agent 拿到 URL,开始回复 "好啦,给你做了一个..."
[T+64s] 用户终于看到第一个字

60 秒 typing indicator。在 K12 chat 里这是灾难——孩子注意力跑了,他切到 B 站去了,回来都忘了自己刚才让你干嘛。

更糟的:Claude Agent SDK 默认 turn timeout 是 5 分钟。一个工具调用占掉 60 秒不致命,但如果 agent 在一个 turn 内决定连调三个生成任务,立刻 timeout。

这是任何 agent harness 都会撞的问题:主 turn 必须保持响应性,但 LLM 想做的事经常超出这个响应窗口。

我们用 Claude Agent SDK 作 runtime,SDK 的工具调用是 cooperative:

PreToolUse(tool, input) → tool.run() → PostToolUse(tool, result)
阻塞整个 turn 直到返回

tool.run() 是同步等的。SDK 没有 fire-and-forget 协议——没有 “我把任务派出去,turn 先结束” 的语义。

所以这事只能在 SDK 之上自己加一层。

我们的模式是 submit_job + bg-worker + claude -p

┌────────────────────────────────────────────────────────────┐
│ 主 agent turn │
│ ↓ 工具调用 │
│ submit_job(kind="generate-video", params={...}) │
│ ↓ 立即返回 batch_id(< 50ms) │
│ end turn,agent 说"我去给你做了,几分钟好" │
└────────────────────────────────────────────────────────────┘
↓ (写 bg_jobs 一行 + 写一条 system 消息进 SQLite)
┌────────────────────────────────────────────────────────────┐
│ bg-worker (Go 进程,独立 daemon) │
│ 每 1s 轮询 bg_jobs where status='pending' │
│ 拿到任务 → claim (status=running) → fork 子进程 │
└────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────┐
│ claude -p (临时 agent 进程,跑 skill) │
│ 读 SKILL.md 里的 spec prompt │
│ 执行 skill 逻辑(fal-cli / class CLI / fal API ...) │
│ stdout 输出 {"status":"ok","summary":"...","video_url":...}│
└────────────────────────────────────────────────────────────┘
↓ (worker 读 stdout JSON,update bg_jobs, 写 system 消息)
┌────────────────────────────────────────────────────────────┐
│ 主 agent 下一个 turn │
│ polling SQLite 看到 [clawbox-internal:job-done] system 消息 │
│ 决定要不要跟孩子说"好啦" │
└────────────────────────────────────────────────────────────┘

主 agent turn 的 submit_job 工具实现:

packages/agent-engine/src/channel-mcp.ts
tool(
"submit_job",
[
"【提交后台长任务】把耗时操作(视频生成、PPT 渲染等)扔给 bg-worker,",
"立即返回 batch_id 不阻塞当前 turn。",
"",
"**什么时候用**:任何预计 > 10s 的工具调用。直接同步等会撞 turn 300s",
"上限或让用户感受'卡了'。",
].join("\n"),
{
kind: z.string().describe("任务类型,对应 skills/<name>/job-spec.yaml 的 kind"),
params: z.record(z.string(), z.any()).describe("任务参数"),
},
async ({ kind, params }) => {
const batchId = `${ymdHMS()}-${randomBytes(4).toString("hex")}`;
insertBgJob(kind, batchId, JSON.stringify(params ?? {}));
insertSystemMessage(
ctx.recipientId,
`[clawbox-internal:job-queued]\nkind=${kind}\nbatch_id=${batchId}\n\n后台任务已入队。`,
`${kind}-watcher`,
);
return {
content: [{ type: "text", text: `submitted kind=${kind} batch_id=${batchId}` }],
};
},
),

batch_id 格式是 YYYYMMDDHHMMSS-<4 hex>——人眼可读,按时间排序,跨进程唯一。

bg_jobs 表(共用 agent 那个 SQLite):

CREATE TABLE IF NOT EXISTS bg_jobs (
kind TEXT NOT NULL,
batch_id TEXT NOT NULL,
params TEXT DEFAULT '',
status TEXT NOT NULL, -- 'pending' | 'running' | 'ok' | 'failed'
created_at INTEGER NOT NULL,
started_at INTEGER,
finished_at INTEGER,
log_path TEXT DEFAULT '',
summary TEXT DEFAULT '',
error TEXT DEFAULT '',
attempts INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (kind, batch_id)
);

worker 是 Go binary,单文件 main loop,~80 行核心逻辑。它启动时加载 ~/.claude/skills/*/job-spec.yaml,知道每种 kind 对应哪个 skill。

为什么 skill 用 claude -p 子进程而不是 in-process

Section titled “为什么 skill 用 claude -p 子进程而不是 in-process”

最开始我们考虑过在 worker 里 in-process import Claude Agent SDK 来跑 skill。否决了。

理由:

1. 上下文隔离。 主 agent 攒了 6000 tokens 的 turn 历史。skill 不应该看到。claude -p 是新进程,新 conversation history,干净。

2. 失败隔离。 skill 跑挂了(OOM、网络炸、API 错),不会影响主 agent。子进程 exit code 非 0,worker 把任务标 failed,主 agent 在下一个 turn 看到 [clawbox-internal:job-failed] system 消息,自己决定怎么跟用户解释。

3. 独立版本管理。 每个 skill 是独立的目录 + SKILL.md。改一个 skill 不需要重新部署 agent-engine。

4. 资源限定。 子进程可以设 ulimit、绑死 CPU、调度优先级。主 agent 是 user-facing 必须低延迟,skill 是后台可以慢。

代价:每跑一次 skill 是一次完整的 Claude API 调用(context 从零开始)。我们的 measurements:

项目数值
skill 调用平均 input tokens~3000-8000(SKILL.md + params)
skill 调用 cost (MiniMax)TODO: 测——估算 $0.001-$0.01
skill 进程冷启动~500ms(node + sdk import)

冷启动 500ms 在异步语境下不是问题。如果用 in-process 节省这 500ms,换来上面四条隔离的丢失,不值。

  • fill-plan-item:填充课程占位卡。读学生 atlas → 决定难度 → 调 class CLI 创建 course / practice → 返回 content_ref。典型耗时 60-120s。
  • analyze-upload:分析用户上传的图/PDF/视频。调 vision / OCR → 输出 summary + 学习钩子。典型耗时 5-30s。
  • generate-video:调 fal-cli 走 fal-ai/wan/v2.2-5b/text-to-video 生成短视频。典型耗时 30-90s。

每个 skill 都是 ~/.claude/skills/<name>/ 目录:

~/.claude/skills/generate-video/
├── SKILL.md # spec prompt("You are the generate-video skill...")
├── job-spec.yaml # kind, timeout, retries, schema
└── (no code — skill 行为完全由 SKILL.md prompt + 内置工具实现)

skill 不是 Python / Go 写的——是用 prompt 描述的 agent 行为claude -p 进去之后,它是一个临时 agent,按 SKILL.md 的 spec 干活。

这是有意的设计:让 skill 跟主 agent 一样可被 prompt 调优,不引入第二套编程模型。

异步异步异步——但用户不知道 “异步”。从用户视角:

  • 他说 “给我做个视频”
  • agent 说 “好的,几分钟给你”
  • 30 秒、60 秒、90 秒过去
  • agent 突然弹出来 “做好了,看 [视频链接]”

中间那段时间,用户不知道发生了什么。他可能切去做别的事,可能等得焦虑,可能反复问 “好了吗”。

我们的当前应对:

  • agent 在 submit_job 之后那个 turn 的回复里必须给出预期时间(“大约一分钟”)
  • worker 完成时塞回一个 system 消息,agent 在下一个 turn 自然继续话题,不刻意 “通知”
  • 用户反复问 “好了吗” 时,agent 可以查 bg_jobs(通过另一个工具)报进度

仍未解决的:

  • 进度条。视频生成不是 0/1——是 0%, 30%, 80%, done。我们没把 fal-cli 的进度信号桥到 agent 这层。TODO: 把 worker 中间状态写到 bg_jobs.summary
  • 取消。用户说 “算了,不要了”。agent 现在不会去 cancel 已经在跑的 job。worker 跑完才知道白做了
  • 多任务编排。agent 一个 turn 内 submit 3 个 job,没有顺序依赖描述。worker 平行跑

SDK 原生支持 fire-and-forget tool。 如果 SDK 能让我们标记一个工具是 async: true,PreToolUse 触发后立刻返回 promise,PostToolUse 在 promise resolve 时回调——bg-worker 这一坨可以删一半。我们已经给 Anthropic 提过 feedback。

worker 可观测性接到主 agent trace。 当前 worker 跑的 skill 在 Langfuse 里是独立 trace(generate-video-<batch_id>),跟主 agent 的 turn trace 没父子关系。想看完整链路要手动 join。TODO: 让 worker 在调 claude -p 时传 TRACEPARENT,把 skill trace 挂到 turn trace 下面。

预热的 skill runner pool。 每次 claude -p 冷启 500ms。如果有几个常驻 worker 进程,热启动可以压到 50ms。但常驻进程会带 state 污染——需要每次 reset。仍在评估。


submit_job 模式是 I-2 三层切分 那种边界意识在 agent 行为层的延伸:长耗时操作不属于主 turn——把它推到外面去,让主 turn 保持轻

每次我们在主 agent 行为里看到一个 “>10s 同步等” 的工具调用,第一反应应该是 “这能不能 submit_job”。多数时候答案是能。


相关文章