☰
小说自动生成系统(一):用大模型与Prompt搭建创作SDK的TaoToken实践
2026/10/9 11:51:28 网站建设 项目流程

1. 从零搭一个小说自动生成系统,为什么第一步是统一 LLM 通道

想用大模型写小说,很多人第一反应是打开某个网页对话框,把主题丢进去,等它吐几千字出来。玩一两次可以,但只要你打算把它做成一个能反复跑、能扩展、能接自己前端的产品,网页对话框这条路很快就走不通了。原因很直接:你需要的是程序化调用,是能嵌进代码里的 SDK,是能控制温度、能重试、能解析结构化输出的稳定通道。

我这次要做的,是一个最小可跑通的小说自动生成系统。它的目标不是一上来就搞出十万字长篇,而是先跑通一个闭环:给定主题和章节数,让大模型先产出结构化大纲,再逐章扩写正文,最后把结果落盘。这个闭环里有两个关键角色,一个是大模型本身,另一个是 Prompt。大模型负责生成,Prompt 负责约束它生成什么、以什么格式生成。而把这两者串起来的,是一个统一的调用通道。

这里就引出一个现实问题:市面上的模型太多了。DeepSeek、Moonshot、通义、还有本地跑的 Ollama,它们大多提供 OpenAI 兼容接口,但 base_url、api_key、模型名各不相同。如果每换一个模型就改一遍代码,项目会变得很难维护。所以我选择用 TaoToken 作为统一入口,把 Key 和 API 通道收敛到一处,代码里只认一套配置。这样后面无论换模型还是加模型,改动都集中在配置文件里,业务逻辑一行不用动。

这篇文章面向的是想快速验证 AI 写作流程的开发者。你不需要有很深的机器学习背景,只要会 Python、能看懂 JSON、愿意动手敲命令就行。我会给出可复制的 SDK 初始化配置、Prompt 模板,以及一次真实的生成验证动作。跑完之后,你手里会有一个能自己扩展的小说生成骨架。

先说清楚整体设计。一本小说,抽象出来就是标题、主题、全书梗概,加上若干章节。每个章节有顺序、标题、本章情节要点、可选线索,以及最重要的正文字段。注意这里有个设计取舍:规划阶段生成大纲时,各章节的正文是空的;写作阶段再逐章把正文填进去。所以不需要为「已写完的章节」单独建一个类,同一个章节模型,正文字段从空到满就行。这个设计让数据流非常干净,规划、写作、导出三个阶段共用一套数据结构。

环境方面,我用的是一台普通开发机,Python 3.11,包管理用 uv。uv 比 pip 快很多,初始化项目、建虚拟环境、装依赖都是一条命令的事。整个项目目录叫 Novel,核心代码放在 src 下。下面我会一步步带你把这个骨架搭起来,重点放在配置、Prompt 和 LLM 客户端这三块,因为它们决定了系统能不能稳定跑通。

2. TaoToken 前置准备:把 Key 和 API 通道收敛到一处

在写任何业务代码之前,先把模型接入这层搞定。这一步做扎实了,后面调试会省很多事。TaoToken 的作用,简单说就是给你一个统一的 OpenAI 兼容端点,你用一套 Key 就能调用多种模型,不用为每个模型单独申请、单独配置。对于小说生成这种需要反复试不同模型、调不同温度的场景,这种统一入口非常实用。

先拿到你的 API Key。登录 TaoToken 官网,进入控制台,在 API Keys 页面创建一个新的 Key。创建时给它起个能认出来的名字,比如 novel-dev,方便以后区分。Key 只在创建时完整显示一次,复制下来存好,别直接写死在代码里,后面我们会用 .env 文件管理。

拿到 Key 之后,记下两个地址。API 基础地址是 https://taotoken.net/api,这个地址兼容 OpenAI 的接口规范,所以任何支持 OpenAI SDK 的代码都能直接对接。模型对话的入口在 https://taotoken.net/api 下的 chat completions 路径,SDK 会自动拼接,你只需要填 base_url 就行。

