跳转到内容

容器外 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 里。 这是有意的设计。

Temporal 那一层就一个 workflow,状态机简化版:

packages/workflows/src/workspace-lifecycle.workflow.ts
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。

用户 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 build5-15s
容器启动 + agent-engine boot3-8s
agent-engine polling 拿到消息< 2s
Claude SDK 首个 streaming token1-3s

写到这里,每一层的 “不做什么” 列表比 “做什么” 还重要。

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。

这套切分不是一开始就有的。早期有几次混合付出了代价:

  • agent-engine 自己调 Coder API 检查 “我还活着吗”。问题是 agent 容器内的 token 跟外部 TeachClaw 权限混了,调用链不清。改:agent 完全不知道 Coder。
  • Temporal workflow 里有 90 秒的 fixed sleep “等 alarm 时机”。问题是 sleep 跟 alarm 之间有 race。改:删 sleep,alarm 时机由外部 signal(commit aacb9186refactor(workflows): Dream 改走 alarm 通道,删 90s sleep race)。

每一次混合都是 “我多调一个 API 就好” 的诱惑——但跨层调用会让事故定位变成考古。

可能有人问: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 行为代码一行没动。

这就是边界的回报:每一层都可以独立演化


相关文章