跳到主要内容

部署层与远程协议

本章讲"在哪跑"这一层。核心洞察:所有远程后端做的是同一件事——把上一章那个 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_sessionruntime.create_sessionserver.py:132-134
POST /run_in_sessionruntime.run_in_sessionserver.py:137-139
POST /executeruntime.executeserver.py:147-149
GET /is_aliveruntime.is_aliveserver.py:127-129

它由 swerex-remote 这个命令行入口启动(pyproject.toml[project.scripts]swerex-remote 映射到 swerex.server:main),main() 解析 --host/--port/--auth-tokenuvicorn.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_exceptionclass_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()不走 HTTPdeployment/local.py:54-56
Remote啥也不起,你已自己起好 server你给的 host/portdeployment/remote.py:59-68
Dockerdocker run 容器,容器内跑 swerex-remote127.0.0.1:<free port>deployment/docker.py:233-283
Modalmodal.Sandbox.create 沙箱 + tunneltunnel 的公网 urldeployment/modal.py:208-246
FargateAWS 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)→ 建 RemoteRuntime127.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 的奇怪镜像兜底。

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(),且在方法内部才 import 具体后端(如 config.py:89-92)——这样没装 modal/boto3 的人用 Docker 后端也不会因 import 失败而崩。顶层 get_deployment(config) 就是调 config.get_deployment()(config.py:226-229)。

好处: 一份 JSON / CLI 参数(带 type 字段)就能选定并构造任意后端,配置可序列化、可走命令行——这正是"换后端不改代码"在工程上的落地方式。

6. 巧妙之处

  • "远端跑 LocalRuntime"避免了两套实现。 不管多远的后端,真正执行命令的永远是同一份 LocalRuntime;远程化只是给它套了层 HTTP(server.py:31-32)。零重复。
  • 511 异常隧道。 用一个非标准状态码 + 动态 import 让异常跨进程"原样复活"(server.py:119runtime/remote.py:84-115),比"所有错都变成一个通用 HTTPError"对 agent 友好得多。
  • 方法内延迟 import 后端。 让"装了哪些可选依赖"和"能用哪些后端"解耦(config.py:89-92),pyproject.toml 里 modal/fargate/daytona 都是 optional extras。
  • || 自举安装。 启动命令自带"没装就现装"的兜底(deployment/docker.py:126),让任意基础镜像开箱即用。

7. 代码地图

主题文件路径关键符号
部署接口src/swerex/deployment/abstract.pyAbstractDeployment
服务端src/swerex/server.pyappmainexception_handlerResponseManager
HTTP 客户端运行时src/swerex/runtime/remote.pyRemoteRuntimeRemoteRuntime._request_handle_transfer_exception
探活轮询src/swerex/utils/wait.py_wait_until_alive
本地部署src/swerex/deployment/local.pyLocalDeployment
远程部署src/swerex/deployment/remote.pyRemoteDeployment
Docker 部署src/swerex/deployment/docker.pyDockerDeploymentDockerDeployment.start_get_swerex_start_cmdglibc_dockerfile
Modal 部署src/swerex/deployment/modal.pyModalDeployment_ImageBuilder
配置 unionsrc/swerex/deployment/config.pyDeploymentConfigget_deployment
部署 hooksrc/swerex/deployment/hooks/abstract.pyDeploymentHookCombinedDeploymentHook