跳到正文
跟我做
自媒体自动化5 分钟llama.cpp

AI 写作与小说:本地模型做人物卡和章节大纲

用一个本地 3B 小模型,把一句话设定变成 3 张人物卡和 4 章大纲;脚本负责提示词、JSON 校验和 Markdown 输出。全程不联网,不依赖付费写作服务。

约 5 分钟
本页目录
  1. 这篇教程解决什么问题
  2. 装本地模型
  3. 建三个文件
  4. 写提示词模块
  5. 写模型调用
  6. 写命令入口
  7. 写故事设定
  8. 跑一次
  9. 看结果
  10. 小模型会在哪里翻车
  11. 常见坑
  12. 1. 提示 fetch failed
  13. 2. JSON 解析失败
  14. 3. 人物卡里出现书名、地点、组织
  15. 4. 章节之间接不上
  16. 下一步读什么

30 秒速查

  • 先装 Ollama,拉 qwen2.5:3b(约 2GB)
  • story.json 写书名、类型、设定、人数、章数
  • 跑 node resources/novel-outline/outline.mjs story.json
  • 结果在 out/人物卡.md、out/章节大纲.md
  • 0.5B 会漏字段,直接上 3B

这篇教程解决什么问题

直接让模型写正文,写到第三章,人物名、动机和时间线就开始打架。回头改,比重新写还慢。

先把结构固定下来:3 张人物卡,再加 4 章大纲。人物卡决定谁要什么、阻力是什么;大纲决定每一章发生什么、结尾留什么钩子。结构稳了,再扩写正文。

这篇用本地模型跑完整条流程,稿子不出本机。

装本地模型

用 Ollama 最省事,先拉一个 3B 的量化模型:

ollama pull qwen2.5:3b

macOS 和 Windows 安装后会自动起服务;Linux 如果连不上,手动跑:

ollama serve

默认端口是 11434,脚本用的地址是 http://127.0.0.1:11434/v1。

不想装 Ollama,也可以用 llama.cpp 的 server,接口一样:

./llama-server -m qwen2.5-3b-instruct-q4_k_m.gguf --port 8082 -c 4096

这时跑脚本要加 --base-url http://127.0.0.1:8082/v1。本文下面的真实输出,就是用这条路径跑出来的。

建三个文件

在 resources/novel-outline/ 下放三个文件:

novel-outline/
├── prompt.mjs
├── model.mjs
└── outline.mjs

prompt.mjs 管提示词、JSON 解析和校验;model.mjs 只发请求;outline.mjs 负责读设定、串流程、写文件。

写提示词模块

保存为 resources/novel-outline/prompt.mjs:

export const SYSTEM_PROMPT = [
  "你是小说结构编辑。只输出 JSON,不要解释,不要 Markdown 代码围栏。",
  "不要把书名、地点、组织当人物;不要复述设定;每个字段写独立的一句,不能照抄其他字段。",
].join("\n");

export function buildCharacterPrompt(story) {
  return [
    `书名:${story.title}`,
    `类型:${story.genre}`,
    `一句话设定:${story.premise}`,
    "",
    `只写 ${story.characters ?? 3} 个人物,不要多写。`,
    "只输出 JSON:",
    '{"characters":[{"name":"","role":"","goal":"","conflict":"","voice":""}]}',
    "role 只能是主角、对手、帮手之一。",
    "goal 写这个人物想要什么;conflict 写眼前的阻力;voice 只写说话风格(长短句、语速、口头禅),不要写身份或经历。",
  ].join("\n");
}

