
最近折腾了一个自托管的多 Agent 系统,踩了不少坑,这篇把核心架构决策和经验教训说清楚。
事情是这样的:我想让五个独立的 AI Agent 运行在同一台工作站上。不是五个线程,是五个真正的 Agent——每个有自己的身份、记忆范围、工具权限和职责。它们各自跑在容器里,通过共享数据库通信,各自执行"分诊 → 执行 → 复盘"的推理循环。
全程用 Rust 实现,不碰 Python,不依赖云端推理,零外部服务依赖。
不是语言情怀,是务实选择。
一个 Agent 系统同时有太多东西在跑:LLM 流式响应、不断膨胀的对话历史、等待用户输入的权限确认、实时渲染的终端界面、数据库订阅导致的异步触发。在 Python 里管好这些复杂度得靠团队纪律,但纪律这东西不是每时每刻都有的。Rust 的编译器直接强制你保持纪律。
静态链接意味着一个二进制文件可以在 Linux 服务器、macOS 笔记本、Docker 容器甚至物理隔离机上完全一致地运行。没有运行时版本冲突,没有"在我机器上能跑"的破事。开启 LTO、开启 size 优化、strip 二进制,最终的编排器(orchestrator)二进制非常小。
更重要的是,所有权模型和异步生态让 crate 之间的边界可以保持得很严格。如果某个工具实现不小心导入了 TUI 层的代码,编译直接失败——意外耦合在编译期就被抓出来,而不是等到运行时才发现。
每个 Agent 运行在独立容器里,各自独立:
它们共享一个 SpacetimeDB 实例——一个响应式数据库,状态变化时自动通知相关 Agent。不需要消息队列,不需要 pub/sub 中间件。数据库本身就是消息总线。
// 简化的 Agent 启动代码
let agent = AgentRuntime::new( config, spacetimedb_client, inference_client, memory_client,
); agent.spawn().await?;
Agent 之间通过 Unix domain socket 通信,wire 格式用 bincode 2.0.1,加上 4 字节协议版本字段。不走 HTTP,不用 REST,内部通信没有 JSON 序列化开销。
编排器监听一个 domain socket,接收 Agent 连接请求,根据 Agent ID 路由消息。够快,够简单,还不需要网络协议栈。
所有 Agent 状态都在 SpacetimeDB 里——任务、消息、记忆条目、待处理的注意力请求。数据库的写操作全部通过 WASM reducer(Rust 编译成 WASM),这意味着:
编排器管理一个推理槽位池——每个 Agent 按优先级排队竞争 GPU 显存。槽位选择器在信号量获取之前运行,这样可以防止 Agent 竞争有限 VRAM 时出现死锁。
每个 Agent 都遵循同样的三步循环:
Agent 接收到刺激——任务更新、来自其他 Agent 的消息、待处理的注意力请求。它评估:这事能处理吗?在我职责范围内吗?优先级怎样?
这不是简单的过滤。Agent 会推理上下文,权衡紧急性和重要性,决定是行动、转交还是推迟。
如果 Agent 决定行动,它在白名单范围内执行工具。DevClaw 写代码,UXClaw 审查 UI,OpsClaw 检查系统健康。每次行动都记录到 SpacetimeDB。
关键约束:Agent 不能修改不属于自己的状态。没有 Agent 能删另一个 Agent 的任务,没有 Agent 能写另一个 Agent 的记忆。这在数据库层强制执行。
行动之后,Agent 会复盘。这次行动成功了吗?哪里出了问题?下次应该怎么做?复盘结果会成为一条记忆条目——结构化的学习点,影响未来的分诊决策。
复盘不只是日志。它是"工程化的记忆"——结构化、可查询、按最近度和重要性加权。
工作空间组织成每个 crate 只有一个职责:
orchestrator — Agent 生命周期、槽位管理、消息路由agent-runtime — 每个 Agent 的分诊→执行→复盘循环ipc-protocol — wire 格式和消息定义inference-client — llama.cpp 集成、槽位池db-bindings — SpacetimeDB 客户端和 schemamemory-client — Convex 混合搜索、记忆分层依赖方向严格向内。如果出现循环依赖,编译直接失败。这不是可选项——这是让 6 个 crate 的工作空间保持可维护的关键。
我没有单独构建记忆系统。记忆是核心循环的副作用。当 Agent 复盘时,复盘结果本身就是一次记忆写入。没有独立的"记忆管理"流程。当日记忆文件超过大小阈值时触发压缩,将相关复盘合并成简洁的长期条目。
够简单,够管用,没有过度工程。
最初设计是一个监督者连接 SpacetimeDB,Agent 通过监督者接收更新。做完之后我改成了五个子系统各自打开自己的 SDB 连接。单监督者方案有太多竞争——每个状态变化都要经过一个连接,成为瓶颈。
教训:设计时要想着你实际在构建的部署场景,而不是你想象中的那个。
第一版在 Unix domain socket 上面自己写了个消息总线。有优先级队列、重试逻辑、死信处理。很优雅,但完全没必要。SpacetimeDB 的订阅就能处理所有这些。我删掉了 400 行代码。
推理系统是按 CUDA 13 设计的。当 CUDA 13.2 引入破坏性变更时,全部炸了。修复方案是锁定到 CUDA 13.1 并在文档里标注约束。简单的约束,但让我花了一天调试。
Docker 的 iptables 规则不是每种主机拓扑都能用的。Phase 3 计划需要出站流量来做工具调用(如网络搜索),但在某些主机上,Docker 默认的 iptables 配置会阻止出站连接。解决方案是 L7 HTTPS 代理,但这增加了部署复杂度。
第一版代码是在 SpacetimeDB schema 定稿之前写的。这意味着 schema 演进时需要不断重构。现在我先设计数据库 schema,再围绕它构建应用代码。数据库就是契约。
CI 卡口加得很晚——拒绝 CUDA 13.2 的构建、拒绝无界的 mpsc 通道、拒绝 await_holding_lock 违规。这些应该从一开始就有。每一个都至少导致了一个生产 bug。对于有并发的系统,CI 卡口不是可选项。
我没有显式跟踪开放问题。这在开始"开放问题"文档后改变了(Q-001 到 Q-053)——每个未解决的设计决策、每个架构模糊点、每个"我以后再想"的决定。有些至今还是开放的。没关系。不是每个决策都需要今天做。但知道自己不知道什么,比假装自己什么都知道有价值得多。
五个 Agent。一个数据库。零云依赖。跑在一台 Threadripper PRO 工作站上,插着 RTX 3090 显卡。每个 Agent 自主分诊、执行、复盘。各自在容器里。各有各的身份。
不完美,没完工,但能跑。
系统可以接收一个刺激——用户消息、任务更新、待处理的注意力请求——然后在没有人干预的情况下产生连贯的多 Agent 响应。Agent 之间互相通信、推理、学习。
而且全都跑在一台能塞进一个机柜的机器上。
Phase 3 加弹性——三层监督、自适应参数、故障恢复。Phase 4 加高级可观测性和元认知。Phase 5 加睡眠、做梦和审计追踪。
代码库在增长。架构在稳定。下一步是让它变得更好,而不是变得更大。