
简介这是一套基于Python的古诗生成器完整源码将后端算法与前端界面设计融为一体面向文学爱好者、编程学习者及对AI古诗创作感兴趣的开发者。项目共43个文件压缩包约10.85MB包含7个Python脚本负责生成算法、数据处理、模型训练与诗词评估5个CSS与5个JavaScript文件构建前端布局与交互逻辑另有XML配置、文本说明、GIF与PNG图形素材及字体文件目录结构清晰、模块划分明确。已有324人学习下载。读者可从中获取一套前后端一体的可运行系统理解古诗生成从数据加载、模型定义到效果评估的完整链路并借助合理注释与文件组织进行二次开发或功能拓展在体验传统文化魅力的同时掌握自然语言处理与网页设计的结合思路。1. 古诗生成器到底在生成什么从字符级语言模型说起很多人第一次听到「基于 Python 的古诗生成器」脑子里浮现的是调用某个大模型接口输入「写一首关于月亮的诗」然后等结果。但真正自己动手做一遍就会发现核心问题根本不是调用而是模型怎么理解「诗」这种高度结构化的文本。古诗有固定的字数、平仄、押韵、意象搭配这些约束如果不在建模阶段处理生成出来的东西就是一堆看起来像诗、读起来别扭的字符堆砌。这个方案要解决的问题很具体用 Python 从零搭一个字符级语言模型让它学会五言、七言绝句的节奏再通过一个前端页面把生成过程暴露给用户。适合两类人一是想入门 NLP 但不想一上来就啃 Transformer 的开发者二是手里有前端项目、想加一个「有点意思」的 AI 功能但不想接第三方 API 的人。整条链路不依赖外部服务训练和推理都在本地完成源码结构清晰改起来不费劲。2. 数据准备与模型选型为什么字符级 RNN 仍然是入门首选2.1 古诗语料从哪来、怎么清洗做古诗生成语料质量直接决定生成结果的上限。常见做法是找一份公开的古诗数据集通常是 JSON 或 CSV 格式每首诗包含标题、作者、正文。拿到之后不能直接丢进模型得先做几件事第一统一格式。把每首诗整理成「标题 换行 正文」的纯文本正文里去掉标点符号只保留汉字。第二过滤长度。五言绝句是 20 个字七言绝句是 28 个字把长度不在这个范围内的诗剔除或者单独归类。第三去重。同一首诗在不同来源里可能重复出现用集合去重。import json import re def clean_poems(raw_path, output_path): with open(raw_path, r, encodingutf-8) as f: data json.load(f) seen set() cleaned [] for item in data: title item.get(title, ).strip() author item.get(author, ).strip() paragraphs item.get(paragraphs, []) # 合并正文去掉标点 content .join(paragraphs) content re.sub(r[^\u4e00-\u9fff], , content) # 只保留五言或七言绝句20 或 28 字 if len(content) not in (20, 28): continue # 去重 key content if key in seen: continue seen.add(key) cleaned.append({ title: title, author: author, content: content }) with open(output_path, w, encodingutf-8) as f: json.dump(cleaned, f, ensure_asciiFalse, indent2) print(f清洗完成共 {len(cleaned)} 首) clean_poems(poems_raw.json, poems_clean.json)这段代码的逻辑很直白读原始 JSON逐条处理用正则去掉所有非汉字字符然后按长度过滤。re.sub(r[^\u4e00-\u9fff], , content)这行是关键\u4e00-\u9fff是 CJK 统一汉字的 Unicode 范围能把标点、空格、英文字母全部清掉。长度过滤那里20 和 28 分别对应五言和七言绝句如果你想生成律诗把 40 和 56 也加进去。注意语料里如果有大量重复或低质量的诗模型会学到很多无意义的组合。清洗阶段多花十分钟训练阶段少调三天参。2.2 字符级建模 vs 词级建模选型理由古诗生成有两种主流建模粒度字符级和词级。词级需要先分词而古诗的分词本身就是一个没有标准答案的问题——「床前明月光」是分成「床前/明月/光」还是「床/前/明月/光」不同分词结果会直接影响模型学到的模式。字符级则绕开了这个问题直接把每个汉字当作一个 token模型自己学字与字之间的转移规律。字符级的另一个好处是词表小。常用汉字也就几千个加上一些生僻字词表控制在 6000 左右就够了。这意味着嵌入层和输出层的参数量可控在普通笔记本上就能训练。词级模型词表动辄几万没有 GPU 会非常吃力。当然字符级也有代价它学不到「词」这个层级的语义单元生成时可能出现「明月」和「光」搭配不当的情况。但对于入门项目来说字符级 RNN 的性价比最高——代码量少、训练快、效果可感知。我一般会先用字符级 LSTM 跑通全流程确认数据管道和前端集成都没问题之后再考虑换更复杂的模型。2.3 用 PyTorch 搭一个字符级 LSTM 的最小实现模型结构不复杂嵌入层 → LSTM 层 → 全连接层。嵌入层把每个汉字映射成一个稠密向量LSTM 负责捕捉序列中的长距离依赖全连接层把 LSTM 的输出映射回词表大小的维度用来预测下一个字。import torch import torch.nn as nn class PoemLSTM(nn.Module): def __init__(self, vocab_size, embed_dim256, hidden_dim512, num_layers2): super().__init__() self.embed nn.Embedding(vocab_size, embed_dim) self.lstm nn.LSTM( embed_dim, hidden_dim, num_layers, batch_firstTrue, dropout0.3 ) self.fc nn.Linear(hidden_dim, vocab_size) def forward(self, x, hiddenNone): # x: (batch, seq_len) emb self.embed(x) # (batch, seq_len, embed_dim) out, hidden self.lstm(emb, hidden) logits self.fc(out) # (batch, seq_len, vocab_size) return logits, hiddenembed_dim256表示每个汉字被映射成 256 维向量这个值在字符级模型里够用了再大容易过拟合。hidden_dim512是 LSTM 隐藏状态的维度决定了模型能记住多少上下文信息。num_layers2表示堆两层 LSTM第一层输出传给第二层能学到更抽象的模式。dropout0.3是防止过拟合的常规操作古诗数据集通常不大不加 dropout 训练 loss 会降得很快但生成质量很差。前向传播的逻辑输入是(batch, seq_len)的整数张量经过嵌入层变成(batch, seq_len, embed_dim)LSTM 处理后输出(batch, seq_len, hidden_dim)最后全连接层把每个时间步的输出映射到词表维度得到每个位置预测下一个字的 logits。3. 训练流程与参数调优把 loss 降下去只是第一步3.1 构建词表与数据加载器训练之前要把所有汉字映射成整数 ID。做法很简单遍历所有诗统计字符频率按频率从高到低排序给每个字分配一个索引。保留一个特殊 token 用于填充或未知字符。from collections import Counter from torch.utils.data import Dataset, DataLoader class PoemDataset(Dataset): def __init__(self, poems, seq_len28): self.seq_len seq_len # 构建词表 all_text .join([p[content] for p in poems]) counter Counter(all_text) self.chars [pad] [c for c, _ in counter.most_common()] self.char2idx {c: i for i, c in enumerate(self.chars)} self.idx2char {i: c for c, i in self.char2idx.items()} self.vocab_size len(self.chars) # 把所有诗转成 ID 序列 self.sequences [] for p in poems: ids [self.char2idx[c] for c in p[content]] self.sequences.append(ids) def __len__(self): return len(self.sequences) def __getitem__(self, idx): seq self.sequences[idx] # 输入是前 n-1 个字目标是后 n-1 个字 x torch.tensor(seq[:-1], dtypetorch.long) y torch.tensor(seq[1:], dtypetorch.long) return x, yCounter统计所有字符的出现次数most_common()按频率排序。pad放在索引 0 的位置后面如果需要做 batch 内对齐可以用到。__getitem__里把每首诗切成输入和目标输入是seq[:-1]目标是seq[1:]也就是用前一个字预测后一个字。这是语言模型的标准做法叫「自回归训练」。数据加载器用 PyTorch 自带的DataLoader设置batch_size64、shuffleTrue即可。如果显存不够把 batch_size 降到 32 或 16。3.2 训练循环与损失函数选择损失函数用交叉熵优化器用 Adam学习率从 1e-3 开始。训练过程中要监控两个指标训练 loss 和生成样本的质量。loss 降到 1.5 以下时生成的诗通常已经有点样子了但还需要看实际效果。def train(model, dataloader, epochs50, lr1e-3, devicecpu): model model.to(device) criterion nn.CrossEntropyLoss(ignore_index0) optimizer torch.optim.Adam(model.parameters(), lrlr) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size20, gamma0.5) for epoch in range(epochs): model.train() total_loss 0 for x, y in dataloader: x, y x.to(device), y.to(device) optimizer.zero_grad() logits, _ model(x) # logits: (batch, seq_len, vocab_size) # y: (batch, seq_len) loss criterion(logits.view(-1, logits.size(-1)), y.view(-1)) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() scheduler.step() avg_loss total_loss / len(dataloader) print(fEpoch {epoch1}, Loss: {avg_loss:.4f}) # 每 10 个 epoch 生成一首看看效果 if (epoch 1) % 10 0: sample generate(model, 春, char2idx, idx2char, device) print(f样本: {sample})ignore_index0让损失函数忽略填充位虽然这个数据集里每首诗长度固定但加上这个参数更稳妥。clip_grad_norm_是梯度裁剪防止 LSTM 训练时梯度爆炸max_norm5.0是经验值。StepLR每 20 个 epoch 把学习率减半帮助模型在后期收敛得更稳。训练 50 个 epoch 在 CPU 上大概需要半小时到一小时取决于数据集大小。如果 loss 降到 1.0 以下还在降可以继续训练如果 loss 开始波动或上升说明过拟合了该停了。3.3 生成策略温度参数和 top-k 采样怎么调训练完之后生成诗的过程是给一个起始字模型预测下一个字的概率分布根据这个分布采样一个字然后把新字加到序列末尾继续预测下一个直到达到目标长度。def generate(model, start_char, char2idx, idx2char, devicecpu, max_len28, temperature0.8, top_k10): model.eval() chars [start_char] input_ids torch.tensor([[char2idx[start_char]]], dtypetorch.long).to(device) hidden None with torch.no_grad(): for _ in range(max_len - 1): logits, hidden model(input_ids, hidden) logits logits[:, -1, :] / temperature # 温度缩放 # top-k 过滤 if top_k 0: values, indices torch.topk(logits, top_k) probs torch.softmax(values, dim-1) next_idx indices[0][torch.multinomial(probs, 1).item()] else: probs torch.softmax(logits, dim-1) next_idx torch.multinomial(probs, 1).item() next_char idx2char[next_idx.item()] chars.append(next_char) input_ids torch.tensor([[next_idx.item()]], dtypetorch.long).to(device) return .join(chars)temperature控制生成的随机性。值越低比如 0.5模型越倾向于选概率最高的字生成结果更保守但可能重复值越高比如 1.2生成结果更多样但可能出现不合理的搭配。古诗生成一般用 0.7 到 0.9 之间。top_k10表示只从概率最高的 10 个字里采样避免选到那些概率极低但偶尔被选中的奇怪字符。这个参数对生成质量影响很大不设 top-k 的话模型偶尔会蹦出一些完全不相干的字。我一般会先用 top_k10、temperature0.8 跑一批样本看看效果再微调。4. 前端集成用 FastAPI 把模型包装成接口4.1 为什么选 FastAPI 而不是 Flask前端集成需要后端提供一个 HTTP 接口接收用户输入比如起始字或主题返回生成的诗。Flask 和 FastAPI 都能做这件事但 FastAPI 有几个优势自带异步支持、自动生成 API 文档、请求和响应模型用 Pydantic 定义类型检查更严格。对于这个项目来说FastAPI 的异步特性不是必须的因为模型推理本身是同步的。但自动生成的/docs页面在调试时非常方便不用手写测试脚本就能在浏览器里试接口。from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel import torch app FastAPI() # 允许前端跨域访问 app.add_middleware( CORSMiddleware, allow_origins[*], allow_methods[*], allow_headers[*], ) class GenerateRequest(BaseModel): start_char: str 春 max_len: int 28 temperature: float 0.8 top_k: int 10 class GenerateResponse(BaseModel): poem: str start_char: str # 全局加载模型启动时执行一次 model None char2idx None idx2char None app.on_event(startup) def load_model(): global model, char2idx, idx2char checkpoint torch.load(poem_lstm.pth, map_locationcpu) char2idx checkpoint[char2idx] idx2char checkpoint[idx2char] model PoemLSTM(vocab_sizelen(char2idx)) model.load_state_dict(checkpoint[model_state]) model.eval() app.post(/generate, response_modelGenerateResponse) def generate_poem(req: GenerateRequest): poem generate(model, req.start_char, char2idx, idx2char, max_lenreq.max_len, temperaturereq.temperature, top_kreq.top_k) return GenerateResponse(poempoem, start_charreq.start_char)CORSMiddleware是必须的否则前端页面发请求会被浏览器拦截。allow_origins[*]在开发阶段没问题上线时要改成具体的前端域名。app.on_event(startup)确保模型只在服务启动时加载一次而不是每次请求都重新加载——后者会让接口响应时间从几十毫秒变成几秒。GenerateRequest用 Pydantic 定义了请求体结构FastAPI 会自动做类型校验。如果前端传的temperature是字符串接口会直接返回 422 错误不用自己写校验逻辑。4.2 前端页面一个输入框加一个展示区就够了前端不需要框架也能做一个 HTML 文件加一段 JavaScript 就能跑。核心逻辑是用户输入起始字点击按钮发 POST 请求到后端把返回的诗显示在页面上。!DOCTYPE html html langzh head meta charsetUTF-8 title古诗生成器/title style body { font-family: KaiTi, serif; max-width: 600px; margin: 60px auto; } #poem { font-size: 24px; line-height: 2; margin-top: 30px; white-space: pre-wrap; } input, button { font-size: 18px; padding: 8px 16px; } /style /head body h2输入一个起始字/h2 input idstartChar value春 maxlength1 button onclickgeneratePoem()生成/button div idpoem/div script async function generatePoem() { const startChar document.getElementById(startChar).value; const resp await fetch(http://localhost:8000/generate, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ start_char: startChar, max_len: 28, temperature: 0.8, top_k: 10 }) }); const data await resp.json(); // 每 7 个字换行模拟七言绝句排版 const poem data.poem; let formatted ; for (let i 0; i poem.length; i 7) { formatted poem.slice(i, i 7) \n; } document.getElementById(poem).textContent formatted; } /script /body /html这段代码里fetch发 POST 请求请求体是 JSON 格式字段名要和后端 Pydantic 模型里的字段名一致。返回的data.poem是一个长字符串前端按每 7 个字换行让显示效果更像一首七言绝句。如果是五言把 7 改成 5 即可。注意前端直接写http://localhost:8000只适合本地开发。部署时后端地址会变建议把 API 地址抽成一个变量或者用相对路径加反向代理。4.3 接口联调时最容易卡住的三个地方第一个是跨域。前端在localhost:3000后端在localhost:8000浏览器会拦截请求。解决办法是在 FastAPI 里加 CORSMiddleware或者用 Nginx 做反向代理把前后端放在同一个域名下。第二个是请求体格式。FastAPI 默认期望 JSON 格式的请求体如果前端发的是表单数据application/x-www-form-urlencoded接口会返回 422。检查Content-Type头是否设置为application/json。第三个是模型加载路径。torch.load(poem_lstm.pth)里的路径是相对于启动服务的目录不是相对于代码文件。如果从不同目录启动服务会报文件找不到。用绝对路径或者os.path.dirname(__file__)拼接路径更稳妥。5. 避坑与排查训练和部署中真实踩过的坑5.1 生成结果全是重复字或乱码现象模型训练 loss 降到很低但生成的诗是「春春春春春春春」或者「春明春明春明春明」这种重复模式。原因最常见的原因是训练数据太少或者模型太大导致过拟合。模型记住了训练集里的局部模式但没有学到泛化能力。另一个原因是 temperature 设得太低模型每次都选概率最高的字陷入循环。解决先检查数据集大小如果少于 5000 首减少模型参数量把hidden_dim从 512 降到 256num_layers从 2 降到 1。然后把 temperature 调到 0.9 到 1.0 之间增加随机性。如果还是重复在生成时加一个简单的惩罚机制对已经出现过的字降低其概率。5.2 训练 loss 不下降或变成 NaN现象训练几个 epoch 后 loss 突然变成 NaN或者一直停在 8.0 左右不动。原因学习率太大导致梯度爆炸或者数据里有空序列导致除零。LSTM 对学习率比较敏感1e-2 以上很容易出问题。解决把学习率降到 1e-3 或 5e-4加上梯度裁剪clip_grad_norm_。检查数据加载器确保没有长度为 0 的序列。如果 loss 停在某个值不动可能是模型陷入了局部最优换一个优化器比如从 Adam 换成 AdamW或者调整 batch size。5.3 前端请求超时或返回 500现象前端点击生成按钮后一直转圈最后报超时或者接口返回 500 错误。原因模型推理太慢超过了前端设置的超时时间。字符级 LSTM 生成 28 个字需要 28 次前向传播每次都要跑一遍 LSTM在 CPU 上可能需要几秒钟。如果同时有多个请求会排队等待。解决在生成函数里加torch.no_grad()减少内存占用和计算量。如果还是慢把模型量化成 int8 或者用 ONNX Runtime 加速推理。另外在 FastAPI 里把生成接口改成异步的避免阻塞其他请求。5.4 部署后模型文件找不到现象本地跑得好好的部署到服务器后启动报错FileNotFoundError: poem_lstm.pth。原因模型文件没有一起打包部署或者路径写的是相对路径而服务启动目录和开发时不一样。解决把模型文件放在项目根目录下的models/文件夹里用os.path.join(os.path.dirname(__file__), models, poem_lstm.pth)拼接绝对路径。如果模型文件太大不方便打包可以存在对象存储里启动时下载到本地临时目录。5.5 生成的诗「不像诗」现象生成的字都认识但读起来没有诗意像是随机拼凑的。原因模型只学到了字与字之间的统计规律没有学到「意象」和「意境」这种更高层的语义。字符级 LSTM 的容量有限无法建模长距离的语义关联。解决这是字符级模型的固有局限不是 bug。如果想提升质量有几个方向一是换用预训练的中文语言模型做微调比如用 HuggingFace 上的小模型二是在训练数据里加入更多高质量的诗尤其是同一主题的诗三是在生成时加规则约束比如强制押韵、限制每句的字数。但要注意每加一层约束代码复杂度就上一个台阶对于入门项目来说接受「有点样子但不够好」的结果是合理的。6. 进阶技巧用前缀约束和押韵规则提升生成质量模型跑通之后如果想让生成结果更可控可以在解码阶段加一些约束。最常见的是前缀约束和押韵约束。前缀约束是指定诗的开头几个字让模型从这几个字之后继续生成。实现方式很简单把前缀的每个字依次输入模型更新 hidden state然后从最后一个字的输出开始采样。def generate_with_prefix(model, prefix, char2idx, idx2char, devicecpu, max_len28, temperature0.8, top_k10): model.eval() chars list(prefix) input_ids torch.tensor([[char2idx[c] for c in prefix]], dtypetorch.long).to(device) hidden None with torch.no_grad(): # 先跑一遍前缀得到 hidden state logits, hidden model(input_ids, hidden) # 从最后一个字的输出开始采样 for _ in range(max_len - len(prefix)): last_logits logits[:, -1, :] / temperature if top_k 0: values, indices torch.topk(last_logits, top_k) probs torch.softmax(values, dim-1) next_idx indices[0][torch.multinomial(probs, 1).item()] else: probs torch.softmax(last_logits, dim-1) next_idx torch.multinomial(probs, 1).item() next_char idx2char[next_idx.item()] chars.append(next_char) input_ids torch.tensor([[next_idx.item()]], dtypetorch.long).to(device) logits, hidden model(input_ids, hidden) return .join(chars)押韵约束稍微复杂一点。七言绝句的押韵规则是第二句和第四句的最后一个字押韵。可以在生成到第 14 个字和第 28 个字时限制候选字必须属于同一个韵部。实现方式是在采样之前把不属于目标韵部的字的 logits 设成负无穷。# 简化的韵部表实际使用需要更完整的韵书 RHYME_GROUPS { an: set(安山关还寒), ang: set(长光霜望乡), ing: set(明清情声行), } def get_rhyme_group(char): for group, chars in RHYME_GROUPS.items(): if char in chars: return group return None def apply_rhyme_constraint(logits, rhyme_group, idx2char): 把不属于指定韵部的字的 logits 设为 -inf mask torch.full_like(logits, float(-inf)) for idx, char in idx2char.items(): if get_rhyme_group(char) rhyme_group: mask[0][idx] logits[0][idx] return mask这两个技巧可以组合使用先用前缀约束指定开头然后在偶数句末尾应用押韵约束。实际测试下来加了押韵约束之后生成的诗读起来会顺很多至少不会出现「前两句押韵、后两句跑偏」的情况。不过要提醒一句约束越多生成结果的多样性越低。如果押韵表太严格模型可能找不到合适的字导致生成失败或者输出奇怪的结果。我一般会先用宽松的约束跑一批样本看看效果再逐步收紧。这套方案从数据清洗到前端展示整条链路大概 500 行代码左右在普通笔记本上就能跑通。模型效果肯定比不上大厂的产品但胜在可控、可改、可解释。如果你想加功能比如支持五言、支持藏头诗、支持指定主题都可以在这个基础上改。我自己做的时候光调 temperature 和 top_k 就试了一下午最后发现 0.85 和 12 的组合最顺眼。希望帮到你。本文还有配套的精品资源点击获取