阅读时间 8 分钟

Agent 的工位:Multica 怎么找到你那台机器

AI 拆解 Multica 的 daemon × 工具 = runtime 模型。为什么 Agent 不跑在服务器上跑在你机器上,心跳怎么判定离线,装了却不能用的工具怎么被识别。Multica 拆解系列第 1 篇。
Agent 的工位:Multica 怎么找到你那台机器

本文是 Multica 拆解系列的第 1 篇。上一篇《2 个人 + 10 个 Agent = 20 个人产出》给了全地图,这一篇钻进第一个"同事属性":工位——Agent 在哪台机器上跑,系统怎么找到它,它离线了怎么办。

这一篇要解决的问题

Multica 最反直觉的设计是:你在网页上点"分配给 Claude Code",真正干活的进程跑在你自己的 MacBook 上,不在云端。

这带来一连串难题:服务器怎么知道"那台 MacBook"在线?任务派出去,怎么保证它真的被那台机器领走,而不是悬空?如果有人合上电脑走了,正在跑的任务怎么办?装了 Claude Code 但密钥过期了,系统会派活给一个跑不动的 Agent 吗?

我通读了 server/internal/daemon/server/pkg/agent/ 的代码,找到了 Multica 的答案。核心是一个叫 runtime 的概念。

心智模型:一台机器 = 一个 daemon = N 个 runtime

先记住一条等式,它是这一篇全部的钥匙:

runtime = 守护进程(daemon)× 一款 AI 编程工具

runtime 既不是一个服务器,也不是一个容器。它是"你机器上的那个 daemon"乘以"这个 daemon 检测到的某一款工具"。一台 MacBook 上启动 daemon,本机装了 Claude Code 和 Codex;你是两个工作区的成员。那么 Multica 会注册 4 个 runtime:

架构图

这就是 Multica 的 Runtimes 页面,每行是一个 runtime,绿点表示在线:

Multica Runtimes 页面:每个 runtime 的在线状态

任务派发时,服务器排的就是这些 runtime 行,而不是机器、不是容器、不是人。

理解 runtime 的关键是理解 daemon 干的事。daemon 是 Multica CLI 的一部分,启动后(multica daemon start)做四件事:读凭证 → 探测本机 PATH 上装了哪些 AI 工具 → 向服务器注册自己 + 每个(工作区 × 工具)对应的 runtime → 然后持续轮询。我在代码里找到了这个轮询和心跳的真实节奏:

// server/internal/daemon/client.go
func (c *Client) ClaimTask(ctx context.Context, runtimeID string) (*Task, error) {
    var resp struct{ Task *Task `json:"task"` }
    if err := c.postJSON(ctx,
        fmt.Sprintf("/api/daemon/runtimes/%s/tasks/claim", runtimeID),
        map[string]any{}, &resp); err != nil {
        return nil, err
    }
    return resp.Task, nil
}

daemon 每 3 秒对它名下的每个 runtime 调一次 claim,有任务就领走。这就是"调度在云、执行在端"的接缝。

反直觉:同一个 daemon + 同一个工作区 + 同一款工具,只会注册一条 runtime。你重启 daemon、重启电脑,都不会产生重复记录。这要求注册逻辑是幂等的——我在 daemon/identity.go 看到它用机器标识 + 工具版本做稳定主键,而不是每次启动随机生成。

深度点一(免费):一台机器怎么变成多个 runtime

为什么要搞"daemon × 工具"这种乘法,而不是"一台机器 = 一个执行单元"?

直觉做法是:服务器维护一张机器列表,任务派给某台机器。但这会在两个维度同时崩:第一,同一台机器装了多款工具(Claude Code、Codex、Cursor…13 款适配器里挑几款),派活时要指定"用哪款工具写代码",机器粒度太粗;第二,你是多个工作区的成员,工作区之间数据隔离,一个机器记录无法表达"这个工作区的任务只在这个工作区的上下文里跑"。

