部署层与远程协议
本章讲"在哪跑"这一层。核心洞察:所有远程后端做的是同一件事——把上一章那个
LocalRuntime用 HTTP 包起来跑到目标机器上,再从本地连过去。理解了这个模板,docker / modal / fargate 就只是"怎么把进程拉起来"的不同实现。
1. 部署接口:start / stop / runtime
AbstractDeployment(deployment/abstract.py:11)的契约很小:
| 成员 | 作用 |
|---|---|
start() | 把运行时拉起来(起容器、跑 server) |
stop() | 关掉 |
is_alive() | 健康检查 |
runtime(属性) | 返回一个可用的 AbstractRuntime |
add_hook() | 注册进度回调 |
注意一个细节:__del__ 里会尝试在对象被回收时自动 stop()(deployment/abstract.py:44-60),处理了"用户忘了关容器"导致资源泄漏的常见情况——它还小心地区分了"当前是否在 async 事件循环里"。
2. 统一模板:server 包住 LocalRuntime,RemoteRuntime 连回来
这是全章的钥匙。远程执行 = 远端跑 server + 本地跑 client。
远端:server.py 是 LocalRuntime 的 HTTP 镜像
server.py 是个 FastAPI 应用,模块级就 runtime = LocalRuntime()(server.py:31-32)。它的每个路由几乎就是接口方法的直译:
| HTTP 路由 | 转发到 | 源码 |
|---|---|---|
POST /create_session | runtime.create_session | server.py:132-134 |
POST /run_in_session | runtime.run_in_session | server.py:137-139 |
POST /execute | runtime.execute | server.py:147-149 |
GET /is_alive | runtime.is_alive | server.py:127-129 |
它由 swerex-remote 这个命令行入口启动(pyproject.toml 的 [project.scripts] 把 swerex-remote 映射到 swerex.server:main),main() 解析 --host/--port/--auth-token 后 uvicorn.run(server.py:193-217)。
本地:RemoteRuntime 把每个方法变成一次 HTTP POST
RemoteRuntime(runtime/remote.py:44)实现和 LocalRuntime 完全相同的接口,但每个方法体都是"发个 HTTP 请求"。核心是私有的 _request(runtime/remote.py:165):
# 示意,非源码 —— RemoteRuntime 每个动作的统一出口
async def _request(self, endpoint, payload, output_class, num_retries=0):
headers = self._headers # X-API-Key 鉴权
headers["X-Request-ID"] = str(uuid.uuid4()) # 幂等键
while retry_count <= num_retries:
try:
resp = await session.post(url, json=payload.model_dump(), headers=headers)
await self._handle_response_errors(resp)
return output_class(**await resp.json()) # JSON 反序列化回 pydantic
except Exception:
await asyncio.sleep(retry_delay) # 指数退避 + 抖动
retry_delay = min(retry_delay * 2 + random.uniform(0, 0.5), 5)
于是 run_in_session 不过是 return await self._request("run_in_session", action, Observation)(runtime/remote.py:203-205)。
3. 远程协议的三个工程细节
这套 HTTP 协议有三处值得单独看,因为它们处理的是"分布式调用"绕不开的难题。
(a) 异常跨进程重抛
命令在远端抛了 NonZeroExitCodeError,你希望在本地也接到同一个异常。怎么把异常"传"过 HTTP?
- 远端:
exception_handler捕获任何异常,把它序列化成_ExceptionTransfer(含message/class_path/traceback/extra_info),用一个特殊状态码 511 返回(server.py:105-119)。 - 本地:
_handle_response_errors看到 511 就解包,_handle_transfer_exception按class_path动态 import 那个异常类并重新实例化、重新 raise(runtime/remote.py:84-115)。导入失败就退化成通用SwerexException。
效果:远端的报错在本地"原样"复活,agent 的 try/except 照常工作。
(b) 幂等键
每个请求带一个 X-Request-ID(runtime/remote.py:168-170)。server 端中间件 handle_request_id 记住"上一个"请求的响应,重复 ID 直接重放(server.py:77-102)。
为什么需要? 因为 (c) 会重试。网络抖动下一个请求可能实际成功了但 client 没收到回包就重发——幂等键避免"一条命令被跑两遍"。局限:ResponseManager 只存最近一个(server.py:50-61),代码注释自陈并发多客户端下不保证幂等(server.py:47)。
(c) 探活与重试
容器/沙箱刚起来时 server 还没就绪,直接连会失败。_wait_until_alive(utils/wait.py:6)轮询 is_alive 直到返回真值或超时——这里用上了 01 章 IsAliveResponse.__bool__ 那个巧思,循环体能干净地写成 if await function(...)。is_alive 自己也吞掉连接错误、只返回 is_alive=False(runtime/remote.py:128-160),所以轮询不会因"还没起来"而崩。
4. 四种后端:差别只在"怎么把进程拉起来"
理解了模板,各后端就是模板的填空。
| 后端 | start() 干的事 | 连回去用什么 host | 源码 |
|---|---|---|---|
| Local | 啥也不起,直接 LocalRuntime() | 不走 HTTP | deployment/local.py:54-56 |
| Remote | 啥也不起,你已自己起好 server | 你给的 host/port | deployment/remote.py:59-68 |
| Docker | docker run 容器,容器内跑 swerex-remote | 127.0.0.1:<free port> | deployment/docker.py:233-283 |
| Modal | modal.Sandbox.create 沙箱 + tunnel | tunnel 的公网 url | deployment/modal.py:208-246 |
| Fargate | AWS ECS 任务(同构,本文未逐行读) | 任务的网络地址 | deployment/fargate.py |
说明:fargate 与 daytona 后端与 modal 结构同构(都是"起远程沙箱 + 建 RemoteRuntime"),本文只逐行读了 modal 作代表,fargate/daytona 仅核对了其 config 类(
deployment/config.py:135-211)。
Docker 后端的几个实战细节
DockerDeployment.start(deployment/docker.py:233)的流程:拉镜像(_pull_image,按 pull 策略 never/always/missing,:133-146)→ 找空闲端口(find_free_port)→ 组 docker run -p <port>:8000 ... <image> <swerex 启动命令> 并 Popen(:251-270)→ 建 RemoteRuntime 连 127.0.0.1:port(:273-280)→ _wait_until_alive 等就绪(:282)。
两个巧思:
- swerex 不在镜像里怎么办? 启动命令是
swerex-remote ... || (装 pipx && pipx run swe-rex ...)(_get_swerex_start_cmd,deployment/docker.py:120-131):先试直接跑,没有就现场用 pipx 装。任意python:3.11之类的镜像都能用。 - glibc standalone python。 可选地构建一个静态 Python 塞进镜像(
glibc_dockerfile,deployment/docker.py:148-194),为没有合适 Python 的奇怪镜像兜底。
Modal 后端
ModalDeployment(deployment/modal.py:110)用 modal.Sandbox.create 起沙箱、开 unencrypted_ports 拿 tunnel 公网 url 连回去(:220-242)。_ImageBuilder.auto(:91-107)能从 Dockerfile / DockerHub / ECR 自动识别镜像来源。还有个 deployment_timeout 作"防烧钱"的硬性 kill 开关(config.py:108-112)。
5. 配置即判别 union:一份配置选一个后端
每个 deployment 配一个 pydantic 配置类,都带一个字面量 type 判别字段(如 type: Literal["docker"],config.py:55)。它们合成一个 union:
# 真实定义见 deployment/config.py:214
DeploymentConfig = (
LocalDeploymentConfig | DockerDeploymentConfig | ModalDeploymentConfig
| FargateDeploymentConfig | RemoteDeploymentConfig | DummyDeploymentConfig
| DaytonaDeploymentConfig
)
每个配置类都有 get_deployment(),且