export function buildChapterPrompt(story, characters, { no = 1, previous = [] } = {}) {
  const roster = characters
    .map((person) => `${person.name}(${person.role}):目标 ${person.goal};冲突 ${person.conflict};说话 ${person.voice}`)
    .join("\n");
  const before = previous.length
    ? previous.map((chapter) => `第 ${chapter.no} 章 ${chapter.title}:${chapter.turn}`).join("\n")
    : "这是第一章,前面没有剧情。";

  return [
    `书名:${story.title}`,
    `类型:${story.genre}`,
    `一句话设定:${story.premise}`,
    "",
    "已确定的人物:",
    roster,
    "",
    "前面章节:",
    before,
    "",
    `只写第 ${no} 章,不要写其他章节。`,
    "只输出 JSON:",
    '{"chapter":{"no":1,"title":"","goal":"","turn":"","hook":""}}',
    "要求:no 必须是 " + no + ";title 不超过 12 个字。",
    "goal 是本章要达成的具体目标;turn 是一个具体事件,必须改变人物处境;hook 是一个未解决的问题。",
    "不要复述人物卡,不要照抄设定原句。",
  ].join("\n");
}

export function extractJson(text) {
  const trimmed = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
  const start = trimmed.indexOf("{");
  const end = trimmed.lastIndexOf("}");
  if (start < 0 || end <= start) throw new Error("模型没有返回 JSON。");
  return JSON.parse(trimmed.slice(start, end + 1));
}

export function validateCharacters(data, count = 3) {
  if (!Array.isArray(data.characters) || data.characters.length < count) {
    throw new Error(`人物卡不足 ${count} 个。`);
  }

  const names = new Set();
  for (const person of data.characters) {
    for (const key of ["name", "role", "goal", "conflict", "voice"]) {
      if (!person[key]?.trim()) throw new Error(`人物卡缺字段:${key}`);
    }
    if (names.has(person.name)) throw new Error(`人物重名:${person.name}`);
    names.add(person.name);
  }
  return data.characters;
}

export function validateChapter(data, expectedNo) {
  const chapter = data.chapter;
  if (!chapter || typeof chapter !== "object") throw new Error("返回里没有 chapter。");
  if (chapter.no !== expectedNo) throw new Error(`章节号不是 ${expectedNo}。`);
  for (const key of ["title", "goal", "turn", "hook"]) {
    if (!chapter[key]?.trim()) throw new Error(`第 ${expectedNo} 章缺字段:${key}`);
  }
  return chapter;
}
export function renderMarkdown(outline, story) {
  const people = outline.characters
    .map(
      (person) =>
        `## ${person.name}(${person.role})\n\n- 目标:${person.goal}\n- 冲突:${person.conflict}\n- 说话方式:${person.voice}`,
    )
    .join("\n\n");

  const chapters = outline.chapters
    .map(
      (chapter) =>
        `## 第 ${chapter.no} 章 ${chapter.title}\n\n- 目标:${chapter.goal}\n- 转折:${chapter.turn}\n- 结尾钩子:${chapter.hook}`,
    )
    .join("\n\n");

  return {
    characters: `# ${story.title}:人物卡\n\n${people}\n`,
    chapters: `# ${story.title}:章节大纲\n\n${chapters}\n`,
  };
}

三个关键点:

  • buildCharacterPrompt 只问人物,不让模型同时写章节,任务小,漏字段少;
  • buildChapterPrompt 把人物卡和前面章节的转折一起喂回去,章节之间才接得上;
  • 校验只认 JSON 里的字段,模型说“下面是结果”不算数。

写模型调用

保存为 resources/novel-outline/model.mjs:

import { SYSTEM_PROMPT } from "./prompt.mjs";

export async function callModel({
  baseUrl,
  model,
  prompt,
  temperature = 0.7,
} = {}) {
  const response = await fetch(`${baseUrl.replace(/\/+$/, "")}/chat/completions`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      model,
      temperature,
      messages: [
        { role: "system", content: SYSTEM_PROMPT },
        { role: "user", content: prompt },
      ],
    }),
  });

  if (!response.ok) {
    throw new Error(`模型接口返回 ${response.status}:${await response.text()}`);
  }

  const data = await response.json();
  const content = data.choices?.[0]?.message?.content;
  if (!content) throw new Error("模型返回里没有 content。");
  return content;
}

这里走的是 OpenAI 兼容的 /v1/chat/completions,所以 Ollama 和 llama.cpp 都能接。换成别的本地服务,只要接口兼容,脚本不用改。

