Skip to main content

Suna 运行时层拆解

前置03 Manus。两者形态最像,对照着看差异最清楚。

本篇回答:Manus 那套「每个任务一台机器」的形态,用开源件怎么搭出来,以及这样搭会在哪里真的烧钱、真的出事。

03 篇里 Manus 的架构是靠泄露物拼出来的,很多环节只能标问号。Suna 补上了那一半:同样的形态 —— Web 界面 + 云端沙箱 + 可部署产出 —— 但源码全在。

更难得的是它的注释里留着两条真实的线上事故复盘,带 session ID、带损失金额、带根因。这类东西绝大多数团队锁在内网,开源项目里能见到是极少数。

一、把代码拉下来

Kortix 出品,开源自托管。

git clone --depth 1 --filter=blob:none --sparse https://github.com/kortix-ai/suna.git
仓库Star语言运行时快照
kortix-ai/suna20,115TypeScriptBun 1.3.112026-08-19

它的价值在于源码注释里有两条带 session ID、损失金额、根因分析的线上事故复盘——这类材料在开源项目里极罕见,多数团队锁在内网。

pnpm monorepo,apps/ 下 11 个应用:

应用职责
api控制面:账号、计费、权限、连接器
kortix-sandbox-agent-server沙箱内 Agent 监工(本篇重点)
sandbox沙箱镜像
llm-gateway模型网关
web / mobile / desktop-electron / cli各端
voice-agent / kortix-app-runtime / whitelabel-demo其他

11 个应用里 Agent 逻辑只占一个,其余是把它变成可售服务所需的部分。

二、运行时的五个必答问题

用户点击发送到 Agent 敲第一行命令之间,需解决:

#问题朴素解法为什么不够
1给哪台机器每用户一个 Docker 容器——
2多久能好起容器 + 装依赖 + Agent 初始化几十秒,产品级不可接受
3预热能否复用预热一批容器预热容器无用户凭据;换凭据需重启进程,重启 ~8s,预热收益归零
4能访问什么沙箱直接联网沙箱隔离的是「影响不到宿主机」,不是「访问不到内网」;且无审计
5烧起来怎么办加超时挡不住「每轮都干净成功」的死循环(第六节实例)

五个问题全部与「Agent 怎么思考」无关。 Suna 的答案是:Agent 循环直接复用开源实现,力气全部投入运行时。

三、最大的发现:它不自己写 Agent 循环

apps/kortix-sandbox-agent-server/package.json 的一句描述:

apps/kortix-sandbox-agent-server/package.json
{
"name": "@kortix/sandbox-agent-server",
"description": "Sandbox-side OpenCode REST supervisor and Kortix API surface.",
...
}

"OpenCode REST supervisor" —— 它是 OpenCode 的监工

源码目录里满眼都是 opencode:

src/
├── opencode.ts ← 进程管理
├── opencode-config-deps.ts ← 配置依赖
├── opencode-events.ts ← 事件流转
├── opencode-turn-state.ts ← 轮次状态
├── opencode-fork-root.ts ← 温启动 fork 的根会话处理
├── opencode-audit-relay.ts ← 审计中继
├── managed-opencode-env.ts ← 环境管理
├── llm-proxy.ts ← 凭证注入代理
├── egress-shim/ ← 出网管控
├── runaway-turn-guard.ts ← 失控防护
└── routes/ ← abort / files / find / git / pty / port-proxy / web-proxy ...

架构长这样:

「Agent 怎么想」外包给了一个成熟的开源 Agent,自己只做「Agent 跑在哪、跑多久、能访问什么、烧了多少钱」。

对照前面几家,这是第三条路:

Agent 循环力气花在哪
Kimi CLI自己写,52,049 行 Python子 Agent 编排、上下文隔离
DeerFlow站在 LangGraph 上四十多个中间件做治理
Suna直接用 OpenCode沙箱、冷启动、出网、成本

子 Agent 的部分也顺理成章地继承自 OpenCode:

src/opencode-events.ts:43(注释)
// subagent (Task tool) child sessions — so the handler is responsible for ...

子 Agent 在这里是 OpenCode 的 Task 工具产生的 child session,Suna 只负责观测和管控。