runtime 把这两个维度正交分解:daemon 负责机器身份和本地执行能力,工具负责"用哪款 AI",工作区负责数据隔离。(工作区 A × Claude Code)和(工作区 A × Codex)是两条独立的 runtime,各有自己的负载和状态。

// server/pkg/agent/version.go —— daemon 探测本机工具
// 扫描 PATH,判断每款工具装没装、版本够不够

daemon 启动时的工具探测走 pkg/agent/version.go:它逐个跑 13 款适配器的版本命令,能跑通就注册成 runtime。这就是为什么"我的智能体不工作"几乎都是本地问题——daemon 没启动、工具没装、密钥过期,都会让某个 runtime 注册不上。

深度点二:心跳与离线判定(三级滑动窗口)

runtime 在不在线,Multica 不靠 TCP 连接判断,靠心跳。daemon 每 15 秒上报一次心跳,服务器据此判定。三级阈值:

阈值 状态 含义
30 秒 active(活跃) 正常工作
90 秒 stale(陈旧) 心跳延迟,可能网络抖动
300 秒(5 分钟) offline(离线) 判定失联,派给它的任务要回收

这里有一个反直觉的设计,也是我觉得最精巧的地方:它不是简单的"超时就断"

朴素做法是:15 秒没心跳 → 立刻判离线。但这会产生大量误杀——网络抖一下、机器 GC 一下,正常工作的 runtime 就被踢掉,任务全被打回队列重跑。Multica 用的是"最近一次心跳时间"的滑动判定:从 active 到 stale 到 offline 是渐进的,而不是一刀切。

我在 server/internal/handler/ 看到,心跳不只是写数据库,还喂给一个 liveness store:

// server/internal/handler/runtime_liveness_store.go
// DB-only behavior: rewrite last_seen_at every beat;
// trust the SQL stale window

每跳一次心跳就更新 last_seen_at,离线判定由 SQL 的 stale window 算。这套设计的全栈闭环是这样的:

架构图

前端 Runtimes 页面的在线状态(绿/灰/红)就是这么来的——不是前端自己计时,是 WS 事件触发缓存失效后重新拉。

心跳判定的完整时间线:

架构图

心跳判定的完整时间线:

架构图

深度点三:装了却不能用的工具——poisoned 机制

最有意思的一个细节在 server/internal/daemon/poisoned.go

13 款适配器都实现了探测,但"探测到工具存在"和"工具真能用"是两回事。你可能装了 Claude Code 但 Anthropic 密钥过期了,或者某款工具的二进制损坏、版本不支持。如果服务器傻乎乎地按"装了就注册成 runtime"派活,任务会派给一个一启动就崩的工具,白白浪费一轮超时。

Multica 的解法叫 poisoned(中毒):daemon 探测到某款工具存在,但如果版本探测或健康检查失败,就把它标记为"有毒"——注册给服务器时附带一个"不可用"标记,服务器不向它派任何任务

// server/internal/daemon/poisoned.go
// 探测到工具但不可用 → 标记 poisoned
// poisoned 的 runtime 不会被 ClaimTask 选中

这个机制让系统对"本地环境坏掉"有韧性:工具坏了不会让任务积压,只会让那个 runtime 静默停摆,等用户修好本地再恢复。

反直觉:你看到 Runtimes 页面上某个工具"离线",可能不是网络问题,是这台机器上那款工具被 poisoned 了——daemon 主动说"我跑不了它"。

这就是 Multica 对"工位"这个概念的完整实现:runtime 定义了工位的坐标,心跳判定工位在不在,poisoned 机制防止给坏工位派活。一个 2 人团队能管 10 个 Agent,前提就是这套机制让"哪台机器能干活"这件事可观测、可信、可恢复。

下一篇:工位有了,但同事还得有大脑——13 款 AI 编程工具怎么共用一套接口,差异(会话恢复、MCP、推理档)怎么被屏蔽。


想看 AI 拆完下一篇?留个邮箱,自动推送。

react-admin.com · Multica GitHub