AI 写作与小说:本地模型做人物卡和章节大纲
用一个本地 3B 小模型,把一句话设定变成 3 张人物卡和 4 章大纲;脚本负责提示词、JSON 校验和 Markdown 输出。全程不联网,不依赖付费写作服务。
本页目录
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 里是这样:
## 林夏(主角)
- 目标:揭开老人收音机的秘密,避免老人受到未知危险
- 冲突:老人的沉默和收音机中播送的内容
- 说话方式:喜欢用短句,语速较快,喜欢打断对方说话
## 老人(对手)
- 目标:隐瞒收音机的秘密,保护收音机中的信息不被外人知晓
- 冲突:林夏的不断追问和调查
- 说话方式:喜欢用长句,语速较慢,喜欢先思考再回答out/章节大纲.md 里是 4 章:
| 章 | 标题 | 转折 |
|---|---|---|
| 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说「退订」就删掉。详见隐私政策。