容器外 vs 容器内:三层切分
“agent 跑在哪里” 不是一个简单问题
Section titled ““agent 跑在哪里” 不是一个简单问题”你做出来一个 agent。它跑在哪里?最直觉的答案:跑在你的 backend 里。
但 K12 场景下我们做的是 “一个孩子一个 agent”——每个 agent 有自己的文件系统、自己的浏览器、自己的代码工程、自己可以装的工具。隔离要做到容器级,不是进程级、不是 namespace 级。
那 agent 跑在容器里。下一个问题:容器的生命周期谁管?
- 创建容器:用户首次登录时,自动 provision 一个属于他的 workspace
- 容器在睡觉:晚上没人聊天的时候,应该停掉容器,省成本
- 容器要醒:早晨用户发消息,要在用户感知到之前把容器拉起来
- 容器死了:oom / 镜像更新 / 节点迁移——要能恢复
这是经典的 stateful workload orchestration 问题。我们的答案是把它彻底从 agent 自身剥离。
┌─────────────────────────────────────────────────────────┐│ Temporal (容器外,平台层) ││ workspaceLifecycleWorkflow — 状态机 ││ running → hibernating ⇄ waking → running │└─────────────────────────────────────────────────────────┘ ↓ (调 Coder API)┌─────────────────────────────────────────────────────────┐│ Coder (容器供应商) ││ POST /api/v2/workspaces ││ POST /api/v2/workspaces/{id}/builds (start/stop) │└─────────────────────────────────────────────────────────┘ ↓ (提供 container)┌─────────────────────────────────────────────────────────┐│ 容器内:Ubuntu + agent-engine + Claude Agent SDK ││ 处理 IM 消息、调工具、写代码、上课 │└─────────────────────────────────────────────────────────┘每一层只做一件事:
- Coder:提供 container。不知道里面跑的是 agent 还是 web server
- Temporal:管 container 生命周期。durable workflow + signal + activity——不跑 AI、不调 LLM
- Claude Agent SDK + agent-engine:在容器内做 agent 行为。不管自己的容器在不在
边界很硬:Temporal 不知道 agent 在做什么;agent 不知道自己跑在 Coder 里。 这是有意的设计。
生命周期状态机
Section titled “生命周期状态机”Temporal 那一层就一个 workflow,状态机简化版:
export async function workspaceLifecycleWorkflow(input) { while (true) { // === Running === state = "running"; const timedOut = !(await condition( () => activityDetected || agentWentOffline, input.hibernateTimeoutMs, // 默认 30 min ));
// === Entering hibernation === // 睡前若 agent 没自己设 alarm,种一个 [dream] alarm +1h if (!agentWentOffline) { const hasFuture = await act.hasFutureWakeAlarm({ agentId }); if (!hasFuture) { await act.scheduleDreamAlarm({ agentId, delayMs: DREAM_DELAY_MS }); } } await act.stopCoderWorkspace({ workspaceId });
// === Hibernating + Waking loop === while (true) { state = "hibernating"; await condition(() => wakeUpPayload !== null); // 等 Signal
state = "waking"; try { await act.startCoderWorkspace({ workspaceId }); await longAct.waitCoderWorkspaceReady(workspaceId); break; // → 回到外层 running } catch { // 唤醒失败,留在 hibernating 等下次 signal } } }}三个状态:running / hibernating / waking。状态之间的过渡都是显式的——hibernate 由超时触发,wake 由 signal 触发。
wakeUpSignal 由 TeachClaw API 在收到 IM 消息发现 workspace 不在线时主动 signal。
一次唤醒的完整流程
Section titled “一次唤醒的完整流程”用户 6 小时没活动,workspace 已 stopped。早晨他突然发消息:
[用户] 发 IM 消息 ↓[OpenIM webhook → TeachClaw API] ↓ 查 agent.workspace_status,发现 stopped[TeachClaw API → Temporal] wakeUpSignal(workflowId, payload) ↓[Temporal workflow] wakeUpPayload = payload // 解除 condition() 阻塞 state = "waking" ↓[Activity: startCoderWorkspace] POST /api/v2/workspaces/{id}/builds { transition: "start" } ↓[Activity: waitCoderWorkspaceReady] 每 2s 轮询 status,直到 ready(10min timeout) ↓[容器启动 → agent-engine 启动 → polling SQLite] getNewMessages() 拉到刚才那条用户消息 ↓[Claude Agent SDK] query() 处理消息,开始 streaming 回复 ↓[用户看到 agent 在打字 / 听到语音]整条链路用户感知到的是 “agent 慢了一拍”。后台的 Coder API、Temporal Signal、Activity retry 这些他完全不知道。
唤醒典型耗时(开发观察值):
| 阶段 | 时间 |
|---|---|
| IM → TeachClaw API → Temporal signal | < 100ms |
| Coder start build | 5-15s |
| 容器启动 + agent-engine boot | 3-8s |
| agent-engine polling 拿到消息 | < 2s |
| Claude SDK 首个 streaming token | 1-3s |
不混合的好处
Section titled “不混合的好处”写到这里,每一层的 “不做什么” 列表比 “做什么” 还重要。
Temporal 不做的事:
- 不跑 agent 行为。Workflow 不调 LLM、不调工具
- 不知道 agent 在跟谁聊
- 不存 agent state(state 在容器内的 SQLite / 文件系统)
Coder 不做的事:
- 不知道容器里跑的是 agent
- 不决定容器何时该睡何时该醒(Temporal 决定)
- 不暴露 agent 的工具或消息
agent-engine + Claude Agent SDK 不做的事:
- 不调 Coder API
- 不感知自己的容器何时会被停(被停时 SIGTERM,graceful shutdown)
- 不维护 “我下次什么时候被唤醒”——alarm 写到 TeachClaw API,由 alarm-scheduler 主动 signal Temporal
每一条 “不做” 都换来清晰性。当容器层出问题(节点漂移、镜像更新),不需要碰 agent 代码;当 agent 行为出问题(说错话、漏工具),不需要碰 Temporal。
反例:曾经混过
Section titled “反例:曾经混过”这套切分不是一开始就有的。早期有几次混合付出了代价:
- agent-engine 自己调 Coder API 检查 “我还活着吗”。问题是 agent 容器内的 token 跟外部 TeachClaw 权限混了,调用链不清。改:agent 完全不知道 Coder。
- Temporal workflow 里有 90 秒的 fixed sleep “等 alarm 时机”。问题是 sleep 跟 alarm 之间有 race。改:删 sleep,alarm 时机由外部 signal(commit
aacb9186:refactor(workflows): Dream 改走 alarm 通道,删 90s sleep race)。
每一次混合都是 “我多调一个 API 就好” 的诱惑——但跨层调用会让事故定位变成考古。
为什么是 Temporal
Section titled “为什么是 Temporal”可能有人问:K3s 自带 controller,能不能就用 K8s 原语?
Temporal 在这里的核心价值是 durable execution:
- workflow 跑了一半进程死了,重启后从同一个位置继续
- signal 不会丢,永远等得到
- activity retry 是声明式的(attempts、backoff、timeout)
K8s controller 也能做,但写一个能 hibernate / wake 的 stateful controller 是 100+ 行 go 代码 + 状态机自己实现。Temporal 那块状态机就是上面贴的 50 行 TypeScript。
代价是:
- 多一个组件(Temporal server,3 节点 HA)
- 工程师要学 workflow 编程模型(determinism、
patched()、versioning) - 改 workflow 代码必须用
patched(),否则老 execution 卡死——一条不容易记住的铁律
我们认为代价值得——一个 agent 一个 workspace 这种 high-cardinality stateful workload,Temporal 是行业标准方案。
- 冷启动延迟:5-15s 的 Coder build 在 K12 场景勉强可接受,但还想压到 < 3s。可能方向:pre-warmed pool、镜像 layer 优化、KubeVirt 替代 Coder。仍在评估。
- 跨节点亲和性:workspace 醒来时如果节点变了,SQLite 持久化在 PVC 上,但 IO 性能会下降。还没遇到大规模问题。
- 批量 wake:早晨 8 点几百个 workspace 同时被唤醒,Coder build queue 会拥堵。
TODO: 测量 burst wake 的 p99 时延,看是否需要预热池。
这套切分有一个意外好处:当我们做 Substrate + Evaluator 这种 agent 行为层改造时,根本不用碰 Temporal 和 Coder。改 prompt、改 mode spec、改 evaluator 维度,全都在容器内 agent-engine 包里。Workflow 不需要重新部署。
反过来,当我们做 Coder → Talos 基础设施迁移时,agent 行为代码一行没动。
这就是边界的回报:每一层都可以独立演化。
相关文章:
- runtime 本身的选型:I-1 Claude Agent SDK 作 runtime
- 容器内 agent 怎么处理 > 1min 的任务:I-3 bg-worker 异步剥离
- agent 自己怎么设 alarm 唤醒自己:II series 行为校准