ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PostgreSQL 存沙箱元数据,Agent 从 TaoToken 取模型 Key

PostgreSQL 存沙箱元数据,Agent 从 TaoToken 取模型 Key 1. 沙箱身份归 PostgreSQL模型凭证归 TaoToken去年帮一个团队复盘 Agent 执行事故根因不在模型写错代码而在两条边界没划清第一条模型 Key 被烘进了镜像 ENV一个快照被克隆三次同一把 Key 出现在四个租户的沙箱里谁泄露的都查不出来第二条沙箱状态只存在 Runner 进程内存里节点重启后三百多个环境既没被回收也算不清账。前者是凭证治理问题后者是元数据治理问题而它们恰好都可以用同一套工程手段收口把沙箱的生命周期元数据全部放进 PostgreSQL把模型侧凭证从镜像里彻底剥离沙箱启动时按需注入。凭证的发放侧我们选的是 TaoToken。在 TaoToken 官网 注册并生成 API Key 之后沙箱内的 Agent 通过统一的 Base URLhttps://taotoken.net/api访问模型服务Key 走环境变量注入既不进 Dockerfile也不进快照层。这样做的好处很直接换 Key、吊销 Key、按租户隔离额度全都在宿主侧完成镜像和快照完全无感。这篇文章按数据平台工程师的视角把三件事讲透沙箱元数据表的 DDL 怎么设计、Key 注入命令怎么写、模型调用在沙箱里失败时怎么靠一条 SQL 反查到具体环节。文中所有 SQL 和命令都请在你自己的元数据库实例与测试环境里执行不要连生产库跑Agent 侧只允许访问控制面接口获取临时凭证永远不要给沙箱里的进程任何数据库连接串。2. 元数据表结构四张表把沙箱状态机钉在 PG 里先把状态机定下来。沙箱的典型流转是creating → started → paused → stopped → archived → deleted中间还可能有failed分支。这个状态机一旦只存在内存里就会出现节点重启后环境失踪同一沙箱被两个任务抢占这类问题所以第一步是把它变成数据库里的强约束。下面这套 DDL 在 PostgreSQL 13 及以上可以直接执行先建库再建类型-- 在独立元数据库里执行不要在业务库上跑 CREATE TYPE sbx_state AS ENUM ( creating, started, paused, stopped, archived, failed, deleted ); CREATE TABLE sbx_sandbox ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id BIGINT NOT NULL, task_id TEXT NOT NULL, state sbx_state NOT NULL DEFAULT creating, snapshot_id UUID, vcpu SMALLINT NOT NULL DEFAULT 1, mem_mb INTEGER NOT NULL DEFAULT 1024, disk_mb INTEGER NOT NULL DEFAULT 3072, auto_stop_sec INTEGER NOT NULL DEFAULT 900, runner_node TEXT, last_activity_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), stopped_at TIMESTAMPTZ, archived_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ, CONSTRAINT ck_sbx_vcpu CHECK (vcpu BETWEEN 1 AND 4), CONSTRAINT ck_sbx_mem CHECK (mem_mb BETWEEN 1024 AND 8192), CONSTRAINT ck_sbx_disk CHECK (disk_mb BETWEEN 1024 AND 10240) );auto_stop_sec这一列是后面排障的关键先记住它。默认 900 秒对应空闲 15 分钟自动停的常见策略但它判定的活跃只以外部交互为准沙箱内部跑的后台进程不算——这一点会在第 8 节展开。快照单独建表和沙箱本体解耦因为一个快照会被复用很多次CREATE TABLE sbx_snapshot ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id BIGINT NOT NULL, name TEXT NOT NULL, base_image TEXT NOT NULL, oci_digest TEXT, build_state TEXT NOT NULL DEFAULT pending, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (tenant_id, name) );第三张表是凭证引用表也是这套设计里最容易被做错的一张。原则只有一条元数据库里不存明文 Key只存引用。CREATE TABLE sbx_credential_ref ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id BIGINT NOT NULL, provider TEXT NOT NULL DEFAULT taotoken, vault_path TEXT NOT NULL, key_hint TEXT NOT NULL, base_url TEXT NOT NULL DEFAULT https://taotoken.net/api, expires_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), revoked_at TIMESTAMPTZ, UNIQUE (provider, vault_path) );vault_path指向外部密钥管理系统中的路径key_hint只保存 Key 的后四位用于人工比对而无法反推。这样即使元数据库被拖走攻击者也拿不到可用凭证。第四张表是事件审计所有状态变更都往里写CREATE TABLE sbx_event ( id BIGSERIAL PRIMARY KEY, sandbox_id UUID NOT NULL REFERENCES sbx_sandbox(id) ON DELETE CASCADE, event TEXT NOT NULL, detail JSONB NOT NULL DEFAULT {}::jsonb, occurred_at TIMESTAMPTZ NOT NULL DEFAULT now() );索引按查询模式来建别一把梭全建 B-treeCREATE INDEX ix_sbx_tenant_state ON sbx_sandbox (tenant_id, state) WHERE state deleted; CREATE INDEX ix_sbx_recycle ON sbx_sandbox (state, last_activity_at) WHERE state IN (started, paused); CREATE INDEX ix_sbx_event_time ON sbx_event (sandbox_id, occurred_at DESC);回收任务用一条 UPDATE 就能完成注意用make_interval把每行的独立阈值算进去而不是写死 15 分钟UPDATE sbx_sandbox s SET state stopped, stopped_at now() WHERE s.state started AND s.auto_stop_sec 0 AND s.last_activity_at now() - make_interval(secs s.auto_stop_sec) RETURNING s.id;沙箱内部有任务需要保活时走心跳更新而不是改全局配置UPDATE sbx_sandbox SET last_activity_at now() WHERE id $1 AND state started;到这里沙箱是谁、在哪、什么状态、什么时候该回收全部落库了。接下来处理模型出口。3. 快照与凭证分离Key 不进镜像、不进快照很多团队的第一版实现是这样的Dockerfile 里ENV ANTHROPIC_AUTH_TOKENxxx然后把装好依赖的容器 commit 成快照。这样做在单租户演示环境没问题一旦多租户就会爆炸——快照被共享的那一刻Key 就跟着共享出去了而且你无法回答这把 Key 现在被哪些沙箱在用。正确的切法是三层分离第一层镜像层不含任何凭证。Dockerfile 里只声明变量名不赋值# 只声明不赋值构建期也不传 --build-arg ENV TAOTOKEN_BASE_URL ENV TAOTOKEN_API_KEY第二层快照层不含任何凭证。构建快照时只做依赖安装和环境配置模型配置一律延迟到运行时。快照本身只记base_image和oci_digest也就是第 2 节那张sbx_snapshot表。第三层运行时注入。沙箱启动时由控制面从密钥系统取 Key作为环境变量传给沙箱进程生命周期与沙箱绑定沙箱销毁即失效。这套切法的收益在排障时特别明显沙箱里模型调用报 401你只需要判断变量有没有注入和Key 是不是被吊销了而不用去翻镜像历史怀疑是不是打包时写错了。顺带说一下网络策略。沙箱默认应该禁止任意出站访问只放行模型出口的白名单域名taotoken.net的 443 端口。不要为了图省事给沙箱开全通网也不要因为在沙箱里加代理而破坏隔离边界——网络策略越简单审计越好做。4. Agent Key 注入两条命令 一个注入脚本先把最小可复现的注入流程跑通。宿主侧准备凭证注意export只作用于当前 shell不会写进任何文件# 1) 在宿主侧准备凭证不要写进镜像 / Dockerfile / 快照 export TAOTOKEN_API_KEYYOUR_API_KEY export TAOTOKEN_BASE_URLhttps://taotoken.net/api # 2) 校验变量确实存在避免 ~/.bashrc 里残留旧值 test -n $TAOTOKEN_API_KEY echo key loaded: ${TAOTOKEN_API_KEY:0:4}****拉起沙箱时注入变量名显式传递不要用-e不跟值的形式去读取宿主环境里的所有同名变量docker run --rm -it \ --name agent-sbx-demo \ --cpus1 --memory1g \ -e TAOTOKEN_API_KEY$TAOTOKEN_API_KEY \ -e TAOTOKEN_BASE_URL$TAOTOKEN_BASE_URL \ -e ANTHROPIC_BASE_URL$TAOTOKEN_BASE_URL \ -e ANTHROPIC_AUTH_TOKEN$TAOTOKEN_API_KEY \ -v $PWD/workspace:/workspace \ -w /workspace \ your-agent-image:latest如果你用的是 Kubernetes 跑 Runner用 Secret 挂载而不是 ConfigMapapiVersion: v1 kind: Pod metadata: name: agent-sbx-demo spec: containers: - name: sandbox image: your-agent-image:latest env: - name: TAOTOKEN_BASE_URL value: https://taotoken.net/api - name: TAOTOKEN_API_KEY valueFrom: secretKeyRef: name: taotoken-cred key: api-key resources: limits: cpu: 1 memory: 1Gi控制面侧的组装逻辑用 Python 写清楚注意这里只做引用解析不做值存储import os import psycopg def read_secret(vault_path: str) - str: 由外部密钥管理系统实现此处不落明文 raise NotImplementedError def resolve_credential(conn, tenant_id: int) - dict: with conn.cursor() as cur: cur.execute( SELECT vault_path, base_url FROM sbx_credential_ref WHERE tenant_id %s AND provider taotoken AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at now()) ORDER BY created_at DESC LIMIT 1 , (tenant_id,), ) row cur.fetchone() if row is None: raise RuntimeError(ftenant {tenant_id} 没有可用凭证引用) vault_path, base_url row return {api_key: read_secret(vault_path), base_url: base_url} def build_sandbox_env(conn, tenant_id: int) - dict: cred resolve_credential(conn, tenant_id) return { TAOTOKEN_API_KEY: cred[api_key], TAOTOKEN_BASE_URL: cred[base_url], ANTHROPIC_BASE_URL: cred[base_url], ANTHROPIC_AUTH_TOKEN: cred[api_key], }拿到 env 字典之后交给运行器 SDK 创建沙箱即可。这里刻意不写死任何运行器的具体方法名因为不同版本的 SDK 签名会变你按自己用的那套填空就行接口边界就是传入 env拿回 sandbox_id。密钥从哪来在 TaoToken 控制台 创建创建完立刻写入你的密钥管理系统元数据库里只留vault_path和key_hint。整套流程的入口在 TaoToken 官网第一次接入建议先用最小沙箱验证连通性再上生产 Runner。5. Claude Code 在沙箱内的 settings.json 配置沙箱里跑 Claude Code 时配置优先走settings.json而不是每次靠 shell 变量临时拼。放在项目级.claude/settings.json或用户级~/.claude/settings.json都行项目级更适合沙箱因为快照里可以预先放一份模板{ env: { ANTHROPIC_BASE_URL: https://taotoken.net/api, ANTHROPIC_AUTH_TOKEN: YOUR_API_KEY, ANTHROPIC_MODEL: 在控制台模型列表中选择, ANTHROPIC_SMALL_FAST_MODEL: 在控制台模型列表中选择 } }三点说明ANTHROPIC_BASE_URL必须是不带尾斜杠的完整地址https://taotoken.net/api多一个/v1或少一个/api都会 404。ANTHROPIC_AUTH_TOKEN与ANTHROPIC_API_KEY是两个不同的变量Claude Code 读的是前者写错变量名不会报错只会静默不生效排查时很容易被误导。模型 ID 请以控制台模型列表里的实际标识为准不要照抄别人的配置——模型上下线是动态的。如果想避免把 Key 写进快照可以让settings.json只放非敏感字段敏感字段仍由沙箱环境变量注入Claude Code 会优先采用已存在的环境变量{ env: { ANTHROPIC_BASE_URL: https://taotoken.net/api, ANTHROPIC_MODEL: 在控制台模型列表中选择 } }这样一来快照可以安全共享Key 只活在运行中的沙箱进程里。验证配置是否生效在沙箱内执行一句环境检查即可env | grep -E ^ANTHROPIC_(BASE_URL|AUTH_TOKEN)$ | sed s/\(TOKEN\).*/\1****/6. Codex 在沙箱内的 config.toml 配置不要混用 ANTHROPIC_*这是最常见的翻车点把 Claude Code 的环境变量原样套到 Codex 上然后发现怎么都不生效。Codex 读的是~/.codex/config.toml变量体系完全不同它不会去看ANTHROPIC_*。正确写法是走 provider 配置# 沙箱内 ~/.codex/config.toml model 在控制台模型列表中选择 model_provider taotoken [model_providers.taotoken] name TaoToken base_url https://taotoken.net/api env_key TAOTOKEN_API_KEY wire_api chat关键点在于env_key指定的是环境变量名不是 Key 本身。也就是说配置文件可以进快照Key 仍然由运行时注入# 沙箱启动命令里带上这一条即可 -e TAOTOKEN_API_KEY$TAOTOKEN_API_KEY如果你更希望把 Key 直接写在配置里仅限本地临时调试把env_key换成对应的内联字段即可但请在提交快照前删掉。生产环境请坚持配置进快照、密钥运行时注入这条线。两者的对照关系整理成一张表贴在团队 wiki 里能省很多沟通成本工具配置文件Base URL 变量认证变量Claude Code.claude/settings.jsonANTHROPIC_BASE_URLANTHROPIC_AUTH_TOKENCodex~/.codex/config.tomlbase_url字段env_key指向的变量7. CC Switch 三件套让沙箱配置可复现、可切换沙箱环境最大的痛点不是配一次而是每次配得都一样。用 CC Switch 这类配置切换工具管理时落到沙箱里其实就是三样东西我习惯叫它三件套第一件供应商描述。一条记录说清楚 provider、base_url、以及 Key 的引用名不包含 Key 值本身[provider.taotoken] name TaoToken base_url https://taotoken.net/api env_key TAOTOKEN_API_KEY第二件模型描述。也就是当前任务用哪个模型、以及小模型走哪个[profile.default] model 在控制台模型列表中选择 small_model 在控制台模型列表中选择 wire_api chat第三件落地模板。一份不含明文的.env.template负责把上面两项渲染成沙箱实际能读到的文件# .env.template —— 进快照不含任何真实 Key TAOTOKEN_BASE_URLhttps://taotoken.net/api TAOTOKEN_API_KEY__INJECT_AT_RUNTIME__渲染脚本把占位符替换成运行时注入的真实值再分别写出settings.json和config.toml。这样切换供应商时只需要改第一件重建快照这一个动作就能让所有新沙箱生效运行中的沙箱不受影响。需要强调一点三件套里任何一个文件都不要出现明文 Key。我在评审中见过把 Key 写进.env再打进快照的做法快照共享出去就等于 Key 共享出去而且因为快照是只读的Key 轮换时你还得重建全部快照运维成本反而更高。8. 排障模型调用失败时用 SQL 反查沙箱元数据沙箱内模型调用失败症状往往只有一句请求失败信息量极低。这时候真正的线索在元数据里。下面按四个高频故障给排查路径。故障一401 / 403认证失败。先确认沙箱状态和归属再确认凭证引用是否有效SELECT s.id, s.state, s.tenant_id, s.last_activity_at, s.auto_stop_sec FROM sbx_sandbox s WHERE s.task_id your-task-id AND s.state deleted; SELECT id, provider, vault_path, key_hint, expires_at, revoked_at FROM sbx_credential_ref WHERE tenant_id 12345 AND provider taotoken ORDER BY created_at DESC;如果revoked_at非空或expires_at已过期说明不是注入环节的问题而是凭证本身失效了去 TaoToken 控制台 重新签发即可。如果引用正常则进沙箱确认变量是否存在重点看变量名是不是写成了ANTHROPIC_API_KEY这种不生效的写法。故障二连接超时。九成是网络白名单没放行taotoken.net。沙箱默认禁止任意出站访问是正确设计但模型出口必须显式放行否则请求会在 TCP 层就挂掉日志里通常只有超时没有响应码。故障三任务跑到十几分钟被中断。这就是第 2 节埋的那个坑自动停止策略判定的活跃只看外部交互沙箱内部长时间跑的后台推理或数据处理不算外部交互任务会被中途停掉。三种处理方式-- 方式一该任务禁用自动停止 UPDATE sbx_sandbox SET auto_stop_sec 0 WHERE id $1; -- 方式二保留策略让 Agent 定时心跳 -- 由沙箱内的保活线程每隔 N 秒执行一次 UPDATE sbx_sandbox SET last_activity_at now() WHERE id $1 AND state started;方式三是在创建沙箱时就把auto_stop_sec设成 0 并配合独立的资源配额适合明确知道是长任务的场景。心跳方式更通用但要注意心跳线程挂掉时会退化成方式一的失效状态。故障四快照里疑似带了 Key。用事件表回溯快照的构建和使用记录SELECT e.sandbox_id, e.event, e.detail, e.occurred_at FROM sbx_event e JOIN sbx_sandbox s ON s.id e.sandbox_id WHERE s.snapshot_id your-snapshot-uuid ORDER BY e.occurred_at DESC LIMIT 50;确认快照本身干净之后把排查范围收敛到运行时注入环节比重扫全部镜像快得多。日常巡检再加一个僵尸沙箱查询把长时间没心跳又没被回收的环境找出来SELECT id, tenant_id, EXTRACT(EPOCH FROM (now() - last_activity_at))::NUMERIC AS idle_sec FROM sbx_sandbox WHERE state started AND auto_stop_sec 0 AND last_activity_at now() - make_interval(secs auto_stop_sec * 2);9. 上线检查清单与下一步把前面几节压缩成一份可勾选的清单上线前逐条过一遍元数据库独立部署不与业务库混用且沙箱进程没有任何数据库连接串sbx_credential_ref只存vault_path与key_hint明文 Key 一律进密钥管理系统镜像与快照中不存在TAOTOKEN_API_KEY的真实值构建期也不传--build-arg沙箱出站策略为白名单模式只放行模型出口域名Claude Code 与 Codex 的配置分别落在settings.json和config.toml变量体系不混用长任务要么把auto_stop_sec设为 0要么接入心跳更新二者必须选一个事件表写入覆盖全部状态流转保留期与合规要求对齐。这些做完之后你会得到一个很舒服的状态沙箱是可枚举的、可回收的、可审计的模型凭证是可轮换的、可吊销的、与沙箱生命周期绑定的。Agent 在沙箱里做什么你不需要靠日志猜一条 SQL 就能还原。如果你还没开始接入建议按这个顺序走先在 模型对话 里验证一次调用是否通畅然后是 Coding Plan 了解额度与并发安排接着到 创建 API Key 把凭证准备好最后对照 Claude Code 接入文档 把settings.json落到你的沙箱模板里。整条链路跑通之后再回头补元数据表和回收任务成本最低。沙箱解决的是在哪跑凭证治理解决的是以什么身份跑。这两件事在 AI Agent 的生产化路径上缺一不可而它们的交汇点恰好就是一张 PostgreSQL 元数据表加上一次运行时的环境变量注入。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进