写命令入口

保存为 resources/novel-outline/outline.mjs:

import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { parseArgs } from "node:util";

import { callModel } from "./model.mjs";
import {
  buildChapterPrompt,
  buildCharacterPrompt,
  extractJson,
  renderMarkdown,
  validateChapter,
  validateCharacters,
} from "./prompt.mjs";

function display(file) {
  const relative = path.relative(process.cwd(), file);
  return relative.startsWith("..") ? file : relative;
}

const isDirectRun =
  process.argv[1] &&
  pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;

if (isDirectRun) {
  const { values, positionals } = parseArgs({
    options: {
      "base-url": { type: "string", default: "http://127.0.0.1:11434/v1" },
      model: { type: "string", default: "qwen2.5:0.5b" },
      out: { type: "string", default: "out" },
      "dry-run": { type: "boolean", default: false },
    },
    allowPositionals: true,
  });
  const storyPath = positionals[0];

  if (!storyPath) {
    console.error("用法:node resources/novel-outline/outline.mjs story.json [--model qwen2.5:0.5b]");
    process.exitCode = 1;
  } else {
    try {
      const story = JSON.parse(await readFile(storyPath, "utf8"));
      const baseUrl = values["base-url"];
      const model = values.model;

      if (values["dry-run"]) {
        console.log(buildCharacterPrompt(story));
      } else {
        const started = Date.now();
        const characterRaw = await callModel({
          baseUrl,
          model,
          prompt: buildCharacterPrompt(story),
        });
        const characters = validateCharacters(
          extractJson(characterRaw),
          story.characters ?? 3,
        );

        const chapterTotal = story.chapters ?? 4;
        const chapters = [];
        for (let no = 1; no <= chapterTotal; no += 1) {
          const chapterRaw = await callModel({
            baseUrl,
            model,
            prompt: buildChapterPrompt(story, characters, { no, previous: chapters }),
            temperature: 0.6,
          });
          chapters.push(validateChapter(extractJson(chapterRaw), no));
        }

        const files = renderMarkdown({ characters, chapters }, story);
        await mkdir(values.out, { recursive: true });
        const characterFile = path.join(values.out, "人物卡.md");
        const chapterFile = path.join(values.out, "章节大纲.md");
        await writeFile(characterFile, files.characters);
        await writeFile(chapterFile, files.chapters);

        console.log(`人物:${characters.length} 个`);
        console.log(`章节:${chapters.length} 章`);
        console.log(`人物卡:${display(characterFile)}`);
        console.log(`章节大纲:${display(chapterFile)}`);
        console.log(`用时:${((Date.now() - started) / 1000).toFixed(1)} 秒`);
      }
    } catch (error) {
      console.error(
        /fetch failed/i.test(error.message)
          ? "连不上本地模型。先启动 Ollama,或用 --base-url 指向 llama.cpp server。"
          : error.message,
      );
      process.exitCode = 1;
    }
  }
}

注意默认模型写的是 qwen2.5:0.5b,只是为了“先跑通”。真要出能用的结构,跑的时候加 --model qwen2.5:3b。

写故事设定

新建 story.json:

{
  "title": "末班地铁",
  "genre": "都市悬疑",
  "premise": "夜班护士林夏每天坐末班地铁,发现一个老人提着的旧收音机里,播的是第二天的新闻。",
  "characters": 3,
  "chapters": 4
}

只写五件事:书名、类型、一句话设定、人物数、章节数。设定越短,模型越不容易跑题。

跑一次

用 Ollama 的 3B:

node resources/novel-outline/outline.mjs story.json --model qwen2.5:3b

我实际验证用的是 llama.cpp + Qwen2.5-3B(Q4),8 核 CPU:

node resources/novel-outline/outline.mjs story.json \
  --base-url http://127.0.0.1:8082/v1 --model local

真实输出:

人物:3 个
章节:4 章
人物卡:out/人物卡.md
章节大纲:out/章节大纲.md
用时:57.7 秒

