「腿」是能跑一整本书的一套引擎凭据。当前三条:kimi(注入 Kimi 的 Anthropic 兼容端点)、codex(本机的 Codex CLI,走 ChatGPT 订阅)、claude(lab 自己的 Claude 订阅 OAuth)。runBookEngine() 把它们串成降级链:第一条倒了换第二条,全倒才算失败。
这段代码在哪
src/server.ts:runBookEngine()(链)、runLeg()(分发)、runClaudeExec()(kimi/claude 两条腿共用的实现)、runCodexExec()(codex 腿)。src/book-legs.ts:parseLegs()解析BOOK_LEGS,availableLegs()按凭据过滤。判据shouldTryNextLeg()是第五章的主角。
它做什么
链的主体只有一个循环:
const legs = availableLegs(BOOK_LEGS, {
kimi: Boolean(BOOK_ANTHROPIC_BASE_URL),
claude: Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN),
});
if (!legs.length) return runCodexExec(prompt, onThread); // 全没配:保底还是老路
let last: CodexOutcome = { ok: false, threadId: "", reply: "", error: "no leg ran" };
for (let i = 0; i < legs.length; i++) {
const leg = legs[i];
console.log(`[book] leg ${i + 1}/${legs.length} = ${legLabel(leg)}`);
last = await runLeg(leg, i === 0 ? prompt : prompt + RESUME_HINT, onThread);
if (last.ok) {
if (i > 0) console.log(`[book] leg ${legLabel(leg)} 写成(前 ${i} 条腿用不了)`);
return last;
}
if (!shouldTryNextLeg(last.error)) {
console.log(`[book] leg ${legLabel(leg)} 失败,但不是配额/凭据问题,不换腿 → ${last.error.slice(0, 120)}`);
return last;
}
console.log(
`[book] leg ${legLabel(leg)} 用不了(配额满或凭据失效)` +
(i + 1 < legs.length ? ` → 换 ${legLabel(legs[i + 1])}` : "(已是最后一条腿)"),
);
}
return last;
三条腿的启动方式与环境变量:
| 腿 | 实现 | 关键 env | 凭据来源 |
|---|---|---|---|
| kimi | runClaudeExec(prompt, onThread, true) | 注入 ANTHROPIC_BASE_URL/ANTHROPIC_API_KEY(来自 BOOK_ANTHROPIC_*),删掉 CLAUDE_CODE_OAUTH_TOKEN | Kimi 兼容端点 key |
| claude | runClaudeExec(prompt, onThread, false) | 不注入,吃 lab 自己的 CLAUDE_CODE_OAUTH_TOKEN,模型退回 MODEL | lab 的 Claude 订阅 |
| codex | runCodexExec() | CODEX_HOME(独立凭据链);BOOK_CODEX_MODEL 非空才加 -m | $HOME/.codex 的 ChatGPT 登录 |
kimi 腿的注入有一个容易漏的细节:第三方端点只认自家模型名,所以要把 Claude Code 内部所有档位都映射过去:
if (useCompat) {
env.ANTHROPIC_BASE_URL = BOOK_ANTHROPIC_BASE_URL;
env.ANTHROPIC_API_KEY = BOOK_ANTHROPIC_API_KEY;
delete env.CLAUDE_CODE_OAUTH_TOKEN; // 订阅 token 在场会抢道,明确让位给兼容端点
// 第三方端点只认自家模型名:把 Claude Code 内部各档位(含子任务的 haiku 档)
// 全部映射到同一个模型,否则内部小任务会拿 claude-* 模型名打 Kimi 端点报错。
env.ANTHROPIC_MODEL = BOOK_CLAUDE_MODEL;
env.ANTHROPIC_DEFAULT_FABLE_MODEL = BOOK_CLAUDE_MODEL;
env.ANTHROPIC_DEFAULT_OPUS_MODEL = BOOK_CLAUDE_MODEL;
env.ANTHROPIC_DEFAULT_SONNET_MODEL = BOOK_CLAUDE_MODEL;
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = BOOK_CLAUDE_MODEL;
env.CLAUDE_CODE_SUBAGENT_MODEL = BOOK_CLAUDE_MODEL;
env.CLAUDE_CODE_EFFORT_LEVEL = process.env.BOOK_CLAUDE_EFFORT ?? "high";
}
codex 腿是 spawn 一个子进程、逐行解析 --json 事件流:
const args = ["exec", "--json", "-s", "danger-full-access", "-C", WORKSPACE, "--skip-git-repo-check"];
if (BOOK_CODEX_MODEL) args.push("-m", BOOK_CODEX_MODEL);
args.push(prompt);
const child = spawn(CODEX_BIN, args, {
cwd: WORKSPACE,
env: { ...process.env, CODEX_HOME },
stdio: ["ignore", "pipe", "pipe"],
});
事件解析只认三类:thread.started(或 session.created)拿线程号;item.completed 且 item.type === "agent_message" 的最后一条文本是答复;turn.failed/error 记错误。进程退出时 ok = code === 0 && !!reply——退出码 0 但没有答复,也算失败。
claude 腿还有一道文字识别:CLI 正常退出但第一轮就是 API 错误时,result 的 subtype 仍是 success,所以要按答复文本再判一次:
if (ok && /Failed to authenticate|API Error: \d{3}/i.test(reply.slice(0, 300))) {
ok = false;
error = reply.slice(0, 200);
}
为什么这样写
- 换腿是整本重跑:三条腿的会话互不互通,换腿不能续上一条腿的上下文,只能靠工作目录里的半成品 +
RESUME_HINT(第三章)。注释承认代价:「前一腿烧掉的轮数收不回来,这是换腿的固有代价」。 - 两条腿共用一个 runClaudeExec:kimi 和 claude 的差别只是「注不注入兼容端点」一个布尔(
injectCompat)。订阅端点不认第三方模型名,所以 claude 腿必须把模型退回MODEL——注释:「订阅端点不认 k3 这类第三方名」。 - codex 的三个坑都有注释:flags 必须紧跟子命令后(无 resume)、
--skip-git-repo-check(workspace 不是 git repo)、stdin: "ignore"(否则等 EOF 挂住)。缺任何一个,腿会以莫名其妙的方式死掉。 - BOOK_RUN_DIR 独立目录:claude 腿的 cwd 不是 WORKSPACE,是独立的
bookrun/。注释记了原因:WORKSPACE 项目积累的 auto-memory(含旧书内容)会自动注入请求,触发 Kimi 风控 400 high risk(2026-08-24 二分定位实锤)。每次开跑还会先清空 bookrun 项目的 memory 目录。 - 错误不被覆盖:catch 里
if (!error)才赋值——SDK 常在 result 之后补抛一个笼统的「process exited with code 1」,会把已经识别出的 403 配额错误盖掉,换腿判别随之瞎掉。
怎么排查
- 看腿怎么换的:
journalctl -u claude-agent | grep '\[book\] leg'。一行一个决定:开跑、写成、不换腿、换哪条。 - claude 腿的逐轮成本:
grep 'claude-engine done'看turns=与cost=。 - 手动探 codex 腿是否能跑:
cd /opt/claude-agent/workspace && codex exec --skip-git-repo-check "说一句你好"——复现的正是 runCodexExec 的最小形态。 - 超时兜底是
BOOK_TIMEOUT_MS(默认 3 小时),超时被 SIGKILL 的错误文本是「书没写完就超时了(180 分钟兜底)」。