这个选择值得琢磨:如果你要做一个 Agent 产品,先问自己「Agent 循环本身是我的差异化吗?」。如果不是,OpenCode、OpenHands 这类成熟实现拿来就用,把力气留给别人替你做不了的那部分。

四、冷启动优化与其代价

4.1 CoW 温 fork

思路是:预先起一个沙箱,把 OpenCode 启动好、创建一个根会话、然后打快照。用户来了就从快照 CoW fork(写时复制)一份出来。

4.2 温 fork 引入的会话串台 bug

这一步是这篇最值得看的地方——它是「性能优化引入正确性问题」的教科书案例。

冷启动 vs 温启动冷启动起容器装运行时依赖启动 OpenCode首次项目初始化合计几十秒温启动← 这四步预先做好并打成快照 →CoW fork秒级可用代价:所有 fork 继承同一个 pinned 根会话 id快照 seedOpenCode 已启动root = s-SEED(pinned)沙箱 A · s-SEED沙箱 B · s-SEED沙箱 C · s-SEED客户端按 session id 索引(假设 id 在沙箱内唯一)三个会话解析到同一 id症状:切换会话,到处看到同一个对话冷启动路径完全正常仅温启动触发修法:seed 把自身 id 写进 marker 文件并冻进快照;fork 读到自己的根 == seed id 时,一次性轮换到全新根,随后退休 marker通用教训:任何「共享预制状态」的优化,都要检查该状态内有无本该唯一的标识符优化收益优化引入的正确性缺陷被消除的耗时
配套的第二处优化:OpenCode 只在 spawn 时读配置,换用户凭据需重启,单次约 8 秒。解法是配置指向 localhost 代理并写死占位 Bearer,真实 token 存代理内存、出网时改写 Authorization 头,换用户只需 setToken(),进程全程不重启。

opencode-fork-root.ts 开头的注释把整件事讲清楚了:

src/opencode-fork-root.ts:1-19
/**
* Warm-fork opencode-root de-collision (pure logic, separated from main.ts so it
* is unit-testable — main.ts self-executes on import).
*
* A warm sandbox is CoW-forked from a snapshot that booted opencode, created ONE
* root session, and pinned it (OPENCODE_SESSION_PIN_PATH) so forks resume warm
* without paying opencode's first-session project init. The catch: every fork
* inherits the SAME pinned root id from that one snapshot.
*
* The client keys ALL session state — messages, parts, status — purely by
* opencode session id (it assumes ids are unique per sandbox). So if forks adopt
* the shared seed root, every session resolves the same id and their chats bleed
* into one another: "switch sessions, see the same thread everywhere".
*
* Fix: the seed records the baked id in a marker file that is captured into the
* snapshot. A fork reads it to rotate onto its OWN fresh root EXACTLY ONCE,
* instead of reusing the shared seed root, then retires the marker so later
* daemon restarts reuse the fork's own root via the normal idempotent path.
*/

拆开看:

这个 bug 的可怕之处在于

  • 冷启动路径完全正常——只有温启动才出现
  • 它不是崩溃,是数据串台——用户看到别人的对话
  • 单机测试很难复现,要同时开多个 fork 才能看到

教训:任何「共享一个预制状态」的优化,都要检查这个状态里有没有本该唯一的标识符

4.3 凭证注入代理:避开 8s 重启

第二个优化。llm-proxy.ts 的注释:

src/llm-proxy.ts:3-23(节选)
// Localhost credential-injecting reverse proxy (the warm-fork "no restart on
// restore" mechanism).
//
// WHY: a stateful warm-fork session attach used to KILL + respawn
// opencode purely to swap in the per-session tokens (LLM gateway key + connector
// token) — re-paying ~8s of opencode init that the snapshot already baked.
// opencode reads its config (provider.options.apiKey, mcp.environment) only at
// spawn, so swapping a token forced a config rebuild + restart.
//
// Fix: make those credentials SESSION-INDEPENDENT in the baked config. The config
// points the relevant baseURL/api-url at THIS localhost proxy with a fixed
// placeholder Bearer; the proxy holds the real per-session token in memory and
// rewrites the Authorization header on the way upstream. On restore the daemon just
// calls setToken() — opencode is never restarted.

这正是第二节「想法二」那个硬问题:

8 秒这个数字是关键。 一个「点一下就开始干活」的产品,8 秒的空白是致命的。这条优化的价值不在架构优雅,在用户按下按钮之后那个瞬间。