这里要强调一个概念:Base URL、API Key、Model ID 这三件套,是接入任何 OpenAI 兼容服务的核心。Base URL 告诉 SDK 请求发到哪里,API Key 证明你有权限,Model ID 决定用哪个模型。三者缺一不可,而且必须匹配。很多人接入失败,不是代码写错了,而是这三件套里有一个填错了,比如 base_url 多写了斜杠,或者 model 名拼错了。

关于模型选择,TaoToken 支持多种模型,你可以根据任务特点来挑。大纲规划这种需要结构化输出、逻辑严谨的任务,适合用指令遵循能力强的模型;正文扩写这种需要文采和创造力的任务,可以换一个更擅长写作的模型。因为通道统一了,换模型只需要改配置里的一个字符串,非常灵活。

如果你打算长期做编码和 Agent 类的开发,可以了解一下 Coding Plan,它针对这类高频调用场景做了优化。不过对于本文这个小说生成的最小闭环,按量调用就够了,先把流程跑通再说。

配置这块,我建议在项目根目录建一个 .env 文件,把所有敏感信息和可变参数都放进去。这样做有两个好处:一是代码里不出现明文 Key,二是换模型、调温度不用改代码。下面是我实际用的配置,你可以直接复制,把 API_KEY 换成你自己的。

# ===== LLM 服务配置 ===== # 兼容 OpenAI 及所有 OpenAI 兼容端点 API_KEY=你的_TaoToken_Key BASE_URL=https://taotoken.net/api MODEL=deepseek-v4-flash # ===== 生成参数 ===== # 大纲偏稳(低一些),正文偏活(高一些) OUTLINE_TEMPERATURE=0.7 CHAPTER_TEMPERATURE=0.85 MAX_TOKENS=4096 # ===== 重试 ===== MAX_RETRIES=3

注意 BASE_URL 这里填的是 TaoToken 的 API 地址,不是某个具体模型厂商的地址。这就是统一通道的价值:你的代码只认这一个地址,背后用哪个模型由 MODEL 字段决定。温度参数我分成了两个,大纲用 0.7,正文用 0.85,原因后面讲温度的时候会详细说。

装依赖的时候,除了 openai 这个 SDK,还要装 python-dotenv,它负责把 .env 文件里的配置读进环境变量。命令是 uv add openai python-dotenv。装完之后,写一个 config.py 把这些配置加载进来,供其他模块使用。

import os from dotenv import load_dotenv load_dotenv() # 自动加载当前目录下的 .env 文件 api_key: str = os.getenv("API_KEY", "") base_url: str = os.getenv("BASE_URL", "https://taotoken.net/api") model: str = os.getenv("MODEL", "deepseek-v4-flash") outline_temperature: float = float(os.getenv("OUTLINE_TEMPERATURE", "0.7")) chapter_temperature: float = float(os.getenv("CHAPTER_TEMPERATURE", "0.85")) max_tokens: int = int(os.getenv("MAX_TOKENS", "4096")) max_retries: int = int(os.getenv("MAX_RETRIES", "3"))

这段代码里,os.getenv 的第二个参数是默认值。这样即使 .env 文件里漏了某一项,程序也不会直接崩,而是用一个合理的默认值兜底。对于 base_url,我特意把默认值设成了 TaoToken 的地址,这样即使配置缺失,请求也会发到统一通道,而不是某个不确定的地方。

到这里,前置准备就完成了。你有了 Key,有了统一地址,有了配置文件加载逻辑。接下来就是写真正调用模型的代码。

3. 可复制配置:Prompt 模板与 LLM 客户端怎么写

这一节是全文的核心,我会把 Prompt 模板和 LLM 客户端两块代码完整给出来,你复制过去改改就能用。先说 Prompt,因为它是决定生成质量的关键。

小说生成分两个阶段,规划阶段和写作阶段,两个阶段的 Prompt 风格完全不同。规划阶段要求模型输出严格的 JSON,因为后面代码要解析它、存进数据结构。写作阶段要求模型输出纯正文,因为那是给人看的小说内容。这两套 Prompt 我放在一个 prompt.py 文件里集中管理,不散落到业务逻辑里,方便以后调优。

