资讯动态

基于鹿鼎记数据集的LSTM小说生成:字符级文本生成实战

发布时间:2026/10/29 15:34:51 来源:尧图企业网站定制
简介这是一套基于金庸《鹿鼎记》语料的LSTM文本生成入门项目面向NLP初学者、Python学习者以及需要毕设或课设起步方案的计算机相关专业学生。项目用requests抓取金庸网站小说目录与章节得到约100万字符的原始文本受硬件限制训练时截取前5万字符经过排序去重、构建字符到整数的映射表再按每句40字切分并保持时间顺序最终实现LSTM语言模型的训练与文本续写。压缩包共5个文件类型包括2个Python脚本——分别负责爬虫和模型训练、1个Markdown说明文档、1个TXT语料文件以及1个HDF5权重文件整体大小约18.78MB目录清晰便于对照学习。目前已有193人浏览/学习项目代码均测试通过适合作为AI写作、循环神经网络应用的实践范例。使用者可借此理解数据采集、文本预处理、序列建模的完整链路也可修改参数或替换语料做二次开发。1. 基于鹿鼎记数据集用 LSTM 学写小说这一步迈过去才算真正入门序列生成用金庸的《鹿鼎记》喂给 LSTM 神经网络让模型自己学着产出小说文本这大概是 NLP 入门者遇到过最有意思、也最容易中途放弃的项目。它本质上不是什么高深的 NLP 前沿课题——没有 Transformer、没有注意力机制甚至不需要 GPU一台普通笔记本就能跑完整个流程——但它把「文本分类」和「序列生成」之间的鸿沟彻底摊开在你面前怎么把小说原文变成模型能吃的数字怎么设计一个能记住上下文的网络又怎么把模型吐出的概率分布翻译回人话每个问题背后都有一堆参数和坑等着你。这篇笔记我想从数据集处理、模型设计、训练生成到排错调试把你照着做就能跑通的完整路径讲清楚。适合已经会用 Python 写基础代码、但不熟悉深度学习的读者也适合跑过图像识别、想跨到 NLP 方向试试水的人。看完你应该能独立训练一个能生成鹿鼎记风格文本的字符级 LSTM顺便搞明白 RNN 系列模型最让人困惑的若干行为。2. 鹿鼎记数据集先处理成什么样子字符级输入的构建与两个关键文件2.1 下载和导入纯文本是底线别拿到什么格式都硬上《鹿鼎记》的文本来源比较杂TXT 版、EPUB 版、网页爬下来的带排版版本都有。这个项目里数据集的干净程度直接决定模型能学到什么。常见的选择是拿公开的 .txt 文本但要注意几个问题。首先是编码老版本 TXT 常常是 GBK 或 GB2312直接用open()按 UTF-8 读会抛UnicodeDecodeError。其次是排版残留每章标题、空格、全角半角混用、段首缩进这些东西不清理的话模型会把它们当正文学进去。我一般这么处理先用chardet探测编码再做一轮正则清理。这个步骤对后续所有工作都至关重要因为字符级模型的输入输出字典是建立在文本里所有出现过的不重复字符之上的——多一个空格字典里就多一个符号。import chardet import re # 第一步探测编码并读取原文 with open(ludingji.txt, rb) as f: raw_data f.read() encoding_info chardet.detect(raw_data) print(检测到的编码, encoding_info[encoding]) # 绝大多数情况下会显示 GBK 或 UTF-8 text raw_data.decode(encoding_info[encoding], errorsignore) # 第二步基础清洗 # 常见残留章节标记、缩进、连续空行、特殊字符 text re.sub(r第[一二三四五六七八九十百千0-9]回.*, , text) # 去掉章回标题行正则自行核对 text re.sub(r\s, , text) # 连续空白归一为单个空格 text re.sub(r[^\u4e00-\u9fff。、——“”‘’\n], , text) # 只保留中文字符和常用标点 # 第三步截取固定长度稳定输入规模 text text[:500_000] # 50万字符足够训练出一个像样的字符级模型 print(清洗后文本长度, len(text))这里有个容易翻车的细节第三步的正则里如果保留英文引号或括号会在后续的set()统计中引入额外字符导致模型输入维度变大。建议先跑一遍set(text)看看字符集内容确认没有乱码和特殊符号。另外errorsignore在解码时会把非法字节直接丢弃这可能导致某些句子缺字但对于训练来说影响不大——模型本来就是在学统计规律而不是背诵原文。字符级模型之所以拿中文小说练手最合适是因为中文信息密度高每个字符都有独立含义LSTM 需要在十几个字符的窗口内学会上下文约束。相比之下英文单词级模型需要分词器中文一句韦小宝笑道五个字如果按词切分就变成了三个 token信息粒度完全不同。2.2 建立字符到数字的映射表这一步是整套代码的地基把文本变成数字的过程叫 tokenization字符级模型的做法很朴素先统计所有不重复字符给每个字符一个整数编号再把整个文本序列转成整数序列。这里有两个参数提前想清楚seq_length表示每条训练样本包含多少个字符step表示滑动窗口每次前进多少字符。这两个参数直接影响模型的能力上限——seq_length太小模型记不住前文太大LSTM 梯度容易消失训练效率直线下降。对于鹿鼎记的叙事风格seq_length100是一个经验上比较稳的起点。# 建立字符-索引映射 chars sorted(list(set(text))) char_to_idx {ch: i for i, ch in enumerate(chars)} idx_to_char {i: ch for ch, i in char_to_idx.items()} vocab_size len(chars) print(f字符集大小{vocab_size}) # 超参数序列长度和滑动步长 seq_length 100 step 10 # 每 10 个字符生成一个新样本样本间有重叠 # 构造输入序列和目标序列 sequences [] targets [] for i in range(0, len(text) - seq_length, step): seq_in text[i: i seq_length] seq_out text[i 1: i seq_length 1] # 目标是下一个字符 sequences.append([char_to_idx[ch] for ch in seq_in]) targets.append([char_to_idx[ch] for ch in seq_out]) print(f总样本数{len(sequences)}) print(f输入形状示例{len(sequences[0])}, 目标形状示例{len(targets[0])})注意seq_out的构造输入是第i到第i99个字符目标是从第i1到第i100个字符。这意味着模型在每个时间步都在做根据前一个字符预测下一个字符的任务学到的不是死记硬背而是在给定上下文条件下字符出现的概率分布。把step设为10会让相邻样本高度重叠数据量膨胀的同时也增加了冗余——这也是个权衡步长小数据多但训练慢步长大数据少但每个样本独立性高。实践上字符级任务里step取seq_length的 1/10 到 1/3 都比较常见。做完这步需要把映射关系存下来——训练时用一次生成时还要用。很多人训练完就忘了存映射表等要生成文本时发现字符集对不上白白浪费一次训练。这个文件务必和模型权重一起保存。2.3 把数据切成训练和验证两份别让 LSTM 把原文背下来字符级模型有个天热倾向过拟合。特别是序列长度短、数据量小的时候模型会把训练集里的句子背下来而不是学到语言规律——生成时会输出大段和原文几乎一样的文字。为了避免这种情况常规做法是留出验证集每训练几个 epoch 就用验证集算一下 loss。验证集不能随机抽样因为 LSTM 靠的是上下文连续性随机打乱样本会把前文-后文的依赖关系破坏掉。正确的切分是按时间顺序比如前 90% 的样本做训练后 10% 做验证这样验证集完全是模型没见过的文章段落。import numpy as np # 将列表转为 numpy 数组便于后续喂给模型 X np.array(sequences, dtypenp.int64) y np.array(targets, dtypenp.int64) # 按顺序切分最后 10% 作为验证集 split_idx int(len(X) * 0.9) X_train, X_val X[:split_idx], X[split_idx:] y_train, y_val y[:split_idx], y[split_idx:] print(f训练样本{len(X_train)}验证样本{len(X_val)})为什么验证集这么重要因为这个项目的关键指标不是准确率而是 loss 曲线的形状。如果训练 loss 持续下降而验证 loss 在某一步开始反弹那就是过拟合的信号——模型开始依赖训练集里的具体句式而不是语法规律。看到这种情况时可以考虑加 dropout、减小模型容量或提前停止训练。3. LSTM 模型怎么搭结构、参数和损失函数的选择逻辑3.1 从 Embedding 到 LSTM 到 Dense三层结构就够用搭建 LSTM 文本生成模型常见的做法是三层叠起来Embedding 层把字符索引转成稠密向量LSTM 层负责学习序列依赖Dense 层把 LSTM 每个时间步的输出映射回字符概率分布。有些新手会在这里纠结要不要用双向 LSTM 或者叠加多层 LSTM我的建议是——别加。字符级中文小说生成是单向任务当前字符只能参考过去不能参考未来而多层 LSTM 在数据量不足时会疯狂过拟合训练时间成倍增加效果却没有显著提升。选 Keras 而不是纯 TensorFlow 或 PyTorch原因很直接这个项目的核心在数据处理和调试而不是实现反向传播。Keras 的SequentialAPI 足够应付而且对初学者最友好的是它的错误提示——维度不匹配时它会直接告诉你哪一层输入输出什么形状。from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Embedding, LSTM, Dense model Sequential([ Embedding(input_dimvocab_size, output_dim128, input_lengthseq_length), LSTM(units256, return_sequencesTrue, dropout0.2), Dense(unitsvocab_size, activationsoftmax) ]) model.compile( optimizeradam, losssparse_categorical_crossentropy, metrics[accuracy] ) model.summary()参数选择的几个关键点Embedding的output_dim128字符向量维度。太小比如 32表示空间不足以区分不同字符的语义差异太大比如 512训练参数量暴涨。128 对 50 万字符的中文文本是一个平衡点。LSTM的units256隐藏状态维度决定模型记忆容量。return_sequencesTrue必须开着——因为我们要输出每个时间步的预测结果而不是只输出最后一个时间步。dropout0.2只对 LSTM 的输入到隐藏层做 dropout不加recurrent_dropout是为了避免训练过慢——后者在 CPU 上会有严重的性能惩罚。sparse_categorical_crossentropy目标值是整数索引而不是 one-hot 向量用这个 loss 能省下大量内存。为什么不直接在 Dense 层前面加Flatten因为我们要的是每个时间步一个概率分布Dense 层会对最后一个维度的 256 个值做全连接变换映射到vocab_size个类。所以LSTM输出形状是(batch, 100, 256)Dense作用在最后一维上输出(batch, 100, vocab_size)每个时间步的预测目标刚好对应一个字符。3.2 训练策略的核心参数batch_size 和 epoch 怎么定训练文本生成模型和训练图像分类模型在心态上完全不同。图像分类你可能几个 epoch 就能看到准确率上到 90%而文本生成跑完第 1 个 epoch 的 loss 通常在 5 到 6 之间——因为词汇表有几千个字符随机猜的困惑度perplexity本身就等于 vocab_size 的自然对数。看到 loss 在 5 以上不要慌这是正常的。batch_size 的选择会影响训练的稳定性和速度。太小的 batch比如 16梯度噪声大loss 曲线剧烈波动太大的 batch比如 256内存占用高且容易陷入尖锐局部极小值。CPU 训练用 64 比较合适GPU 上可以上 128 或 256。下面这一段是带回调函数的训练代码回调是本项目一个重要的避坑环节。from tensorflow.keras.callbacks import ModelCheckpoint, EarlyStopping import math checkpoint ModelCheckpoint( lstm_ludingji_best.keras, monitorval_loss, save_best_onlyTrue, verbose1 ) early_stop EarlyStopping( monitorval_loss, patience8, # 验证集 loss 连续 8 个 epoch 不降低就停 restore_best_weightsTrue ) history model.fit( X_train, y_train, batch_size64, epochs80, validation_data(X_val, y_val), callbacks[checkpoint, early_stop], verbose1 )save_best_onlyTrue的含义是只在验证 loss 创新低时保存权重这样硬盘上永远是最优模型而不是最后一个 epoch 的模型——文本生成训练后期模型可能已经过拟合最后一个 epoch 的权重往往不是最好的。restore_best_weights是又一个后悔药选项它让训练结束后自动把权重恢复到验证 loss 最低的那个状态。这两个参数配合使用能避免两个最常见的尴尬训练完发现模型只会复读原文或者 loss 没降反升但自己没盯着看。patience8是 EarlyStopping 的耐心值。过小会被正常的 loss 波动吓退过大又起不到防止过拟合的作用。8 到 10 是我在字符级生成任务上的常用区间。3.3 训练过程里盯着什么看loss 曲线比你想象的更有信息量训练跑起来之后不要只盯一个指标。终端输出里有两列要同时关注loss和val_loss。如果二者同步下降模型在学真东西如果loss降而val_loss停滞甚至上升过拟合已经开始了。还有一个经验可以分享字符级中文模型在 loss 降到 2.0 左右时生成的文本开始能看懂局部短语降到 1.5 左右句子的语法结构基本成立降到 1.2 左右会出现几句连贯的小说风格段落。这里的数字会根据字符集大小略有浮动但大体趋势不会变。如果你训练了 30 个 epoch 后 loss 仍在 4 以上多半是模型容量不够或者数据清洗有问题——先去检查文本里是不是混入了大量英文注释或稀有字符。4. 训练好之后怎么生成小说采样策略与温度系数的实操4.1 写一个生成函数把训练好的模型变成续写器模型训练完成只是上半场真正决定生成文本质量的是下半场的采样策略。生成的基本逻辑是给定一段长度为seq_length的种子文本模型预测下一个字符的概率分布然后从这个分布中采样一个字符把它拼到种子文本末尾再丢掉最开头的字符循环往复。这个滑动窗口的方式保证了输入永远保持同样的序列长度且每一步的预测都基于最新的上下文。import numpy as np def generate_text(model, seed_text, length500, temperature0.8): # 将种子文本转为索引序列 seed_indices [char_to_idx[ch] for ch in seed_text if ch in char_to_idx] if len(seed_indices) seq_length: raise ValueError(种子文本长度不够请提供至少 seq_length 个字符) # 只取最后 seq_length 个字符作为初始输入 seed seed_indices[-seq_length:] generated list(seed) for _ in range(length): # 输入形状(1, seq_length) x np.array(seed).reshape(1, -1) pred_probs model.predict(x, verbose0)[0, -1, :] # 取最后一个时间步 # 温度采样 pred_probs np.log(pred_probs 1e-8) / temperature exp_preds np.exp(pred_probs) pred_probs exp_preds / np.sum(exp_preds) # 按概率分布随机抽取一个字符 next_idx np.random.choice(len(pred_probs), ppred_probs) generated.append(next_idx) # 滑动窗口丢掉第一个字符加入新字符 seed seed[1:] [next_idx] # 索引转回文本 return .join(idx_to_char[i] for i in generated)temperature是这个函数里最值得反复调的参数。它做的事情很本质在进入softmax之前把 logits 除以一个系数。温度越高概率分布越平坦生成的文本越随机、越有创造性温度越低分布越尖锐模型倾向于选概率最大的字符生成的文本越保守、越接近原文。temperature1.0是原分布不做改变0.5会让文本变得机械1.5以上基本是胡言乱语。取最后一个时间步的输出pred_probs model.predict(x, verbose0)[0, -1, :]这行是新手的重灾区。LSTM 输出了(1, seq_length, vocab_size)的三维张量你要的是当前时刻对下一个字符的预测所以必须在seq_length维度取-1。写成model.predict(x, verbose0)[0]的话后面选概率分布时会报维度错误。4.2 种子文本的选择开局定生死别从半句话开始生成文本的结果高度依赖种子文本的语义走向。如果你从韦小宝三个字开始生成模型大概率会基于训练集中韦小宝出现的所有上下文来决定后文如果你从皇上说道开始那生成的内容就偏向宫廷对话场景。这是字符级 LSTM 的特性——它没有全局大纲每一句话都是前文几十个字符的统计延续。实际操作时建议准备几个不同风格的种子文本备用比如开篇叙述、对话场景、打斗场景各一段观察模型在不同上下文下表现出的风格差异。种子文本的第一个字符不在字符集里有概率报KeyError清理文本时留了一些罕见字符就可能触发所以生成函数里我加了if ch in char_to_idx的过滤字符不在字典里就跳过。另一个影响生成效果的因素是 length。生成 200 字和生成 2000 字的体验完全不同。200 字内模型靠上下文连贯性还能维持局部逻辑超过 500 字前面的剧情线索已超过seq_length的窗口模型逐渐遗忘早期设定容易出现人物性格漂移、对话前言不搭后语。这不是训练的锅是架构本身的限制——普通 LSTM 的记忆窗口就是有限的。4.3 多次生成取最优一次生成的失望不代表模型失败新手容易犯一个认知错误运行一次生成发现文本不通顺就断定模型没训练好。这个判断下得太早了。采样过程自带随机性同一个种子文本、同一套权重每次生成的结果都不一样——有时连续两句都通顺有时第一句就崩。正确的做法是固定temperature生成 5 到 10 次挑可读性最好的作为结果。这个阶段有一个小技巧每次生成时把temperature稍微调低一点比如 0.6 到 0.7然后多次生成挑文本质量最高的。低温度不等于一定好但当模型的困惑度在 3 以上时低温度能压制那些明显不合理的字符选择。如果模型已经训练到 loss 1.5 左右了可以适当把温度上调到 0.9 甚至 1.0让文本更灵活。5. 避坑指南LSTM 文本生成最常见的 5 个翻车现场5.1 训练 loss 不降卡在 5.x 附近纹丝不动现象模型训练了十来个 epochloss一直停在 5.2 左右降不下去验证集 loss 也是如此。原因首当其冲怀疑学习率和优化器不匹配。adam的默认学习率是 0.001但 LSTM 文本生成对学习率非常敏感。如果数据量小、网络不深0.001 的 Adam 可能让梯度更新幅度过小loss 卡在高原区。另一个高概率原因是字符集里有大量低频字符——比如清洗不彻底的标点或数字模型需要额外容量去拟合这些字符的出现概率。解决先把学习率调到 0.0005 试试采用ReduceLROnPlateau回调让学习率在 loss 不降时自动衰减。另外检查文本清洗的正则确认只剩中文字符和常见标点——原则上字符集控制在 2000 到 3000 个是正常的《鹿鼎记》全文不算生僻字大约在这个范围。如果字符集超过 4000说明清洗还不够狠模型在学噪声而不是语言。5.2 生成的文本和原文几乎一模一样像在背课文现象生成的文本里有连续十几二十个字和原文完全一致越到后面越是原句重现模型完全没在创作。原因过拟合的典型症状。模型参数太多而训练数据不足它学会了把训练集中出现过的序列直接记住在解码时选择概率最高的路径就是原文本身。验证集 loss 大概率已经回升。seq_length设得偏大也是帮凶——超过 150 个字符的上下文窗口让模型可以锁定更长的原文片段。解决增大数据的清洗范围至少 100 万字符起或者缩小 LSTM 单元数到 128同时提高dropout到 0.3 到 0.4 之间。训练时盯着val_loss一旦发现掉头向上就手动终止用ModelCheckpoint恢复最优权重。更极端的方案是降低seq_length到 80——窗口变小模型就背不了那么长的原句了。5.3 模型生成全是嗯啊的之类虚词段不成段句不成句现象输出文本高频出现语气助词、标点符号实际信息量极低看起来像凑字数但没有内容。原因中文文本里虚词和标点本身出现频率就高如果模型训练不充分或者困惑度过高它倾向于选择这些安全的字符来降低 loss——每个虚词都有温和的概率支持不会让整体 loss 爆炸。另一个相关因素是temperature设得太高分布被拉平后高频字符的优势被进一步放大。解决把生成时的temperature降到 0.5 到 0.6先看低频但语义明确的字符有没有被激活。如果降到 0.4 仍然全是虚词说明模型确实没学好——这时候去检查训练集的验证 loss 是不是超过 2.5如果是继续增加 epoch 或调整模型容量。另有一个土办法但有效训练完成后统计字符频率生成时对虚词的概率乘一个 0.8 的惩罚系数让它不完全是概率最大的那个选择。5.4 每批次训练时间长得令人绝望一个 epoch 要跑半小时以上现象数据集 50 万字符step1的时候训练样本量达到 50 万条CPU 训练一个 epoch 的时间超过 1 小时这显然不合理。原因step1会让样本数量爆炸式增长。50 万字符生成约 50 万条样本每条样本 100 个字符相当于每个 epoch 处理 5000 万字符——这个数据规模在 CPU 上跑 LSTM 当然要命。很多人下意识觉得样本越多越好忽略了文本生成的局部重复特性。解决把step提到 10 或 20样本数降一个数量级训练时间就能压缩到可以接受的范围。实践证明step10生成的文本质量和step1相差无几因为相邻样本的上下文重叠太多了。还有一个操作层面的大头确保model.predict或model.fit的输入是 numpy 数组而不是 Python 列表后者会触发每次拷贝数组的开销。5.5 加载保存的模型时报错说 Embedding 层的 weights 维度对不上现象训练完保存模型下次运行生成脚本时load_model成功但一 predict 就报维度不匹配错误。原因跑了model.summary()没注意到Embedding层保存的维度取决于vocab_size。如果你在两个不同脚本里分别建立映射表且一个脚本里vocab_size是 2800另一个是 2847可能因为统计字符时的清洗步骤不一致权重矩阵就对不上了。解决训练完成后立刻保存一份char_to_idx和idx_to_char的映射表用 JSON 格式带上vocab_size一起存。生成脚本里先加载这个 JSON 再构建模型结构永远不要用重新计算字符集的方式初始化模型。这是一件看起来无关紧要但影响全局的小事却是我见人踩过最多的一次坑。6. 风格迁移的进阶玩法从写得像到能控制当你能稳定生成通顺的鹿鼎记风格文本后下一步值得琢磨的是怎么控制生成方向。这里有一个容易忽略的关键点LSTM 的隐藏状态在生成过程中隐式编码了当前的叙事状态而方向控制本质上是在干预这个状态。一个常见的做法是手动设置种子文本的情绪基调但这只是在输入层面做了控制其实还有一个更有效的维度——通过后处理对采样分布的干预来实现风格微调。具体的技巧是调整生成时的字符级偏置bias。训练结束后统计鹿鼎记文本里高频的句末标点出现频率比如问号占标点总数的比例。生成时对问号的 logits 乘一个大于 1 的系数模型会更多地生成疑问句整体叙事氛围就偏向对话质询。这个方法不需要任何额外训练只修改采样函数里的那几行代码完全是在推理阶段做手脚却能产生截然不同的叙事节奏。# 一个简单的推理期偏置示例让模型更倾向于生成问号 bias_dict {: 1.8, 。: 1.0, : 1.2, : 0.8} def biased_sample(pred_probs, temperature0.8): # pred_probs 已经是经过温度缩放后的概率 for ch, weight in bias_dict.items(): if ch in char_to_idx: idx char_to_idx[ch] pred_probs[idx] pred_probs[idx] * weight # 重新归一化 pred_probs pred_probs / np.sum(pred_probs) return np.random.choice(len(pred_probs), ppred_probs)这个偏置乘子放在归一化之后只改变字符之间的相对竞争关系。问号乘 1.8 意味着问号原本的概率如果是 0.05它和其他字符的相对比例会放大近一倍最终采样到问号的次数显著增加。但注意权重乘在归一化之后的概率上总概率之和不再是 1所以必须重新归一化。更严谨的做法是在 logits 层面加偏置然后过 softmax但上面的代码对于入门已经足够。在这个阶段验证模型能力的常见方法是做一个对比测试用同一段种子文本分别用默认采样和带偏置的采样各生成 500 字统计两次生成中的问号数量和对话占比。如果模型真的学到了语言结构而不是死背原文偏置方案下问号数量会明显上升且增加的问号大多出现在对话场景而不是叙述段落中——这说明模型内部的语义上下文约束还在起作用。如果问号数量上升了但都出现在他说问道这类固定搭配后面那说明训练只学到了局部搭配没有学会对话轮换的深层模式这时候最好回到数据清洗和模型设计上重新审视。另外一个实用的验证思路是把训练集里没出现过的、同时代的另一部金庸小说片段作为种子文本。模型在生成时展现的风格是否仍然是鹿鼎记风俚俗口语多、叙事节奏快而不是被种子文本带跑偏这是判断模型到底学到了语料风格的试金石。把这两招配合起来你就能用 LSTM 做出不止像《鹿鼎记》而是像某个特定叙事模式的生成器了。我自己做完这个项目之后留下的一个习惯每次训练完必做一次温度扫描——同一个种子文本分别用 0.4、0.7、1.0、1.3 的温度生成五段文字并排放在一起。这不是为了挑最优而是为了理解模型在不同采样激进程度下的行为边界。温度越高越暴露模型的知识盲区温度越低越暴露模型的记忆机械性两者对比能快速判断模型训练得是否平衡。这套思路后来我在处理其他序列生成任务时也一直在用。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