Vercel 部署 Claude 受管智能体:基于自托管 Sandbox 的 Webhook → 工作队列 → 每会话运行器实战指南
导读:本文完整拆解 claude-cookbooks 仓库中 Vercel 参考实现(managed_agents/self_hosted_sandboxes/vercel/),它是"自托管执行沙箱(Self-Hosted Sandboxes)"系列中基于 Vercel Functions + Vercel Sandbox 的一个变体。你将掌握:如何用同一个 webhook 调用链(签名校验 → 排空环境工作队列 → 每工作项拉起一个 Vercel Sandbox)把 Claude 受管智能体的工具执行环境从 Anthropic 托管侧迁到你自己可控的 Vercel 基础设施上,以及沙箱复用、冷启动控制与单一环境密钥凭证体系等关键实现细节。
背景:同一契约,五种计算提供方
要理解 Vercel 变体,首先要明白它在整个 self_hosted_sandboxes 体系中的位置。系列总览 managed_agents/self_hosted_sandboxes/README.md 明确定义了每个变体都要实现的统一契约:
- 接收
session.status_run_startedwebhook,并用client.beta.webhooks.unwrap()校验签名; - 排空环境工作队列(drain),保证单次投递即可补上之前错过的条目;
- 对每个工作项,启动一个每会话(per-session)沙箱,运行 SDK/CLI 工具运行器(
bash/read/write/edit/glob/grep),心跳续租,并把tool_result回传给会话。
各变体只是换了"计算层 + 运行器载体":
| 变体 | 计算层 | 运行器 |
|---|---|---|
docker/ |
自控主机上的原生 Docker | 每会话容器中跑 ant beta:worker run |
cf/ |
Cloudflare Containers | 每会话容器中跑 ant beta:worker run |
cf-worker/ |
Cloudflare Workers(无容器) | Durable Object 中的 TS SessionToolRunner + 隔离内假文件系统 |
modal/ |
Modal Sandbox + 每会话 Volume | Python sandbox_runner.py |
vercel/ |
Vercel Functions + Sandbox | Node runner.mjs 运行于 Vercel Sandbox |
本文主角的形态是:webhook → 排空队列 → 每会话运行器,与 Modal、Daytona、Cloudflare 各 demo 一致,但运行器是跑在 Vercel Sandbox 里的 TS EnvironmentWorker.handleItem(),并使用 SDK 的 betaAgentToolset20260401() 工具集,直接操作沙箱的真实文件系统(bash / read / write / edit / glob / grep)。
仓库文件结构
该变体代码量小但职责清晰,共四个文件 + 两份工程配置:
| 文件 | 职责 |
|---|---|
api/webhook.ts |
Vercel Function:校验签名 → 排空队列 → 每工作项拉起 Sandbox |
runner/runner.mjs |
在沙箱内运行:EnvironmentWorker.handleItem(),构建每会话的 AgentToolContext、下载智能体的 skills、心跳续租地跑 SessionToolRunner,退出时强制停止工作项 |
package.json |
工程与依赖声明(vercel dev / vercel deploy / tsc --noEmit 脚本) |
tsconfig.json |
ES2022 + Bundler 模块解析、strict 模式,include 覆盖 api 与 runner |
vercel.json |
Functions 配置:maxDuration: 60 与 includeFiles |
README.md |
部署与测试文档(本文对应的原文档) |
关键依赖版本(见 package.json):@anthropic-ai/sdk ^0.97.0、@vercel/sandbox ^1.10.0、@vercel/kv ^3.0.0、vercel ^52.0.0(CLI)、minimatch ^9.0.5。这些版本与仓库配套的自托管使用指南中钉定的 TS SDK 0.97.0 一致。
整体调用链:webhook 只是"唤醒信号"
api/webhook.ts 顶部注释就把设计意图讲得很透:The webhook is a wake-up signal only(webhook 仅作唤醒信号)。Vercel Functions 是无状态、有超时上限的 serverless 函数,不可能像常驻进程那样长轮询,因此设计上把所有"重量级工作"都转移到沙箱内的分离运行器身上。
每次 webhook 投递的处理流程(对应 POST 处理器):
- 校验签名:用
ANTHROPIC_ENVIRONMENT_KEY构造 Anthropic 客户端(webhookKey传入ANTHROPIC_WEBHOOK_SECRET),对原始 body 调用anthropic.beta.webhooks.unwrap(body, { headers });签名不过则返回401 signature verification failed。注释特别说明whsec_密钥原样传入即可,SDK 内部会做 URL-safe base64 解码。 - 事件类型过滤:只有
data.type === "session.status_run_started"才继续处理,其余事件返回{ status: "ignored" }。 - 排空工作队列:调用
drainWork()轮询并处理工作项。
drainWork:为什么是"排空"而不是"取一条"
drainWork() 的实现值得细看。SDK 的 TS WorkPoller 是长轮询、空队列时永不返回的,而 serverless handler 必须及时响应,所以这里手动 poll → ack 直到队列为空:
- 上限
MAX_DRAIN = 25(常量定义),防止单次函数调用被无限排空拖垮; - 每次
poll传入reclaim_older_than_ms: 2000,即工作项认领后超过 2 秒未续租就允许被重新认领; - 单次投递排空全部待处理工作项(而不只是触发它的那一条),因此一条迟到或错过的 webhook 也能通过下一次投递补齐;
poll返回 404 时的处理值得注意:/work/poll在取出一个 Redis 中 session 已消失的过期条目时会 404,该过期条目已被服务端消费,所以重试即可越过;其余错误视为配置/瞬时故障,终止本次排空、等下一个 webhook 恢复。
代码注释还提示 SDK 会自动发送 anthropic-beta: managed-agents-2026-04-01 请求头,无需手动附加 beta 参数。
processWorkItem:认领后拉起沙箱
对每条已认领的工作项,processWorkItem() 依次完成:
- ack 认领:客户端已用 environment key 鉴权,
ack无需任何逐调用鉴权头; - 沙箱复用探测(见下文 KV 小节);
- 创建 Sandbox:
Sandbox.create()指定runtime: "node24"、超时 30 分钟,并设置networkPolicy——出网白名单只放行 Anthropic API 域名与registry.npmjs.org(供npm install),若你的工具还需要访问 github.com 等域名需自行追加; - 写入运行器:
writeFiles把runner.mjs与其内联package.json写入沙箱内/vercel/sandbox/runner/。注意runner.mjs源码是在 webhook 模块加载时用readFileSync读入内存的(L44-L47),这样既能被 Vercel 文件追踪器打进产物,又省去沙箱二次读取; - 准备目录:以 root(
sudo: true)执行mkdir -p /mnt/session /workspace并chown给沙箱用户,确保非特权运行的 runner 有权限写智能体的工作区; - 沙箱内
npm install:安装@anthropic-ai/sdk与minimatch,结果回显到函数日志(而不是只有沙箱终端可见)以便排查; - detached 启动 runner:
runCommand({ cmd: "node", args: ["runner.mjs"], cwd: RUNNER_DIR, detached: true, env: {...} })。detached: true是关键——运行器的心跳与工具执行必须活过本次函数调用。
detached 进程的环境变量由 webhook 注入,与 ant beta:worker poll --on-work 的环境契约一致(见 webhook.ts L216-L232):
ANTHROPIC_BASE_URL # 默认 https://api.anthropic.com,可经 ANTHROPIC_BASE_URL 覆盖
ANTHROPIC_ENVIRONMENT_KEY # 运行器的单一凭证
ANTHROPIC_SESSION_ID # 会话 id
ANTHROPIC_ENVIRONMENT_ID # 自托管环境 id
ANTHROPIC_WORK_ID # 工作项 id
WORKDIR # 智能体工作目录,默认 /workspace
日志镜像与脱敏
webhook 启动运行器后会监听其日志流约 30 秒(或累计 8 KB)并把开头内容镜像到函数日志(L238-L251),这样启动即崩(坏 import、401、缺环境变量)或首次工具分发都能仅靠 vercel logs 排查,不必依赖 Sandboxes 观测面板。出于安全考虑,输出前经 redact() 将 sk-ant-…、whsec_…、Bearer … 等凭据形态打码。
运行器内部:EnvironmentWorker.handleItem()
runner/runner.mjs 全部逻辑只有约 60 行——因为它是对 Modal 变体 sandbox_runner.py 的 TS 类比,真正的工作由 SDK 内部完成:
const ctrl = new AbortController();
process.once("SIGTERM", () => ctrl.abort());
process.once("SIGINT", () => ctrl.abort());
const environmentKey = process.env.ANTHROPIC_ENVIRONMENT_KEY;
const client = new Anthropic({
authToken: environmentKey,
logLevel: "info", // 把 worker 生命周期(start/idle/heartbeat shutdown)写进沙箱日志
});
await client.beta.environments.work
.worker({
environmentKey,
workdir: WORKDIR, // 默认 /workspace
unrestrictedPaths: true, // 放开 bash/read/write/edit/glob/grep 的路径限制
signal: ctrl.signal,
})
.handleItem();
client.beta.environments.work.worker(...).handleItem() 就是整个运行器:它会在 workdir 构建每会话的 AgentToolContext、把智能体的 skills 下载到 {workdir}/skills/<name>/,然后为该会话跑一个 SessionToolRunner(心跳 + 事件流 + 工具分发 + 结果回帖,完整实现可对照 use-guide 的 Library usage 说明),退出时强制停止工作项(force-stop)。
这里有个值得注意的设计取舍:runner 是 npm install 出来的 SDK 依赖,运行器代码不需要每个沙箱里都有 tsc 编译产物,runner.mjs 本身是 ESM JavaScript,直接 node runner.mjs 即可运行。
关于 unrestrictedPaths: true:对应 CLI 的 --unrestricted-paths 标志,放开后工具可读写任意路径(沙箱本身就是隔离边界,无需像宿主机那样收紧)。与 usage-guide 中"worker 直接在宿主机执行 shell 与文件操作时必须放进隔离边界"的告诫互为印证。
空闲策略(Idle Policy)
README 强调该 demo 使用 SDK 默认空闲策略:运行器在 session.status_idle 且 stop_reason: end_turn 之后 60 秒退出;而任何其它事件——包括 requires_action 空闲(智能体正阻塞在沙箱工具上)——都会重置时钟。也就是说,只要会话仍有活动迹象,运行器就保持存活;只有真正结束一轮(end_turn)后才开始倒计时退出。这与 usage-guide 中 --max-idle 默认 1m after end_turn idle 的语义完全一致。
单一凭证设计
整个系列(不只 Vercel)在认证上有一个统一原则:环境密钥是唯一凭证。ANTHROPIC_ENVIRONMENT_KEY 同时用于控制面(poll / ack / stop / 心跳)与每会话调用(事件流、工具执行、skills 下载、force-stop),没有任何一处出现 org API key。这在架构上是刻意的:沙箱内运行器只拿到作用域受限的环境密钥,即使沙箱被攻破,也无法滥用组织级凭据。
对升级路径感兴趣的读者可对照 upgrade-guide:旧版 SDK 曾用 "service key 只授权 poll + 每工作项 secret 解码出 sessions_token" 的双凭证模型,新模型已合并为单一环境密钥,decodeWorkSecret 与 sessions_token 均已移除。
前提条件
npm、Vercel CLI(npm i -g vercel);- 一个已关联的 Vercel 项目(
vercel link); - 一个已注册的自托管环境及其环境密钥。
创建自托管环境与生成环境密钥的完整流程见 managed_agents/self_hosted_sandboxes/docs/usage-guide.md:既可以在 Console 中 Workspace → Environments → New → Self-hosted 创建,也可以用 client.beta.environments.create({ name, config: { type: "self_hosted" } }) 在代码中创建,随后在 Console 中 Generate environment key 并将其导出为 ANTHROPIC_ENVIRONMENT_KEY。所有公开 API 调用都需要请求头 anthropic-version: 2023-06-01 与 anthropic-beta: managed-agents-2026-04-01(SDK helper 会自动附加)。
配置环境变量
在项目根目录依次执行:
npm install
vercel link
vercel env add ANTHROPIC_WEBHOOK_SECRET # 先填占位符
vercel env add ANTHROPIC_ENVIRONMENT_ID
vercel env add ANTHROPIC_ENVIRONMENT_KEY
三个环境变量的语义如下(对照 webhook.ts 源码注释):
| 环境变量 | 用途 | 说明 |
|---|---|---|
ANTHROPIC_WEBHOOK_SECRET |
webhook 签名校验 | 注册 webhook 时由 Anthropic 签发的 whsec_… 密钥,第一阶段先用占位符 |
ANTHROPIC_ENVIRONMENT_ID |
定位自托管环境 | 形如 env_01… |
ANTHROPIC_ENVIRONMENT_KEY |
单一凭证 | 控制面(poll/ack/stop)+ 每会话调用的唯一凭据 |
ANTHROPIC_BASE_URL(可选) |
API 网关地址 | 默认 https://api.anthropic.com,自建网关/代理时可覆盖 |
此外,若启用沙箱复用还需 Vercel KV 的 KV_REST_API_URL / KV_REST_API_TOKEN(见下文"沙箱复用")。
部署
vercel deploy --prod
命令会打印 https://<project>.vercel.app 形式的线上地址。随后把 https://<project>.vercel.app/api/webhook 注册为 session.status_run_started 事件的 webhook(在 Console 或通过 API 完成)。注册时 Anthropic 会签发一个 secret,用它覆盖占位符并重新部署:
vercel env rm ANTHROPIC_WEBHOOK_SECRET production
vercel env add ANTHROPIC_WEBHOOK_SECRET production
vercel deploy --prod
这一步的"先部署占位符、再回填真值、再部署"流程是必须的:你只有先拿到线上 URL 才能完成 webhook 注册,而注册之后才知道真正要用的 secret。
本地联调可用 npm run dev(即 vercel dev),类型检查用 npm run typecheck(tsc --noEmit)。
测试
创建一个指向该自托管环境 id 的会话并向其发送消息:
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"}])
然后观察 Vercel 函数日志,应当依次看到:
vercel logs <project>.vercel.app
# [webhook] event=session.status_run_started session_id=...
# [webhook] acked work=... session=... sandbox=sbx_... (created)
沙箱的 stdout/stderr([runner] 前缀行)显示在 Vercel 控制台项目的 Observability → Sandboxes 标签页下;同时在函数日志里也能看到被镜像过来的运行器头 30 秒输出,双重可观测。
生产化要点(Notes 深度解读)
冷启动预算与 maxDuration
Sandbox.create() + writeFiles() + 沙箱内 npm install 合计约 15–25 秒,之后函数才响应;因此 vercel.json 给该函数设置了 "maxDuration": 60,否则函数会在沙箱就绪前被 Vercel 平台超时杀掉。运行器本身是 detached 的,不受函数超时影响。
vercel.json 中另一处容易被忽略的配置是 "includeFiles": "runner/**":它把 runner/runner.mjs 打进函数产物,作为文件追踪器漏掉 readFileSync 时的双保险(对应 webhook.ts 的注释说明)。
沙箱复用:Vercel 无原生标签 API,需要外部状态
与 Modal(Sandbox.from_name + tags)不同,Vercel Sandbox 没有原生的 tag / label / get-by-name API,且 Sandbox.list() 不返回任何每沙箱元数据,所以复用必须借助外部状态。本 demo 的做法是:
- 若检测到
KV_REST_API_URL/KV_REST_API_TOKEN(即项目挂载了 Vercel KV),webhook 就把session_id → sandbox_id映射写入 KV(L184),key 形如cma:sandbox:<sessionId>,TTL 与沙箱超时一致(30 分钟)以便自清理; - 下一次
run_started到达时,findLiveSandbox()先查 KV 映射,再用Sandbox.get({ sandboxId })确认沙箱仍running(否则删掉映射重新创建),并调用extendTimeout()续期沙箱 VM——沙箱 VM 有自己的时钟、独立于运行器,因此每个新 run 都要续租,让活跃会话的 VM 能跨轮次存活;一旦运行器在 end_turn 空闲 60 秒后退出,续租停止,沙箱在下一次超时边界自然消亡; - 未配置 KV 时,每次投递都创建全新沙箱。此时靠
SessionToolRunner的seen/answered去重逻辑兜底——重复运行器只是浪费,而不会出错(不会重复回帖结果)。
KV 写入是 best-effort 的:写失败只意味着下次投递会新建一个重复沙箱,不会破坏正确性。
工作目录:纯临时目录,非持久卷
webhook 在沙箱启动时创建 /mnt/session 与 /workspace 两个目录,并把 /workspace 作为工具分发的工作目录(skills 下载到 /workspace/skills/<name>/),与 Modal/CF 各 demo 的布局对齐(对应注释:Modal 在 /mnt/session 挂卷、CF 容器用 --workdir /workspace)。但需要特别强调:
Vercel Sandbox 没有 volume API,这两个目录只是普通的一次性临时目录,不是持久卷。智能体的工作树在沙箱停止时即消失。
这是与 Modal 变体(每个会话挂载独立 cma-session-<session_id> Volume,工作树与 skills 可跨沙箱生命周期存活,见 modal_sandbox_webhook.py)最本质的差异:Vercel 上如果你想在会话跨多轮后保留文件,必须自己做外部持久化(如把产物上传到对象存储),无法靠沙箱磁盘。
npm install 与冷启动优化
npm install 在沙箱启动时(spawn 时刻)执行,所以本 demo 没有构建步骤——runner.mjs 原样上传即可。若想再省约 10 秒冷启动,可改用 esbuild 预打包 runner/runner.mjs 为单文件 bundle 再上传,跳过沙箱内安装依赖的环节。代价是构建与上传逻辑前移,适合对 P50 冷启动敏感的线上场景。
常见问题定位
- 签名 401:检查
ANTHROPIC_WEBHOOK_SECRET是否已用注册后签发的真值覆盖(而非占位符),并确认已重新vercel deploy --prod。 - webhook 收到但无沙箱:先看
vercel logs是否有[webhook] FAILED work=...。WorkError携带可控诊断文本,其余异常只打印类型名(构造器名),避免 SDK/Vercel 异常把请求上下文泄露进日志——这是代码注释中刻意的安全取舍。 - 沙箱内 401/网络失败:核对注入 runner 的
ANTHROPIC_ENVIRONMENT_KEY是否属于该环境,以及networkPolicy.allow是否覆盖了你工具要访问的域名(默认只放行 Anthropic API 主机与registry.npmjs.org)。 - 跨轮工作树丢失:这是 Vercel 无卷 API 的固有限制,需自行做外部持久化,或接受每会话临时文件语义。
小结
这套 Vercel 参考实现展示了一种优雅的分层:serverless 函数只做"信号处理 + 资源编排"(校验、排空、拉起),真正有状态、需要长连接的工作全部下沉到 detached 的沙箱运行器。它绕开了 serverless 的三大天然限制(执行时长、无状态、出网白名单),同时通过 KV 复用、日志镜像、单一环境密钥等手段把成本与安全风险都控制在合理范围。若你的执行环境恰好落在 Vercel 生态内,可在此基础上按需扩展 networkPolicy 出网白名单、追加自定义工具(参考 usage-guide 的 Customising the tool list 一节中 betaAgentToolset20260401(ctx) 的组合用法),并结合 upgrade-guide 在 SDK 升级时保持 API 面一致。
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