背景
在 Agent 系统里,工具调用通常不只是一次简单的 HTTP 请求。很多工具背后需要一个可持续的执行环境,比如文件系统、Shell 进程、Notebook kernel、浏览器上下文、临时依赖和任务产物。这类工具更准确地说是 sandbox tool:Agent 不是单次调用一个无状态函数,而是在一个隔离环境里连续完成一组操作。
XAgent 的 ToolServer 就是一个典型例子。它把工具执行拆成两层:
ToolServerManager:负责创建、发现、路由和回收执行容器。ToolServerNode:真正执行 Shell、文件、浏览器、Notebook 等工具。
这个设计本质上是在解决一个问题:如何给每个 Agent 会话提供一个独立、可复用、可回收的执行沙箱。
XAgent 当前方案
XAgent 当前的调用链大致是:
text
XAgent
-> ToolServerManager
-> 动态创建 ToolServerNode 容器
-> 返回 node_id cookie
-> 后续工具请求携带 cookie
-> Manager 根据 node_id 查 Node
-> 转发到对应 ToolServerNode
ToolServerManager 自身不执行工具。它更像一个简化版的调度器和反向代理:
- 收到
/get_cookie后,通过 Docker API 创建ToolServerNode容器。 - 把容器 ID、IP、端口、状态写入 MongoDB。
- 通过 HTTP cookie 把
node_id返回给 XAgent。 - 后续
/execute_tool、/upload_file、/download_file等请求都根据 cookie 转发到对应 Node。 - 后台定期检查容器状态,空闲超时后停止容器。
ToolServerNode 才是实际的工具运行时。它在容器内运行 FastAPI,提供:
/get_available_tools/get_json_schema_for_tools/execute_tool/upload_file/download_file/download_workspace/register_new_tool
工具本身通过 @toolwrapper() 包装成 schema,供 Agent 的 function calling 使用。最后真正执行时,Node 会根据工具名找到 Python 函数或 Env 方法,然后调用:
python
result = tool(**arguments)
为什么需要一会话一容器
如果工具是无状态的,比如"查询天气""调用搜索 API""做一次文本转换",前面挂一个普通网关或负载均衡就够了。任意请求打到任意后端实例,结果都一样。
但 XAgent 里的工具不是纯无状态的。一次任务可能会连续执行:
text
上传文件 -> Shell 修改文件 -> Notebook 分析 -> 浏览器查资料 -> 下载结果
这些操作依赖同一个 workspace、同一个进程环境、同一个 Notebook 状态,甚至可能依赖临时安装的 Python 包。如果下一次工具调用被路由到另一个实例,就会丢失上下文。
因此核心要求变成:
text
同一个 Agent run 的所有工具调用,必须进入同一个 sandbox。
这就是 node_id cookie -> ToolServerNode 的意义。它不是普通意义上的负载均衡,而是会话级路由。
MCP 能替代什么
MCP 主要解决的是工具协议标准化问题。它可以替代 XAgent 当前自定义的工具接口:
text
/get_available_tools -> MCP tools/list
/execute_tool -> MCP tools/call
/get_json_schema_for_tools -> MCP tool schema
也就是说,ToolServerNode 的工具协议层可以改造成 MCP Server。这样工具 schema、调用方式、错误模型都会更标准,也更容易接入其他 MCP Client。
但 MCP 本身不解决 sandbox 调度问题。它不会负责:
- 创建容器或 Pod。
- 绑定 session 和 sandbox。
- 管理 workspace 生命周期。
- 回收空闲执行环境。
- 做资源配额和安全限制。
所以如果要实现"一会话一容器",仍然需要一个调度层。
MCP Gateway 和 Scheduler 是否应该合并
如果使用 MCP,可以把架构改成:
text
Agent / MCP Client
-> MCP Gateway + Scheduler
-> per-session MCP Server container
这个 Gateway 同时承担两类职责:
- 作为 Scheduler:创建、绑定、健康检查、回收 sandbox。
- 作为 MCP Gateway:代理
tools/list和tools/call到对应 sandbox。
这种设计是合理的,但要注意 Gateway 的复杂度取决于工具是否有状态。
如果后端 MCP Server 是无状态的,Gateway 只是普通网关:
text
鉴权 + 路由 + 限流 + 日志 + 负载均衡
如果后端是有状态 sandbox,Gateway 就必须维护:
text
session_id -> backend sandbox endpoint
并保证同一个 session 的所有工具调用都路由到同一个后端。
首次调用时 session 为空怎么办
如果 Gateway 集成调度,第一次请求时通常还没有映射。解决方式是定义一个首次绑定策略。
比较推荐的是让 MCP 请求携带稳定的外部 session key,例如:
http
Authorization: Bearer <token>
X-Agent-Run-Id: run_123
X-Conversation-Id: conv_456
Gateway 收到第一次 tools/list 或 tools/call 时:
text
1. 从 header 解析 user_id / tenant_id / run_id
2. 查询是否已有 sandbox
3. 如果没有,创建一个新的 sandbox
4. 等待 sandbox 内 MCP Server ready
5. 记录 run_id -> sandbox endpoint
6. 转发当前请求
这样首次 session 为空不是问题,而是 lazy create 的触发点。
不太推荐把 session 放到每个工具参数里,因为这样会污染所有工具 schema,也会把身份和路由逻辑暴露给模型。
Gateway 要不要维护工具元数据
如果 Gateway 对外表现成一个完整 MCP Server,它可能需要维护工具元数据。但更简单的方式是让 Gateway 只做透明代理:
text
tools/list -> 转发到该 session 对应的 MCP Server
tools/call -> 转发到该 session 对应的 MCP Server
这样工具元数据仍然由后端 sandbox 内的 MCP Server 管理。Gateway 只维护 session 映射、ready 状态、TTL、鉴权上下文,最多做短 TTL 缓存。
这比让 Gateway 成为全局工具注册中心更简单,也更符合"一会话一沙箱"的模型。
业界通常怎么做 Sandbox Tool
业界做 sandbox 通常不是围绕 MCP 设计的。MCP 是工具协议,sandbox 是执行隔离和资源调度问题。
常见架构是:
text
Agent Runtime
-> Tool Runtime / Gateway
-> Sandbox Manager
-> Container / Pod / MicroVM
-> sandbox daemon
sandbox 内部通常会跑一个 daemon,提供执行层 API:
text
POST /exec
POST /files/read
POST /files/write
POST /notebook/execute
POST /browser/action
GET /artifacts
外层 Agent 再把这些执行 API 包装成 tool schema。也就是说:
text
Tool schema 是 Agent 层概念
Sandbox API 是执行层概念
生产系统更关注的是:
- 强隔离:container、gVisor、Kata、Firecracker。
- 调度:Kubernetes Pod、Job、Nomad、ECS/Fargate。
- 生命周期:创建、ready、idle timeout、销毁。
- 资源限制:CPU、内存、磁盘、网络。
- 存储:workspace、artifact、日志。
- 安全:seccomp、AppArmor、network policy、egress control。
MCP 可以作为 Agent 到工具层的标准协议,但不是 sandbox 的核心。
对 XAgent 的评价
XAgent 当前 ToolServer 是一个早期但完整的 sandbox 架构雏形:
text
ToolServerManager = Control Plane + Gateway
ToolServerNode = Sandbox Runtime + Tool API
MongoDB = Node 状态表
Cookie = Session routing key
Docker = Sandbox runtime
它的问题也比较明显:
- Manager 直接使用单机 Docker,横向扩展有限。
- MongoDB 对当前 Node 状态表来说偏重。
- Node 默认高权限运行,安全边界需要加强。
- 工具协议是自定义 HTTP,生态兼容性不如 MCP。
- 动态注册工具直接
exec代码,依赖容器隔离兜底。
如果要生产化,可以逐步演进:
text
Docker 单机 -> Kubernetes Pod / MicroVM
MongoDB 状态表 -> Redis / 数据库 / K8s API
自定义 HTTP tool API -> MCP 或 gRPC runtime API
冷启动容器 -> 预热 sandbox 池
privileged 容器 -> gVisor / Kata / Firecracker
更完整的运行链路
把 XAgent 当前实现按真实请求拆开,可以看到它其实有三条链路:会话创建链路、工具发现链路和工具执行链路。
会话创建链路
第一次使用工具时,XAgent/toolserver_interface.py 会调用:
python
response = requests.post(f'{self.url}/get_cookie')
self.cookies = response.cookies
这里的 self.url 是 ToolServerManager 地址,例如:
text
http://ToolServerManager:8080
Manager 收到 /get_cookie 后,通过 Docker SDK 创建一个新的 Node 容器:
python
container = docker_client.containers.run(...)
创建参数来自 assets/config/manager.yml,其中比较关键的是:
yaml
node:
port: 31942
creation_kwargs:
image: "xagentteam/toolserver-node:latest"
network: "tool-server-network"
privileged: true
detach: true
volumes:
- "toolserverconfig:/app/assets/config"
也就是说,当前 Manager 并不是调用远端调度器,而是直接通过本机 Docker daemon 创建容器。docker-compose.yml 里也能看到 Manager 挂载了 Docker socket:
yaml
volumes:
- /var/run/docker.sock:/var/run/docker.sock
这带来两个结果:
- Manager 能直接控制宿主机 Docker。
- Manager 的扩展能力受限于单机 Docker。
创建完成后,Manager 把容器信息写入 MongoDB,并把容器 ID 放进 HTTP cookie:
python
response.set_cookie(key="node_id", value=container.id)
这就是后续会话路由的关键。
工具发现链路
Agent 不是凭空知道有哪些工具的。初始化时,FunctionHandler.get_functions() 会调用:
python
output = self.toolserver_interface.get_available_tools()
这会请求:
text
POST /get_available_tools
请求先到 ToolServerManager。Manager 根据 cookie 中的 node_id 查 MongoDB,找到对应 Node 的 IP 和端口,然后转发到:
text
http://<node_ip>:31942/get_available_tools
ToolServerNode 返回三类信息:
json
{
"available_envs": [],
"available_tools": [],
"tools_json": []
}
其中 tools_json 是给 function calling 使用的 JSON schema。之后 FunctionHandler 会把这些 schema 注册到 function_manager,并提供给 LLM。
工具执行链路
LLM 决定调用工具后,会返回类似 function call 的结构。ToolAgent 会把这个 function call 转成 ToolNode,然后进入:
python
FunctionHandler.handle_tool_call()
如果工具名不是 subtask_submit、ask_human_for_help 这类内置函数,就会走:
python
self.toolserver_interface.execute_command_client(command_name, arguments)
这个方法会请求:
text
POST /execute_tool
请求体大致是:
json
{
"tool_name": "FileSystemEnv_read_file",
"arguments": {
"file_path": "xxx"
}
}
Manager 仍然只是根据 cookie 转发。真正执行在 Node 里:
python
tool = tool_register[tool_name]
result = tool(**arguments)
如果工具返回 coroutine,Node 会继续 await:
python
if isinstance(result, Coroutine):
result = await result
最后结果会被 wrap_tool_response() 包装后返回给 Agent。
Cookie 在这里到底是什么
XAgent 里的 cookie 就是普通 HTTP cookie,没有特殊协议。它只是被拿来保存 node_id。
普通 Web 系统里,cookie 常用于:
text
session_id -> 用户登录会话
XAgent 里则变成:
text
node_id -> ToolServerNode 容器
因此它确实承担了"服务发现 + sticky routing"的一部分功能:
text
请求携带 node_id cookie
|
Manager 查 MongoDB
|
找到容器 IP 和端口
|
转发请求
但这不是通用服务发现。通用服务发现通常是:
text
service_name -> 多个实例 -> 负载均衡
XAgent 这里是:
text
session_id/node_id -> 一个固定容器 -> 会话粘滞路由
更准确地说,它是一个 session-based container router。
ToolServerManager 的定位
ToolServerManager 可以拆成五个角色。
第一,它是统一入口。Agent 只需要知道 Manager 地址,不需要知道后面有多少 Node,也不需要知道 Node 的动态 IP。
第二,它是容器创建器。收到 /get_cookie 后,它根据配置创建一个新的 Node 容器。这个容器里跑的是 ToolServerNode。
第三,它是状态注册中心。它把 Node 的 id、short_id、status、health、last_req_time、ip、port 写入 MongoDB。
第四,它是反向代理。/execute_tool、/get_available_tools、/upload_file、/download_file 等路径都会被注册成转发路由。Manager 收到请求后,从 cookie 取 node_id,找到 Node endpoint,再转发。
第五,它是生命周期管理器。node_checker.py 会循环检查 Docker 容器状态,发现容器消失就删除数据库记录,发现空闲超过配置时间就 stop 容器。
这个设计简单直接,但它的边界也很清楚:它不是分布式调度系统,而是单机 Docker 调度器。
ToolServerNode 的定位
ToolServerNode 是 sandbox runtime。它不是普通工具集合,而是一个有状态执行环境。它内部包含:
/app/workspace/工作目录。- Shell 执行进程。
- Notebook 文件和执行状态。
- Playwright/Chromium 浏览器能力。
- 动态注册工具后的 Python 函数。
- 工具 embedding 检索数据。
Node 启动时会执行:
python
app.tool_register = ToolRegister()
app.doc_embeddings, app.id2tool = build_tool_embeddings(...)
ToolRegister() 会加载 core.envs、core.tools 里的工具和环境,也会根据配置加载 extension。
这也是为什么 Node 适合一会话一容器:它天然会积累会话状态。
toolwrapper 的作用
XAgent 的工具不是随便一个函数就能暴露给 LLM。工具需要被 @toolwrapper() 包装。
当 @toolwrapper() 放在函数上时,它会:
- 解析函数 docstring。
- 提取工具描述。
- 提取参数名、类型、描述、是否必填。
- 生成
ToolLabels。 - 把
tool_labels挂到函数对象上。
例如:
python
@toolwrapper()
def bing_search(query: str, region: str = None) -> str | list[str]:
"""Return 3 most relevant results of a Bing search.
:param string query: The search query.
:param string? region: The region code.
:return string: The results of the search.
"""
最后会生成类似 function calling schema:
json
{
"name": "bing_search",
"description": "Return 3 most relevant results of a Bing search.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query."
}
},
"required": ["query"]
}
}
当 @toolwrapper() 放在类上时,这个类必须继承 BaseEnv。它不会把类本身注册成一个普通工具,而是把类包装成一个 Env,然后把类里的方法注册成子工具。
例如:
text
FileSystemEnv.read_file -> FileSystemEnv_read_file
FileSystemEnv.write_file -> FileSystemEnv_write_file
PythonNotebook.execute_cell -> PythonNotebook_execute_cell
这样做的价值不是"独立函数无法共享状态"。独立函数当然也可以通过模块全局变量共享状态。Env 的主要价值是:
- 给一组相关工具提供命名空间。
- 统一读取配置和初始化资源。
- 支持方法扫描和 schema 生成。
- 支持继承和可见性控制。
- 避免工具名冲突。
所以 Env 更像一个工具分组和组织抽象,而不是不可替代的状态模型。
Shell 工具的状态管理
core/tools/shell.py 里有一个模块级全局变量:
python
ALL_SHELLS: dict[int, asyncio.subprocess.Process] = {}
它在模块加载时初始化。Node 启动并 import shell.py 后,ALL_SHELLS 就存在于当前 Python 进程内存里。
当调用 shell_command_executor() 且没有传 shell_id 时,它会创建一个新的 bash 进程:
python
exec_proc = await asyncio.create_subprocess_shell(
'bash',
stderr=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.PIPE,
cwd=CONFIG['filesystem']['work_directory']
)
然后分配一个逻辑 ID:
python
shell_id = max(ALL_SHELLS.keys(), default=-1) + 1
ALL_SHELLS[shell_id] = exec_proc
这个 shell_id 不是 Linux PID,而是 ToolServerNode 自己维护的应用层句柄。
为什么不用 PID?因为后续不只是 kill 进程,还要读 stdout/stderr、写 stdin。这些能力来自 asyncio.subprocess.Process 对象。只有 PID 不够方便地拿到这些 pipe。
因此真实映射是:
text
shell_id -> asyncio.subprocess.Process
不是:
text
shell_id -> pid
shell_id 的作用包括:
- 查询异步命令的输出。
- 继续向同一个 bash 进程写入命令。
- 保留 shell 内的
cd、环境变量等状态。 - 杀死这个 shell。
但这个实现也比较粗糙:
- 没有列出所有 shell 的接口。
- 没有独立的 status 接口。
shell_id只存在当前 Node 内存里。- Node 重启后全部丢失。
- 如果调用方丢了返回的
shell_id,外部无法找回。
这再次说明 ToolServerNode 是有状态 runtime,而不是无状态 HTTP service。
动态注册工具的实现
/register_new_tool 的实现很直接:
python
tool_dict = tool_register.register_tool(tool_name, code)
ToolRegister.register_tool() 会做几件事:
text
1. exec(code, tool_creation_context)
2. eval(tool_name, tool_creation_context) 找到函数
3. 检查函数是否有 @toolwrapper() 生成的 tool_labels
4. 注册到 self.tools
5. 写入 extensions/tools/<tool_name>.py
6. 返回工具 schema
这说明动态注册工具本质上是在当前 Node 容器里执行一段 Python 代码。
这很灵活,但风险也很高。它等价于允许工具作者在 sandbox 里运行任意 Python。安全边界主要依赖:
- Node 容器隔离。
- Docker/K8s 安全配置。
- 文件系统和网络限制。
- 是否暴露给可信用户。
当前项目没有完整依赖管理。新工具依赖一般来自:
- Node 镜像里预装的 Python 包。
- 工具代码 import 已存在的库。
- 通过 Shell 工具临时
pip install。 - 修改 Node 镜像,把依赖固化进
requirements.txt或 Dockerfile。
更成熟的插件系统通常会支持:
json
{
"tool_name": "xxx",
"code": "...",
"requirements": ["pandas", "beautifulsoup4"],
"system_packages": ["libxml2-dev"]
}
然后用独立 venv、环境缓存或镜像构建来隔离依赖。XAgent 当前还不是完整插件包系统,更像运行时函数注册。
浏览器为什么能在 Docker 里跑
Docker 默认没有桌面 UI,也没有真实显示器。但浏览器自动化通常不需要可见 UI,而是使用 headless browser。
ToolServerNode 的 Dockerfile 里安装了 Playwright 和 Chromium:
dockerfile
RUN pip install playwright && playwright install chromium && playwright install-deps
assets/config/node.yml 里也配置了:
yaml
web:
browser: "chrome"
headless: true
headless: true 表示不打开可见窗口,但仍然可以:
- 打开网页。
- 读取 DOM。
- 点击按钮。
- 填表单。
- 执行 JavaScript。
- 截图。
- 抓取页面文本。
所以流程是:
text
Python/Playwright -> headless Chromium -> 网页操作 -> 返回文本/截图/结果
如果需要可视化 UI,通常要额外引入 Xvfb、VNC/noVNC、窗口管理器、字体和 DISPLAY 配置。但多数 Agent 网页读取场景不需要。
不过 headless browser 容易被反作弊和风控识别。常见识别点包括:
navigator.webdriver。- Headless Chromium 指纹。
- Canvas/WebGL/Audio 指纹。
- 字体、时区、语言、屏幕尺寸异常。
- 鼠标轨迹和操作节奏不自然。
- Cookie/localStorage 历史过于干净。
- IP 或 TLS 指纹异常。
因此这类工具更适合访问公开网页、读取文档、做普通页面交互,不适合规避验证码、刷票、抢购、批量注册等行为。
MongoDB 是否过重
从当前模型看,MongoDB 存的东西很少:
text
ToolServerNode:
id
short_id
status
health
last_req_time
ip
port
NodeChecker:
manager_id
interval
pid
它的核心用途是:
text
node_id -> node endpoint
以及让多个 Manager worker 共享 Node 状态。因为 docker-compose.yml 里 Manager 使用了多个 gunicorn worker:
yaml
command: ["--workers","2","-t","600"]
如果只用 Python 内存 dict,每个 worker 状态不同步,所以需要外部存储。
但 MongoDB 对这个场景确实偏重。更合适的替代方案包括:
- Redis:最贴近
node_id -> JSON的 KV 场景,也方便 TTL 和空闲回收。 - SQLite:适合单机,但多进程写入要谨慎。
- MySQL/PostgreSQL:如果系统已经依赖关系数据库,可以复用。
- K8s API:如果迁到 Kubernetes,可以把 Pod 状态作为事实来源。
如果用 Redis,可以设计成:
text
toolnode:{node_id} -> JSON(node endpoint, status, health, last_seen)
node:last_seen -> sorted set
空闲回收时扫 sorted set 即可。
所以 MongoDB 在这里主要是"跨 worker 共享状态"的外部状态表,而不是因为需要复杂文档查询。
单机 Docker 的局限和 K8s 迁移
当前 Manager 使用:
python
docker.from_env()
docker_client.containers.run(...)
所以它能创建多少 Node,受限于单台宿主机:
- CPU。
- 内存。
- 磁盘。
- Docker daemon。
- 容器数量。
- 网络资源。
如果要横向扩展,需要把底层 Docker 操作抽象出来,例如:
text
NodeProvider:
create_node()
get_node_status()
stop_node()
delete_node()
get_node_endpoint()
然后提供两个实现:
text
DockerNodeProvider
KubernetesNodeProvider
迁移到 K8s 后,对应关系大致是:
text
Docker container id -> Pod name / Pod UID
container ip -> Pod IP / Service DNS
container status -> Pod phase / container status
healthcheck -> readiness / liveness probe
container stop -> delete pod 或 scale down
container remove -> delete pod
但 K8s 不是简单替换 Docker API。还需要考虑:
- Manager 在集群内还是集群外。
- Pod 如何暴露给 Gateway。
- 使用 Pod IP、Headless Service 还是 Service per session。
- Workspace 是否挂 PVC。
- Pod Ready 之前如何等待。
- 多副本 Manager 如何避免重复分配。
- Resource limit 如何设置。
- NetworkPolicy 如何限制 egress。
- 是否需要 gVisor/Kata 这类 runtime class。
更合理的演进不是直接大改,而是先抽象调度后端,再实现 K8s provider。
反向代理加 sticky session 是否可替代
如果把 ToolServerNode 做成常驻集群,前面放 Nginx、Traefik 或 Envoy,再用 sticky session,把同一个会话固定路由到同一个 Node,理论上可以实现类似效果。
架构是:
text
Agent -> Reverse Proxy -> ToolServerNode 集群
|
+-- sticky session: session_id -> node_a
但这只能保证"同一会话打到同一实例",不能自动保证"同一会话拥有独立容器级隔离"。
如果多个会话共享同一个 Node,就必须额外解决:
- workspace 目录隔离。
- Shell 进程隔离。
- Notebook kernel 隔离。
- 浏览器上下文隔离。
- 动态安装依赖污染。
- 动态注册工具冲突。
- 会话清理和资源回收。
所以 sticky session 更适合无状态或弱状态 worker。对于执行任意代码、写文件、跑浏览器和 notebook 的场景,一会话一 sandbox 更清晰。
折中方案是预热 Node 池:
text
Manager 维护一批 idle Node
session 来了分配一个空闲 Node
用完清理或销毁
这能降低冷启动成本,同时保留会话级隔离。
MCP Gateway 的身份传递
如果用 MCP Gateway 做会话路由,Gateway 必须知道每个请求属于哪个 session。
常见传递方式有几种。
第一种是 HTTP header。MCP 走 HTTP、SSE 或 Streamable HTTP 时,可以用:
http
Authorization: Bearer <token>
X-Agent-Run-Id: run_123
X-Conversation-Id: conv_456
X-Workspace-Id: ws_789
Gateway 从 token 中解析用户和租户,从 X-Agent-Run-Id 或 X-Conversation-Id 中得到 session key。
第二种是 cookie。它和 XAgent 当前方式类似:
http
Cookie: session_id=xxx
但 MCP client 未必像浏览器一样自动处理 cookie,所以通用性不如 header。
第三种是连接级身份。Gateway 在 MCP 连接建立时认证一次,然后把这条连接绑定到某个 sandbox。这个方式适合:
text
一个 MCP 连接 = 一个 Agent run
但如果连接被复用,或者断线后需要恢复,就需要额外 session key。
第四种是显式控制面 API:
text
POST /sessions -> 创建 sandbox -> 返回 session_id / mcp_endpoint
Agent Runtime 先调用调度 API,再连接对应 MCP Server。这种职责清楚,但不透明,需要 Runtime 配合。
不推荐把 session 放到每个工具参数里。那会导致所有工具 schema 都被 session 污染,也让模型能看到甚至篡改路由身份。
Gateway 集成 Scheduler 的几种形态
如果 MCP Gateway 和 Scheduler 合并,可以有几种实现方式。
第一种是透明代理:
text
Gateway 收到 tools/list
-> 找到 session 对应 sandbox
-> 转发到后端 MCP Server tools/list
-> 原样返回
Gateway 收到 tools/call
-> 找到 session 对应 sandbox
-> 转发到后端 MCP Server tools/call
-> 原样返回
这种方式最简单。Gateway 不维护完整工具元数据,只维护 session mapping,最多做短 TTL cache。
第二种是聚合型 Gateway:
text
Gateway 自己维护 tools/list
不同工具路由到不同后端
这种更强大,可以把 filesystem、browser、database、search 等工具路由到不同服务。但复杂度明显升高,需要处理:
- 工具名冲突。
- schema 缓存失效。
- 权限过滤。
- 后端不可用。
- 错误模型统一。
- tools/list 是否随 session 变化。
第三种是旁路调度:
text
Agent Runtime -> Scheduler API 创建 sandbox
Agent Runtime -> 直接连接 sandbox MCP endpoint
这样 Gateway 可以不存在,或者只是普通网络代理。优点是职责清晰,缺点是 Runtime 必须理解调度流程。
如果目标是迁移 XAgent,我更倾向于透明代理型 Gateway:
text
MCP Gateway = session-aware MCP proxy + sandbox scheduler
Tool metadata = 后端 per-session MCP Server 管理
这样和当前 Manager/Node 模型最接近。
MCP 与 Function Calling 的关系
XAgent 当前核心是 function calling:工具被转成 JSON schema,提供给 LLM,LLM 返回 function_call,Agent 再执行工具。
MCP 不改变这个基本事实。它只是把工具提供方标准化了。
可以这样理解:
text
Function Calling:
LLM 选择工具和参数
MCP:
Runtime 发现工具、调用工具、获得结果
Sandbox:
工具执行的隔离环境
三者位于不同层:
text
模型层:function calling / tool choice
协议层:MCP / HTTP / gRPC
执行层:container / pod / microVM
如果把 MCP 当成 sandbox 方案,会混淆层次。MCP 解决不了执行隔离,也解决不了会话状态。它只让工具协议更统一。
业界 Sandbox Tool 的典型分层
成熟系统通常会把 Agent tool 和 sandbox runtime 分开。
Agent 看到的是工具:
text
read_file(path)
write_file(path, content)
run_shell(command)
execute_notebook_cell(code)
browser_click(selector)
sandbox runtime 看到的是执行 API:
text
ExecService.Run(command, timeout)
FileService.Read(path)
FileService.Write(path, content)
NotebookService.Execute(code)
BrowserService.Action(action)
ArtifactService.Upload(path)
底层隔离单元可能是:
- 容器:启动快,生态成熟,但隔离弱于 VM。
- Kubernetes Pod:调度、资源限制和生命周期管理成熟。
- gVisor:增强系统调用隔离。
- Kata Containers:用轻量 VM 提供容器接口。
- Firecracker:microVM 强隔离,适合不可信代码。
控制面通常负责:
- 创建 sandbox。
- 分配资源。
- 设置网络策略。
- 注入 workspace。
- 等待 runtime ready。
- 记录 session mapping。
- 回收空闲 sandbox。
数据面负责:
- 执行命令。
- 文件读写。
- 浏览器操作。
- Notebook kernel。
- 日志和 artifact。
这也是为什么业界不会说"用 MCP 实现 sandbox"。更准确的说法是:
text
用 MCP 暴露 sandbox tools
用调度系统管理 sandbox
用容器/Pod/microVM 提供隔离
针对 XAgent 的改造路线
如果要把 XAgent 当前 ToolServer 改成更现代的架构,可以分阶段做。
第一阶段,保持 Docker 单机,但整理边界:
- 抽象
NodeProvider,把 Docker 操作从 Manager 主逻辑中剥离。 - 用 Redis 替代 MongoDB 存 Node 状态。
- 增加资源限制,例如 CPU、memory、pids limit。
- 降低 privileged 权限,增加 seccomp/AppArmor。
- 给 shell 增加 list/status/kill API。
- 给动态工具注册增加依赖声明和权限限制。
第二阶段,引入预热池:
- Manager 维护 idle/running/recycling 状态。
- 新 session 优先分配 idle Node。
- session 结束后清理 workspace、shell、notebook、browser。
- 清理不可靠时销毁重建。
第三阶段,支持 K8s:
- 实现
KubernetesNodeProvider。 - 用 Pod 作为 Node。
- 用 readiness probe 表示 Node ready。
- 用 ResourceQuota/LimitRange 控制资源。
- 用 NetworkPolicy 控制 egress。
- 用 PVC 或对象存储保存 artifact。
第四阶段,协议标准化:
- 把 ToolServerNode 的工具接口改造成 MCP Server。
- Gateway 代理
tools/list和tools/call。 - Agent Runtime 从 Gateway 获取工具。
- 保留 session-aware routing。
第五阶段,增强隔离:
- 对不可信代码使用 gVisor/Kata/Firecracker。
- 限制网络访问。
- 限制文件系统挂载。
- 对动态注册工具做审计。
- 对 shell 命令做超时、输出大小和进程树清理。
这条路线的重点是不要一上来把所有东西都换成 MCP。先把 sandbox 调度和安全边界做好,再决定协议层是否标准化。
几种架构方案对比
可以把几个方案放在一起看。
text
方案 A:当前 XAgent
Agent -> Manager -> per-session Docker Node
优点:简单,隔离语义清楚
缺点:单机 Docker,安全和扩展性有限
text
方案 B:常驻 Node 集群 + sticky session
Agent -> LB/Gateway -> shared Node
优点:启动快,资源利用率高
缺点:会话隔离弱,需要自己隔离 workspace/process/kernel
text
方案 C:MCP Gateway + per-session MCP Server
Agent -> MCP Gateway/Scheduler -> per-session MCP Server sandbox
优点:协议标准,Agent 接入简单,隔离清晰
缺点:Gateway 要做 session 路由和 sandbox 生命周期
text
方案 D:旁路 Scheduler + 直连 MCP Server
Agent Runtime -> Scheduler API -> sandbox endpoint
Agent Runtime -> sandbox MCP Server
优点:职责清晰,Gateway 简单
缺点:Agent Runtime 要理解调度流程
text
方案 E:K8s Pod / microVM sandbox
Agent -> Gateway -> Pod/microVM runtime
优点:生产级调度和隔离
缺点:系统复杂度高,需要完善控制面
对于 XAgent 这类系统,比较实际的选择是:
text
短期:当前 Manager/Node 模型 + 安全和状态管理增强
中期:预热池 + Redis + NodeProvider 抽象
长期:K8s/microVM + MCP/gRPC 标准协议
结论
MCP 能标准化工具调用,但不能替代 sandbox 调度。对于无状态工具,MCP Gateway 可以只是一个普通网关;对于有状态 sandbox tool,Gateway 必须处理 session 路由和生命周期管理。
更准确的分层应该是:
text
MCP / Function Calling: 工具协议层
Gateway / Scheduler: 会话路由和生命周期层
Sandbox Runtime: 真正执行代码、文件、浏览器、Notebook 的隔离环境
Container / Pod / MicroVM: 底层隔离与资源边界
所以,围绕 XAgent 这类系统,真正复杂的不是 MCP,而是 sandbox 的状态、隔离、调度和回收。MCP 可以让工具协议更标准,但 sandbox 架构仍然需要单独设计。