规划阶段的系统提示词,核心是约束模型只输出 JSON,不要夹带解释、不要包 markdown 代码块。这一点非常重要,因为大模型有个习惯,喜欢在 JSON 外面加一句「好的,这是您要的大纲」之类的话,或者用 ```json 包起来。这些都会导致解析失败。所以提示词里要明确禁止。

OUTLINE_SYSTEM = """你是一位资深小说策划与编辑。你的任务是根据用户给定的主题,生成一份结构清晰、情节连贯的小说大纲。 你必须严格只输出一个 JSON 对象,不要输出任何解释、前后缀文字或 markdown 代码块标记(不要写 ```json)。 JSON 结构如下: { "title": "小说标题", "theme": "用户给定的主题", "summary": "全书梗概,100~200 字,交代背景、主线与结局走向", "chapters": [ { "index": 1, "title": "第X章 章节标题", "outline": "本章核心事件、出场人物、情节要点,3~5 句话", "clues": ["可选:本章埋设或呼应的线索"] } ] } 要求: - 章节数量严格等于用户指定数量 - 章节之间情节递进、有因果,不能各自孤立 - 每章 outline 要具体到可据此扩写正文,不要空泛 """ def outline_user(theme: str, chapter_count: int) -> str: return ( f"主题:{theme}\n" f"章节数量:{chapter_count}\n\n" f"请据此生成大纲 JSON。只输出 JSON 本身。" )

写作阶段的系统提示词,重点转向文笔和连贯性。这里要告诉模型:遵循大纲但不要照抄,要丰富细节;要和全书设定、前情保持一致;只输出正文,不要复述大纲。字数要求也写进去,我设的是不少于 1500 字,你可以按需调整。

CHAPTER_SYSTEM = """你是一位文笔老练的小说家。你的任务是根据给定的大纲章节信息,扩写出该章的完整正文。 要求: - 遵循本章大纲的情节要点,但不要照抄,要丰富细节、对话、场景描写 - 与全书设定、人物性格、前情保持一致,不要出现前后矛盾 - 文风统一,语言生动 - 只输出小说正文(可使用 Markdown 段落),不要输出 JSON,不要解释,不要复述大纲 - 正文字数不少于 1500 字 """ def chapter_user( book_title: str, book_summary: str, chapter_title: str, chapter_outline: str, chapter_clues: list[str], prev_ending: str = "", ) -> str: clues_text = ";".join(chapter_clues) if chapter_clues else "无" parts = [ f"【全书标题】{book_title}", f"【全书梗概】{book_summary}", f"【本章标题】{chapter_title}", f"【本章大纲】{chapter_outline}", f"【本章线索】{clues_text}", ] if prev_ending: parts.append(f"【上一章结尾】……{prev_ending}\n(请衔接上一章结尾,保持连贯)") parts.append("\n请扩写本章正文。") return "\n".join(parts)

注意 chapter_user 里有个 prev_ending 参数,用来把上一章的结尾片段传给模型,让它衔接。这是保证长篇连贯性的一个小技巧。首章没有上一章,传空字符串就行。

Prompt 准备好了,接下来是 LLM 客户端。这是全项目唯一和模型打交道的地方,其他代码都通过它调模型。它要提供两种调用方式:普通对话返回文本,要 JSON 的对话返回解析好的字典。后者有个现实问题:模型经常把 JSON 包在代码块里或夹带废话,所以拿回来要先清洗再解析;如果还是失败,就把错误回喂给模型,让它自己改一次。

先定义异常层次,区分「调用本身失败」和「JSON 解析失败」,这样上层代码能针对性地处理。

class LLMError(Exception): """所有 LLM 相关异常的基类。""" class LLMConnectionError(LLMError): """调用本身失败:网络错误、超时、限流、服务端 5xx 等。""" class LLMJSONError(LLMError): """LLM 返回了文本,但 JSON 解析失败。""" def __init__(self, message: str, raw_text: str, parse_error: str): super().__init__(message) self.raw_text = raw_text self.parse_error = parse_error

然后是客户端实例的延迟导入。为什么要延迟导入?因为这样即使没装 openai,纯逻辑部分(比如 JSON 清洗)也能单独测试,不会因为 import 失败而整个模块挂掉。

_client = None def _get_client(): global _client if _client is None: from openai import OpenAI _client = OpenAI(api_key=config.api_key, base_url=config.base_url) return _client

核心的调用函数带指数退避重试。网络抖动、限流这些情况很常见,一失败就崩体验很差。重试策略是 1 秒、2 秒、4 秒这样退避,最多重试 config.max_retries 次。判断哪些异常值得重试也有讲究:超时和限流值得重试,5xx 服务端错误值得重试,4xx 客户端错误(比如 Key 错了)重试也没用,直接抛。

def _should_retry(exc: Exception) -> bool: try: from openai import APIError, APITimeoutError, RateLimitError except ImportError: return False if isinstance(exc, (APITimeoutError, RateLimitError)): return True if isinstance(exc, APIError): status = getattr(exc, "status_code", 0) or 0 return 500 <= status < 600 return False def _chat_messages(messages: list[dict], temperature: float | None) -> str: client = _get_client() max_attempts = config.max_retries + 1 last_exc = None for attempt in range(1, max_attempts + 1): try: resp = client.chat.completions.create( model=config.model, messages=messages, temperature=temperature, max_tokens=config.max_tokens, ) return resp.choices[0].message.content or "" except Exception as e: last_exc = e if _should_retry(e) and attempt < max_attempts: wait = 2 ** (attempt - 1) time.sleep(wait) continue raise LLMConnectionError( f"LLM 调用失败(尝试 {attempt} 次后放弃): {e}" ) from e raise LLMConnectionError( f"LLM 调用失败(尝试 {max_attempts} 次后放弃): {last_exc}" )

JSON 清洗函数负责把模型返回的文本里真正的 JSON 抠出来。策略是先去 markdown 代码块标记,再找第一个 { 和最后一个 } 之间的内容。这个策略覆盖了绝大多数情况。

def _clean_json(raw: str) -> str: text = re.sub(r"```(?:json)?\s*", "", raw).strip() start = text.find("{") end = text.rfind("}") if start != -1 and end != -1 and end > start: return text[start : end + 1] return text

chat_json 是重点。它先调一次,解析失败就把错误和原始输出回喂给模型,让它自己修正一次。第二次还失败才抛 LLMJSONError。这个自我修正机制在实际使用中能救回不少格式问题。

def chat_json(prompt: str, system: str | None = None, temperature: float | None = None) -> dict: messages = [] if system is not None: messages.append({"role": "system", "content": system}) messages.append({"role": "user", "content": prompt}) raw = _chat_messages(messages, temperature) cleaned = _clean_json(raw) try: return json.loads(cleaned) except json.JSONDecodeError as e: first_error = str(e) fix_prompt = ( "你上次返回的内容不是合法的 JSON。\n" f"解析错误:{first_error}\n\n" f"你上次返回的原始内容:\n{raw}\n\n" "请只输出一个合法的 JSON 对象,不要包含任何解释、前后缀文字或 markdown 代码块标记。" ) fixed_messages = messages + [ {"role": "assistant", "content": raw}, {"role": "user", "content": fix_prompt}, ] raw2 = _chat_messages(fixed_messages, temperature) cleaned2 = _clean_json(raw2) try: return json.loads(cleaned2) except json.JSONDecodeError as e2: raise LLMJSONError( "JSON 解析失败(自我修正后仍失败)", raw_text=raw2, parse_error=str(e2), ) from e2

到这里,配置、Prompt、客户端三块都齐了。这套代码的优点是:换模型只改 .env,调温度只改 .env,业务逻辑完全不用动。而且异常分得清楚,出问题能快速定位是网络问题还是格式问题。

4. 验证请求:跑通从设定到章节草稿的最小闭环

代码写完了,得验证它真能跑。这一节我带你把整个闭环跑一遍,从主题输入到章节草稿落盘。先确认目录结构,应该是这样的:

Novel/ ├── .env ├── pyproject.toml ├── src/ │ ├── __init__.py │ ├── config.py │ ├── prompt.py │ ├── llm_client.py │ └── schema.py

schema.py 里用 pydantic 定义数据结构。一本小说有标题、主题、全书概括、章节列表;每个章节有顺序、标题、情节要点、线索、正文。正文默认空字符串,写作阶段再填。

from pydantic import BaseModel, Field class Chapter(BaseModel): index: int title: str outline: str clues: list[str] = Field(default_factory=list) content: str = "" class Novel(BaseModel): title: str theme: str summary: str chapters: list[Chapter] = Field(default_factory=list)

现在写一个验证脚本,跑一次完整流程。先调大纲,再逐章扩写。为了快速验证,章节数设成 3,正文长度要求可以临时调低。

from src import config, prompt from src.llm_client import chat_json, chat from src.schema import Novel, Chapter def generate_outline(theme: str, chapter_count: int) -> Novel: data = chat_json( prompt=prompt.outline_user(theme, chapter_count), system=prompt.OUTLINE_SYSTEM, temperature=config.outline_temperature, ) return Novel(**data) def write_chapter(novel: Novel, chapter: Chapter, prev_ending: str = "") -> str: return chat( prompt=prompt.chapter_user( book_title=novel.title, book_summary=novel.summary, chapter_title=chapter.title, chapter_outline=chapter.outline, chapter_clues=chapter.clues, prev_ending=prev_ending, ), system=prompt.CHAPTER_SYSTEM, temperature=config.chapter_temperature, ) if __name__ == "__main__": novel = generate_outline("一个关于时间循环的悬疑故事", 3) print(f"标题:{novel.title}") print(f"梗概:{novel.summary}") for ch in novel.chapters: print(f" {ch.index}. {ch.title} - {ch.outline[:40]}...") prev = "" for ch in novel.chapters: ch.content = write_chapter(novel, ch, prev) prev = ch.content[-200:] print(f"第 {ch.index} 章写完,{len(ch.content)} 字")

运行这个脚本,你会看到类似这样的输出:先打印出标题、梗概和章节列表,然后逐章打印写完的字数。如果一切正常,大纲的 JSON 被正确解析成了 Novel 对象,每章正文也生成了。这就是从设定到章节草稿的最小闭环。

验证的时候有几个点要留意。第一,大纲返回的 JSON 里 chapters 的字段名要和 schema 对上,如果模型返回了额外的字段,pydantic 默认会忽略,但如果缺了必填字段就会报错。第二,正文生成时如果模型返回了空字符串,可能是 max_tokens 设太小被截断了,或者触发了内容过滤。第三,如果中途报 LLMJSONError,把 raw_text 打出来看看模型到底返回了什么,多半是格式问题,调整 Prompt 里的约束能解决。

跑通之后,你可以把生成结果存成文件,方便查看和后续处理。比如把每章正文写进单独的 markdown 文件,或者把整个 Novel 对象序列化成 JSON 存档。这些扩展都很简单,因为数据结构是清晰的。

实测下来,这套流程在 TaoToken 统一通道上跑得很稳。换模型只需要改 .env 里的 MODEL 字段,比如从 deepseek-v4-flash 换成别的写作模型,代码一行不用动。这种灵活性对于需要反复试不同模型、调不同参数的创作类项目来说,价值很大。

5. 本篇常见错排查:401、local proxy failed、reading choices 怎么解

接入和调试过程中,有几类报错特别常见。我把它们整理出来,对照着排查能省不少时间。这些报错大多不是代码逻辑问题,而是配置或环境问题。

第一类是 401 错误,提示 authentication failed 或 invalid api key。这几乎总是 Key 的问题。检查三处:.env 里的 API_KEY 是不是复制完整了,有没有多余的空格或换行;这个 Key 在 TaoToken 控制台是不是还有效,有没有被删除或过期;代码里加载 .env 的路径对不对,如果脚本不在项目根目录运行,load_dotenv 可能找不到文件。一个快速验证方法是把 Key 打印出来看前几位和后几位,确认没被截断。

第二类是 local proxy failed 或 connection error。这类报错通常和网络环境有关。先确认 BASE_URL 填的是 https://taotoken.net/api,注意是 https 不是 http,末尾不要多加斜杠。然后检查本机有没有设置一些奇怪的全局代理,有时候系统代理会干扰 SDK 的正常请求。如果你在公司网络里,防火墙可能拦了外部请求,换个网络环境试试。还有一种情况是 DNS 解析问题,可以 ping 一下域名看能不能通。

第三类是 reading choices 相关的报错,比如 'NoneType' object has no attribute 'choices' 或者 list index out of range。这说明 resp.choices 是空的或者 None。可能的原因有几个:模型返回了内容但被内容过滤拦截了,导致 choices 为空;max_tokens 设得太小,模型还没输出有效内容就结束了;请求参数有问题,比如 temperature 超出了模型支持的范围。排查方法是把完整的 resp 对象打出来看,或者临时把 max_tokens 调大、temperature 调回默认值再试。

第四类是 JSON 解析失败,抛 LLMJSONError。这个前面讲过,模型返回的文本不是合法 JSON。先看 raw_text,如果里面夹带了「好的,以下是」之类的话,说明 Prompt 约束不够强,在系统提示词里再强调一遍「只输出 JSON」。如果 JSON 本身格式有问题,比如少了逗号、多了尾逗号,chat_json 的自我修正机制会尝试救一次,救不回来才抛错。如果频繁出现,可以考虑换一个指令遵循能力更强的模型来跑大纲阶段。

第五类是 OAuth 或权限相关报错。如果你用的是某些需要额外授权的服务,可能会遇到 token 过期或 scope 不足的问题。对于 TaoToken 这种用 API Key 的方式,一般不会遇到 OAuth 问题,但如果你在代码里混用了其他认证方式,要确认没有冲突。检查一下有没有环境变量里残留了其他服务的 Key,导致 SDK 拿错了凭证。

第六类是模型名错误,提示 model not found。检查 .env 里的 MODEL 字段拼写,大小写要和服务端一致。不同模型的命名规则不一样,有的带版本号有的不带,最好从 TaoToken 的文档里复制准确的模型 ID。如果换了模型但忘了改配置,也会报这个错。

排查这类问题的通用思路是:先看报错信息里的关键词,定位是认证、网络、参数还是格式问题;再把关键变量(base_url、model、key 前几位)打印出来确认;最后用最小化的请求单独测试,排除业务代码的干扰。大部分问题都能在这三步里找到答案。

6. 把通道固定下来,让创作逻辑自由生长

跑通这个最小闭环之后,你会发现真正花时间的不是调用模型,而是调 Prompt 和试参数。这时候一个稳定的接入通道就特别重要,它让你能把精力集中在创作逻辑上,而不是反复折腾配置。

我现在的做法是,所有模型调用都走 TaoToken 这一条通道,Key 和地址固定在 .env 里,业务代码只依赖 config 模块暴露的变量。这样无论后面是加大纲润色、人物设定管理,还是接一个前端界面,底层都不用动。想试新模型,改一个字符串就行;想调温度,改一个数字就行。

如果你也想快速验证自己的 AI 写作想法,建议就从这套骨架开始。先把大纲和单章扩写跑通,再逐步加功能。下一步可以做的方向很多:给章节加人物一致性检查、把生成结果做成可编辑的草稿、加一个简单的 Web 界面让非开发者也能用。这些都是在稳定通道之上生长出来的东西。

需要 Key 和接入文档的话,可以从 API Keys 页面创建,接入细节看接入文档。想先感受一下模型对话效果,可以直接在模型对话里试。如果打算长期做编码和 Agent 相关的开发,Coding Plan 值得了解一下。通道固定下来,剩下的就是让创作逻辑自由生长了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询