site logo

Marico's space

用 Rust 构建自托管多 Agent 系统:架构决策与经验总结

编程技术 2026-05-13 20:57:19 12

最近折腾了一个自托管的多 Agent 系统,踩了不少坑,这篇把核心架构决策和经验教训说清楚。

事情是这样的:我想让五个独立的 AI Agent 运行在同一台工作站上。不是五个线程,是五个真正的 Agent——每个有自己的身份、记忆范围、工具权限和职责。它们各自跑在容器里,通过共享数据库通信,各自执行"分诊 → 执行 → 复盘"的推理循环。

全程用 Rust 实现,不碰 Python,不依赖云端推理,零外部服务依赖。

为什么选 Rust?

不是语言情怀,是务实选择。

一个 Agent 系统同时有太多东西在跑:LLM 流式响应、不断膨胀的对话历史、等待用户输入的权限确认、实时渲染的终端界面、数据库订阅导致的异步触发。在 Python 里管好这些复杂度得靠团队纪律,但纪律这东西不是每时每刻都有的。Rust 的编译器直接强制你保持纪律。

静态链接意味着一个二进制文件可以在 Linux 服务器、macOS 笔记本、Docker 容器甚至物理隔离机上完全一致地运行。没有运行时版本冲突,没有"在我机器上能跑"的破事。开启 LTO、开启 size 优化、strip 二进制,最终的编排器(orchestrator)二进制非常小。

更重要的是,所有权模型和异步生态让 crate 之间的边界可以保持得很严格。如果某个工具实现不小心导入了 TUI 层的代码,编译直接失败——意外耦合在编译期就被抓出来,而不是等到运行时才发现。

架构设计

五个 Agent,共用一个数据库

每个 Agent 运行在独立容器里,各自独立:

  • 独立的记忆范围 — Agent A 的短期记忆默认看不到 Agent B 的,除非显式共享
  • 工具白名单 — DevClaw 可以写代码,UXClaw 可以审查 UI,但没有人能删别人的成果
  • 身份定义 — 每个 Agent 清楚自己是谁、该干什么

它们共享一个 SpacetimeDB 实例——一个响应式数据库,状态变化时自动通知相关 Agent。不需要消息队列,不需要 pub/sub 中间件。数据库本身就是消息总线。

// 简化的 Agent 启动代码
let agent = AgentRuntime::new( config, spacetimedb_client, inference_client, memory_client,
); agent.spawn().await?;

IPC:Unix Domain Socket

Agent 之间通过 Unix domain socket 通信,wire 格式用 bincode 2.0.1,加上 4 字节协议版本字段。不走 HTTP,不用 REST,内部通信没有 JSON 序列化开销。

编排器监听一个 domain socket,接收 Agent 连接请求,根据 Agent ID 路由消息。够快,够简单,还不需要网络协议栈。

SpacetimeDB 作为事实来源

所有 Agent 状态都在 SpacetimeDB 里——任务、消息、记忆条目、待处理的注意力请求。数据库的写操作全部通过 WASM reducer(Rust 编译成 WASM),这意味着:

  • 验证发生在数据库层,而不是应用代码
  • 没有竞态条件——SpacetimeDB 处理并发
  • 订阅是响应式的——Agent 会在相关状态变化时收到通知
  • 数据库 schema 就是 API

推理引擎: llama.cpp,不选 Ollama

编排器管理一个推理槽位池——每个 Agent 按优先级排队竞争 GPU 显存。槽位选择器在信号量获取之前运行,这样可以防止 Agent 竞争有限 VRAM 时出现死锁。

核心循环:分诊 → 执行 → 复盘

每个 Agent 都遵循同样的三步循环:

分诊

Agent 接收到刺激——任务更新、来自其他 Agent 的消息、待处理的注意力请求。它评估:这事能处理吗?在我职责范围内吗?优先级怎样?

这不是简单的过滤。Agent 会推理上下文,权衡紧急性和重要性,决定是行动、转交还是推迟。

执行

如果 Agent 决定行动,它在白名单范围内执行工具。DevClaw 写代码,UXClaw 审查 UI,OpsClaw 检查系统健康。每次行动都记录到 SpacetimeDB。

关键约束:Agent 不能修改不属于自己的状态。没有 Agent 能删另一个 Agent 的任务,没有 Agent 能写另一个 Agent 的记忆。这在数据库层强制执行。

复盘

行动之后,Agent 会复盘。这次行动成功了吗?哪里出了问题?下次应该怎么做?复盘结果会成为一条记忆条目——结构化的学习点,影响未来的分诊决策。

复盘不只是日志。它是"工程化的记忆"——结构化、可查询、按最近度和重要性加权。

做对了的事

单一职责 crate 模式

工作空间组织成每个 crate 只有一个职责:

  • orchestrator — Agent 生命周期、槽位管理、消息路由
  • agent-runtime — 每个 Agent 的分诊→执行→复盘循环
  • ipc-protocol — wire 格式和消息定义
  • inference-client — llama.cpp 集成、槽位池
  • db-bindings — SpacetimeDB 客户端和 schema
  • memory-client — Convex 混合搜索、记忆分层

依赖方向严格向内。如果出现循环依赖,编译直接失败。这不是可选项——这是让 6 个 crate 的工作空间保持可维护的关键。

记忆作为副作用

我没有单独构建记忆系统。记忆是核心循环的副作用。当 Agent 复盘时,复盘结果本身就是一次记忆写入。没有独立的"记忆管理"流程。当日记忆文件超过大小阈值时触发压缩,将相关复盘合并成简洁的长期条目。

够简单,够管用,没有过度工程。

每个子系统独立连接 SpacetimeDB

最初设计是一个监督者连接 SpacetimeDB,Agent 通过监督者接收更新。做完之后我改成了五个子系统各自打开自己的 SDB 连接。单监督者方案有太多竞争——每个状态变化都要经过一个连接,成为瓶颈。

教训:设计时要想着你实际在构建的部署场景,而不是你想象中的那个。

翻车的经验

过度设计消息总线

第一版在 Unix domain socket 上面自己写了个消息总线。有优先级队列、重试逻辑、死信处理。很优雅,但完全没必要。SpacetimeDB 的订阅就能处理所有这些。我删掉了 400 行代码。

假设 CUDA 13 会稳定

推理系统是按 CUDA 13 设计的。当 CUDA 13.2 引入破坏性变更时,全部炸了。修复方案是锁定到 CUDA 13.1 并在文档里标注约束。简单的约束,但让我花了一天调试。

Docker 出站流量问题

Docker 的 iptables 规则不是每种主机拓扑都能用的。Phase 3 计划需要出站流量来做工具调用(如网络搜索),但在某些主机上,Docker 默认的 iptables 配置会阻止出站连接。解决方案是 L7 HTTPS 代理,但这增加了部署复杂度。

如果重来,我会怎么做

先设计数据库 schema

第一版代码是在 SpacetimeDB schema 定稿之前写的。这意味着 schema 演进时需要不断重构。现在我先设计数据库 schema,再围绕它构建应用代码。数据库就是契约。

早点把 CI 卡口加上

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 加睡眠、做梦和审计追踪。

代码库在增长。架构在稳定。下一步是让它变得更好,而不是变得更大。

原文链接:https://dev.to/user-2492f6c4/building-a-selfhosted-multiagent-system-in-rust-architecture-decisions-and-what-i-learned