From 3963ddbe2049aff8bd8b5ffd64a75710a6e23203 Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Sun, 29 Mar 2026 14:49:45 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E6=B2=99=E7=9B=92?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E8=AF=B4=E6=98=8E=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E6=A0=A1=E5=AF=B9=E6=8F=90=E7=A4=BA=E5=92=8C?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E9=9C=80=E6=B1=82=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 6 ++++- CLAUDE.md | 6 ++++- docs/agents/sandbox-architecture.md | 38 +++++++++++++++++------------ 3 files changed, 33 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6af948c9..0848706f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,6 @@ make format # 格式化代码 # 直接在容器内执行命令 docker compose exec api uv run python test/your_script.py # 放在 test 文件夹 ``` -安装依赖太慢时可以使用代理端口 7897 注意: - Python 代码要符合 pythonic 风格 - 尽量使用较新的语法,避免使用旧版本的语法(版本兼容到 3.12+) @@ -51,3 +50,8 @@ docker compose exec api uv run python test/your_script.py # 放在 test 文件 - 如果需要新建说明文档(仅开发者可见,非必要不创建),则保存在 `docs/vibe` 文件夹下面 - 代码更新后要检查文档部分是否有需要更新的地方,文档的目录定义在 `docs/.vitepress/config.mts` 中 + +## 提交规范 + +1. 参考 [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) 规范编写提交信息。 +2. 使用中文提交信息,标题简洁明了,描述具体改动内容和原因。 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index d345592a..0848706f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,6 @@ make format # 格式化代码 # 直接在容器内执行命令 docker compose exec api uv run python test/your_script.py # 放在 test 文件夹 ``` - 注意: - Python 代码要符合 pythonic 风格 - 尽量使用较新的语法,避免使用旧版本的语法(版本兼容到 3.12+) @@ -51,3 +50,8 @@ docker compose exec api uv run python test/your_script.py # 放在 test 文件 - 如果需要新建说明文档(仅开发者可见,非必要不创建),则保存在 `docs/vibe` 文件夹下面 - 代码更新后要检查文档部分是否有需要更新的地方,文档的目录定义在 `docs/.vitepress/config.mts` 中 + +## 提交规范 + +1. 参考 [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) 规范编写提交信息。 +2. 使用中文提交信息,标题简洁明了,描述具体改动内容和原因。 \ No newline at end of file diff --git a/docs/agents/sandbox-architecture.md b/docs/agents/sandbox-architecture.md index 324a67e3..81cccf66 100644 --- a/docs/agents/sandbox-architecture.md +++ b/docs/agents/sandbox-architecture.md @@ -1,6 +1,14 @@ # Yuxi 沙盒架构说明 -这份文档说明当前项目中“沙盒”这一层到底是什么、为什么同时会看到 Docker 和 Kubernetes、默认开发环境实际启用的是哪一种模式,以及沙盒如何和 `skills`、知识库、附件、工作区文件系统组合在一起工作。文档内容以当前仓库实现为准,重点解释真实调用链、配置入口、路径语义和运维边界,而不是抽象地介绍容器技术。 +::: tip info +本文档是由 Codex 联合撰写,开发者审阅,尽管已经多次校对,但仍可能存在不准确或过时的描述。如果你发现任何问题,欢迎提交 issue 或 PR 来帮助我们改进文档。 +::: + +我们在 Yuxi 里引入沙盒,不是为了让架构更“重”,而是因为 Agent 一旦从纯文本对话进入真实执行阶段,就一定会碰到一组很具体的运行时需求:执行命令、读写文件、处理用户上传附件、产出可下载结果,以及在受控目录里保留中间过程文件。如果把这些能力直接放进 API 进程本身,权限边界、租户隔离、环境一致性和后续运维成本都会迅速恶化。 + +从设计目标上看,沙盒这一层主要解决三件事。第一,给 Agent 一个可写、可执行、可回收的独立运行空间,而不是让它直接操作应用主进程。第二,把模型可见文件系统整理成稳定的命名空间,例如 `/home/gem/user-data`、`/home/gem/skills`、`/home/gem/kbs`,这样 prompt、工具、viewer 和 artifact 下载接口可以共享同一套路径语义。第三,让这套能力既能在本地 Docker 开发环境里稳定工作,也能在需要时切到 Kubernetes 这类更适合多实例部署的承载方式。 + +这份文档说明当前项目中“沙盒”这一层到底是什么、为什么同时会看到 Docker 和 Kubernetes、默认开发环境实际启用的是哪一种模式,以及沙盒如何和 `skills`、知识库、附件、工作区文件系统组合在一起工作。内容以当前仓库实现为准,我们重点解释真实调用链、配置入口、路径语义和运维边界,而不是抽象地介绍容器技术。 ## 一、先说明白:Docker 和 K8s 在这里是什么关系 @@ -8,7 +16,7 @@ Docker 和 Kubernetes 不是互斥关系。Docker 解决的是“把一个进程 放到 Yuxi 里,这个关系更具体一些。Yuxi 本身并不直接决定“沙盒一定跑在 Docker 还是一定跑在 K8s 上”,它只要求后端拿到一个可访问的沙盒地址,然后通过 `agent-sandbox` 的 HTTP API 去执行命令、读写文件。真正负责创建和回收沙盒实例的是 `sandbox-provisioner` 这个单独的服务。也就是说,Yuxi 的应用层只依赖 “provisioner”,而 provisioner 的后端可以选择用本机 Docker 去起容器,也可以选择向 Kubernetes 集群创建 Pod 和 Service。 -所以项目里看到的概念其实分成两层。第一层是应用层的 `SANDBOX_PROVIDER`,当前代码只支持 `provisioner`。第二层是 provisioner 内部的 `SANDBOX_PROVISIONER_BACKEND`,它决定具体用哪种底层实现去创建沙盒。你在配置中看到的 `local`、`docker`、`kubernetes`,都属于这一层,而不是三套互相独立的产品形态。 +所以项目里看到的概念其实分成两层。第一层是应用层的 `SANDBOX_PROVIDER`,当前代码只支持 `provisioner`。第二层是 provisioner 内部的 `SANDBOX_PROVISIONER_BACKEND`,它决定具体用哪种底层实现去创建沙盒。配置中看到的 `local`、`docker`、`kubernetes`,都属于这一层,而不是三套互相独立的产品形态。 ## 二、当前项目的真实沙盒调用链 @@ -16,7 +24,7 @@ Docker 和 Kubernetes 不是互斥关系。Docker 解决的是“把一个进程 调用链可以概括为:Web/API 请求进入 Yuxi 后端,后端构造 `ProvisionerSandboxBackend`,再经由 `ProvisionerClient` 调用 `sandbox-provisioner` 的 `/api/sandboxes` 接口。`sandbox-provisioner` 根据 `SANDBOX_PROVISIONER_BACKEND` 选择本地内存实现、Docker 容器实现或 Kubernetes 实现。沙盒真正启动后,对外暴露一个 HTTP 地址,Yuxi 再使用这个地址完成执行命令、上传文件、下载文件、目录遍历等操作。 -当前仓库的默认配置和当前开发环境都是 `local`。默认值来自 `.env.template` 中的 `SANDBOX_PROVISIONER_BACKEND=local`,而当前运行中的 provisioner 健康检查返回的也是 `backend=local`。这意味着你现在用 `docker compose up -d` 启动项目时,应用并不是直接把代码跑在宿主机上,而是通过 `sandbox-provisioner` 再去用 Docker 启一个真正的沙盒容器。 +当前仓库的默认配置和默认开发环境都是 `local`。默认值来自 `.env.template` 中的 `SANDBOX_PROVISIONER_BACKEND=local`,而运行中的 provisioner 健康检查返回的也应当是 `backend=local`。这意味着我们用 `docker compose up -d` 启动项目时,应用并不是直接把代码跑在宿主机上,而是通过 `sandbox-provisioner` 再去用 Docker 启一个真正的沙盒容器。 ## 三、`local`、`docker`、`kubernetes` 到底分别是什么 @@ -28,7 +36,7 @@ Docker 和 Kubernetes 不是互斥关系。Docker 解决的是“把一个进程 `kubernetes` 则是另一条实现路径。它不会再去调用本机 Docker 起容器,而是使用 Kubernetes API 在指定 namespace 中创建一个 Pod 和一个 NodePort Service,然后把这个 Service 对应的可访问地址回传给 Yuxi 后端。 -因此,如果你在界面、文档或者环境变量里看到 “local / docker / k8s” 这几个词,最准确的理解应该是:Yuxi 的应用层只有一种 provider,也就是 `provisioner`;provisioner 下面有多种 backend;其中 `local` 和 `docker` 是同一种 Docker 本机后端的两个别名,`kubernetes` 才是另一种远程集群后端。 +因此,如果在界面、文档或者环境变量里看到 “local / docker / k8s” 这几个词,最准确的理解应该是:Yuxi 的应用层只有一种 provider,也就是 `provisioner`;provisioner 下面有多种 backend;其中 `local` 和 `docker` 是同一种 Docker 本机后端的两个别名,`kubernetes` 才是另一种远程集群后端。 ## 四、默认开发模式到底是什么 @@ -54,15 +62,15 @@ Docker 后端在启动沙盒时,会挂载两类关键目录。第一类是线 Kubernetes 后端下,沙盒还是同一套镜像,还是暴露同样的 HTTP API,但存储方式和暴露方式变了。它不会依赖宿主机 Docker bind mount,而是要求有一个可写的 PVC。当前实现里真正使用的是 `THREAD_PVC`,Pod 会把这块共享存储挂到 `/mnt/shared-data`,然后用 `subPath` 的方式把 `threads//user-data` 挂到 `/home/gem/user-data`,把 `threads//skills` 挂到 `/home/gem/skills`。这样做的好处是线程之间的数据目录结构仍然可以和 Docker 模式保持一致。 -需要特别说明的是,代码里虽然读取了 `SKILLS_PVC` 这个环境变量,但当前 Pod 规格实际没有使用单独的 skills PVC,而是统一从 `THREAD_PVC` 中切 `threads//skills` 这个子路径。因此,如果你看到环境变量里同时出现 `SKILLS_PVC` 和 `THREAD_PVC`,请以 `THREAD_PVC` 的真实挂载语义为准,`SKILLS_PVC` 目前更像一个预留字段。 +需要特别说明的是,代码里虽然读取了 `SKILLS_PVC` 这个环境变量,但当前 Pod 规格实际没有使用单独的 skills PVC,而是统一从 `THREAD_PVC` 中切 `threads//skills` 这个子路径。因此,如果看到环境变量里同时出现 `SKILLS_PVC` 和 `THREAD_PVC`,应当以 `THREAD_PVC` 的真实挂载语义为准,`SKILLS_PVC` 目前更像一个预留字段。 Kubernetes 后端还需要一个 `NODE_HOST`。这是因为当前实现使用的是 NodePort Service,而不是 Ingress,也不是 ClusterIP。provisioner 创建完 Service 之后,会把最终访问地址拼成 `http://:` 返回给 Yuxi 后端。所以 `NODE_HOST` 必须是 Yuxi 后端能够访问到的 Kubernetes 节点地址、负载均衡地址或者对 NodePort 做了透出的外部域名。 -## 七、如果我要使用“远程 K8s”,应该怎么接 +## 七、如果要使用“远程 K8s”,应该怎么接 -这里最容易误解的一点是,所谓“选择远程 K8s”,并不是在 Yuxi 页面里点一个开关,然后系统自动发现一个集群。当前实现没有内建集群选择器,也没有多集群管理界面。它的工作方式很直接:你把 `sandbox-provisioner` 配置成 `kubernetes` 后端,并让它能拿到目标集群的 kubeconfig 或者运行在集群内即可。对 provisioner 来说,只要 Kubernetes 客户端能连上 API Server,这个集群就是它要操作的“远程 K8s”。 +这里最容易误解的一点是,所谓“选择远程 K8s”,并不是在 Yuxi 页面里点一个开关,然后系统自动发现一个集群。当前实现没有内建集群选择器,也没有多集群管理界面。它的工作方式很直接:我们把 `sandbox-provisioner` 配置成 `kubernetes` 后端,并让它能拿到目标集群的 kubeconfig 或者运行在集群内即可。对 provisioner 来说,只要 Kubernetes 客户端能连上 API Server,这个集群就是它要操作的“远程 K8s”。 -如果你的 Yuxi 部署在 Docker Compose 里,而 Kubernetes 集群在另一台机器或云厂商托管环境中,那么最常见的做法是把本地 kubeconfig 文件挂载进 `sandbox-provisioner` 容器,然后设置 `KUBECONFIG_PATH`。同时把 `SANDBOX_NODE_HOST` 改成一个从 `api` 容器也能访问的节点公网 IP、负载均衡域名,或者你自己做过反向代理的地址。 +如果 Yuxi 部署在 Docker Compose 里,而 Kubernetes 集群在另一台机器或云厂商托管环境中,那么最常见的做法是把本地 kubeconfig 文件挂载进 `sandbox-provisioner` 容器,然后设置 `KUBECONFIG_PATH`。同时把 `SANDBOX_NODE_HOST` 改成一个从 `api` 容器也能访问的节点公网 IP、负载均衡域名,或者已经做过反向代理的地址。 一个典型的 Compose 覆盖配置会长这样: @@ -81,7 +89,7 @@ services: 这段配置表达的意思不是“把整个应用迁到 K8s”,而是“仍然用 Compose 跑 Yuxi 主服务,但沙盒实例改为由远程 Kubernetes 集群承载”。这是当前代码最自然的混合部署方式。 -如果你的 `sandbox-provisioner` 本身就运行在 Kubernetes 集群内部,那么通常不需要显式提供 `KUBECONFIG_PATH`。它会优先尝试 `incluster_config`,也就是使用 Pod 的服务账号权限直接访问 Kubernetes API。此时你更需要关注的是 namespace、PVC 和 NodePort 的可达性,而不是 kubeconfig 文件本身。 +如果 `sandbox-provisioner` 本身就运行在 Kubernetes 集群内部,那么通常不需要显式提供 `KUBECONFIG_PATH`。它会优先尝试 `incluster_config`,也就是使用 Pod 的服务账号权限直接访问 Kubernetes API。此时更需要关注的是 namespace、PVC 和 NodePort 的可达性,而不是 kubeconfig 文件本身。 ## 八、当前项目的沙盒文件系统是如何设计的 @@ -110,7 +118,7 @@ saves/ 这里要重点理解 `workspace` 和 `uploads/outputs` 的区别。按照当前宿主机路径解析逻辑,`workspace` 被定义为共享目录,位置是 `saves/threads/shared/workspace`;而 `uploads` 和 `outputs` 属于线程私有目录,位置分别是 `saves/threads//user-data/uploads` 和 `saves/threads//user-data/outputs`。viewer 文件系统、artifact 下载接口以及路径解析函数都按这个语义工作,因此不同线程可以看到同一个 workspace,但看不到彼此的 uploads。 -与此同时,运行时 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 仍然保持线程隔离。 +与此同时,运行时 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 仍然保持线程隔离。 ## 九、路径暴露规则是什么 @@ -132,7 +140,7 @@ skills 的结合方式分成两层。第一层是提示词层,`SkillsMiddlewar ## 十一、当前推荐如何使用 Docker 沙盒 -如果你只是正常开发、调试或单机部署,最简单也是当前默认的方式就是保留 `SANDBOX_PROVIDER=provisioner`,同时把 `SANDBOX_PROVISIONER_BACKEND` 设为 `local`。这会让整个项目继续由 Docker Compose 管理,而沙盒实例由 provisioner 动态创建。你通常不需要手工 `docker run` 沙盒镜像,也不需要在 Compose 文件里静态声明每一个沙盒容器。 +如果只是正常开发、调试或单机部署,最简单也是当前默认的方式就是保留 `SANDBOX_PROVIDER=provisioner`,同时把 `SANDBOX_PROVISIONER_BACKEND` 设为 `local`。这会让整个项目继续由 Docker Compose 管理,而沙盒实例由 provisioner 动态创建。通常不需要手工 `docker run` 沙盒镜像,也不需要在 Compose 文件里静态声明每一个沙盒容器。 最小必要配置通常就是下面这几项: @@ -153,7 +161,7 @@ curl http://localhost:8002/health 如果健康检查返回 `backend: local`,就说明 provisioner 已经处于默认的 Docker 本机后端。真正的沙盒容器不会在系统启动时立即全部出现,而是在你第一次创建线程并触发需要文件系统或命令执行的操作后才会被创建。 -如果你运行在 Linux,而不是 Docker Desktop,那么 `host.docker.internal` 不一定总是可用。这时要把 `SANDBOX_DOCKER_SANDBOX_HOST` 改成一个从 `api` 容器可达的宿主机地址,或者改成你自己网络环境里更稳定的名字。否则 provisioner 虽然能成功起容器,但后端可能拿到一个自己无法访问的 `sandbox_url`。 +如果运行在 Linux,而不是 Docker Desktop,那么 `host.docker.internal` 不一定总是可用。这时要把 `SANDBOX_DOCKER_SANDBOX_HOST` 改成一个从 `api` 容器可达的宿主机地址,或者改成当前网络环境里更稳定的名字。否则 provisioner 虽然能成功起容器,但后端可能拿到一个自己无法访问的 `sandbox_url`。 ## 十二、如何理解文件管理与暴露边界 @@ -169,8 +177,8 @@ curl http://localhost:8002/health ## 十四、排障时建议先看什么 -如果你怀疑是 provisioner 级问题,先看 `http://localhost:8002/health`,确认 backend 类型和 idle timeout 是否符合预期。接着看 `docker logs sandbox-provisioner --tail 200`,因为这里能直接看到创建容器、复用旧实例、健康检查失败和 idle reaper 删除的日志。 +如果怀疑是 provisioner 级问题,先看 `http://localhost:8002/health`,确认 backend 类型和 idle timeout 是否符合预期。接着看 `docker logs sandbox-provisioner --tail 200`,因为这里能直接看到创建容器、复用旧实例、健康检查失败和 idle reaper 删除的日志。 -如果你怀疑是 Docker 地址不可达,重点检查 `SANDBOX_DOCKER_SANDBOX_HOST` 和随机映射端口是否从 `api` 容器可访问。可以在 `api` 容器内直接 `curl` provisioner 返回的 `sandbox_url`。如果你怀疑是 Kubernetes 地址不可达,重点检查 `NODE_HOST` 和 NodePort 的外部连通性,因为当前实现并不是通过集群内部 Service 名称回连。 +如果怀疑是 Docker 地址不可达,重点检查 `SANDBOX_DOCKER_SANDBOX_HOST` 和随机映射端口是否从 `api` 容器可访问。可以在 `api` 容器内直接 `curl` provisioner 返回的 `sandbox_url`。如果怀疑是 Kubernetes 地址不可达,重点检查 `NODE_HOST` 和 NodePort 的外部连通性,因为当前实现并不是通过集群内部 Service 名称回连。 -如果你怀疑是文件看得到但模型读不到,或者模型写了但 viewer 看不到,优先把问题拆成两层:一层是宿主机路径是否存在于 `saves/...` 下,另一层是该路径是否真的被当前线程沙盒挂载并暴露到了 `/home/gem/user-data`、`/home/gem/skills` 或 `/home/gem/kbs`。只要先分清“宿主机侧文件语义”和“沙盒侧运行时挂载语义”,定位问题会快很多。 +如果怀疑是文件看得到但模型读不到,或者模型写了但 viewer 看不到,优先把问题拆成两层:一层是宿主机路径是否存在于 `saves/...` 下,另一层是该路径是否真的被当前线程沙盒挂载并暴露到了 `/home/gem/user-data`、`/home/gem/skills` 或 `/home/gem/kbs`。只要先分清“宿主机侧文件语义”和“沙盒侧运行时挂载语义”,定位问题通常会快很多。