模型要调用 5 次:1 次人物卡,4 次章节。每次只生成一小段,速度和稳定性都比一次生成全部好。

看结果

out/人物卡.md 里是这样:

AI 生成内容人物卡 · 由模型 JSON 渲染为人物卡,内容未改写
## 林夏(主角)

- 目标:揭开老人收音机的秘密,避免老人受到未知危险
- 冲突:老人的沉默和收音机中播送的内容
- 说话方式:喜欢用短句,语速较快,喜欢打断对方说话

## 老人(对手)

- 目标:隐瞒收音机的秘密,保护收音机中的信息不被外人知晓
- 冲突:林夏的不断追问和调查
- 说话方式:喜欢用长句,语速较慢,喜欢先思考再回答

out/章节大纲.md 里是 4 章:

AI 生成内容章节大纲 · 章节标题、转折与钩子来自模型输出,表格为便于阅读整理
章 标题 转折
1 深夜的秘密 林夏发现收音机播的是第二天的新闻
2 夜深人静 老人突然消失
3 凌晨的谜团 老人在住处留下带线索的纸条
4 黎明的谜团 林夏在日记里找到关键信息

这份大纲不算好:第 2、3 章的转折偏平,人物卡和章节之间的衔接还可以再紧一点。但它把“谁要什么、每章发生什么、下一章留什么”固定下来了,改结构比改正文快。

小模型会在哪里翻车

我实测了三个尺寸,问题很具体:

  • 0.5B:JSON 经常坏掉,人物卡漏 goal,还会把“末班地铁”当人名。只能用来验证脚本能跑通。
  • 1.5B:人物卡能过校验,但让它一次写 4 章,只返回 1 章。改成逐章生成后才稳定。
  • 3B:3 人物、4 章都能过一次校验;仍会重复“收音机的秘密”这类说法,需要人工改。

所以脚本没有做“一次生成整份大纲”,而是人物一次、每章一次。小模型的任务越小,越不容易偷懒。

常见坑

1. 提示 fetch failed

模型服务没起,或端口不对。Ollama 默认 11434;llama.cpp 启动时用 --port 指定。先确认 curl http://127.0.0.1:11434/v1/models 有返回。

2. JSON 解析失败

小模型会在 JSON 前后加解释,或漏引号。脚本会从第一个 { 截到最后一个 },但坏在中间的 JSON 救不回来。重跑一次,或换 3B。

3. 人物卡里出现书名、地点、组织

提示词已经写了“不要把书名、地点、组织当人物”,小模型仍会犯。3B 明显少一些;还出现就手动删掉,再跑章节。

4. 章节之间接不上

脚本把前面章节的 turn 喂给下一章,但只喂一句。长篇小说要换成更完整的摘要,否则后面会重复前面的冲突。

下一步读什么

人物卡和大纲稳定后,下一步是逐章扩写和一致性检查,这两篇还在AI 写作与小说合集里排队。

还没装本地模型,先看本地跑大模型前先算硬件;装完再回来跑这篇。

常见问题

没有显卡能跑吗?

能。3B 的量化模型约 2GB,8 核 CPU 跑 4 章大约 1 分钟;7B 会慢很多,但不至于跑不动。内存至少留 8GB。

一定要装 Ollama 吗?

不一定。脚本只要求 OpenAI 兼容接口,Ollama 和 llama.cpp 的 server 都可以;我用 llama.cpp 跑的下面这份输出。

生成的人物卡能直接用吗?

只能当结构草稿。人物动机、转折和台词仍要人工改;小模型容易重复同一个说法,尤其是章节的转折。

为什么不用 0.5B,越小越快?

0.5B 会漏字段、把书名当人名,JSON 也常坏。1.5B 能出人物卡,但写多章会只写一章。3B 是能稳定跑完这条流程的下限。

每周一张复习表

每周分享一张实用复习表,不收钱不打扰,想走随时走

邮箱存在 Cloudflare KV 里,只用来发每周更新;任何时候写信到hello@genwozuo.com说「退订」就删掉。详见隐私政策。

同方向继续

全部教程