思路可以推广:当一个进程只在启动时读某个配置,而你又需要频繁改这个配置时——别改配置,在配置指向的那一端做手脚。

4.4 出网管控:每沙箱临时 CA

egress-shim/ 做的是在沙箱内劫持出网流量。要检查 HTTPS 就得做中间人,做中间人就得有 CA,所以每个沙箱签发一个临时 CA

这里有一段我在整个专题里见过最好的踩坑记录,在测试文件开头:

src/egress-shim/ca.test.ts:1-9
/**
* The certificate extensions, pinned because a missing one is invisible until a
* specific client rejects the chain in a real guest.
*
* Found the hard way: without the key-identifier pair, `curl` accepted the
* certificate and `python3 -m requests` refused it with
* `CERTIFICATE_VERIFY_FAILED ... Missing Authority Key Identifier`. Measured in
* a real Daytona sandbox — every local test passed while Python was broken for
* every agent that would have used it.
*/

翻译:证书少了一个扩展字段,curl 能过,Python 的 requests 过不去。本地测试全绿,真沙箱里所有用 Python 的 Agent 全挂。

这段注释本身就是最好的论据:沙箱这层的坑跟 Agent 一点关系都没有,但会让整个 Agent 产品不可用。而且只能在真环境里发现——本地测试用的是同一套 TLS 实现,测不出客户端差异。

五、最危险的失控:一直干净地成功

2026-08-18 线上事故:每一轮都「干净地成功」现象回答 · 第 1 次同一答案 · 第 2 次同一答案 · 第 3 次…无限重复,直至人工中止同一条 parent message 被反复应答;根因推测为调用方传的 messageID 不符合 OpenCode 可排序时钟格式,破坏了「这条 prompt 是否已回答过」的判定。人工中止时已 44 条消息 / $0.18既有三道防护为何全部失效try / catch✗ 没有异常超时✗ 每轮都很快完成turn-auto-resume(监听 session.error)✗ 没有 error新增 runaway-turn-guard:不看错误,看「是否在重复应答同一条 parent message」,连续 3 次即中止阈值对齐 turn-auto-resume 的 MAX_ATTEMPTS_PER_WINDOW;按 session 计数并显式覆盖 spawned child
同日第二起事故循环的是一个子会话,而上报链路在守卫看到重复之前就把非 root 会话过滤掉了——主 Agent 的防护做得再好,子 Agent 是盲区,钱一样烧。

runaway-turn-guard.ts 的顶部注释,是本专题里信息密度最高的一段文字。它把一次线上事故从现象到根因到修复完整记录了下来:

src/runaway-turn-guard.ts:3-26(节选)
// A turn — in ANY session, root or spawned child — that completes successfully
// (`session.idle`, no error) while answering the SAME parent user message it
// already answered on the PREVIOUS completion is a runaway: something re-triggered
// generation against a STANDING prompt instead of recognizing it as already
// answered — observed live 2026-08-18 (session `749045da`) as OpenCode replying
// the same one-word answer back-to-back, indefinitely, until manually aborted at
// 44 messages / $0.18. The likely trigger was a caller-supplied `messageID` that
// did not conform to OpenCode's own sortable-clock id format, breaking its
// "has this prompt already been answered" ordering check ...
//
// ... each repeat is a full clean success — no error, no timeout — so
// `turn-auto-resume.ts` (which watches `session.error`) does not apply and
// nothing else stops it. Left unguarded this burns real tokens/cost with no ceiling.
//
// Per opencode SESSION, children included: the 2026-08-18 Essentia incident
// (session `5d9e298a`) was a spawned child looping this way while
// `relayTurnEndToApi` filtered non-root sessions out before this guard ever saw
// a repeat — the abort must target the session that is looping.
//
// MAX_CONSECUTIVE_REPEATS=3 mirrors `turn-auto-resume.ts`'s MAX_ATTEMPTS_PER_WINDOW:
// a small number of repeats could in principle be legitimate ...; a 4th identical
// repeat of the SAME standing prompt is not.

拆成表:

