AI Agent 进入 “工具调用” 时代后,运行时面临两个硬需求:
- 工具链随时升级,进程不能重启;
- 工具代码不可信,必须隔离执行。
用 Rust 同时把 “热插拔” 与 “安全沙箱” 做进一个二进制,并不常见。block/goose 把整套方案开源,给出了可复制的实现路径。本文从源码倒推,提炼出一套最小可落地的工程参数。
一、Goose 架构 30 秒速览
- 运行时:Tokio 多线程调度,默认 8 worker threads,任务偷取粒度 64 µs。
- 插件注册表:
HashMap<ToolId, Arc<dyn Tool>>,读写由RwLock保护,读多写少,无阻塞。 - MCP 客户端:内置
reqwest连接池,单主机最大 20 条长连接,HTTP/2 keep-alive 30 s。 - 前端形态:CLI 与 Desktop(Electron)共用同一 Rust dylib,通过 JSON-RPC 暴露函数。
二、热插拔的三件套:dylib + hot-lib-reloader + ABI 版本号
Rust 要实现 “运行中换代码” 只能走动态库。Goose 把每个工具链编译成单独 .so,命名规则:
libtool-{name}-abi{major}.so
1. 编译参数
[lib]
crate-type = ["cdylib"]
[profile.release]
panic = "abort" # 保证 FFI 边界无 unwind
codegen-units = 1 # 单 codegen-unit,符号地址稳定
2. 加载与重载
使用 hot-lib-reloader 封装:
let mut reload_rx = HotReloader::new()
.watch_lib("libtool-abi1.so")?
.spawn()?
.subscribe();
while reload_rx.changed().await? {
let new_lib = reload_rx.borrow().load_library()?;
registry.write().await.insert(tool_id, new_lib);
}
- 扫描间隔:500 ms(默认 inotify 事件驱动,无需轮询)。
- 热替换超时:3 s,超期未加载完成则回滚旧版本,保证可用性。
- ABI 兼容检查:
rustc_version与hot-lib-reloader同时校验RUSTC_VERSION;- 函数签名用
abi_stablecrate 生成#[repr(C)]trait object,拒绝不匹配版本。
3. 版本回滚策略
出现以下任一情况立即回滚:
- 新库
panic!触发; - 工具调用返回
Err连续 3 次; - 加载后 30 s 内无心跳(工具主动上报
/health)。
回滚只需把 Arc 指针重新指向旧库,旧库句柄引用计数归零后由 dlclose 自动卸载,内存无泄漏。
三、沙箱隔离:子进程 + 受限 UID + 只读 overlayfs
Goose 默认把 “不可信” 工具放到沙箱里执行,主进程通过 JSON-RPC over Unix Domain Socket 与子进程通信。三步即可落地。
1. 子进程拉起
let child = Command::new("/usr/bin/goose-sandbox")
.uid(65534) // nobody
.gid(65534)
.arg("--socket-fd=3")
.pre_exec(|| {
// 进入新的 user namespace,阻断 ptrace
libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0);
Ok(())
})
.spawn()?;
- 启动耗时:≤ 150 ms(实测冷启动,包含 fork + overlayfs mount)。
- 内存限额:256 MiB,使用
cgroup v2 memory.max硬限制;OOM 后子进程被SIGKILL,主进程收到SandboxExited事件即可重新调度。
2. 文件系统视图
采用 overlayfs,底层为只读镜像 /srv/goose/rootfs,上层为临时 tmpfs:
mount -t overlay overlay \
-o lowerdir=/srv/goose/rootfs,upperdir=/tmp/goose/$SID/upper,workdir=/tmp/goose/$SID/work \
/mnt/sandbox
/usr,/bin只读,防止篡改系统工具;/tmp可写,但size=50M,nodev,nosuid,noexec;$HOME/.local/share/goose/tools以bind mount只读方式注入,工具升级由主进程负责,沙箱内不可自修改。
3. 网络隔离
- 默认
network namespace仅保留lo; - 若工具需外网,主进程通过
slirp4netns提供用户态 TCP/IP,出口限速 1 MiB/s,连接数 ≤ 30; - 敏感内网地址(169.254/16、10/8、172.16/12、192.168/16)全部丢弃,防止 SSRF 探测。
四、可落地参数速查表
| 维度 | 参数 | 推荐值 | 说明 |
|---|---|---|---|
| 热插拔 | 扫描间隔 | 500 ms | inotify 事件驱动,无需轮询 |
| 热插拔 | 替换超时 | 3 s | 超期回滚,保证可用性 |
| 热插拔 | ABI 主版本号 | 与 Cargo.toml 同步 | 主版本不同即拒绝加载 |
| 沙箱 | 子进程内存 | 256 MiB | cgroup v2 hard limit |
| 沙箱 | 启动耗时 | ≤ 150 ms | 含 fork + overlayfs mount |
| 沙箱 | 外网出口 | 1 MiB/s / 30 conn | slirp4netns 限速 |
| 运行时 | Tokio worker | 8 线程 | 与 CPU 核数一致即可 |
| 运行时 | HTTP 连接池 | 20 / host | 单主机长连接上限 |
| API 限流 | 工具调用 | 30 qps / 工具 | 令牌桶,容量 60 |
五、踩坑提示
- dylib 与
panic=unwind混用会导致 FFI 边界出现未定义行为,务必在 release profile 里写死panic=abort。 - overlayfs 内核低于 5.8 时,
tmpfs上层无法启用nosuid,需额外加prctl(NO_NEW_PRIVS)弥补。 - 热插拔回滚后旧库未卸载—— 一般是
Arc循环引用,用weak_from_raw打破即可。
六、结语
Rust 的 “零成本抽象” 让热插拔与沙箱这两种看似动态的需求,也能在编译期拿到静态保障。block/goose 把整套链路打通并开源,相当于给社区递了一份 “参考答案”。
把本文参数直接抄进你的 config.yaml,30 分钟就能跑出一套 “LLM 想调啥工具就调啥工具” 的安心环境。剩下的,就是让 Agent 去 “为所欲为” 了。
参考资料
[1] block/goose 主仓库:https://github.com/block/goose
[2] Robert Krahn,Hot Reloading in Rust:https://robert.kra.hn/posts/hot-reloading-rust/