一本书是怎么被写出来的 书架

第 04 章 / 共 10 章

第四章 · 三条腿

「腿」是能跑一整本书的一套引擎凭据。当前三条:kimi(注入 Kimi 的 Anthropic 兼容端点)、codex(本机的 Codex CLI,走 ChatGPT 订阅)、claude(lab 自己的 Claude 订阅 OAuth)。runBookEngine() 把它们串成降级链:第一条倒了换第二条,全倒才算失败。

这段代码在哪

它做什么

链的主体只有一个循环:

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凭据来源
kimirunClaudeExec(prompt, onThread, true)注入 ANTHROPIC_BASE_URL/ANTHROPIC_API_KEY(来自 BOOK_ANTHROPIC_*),删掉 CLAUDE_CODE_OAUTH_TOKENKimi 兼容端点 key
clauderunClaudeExec(prompt, onThread, false)不注入,吃 lab 自己的 CLAUDE_CODE_OAUTH_TOKEN,模型退回 MODELlab 的 Claude 订阅
codexrunCodexExec()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);
}

为什么这样写

怎么排查

一本书是怎么被写出来的 · 王建硕
费曼式写法 · 由多个 AI 代理撰写与互相审校