写书 agent 不是人,它读到的全部世界就是一段提示词。这一章讲 startBookJob() 怎么把这段提示词拼出来——四块料:通用前言 CODEX_BOOK_PREAMBLE、署名、登记字段(jobId/owner)、以及续跑时的补充提示。
这段代码在哪
src/server.ts的常量CODEX_BOOK_PREAMBLE。src/server.ts的startBookJob()前半段:拼 prompt。src/server.ts的常量RESUME_HINT与函数resumeAfterRestartHint():两段名字像、用途不同的续跑提示。
它做什么
前言是写死的全文,一字未改过(2026-08-20 定稿至今):
const CODEX_BOOK_PREAMBLE =
`你是跑在服务器上的自动写书代理(可跑 bash、读写文件、联网)。\n` +
`先完整阅读 ${SKILLS_DIR}/wjs-voicedrop-writing-book/SKILL.md 并严格照做;` +
`按它第 0 步选定书的类型后,把对应写作 skill 的 SKILL.md(同在 ${SKILLS_DIR}/ 下)也完整读进来再动笔。\n` +
`你没有并行子代理,也没有 Workflow——skill 里说 spawn 写手/评审 subagent 的地方,一律由你自己分步串行扮演:` +
`写完一章,抛开写作时的思路,按该类型的评审维度独立重读打分并把意见落盘 reviews/NN.json;` +
`不过就照 must_fix 重写(最多 3 轮),过审立刻 build.mjs done 发布,绝不攒到最后。\n` +
`其余约定(工作目录、book.json、边写边发、断点续跑、封面用 /opt/claude-agent/bin/paint)一律照 skill 执行。`;
上面的注释解释了为什么需要这段:「codex 是单代理循环,没有 Claude 的 skill 装载/并行 subagent/Workflow——skill 里这三样都要在 prompt 里翻译成『自己读文件 + 自己分步扮演角色』」。skill 文件是给人和 Claude 写的,前言负责把它「转译」成单代理能执行的版本。同一份前言也喂给 claude 腿——三条腿引擎无关,提示词完全一致。
前言之后,startBookJob() 拼上这单的专属字段:
const byline = author
? `作者署名「${author}」——book.json 的 author 字段用这个名字,封面/页脚照此署名。`
: `提交者没有留名字——book.json 不写 author 字段,全书不署名(不要默认署任何人名)。`;
const prompt =
`${CODEX_BOOK_PREAMBLE}\n\n` +
`任务:写一本书。${byline}\n` +
`本次写书任务号 jobId=「${jobId}」——建 book.json 时把它原样写进顶层 "jobId" 字段(登记簿要靠它对号,别漏)。\n` +
(scope ? `这本书的产权归属 owner=「${scope}」——建 book.json 时把它原样写进顶层 "owner" 字段(谁能在线修改这本书以此为准)。\n` : "") +
`种子:\n${seed}` +
(resumed ? resumeAfterRestartHint(jobId) : "");
逐字段看:
| 注入内容 | 形式 | 服务端怎么用它 |
|---|---|---|
| author | 署名语句,有名字/没名字两种措辞 | 只进 book.json 的 author;没名字时明确叮嘱「不要默认署任何人名」 |
| jobId | 「原样写进顶层 "jobId" 字段」 | 收尾时 findBookByJobId() 靠它反查 slug;退款 ref=jobId 靠它幂等 |
| owner | 「原样写进顶层 "owner" 字段」 | 修书的主人校验以此为准;30 秒轮询还有兜底补写(第七章) |
| seed | 全文 | 不进 prompt 之外的任何地方(扣费只带前 200 字) |
最后是续跑提示词——系统里有两段,常被混淆:
RESUME_HINT | resumeAfterRestartHint() | |
|---|---|---|
| 触发时机 | 换腿:runBookEngine 里第 2 条腿起追加 | 重启:inflight 孤儿续跑时追加(attempts > 1) |
| 原因 | 上一条腿配额/凭据倒了,书可能写了一半 | 进程被杀,书可能「已经很完整」 |
| 措辞强度 | 「先查有没有半成品」 | 更具体:用 build.mjs status 对账,明确「不要重画已有的插图、不要重写已过审的章节」 |
| 带不带 jobId | 不带 | 带(写书单),帮 agent 在 workspace 里认出自己的目录 |
function resumeAfterRestartHint(jobId?: string): string {
return (
`\n\n补充(本单是服务器重启后的续跑):上一次尝试被服务重启打断,很可能已经写了一部分甚至接近完成——` +
`动笔前先列一下 ${WORKSPACE}/ 下本单的工作目录` + (jobId ? `(book.json 里 jobId=「${jobId}」的那个)` : "") +
`,用 build.mjs status 看每章是 done / 待发(有稿) / 待写:有稿的直接发布,缺的补写,缺封面就补封面。` +
`**不要另起一本新书、不要换 slug、不要重画已有的插图、不要重写已过审的章节。**`
);
}
为什么这样写
- 提示词即接口:jobId/owner 不靠服务端事后修数据库,而是「嘱咐 agent 写进 book.json」。因为书的真源就是文件系统上的 book.json——服务端、build.mjs、修书模式全都读它,一处是真源处处是真源。
- 两段续跑提示词分开写:9/8《嘟嘟和山上的灯塔》被发版 restart 杀掉时 14 页 14 图全在、只差发布,如果续跑提示词只说「接着写」,引擎很可能把 14 张图重画一遍。所以重启续跑的措辞专门加了一整排「不要」。换腿续跑没这个强度——换腿时书通常还残得多。
- 没名字要明说:「不要默认署任何人名」是防 LLM 的本能——不写这句,它会自己编一个署名。
怎么排查
- 想看成形的提示词全文:写书单在 codex 腿时去
$CODEX_HOME/sessions/YYYY/MM/DD/rollout-*.jsonl找第一条user_message;lab 侧栏的会话列表(summarizeCodex())也能直接点开看。 - agent 没写 jobId/owner 导致对不上号:先看第七章的 30 秒轮询有没有打
[book] injected defaults——owner 有兜底,jobId 没有(jobId 漏写只能落_unmatched,第八章)。 - 改提示词后想验证:起一本测试书,
journalctl -u claude-agent -f | grep '\[book\] start'确认开跑,再翻 rollout 第一条消息。