在 Modal 上自托管 Claude Managed Agents 沙箱:基于 Webhook 驱动的工作队列消费实现解析
导读
本文基于 claude-cookbooks 仓库中的 Modal 自托管沙箱参考实现(modal/README.md),详细拆解"如何在 Modal 平台上为 Claude Managed Agents 会话提供自托管执行沙箱"的完整链路:从接收 session.status_run_started Webhook、校验签名、排空环境工作队列,到按会话拉起隔离的 Modal Sandbox 并运行工具 Runner。读完你将掌握 Webhook 编排、工作队列 Drain、ANTHROPIC_* 环境变量契约、按会话持久化 Volume 挂载,以及环境密钥(单凭据)认证体系在控制面与沙箱之间的具体落地方式。
整体架构:从 Webhook 到每会话 Sandbox
该实现属于 self_hosted_sandboxes 目录下的 Modal 变体。仓库中同一套执行契约在不同算力平台上各有实现:Docker 上的 ant beta:worker、Cloudflare Containers/Workers、Daytona、Vercel,而 Modal 变体是唯一同时把"编排侧 Webhook 函数"与"沙箱内 Runner"都写成 Python SDK 代码的参考实现。
Modal 变体由两个 Python 文件组成(位于 modal/ 目录):
| 文件 | 运行位置 | 职责 |
|---|---|---|
| modal_sandbox_webhook.py | Modal 容器(FastAPI endpoint) | 接收 Webhook、验签、排空工作队列、按工作项拉起 Modal Sandbox |
| sandbox_runner.py | 每个沙箱容器内部 | 通过 worker(...).handle_item() 完成工具执行、心跳、事件流与结果回传 |
核心流程(一次 Webhook 投递):
- 用
client.beta.webhooks.unwrap()校验 Standard Webhooks 签名; - 只处理
data.type == "session.status_run_started"的事件,其余类型返回{"status": "ignored"}; - 以
client.beta.environments.work.poller(drain=True, auto_stop=False)拉取并 ack 队列中全部待处理工作项(每个工作项对应一个会话); - 对每个工作项,按
session_id幂等地 get-or-create 一个 Modal Sandbox,运行sandbox_runner.py,并注入一套ANTHROPIC_*环境变量; - 沙箱内部执行
client.beta.environments.work.worker(...).handle_item()完成一个会话的完整服务。
从 modal_sandbox_webhook.py 的模块文档可以看到一个关键设计:Webhook 只是"唤醒信号"(wake-up signal)。每次投递都会排空所有待处理工作项,而不只是触发本次投递的那一个,因此单次到达的 Webhook 就能补救此前所有错过的投递。这与 Docker 变体的长轮询方案(见 docker/on-work.sh,无 Webhook、无需暴露公网)形成对照:Modal 变体选择事件驱动,由 Anthropic 侧在有工作项时主动回调。
单凭据设计:环境密钥贯穿控制面与会话
两个文件的最突出设计是全链路不使用组织 API Key:
- Webhook 编排侧用环境密钥调
poller(...)认领与 ack 工作项; - 沙箱内的 Runner 用同一把环境密钥作为唯一凭据,认证其调用的每一次 poll / heartbeat / 事件流 / force-stop。
正如 usage-guide.md 所述,这把 ANTHROPIC_ENVIRONMENT_KEY 密钥"authenticates the whole worker flow — poll, ack, stop, heartbeat, the session event stream, and skill download — for that one environment",并且是 Runner 唯一需要的凭据。实际运行时,Modal Webhook 在创建沙箱时通过 env={...} 注入(见 modal_sandbox_webhook.py),Runner 侧从 os.environ["ANTHROPIC_ENVIRONMENT_KEY"] 读取并同时用于 AsyncAnthropic 客户端与 worker 调用(见 sandbox_runner.py)。
Prerequisites 与本地认证
原文档给出两个前置步骤:
pip install modal
modal setup # auth to your Modal workspace
要点解析:
modal setup完成本机对 Modal 工作区的认证,后续modal deploy/modal secret create/modal app logs均依赖该认证状态;- 代码侧通过镜像构建指定依赖:Webhook 镜像额外安装
fastapi[standard]与standardwebhooks,这是因为client.beta.webhooks.unwrap()底层由standardwebhooks库提供签名校验能力(SDK 的[webhooks]extra);而沙箱镜像只需anthropic一个包——沙箱内永远接触不到原始 Webhook 投递体(见 modal_sandbox_webhook.py)。
配置 Secret:一套含四个键的环境契约
原文档中的创建命令:
modal secret create cma-self-hosted-sandboxes-secrets \
ANTHROPIC_WEBHOOK_SECRET=placeholder \
ANTHROPIC_ENVIRONMENT_ID='env_...' \
ANTHROPIC_ENVIRONMENT_KEY='sk-ant-oat...'
这套 Secret 在代码中以 modal.Secret.from_name("cma-self-hosted-sandboxes-secrets") 方式注入 Webhook 函数(见 modal_sandbox_webhook.py)。各键含义如下:
| Secret 键 | 用途 |
|---|---|
ANTHROPIC_WEBHOOK_SECRET |
注册 Webhook 时由 Anthropic 签发(whsec_...),用于签名校验 |
ANTHROPIC_ENVIRONMENT_ID |
自托管环境的 env_... 标识,poller 按它拉取工作项 |
ANTHROPIC_ENVIRONMENT_KEY |
sk-ant-oat... 环境密钥,同时是 Runner 的唯一凭据 |
ANTHROPIC_BASE_URL(可选) |
API 基地址,默认 https://api.anthropic.com,便于代理或内部网络部署 |
值得注意的两个实现细节:
- 验签客户端懒加载:
_client()被@cache修饰,注释明确说明懒加载的原因——modal deploy会在本地导入该模块,而本地没有 Secret 环境变量,若在模块顶层构造客户端会直接导致部署失败(见 modal_sandbox_webhook.py)。 unwrap()是同步方法:即便使用AsyncAnthropic也不能await它;whsec_前缀的密钥原样传给webhook_key,URL-safe base64 解码由 SDK 内部完成。验签失败时只记录签名/配置形态的异常信息(绝不记录请求体),然后抛出HTTPException(401)(见 modal_sandbox_webhook.py)。
部署 Webhook 并回填真实 Secret
部署只需一条命令:
modal deploy modal_sandbox_webhook.py
部署完成后会打印一个 *.modal.run URL,此时:
- 在 Console(或通过 API)将该 URL 注册为
session.status_run_started的 Webhook 端点; - 拿到平台签发的真实签名密钥后,用
--force重新创建 Secret 覆盖占位值:
modal secret create cma-self-hosted-sandboxes-secrets \
ANTHROPIC_WEBHOOK_SECRET='whsec_...' \
ANTHROPIC_ENVIRONMENT_ID='env_...' \
ANTHROPIC_ENVIRONMENT_KEY='sk-ant-oat...' \
--force
原文档特别强调:无需重新部署——Secret 在容器启动时读取。这使你在迭代阶段可以频繁更换密钥而保持 Webhook 端点稳定。
Webhook 处理器的排队与排空细节
Webhook 函数本体是一个 @app.function(...) 修饰的 modal.fastapi_endpoint(method="POST")(见 modal_sandbox_webhook.py),核心动作发生在 _drain_work 中:
async for work in client.beta.environments.work.poller(
environment_id=environment_id,
environment_key=environment_key,
block_ms=None, # None -> 省略 -> 非阻塞(API 拒绝 block_ms=0)
reclaim_older_than_ms=2000,
drain=True, # 队列空即返回,避免 handler 死循环
auto_stop=False, # 停止权交给沙箱,poller 不得终止租约
):
三个关键语义值得展开:
drain=True:Webhook handler 必须尽快响应而不能永远循环,因此排空到队列为空即返回;auto_stop=False:每个工作项被移交给一个独立运行的 Modal Sandbox,/stop由沙箱自己控制(见sandbox_runner.py退出逻辑),poller 若在移交互时自动停掉租约,会与沙箱的执行相互踩踏;reclaim_older_than_ms=2000:已 ack 但拉起沙箱失败的工作项会被跳过并在租约到期后由下一次 Webhook 重新回收。
对每个工作项,_process_work_item 先以 session_id 作为沙箱名称去查是否已有存活沙箱(modal.Sandbox.from_name + poll() 判活),有则复用并打 (live) 日志,无则新建并打 (created) 日志。异常处理上,失败工作项只记录异常类型名(SDK/httpx/Modal 异常可能内嵌请求上下文),绝不记录消息体。
按会话持久化:Volume 挂载 /workspace
Modal 变体相较其他平台的独特之处,是使用每会话一个 modal.Volume 来持久化完整工作目录:
session_vol = modal.Volume.from_name(
f"cma-session-{session_id}", create_if_missing=True
)
sb = await modal.Sandbox.create.aio(
"python",
RUNNER_PATH,
app=sb_app,
name=session_id,
image=sandbox_image,
timeout=sandbox_timeout, # 实际调用传 3600s
volumes={"/workspace": session_vol},
env={...},
)
代码注释说明了为什么持久化的是整个 workdir 而不只是 outputs/:Agent 的工作树与下载的 skills 需要跨同会话的多次沙箱生命周期存活。挂载点固定为 /workspace,使 {workdir}/skills/<name>/ 与 Agent 提示词以及其他变体(如 Docker 变体的 cma-ws-<session_id> volume)保持一致(见 modal_sandbox_webhook.py)。
沙箱创建后还调用 sb.set_tags.aio({"session_id": session_id}),便于在 Modal Dashboard 中按会话过滤排查。
沙箱内 Runner:一行调用完成整个会话服务
sandbox_runner.py 的全部业务逻辑浓缩为一行:
environment_key = os.environ["ANTHROPIC_ENVIRONMENT_KEY"]
async with AsyncAnthropic(auth_token=environment_key) as client:
await client.beta.environments.work.worker(
environment_key=environment_key,
workdir=WORKDIR, # /workspace
unrestricted_paths=True,
).handle_item()
依据 usage-guide.md 的库用法说明,client.beta.environments.work.worker(...) 组合了完整循环——认领 → 建立 workdir 并下载该会话 Agent 的 skills 到 {workdir}/skills/<name>/ → 运行工具集(bash/read/write/edit/glob/grep)并同时向工作项租约发送心跳 → 退出时 force-stop。而 .handle_item() 是逐项形式(per-item form):不轮询,读取 ANTHROPIC_* 环境变量后直接服务这一个已被认领的工作项——正好匹配"控制面按会话拉起独立沙箱"的场景。
文件还体现了三个工程细节:
unrestricted_paths=True:按 usage-guide.md 的 Flag 表,--unrestricted-paths默认为false,此处置真相当于放开路径限制,让 worker 在/workspace内自由执行;- 日志路由到 stdout:
EnvironmentWorker以 INFO 级别通过标准库 logging 上报生命周期事件(start、idle-out、心跳关闭、流重连、工具派发),没有 handler 时会被静默丢弃——而退出原因是该进程唯一的诊断输出,因此显式logging.basicConfig(... stream=sys.stdout)并加上[runner]前缀,使日志能出现在modal app logs中(见 sandbox_runner.py); - 空闲策略是 SDK 默认:Runner 在会话有活动时一直存活,
session.status_idle且stop_reason: end_turn后 60s 退出;任何其他事件(包括requires_action空闲,即 Agent 正被沙箱阻塞)都会重置计时器。
环境变量契约上与 ant beta:worker poll --on-work 完全一致,共五个注入项:ANTHROPIC_BASE_URL、ANTHROPIC_ENVIRONMENT_KEY、ANTHROPIC_SESSION_ID、ANTHROPIC_ENVIRONMENT_ID、ANTHROPIC_WORK_ID(见 sandbox_runner.py)。这也意味着这套 Runner 是与算力平台无关的——Daytona 变体的 daytona_webhook.py 就直接复用模态目录下同一个 sandbox_runner.py 文件,只是改由 daytona_sdk 上传并启动。
端到端测试与日志定位
创建会话并发送消息进行验证:
session = client.beta.sessions.create(agent=agent_id, environment_id=ENVIRONMENT_ID)
client.beta.sessions.events.send(session.id, events=[{"type": "user.message", "content": "ls -la"}])
预期按序看到的编排侧日志:
modal app logs cma-self-hosted-sandboxes
# [webhook] event=session.status_run_started session_id=...
# [webhook] acked work=... session=... sandbox=sb-... (created)
日志定位要点:
- 编排侧
[webhook]前缀日志用modal app logs cma-self-hosted-sandboxes查看; - 沙箱内部
[runner]前缀日志位于 Modal Dashboard 的 Apps → cma-self-hosted-sandboxes → Sandboxes 页面(对应session_id命名的沙箱,配合set_tags过滤更清晰)。
迭代工作流
原文档给出的迭代法则可以概括为三条:
- 改 Python 文件 → 必须重新部署:
modal deploy modal_sandbox_webhook.py,因为镜像中通过add_local_file把sandbox_runner.py以拷贝方式打包进镜像(见 modal_sandbox_webhook.py),本地文件变更不会自动同步; - 只改 Secret → 无需重部署:Secret 在容器启动时读取;
- 想要彻底干净的环境 → 先停应用再部署:
modal app stop cma-self-hosted-sandboxes可终止现存容器,随后重部署以获得全新状态。
结语:事件驱动沙箱编排的参考范本
纵观整个实现,Modal 变体为"如何把 Anthropic 的 Webhook + 工作队列契约嫁接到第三方算力平台"提供了一个高完整度的参考范本:Webhook 仅作唤醒信号、drain=True 保证漏投可恢复、auto_stop=False 把租约控制权完整交给沙箱内 Runner、单把环境密钥贯穿控制面与执行面、每会话 Volume 持久化工作树与 skills。若你希望在 Docker 变体的 ant CLI 方案与纯 SDK 库方案之间选择,usage-guide.md 同时给出了 CLI flag/env 对照表与 Python/TypeScript 库级调用示例,可配合本实现一起阅读;如需把自定义工具注入 Runner,worker(...) 的 tools= 参数支持 beta_agent_toolset_20260401(env) 生成的默认工具集进行过滤与扩展(见 usage-guide.md 的"Customising the tool list"一节)。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00