我们把AIagent赶下了服务器
这是《AI Agent 工程实践》系列第 1 篇,共 10 篇。
做 AI agent 平台,第一个要回答的问题不是怎么编排任务,是:用户的 Anthropic API Key 存在谁家的硬盘上?
我们的答案是存在用户自己的硬盘上。为了守住这一条,整个系统被劈成了两半,然后我们花了很长时间给这道裂缝打补丁。
Multica(早期代号 moss platform)是一个 AI 原生的任务管理平台,骨架跟 Linear 那套 issue / project 差不多,区别在这儿:
AI agent 是一等公民,能持有 issue、发评论、改状态、当 project lead,和人类成员地位对等。
真正反直觉的地方在下面这句话:这些 agent 都不在 Multica 的服务器上跑,它们在用户自己的机器上跑。
服务器这一半叫协作层:workspace、issue、comment、project、任务队列、WebSocket 推送。
用户机器这一半叫执行层:一个叫 daemon 的常驻进程,加上本机装好的 AI 编码工具(Claude Code、Codex、Cursor 等 12 个)。
分割线画在哪儿?画在 API Key、工具链、代码目录永不上传服务器这条线上。
我得先说清楚,这不是隐私营销话术,是产品能不能成立的前提:
- • agent 要读写你本地的 git 工作区,才能真的改代码、建分支、提 PR;
- • agent 要能跑你的构建脚本和测试,改完得自己验证一遍;
- • agent 用的是你自己的订阅额度,Claude Code 走你的 Max 订阅,Codex 走你的登录态。
这三条任何一条搬到服务器上,都意味着我们要替你保管代码、替你保管密钥,还得替你付 token。所以没有中间路线,只能是服务器负责协调,你的机器负责干活。
内部文档里有一句话我特别认:几乎所有特有概念,runtime、daemon、task、超时重试、目录锁,都源于这个分裂。
下面这四个并发症,每一个都是这条裂缝的直接后果。
并发症一:服务器不知道 agent 还活着
云端调度最基础的能力,是确认对方还活着。执行层搬到用户机器上之后,这个能力直接没了。服务器连对方是不是合上了盖子都不知道。
补丁是心跳。daemon 每 15 秒往服务器发一次心跳,连续 3 拍没收到,也就是 45 秒,服务器把这个 runtime 标记为 offline。
为什么是 3 拍而不是 1 拍?因为家用网络本来就会抖,一次丢包就判死,任务会被无谓地反复重排。三拍是拿 45 秒的不确定性换一个不抖的判定。
代价也很直接:机器真断电了,最长要 45 秒后系统才发现。这 45 秒里队列可能还在往这台死机器派活。
并发症二:笔记本会合盖,断网不是故障
传统后端里,节点掉线是事故。在这儿,节点掉线是常态,下班了、合盖了、换 Wi-Fi 了、地铁里没信号了。
所以失败原因被分成了两类,这个分类我觉得是整套设计里最值钱的一处:
retryable(自动重排队):
runtime_offline 机器掉线
runtime_recovery 机器恢复中
timeout 超时
非 retryable(停在 failed):
agent_error API 报错 / 配额耗尽 / 内部 bug判定标准不是错误严不严重,而是重来一次结果会不会变。机器掉线是基础设施故障,重来一次大概率就好了;API 配额耗尽是输入的问题,重来一百次也一样。
还有个细节我很喜欢:手动 rerun 和自动 retry 继承的东西不一样。
手动 rerun 不继承 session,开一个全新会话,因为你点 rerun 就代表你判定上一轮输出是坏的。
自动 retry 继承 session,因为它认为上次没跑完不是输出的问题。
自动重试有上限,最多 2 次(1 次原始 + 1 次重试)。而且 autopilot 触发的任务不自动重试,靠下次 cron 再跑,定时任务本来就会再来一次,没必要在分钟级做重试。
还有一处联动我觉得很聪明:issue 触发的 task 一旦最终失败,issue 状态会从 in_progress 自动回滚到 todo。
任务失败这件事,在看板上就能看见,不用翻任务日志。
并发症三:服务器没法确认工具真的启动了
任务被 daemon 领走之后,状态从 queued 变成 dispatched。但领走不等于跑起来了。
daemon 拿到任务、拉起 AI CLI、CLI 真正开始读 issue,中间任何一步都可能卡住:CLI 装坏了、登录态过期、机器刚好在休眠唤醒的半途。
补丁是超时扫描。服务器每 30 秒扫一次队列:dispatched 状态停留超过 5 分钟还没变成 running,判 timeout;running 超过 2.5 小时,同样判 timeout。
5 分钟这个数字有点保守,我觉得是为了避开冷启动,尤其是桌面端从休眠唤醒、或者第一次拉起某个 CLI 时的初始化开销。宁可慢判,不可误杀。
这里也顺带解释了为什么状态机里要单独留一个 dispatched。
它不是正在跑,它是已经交给某台机器、但还没确认那台机器真动起来了。这是在服务器视野之外,唯一能表达薛定谔式执行中的办法。
并发症四:本机资源有限,任务会互相抢
服务器集群可以按人扩,用户的一台 MacBook 不行。CPU、内存、磁盘就那么多,AI 编码工具又都是吃资源的货。
第一层限制是并发。daemon 有全局并发上限,默认 20(MULTICA_DAEMON_MAX_CONCURRENT_TASKS);agent 自己也有一份并发上限,默认 6。
两层取小者生效,道理很朴素:机器那个天花板管总量,agent 那个天花板管住单个 agent 别把机器吃干。
第二层限制更隐蔽:同一台机器上的多个任务会争用同一个本地目录。
agent 干活要在本地磁盘上 clone 代码、改文件、留 git 工作区。两个任务同时改同一个工作目录,结果就是互相覆盖。
所以状态机里有一个专门的状态 waiting_local_directory(109_agent_task_waiting_local_directory 迁移引入):
任务拿到目录锁才能转成 running,否则就排着,UI 上就显示等待本地目录释放。
这个状态本身就是对架构的证明:如果工作目录在云端共享存储上,根本不需要它。
两层并发取小者还有个容易被忽略的后果,你精心给 agent 调大的并发,很可能根本没生效。
机器级天花板是 20,agent 级是 6,那这个 agent 实际就只能跑 6 个。调 agent 的并发之前,先看一眼 daemon 那个数。
健康状态是前端算出来的,后端只给两个数
这是我觉得设计得最聪明的一处,也最容易被抄错。
后端只给 runtime 两个字段:二元的 status(online / offline)和一个 last_seen_at 心跳时间。
服务器不做更细的判断,也不推送快被回收了这种事件。
四档健康态全在前端按当前时间派生(deriveRuntimeHealth):
online 绿
recently_lost 掉线 < 5 分钟 琥珀,可能只是瞬断
offline 5 分钟 ~ 6 天 灰
about_to_gc > 6 天 接近回收阈值,提示抢救注意最后一档 about_to_gc,后端根本没有这个状态,它是前端看着离线超过 6 天自己算出来的。
规则是:runtime offline 且没有关联 agent 超过 7 天,会被自动删除。所以第 6 天开始前端就给提示,留一天抢救窗口。
还有个实现细节容易漏掉:useRuntimeHealth 这个 hook 会每 30 秒 tick 一次强制重渲染。
因为 recently_lost → offline 这种转换纯粹是时间推进的结果,没有任何事件会触发它。不 tick,UI 上的琥珀色就会一直挂在那儿不动。
真正的上下线事件走 WebSocket:收到 daemon 事件就失效掉 runtime 列表的 query 缓存;断线重连时再把 workspace 级的 query 整体失效一次,补回断连期间漏掉的事件。
把健康放在前端算,代价是每台客户端的时间可能不准;收益是后端不用为 6 天这种纯展示阈值发事件、存状态。
我认可这个取舍,但前提是你能接受客户端时钟漂移,如果你的用户全在企业内网有 NTP,那没事。
这套取舍背后的原则
内部文档把这套思路总结成 16 个字:协调中心化,执行分布式,状态本地化,通信经由黑板。
翻译成人话:所有需要全局视野的决策(调度、串行化、原子认领、健康判定)放在中心,代价是中心对执行层的认知永远是二手的、有延迟的。
所有需要真实资源的操作(读代码、跑命令、花 token)放在边缘,代价是你得为看不见付四个并发症的税。
这套取舍能迁移到什么场景?我的判据是两条:
第一,任务是否需要访问不能移动的资源。
如果你的 agent 只是调 API、写数据库,放云上完全没问题;一旦它要碰本地文件、本地登录态、本地订阅额度,执行层就得下沉。
第二,故障是常态还是异常。如果执行节点会频繁、正常地消失(用户设备、边缘节点、抢占式实例),那你必须把节点消失设计成可重试的基础设施故障,而不是当成事故告警。
这一条比第一条更容易被忽略,很多团队把架构改对了,却忘了改故障分类,结果半夜被 oncall 叫起来处理用户合盖了笔记本。
这个系列我会把整套 Agent 执行链路拆完,从架构取舍到进程细节到可观测性,每一篇都来自真实系统的内部设计文档,包括那些还没解决的问题。
关注一下,后面 9 篇不迷路。
你的 agent 系统里,有哪些状态其实是服务器猜出来的?心跳间隔你设的是多少,为什么是这个数?评论区聊聊。
下篇预告:第 2 篇《100 台机器,但我们没有集群》,开一群 EC2 出来,它们彼此之间居然一条连接都没有。