Hotdry.

Article

用 Rust 构建可热插拔的 AI Agent 运行时:LLM 与任意工具的安全沙箱交互

基于 block/goose 源码,拆解 Rust 侧插件化运行时与沙箱隔离的工程化要点,给出热插拔超时、沙箱内存、API 限流等可落地参数。

2025-12-11ai-systems

AI Agent 进入 “工具调用” 时代后,运行时面临两个硬需求:

  1. 工具链随时升级,进程不能重启;
  2. 工具代码不可信,必须隔离执行。

用 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_versionhot-lib-reloader 同时校验 RUSTC_VERSION
    • 函数签名用 abi_stable crate 生成 #[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/toolsbind 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

五、踩坑提示

  1. dylib 与 panic=unwind 混用会导致 FFI 边界出现未定义行为,务必在 release profile 里写死 panic=abort
  2. overlayfs 内核低于 5.8 时,tmpfs 上层无法启用 nosuid,需额外加 prctl(NO_NEW_PRIVS) 弥补。
  3. 热插拔回滚后旧库未卸载—— 一般是 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/

ai-systems