bg-worker:重 IO 从主 turn 剥离
一个 60 秒的视频请求
Section titled “一个 60 秒的视频请求”孩子说:“给我生成一个奥特曼跟怪兽打架的小视频。”
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 想做的事经常超出这个响应窗口。
SDK 不会救你
Section titled “SDK 不会救你”我们用 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 工具实现:
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,换来上面四条隔离的丢失,不值。
三个当前在用的 skill
Section titled “三个当前在用的 skill”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 调优,不引入第二套编程模型。
用户体验上的不对齐
Section titled “用户体验上的不对齐”异步异步异步——但用户不知道 “异步”。从用户视角:
- 他说 “给我做个视频”
- 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 平行跑
还在思考的方向
Section titled “还在思考的方向”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”。多数时候答案是能。
相关文章:
- 为什么 SDK 是 cooperative 的:I-1 Claude Agent SDK 作 runtime
- agent 怎么知道 skill 跑完了:alarm + system 消息机制——见 II-1 旁听陪伴
- worker 怎么接进 trace 体系:III-2 Langfuse trace 是行为回放器