1. 为什么我把 TaoToken 接进 Daytona 的 NestJS 控制平面
我在 NestJS 控制平面里统一模型出口:先到 TaoToken 官网 领取 Key,再把 Base URL 固定为https://taotoken.net/api。这样 Agent 只认控制平面下发的任务,模型侧不再四处填地址。过去一段时间,很多团队把 Daytona 当作临时隔离执行底座:Agent 生成一段脚本,控制平面拉起 Sandbox,执行、收集日志、销毁环境。但真正上线后会发现,沙箱解决的是“代码在哪里跑”,模型调用解决的是“ Agent 怎么想、怎么迭代、怎么决定下一步”。如果模型入口散落在各个 Node 进程、CI 变量、本地配置文件里,后面排障会非常痛苦:一次 401 要查五个地方,一次模型名变更要重新发版,一次额度切换要把多个服务重启。
Daytona 控制平面本身用 NestJS REST API 暴露沙箱生命周期、快照、调度等能力,这对后端平台工程师很友好:Controller、Service、Guard、Interceptor、ConfigModule 都是熟悉的味道。我的做法不是改 Daytona 控制平面源码,而是在它旁边加一层“Agent Gateway”。这层 Gateway 仍然用 NestJS 写,负责三件事:
- 接收 Agent 任务,校验任务 ID、语言、超时、网络策略。
- 调 Daytona 创建隔离沙箱,执行命令或脚本,回收沙箱。
- 在需要模型推理时,通过统一的 TaoToken Base URL 调用模型,并把结果写回任务上下文。
这里的关键点是:沙箱要隔离,模型入口也要收口。TaoToken 在这个架构里承担的是模型 API 的统一入口,Base URL 固定为https://taotoken.net/api,Key 使用占位符YOUR_API_KEY。实际落地时,你可以把它放进 NestJS 的环境变量、Claude Code 的settings.json、Codex 的config.toml,或者 CC Switch 的配置项里。下面我从后端平台工程师的视角,把可复现的环境变量、NestJS 服务代码、沙箱调度接口和常见报错一次讲清楚。
2. NestJS 环境变量:TaoToken Base URL 与 Daytona Key 分离
先把配置边界划清楚。很多事故不是代码写错,而是把不同系统的 Key 混在一个变量里:Daytona 的 API Key 用来调度沙箱,TaoToken 的 Key 用来调模型,两者权限、配额、审计维度完全不同。我的.env结构如下,Key 全部用占位符,不要提交真实值到仓库。
# TaoToken 模型入口 TAOTOKEN_BASE_URL=https://taotoken.net/api TAOTOKEN_API_KEY=YOUR_API_KEY # Daytona 控制平面 DAYTONA_API_URL=https://app.daytona.io/api DAYTONA_API_KEY=YOUR_DAYTONA_API_KEY DAYTONA_CREATE_PATH=/sandbox DAYTONA_EXEC_PATH=/sandbox/{id}/toolbox/process/execute # NestJS 基础设施 REDIS_URL=redis://127.0.0.1:6379 DATABASE_URL=postgresql://user:pass@127.0.0.1:5432/agent_gateway PORT=3000DAYTONA_CREATE_PATH和DAYTONA_EXEC_PATH不要硬编码在 Service 里。Daytona 控制平面版本升级、部署形态变化时,路径可能不同;把它们放到环境变量,至少不会因为一次接口调整就把整个服务重新编译。如果你在托管云上验证原型,可以先到 TaoToken 官网 领取 Key,再把TAOTOKEN_BASE_URL写成https://taotoken.net/api。注意,Base URL 后面不要自己乱加/v1或/chat/completions,具体路径交给 SDK 或你的客户端适配层处理。
NestJS 里我建议用@nestjs/config配合 Joi 做启动时校验。配置缺失时直接启动失败,比运行到一半抛 401 更容易定位。
// src/config/env.validation.ts import * as Joi from 'joi'; export const envValidationSchema = Joi.object({ NODE_ENV: Joi.string().valid('development', 'test', 'production').default('development'), PORT: Joi.number().port().default(3000), TAOTOKEN_BASE_URL: Joi.string().uri().required(), TAOTOKEN_API_KEY: Joi.string().min(8).required(), DAYTONA_API_URL: Joi.string().uri().required(), DAYTONA_API_KEY: Joi.string().min(8).required(), DAYTONA_CREATE_PATH: Joi.string().required(), DAYTONA_EXEC_PATH: Joi.string().required(), REDIS_URL: Joi.string().uri().required(), DATABASE_URL: Joi.string().uri().required(), });// src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { envValidationSchema } from './config/env.validation'; import { SandboxModule } from './sandbox/sandbox.module'; import { ModelModule } from './model/model.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: ['.env.local', '.env'], validationSchema: envValidationSchema, validationOptions: { abortEarly: false }, }), ModelModule, SandboxModule, ], }) export class AppModule {}这里有一个实践细节:不要把TAOTOKEN_API_KEY注入到沙箱内部。沙箱里跑的是模型生成的代码,理论上不可信;模型 Key 只应该存在于控制平面的进程环境。沙箱需要模型能力时,由控制平面代理调用,再把结果写回沙箱文件或返回给 Agent。这样即使沙箱内代码尝试读取环境变量,也拿不到模型入口 Key。
3. 控制平面模型客户端:从 NestJS 到 TaoToken 的可复制配置
模型调用统一封装成ModelClientService,业务 Service 只依赖它,不直接实例化 OpenAI、Anthropic 或其他 SDK。这样切换供应商、调整 Base URL、增加超时和重试,都只改一个地方。下面用 OpenAI 兼容客户端举例,Base URL 读取TAOTOKEN_BASE_URL,Key 读取TAOTOKEN_API_KEY。
// src/model/model-client.service.ts import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import OpenAI from 'openai'; export type ChatMessage = { role: 'system' | 'user' | 'assistant'; content: string; }; @Injectable() export class ModelClientService { private readonly logger = new Logger(ModelClientService.name); private readonly client: OpenAI; constructor(private readonly config: ConfigService) { this.client = new OpenAI({ apiKey: this.config.getOrThrow<string>('TAOTOKEN_API_KEY'), baseURL: this.config.getOrThrow<string>('TAOTOKEN_BASE_URL'), timeout: 60_000, maxRetries: 2, }); } async chat(model: string, messages: ChatMessage[]) { this.logger.debug(`model=${model} baseURL=${this.config.get('TAOTOKEN_BASE_URL')}`); const completion = await this.client.chat.completions.create({ model, messages, temperature: 0.2, }); const content = completion.choices[0]?.message?.content; if (!content) { throw new Error('TaoToken returned empty completion'); } return content; } }如果你的 NestJS 服务使用原生fetch而不是 OpenAI SDK,建议也封装成适配器,不要在每个 Controller 里手写 URL。TaoToken 的 Base URL 是https://taotoken.net/api,客户端负责拼接后续路径;手动拼接时最容易出现/api/v1/v1/chat/completions这类重复路径。遇到 404 时,先打印最终请求 URL,再检查是不是自己多拼了一层。
模型名也不要写死在代码里。Agent 任务有快任务、慢任务、代码审查、摘要生成,可能对应不同模型。可以在 DTO 里传model,或者由控制平面根据任务类型路由。为了避免把未核实的信息写进配置,示例里用model: string,实际值从你的 TaoToken 控制台或 Coding Plan 页面确认。
如果你在本地调试 Claude Code,settings.json可以这样配:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_AUTH_TOKEN": "YOUR_API_KEY" } }注意,这是 Claude Code 的配置方式,变量前缀是ANTHROPIC_*。不要把这套变量名复制到 Codex。Codex 使用config.toml和它自己的 provider 配置,下一节单独说。
4. Claude Code、Codex、CC Switch 三件套的配置差异
后端平台工程师经常同时维护几套客户端:Claude Code 用来跑终端里的代码任务,Codex 用来做另一种交互,CC Switch 用来在多个供应商配置之间切换。三者配置格式不同,最容易出错的地方就是“把 Claude 的环境变量套到 Codex 上”。下面分开写。
Claude Code 侧,核心是settings.json里的env:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_AUTH_TOKEN": "YOUR_API_KEY" } }如果你的 Claude Code 版本还支持其他 Anthropic 兼容变量,也保持同一原则:Base URL 指向 TaoToken,鉴权使用 TaoToken Key。不要把 Daytona 的 Key 填进去。
Codex 侧,使用config.toml。下面是一个 provider 配置示例,env_key指向TAOTOKEN_API_KEY,再由 shell 环境提供实际值:
model = "你的模型名" model_provider = "taotoken" [model_providers.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_API_KEY" wire_api = "chat"对应地在终端里导出 Key:
export TAOTOKEN_API_KEY=YOUR_API_KEY如果你在 Windows PowerShell 里调试,可以用:
$env:TAOTOKEN_API_KEY="YOUR_API_KEY"再说 CC Switch 三件套。这里的“三件套”我理解为在 CC Switch 里新增一个供应商配置时需要填的三项核心信息:
- 供应商名称:TaoToken。
- Base URL:
https://taotoken.net/api。 - API Key:
YOUR_API_KEY。
填完后,在 Claude Code、Codex 或对应客户端之间切换时,让 CC Switch 负责覆盖配置文件。这样你不需要手动改settings.json和config.toml。但要注意:切换后回到客户端确认最终生效的 Base URL,因为有些客户端会合并多层配置,旧的环境变量可能优先级更高。
5. 沙箱调度接口:NestJS Controller 与 Daytona Adapter 示例
现在进入控制平面核心。我的 NestJS 服务对外暴露一个内部接口,Agent 不直接调 Daytona,而是把任务提交给控制平面。接口设计如下:
POST /internal/agent/sandbox/run Content-Type: application/json { "taskId": "task_20250101_001", "language": "python", "code": "print('hello from sandbox')", "timeoutMs": 30000, "allowNetwork": false, "model": "你的模型名" }Controller 只做参数校验和调用 Service:
// src/sandbox/dto/run-sandbox.dto.ts import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min, } from 'class-validator'; export class RunSandboxDto { @IsString() taskId!: string; @IsIn(['python', 'node', 'bash']) language!: 'python' | 'node' | 'bash'; @IsString() code!: string; @IsInt() @Min(1000) @Max(120000) timeoutMs = 30000; @IsBoolean() allowNetwork = false; @IsString() @IsOptional() model?: string; }// src/sandbox/sandbox.controller.ts import { Body, Controller, Post } from '@nestjs/common'; import { RunSandboxDto } from './dto/run-sandbox.dto'; import { SandboxService } from './sandbox.service'; @Controller('internal/agent/sandbox') export class SandboxController { constructor(private readonly sandboxService: SandboxService) {} @Post('run') async run(@Body() dto: RunSandboxDto) { return this.sandboxService.runOnce(dto); } }SandboxService负责幂等锁、创建沙箱、执行、模型总结、销毁:
// src/sandbox/sandbox.service.ts import { ConflictException, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import Redis from 'ioredis'; import { DaytonaAdapter } from './daytona.adapter'; import { ModelClientService } from '../model/model-client.service'; import { RunSandboxDto } from './dto/run-sandbox.dto'; @Injectable() export class SandboxService { private readonly redis: Redis; constructor( private readonly config: ConfigService, private readonly daytona: DaytonaAdapter, private readonly model: ModelClientService, ) { this.redis = new Redis(this.config.getOrThrow<string>('REDIS_URL')); } async runOnce(dto: RunSandboxDto) { const lockKey = `sandbox:task:${dto.taskId}`; const locked = await this.redis.set(lockKey, '1', 'NX', 'PX', 120000); if (!locked) { throw new ConflictException(`task ${dto.taskId} is already running`); } const sandbox = await this.daytona.create({ autoStopInterval: 0, allowNetwork: dto.allowNetwork, }); try { const execution = await this.daytona.exec({ sandboxId: sandbox.id, language: dto.language, code: dto.code, timeoutMs: dto.timeoutMs, }); const summary = await this.model.chat(dto.model ?? '你的模型名', [ { role: 'system', content: '你是代码执行结果分析器,只根据日志判断成功、失败原因和下一步建议。', }, { role: 'user', content: `任务ID: ${dto.taskId}\n语言: ${dto.language}\n退出码: ${execution.exitCode}\n标准输出:\n${execution.stdout}\n标准错误:\n${execution.stderr}`, }, ]); return { taskId: dto.taskId, sandboxId: sandbox.id, exitCode: execution.exitCode, stdout: execution.stdout, stderr: execution.stderr, summary, }; } finally { await this.daytona.destroy(sandbox.id); await this.redis.del(lockKey); } } }DaytonaAdapter把控制平面 HTTP 细节隔离起来。下面示例中的路径来自环境变量,避免把控制平面路由写死:
// src/sandbox/daytona.adapter.ts import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; type CreateSandboxOptions = { autoStopInterval: number; allowNetwork: boolean; }; type ExecOptions = { sandboxId: string; language: string; code: string; timeoutMs: number; }; @Injectable() export class DaytonaAdapter { constructor(private readonly config: ConfigService) {} private get baseUrl() { return this.config.getOrThrow<string>('DAYTONA_API_URL'); } private get headers() { return { Authorization: `Bearer ${this.config.getOrThrow<string>('DAYTONA_API_KEY')}`, 'Content-Type': 'application/json', }; } async create(options: CreateSandboxOptions): Promise<{ id: string }> { const path = this.config.getOrThrow<string>('DAYTONA_CREATE_PATH'); const response = await fetch(`${this.baseUrl}${path}`, { method: 'POST', headers: this.headers, body: JSON.stringify({ auto_stop_interval: options.autoStopInterval, network: options.allowNetwork ? 'allow' : 'deny', }), }); if (!response.ok) { throw new InternalServerErrorException(await response.text()); } return response.json() as Promise<{ id: string }>; } async exec(options: ExecOptions): Promise<{ exitCode: number; stdout: string; stderr: string; }> { const template = this.config.getOrThrow<string>('DAYTONA_EXEC_PATH'); const path = template.replace('{id}', encodeURIComponent(options.sandboxId)); const response = await fetch(`${this.baseUrl}${path}`, { method: 'POST', headers: this.headers, body: JSON.stringify({ language: options.language, code: options.code, timeout: options.timeoutMs, }), }); if (!response.ok) { throw new InternalServerErrorException(await response.text()); } return response.json() as Promise<{ exitCode: number; stdout: string; stderr: string; }>; } async destroy(sandboxId: string): Promise<void> { const path = `/sandbox/${encodeURIComponent(sandboxId)}`; await fetch(`${this.baseUrl}${path}`, { method: 'DELETE', headers: this.headers, }); } }这段代码的重点不是“复制即可连接所有 Daytona 版本”,而是示范控制平面应该如何分层:Controller 管输入,Service 管编排,Adapter 管外部系统,ModelClientService 管模型入口。Daytona 的具体 REST 路径请以你部署的控制平面版本为准,放到环境变量里就能减少升级摩擦。
6. 生命周期、自动停止与网络白名单的踩坑清单
Daytona 沙箱有几个工程细节,后端平台工程师必须写进控制平面,而不是靠 Agent 自觉。
第一,默认自动停止策略。沙箱如果一段时间没有外部交互,可能被自动停止以节省资源。但“不活跃”的判定通常不包含沙箱内部后台进程。如果你的任务是长时推理、批量数据处理、模型评测,沙箱内部还在跑,但外部没有请求,就可能被中途停掉。我的做法是在创建沙箱时把auto_stop_interval设为0,或者在任务执行期间定时发送心跳。上面的DaytonaAdapter.create已经把它作为参数暴露出来。
第二,Stop、Pause、Archive 的语义不同。Stop 更接近关机,文件系统保留,内存清空;Pause 会把内存状态一起保存,适合需要保留运行上下文的长任务;Archive 把文件系统快照放到对象存储,适合长期不活跃但需要保留的环境。控制平面不要只会创建和删除,应该把这些状态操作封装成明确的任务动作。
第三,快照要版本化。把预装依赖、运行时、工具链固化到 Snapshot,后续任务基于快照启动,才能保证每次执行环境一致。否则 Agent 每次临时pip install、npm install,既慢又不可复现。快照名称里带上语言、依赖锁文件哈希、创建时间,控制平面路由时按任务需求选择。
第四,网络白名单默认收紧。Agent 生成的代码可能对外发起请求。控制平面默认应该禁止外网,只在明确需要时开启白名单。RunSandboxDto.allowNetwork默认false,需要下载依赖时走内部镜像或预构建快照,而不是直接放开全部出口。
第五,日志要流式采集。沙箱执行可能超时、死循环、输出大量日志。控制平面至少要有最大输出限制、超时强杀、退出码记录。否则一个无限循环就能把网关内存打满。
第六,不要把生产数据库连接串、云厂商主账号 Key、内部管理 Token 注入沙箱。模型生成的代码在隔离环境里执行,但隔离不是绝对安全。涉及 SQL、DDL、迁移、删除数据的命令,请由你在本地终端手动执行,不要让 Agent 在沙箱里直连生产数据库。控制平面只返回执行日志和分析结果。
7. 排障:401、404、超时与 Base URL 拼接错误
接入 TaoToken 和 Daytona 后,最常见的报错可以分成几类。
401 或 403:先区分是 TaoToken 还是 Daytona。模型调用返回 401,检查TAOTOKEN_API_KEY是否已导出、是否有多余空格、是否误用了 Daytona Key。Claude Code 的ANTHROPIC_AUTH_TOKEN、Codex 的TAOTOKEN_API_KEY、NestJS 的TAOTOKEN_API_KEY应该是同一个 TaoToken Key,但不要和DAYTONA_API_KEY混用。
404:模型调用 404 通常是 Base URL 或路径拼接问题。TaoToken 的 Base URL 是https://taotoken.net/api,使用 OpenAI SDK 时让 SDK 自己拼路径;手动fetch时先打印最终 URL。如果你看到/api/v1/v1/...,就是重复拼接。Daytona 调用 404 则检查DAYTONA_API_URL和DAYTONA_CREATE_PATH是否匹配当前控制平面版本。
超时:模型调用超时和沙箱执行超时要分开设。ModelClientService里可以设 60 秒超时,沙箱执行按任务设 30 秒到 120 秒。不要用一个全局超时覆盖两者,否则模型还在生成,沙箱已经被销毁。
模型不存在:如果返回模型未找到,先去模型对话页确认当前 Key 可用的模型名,再写进 DTO 或配置。不要把控制台里看到的展示名称直接当成 API 模型 ID。
配置不生效:Claude Code 检查settings.json的层级和 JSON 语法;Codex 检查config.toml的 provider 名是否和model_provider一致;CC Switch 切换后重新打开客户端。NestJS 侧可以用启动日志打印非敏感配置,例如TAOTOKEN_BASE_URL和DAYTONA_API_URL,但不要打印 Key。
沙箱提前停止:检查auto_stop_interval是否为 0,任务期间是否发心跳,是否被其他清理任务误删。控制平面的幂等锁也要设过期时间,避免任务崩溃后锁不释放。
8. CTA:从模型对话到 Claude Code 文档的落地路径
如果你准备把这条链路跑起来,我建议按下面顺序验证,不要一上来就改控制平面代码。
第一步,先在 模型对话 里确认 Key 和模型可用。这个页面适合做最小验证:能对话,说明 Base URL 和 Key 没问题。
第二步,根据你的使用强度看 Coding Plan。如果你只是本地调试 Claude Code、Codex,可以先小规模验证;如果要把 NestJS 控制平面接入生产 Agent 工作流,再按并发和任务量评估。
第三步,到 API Keys 创建或管理 Key。把 Key 放进 NestJS 的TAOTOKEN_API_KEY,不要写进代码仓库,也不要注入沙箱。
第四步,按 Claude Code 文档 配置settings.json。如果你同时用 Codex,再单独写config.toml,不要把ANTHROPIC_*变量套过去。CC Switch 里则把供应商名称、Base URL、API Key 三件套填好,作为多配置切换入口。
最后再回到 NestJS 控制平面:把TAOTOKEN_BASE_URL设为https://taotoken.net/api,把TAOTOKEN_API_KEY设为YOUR_API_KEY,把 Daytona 调度 Key 单独放好。启动服务后,先用一条最小沙箱任务验证创建、执行、销毁,再验证模型总结。整套链路跑通后,你会发现 Agent 的模型入口和沙箱执行入口都被收进了同一个 NestJS 控制平面,排障、审计、扩容都会清晰很多。需要统一管理入口时,可以从 TaoToken 官网 开始,把 Key、Base URL 和客户端配置一次理顺。