如果你在终端里敲下openclaw然后回车,到它真正开始响应你的第一句话之前,这中间大约几百毫秒内发生的事,就是这次要拆的内容。上一篇我讲过 OpenClaw 的整体架构和模块划分,这篇把镜头拉到最底层:Node CLI 启动链路。说白了,就是一条从package.json的bin声明开始,经过配置加载、运行时构建,最后进入命令分发的完整执行路径。这个链路几乎是所有以 Node.js 为基础的 CLI 工具(尤其是 AI Agent 类项目)的通用模板,读懂了它,以后再看同类项目会快很多。
这篇分析基于 OpenClaw 0.4.x 的源码结构,适合已经跑通过基础部署、想深入读源码的人。如果你是刚接触,建议先把openclaw --help跑一遍,再回来看这篇文章,会更有感觉。
1. 从 openclaw 回车开始:bin 声明、入口文件和第一道异常兜底
1.1 package.json 里的 bin 字段如何变成全局命令
所有 Node CLI 的起点都长一个样:package.json里的bin字段。OpenClaw 也不例外,它的根package.json里写着:
{ "name": "openclaw", "version": "0.4.2", "bin": { "openclaw": "bin/cli.js" }, "engines": { "node": ">=18.0.0" }, "scripts": { "build": "tsc -p tsconfig.json" } }关键点在于bin字段的结构:"openclaw": "bin/cli.js"表示安装时 npm 会在全局node_modules/.bin目录下生成一个名为openclaw的软链接,指向这个文件。你在命令行里敲openclaw,shell 去PATH里找到的其实是这个软链接,然后由 Node.js 来执行它。
这里有个值得注意的细节:为什么很多项目要把bin指向一个bin/目录下的独立 JS 文件,而不是直接指向dist/index.js?我见过不少初学者把入口直接怼到编译产物上,结果每次改了源码就得重新npm link才能生效。OpenClaw 的做法是让bin/cli.js做一个"存在性判断",类似这样的逻辑:
#!/usr/bin/env node 'use strict'; const path = require('path'); const { existsSync } = require('fs'); let entry = path.join(__dirname, '../dist/cli/index.js'); if (!existsSync(entry)) { // 开发模式下源码还没编译,直接走 ts 入口 entry = path.join(__dirname, '../src/cli/index.ts'); } require(entry);第一行#!/usr/bin/env node不是摆设,它告诉操作系统用哪个解释器来跑这个文件。在 Windows 的 WSL2 环境下,这里偶尔会踩到坑:如果系统里同时装了多个 Node 版本(比如 nvm 和系统自带 Node 混用),env node解析到的路径可能不是你当前 shell 里node -v看到的版本。后面我会专门讲这个坑。
1.2 dist 产物与源码之间的对应关系
阅读源码时很多人会被dist/和src/这两个目录搞晕。OpenClaw 的发布包默认只带编译后的dist/目录,而仓库里你看到的是src/下的 TypeScript 源码。对应关系很简单:src/cli/index.ts编译后就是dist/cli/index.js。
我用的是 Source Map 的方式,在tsconfig.json里开着"sourceMap": true,这样跑node --inspect时可以直接在 DevTools 里打断点看src/下的原始 TypeScript 代码,不用手动在编译产物里找位置。这是读这个项目最舒服的方式,后面调试章节我还会细说。
1.3 入口脚本里的"第一道防线":进程级异常兜底
src/cli/index.ts非常短,短到只有十几行:
import { main } from './main'; main().catch((err) => { console.error('[openclaw] 启动失败:', err); process.exit(1); });但这里的价值被大多数人低估了。CLI 工具最容易出现的毛病就是:异步错误没被捕获,进程直接静默退出,用户完全不知道发生了什么。main().catch()是最后一道兜底,保证任何未预期的异常都会以非零退出码结束,并且把错误信息打到 stderr。
顺带一提,我之前翻到过这个文件的历史提交记录,早期版本还会在入口处捕获unhandledRejection和uncaughtException再统一处理,后来被删掉了。原因是全局捕获会让进程处于"不确定状态",继续跑下去反而会产生脏数据,不如直接退出让外层管理器(systemd、Docker、Windows 任务计划)拉起。这个取舍我觉得是对的——CLI 工具就该快速失败,而不是苟延残喘。
2. 配置系统三层合并:内置默认值、配置文件到环境变量与 CLI 参数
启动链路的第二步是配置加载。OpenClaw 的配置系统是我见过这类项目里比较规整的:一个src/config/目录,拆成了defaults.ts、loader.ts、schema.ts和types.ts四个文件。核心功能一句话总结:把内置默认值、磁盘配置文件、环境变量、CLI 参数按优先级合并成一份运行时配置。
2.1 配置查找:项目级、用户级、平台级三层
loader.ts里做配置查找的顺序是这样的:
- 当前工作目录下的
openclaw.config.json(项目级) - 当前工作目录下的
.openclawrc(兼容旧版本命名的项目级) - 用户主目录下的
~/.openclaw/config.json(用户级)
三层配置最终会做深度合并(deep merge),不是简单的"找到哪个用哪个":
import { merge } from 'lodash-es'; export async function loadConfig(options: LoadOptions): Promise<ClawConfig> { const projectConfig = await readOptionalJson('openclaw.config.json'); const userConfig = await readOptionalJson(path.join(homedir(), '.openclaw', 'config.json')); // lodash 的 merge 会递归合并嵌套对象,数组默认按索引覆盖 const merged = merge({}, DEFAULT_CONFIG, projectConfig, userConfig); return schema.parse(merged); }这里有个很容易让人踩坑的细节:merge对嵌套对象是递归合并,但对数组是按索引覆盖。比如默认配置里skills.enabled是["code", "search"],你配置里只写了["web"],合并结果不是["code", "search", "web"],而是["web"]。我一开始以为是自己配置文件写错了,后来翻loader.ts才看到这层逻辑。所以你的自定义配置如果涉及数组,要么全量覆盖,要么用特殊标记(OpenClaw 支持在数组元素里写"$append": true来追加,这个设计比较贴心)。
2.2 合并优先级:谁覆盖谁
完整覆盖链从低到高是这样的:
| 优先级 | 来源 | 示例 |
|---|---|---|
| 1 | 内置默认值DEFAULT_CONFIG | { "model": "qwen2.5:7b" } |
| 2 | 项目级配置文件 | ./openclaw.config.json |
| 3 | 用户级配置文件 | ~/.openclaw/config.json |
| 4 | 环境变量 | OPENCLAW_MODEL=..." |
| 5 | CLI 参数 | openclaw chat --model qwen2.5:32b |
| 6 | 交互式会话内临时指令 | /model qwen2.5:32b(只对当前会话生效) |
这个设计我比较认可:既有项目级配置可以进版本库(适合团队统一),也有用户级配置个性化(比如你自己的 API Key 习惯放用户级),环境变量和 CLI 参数用来做临时覆盖(适合调试和跑自动化脚本)。会话内临时指令则保证你在不修改任何文件的情况下切换模型或技能。
2.3 环境变量映射与类型转换:OPENCLAW_ 前缀
环境变量的映射规则在loader.ts里单独一段:所有以OPENCLAW_开头的环境变量,前缀去掉后,剩下部分转小写,用_分割成路径,逐级对应到配置对象。
// OPENCLAW_LOG_LEVEL=trace // 会被解析成 config.log.level = 'trace' function applyEnvOverrides(base: ClawConfig, env: NodeJS.ProcessEnv): void { for (const key of Object.keys(env)) { if (!key.startsWith('OPENCLAW_')) continue; const pathParts = key.slice('OPENCLAW_'.length).toLowerCase().split('_'); setByPath(base, pathParts, coerceEnvValue(env[key])); } }这个coerceEnvValue很关键,它做了类型转换。比如OPENCLAW_DEV_MODE=false直接传给配置对象时,如果走 JSON 解析会拿到布尔值false,但如果直接赋值会拿到字符串"false"——提醒你,"false"在 JavaScript 里可是个真值(truthy),这会导致config.devMode被误判为开启。coerceEnvValue内部做了类似 JSON.parse 的尝试,失败才回退成字符串。这块代码值得那些需要环境变量注入配置的同类工具抄一抄。
2.4 配置校验:报错信息如何定位到具体字段
合并完的配置会交给schema.ts里的 zod schema 做校验。OpenClaw 用 zod 不是随便选的,它的error.path能力能把校验错误定位到具体字段路径:
import { z } from 'zod'; export const ClawConfigSchema = z.object({ model: z.string().min(1, 'model 不能为空'), provider: z.enum(['ollama', 'openai-compatible', 'anthropic']), log: z.object({ level: z.enum(['trace', 'debug', 'info', 'warn', 'error']).default('info'), file: z.string().optional(), }), skills: z.object({ enabled: z.array(z.string()).default([]), scanTimeoutMs: z.number().default(5000), }), companion: z.object({ host: z.string().default('127.0.0.1'), port: z.number().int().min(1024).default(8787), }).optional(), }); export const schema = ClawConfigSchema.passthrough();注意最后一行.passthrough(),它表示未知字段会被保留而不是报错。这是个有争议的设计:好处是向下兼容,旧配置里多了字段不会导致启动失败;坏处是配置拼错名字时没有任何提示。比如你把companion写成compainon,启动照常,但 Windows Companion 功能就是起不来。你搜日志找半天才会想到是配置字段拼错了。这个问题我在第 5 章还会专门展开,这里先记住一个结论:排错时先怀疑配置拼写。
3. 启动时的五件套初始化:日志、密钥库、模型网关、技能注册和运行时自检
配置合并校验完成后,main.ts会根据配置依次初始化五个核心子系统。顺序是固定的,因为后一个依赖前一个的对象实例。我把这个顺序叫"启动五件套",按我理解的依赖关系排个优先级。
3.1 日志初始化:stdout 与控制台日志文件的分流
日志初始化是第一个,因为后面的所有步骤都要用到它来打日志。OpenClaw 自己写了个轻量 Logger,没有直接上 pino 之类的重框架。核心逻辑是:根据config.log.level决定输出级别,同时按是否 TTY 决定输出格式。
export function createLogger(config: ClawConfig): Logger { const level = config.log.level; const format = process.stdout.isTTY ? 'pretty' : 'json'; const logger = new Logger({ level, format }); if (config.log.file) { logger.addFileTransport(path.resolve(config.log.file)); } return logger; }这里有个容易忽略的点:如果程序输出被重定向到文件(比如openclaw chat > output.txt),process.stdout.isTTY会是false,日志格式自动切换成 JSON。这个设计对跑自动化脚本的人非常友好——可以直接用jq解析日志,而不用从一堆彩色字符串里抠信息。
实际使用中我会建议你把config.log.file设成一个固定路径,比如~/.openclaw/logs/openclaw.log,因为交互模式下日志会刷屏,有了文件日志才能做"事后复盘"。下文排查启动问题时,先翻这个文件,信息量比 stdout 多得多。
3.2 密钥库初始化:从配置读取到安全存储的转换
密钥库(CredentialStore)负责管理各模型提供商的 API Key。OpenClaw 没把 API Key 直接放进主配置,而是单独存到~/.openclaw/auth.json,并且落盘前做了一层基础的混淆加密(不是强加密,别指望它替代专业密钥管理)。
我在源码里看到src/core/credential-store.ts里有这么一段:
export class CredentialStore { private entries: Record<string, CredentialEntry> = {}; public async load(): Promise<void> { const raw = await fs.promises.readFile(AUTH_FILE_PATH, 'utf8'); const parsed = JSON.parse(raw); for (const [provider, entry] of Object.entries(parsed)) { this.entries[provider] = { ...entry, apiKey: obfuscateDecode(entry.apiKey), }; } } }密钥库初始化时机是在日志之后、模型网关之前。原因很直接:模型网关创建时要读 provider 列表和对应密钥,而网关的错误提示要靠日志输出。
这里有个安全上的坑:如果auth.json被误提交到 Git 仓库,或者文件权限设置不对(默认应该600),密钥就泄露了。我建议拿到新机器部署 OpenClaw 时,立刻执行chmod 600 ~/.openclaw/auth.json。虽然混淆不是加密,但至少别在文件权限上再放松。
3.3 运行时构建:模型网关与提供商连接
模型网关(ModelGateway)是启动链路的核心产物。它负责把上层的模型调用统一抽象化:不管后端是 Ollama、OpenAI 兼容接口还是 Anthropic 格式,网关都暴露同样的chat()方法。初始化时它会读取config.provider和config.model,然后创建一个连接实例,但注意——创建实例不等于建立连接。
export class ModelGateway { constructor(private readonly providerConfig: ProviderConfig) {} public async initialize(): Promise<void> { const { provider, model } = this.providerConfig; this.adapter = createAdapter(provider); // 这里只做本地预检,不会真的发网络请求 this.adapter.validateModel(model); } }validateModel只做格式校验(比如qwen2.5:7b是否符合name:tag的格式),真正的连接握手要等到第一次对话时才发生。这是个刻意的设计:如果每次启动都做网络探测,那么在没有外网的环境(比如离线局域网部署)启动就会卡住。但你也要记住它的副作用——配置里写了一个不存在的模型,启动不会报错,第一次对话才会暴露问题。看到这个行为时不要觉得是 bug,是设计权衡。
3.4 技能系统注册:什么时候扫描、扫描失败如何处理
技能系统(Skill System)是 OpenClaw 区别于裸模型调用的关键组件。它做的事是在启动时扫描技能目录,把每个技能的 manifest.json 注册到内存索引中。
扫描顺序同样是两段式:
- 项目内部的
skills/目录 - 用户目录
~/.openclaw/skills/
扫描用的是fast-glob,找所有**/manifest.json文件,然后逐个解析。每个 manifest 里声明了技能的name、description、triggers(触发关键词)和entry(入口脚本)。
export async function scanSkills(baseDir: string, logger: Logger): Promise<SkillRecord[]> { const files = await fg('**/manifest.json', { cwd: baseDir, onlyFiles: true, deep: 3 }); const results = []; for (const file of files) { try { const manifest = JSON.parse(await fs.promises.readFile(path.join(baseDir, file), 'utf8')); results.push({ ...manifest, baseDir }); } catch (err) { logger.warn(`技能清单解析失败,已跳过: ${file}`); } } return results; }扫描失败只打 warn 不抛异常,这是有意为之——一个技能坏了不应该拖垮整个启动流程。但要注意,scanTimeoutMs默认 5 秒,如果某个技能目录挂载在网络盘上导致扫描超时,超时后的处理是跳过剩余目录。你在某些慢速的 NAS 环境部署时如果发现技能突然少了几个,先想想是不是扫描超时了。
3.5 运行时自检:能启动不代表能干活
五件套的最后一件是自检。OpenClaw 在Runtime类里做了一组快速检查:Node 版本是否满足、当前平台是否 WSL2、必要目录是否可写、companion端口是否被占用等。自检通过后,才会打印启动 banner 并进入命令分发阶段。
这一段代码不复杂,但它把"启动"和"可用"两个概念分开了:启动成功只表示进程起来了,自检通过才表示它真的能干活。
4. 命令分发规则:有子命令走注册表,没子命令直接进交互会话
启动链路的最后一步是命令分发。OpenClaw 用的是类 commander 的自研参数解析器(不是直接引 commander,是套了一层壳),我们直接看它怎么把输入分流。
4.1 参数解析器的设计:全局选项与子命令的区分
先看全局选项,这些选项在子命令之前解析,放在任何位置都生效:
| 全局选项 | 作用 | 示例 |
|---|---|---|
--config, -c <path> | 手动指定配置文件路径 | openclaw --config ./my-config.json chat |
--profile, -p <name> | 切换配置 profile(不同环境用不同配置) | openclaw --profile staging chat |
--verbose, -v | 日志级别提升到 debug | openclaw chat -v |
--quiet, -q | 日志级别降至 error | openclaw run --quiet |
--version | 打印版本 | openclaw --version |
解析器的核心逻辑是:先扫一遍参数,把已知的全局选项吃掉,剩下的第一个位置参数当作子命令名,再交给对应子命令的解析器。
export function parse(argv: string[]): ParseResult { const { options, rest } = extractGlobalOptions(argv); const subcommand = rest[0] ?? null; return { options, subcommand, subcommandArgs: rest.slice(1) }; }这里有个新手容易误解的地方:全局选项不强制写在子命令前面。也就是说openclaw chat --verbose和openclaw --verbose chat是等价的。但如果你写了openclaw chat --config foo.json,它抛出解析错误(--config只属于全局,不属于chat)。这种"局部选项不认全局选项位置"的策略保证了错误能尽早暴露。
4.2 子命令注册表:chat/run/serve/config 的分流逻辑
OpenClaw 的commands/目录下有几个核心子命令,各自对应一个模块:
| 命令 | 功能 | 典型场景 |
|---|---|---|
chat | 进入交互式多轮会话 | 日常使用 |
run | 单次非交互执行(参数组合成 prompt 执行后退出) | 脚本调用、自动化 |
serve | 启动一个 HTTP 服务(面向外部集成) | 给其他系统提供接口 |
config | 查看/修改当前配置 | 排查问题 |
skill | 技能的管理子命令(list/install/remove) | 管理技能包 |
doctor | 环境诊断(Node 版本、WSL2、端口、密钥) | 排查环境问题 |
分发逻辑是查注册表:
const registry: Record<string, CommandHandler> = { chat: handleChat, run: handleRun, serve: handleServe, config: handleConfig, skill: handleSkill, doctor: handleDoctor, }; export async function dispatch(name: string, args: string[], ctx: RuntimeContext): Promise<number> { const handler = registry[name]; if (!handler) { ctx.logger.error(`未知命令: ${name},运行 openclaw --help 查看支持的命令`); return 1; } return handler(args, ctx); }这个注册表模式很朴素,但扩展性很好。如果你要加一个export命令,只需要加一个handleExport函数并注册进去,不用动主流程。读这个项目时我建议你把registry当作地图,顺着它找到你要看的模块。
4.3 未带命令时的默认行为:交互会话的引导
如果用户直接执行openclaw不带任何子命令,会走一段引导逻辑。源码里是这样判断的:
export async function handleNoCommand(args: string[], ctx: RuntimeContext): Promise<number> { if (process.stdin.isTTY) { // 交互式终端,默认进入 chat return handleChat([], ctx); } // 非交互环境(管道、CI),把 stdin 内容当作 prompt 执行一次 run const input = await readStdin(); return handleRun([input], ctx); }这个行为设计得很巧:你在终端里跑openclaw进交互会话;你在脚本里用echo "写一首诗" | openclaw,它自动按单次执行来跑,不用额外传run参数。这种"根据环境决定默认行为"的思路,值得所有写 CLI 的同学参考。
进交互会话之前,还有一个值得提的细节:OpenClaw 会检查当前目录下有没有.openclaw/session.json,如果有,会提示"检测到上次未完成的会话,是否恢复?"。恢复的实现是把历史消息数组重新注入会话上下文。这个过程我一开始以为会有损压缩,看过代码发现它是完整保留的,没有截断历史。所以恢复会话时 token 消耗会明显变大,这不算 bug。
5. 启动链路里的高发问题:WSL2 校验、Windows Companion、Node 版本与静默配置
这一章是我读源码过程中最有价值的部分——启动链路里藏着一堆不影响全局但能折腾你半小时的坑。逐个说。
5.1 WSL2 环境校验失败:PowerShell 提示从哪来
在 Windows 上部署 OpenClaw,最经典的问题是启动时提示环境校验失败,让用户"在 PowerShell 中运行wsl --status"。这段提示的来源就在src/core/doctor.ts里:
export function detectWsl(): boolean { if (process.platform !== 'linux') return false; try { const version = fs.readFileSync('/proc/version', 'utf8').toLowerCase(); return version.includes('microsoft'); } catch { return false; } } export function checkWslEnvironment(ctx: RuntimeContext): void { if (!detectWsl()) return; const status = checkKernelVersion(); if (status !== 'ok') { ctx.logger.warn( '检测到 WSL 1 环境。OpenClaw 在 WSL 2 下运行更稳定。\n' + '请在 PowerShell 中运行 `wsl --status` 查看当前版本,并考虑升级到 WSL 2。' ); } }逻辑的触发点是/proc/version里的microsoft标记。它在 WSL1 和 WSL2 下都存在,但内核版本号不同:WSL2 的内核是完整 Linux 内核,版本号形如5.15.153.1-microsoft-standard-WSL2;WSL1 则直接显示 Windows NT 内核版本或很老的 Linux 版本。checkKernelVersion()做的就是解析版本号,判断是否包含 WSL2 标志。
我遇到的实际情况里,很多人的 WSL 其实是 2,但提示照样弹出来。原因往往是/proc/version里写的是WSL2(大写)而代码里查的是小写wsl2。如果哪天你看到一个"明明我用的是 WSL2 还提示我升级"的怪现象,先检查这个字符串匹配的大小写问题。另外,如果在旧版 WSL(内核 4.x 时代)上跑,内核版本号里可能根本没有WSL2字样,这种需要先更新 WSL(wsl --update)再排查。
5.2 Windows Companion 端口配置不生效的排查顺序
Windows Companion 是 OpenClaw 在 Windows 宿主机上提供的一个辅助服务,负责剪贴板同步、文件访问等系统级集成。它的监听地址配置在config.companion下。在 WSL2 里,网段和 Windows 宿主机不通,所以 OpenClaw 默认把companion.host设为127.0.0.1,只监听本地回环。
如果你在 WSL2 里改了companion.port却发现服务起不来,或 Windows 端连不上,按这个顺序排查:
- 先看日志里的实际监听地址:OpenClaw 启动时会打印
Companion listening on http://127.0.0.1:8787,如果端口不是你配的,说明配置没生效,回到"验证配置"步骤(第 5.4 节)。 - 确认 WSL2 的
localhost转发是否打开:WSL2 对127.0.0.1的转发依赖.wslconfig文件里的[wsl2] localhost=true(新版默认开启)。 - 防火墙放行:Windows 防火墙对 Node.js 的入站规则如果缺失,Windows 端访问会直接超时。
- 高危操作:如果你的场景确实需要监听
0.0.0.0让局域网其他机器访问,请先确认你的网络环境安全,并且加上 API Token 校验,再改host。源码里对这个字段是有意识限制的,schema 里host只能是127.0.0.1或localhost,要放开得改配置 schema。这不是限制,是默认安全的刻板设计。
5.3 Node 版本与原生模块导致启动即崩溃
OpenClaw 的engines字段要求 Node 不小于 18。但我在实际部署中遇到的最多的问题是:Node 版本满足要求,但安装时原生模块编译失败。
原因通常是 OpenClaw 依赖里有一个可选原生模块(比如fs.watch相关的chokidar的某版本,在 Linux 下需要重新编译),它属于 optionalDependencies。在 Windows(非 WSL)上直接npm install时,如果没装 Visual Studio Build Tools,这个可选依赖会降级为纯 JS 实现,不影响主功能;但如果你在 WSL2 里装系统 Node 后跑npm ci,可能触发生成原生.node文件,而你的系统缺build-essential,就会在启动时加载.node文件失败,报错cannot open shared object file。
排查思路很简单:检查node_modules下有没有.node结尾的文件;如果是,确认安装时有没有编译错误日志。最直接的修复是重装依赖:先删除node_modules和package-lock.json,然后用npm install --no-optional强制跳过可选原生依赖(前提是你用不到那块功能)。这个做法经过了多次实践验证,能解决绝大多数 WSL2 下的启动即崩溃问题。
5.4 配置项写错名字时的"静默忽略"陷阱
我在第 2.4 节留了个尾巴:.passthrough()导致拼写错误的配置项被静默忽略。这里展开说。
假设你在openclaw.config.json里写:
{ "model": "qwen2.5:7b", "temperature": 0.7, "top_p": 0.9, "log": { "leve": "debug" } }这里leve是level的拼写错误。启动时不会报错,但你的 debug 日志永远不出来,因为config.log.level仍然是默认的info。最坑的是,你加了--verbose它又能输出——因为 CLI 参数覆盖了配置,这让你更难意识到配置文件写错了。
排查办法可以这样:启动时加一个环境变量OPENCLAW_LOG_LEVEL=trace,这时候如果 trace 日志出来了,说明配置文件的 log.level 没被读到,问题大概率出在拼写上。或者更直接一点,跑openclaw config get log看看实际解析出来的配置值,一眼就能发现leve被忽略了,level还是默认值。
说实话,我挺希望项目能加一个--strict-config模式来强制未知字段报错,但在那之前,如果遇到"配置像是没生效"的问题,先用openclaw config get验证。
6. 把调试手段焊死在日常流程里:trace 日志、inspect 断点与最小复现
前面几章是看代码得出的结论,这一章讲的是实际排查启动问题时的三板斧。都是我用下来觉得效率最高的手段。
6.1 第一板斧:trace 级别的启动日志
OpenClaw 的日志级别里有一档trace,比debug还详细。启动阶段开启 trace 的方式很简单:
OPENCLAW_LOG_LEVEL=trace openclaw chat这个命令下,配置加载的每一步都会打印:配置文件路径、读取到的原始 JSON、合并后的配置对象(日志会做脱敏,不会打印 apiKey 字段)、每个初始化步骤的开始和结束耗时。
我为什么推荐先开 trace?因为它不需要改代码、不需要断点,是最快的"信息获取"方式。看到哪个步骤耗时突然变高,或者某一行日志打出了undefined,问题范围立刻缩小一半。
6.2 第二板斧:node --inspect 断点看启动过程
如果日志不够用,上断点。OpenClaw 编译产物自带 source map,所以可以用--inspect直接在源码上断点:
node --inspect-brk $(which openclaw) chat--inspect-brk会在第一行代码执行前暂停,等着你用 Chrome DevTools(chrome://inspect)连接。连接上之后,你可以在src/cli/main.ts里打断点,逐步走一遍整个启动链路。我一般习惯在loadConfig、createRuntime、dispatch三个函数入口各打一个断点,这样能看清配置对象在每一阶段的实际值,比猜代码快得多。
如果你在 WSL2 环境下调试,记得在.wslconfig里配置端口转发,或在 Windows 上用netsh interface portproxy映射 DevTools 所需的端口,否则浏览器连不上 WSL 里的 inspect 端口。
6.3 第三板斧:最小复现法
遇到启动链路相关的问题,永远先做减法。
我处理过一个案例:用户说加了某个技能之后 OpenClaw 启动变得很慢。第一反应不是去读那个技能的代码,而是先做一个"干净环境最小复现":
# 把项目配置和用户配置都临时挪走,用纯默认配置启动 mv openclaw.config.json openclaw.config.json.bak mv ~/.openclaw/config.json ~/.openclaw/config.json.bak openclaw chat如果干净环境启动正常,再一个变量一个变量地加回来:先加用户配置,再加项目配置,再加自定义技能,每加一步重启一次,直到复现为慢。这样一个二分法通常三轮以内就能锁定元凶。那次查出来的原因其实是某个技能目录里有几万个文件,fast-glob扫描直接命中了scanTimeoutMs的超时保护,不是启动链路本身的 bug。
6.4 关于 Doctor 命令的最后建议
OpenClaw 自带doctor子命令,它能把当前环境的各种状态汇总打印出来:Node 版本、WSL 版本、配置文件路径、模型网关状态、端口占用情况。我现在的习惯是:任何人报"启动不了"的问题,第一句统一回复"先跑openclaw doctor贴结果"。这比自己盲猜或者让用户贴一堆启动日志效率高得多。源码里doctor的实现也值得一看,它把前面提到的自检逻辑全部串联了起来,是理解整个启动链路的最佳入口之一。
回到开头那句话:从敲下openclaw到它能回你话,这中间几百毫秒里包含的其实是"入口兜底—配置合并—子系统初始化—命令分发"四个大环节。整个链路的设计基本遵循"快速失败、默认安全、静默降级"三个原则:启动错误要立刻退、未配置的开放端口要拒绝、个别组件失败不要拖垮整体。读懂了这三个原则,你不仅能排查 OpenClaw 自己的问题,以后遇到任何一个 Node CLI 项目,都能很快找到它的命门。