1. claw 工程化构建到底难在哪:vite pnpm rolldown tsdown 四件套的真实协作
claw 这个项目(也就是 OpenClaw 的 control-ui 部分)在构建阶段会同时跑 Vite、rolldown、tsdown 三套打包器,外加 pnpm workspace 管理 176 个子项目。如果你第一次拉下代码直接pnpm build,大概率会在终端里看到几百行输出,然后卡在某个 tsdown 子进程上等好几分钟。这不是配置写错了,而是这套工程化方案本身的设计取舍:Vite 负责浏览器端 UI 的快速构建,rolldown 负责插件运行时和轻量 bundle,tsdown 负责把 TypeScript 源码编译成带.d.ts声明的产物。
先说清楚这三个工具各自干什么。Vite 你大概率用过,开发时用 esbuild 预构建依赖,生产构建默认走 Rollup。但 claw 里 Vite 8 已经把底层换成了 rolldown,所以你在构建日志里会看到[plugin rolldown:vite-resolve]这样的前缀。rolldown 是 Rust 写的打包器,速度比 Rollup 快一个量级,claw 用它来处理a2ui.bundle.js这类独立运行时产物,日志里rolldown v1.2.0 Finished in 278.49 ms就是它的输出。tsdown 则是基于 rolldown 的 TypeScript 声明文件生成工具,专门解决tsc --emitDeclarationOnly太慢的问题,claw 用它给 148 个 plugin-sdk 子路径生成类型声明。
pnpm workspace 在这里的角色是依赖隔离和脚本编排。176 个 workspace 项目意味着你不能用 npm 的扁平 node_modules,否则依赖提升会导致幽灵依赖问题。pnpm 的符号链接结构让每个子包只能访问自己声明的依赖,这在多包 monorepo 里是刚需。pnpm -r build会按拓扑顺序递归执行每个包的 build 脚本,claw 的build-all.mjs就是在这个基础上做了更细的阶段划分。
ghostty 终端环境在这个流程里不是必须的,但 claw 的 control-ui 里有一个ghostty-web模块,构建产物ghostty-web-DFkqngpx.js有 636.95 kB(gzip 后 187.36 kB),是最大的单个 JS 文件。如果你在 ghostty 里跑pnpm dev,终端渲染性能会比普通终端好一些,因为 ghostty 支持 GPU 加速和更快的滚动缓冲。但构建阶段本身跟终端无关,只是调试时体验差异。
这套组合的痛点在于:三个打包器的配置格式不统一。Vite 用vite.config.ts,rolldown 用rolldown.config.ts或直接 API 调用,tsdown 用tsdown.config.ts。claw 的解决方案是在根目录放一个build-all.mjs脚本,按阶段串行调用,每个阶段独立配置。这样虽然总耗时 11 分 54 秒(日志里total 11m 54.2s),但每个阶段可以单独重跑,调试时不用全量构建。
我试过把 tsdown 阶段并行化,结果内存直接爆了。tsdown 的deps插件占 60% 时间,rolldown-plugin-dts:generate占 26%,这两个都是 CPU 密集型,并行跑 9 个 invocation 时(日志里tsdown-unified阶段)单个进程要等 30 秒以上才有输出。所以 claw 的串行设计是有道理的,不是没优化,是优化过了发现并行不划算。
2. TaoToken 前置:统一 Key 通道怎么接进 claw 的构建验证流程
claw 的构建产物最终要跑起来验证,验证时如果涉及模型调用(比如 control-ui 里的 model-providers-page 或 chat-page),就需要一个统一的 API 通道。TaoToken 在这里的作用是提供兼容 OpenAI 格式的接口,让你不用在代码里硬编码多个厂商的 Key。官网入口是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=,API 端点固定为 https://taotoken.net/api。
为什么构建验证阶段需要这个?因为 claw 的 control-ui 里有model-auth-BW81fSVK.js和model-setup-page这些模块,它们会在运行时读取环境变量或配置文件里的 API Key。如果你只是构建不运行,确实不需要。但端到端验证要求你启动 dev server 后能实际发一次请求,确认模型通道是通的。这时候如果没有统一 Key,你就得在.env里填一堆厂商的 Key,切换模型时还要改代码。
TaoToken 的接入方式跟 OpenAI SDK 兼容,所以 claw 里任何用openai包的地方都可以直接改baseURL。具体来说,你需要在项目根目录创建一个.env.local文件(Vite 会自动加载),内容如下:
VITE_TAOTOKEN_API_KEY=sk-your-key-here VITE_TAOTOKEN_BASE_URL=https://taotoken.net/api然后在需要调用模型的地方这样初始化:
import OpenAI from 'openai'; const client = new OpenAI({ apiKey: import.meta.env.VITE_TAOTOKEN_API_KEY, baseURL: import.meta.env.VITE_TAOTOKEN_BASE_URL, dangerouslyAllowBrowser: true, // 仅开发环境,生产环境走服务端代理 });注意dangerouslyAllowBrowser这个参数,claw 的 control-ui 是浏览器端代码,Vite 构建时会把它打包进chat-page或model-providers-page。生产环境不应该把 Key 暴露在浏览器里,所以 claw 的做法是通过 gateway 服务端转发。但本地调试时为了快速验证,可以临时开启。
如果你用的是 Claude Code 或 Cline 这类工具来辅助开发 claw,它们的配置方式略有不同。Claude Code 需要在~/.claude/settings.json里配置:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_API_KEY": "sk-your-key-here" } }Cline 的 MCP 配置则在 VS Code 的settings.json里:
{ "cline.mcpServers": { "taotoken": { "command": "npx", "args": ["-y", "@taotoken/mcp-server"], "env": { "TAOTOKEN_API_KEY": "sk-your-key-here", "TAOTOKEN_BASE_URL": "https://taotoken.net/api" } } } }Codex 的auth.json配置在~/.codex/auth.json:
{ "api_key": "sk-your-key-here", "base_url": "https://taotoken.net/api" }这三件套(Base URL + Key + Model ID)在 claw 的构建验证里只需要配一次,之后切换模型只改 Model ID 就行。Model ID 的取值取决于你要验证哪个模块,比如gpt-4o、claude-3-5-sonnet-20241022这些。TaoToken 的模型列表可以在模型对话页面查看,地址是 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=。
需要提醒的是,构建阶段本身不消耗 API 额度,只有你启动 dev server 后实际发请求才会。所以你可以先把构建跑通,再配 Key 做端到端验证。这样排查问题时能分清是构建配置错了还是 Key 配错了。
3. 可复制配置:pnpm workspace + Vite + rolldown + tsdown 完整片段
claw 的工程化配置分散在多个文件里,我把它整理成可以直接复制的版本。先看根目录的pnpm-workspace.yaml:
packages: - 'packages/*' - 'apps/*' - 'plugins/*' - 'tools/*'这个配置决定了 pnpm 扫描哪些目录作为 workspace 项目。claw 的 176 个项目分布在packages/(核心库)、apps/(可执行应用)、plugins/(插件)、tools/(构建工具)四个目录下。如果你只关心 control-ui,可以缩小范围到apps/control-ui,但那样会丢失跨包依赖的符号链接。
根目录package.json的 scripts 部分:
{ "scripts": { "build": "node scripts/build-all.mjs", "build:ui": "node scripts/ui.js build", "build:tsdown": "tsdown --config tsdown.config.ts", "dev": "vite --config apps/control-ui/vite.config.ts", "check:perf": "node scripts/check-control-ui-performance.mjs" } }build-all.mjs是总入口,它按阶段调用各个子构建。你可以单独跑pnpm build:ui只构建 UI 部分,耗时约 7.71 秒(日志里ui:build done in 7.71s)。build:tsdown单独跑的话,tsdown-ai阶段约 16.3 秒,tsdown-packages约 1 分 46.5 秒,tsdown-unified约 8 分 29.8 秒。
Vite 配置在apps/control-ui/vite.config.ts:
import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], build: { outDir: '../../dist/control-ui', rollupOptions: { output: { manualChunks: { 'ghostty-web': ['./src/terminal/ghostty-web.ts'], 'control-ui-core': ['./src/core/index.ts'], }, }, }, terserOptions: { compress: { drop_console: true, }, }, }, resolve: { alias: { '@': './src', }, }, });注意manualChunks里把ghostty-web单独拆出来了,这就是为什么构建产物里ghostty-web-DFkqngpx.js是独立文件。如果不拆,它会跟chat-page合并,导致首屏加载变慢。日志里startup JS: 10 requests, 292.5 KiB gzip这个指标就是靠 manualChunks 控制的。
rolldown 配置在rolldown.config.ts:
import { defineConfig } from 'rolldown'; export default defineConfig({ input: 'packages/a2ui/src/index.ts', output: { file: 'dist/a2ui.bundle.js', format: 'esm', }, plugins: [], });claw 里 rolldown 主要用来打包a2ui.bundle.js,日志显示chunk │ size: 398.62 kB,Finished in 278.49 ms。这个速度比 Rollup 快很多,因为 rolldown 用 Rust 重写了核心逻辑。
tsdown 配置在tsdown.config.ts:
import { defineConfig } from 'tsdown'; export default defineConfig({ entry: ['packages/plugin-sdk/src/index.ts'], outDir: 'dist/plugin-sdk', dts: true, clean: true, deps: { neverBundle: ['node:*'], }, });deps.neverBundle这个配置很关键,它告诉 tsdown 不要把node:path、node:fs这些内置模块打包进去。日志里那个Module "node:path" has been externalized for browser compatibility的警告就是因为 Vite 端没有正确 externalize,但 tsdown 端配了neverBundle所以没问题。
ghostty 终端下的调试配置,你需要在apps/control-ui/src/terminal/ghostty-web.ts里这样初始化:
import { Terminal } from '@xterm/xterm'; import { WebglAddon } from '@xterm/addon-webgl'; export function createGhosttyTerminal(container: HTMLElement) { const term = new Terminal({ fontFamily: 'JetBrains Mono, monospace', fontSize: 14, theme: { background: '#1a1b26', foreground: '#c0caf5', }, }); term.loadAddon(new WebglAddon()); term.open(container); return term; }WebglAddon 在 ghostty 里能启用 GPU 加速渲染,普通终端下会回退到 canvas 渲染。这个差异在大量日志输出时很明显,ghostty 里滚动不掉帧。
4. 验证请求与成功结果:从 pnpm build 到端到端跑通
构建验证分三步:先确认构建产物完整,再启动 dev server,最后发一次模型请求确认通道通。第一步跑pnpm build,完整输出会很长,你只需要关注几个关键指标。日志里✓ 2969 modules transformed表示 Vite 转换了 2969 个模块,✓ built in 12.59s是首次构建耗时,二次构建因为缓存会降到 5.98 秒。
构建完成后检查dist/control-ui/目录,应该有index.html(15.37 kB)、assets/目录下的 JS 和 CSS 文件。重点看check-control-ui-performance.mjs的输出:
startup JS: 10 requests, 292.5 KiB gzip, 269.7 KiB br (limits: 18 requests, 317.0 KiB gzip) startup CSS: 1 request, 40.6 KiB gzip, 34.8 KiB br (limits: 1 request, 45.0 KiB gzip) largest JS: assets/ghostty-web-DFkqngpx.js, 179.8 KiB gzip (limit: 215.0 KiB) largest CSS: assets/control-ui-core-BI-TN4yk.css, 40.6 KiB gzip (limit: 45.0 KiB)这些指标都在限制范围内,说明构建产物没有膨胀。如果startup JS超过 317 KiB,你需要检查是不是某个依赖被错误地打进了首屏 chunk。日志里那个hint: startup JS gzip is more than 4096 B below the 324587 B baseline是提示你可以更新 baseline,命令是node scripts/check-control-ui-performance.mjs --update-baseline --reason "optimize ghostty chunk"。
第二步启动 dev server:
pnpm devVite 会在http://localhost:5173启动,你会看到VITE v8.1.5 ready in 342 ms。打开浏览器访问,如果 control-ui 正常渲染,说明构建产物没问题。
第三步验证模型通道。在浏览器控制台里执行:
const res = await fetch('https://taotoken.net/api/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${import.meta.env.VITE_TAOTOKEN_API_KEY}`, }, body: JSON.stringify({ model: 'gpt-4o', messages: [{ role: 'user', content: 'ping' }], max_tokens: 10, }), }); const data = await res.json(); console.log(data.choices[0].message.content);如果返回pong或类似内容,说明通道通了。注意import.meta.env在浏览器控制台里不能直接用,你需要在代码里先把它挂到window上,或者直接在.env.local里看值然后手动填。
端到端跑通的标志是:构建无报错、dev server 启动、模型请求返回 200。日志里[build-all] phase timings: total 11m 54.2s这个总耗时是冷启动,二次构建因为 tsdown 缓存会降到 3 分钟左右。如果你只改 UI 代码,跑pnpm build:ui只要 7 秒。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth 报错对照
构建和接入过程中最容易遇到的四类报错,我按出现频率排序。
第一类:401 Unauthorized。这个通常出现在模型请求阶段,原因是 Key 没配或配错了。检查.env.local里的VITE_TAOTOKEN_API_KEY是否以sk-开头,以及VITE_TAOTOKEN_BASE_URL是否是https://taotoken.net/api(注意结尾没有/v1,OpenAI SDK 会自动加)。如果你用的是 Claude Code,检查~/.claude/settings.json里的ANTHROPIC_API_KEY和ANTHROPIC_BASE_URL。Cline 的话检查 MCP 配置里的TAOTOKEN_API_KEY。Codex 检查~/.codex/auth.json的api_key字段。
第二类:local proxy failed。这个报错在 claw 的 gateway 模块里出现,原因是 gateway 尝试连接本地代理但失败了。claw 的gateway-runtime-DWs8EJ0W.js会读取HTTP_PROXY环境变量,如果你系统里设了这个变量但代理没启动,就会报这个错。解决方法是临时取消代理设置:
unset HTTP_PROXY unset HTTPS_PROXY pnpm dev或者在.env.local里显式设为空:
HTTP_PROXY= HTTPS_PROXY=第三类:Cannot read properties of undefined (reading 'choices')。这个报错说明 API 返回的 JSON 结构跟预期不符。常见原因是 baseURL 配成了https://taotoken.net/api/v1,然后 SDK 又加了一次/v1,导致请求发到了https://taotoken.net/api/v1/v1/chat/completions。正确的 baseURL 是https://taotoken.net/api,SDK 会自动补/v1。另一个原因是模型名写错了,比如写了gpt-4但实际可用的是gpt-4o,这时候 API 会返回错误对象而不是正常的 choices 数组。
第四类:OAuth token expired。这个在 Claude Code 里出现,原因是 Claude Code 默认走 OAuth 流程,但你配了 API Key 后它可能还在尝试刷新 OAuth token。解决方法是在~/.claude/settings.json里加一行:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_API_KEY": "sk-your-key-here", "CLAUDE_CODE_DISABLE_OAUTH": "1" } }CLAUDE_CODE_DISABLE_OAUTH=1会强制 Claude Code 只用 API Key,不走 OAuth。
除了这四类,还有一个构建阶段的坑:tsdown-packages阶段卡住不动。日志里[tsdown-build] still running pid=6844; no output for 30s会重复出现,这是正常的,tsdown 在生成大量.d.ts文件时确实会静默 30 秒以上。如果你等了 5 分钟还没输出,检查tsdown.config.ts里的deps.neverBundle是否包含了所有node:*模块,漏配会导致 tsdown 尝试打包 Node 内置模块然后卡死。
ghostty 终端下还有一个特有问题:WebGL context lost。这是因为 ghostty 的 GPU 加速跟 xterm 的 WebglAddon 冲突。解决方法是在createGhosttyTerminal里加一个降级逻辑:
try { term.loadAddon(new WebglAddon()); } catch (e) { console.warn('WebGL not available, falling back to canvas'); }这样在 ghostty 里如果 WebGL 不可用,会自动回退到 canvas 渲染,不会白屏。
6. 语义一致 CTA:构建验证通过后怎么继续用 TaoToken
构建跑通、模型请求返回 200 之后,你大概率会想把这个通道用到日常开发里。TaoToken 的接入点有三个方向,按使用频率排。
第一个方向是继续用 Claude Code 或 Cline 辅助写 claw 的代码。Claude Code 的配置上面已经给了,你只需要把ANTHROPIC_BASE_URL指向https://taotoken.net/api,然后在模型对话页面确认可用的模型 ID。Cline 的 MCP 配置也给了,注意@taotoken/mcp-server这个包需要 Node 18 以上。如果你用的是 Codex,auth.json里的base_url字段就是https://taotoken.net/api。
第二个方向是在 claw 的 control-ui 里做模型切换功能。claw 的model-providers-page模块已经预留了多厂商配置的 UI,你只需要在model-auth模块里把 TaoToken 作为一个 provider 加进去。具体做法是在packages/gateway-protocol/src/schema/model-providers.ts里加一个 provider 定义:
export const taotokenProvider = { id: 'taotoken', name: 'TaoToken', baseUrl: 'https://taotoken.net/api', models: ['gpt-4o', 'claude-3-5-sonnet-20241022', 'deepseek-chat'], authType: 'api-key', };然后在model-setup-page里渲染这个 provider 的配置表单。这样用户就能在 UI 里直接填 Key 切换模型,不用改.env.local。
第三个方向是长期编码和 Agent 任务。如果你打算用 claw 跑长时间的 Agent 任务(比如cron-page里的定时任务),建议用 Coding Plan 而不是按量计费。Coding Plan 的入口在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=,它提供固定的月度额度,适合高频调用。API Keys 管理在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=,你可以在这里创建多个 Key 分别给不同环境用。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=,里面有各语言 SDK 的完整示例。
如果你在构建验证阶段遇到了上面没覆盖的报错,先去接入文档里搜报错关键词,大部分常见问题都有说明。模型对话页面可以用来快速测试某个模型 ID 是否可用,不用改代码。控制台在 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=,可以看请求日志和额度消耗。
最后说一个实际经验:claw 的构建产物里ghostty-web那个 636 kB 的文件,如果你不用 ghostty 终端,可以在vite.config.ts的manualChunks里把它去掉,首屏 JS 能降到 200 KiB 以下。但如果你用 ghostty 调试,保留它,因为 WebGL 渲染的体验差异很明显。这个取舍取决于你的调试环境,没有标准答案。