Python实现NLP诗歌接龙:检索与字级LSTM双引擎及韵脚约束

发布时间:2026/11/2 13:05:32
Python实现NLP诗歌接龙:检索与字级LSTM双引擎及韵脚约束 简介这是一套面向NLP入门与实战练习者的诗歌接龙项目源码包围绕中文分词、拼音转换、韵律匹配与接龙算法展开适合希望把Python文本处理知识落到完整小项目中的开发者参考。压缩包共17个文件约5.86MB以py源代码、xml配置、pk与dat数据/模型文件为主并配有docx说明文档、txt辅助材料及少量exe、pyc文件便于直接运行、阅读实现与对照调试。项目文档梳理了背景、目标、功能模块与使用方式源码部分可看到诗词数据获取、jieba分词、pypinyin音韵处理以及基于规则或统计的接龙思路能帮助读者理解从文本预处理到诗句生成的链路。已有499人学习适合用作课程设计、练手项目或NLP基础能力巩固的参考案例。1. 从一句“床前明月光”说起上个月跟一个做教育硬件的朋友聊需求他们想在诗词机里加个接龙功能用户念一句“床前明月光”设备得接得住还得接得像那么回事。我第一反应是接个通用大模型试完发现有三个问题韵脚经常不对、典故容易张冠李戴、离线设备根本无法运行。最后落到一个纯 Python 的本地项目上用检索加小模型的双引擎整包不到 30MB千元级开发板上也能在几百毫秒内输出结果。这份 python 实现的 NLP 诗歌接龙项目核心就三件事把古诗词语料切成可检索的结构、按字、韵脚、平仄做约束、最后给候选句排个序。它适合两类人想拿一个完整项目练手 NLP 全链路的初学者和需要在离线环境里实现一个“会背诗”能力的开发者。下面按我拆包的顺序讲。2. 结构先摸清目录分层与两路引擎的数据通路拿到压缩包先别急着pip install花十分钟把目录看一遍能省掉后面一半的调试时间。这个项目的组织方式是典型的“引擎分离 统一出口”检索式接龙和生成式接龙各走各的模块最后由一个 pipeline 汇合。好处是检索那一路不需要 torch 也能跑模型部分挂了不影响主流程。2.1 目录结构四个包、一份配置、两套产物解压后的树大概长这样我按职责加了注释poem_chain/ ├── app.py # FastAPI 服务入口暴露 /chain 与 /health ├── cli.py # 命令行入口调参和排查主要靠它 ├── config.yaml # 语料路径、模型路径、融合权重全在这 ├── requirements.txt ├── data/ │ ├── raw/ # 原始诗词 txt一行一首允许带标点 │ └── corpus.jsonl # 清洗产物一行一句 ├── chain/ │ ├── loader.py # 读取、归一化、切句、去重 │ ├── rhyme.py # 拼音 - 韵母、平仄判定 │ ├── index.py # 建索引字表、韵脚表、首字表 │ ├── retriever.py # 检索式接龙顶针 / 飞花令 │ ├── dataset.py # 字级样本切片 │ ├── model.py # 字级 LSTM 注意力门控 │ ├── generator.py # beam search 推理带约束 │ ├── scorer.py # 多特征打分融合 │ └── pipeline.py # 串起两路引擎对外只留一个函数 ├── artifacts/ │ ├── vocab.json # 字表通常四千到六千字 │ ├── index.pkl # 落盘的倒排索引 │ └── poem_lstm.pt # 模型权重 └── scripts/ ├── build_index.py # 建索引脚本 └── train.py # 训练脚本chain/下面每个文件都只做一件事这是我最喜欢的一点。rhyme.py只管拼音和韵母index.py只管落盘结构retriever.py甚至不知道模型的存在。二次开发时想换语料只动loader.py和重跑脚本想换模型只动model.py和generator.py。耦合点集中在pipeline.py那几十行里。data/raw/里放的是公开的古诗词文本按行组织允许带中文标点、书名号、作者小传。清洗逻辑在后面第 3 章细讲这里先记住一件事查询侧和建库侧必须共用一个归一化函数否则会出现“明明库里有这句就是搜不出来”的常见问题。2.2 环境与依赖先装最小集合别一上来全量项目写的是 Python 3.9 以上我用 3.10 和 3.11 都跑过没遇到兼容问题。依赖不多# 建议单独开虚拟环境避免和系统里的 torch 打架 python -m venv .venv source .venv/bin/activate # Windows 下用 .venv\Scripts\activate pip install -r requirements.txt # requirements.txt 里的关键几项 # torch1.13 模型和推理CPU 版就够语料规模用不上显卡 # pypinyin0.49 韵母、平仄判定的底座 # fastapi uvicorn 服务化 # pyyaml, tqdm, numpy # jieba 不在依赖里——诗词是按字建模的分词反而破坏结构如果你的机器内存只有 2G或者只想先跑起来看看效果可以先跳过 torch# 只跑检索式不装 torch 也完全可用这是这个项目设计上最友好的地方 python scripts/build_index.py --config config.yaml python cli.py --seed 床前明月光 --mode retrieve --topk 5--mode有三个值retrieve只走检索generate只走模型hybrid两路融合。第一次跑强烈建议从retrieve开始它不依赖任何模型文件出问题也容易定位——如果检索都召回不了那一定不是模型的锅。配置集中在一个 YAML 里改参数不用翻代码# config.yaml corpus: raw_path: data/raw min_len: 4 # 少于 4 字的残句直接丢 max_len: 30 # 超过 30 字的多半是序文或小传 index: out_path: artifacts/index.pkl model: path: artifacts/poem_lstm.pt vocab: artifacts/vocab.json max_len: 16 # 生成句的长度上限七言绝句 7 字留点余量 device: cpu score: w_rhyme: 0.45 # 韵脚一致性的权重接龙场景下最重要 w_fluency: 0.35 # 模型给出的流畅度 w_penalty: 0.20 # 重复字、超长句的惩罚项三个权重加起来等于 1这是有意的。融合分最后要归一到 0 到 1 之间方便后面接阈值过滤。你要是想让结果更“稳”更保守把w_rhyme调到 0.6把模型的不确定性压下去。2.3 跑通最小链路一次接龙从输入到输出先看命令行怎么用这是理解整条链路最快的入口# 顶针接龙下一句的首字必须等于上一句的尾字 python cli.py --seed 床前明月光 --mode hybrid --rule dernier --topk 5 # 飞花令下一句必须含指定字且尾字押韵 python cli.py --seed 床前明月光 --mode retrieve --rule feihua --char 月 --topk 5 # 不带参数就是随机抽一句当代替种子 python cli.py --mode retrieve --topk 3--rule dernier对应顶针--rule feihua对应飞花令两个规则的候选召回走的是不同索引这一点在下一章展开。输出是一个 JSON 列表每条带text、score、source三个字段source标的是retrieve还是generate方便你回调权重的时候看清楚是哪一路在贡献分数。服务化入口在app.py用 FastAPI 的 lifespan 做预加载这点很关键# app.py from contextlib import asynccontextmanager from fastapi import FastAPI from chain.pipeline import Pipeline state {} asynccontextmanager async def lifespan(app: FastAPI): # 索引和模型都在进程启动时加载一次绝不放进请求处理函数里 state[pipe] Pipeline.from_config(config.yaml) yield state.clear() app FastAPI(lifespanlifespan) app.get(/chain) def chain(seed: str, rule: str dernier, topk: int 5): # seed 传上一句返回候选列表异常统一在 pipeline 里兜成空列表 return {seed: seed, candidates: state[pipe].run(seed, rule, topk)}lifespan这个写法是 FastAPI 新版本推荐的启动钩子早几年项目里常见的app.on_event(startup)已经不建议用了。为什么强调“别放进请求里”索引 pickle 反序列化大概 1.5 到 3 秒模型torch.load再花 1 秒左右如果每次请求都加载第一个用户会以为服务挂了。这个问题我在别的项目上出过一次问题后面第 5 章会细说。/health接口只做一件事检查state[pipe]是否存在且索引非空。上线时把它挂到探针上比什么都靠谱。3. 语料入库与索引从三十万句到一张能查的表这一章是整个项目的底座。检索式接龙的效果好不好八成取决于索引建得对不对模型那部分反而是锦上添花。我见过不少人一上来就调模型结果生成的句子读着通顺但完全不合韵问题其实出在数据层。3.1 语料清洗把一首诗切成一行一句原始语料是一行一首带标点、带作者、带校勘记号。接龙的最小单位是“句”所以第一步是按标点切句再做归一化。# chain/loader.py import json import re from pathlib import Path # 只保留汉字标点、注释、校勘记号、异体标记一律丢掉 CJK re.compile(r[\u4e00-\u9fff]) # 诗词里常见的断句符就这几个顿号也算词牌里常用 SPLIT re.compile(r[。、,.!?;]) def normalize(line: str) - str: 归一化索引侧和查询侧必须调同一个函数否则必然对不上 return .join(CJK.findall(line)) def iter_lines(raw_dir: Path): 原始文件一行可能是一整首这里按标点切成单句 for fp in sorted(raw_dir.glob(*.txt)): for raw in fp.read_text(encodingutf-8).splitlines(): raw raw.strip() if not raw or raw.startswith(#): continue for piece in SPLIT.split(raw): seg normalize(piece) # 4 到 30 字之间才留太短是残句太长多半是序文或注 if 4 len(seg) 30: yield seg def build_corpus(raw_dir: Path, out_path: Path) - int: seen, kept set(), 0 out_path.parent.mkdir(parentsTrue, exist_okTrue) with out_path.open(w, encodingutf-8) as f: for seg in iter_lines(raw_dir): if seg in seen: # 不同诗集之间重复的句子非常多先去重 continue seen.add(seg) f.write(json.dumps({text: seg, len: len(seg)}, ensure_asciiFalse) \n) kept 1 return kept几个参数值得说清。min_len4不是随便定的五言绝句去掉标点后是 5 字四言是 4 字三字以下基本是断句失误或者残卷。max_len30对应的是词牌里的长调句子再长就不是句子而是整首了。这两个值在config.yaml里可以改但改了之后必须重跑建索引否则索引和配置不一致排查起来非常痛苦。去重用的是内存里的set。三十万句、平均十四字字符串对象加哈希表开销大概在 50MB 到 80MB 之间2G 内存的机器能应付。如果语料量级再往上翻一个数量级就得换成布隆过滤器或者分片去重不然会触发内存耗尽。normalize那行注释是整个文件里最重要的一句。我见过一个改法建库时用了CJK正则查询时直接拿用户输入去查用户输入带个句号召回就是 0然后开始怀疑人生。3.2 索引构建三张表撑起全部检索接龙有两种玩法索引就得建两张顶针看首字飞花令看任意字两个规则最后都要求押韵所以还要一张韵脚表。# chain/index.py import json import pickle from array import array from collections import defaultdict from chain.rhyme import rhyme_of, tone_of def build_index(corpus_path, out_path): texts [] with open(corpus_path, encodingutf-8) as f: for line in f: texts.append(json.loads(line)[text]) by_char defaultdict(lambda: array(i)) # 字 - 句子 id 列表飞花令 by_head defaultdict(lambda: array(i)) # 首字 - 句子 id 列表顶针 by_rhyme defaultdict(lambda: array(i)) # 尾字韵母 - 句子 id 列表 tones [] # 每句的平仄串供打分用 for idx, text in enumerate(texts): # 同一句里重复出现的字只记一次否则飞花令召回会出一堆重复项 for ch in set(text): by_char[ch].append(idx) by_head[text[0]].append(idx) by_rhyme[rhyme_of(text[-1])].append(idx) tones.append(.join(tone_of(c) for c in text)) payload { texts: texts, by_char: {k: v for k, v in by_char.items()}, by_head: {k: v for k, v in by_head.items()}, by_rhyme: {k: v for k, v in by_rhyme.items()}, tones: tones, } with open(out_path, wb) as f: pickle.dump(payload, f, protocol4) return payload用array(i)而不是原生list是这里的关键优化。三十万句、平均十四字如果by_char的 value 用 list每个 int 对象 28 字节加指针 8 字节光倒排表就能吃掉七八百兆。换成array(i)之后是紧凑的 4 字节整数序列同样的数据压到一百兆以内这是我在 2G 机器上跑通这个项目的前提。set(text)那个循环也值得留意。如果直接for ch in text一句里出现两个“月”by_char[月]就会记录两次同一个句子 id飞花令召回的时候列表里全是重复项去重还得再花一遍时间。韵脚表只按尾字建因为接龙规则要求的是下一句的尾字和上一句押韵。如果你想做“句中押韵”的变体就得再加一张表那是另一个话题了。3.3 韵母与平仄多音字是躲不过的地方押韵判定依赖拼音拼音依赖pypinyin而pypinyin在诗词场景下的准确率不算高——它按现代汉语的常用读音来很多古音和特定语境下的读法它对不上。# chain/rhyme.py from functools import lru_cache from pypinyin import lazy_pinyin, Style # 诗词里读法相对固定、但现代读音会判错的字先写死兜底 OVERRIDE {斜: xia2, 衰: cui1, 看: kan1, 只: zhi1} def split_tone(py: str): 把 ming2 拆成 (ming, 2)没有声调数字的按轻声处理 if py and py[-1].isdigit(): return py[:-1], py[-1] return py, 5 lru_cache(maxsize8192) # 常用汉字不到六千缓存命中率接近 100% def pinyin_of(ch: str) - str: if ch in OVERRIDE: return OVERRIDE[ch] return lazy_pinyin(ch, styleStyle.TONE3, neutral_tone_with_fiveTrue)[0] def rhyme_of(ch: str) - str: 取韵母剥掉声母保留韵腹和韵尾 base, _ split_tone(pinyin_of(ch)) for i, c in enumerate(base): if c in aoeiuv: return base[i:] return base # 整体认读之类原样返回 def tone_of(ch: str) - str: 平仄一二是平三四为仄轻声按平处理 _, t split_tone(pinyin_of(ch)) return P if t in 125 else Zlru_cache是必须的。接龙的时候每句都要算一遍每个字的拼音不缓存的话一秒钟能算几万次纯属浪费。缓存大小给 8192 覆盖常用字绰绰有余。OVERRIDE这张表是长期维护项。诗词里的多音字比如“斜”读 xiá、“衰”读 cuī、“看”在某些词牌里读平声这些都不是现代拼音能自动判断的。项目里预置了一小批你在实际语料上跑的时候可以统计一下哪些字的韵脚判定明显可疑往表里补就行。一千条以内的 override 用字典查性能没有影响。韵母剥声母的逻辑简单粗暴找到第一个元音字母从那里截断。对zhang得到ang对gui得到ui对yu得到u。严格的音韵学上ui和ei不同韵但接龙场景下按这个粒度分组已经够用真要上《平水韵》那种粒度就得引入韵书数据表那是另一个量级的工作。3.4 检索式接龙两种规则两个入口索引建好之后召回就是查表快得离谱。# chain/retriever.py from chain.rhyme import rhyme_of class Retriever: def __init__(self, index): self.texts index[texts] self.by_char index[by_char] self.by_head index[by_head] self.by_rhyme index[by_rhyme] def _keep(self, ids, seed, limit): 统一过滤排除原句、排除和种子完全同字的句子 out [] for i in ids: text self.texts[i] if text seed: continue out.append(text) if len(out) limit: # 提前截断别把整个倒排表都物化成 list break return out def dernier(self, seed: str, limit: int 200): 顶针下句首字 上句尾字 ids self.by_head.get(seed[-1], []) return self._keep(ids, seed, limit) def feihua(self, seed: str, ch: str, limit: int 200): 飞花令下句含指定字且尾字与上句押韵 target rhyme_of(seed[-1]) ids self.by_char.get(ch, []) out [] for i in ids: text self.texts[i] if text seed or rhyme_of(text[-1]) ! target: continue out.append(text) if len(out) limit: break return outlimit默认 200这个数字是权衡出来的。太小候选池不够融合打分的区分度出不来太大后面的韵脚判断和模型打分都会变慢。实测在三十万句的库上200 个候选走完全链路大概 30 到 60 毫秒。dernier走的是首字表这里有个容易忽略的点首字表召回量大得惊人。常用字当首字的句子动辄上万条所以_keep里的提前截断不是优化是必须——不截断的话光是把这个array转成 Python list 就要几十毫秒。feihua里先查字表再判韵脚顺序不能反。字表召回通常几千到几万判韵脚要算拼音虽然带缓存但仍是开销反过来先算全库韵脚那就是全表扫描慢两个数量级。4. 生成式接龙字级 LSTM 与约束解码检索那一路的优点是永远说人话缺点是遇到生僻的韵脚或者冷门的字就哑火。生成那一路补的就是这个洞。项目里用的是字级 LSTM 加一个简单的注意力门控不是因为它效果最好而是因为它在一台没有显卡的机器上二十分钟能训完改起来也不用懂太多。4.1 样本构造一条样本是一对上句和下句诗词是按字建模的不做分词。字表控制在四千到六千之间覆盖语料里 99% 以上的出现次数剩下的归到 UNK。# chain/dataset.py import torch from torch.utils.data import Dataset PAD, BOS, EOS, UNK 0, 1, 2, 3 class PoemPairs(Dataset): 一条样本 (上句, 下句)都是字级 id 序列 def __init__(self, pairs, vocab, max_len32): self.pairs pairs self.vocab vocab self.max_len max_len def encode(self, text): # 首尾加 BOS / EOS模型才知道什么时候该停 ids [self.vocab.get(c, UNK) for c in text][: self.max_len - 2] return [BOS] ids [EOS] def __len__(self): return len(self.pairs) def __getitem__(self, i): src, tgt self.pairs[i] return torch.tensor(self.encode(src)), torch.tensor(self.encode(tgt)) def collate(batch): 按 batch 内最长补齐比全局 max_len 省一半算力 srcs, tgts zip(*batch) srcs torch.nn.utils.rnn.pad_sequence(srcs, batch_firstTrue, padding_valuePAD) tgts torch.nn.utils.rnn.pad_sequence(tgts, batch_firstTrue, padding_valuePAD) return srcs, tgts配对是怎么来的把清洗后的句子按原始顺序两两相邻组队第 1 句配第 2 句第 3 句配第 4 句。这样得到的 pair 天然带有上下文关系虽然切句过程中会损失一些跨句信息但对“给上句接下句”这个任务来说够用。PAD/BOS/EOS/UNK四个特殊符号占用 id 0 到 3字表从 4 开始编。padding_idx0在 embedding 里要显式指定否则 PAD 也会参与梯度更新训出来的向量会漂。collate用pad_sequence而不是固定max_len能省不少算力。一个 batch 里如果最长的样本只有 12补齐到 32 就是白白多算一倍多。4.2 模型定义双向编码 单向解码 门控结构不复杂编码器用双向 LSTM 把上句压成一个向量解码器逐字生成下句中间用一个门控把上下文注入进去。# chain/model.py import torch import torch.nn as nn class PoemLSTM(nn.Module): def __init__(self, vocab_size, emb128, hidden256, layers2, dropout0.2): super().__init__() self.emb nn.Embedding(vocab_size, emb, padding_idx0) self.enc nn.LSTM(emb, hidden, layers, batch_firstTrue, bidirectionalTrue, dropoutdropout) self.dec nn.LSTM(emb, hidden * 2, layers, batch_firstTrue, dropoutdropout) # 输入是「解码器隐状态 上下文向量」的拼接输出一个门控 self.gate nn.Linear(hidden * 4, hidden * 2) self.out nn.Linear(hidden * 2, vocab_size) def forward(self, src, tgt_in): mask (src ! 0).unsqueeze(-1).float() enc_out, _ self.enc(self.emb(src)) # 均值池化代替复杂 attention短句上效果接近速度快很多 ctx (enc_out * mask).sum(1) / mask.sum(1).clamp(min1.0) dec_out, _ self.dec(self.emb(tgt_in)) g torch.sigmoid(self.gate( torch.cat([dec_out, ctx.unsqueeze(1).expand_as(dec_out)], dim-1))) fused dec_out g * ctx.unsqueeze(1) # 残差式注入避免上下文淹没解码器 return self.out(fused)几个参数的含义emb128对四千多字的字表足够再大就是过拟合hidden256双向之后编码器输出是 512 维所以解码器的输入维度写hidden * 2layers2是深度和训练时间的折中一层欠拟合三层在 CPU 上训一轮要半小时以上。fused dec_out g * ctx这行是残差式的。如果直接concat再接线性层模型容易忽略上下文退化成纯语言模型——生成出来的句子通顺但跟上句没关系这个坑我踩过。用门控加残差模型可以选择性吸收上下文训练早期梯度也更稳。训练脚本里用的是CrossEntropyLoss(ignore_index0)标签是下句右移一位。学习率 1e-3 配 Adam五到八个 epoch 收敛单机 CPU 大概二十分钟。4.3 推理beam search 叠韵脚约束贪心解码在这个任务上一定会出现循环比如生成“春风春风春风”。beam search 加约束过滤是必要的。# chain/generator.py import torch torch.no_grad() def beam_search(model, src, inv_vocab, rhyme_fn, beam5, max_len16, top_k8): model.eval() src src.unsqueeze(0) # 每条候选(id 序列, 累计对数似然) beams [([1], 0.0)] # 1 是 BOS finished [] for _ in range(max_len): cand [] for seq, score in beams: tgt torch.tensor([seq]) logits model(src, tgt)[0, -1] # 取最后一个时间步 logp torch.log_softmax(logits, dim-1) vals, idxs logp.topk(top_k) for v, i in zip(vals.tolist(), idxs.tolist()): if i 3: # UNK 直接禁掉 continue cand.append((seq [i], score v)) if not cand: break # 按长度归一化排序否则 beam 天然偏爱短句 cand.sort(keylambda x: x[1] / len(x[0]), reverseTrue) beams cand[:beam] # 出 EOS 的挪到完成集不再参与扩展 for seq, sc in list(beams): if seq[-1] 2: finished.append((seq, sc)) beams.remove((seq, sc)) if not beams: break pool finished or beams results [] for seq, sc in pool: text .join(inv_vocab.get(i, ) for i in seq[1:-1]) if len(text) 4: continue results.append({text: text, logp: sc / max(len(text), 1)}) return resultsseq[1:-1]是把 BOS 和 EOS 切掉。长度归一化那行score / len(x[0])很重要不做的话 beam 会系统性地偏向三四个字的短句因为短句的累计对数似然天然更高。约束放在哪里我在这个版本里把韵脚约束留在了打分阶段而不是解码阶段。原因是硬约束在早期会大幅压缩搜索空间模型被迫在很窄的候选里挑语义质量掉得厉害。做法是解码时放开出 30 到 50 条候选然后在scorer.py里对韵脚一致的那部分加权。这算是“软约束”实测比硬过滤的最终盲评得分高。top_k8配合beam5单次推理大概 100 到 200 毫秒在 CPU 上是可以接受的水平。如果你对延迟极敏感把beam降到 3、top_k降到 5延迟能压到 50 毫秒以内质量会掉一点点。4.4 融合打分把两路候选排成一个列表检索出来的句子和模型生成的句子最后要放在一起排序靠的就是这张权重表。# chain/scorer.py from chain.rhyme import rhyme_of def score_candidate(text, seed, logp, w, max_len16): 三个特征加权全部归一化到 0~1 之间再相加 # 1. 韵脚一致性接龙最重要的硬指标 s_rhyme 1.0 if rhyme_of(text[-1]) rhyme_of(seed[-1]) else 0.0 # 2. 流畅度模型给的平均对数似然经验值域大约在 -6 ~ -1 s_flu min(max((logp 6.0) / 5.0, 0.0), 1.0) # 3. 惩罚项超长、和种子重复字过多都要扣 s_pen 1.0 if len(text) 12: s_pen - 0.3 overlap len(set(text) set(seed)) / max(len(set(text)), 1) s_pen - 0.5 * overlap s_pen max(s_pen, 0.0) return (w[w_rhyme] * s_rhyme w[w_fluency] * s_flu w[w_penalty] * s_pen)s_flu的归一化区间-6 到 -1是我在实测数据上定的。字级模型的平均对数似然普遍落在这个区间低于 -6 基本是乱码级别的输出高于 -1 基本不可能出现。你换模型之后这个区间要重新标定不然流畅度这一项的区分度会丧失。overlap那项针对的是检索路的一个毛病飞花令召回出来的句子经常和种子句高度相似读起来像复读。按重复字比例扣分能把它压下去。三路分数加完按score降序取 topksource字段保留着方便你回溯是哪一路贡献的。如果发现前五条全是检索结果说明模型的分数给低了去调w_fluency反过来全是生成结果、读着别扭就把w_rhyme往上提。5. 避坑与排查上线前踩过的五个坑这一章是血泪经验。项目从能跑到能用之间隔着的基本都在这些地方。5.1 生成结果无限循环同一个字现象输入“床前明月光”模型给出的下句是“明明明明明明明”或者“春风春风春风”。原因字级模型在训练不充分时对高频字的概率估得过高贪心解码会一路选最高概率重复几次之后就形成了自回归的死循环。早期版本里我没加 beam search也没做重复惩罚。解决两处改。解码侧换成 beam search并按长度归一化排序打分侧加overlap惩罚项对和种子重复字过多的句子扣分。另外训练时把 epoch 从 3 提到 8重复现象明显下降。如果还不行在logp上直接减一个重复惩罚每个已生成的字减 1.0这个土办法很有效。5.2 韵脚判定把“斜”判成了 xié现象检索出来的句子读着不押韵但打分器给了一致性满分。原因pypinyin按现代汉语常用读音走“斜”在诗里读 xiá被判成 xié韵母从ia变成ie韵脚分组全错。解决在rhyme.py里维护OVERRIDE表把诗词语境下读法固定的字写死。做法是先用一个统计脚本跑一遍全库找出同一个字在不同句子里的被判韵母人工抽查可疑项。这张表现在有一百多条覆盖了绝大多数常见异常。5.3 明明库里有这句检索召回是 0现象调试时明确知道语料里有某句用相关字去查返回空列表。原因建索引时normalize把标点剥了查询侧直接把用户输入丢进去查输入带了句号或者空格字表里当然找不到。解决所有入口包括 CLI、HTTP 参数、内部调用一律先过normalize。我在pipeline.run()的第一行强制加了一次不走旁路。这个坑我踩过两次第二次是因为新加了个内部接口没走统一入口。5.4 服务启动后内存直接涨到 2.5G现象本地开发没问题部署到 2G 内存的机器上容器直接被 OOM 杀掉。原因倒排索引的 value 用了 Python 原生list每个 int 都是一个 28 字节的对象三十万句的索引直接冲到两千多兆。解决by_char、by_head、by_rhyme三张表的 value 全换成array(i)。同样的数据降到 400M 以内。还有一个副作用是 pickle 文件小了三分之二反序列化也快了。5.5 第一个请求要等三秒现象服务刚起来第一个/chain请求耗时三千多毫秒后面都是几十毫秒。原因早期版本把索引加载和模型加载写在了请求处理函数里每次请求都会pickle.load和torch.load。解决挪到 FastAPI 的lifespan里做一次预加载同时把Pipeline做成单例。另外注意torch.load在 CPU 上加载完成后第一次前向传播还会慢一些那是算子初始化的开销可以在启动时跑一次空推理把预热做掉。这个问题和其他项目里的冷启动优化是同一类。6. 二次开发把接龙引擎接到自己的场景里拆完这一圈改造点其实很清晰。我按“改哪里、影响什么”列个表你对照着看就行。改造目标要动的文件影响面换语料比如换成宋词或现代诗data/raw/、config.yaml的长度阈值必须重跑建索引和训练rhyme.py的罚则可能要放宽加一种接龙规则比如限字位index.py加一张表、retriever.py加一个方法pipeline.py的路由要跟着加分支换成 Transformer 或接入本地大模型model.py、generator.pyscorer.py的s_flu归一化区间要重新标定加缓存扛并发pipeline.py外面包一层 LRU注意缓存 key 要包含rule和topk做多轮接龙pipeline.py维护会话状态要加“已用句子”集合避免来回打转最值得先做的是加缓存。接龙这个场景的输入空间其实很小种子句加规则的组合重复率很高一个 512 条的 LRU 就能挡掉大部分请求。包在pipeline外面别包在retriever里面因为融合结果才是最终输出。验证方法我一般走两步。第一步是离线评测从语料里抽 500 对相邻句拿上句当输入看人工标注的“可接受”比例检索式的基线大概在 0.35 左右融合之后能到 0.55 到 0.6。第二步是盲评把检索结果和生成结果混在一起打乱让人判断哪句更像人写的这个方法能很快暴露权重调歪了的问题。还有一个具体技巧config.yaml里的三个权重别手调写个小的网格搜索脚本w_rhyme在 0.3 到 0.6 之间步长 0.05w_fluency在 0.2 到 0.5 之间同样步长用那 500 对评测集跑一遍取可接受率最高的组合。我一开始是凭感觉调的 0.45 和 0.35网格搜出来的最优解是 0.50 和 0.30差了将近 4 个百分点。从那以后我每次动打分权重或者换模型都强制先跑一遍那 500 对的离线评测再去看线上表现——凭直觉调参数这件事翻车概率比想象中高得多。这套东西的代码量不大但每个模块的边界划得很清楚你按上面的表去改基本不会牵一发动全身。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询