11# Yuxi 沙盒架构说明
22
3- 这份文档说明当前项目中“沙盒”这一层到底是什么、为什么同时会看到 Docker 和 Kubernetes、默认开发环境实际启用的是哪一种模式,以及沙盒如何和 ` skills ` 、知识库、附件、工作区文件系统组合在一起工作。文档内容以当前仓库实现为准,重点解释真实调用链、配置入口、路径语义和运维边界,而不是抽象地介绍容器技术。
3+ ::: tip info
4+ 本文档是由 Codex 联合撰写,开发者审阅,尽管已经多次校对,但仍可能存在不准确或过时的描述。如果你发现任何问题,欢迎提交 issue 或 PR 来帮助我们改进文档。
5+ :::
6+
7+ 我们在 Yuxi 里引入沙盒,不是为了让架构更“重”,而是因为 Agent 一旦从纯文本对话进入真实执行阶段,就一定会碰到一组很具体的运行时需求:执行命令、读写文件、处理用户上传附件、产出可下载结果,以及在受控目录里保留中间过程文件。如果把这些能力直接放进 API 进程本身,权限边界、租户隔离、环境一致性和后续运维成本都会迅速恶化。
8+
9+ 从设计目标上看,沙盒这一层主要解决三件事。第一,给 Agent 一个可写、可执行、可回收的独立运行空间,而不是让它直接操作应用主进程。第二,把模型可见文件系统整理成稳定的命名空间,例如 ` /home/gem/user-data ` 、` /home/gem/skills ` 、` /home/gem/kbs ` ,这样 prompt、工具、viewer 和 artifact 下载接口可以共享同一套路径语义。第三,让这套能力既能在本地 Docker 开发环境里稳定工作,也能在需要时切到 Kubernetes 这类更适合多实例部署的承载方式。
10+
11+ 这份文档说明当前项目中“沙盒”这一层到底是什么、为什么同时会看到 Docker 和 Kubernetes、默认开发环境实际启用的是哪一种模式,以及沙盒如何和 ` skills ` 、知识库、附件、工作区文件系统组合在一起工作。内容以当前仓库实现为准,我们重点解释真实调用链、配置入口、路径语义和运维边界,而不是抽象地介绍容器技术。
412
513## 一、先说明白:Docker 和 K8s 在这里是什么关系
614
715Docker 和 Kubernetes 不是互斥关系。Docker 解决的是“把一个进程放进容器里运行”这个问题,Kubernetes 解决的是“如何在一组机器上批量调度、暴露、重建和管理这些容器”这个问题。可以把 Docker 理解成容器运行时和镜像分发方式,把 Kubernetes 理解成容器编排平台。
816
917放到 Yuxi 里,这个关系更具体一些。Yuxi 本身并不直接决定“沙盒一定跑在 Docker 还是一定跑在 K8s 上”,它只要求后端拿到一个可访问的沙盒地址,然后通过 ` agent-sandbox ` 的 HTTP API 去执行命令、读写文件。真正负责创建和回收沙盒实例的是 ` sandbox-provisioner ` 这个单独的服务。也就是说,Yuxi 的应用层只依赖 “provisioner”,而 provisioner 的后端可以选择用本机 Docker 去起容器,也可以选择向 Kubernetes 集群创建 Pod 和 Service。
1018
11- 所以项目里看到的概念其实分成两层。第一层是应用层的 ` SANDBOX_PROVIDER ` ,当前代码只支持 ` provisioner ` 。第二层是 provisioner 内部的 ` SANDBOX_PROVISIONER_BACKEND ` ,它决定具体用哪种底层实现去创建沙盒。你在配置中看到的 ` local ` 、` docker ` 、` kubernetes ` ,都属于这一层,而不是三套互相独立的产品形态。
19+ 所以项目里看到的概念其实分成两层。第一层是应用层的 ` SANDBOX_PROVIDER ` ,当前代码只支持 ` provisioner ` 。第二层是 provisioner 内部的 ` SANDBOX_PROVISIONER_BACKEND ` ,它决定具体用哪种底层实现去创建沙盒。配置中看到的 ` local ` 、` docker ` 、` kubernetes ` ,都属于这一层,而不是三套互相独立的产品形态。
1220
1321## 二、当前项目的真实沙盒调用链
1422
1523当前仓库里,后端只支持 ` SANDBOX_PROVIDER=provisioner ` 。当某个对话线程第一次需要执行文件操作或命令执行时,后端会基于 ` thread_id ` 生成一个稳定的 ` sandbox_id ` ,然后请求 ` sandbox-provisioner ` 创建或复用对应沙盒。应用层拿到返回的 ` sandbox_url ` 之后,才会真正通过 ` agent-sandbox ` 客户端去调用远程沙盒的文件 API 和 shell API。
1624
1725调用链可以概括为:Web/API 请求进入 Yuxi 后端,后端构造 ` ProvisionerSandboxBackend ` ,再经由 ` ProvisionerClient ` 调用 ` sandbox-provisioner ` 的 ` /api/sandboxes ` 接口。` sandbox-provisioner ` 根据 ` SANDBOX_PROVISIONER_BACKEND ` 选择本地内存实现、Docker 容器实现或 Kubernetes 实现。沙盒真正启动后,对外暴露一个 HTTP 地址,Yuxi 再使用这个地址完成执行命令、上传文件、下载文件、目录遍历等操作。
1826
19- 当前仓库的默认配置和当前开发环境都是 ` local ` 。默认值来自 ` .env.template ` 中的 ` SANDBOX_PROVISIONER_BACKEND=local ` ,而当前运行中的 provisioner 健康检查返回的也是 ` backend=local ` 。这意味着你现在用 ` docker compose up -d ` 启动项目时,应用并不是直接把代码跑在宿主机上,而是通过 ` sandbox-provisioner ` 再去用 Docker 启一个真正的沙盒容器。
27+ 当前仓库的默认配置和默认开发环境都是 ` local ` 。默认值来自 ` .env.template ` 中的 ` SANDBOX_PROVISIONER_BACKEND=local ` ,而运行中的 provisioner 健康检查返回的也应当是 ` backend=local ` 。这意味着我们用 ` docker compose up -d ` 启动项目时,应用并不是直接把代码跑在宿主机上,而是通过 ` sandbox-provisioner ` 再去用 Docker 启一个真正的沙盒容器。
2028
2129## 三、` local ` 、` docker ` 、` kubernetes ` 到底分别是什么
2230
@@ -28,7 +36,7 @@ Docker 和 Kubernetes 不是互斥关系。Docker 解决的是“把一个进程
2836
2937` kubernetes ` 则是另一条实现路径。它不会再去调用本机 Docker 起容器,而是使用 Kubernetes API 在指定 namespace 中创建一个 Pod 和一个 NodePort Service,然后把这个 Service 对应的可访问地址回传给 Yuxi 后端。
3038
31- 因此,如果你在界面 、文档或者环境变量里看到 “local / docker / k8s” 这几个词,最准确的理解应该是:Yuxi 的应用层只有一种 provider,也就是 ` provisioner ` ;provisioner 下面有多种 backend;其中 ` local ` 和 ` docker ` 是同一种 Docker 本机后端的两个别名,` kubernetes ` 才是另一种远程集群后端。
39+ 因此,如果在界面 、文档或者环境变量里看到 “local / docker / k8s” 这几个词,最准确的理解应该是:Yuxi 的应用层只有一种 provider,也就是 ` provisioner ` ;provisioner 下面有多种 backend;其中 ` local ` 和 ` docker ` 是同一种 Docker 本机后端的两个别名,` kubernetes ` 才是另一种远程集群后端。
3240
3341## 四、默认开发模式到底是什么
3442
@@ -54,15 +62,15 @@ Docker 后端在启动沙盒时,会挂载两类关键目录。第一类是线
5462
5563Kubernetes 后端下,沙盒还是同一套镜像,还是暴露同样的 HTTP API,但存储方式和暴露方式变了。它不会依赖宿主机 Docker bind mount,而是要求有一个可写的 PVC。当前实现里真正使用的是 ` THREAD_PVC ` ,Pod 会把这块共享存储挂到 ` /mnt/shared-data ` ,然后用 ` subPath ` 的方式把 ` threads/<thread_id>/user-data ` 挂到 ` /home/gem/user-data ` ,把 ` threads/<thread_id>/skills ` 挂到 ` /home/gem/skills ` 。这样做的好处是线程之间的数据目录结构仍然可以和 Docker 模式保持一致。
5664
57- 需要特别说明的是,代码里虽然读取了 ` SKILLS_PVC ` 这个环境变量,但当前 Pod 规格实际没有使用单独的 skills PVC,而是统一从 ` THREAD_PVC ` 中切 ` threads/<thread_id>/skills ` 这个子路径。因此,如果你看到环境变量里同时出现 ` SKILLS_PVC ` 和 ` THREAD_PVC ` ,请以 ` THREAD_PVC ` 的真实挂载语义为准,` SKILLS_PVC ` 目前更像一个预留字段。
65+ 需要特别说明的是,代码里虽然读取了 ` SKILLS_PVC ` 这个环境变量,但当前 Pod 规格实际没有使用单独的 skills PVC,而是统一从 ` THREAD_PVC ` 中切 ` threads/<thread_id>/skills ` 这个子路径。因此,如果看到环境变量里同时出现 ` SKILLS_PVC ` 和 ` THREAD_PVC ` ,应当以 ` THREAD_PVC ` 的真实挂载语义为准,` SKILLS_PVC ` 目前更像一个预留字段。
5866
5967Kubernetes 后端还需要一个 ` NODE_HOST ` 。这是因为当前实现使用的是 NodePort Service,而不是 Ingress,也不是 ClusterIP。provisioner 创建完 Service 之后,会把最终访问地址拼成 ` http://<NODE_HOST>:<nodePort> ` 返回给 Yuxi 后端。所以 ` NODE_HOST ` 必须是 Yuxi 后端能够访问到的 Kubernetes 节点地址、负载均衡地址或者对 NodePort 做了透出的外部域名。
6068
61- ## 七、如果我要使用 “远程 K8s”,应该怎么接
69+ ## 七、如果要使用 “远程 K8s”,应该怎么接
6270
63- 这里最容易误解的一点是,所谓“选择远程 K8s”,并不是在 Yuxi 页面里点一个开关,然后系统自动发现一个集群。当前实现没有内建集群选择器,也没有多集群管理界面。它的工作方式很直接:你把 ` sandbox-provisioner ` 配置成 ` kubernetes ` 后端,并让它能拿到目标集群的 kubeconfig 或者运行在集群内即可。对 provisioner 来说,只要 Kubernetes 客户端能连上 API Server,这个集群就是它要操作的“远程 K8s”。
71+ 这里最容易误解的一点是,所谓“选择远程 K8s”,并不是在 Yuxi 页面里点一个开关,然后系统自动发现一个集群。当前实现没有内建集群选择器,也没有多集群管理界面。它的工作方式很直接:我们把 ` sandbox-provisioner ` 配置成 ` kubernetes ` 后端,并让它能拿到目标集群的 kubeconfig 或者运行在集群内即可。对 provisioner 来说,只要 Kubernetes 客户端能连上 API Server,这个集群就是它要操作的“远程 K8s”。
6472
65- 如果你的 Yuxi 部署在 Docker Compose 里,而 Kubernetes 集群在另一台机器或云厂商托管环境中,那么最常见的做法是把本地 kubeconfig 文件挂载进 ` sandbox-provisioner ` 容器,然后设置 ` KUBECONFIG_PATH ` 。同时把 ` SANDBOX_NODE_HOST ` 改成一个从 ` api ` 容器也能访问的节点公网 IP、负载均衡域名,或者你自己做过反向代理的地址 。
73+ 如果 Yuxi 部署在 Docker Compose 里,而 Kubernetes 集群在另一台机器或云厂商托管环境中,那么最常见的做法是把本地 kubeconfig 文件挂载进 ` sandbox-provisioner ` 容器,然后设置 ` KUBECONFIG_PATH ` 。同时把 ` SANDBOX_NODE_HOST ` 改成一个从 ` api ` 容器也能访问的节点公网 IP、负载均衡域名,或者已经做过反向代理的地址 。
6674
6775一个典型的 Compose 覆盖配置会长这样:
6876
@@ -81,7 +89,7 @@ services:
8189
8290这段配置表达的意思不是“把整个应用迁到 K8s”,而是“仍然用 Compose 跑 Yuxi 主服务,但沙盒实例改为由远程 Kubernetes 集群承载”。这是当前代码最自然的混合部署方式。
8391
84- 如果你的 ` sandbox-provisioner` 本身就运行在 Kubernetes 集群内部,那么通常不需要显式提供 `KUBECONFIG_PATH`。它会优先尝试 `incluster_config`,也就是使用 Pod 的服务账号权限直接访问 Kubernetes API。此时你更需要关注的是 namespace、PVC 和 NodePort 的可达性,而不是 kubeconfig 文件本身。
92+ 如果 ` sandbox-provisioner` 本身就运行在 Kubernetes 集群内部,那么通常不需要显式提供 `KUBECONFIG_PATH`。它会优先尝试 `incluster_config`,也就是使用 Pod 的服务账号权限直接访问 Kubernetes API。此时更需要关注的是 namespace、PVC 和 NodePort 的可达性,而不是 kubeconfig 文件本身。
8593
8694# # 八、当前项目的沙盒文件系统是如何设计的
8795
@@ -110,7 +118,7 @@ saves/
110118
111119这里要重点理解 `workspace` 和 `uploads/outputs` 的区别。按照当前宿主机路径解析逻辑,`workspace` 被定义为共享目录,位置是 `saves/threads/shared/workspace`;而 `uploads` 和 `outputs` 属于线程私有目录,位置分别是 `saves/threads/<thread_id>/user-data/uploads` 和 `saves/threads/<thread_id>/user-data/outputs`。viewer 文件系统、artifact 下载接口以及路径解析函数都按这个语义工作,因此不同线程可以看到同一个 workspace,但看不到彼此的 uploads。
112120
113- 与此同时,运行时 provisioner 在创建 Docker 容器或 Kubernetes Pod 时,会把共享的 `saves/threads/shared/workspace` 单独挂到 `/home/gem/user-data/workspace`,再把当前线程自己的 `uploads/outputs` 分别挂到 `/home/gem/user-data/uploads` 和 `/home/gem/user-data/outputs`。因此在排查文件问题时,你需要有一个清晰意识 :当前项目里同时存在“宿主机侧目录组织”和“容器内统一虚拟路径”两层概念。对外接口和 viewer 语义与底层挂载实现现在是一致的,workspace 是共享空间,而 uploads/outputs 仍然保持线程隔离。
121+ 与此同时,运行时 provisioner 在创建 Docker 容器或 Kubernetes Pod 时,会把共享的 `saves/threads/shared/workspace` 单独挂到 `/home/gem/user-data/workspace`,再把当前线程自己的 `uploads/outputs` 分别挂到 `/home/gem/user-data/uploads` 和 `/home/gem/user-data/outputs`。因此在排查文件问题时,需要先明确一个前提 :当前项目里同时存在“宿主机侧目录组织”和“容器内统一虚拟路径”两层概念。对外接口和 viewer 语义与底层挂载实现现在是一致的,workspace 是共享空间,而 uploads/outputs 仍然保持线程隔离。
114122
115123# # 九、路径暴露规则是什么
116124
@@ -132,7 +140,7 @@ skills 的结合方式分成两层。第一层是提示词层,`SkillsMiddlewar
132140
133141# # 十一、当前推荐如何使用 Docker 沙盒
134142
135- 如果你只是正常开发 、调试或单机部署,最简单也是当前默认的方式就是保留 `SANDBOX_PROVIDER=provisioner`,同时把 `SANDBOX_PROVISIONER_BACKEND` 设为 `local`。这会让整个项目继续由 Docker Compose 管理,而沙盒实例由 provisioner 动态创建。你通常不需要手工 `docker run` 沙盒镜像,也不需要在 Compose 文件里静态声明每一个沙盒容器。
143+ 如果只是正常开发 、调试或单机部署,最简单也是当前默认的方式就是保留 `SANDBOX_PROVIDER=provisioner`,同时把 `SANDBOX_PROVISIONER_BACKEND` 设为 `local`。这会让整个项目继续由 Docker Compose 管理,而沙盒实例由 provisioner 动态创建。通常不需要手工 `docker run` 沙盒镜像,也不需要在 Compose 文件里静态声明每一个沙盒容器。
136144
137145最小必要配置通常就是下面这几项:
138146
@@ -153,7 +161,7 @@ curl http://localhost:8002/health
153161
154162如果健康检查返回 `backend : local`,就说明 provisioner 已经处于默认的 Docker 本机后端。真正的沙盒容器不会在系统启动时立即全部出现,而是在你第一次创建线程并触发需要文件系统或命令执行的操作后才会被创建。
155163
156- 如果你运行在 Linux,而不是 Docker Desktop,那么 `host.docker.internal` 不一定总是可用。这时要把 `SANDBOX_DOCKER_SANDBOX_HOST` 改成一个从 `api` 容器可达的宿主机地址,或者改成你自己网络环境里更稳定的名字 。否则 provisioner 虽然能成功起容器,但后端可能拿到一个自己无法访问的 `sandbox_url`。
164+ 如果运行在 Linux,而不是 Docker Desktop,那么 `host.docker.internal` 不一定总是可用。这时要把 `SANDBOX_DOCKER_SANDBOX_HOST` 改成一个从 `api` 容器可达的宿主机地址,或者改成当前网络环境里更稳定的名字 。否则 provisioner 虽然能成功起容器,但后端可能拿到一个自己无法访问的 `sandbox_url`。
157165
158166# # 十二、如何理解文件管理与暴露边界
159167
@@ -169,8 +177,8 @@ curl http://localhost:8002/health
169177
170178# # 十四、排障时建议先看什么
171179
172- 如果你怀疑是 provisioner 级问题,先看 `http://localhost:8002/health`,确认 backend 类型和 idle timeout 是否符合预期。接着看 `docker logs sandbox-provisioner --tail 200`,因为这里能直接看到创建容器、复用旧实例、健康检查失败和 idle reaper 删除的日志。
180+ 如果怀疑是 provisioner 级问题,先看 `http://localhost:8002/health`,确认 backend 类型和 idle timeout 是否符合预期。接着看 `docker logs sandbox-provisioner --tail 200`,因为这里能直接看到创建容器、复用旧实例、健康检查失败和 idle reaper 删除的日志。
173181
174- 如果你怀疑是 Docker 地址不可达,重点检查 `SANDBOX_DOCKER_SANDBOX_HOST` 和随机映射端口是否从 `api` 容器可访问。可以在 `api` 容器内直接 `curl` provisioner 返回的 `sandbox_url`。如果你怀疑是 Kubernetes 地址不可达,重点检查 `NODE_HOST` 和 NodePort 的外部连通性,因为当前实现并不是通过集群内部 Service 名称回连。
182+ 如果怀疑是 Docker 地址不可达,重点检查 `SANDBOX_DOCKER_SANDBOX_HOST` 和随机映射端口是否从 `api` 容器可访问。可以在 `api` 容器内直接 `curl` provisioner 返回的 `sandbox_url`。如果怀疑是 Kubernetes 地址不可达,重点检查 `NODE_HOST` 和 NodePort 的外部连通性,因为当前实现并不是通过集群内部 Service 名称回连。
175183
176- 如果你怀疑是文件看得到但模型读不到 ,或者模型写了但 viewer 看不到,优先把问题拆成两层:一层是宿主机路径是否存在于 `saves/...` 下,另一层是该路径是否真的被当前线程沙盒挂载并暴露到了 `/home/gem/user-data`、`/home/gem/skills` 或 `/home/gem/kbs`。只要先分清“宿主机侧文件语义”和“沙盒侧运行时挂载语义”,定位问题会快很多 。
184+ 如果怀疑是文件看得到但模型读不到 ,或者模型写了但 viewer 看不到,优先把问题拆成两层:一层是宿主机路径是否存在于 `saves/...` 下,另一层是该路径是否真的被当前线程沙盒挂载并暴露到了 `/home/gem/user-data`、`/home/gem/skills` 或 `/home/gem/kbs`。只要先分清“宿主机侧文件语义”和“沙盒侧运行时挂载语义”,定位问题通常会快很多 。
0 commit comments