要素内容
现象OpenCode 对同一条用户消息反复给出同样的一个词的回答,无限循环
发现时间2026-08-18(这份代码拉下来的前一天
止损方式人工中止,止损时已 44 条消息 / $0.18
根因推测调用方传的 messageID 不符合 OpenCode 的可排序时钟 id 格式,破坏了「这条 prompt 是否已回答过」的判定
为什么没被抓到每一轮都是干净的成功——没有 error、没有 timeout,所以监听 session.error 的自动恢复逻辑完全不触发
第二起事故同日另一起(session 5d9e298a),循环的是一个子会话,而上报逻辑在守卫看到重复之前就把非 root 会话过滤掉了
修复按 session 粒度(含子会话)计数,同一 parent message 连续重复 3 次就中止

三个教训,每一条都很硬:

① 最危险的失控是「一直成功」的那种。 报错会被重试逻辑接住,超时会被超时逻辑接住,但「每次都干净地成功、只是在做同一件事」什么都接不住DeerFlow 的 loop_detection_middleware 解决的是同一类问题。

② 子 Agent 的监控必须和主 Agent 一视同仁。 第二起事故的直接原因就是上报链路把非 root 会话过滤掉了——主 Agent 的防护做得再好,子 Agent 是盲区,钱一样烧。

③ 阈值要有依据。 MAX_CONSECUTIVE_REPEATS = 3 明确对齐了另一个模块的 MAX_ATTEMPTS_PER_WINDOW,并解释了为什么 3 次可能合法、第 4 次一定不合法。这跟 DeerFlow 那段 token 预算注释 是同一种职业素养。

顺带一提,$0.18 这个损失金额不值一提——但它是被人工中止的。没人盯着的话,上限是无穷。写这个守卫的人显然想明白了这一点。

六、会在哪儿翻车:症状 → 原因 → 对策

症状原因对策
用户点击后要等几十秒冷启动:起容器 + 装依赖 + Agent 初始化CoW 快照温 fork(Step 1)
切换会话看到别人的对话温 fork 共享了同一个 pinned 根会话 idfork 后强制轮换到全新的根,且只换一次(Step 2)
温启动省的时间又被赔回去换用户凭据要重启 Agent 进程凭证注入代理,配置指向 localhost(Step 3)
本地测试全绿,真沙箱里 Python 全挂自签 CA 少了 Authority Key Identifier证书扩展字段要 pin 住,且在真环境测(Step 4)
Agent 无限重复同一个回答,无报错每轮都干净成功,所有错误监控失效按「重复回答同一条消息」计数中止(第五节)
主 Agent 有防护,子 Agent 还在烧上报链路把非 root 会话过滤掉了防护必须覆盖 spawned child(第五节)
沙箱访问了不该访问的地址直接放开出网出网 shim + 每沙箱临时 CA(Step 4)

七、全局定位

01 的五个维度 打分:

维度Suna 的答案
D1 隔离单位一个沙箱(CoW fork 的容器),粒度仅次于 Manus 的虚拟机
D2 通信拓扑继承 OpenCode 的 Task 子会话模型,星型
D3 结果回收由 OpenCode 负责,Suna 只做事件中继和审计
D4 递归深度由 OpenCode 决定;Suna 侧的防护显式覆盖 spawned child
D5 生命周期温启动 CoW fork、凭证热替换、失控自动中止

一句话总结:Suna 证明了一件事——做一个 Manus 类产品,难点不在 Agent 循环,在沙箱、冷启动、出网和成本刹车。

八、小结与自查清单

如果你要做一个「给用户一台电脑」型的 Agent 产品:

冷启动

  • 从用户点击到第一个动作,实测多少秒?
  • 有没有预热机制?预热的状态里有没有本该唯一的标识符?
  • 换用户凭据要不要重启进程?能不能改成代理注入?

隔离与安全

  • 沙箱能访问哪些地址?有审计吗?
  • 自签 CA 的证书扩展在真实客户端上测过吗?(curl 过了不代表 Python 过)

成本刹车

  • 有没有针对「一直成功的死循环」的防护?(try/catch、超时、错误监听都抓不到它)
  • 这个防护覆盖子 Agent 吗?
  • 阈值是怎么定的?写下理由了吗?

架构选择

  • Agent 循环本身是你的差异化吗?如果不是,考虑直接用 OpenCode / OpenHands
  • 你的力气花在了「别人替你做不了」的那部分上吗?

九、参考