首页
/ 在 Modal 上自托管 Claude Managed Agents 沙箱:基于 Webhook 驱动的工作队列消费实现解析

在 Modal 上自托管 Claude Managed Agents 沙箱:基于 Webhook 驱动的工作队列消费实现解析

2026-09-07 20:53:51作者:柯茵沙

导读

本文基于 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 投递):

  1. client.beta.webhooks.unwrap() 校验 Standard Webhooks 签名;
  2. 只处理 data.type == "session.status_run_started" 的事件,其余类型返回 {"status": "ignored"}
  3. client.beta.environments.work.poller(drain=True, auto_stop=False) 拉取并 ack 队列中全部待处理工作项(每个工作项对应一个会话);
  4. 对每个工作项,按 session_id 幂等地 get-or-create 一个 Modal Sandbox,运行 sandbox_runner.py,并注入一套 ANTHROPIC_* 环境变量;
  5. 沙箱内部执行 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,便于代理或内部网络部署

值得注意的两个实现细节:

  1. 验签客户端懒加载_client()@cache 修饰,注释明确说明懒加载的原因——modal deploy 会在本地导入该模块,而本地没有 Secret 环境变量,若在模块顶层构造客户端会直接导致部署失败(见 modal_sandbox_webhook.py)。
  2. 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,此时:

  1. 在 Console(或通过 API)将该 URL 注册为 session.status_run_started 的 Webhook 端点;
  2. 拿到平台签发的真实签名密钥后,用 --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 内自由执行;
  • 日志路由到 stdoutEnvironmentWorker 以 INFO 级别通过标准库 logging 上报生命周期事件(start、idle-out、心跳关闭、流重连、工具派发),没有 handler 时会被静默丢弃——而退出原因是该进程唯一的诊断输出,因此显式 logging.basicConfig(... stream=sys.stdout) 并加上 [runner] 前缀,使日志能出现在 modal app logs 中(见 sandbox_runner.py);
  • 空闲策略是 SDK 默认:Runner 在会话有活动时一直存活,session.status_idlestop_reason: end_turn 后 60s 退出;任何其他事件(包括 requires_action 空闲,即 Agent 正被沙箱阻塞)都会重置计时器。

环境变量契约上与 ant beta:worker poll --on-work 完全一致,共五个注入项:ANTHROPIC_BASE_URLANTHROPIC_ENVIRONMENT_KEYANTHROPIC_SESSION_IDANTHROPIC_ENVIRONMENT_IDANTHROPIC_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 过滤更清晰)。

迭代工作流

原文档给出的迭代法则可以概括为三条:

  1. 改 Python 文件 → 必须重新部署modal deploy modal_sandbox_webhook.py,因为镜像中通过 add_local_filesandbox_runner.py 以拷贝方式打包进镜像(见 modal_sandbox_webhook.py),本地文件变更不会自动同步;
  2. 只改 Secret → 无需重部署:Secret 在容器启动时读取;
  3. 想要彻底干净的环境 → 先停应用再部署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"一节)。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388