尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Transformer时间序列预测实战:从架构原理到PyTorch实现与踩坑指南
简介一个基于Transformer架构的时间序列预测完整工程包面向具备Python基础、希望掌握深度学习时序建模的开发者与研究者。项目针对天气、股票、电力消耗等多个真实场景从零实现Transformer核心模块覆盖数据处理、可视化、训练与评估全过程。压缩包共91个文件约48.85MB以40个Jupyter Notebook和24个Python脚本为主体另有11个RST文档、9张图片、JSON配置及说明文档Notebook侧重可视化、训练与基准对比脚本覆盖模型搭建、交叉验证、超参数搜索、学习曲线绘制等。目前已有297人学习。通过该资源可系统掌握自注意力、多头注意力、位置编码等关键概念并获得包含编码器解码器实现及与ARIMA、LSTM对比分析的完整工程范例内含可扩展模型代码与工具脚本便于二次开发、实验复现或作为课程设计、毕设的基础非常适合实际应用参考。1. 拿到「基于 Transformer 的时间序列预测.zip」之后先搞清楚三件事这个标题看起来像一个压缩包的名字但真正值得拆解的是前半句用 Transformer 架构做时间序列预测。你在 GitHub、Gitee 或者某篇博客的附件里下载到这样一个 zip解压之后大概率会看到model.py、train.py、data.csv或者.ipynb文件。问题在于这类项目代码的质量参差不齐有的能直接跑通有的缺依赖、数据路径写死、甚至模型结构还是早期实现。我一般拿到手会先做三件事看requirements.txt、看数据文件的格式、看模型输入输出的维度定义。这三样确认清楚后面基本不会翻车。本文就顺着这条线把这个标题背后的原理、实现、参数调优和踩坑点讲透让你不仅能跑通别人写的代码还能自己从零搭一套基于 Transformer 的时间序列预测流程。2. Transformer 凭什么能做时间序列预测架构拆解与选型逻辑2.1 从 RNN 到 Transformer序列建模的范式转移时间序列预测这个任务传统上由 RNN、LSTM、GRU 这类循环神经网络把持。它们的核心思路是按时间步逐步处理当前时刻的隐状态承接上一时刻的信息形成一种“链式”的依赖传递。这种设计有两个天生的短板一是梯度在长序列上容易消失导致模型只能记住邻近时刻的模式二是无法并行因为每个时间步必须等前面的步算完训练效率很低。Transformer 在 2017 年提出时是为机器翻译设计的但它的核心机制恰好击中了时间序列预测的要害——自注意力Self-Attention。自注意力允许序列中的任意两个位置直接计算相关性也就是说第 100 个时间步可以直接“看到”第 1 个时间步的信息不需要经过中间 98 步的传递。这让模型具备了捕获长期依赖的能力同时整个序列可以并行输入训练速度远快于 LSTM。这也是为什么从 2020 年开始学术界和工业界涌现了大量基于 Transformer 变体的时序预测模型比如 Informer、Autoformer、PatchTST它们的共同出发点都是用注意力机制替代循环结构让模型自己学会“该看多远”。2.2 自注意力机制的工作原理与计算流程自注意力的计算可以拆成三个关键步骤。第一步对输入序列的每个位置生成三个向量Query查询、Key键、Value值。这三个向量是输入向量分别乘以三个权重矩阵得到的。第二步计算每个位置和其他所有位置的注意力分数公式是 Query 和 Key 的点积除以缩放因子 \sqrt{d_k}其中 d_k 是 Key 向量的维度。第三步把注意力分数过 Softmax 变成权重再对 Value 向量做加权求和得到该位置的输出。这个过程的直觉理解很简单Query 是“我在找什么”Key 是“我有什么”Value 是“我实际给什么”。模型在预测 t 时刻的值时会对比 t 时刻的 Query 和历史所有时刻的 Key如果某个历史时刻的模式与当前需求高度匹配注意力权重就会很大那个时刻的 Value 就对预测结果施加更大的影响。对于时间序列来说这就等价于模型自动发现了周期性和相似模式——比如每天的用电高峰、每周的销量波动这些模式未必是固定周期的但注意力机制可以按需“检索”。2.3 位置编码为什么时间序列必须加位置信息Transformer 的注意力机制本身是顺序无关的。你把序列的顺序打乱每个位置的 Query、Key、Value 仍然各自配对计算结果完全相同因为注意力计算中没有天然的位置感。这在 NLP 里有个经典的解决办法叫位置编码Positional Encoding——给每个位置注入一个唯一的向量让模型能区分“第 1 步”和“第 100 步”。时间序列预测里位置信息就是时间信息所以位置编码的选择直接决定了模型能否感知时间顺序。最常见的做法是用三角函数生成固定编码import numpy as np def positional_encoding(seq_len: int, d_model: int) - np.ndarray: 生成 Transformer 标准位置编码 Args: seq_len: 输入序列长度 d_model: 模型隐藏维度 Returns: 形状为 (seq_len, d_model) 的位置编码矩阵 pe np.zeros((seq_len, d_model)) position np.arange(0, seq_len).reshape(-1, 1) # 形状: (seq_len, 1) div_term np.exp(np.arange(0, d_model, 2) * (-np.log(10000.0) / d_model)) pe[:, 0::2] np.sin(position * div_term) pe[:, 1::2] np.cos(position * div_term) return pe这段代码生成了一个形状为(seq_len, d_model)的矩阵每个位置的编码由不同频率的正弦和余弦值组成。高频分量对应相邻位置的区分低频分量对应长距离的模式。使用时把这个矩阵加到嵌入向量上即可。除了固定编码也有可学习的位置编码方案——把位置编码当作一个nn.Embedding层随模型一起训练。我个人的经验是如果数据本身有明显的周期性三角编码效果更好如果周期不固定或没有周期可学习编码更灵活。2.4 三种主流 Transformer 时序建模架构的取舍用 Transformer 做时间序列预测输入输出结构上有三种常见方案需要根据任务场景选择。第一种是编码器-解码器结构借用了 NLP 里 Seq2Seq 的范式。编码器读入历史序列解码器一步步生成未来序列。训练时可以用 Teacher Forcing把真实值作为解码器输入加速收敛推理时用自回归方式逐点生成。适合多步预测且序列长度较长的任务。第二种是仅编码器结构把预测任务建模为“输入一个窗口输出一个向量再过全连接层映射到预测长度”。这种做法最简单直接Informer 的早期版本就是这么做的。适合预测长度较短、依赖模式相对固定的场景。第三种是Patch 嵌入 自注意力这是目前效果较好的方案。核心思路是先把时间序列切分成多个“补丁”Patch每个 Patch 包含若干个连续时间步然后对 Patch 序列做注意力计算。这样做的好处是显著降低了序列长度因为注意力复杂度是 O(n²)Patch 化之后输入长度缩小了数倍训练和推理都快得多。PatchTST 是这种结构的代表。选型上我一般遵循一个原则如果预测长度不超过输入长度的 1/4用仅编码器结构就够了如果预测长度很长或者要输出不确定性区间用编码器-解码器结构如果序列长度超过 1000优先考虑 Patch 化。下面第 4 章的完整实现里我会采用仅编码器结构因为它最容易理解和复现同时也适合展示位置编码、注意力掩码这些核心概念。3. 数据准备与预处理从 zip 里的 CSV 到模型能吃的张量3.1 解压后的数据文件格式检查与加载拿到 zip 解压之后第一步是看数据文件长什么样。大多数这类项目会附带一个 CSV 文件列名通常包含date、value之类的字段也可能有多列特征。我建议先用pandas快速加载并检查数据的基本属性import pandas as pd # 先解压 zip再读取数据 df pd.read_csv(data.csv, parse_dates[date]) print(df.head()) print(df.info()) print(df.describe()) # 检查缺失值和数据范围 print(缺失值数量:\n, df.isnull().sum())运行这段代码你会看到数据的列类型、非空数量、统计特征。这里有几个关键点需要确认时间列是否是datetime类型如果不是需要转换否则后续按时间排序就会出错。是否有缺失值。时间序列的缺失不能直接删除因为时间顺序被打断后模型的窗口采样就会错位。采样频率是否一致。有的数据是日粒度有的是小时粒度混在一起会导致模型学习到错误的周期模式。3.2 滑窗采样构造监督学习所需的 (X, y) 样本Transformer 做时间序列预测本质上把序列问题转换成了监督学习问题用过去context_len个时间步预测未来pred_len个时间步。滑窗采样是最基础也是最通用的构造方法它的逻辑如下import numpy as np import torch from torch.utils.data import Dataset class TimeSeriesDataset(Dataset): 滑窗采样的时间序列数据集 def __init__(self, data: np.ndarray, context_len: int 96, pred_len: int 24): # data: 形状 (总长度, 特征数)单变量也保留二维形状 self.data torch.from_numpy(data).float() self.context_len context_len self.pred_len pred_len def __len__(self) - int: # 最后一个能完整取到 context pred 的窗口位置 return len(self.data) - self.context_len - self.pred_len 1 def __getitem__(self, idx: int): x_start idx x_end idx self.context_len y_end x_end self.pred_len x self.data[x_start:x_end] # 历史窗口作为输入 y self.data[x_end:y_end] # 未来窗口作为预测目标 return x, y这个数据集类返回的x形状是(context_len, 特征数)y形状是(pred_len, 特征数)。context_len通常设置为 96 或 192对应 4 到 8 天的日粒度数据pred_len按业务需求定常见的取 24、48、96。两个长度的比值会影响训练难度预测越远不确定性越大模型越容易“摆烂”预测成均值。3.3 归一化与反归一化容易被忽略的陷阱时间序列预测的归一化比图像分类更讲究因为预测结果必须还原到原始量纲。最常见的做法是使用训练集的均值和标准差做标准化验证集和测试集用同一组统计量不能重新计算否则就引入了未来信息——这在时序预测里叫数据泄露Data Leakage会让评估结果虚高。# 只用训练集拟合 scaler验证集和测试集复用同一参数 from sklearn.preprocessing import StandardScaler scaler StandardScaler() # train_data 是一维数组需要 reshape 成 (样本数, 1) 再 fit train_scaled scaler.fit_transform(train_data.reshape(-1, 1)) # 预测完成后还原成原始数值 pred_original scaler.inverse_transform(pred_scaled.reshape(-1, 1))还有一个细节如果你有多步预测输出反归一化得到的只是点预测的均值。如果想要预测区间需要额外输出方差参数这在标准的 Transformer 实现里不常用通常用 Monte Carlo Dropout 在推理时多次前向、统计分布来近似。另外归一化不能解决趋势项的问题——如果数据有明显上升趋势模型大概率会预测出一个“平滑延续”这时更合理的做法是先做差分比如一阶差分再去建模预测完再加回差分值。3.4 训练集 / 验证集 / 测试集的划分不能随机打乱时间序列数据和普通监督学习数据有一个根本区别样本之间存在时间依赖不能随机打乱。你如果随机采样训练集里可能会有测试集之后的数据模型等于“偷看”了未来验证结果完全失真。正确的划分方式是按时间顺序切分total_len len(df) train_ratio, val_ratio 0.7, 0.15 train_end int(total_len * train_ratio) val_end int(total_len * (train_ratio val_ratio)) train_df df.iloc[:train_end] val_df df.iloc[train_end:val_end] test_df df.iloc[val_end:]这里我通常用 70% / 15% / 15% 的划分比例。比例不是固定的但基本逻辑是训练集必须是最早的数据测试集必须是最晚的数据验证集夹在中间。还有一种更严格的评估方法是时间序列交叉验证TimeSeriesSplit)它让模型在多个时间段上分别训练和验证能更稳健地评估模型在不同时期的泛化能力但计算成本更高。如果你的数据量不大几千条以内我更推荐直接按比例固定划分保持简单可复现。4. 完整实现用 PyTorch 从零搭建 Transformer 预测模型4.1 模型结构设计编码器 全连接输出层前面说的数据都是二维的(batch_size, context_len, features)Transformer 编码器接收这个输入后输出的形状保持为(batch_size, context_len, d_model)最后的预测用全连接层直接映射到pred_len个值。整体模型定义如下import torch import torch.nn as nn class TransformerTimeSeries(nn.Module): 基于 Transformer 编码器的时间序列预测模型 def __init__( self, input_dim: int, # 输入特征数单变量为 1 d_model: int 64, # 嵌入维度 nhead: int 4, # 注意力头数 num_layers: int 2, # 编码器层数 dropout: float 0.1, # 丢弃率 context_len: int 96, # 输入窗口长度 pred_len: int 24 # 预测窗口长度 ): super().__init__() self.context_len context_len self.pred_len pred_len # 输入嵌入把原始特征映射到 d_model 维度 self.embedding nn.Linear(input_dim, d_model) # 位置编码使用可学习的位置参数 self.pos_encoder nn.Parameter( torch.zeros(1, context_len, d_model) ) # 初始化位置编码 nn.init.normal_(self.pos_encoder, mean0, std0.02) # Transformer 编码器层 encoder_layer nn.TransformerEncoderLayer( d_modeld_model, nheadnhead, dim_feedforwardd_model * 4, dropoutdropout, batch_firstTrue ) self.encoder nn.TransformerEncoder(encoder_layer, num_layers) # 输出层把整个序列压缩到 pred_len 个点 self.decoder nn.Sequential( nn.Linear(d_model, d_model * 2), nn.GELU(), nn.Dropout(dropout), nn.Linear(d_model * 2, pred_len) ) def forward(self, x: torch.Tensor) - torch.Tensor: # x: (batch_size, context_len, input_dim) x self.embedding(x) # (batch, context_len, d_model) x x self.pos_encoder # 加入位置信息 x self.encoder(x) # (batch, context_len, d_model) x x.mean(dim1) # 全局池化取所有时间步的平均 out self.decoder(x) # (batch, pred_len) return out这段代码的逻辑说明如下embedding层把每个时间步的原始特征映射到d_model维空间这里用的是线性变换可以理解为对原始值做了一次可学习的特征提取。pos_encoder是可学习的位置编码参数形状为(1, context_len, d_model)在训练中会根据数据自动调整。TransformerEncoderLayer是 PyTorch 自带的实现内部包含多头自注意力、前馈网络、残差连接和层归一化。batch_firstTrue让输入输出都保持(batch, seq, feature)的顺序避免维度调换。forward里最后用mean对整个时间维做池化把(batch, context_len, d_model)压缩成(batch, d_model)。这一步是合理的因为自注意力已经把所有时刻的信息聚合到各自的输出里了池化相当于再做了一次综合。4.2 训练循环损失函数、优化器与学习率调度训练循环的核心选择有三个损失函数用MSELoss还是SmoothL1Loss优化器用 Adam 还是 AdamW学习率要不要调度。我的默认组合是SmoothL1Loss AdamW 余弦退火。SmoothL1Loss在误差较小时梯度平滑误差大时梯度有界对异常值不敏感比 MSE 更稳。model TransformerTimeSeries( input_dim1, d_model64, nhead4, num_layers2, dropout0.1, context_len96, pred_len24 ) criterion nn.SmoothL1Loss() optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-5) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max50) epochs 80 for epoch in range(epochs): model.train() train_loss 0.0 for x_batch, y_batch in train_loader: optimizer.zero_grad() pred model(x_batch) # (batch, pred_len) # y_batch 的取值范围pred_len 内每个特征 loss criterion(pred, y_batch[:, :, 0]) # 单特征就用第 0 列 loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() train_loss loss.item() * x_batch.size(0) scheduler.step() avg_loss train_loss / len(train_loader.dataset) if (epoch 1) % 10 0: print(fEpoch {epoch1:3d} | Loss: {avg_loss:.5f} | LR: {scheduler.get_last_lr()[0]:.2e})训练循环里有三个值得注意的参数clip_grad_norm_设置了梯度裁剪阈值max_norm1.0。Transformer 在训练早期容易出现梯度爆炸梯度裁剪能稳定训练过程。CosineAnnealingLR让学习率从初始值平滑下降到接近 0到第 80 轮时学习率几乎为 0。配合T_max50学习率在第 50 轮附近就已经很低了后面是微调阶段。y_batch[:, :, 0]取的是pred_len时间步上第 0 个特征。如果数据有多维特征比如同时预测温度和湿度就需要修改为y_batch[:, :, :input_dim]并调整输出层的维度。4.3 评估指标与可视化不只是看损失函数训练完成之后用测试集评估泛化能力。除了 MSE 和 MAE时间序列预测还经常用对称平均绝对百分比误差SMAPE因为它同时放大了低估和高估的惩罚避免模型通过“预测偏大”来取巧import numpy as np def smape(y_true: np.ndarray, y_pred: np.ndarray) - float: 计算 SMAPE取值越小越好 denominator (np.abs(y_true) np.abs(y_pred)) / 2 diff np.abs(y_true - y_pred) return np.mean(diff / denominator) * 100评估时还需要注意逐点多步预测的误差会随预测步长增大而增大。所以更好的做法是分别计算每个预测位置的误差比如pred_len24时输出第 1 步的误差、第 12 步的误差、第 24 步的误差画出一条“误差随预测距离增长”的曲线。这条曲线能直观展示模型的有效预测范围——如果第 24 步的误差是第 1 步的 5 倍以上说明模型在超出一定范围后基本在“盲猜”这时应该缩短pred_len或调整模型容量。5. 训练中的三个核心坑与 zip 包部署时的排查技巧5.1 冷启动阶段损失不下降检查学习率和数据范围Transformer 训练有一个典型特征刚开始损失可以反弹或停滞。如果你的 loss 在前 5 个 epoch 里完全不动优先检查学习率是否过大或过小。lr1e-3对 AdamW 来说通常是安全的但如果你发现 loss 在 0.1 附近震荡不降可以试试lr3e-4。另一个常见问题是输入数据没有归一化原始数值在几千到几万之间embedding层前向传播后的数值范围很大位置编码的加性影响几乎可以忽略模型学不到有效模式。5.2 预测结果是一条水平线大概率是特征维度对错了这是最让新手困惑的现象训练损失收敛得很好但画出来的预测曲线是一条水平的直线只是比历史均值略高一点。这通常说明模型学到了“都预测成均值”的策略——因为全局池化把整个输入序列压缩成一个向量如果预测长度远长于输入长度模型倾向于用一个常数去近似反正误差也不会太大。解决办法有两个一是把context_len加长给模型更多信息二是改预测头不要用全局池化而是取最后一个时间步的隐状态作为解码输入。我在实现里选择全局池化是为了代码简洁但如果你发现预测结果呈直线换成x[:, -1, :]能显著改善。5.3 解压报错「invalid zip archive: could not find EOCD」的排查思路回到标题里的.zip。你在解压这个项目包时如果遇到类似报错End-of-central-directory signature not found. Either this file is not a zipfile, or it constitutes one disk of a multi-part archive.这个错误的意思是 zip 包的中央目录结构缺失通常有三种原因文件下载不完整网盘下载中断或限速导致只下了一部分、文件损坏、或者它根本不是 zip 格式比如其实是 rar 或 7z。排查方式很简单# Linux / macOS 下检查文件类型 file 基于transformer的时间序列的预测.zip # 查看文件末尾是否有 PK 标志 xxd 基于transformer的时间序列的预测.zip | tail -n 5 # 尝试用 7z 强制解压7z 对部分损坏文件的容错性更好 7z x 基于transformer的时间序列的预测.zip -ooutput_dir/如果file输出显示是HTML document或者text/plain那说明你下载到的是一个下载失败页面重新下载即可。如果显示Zip archive data但解压仍失败用 7z 尝试强制解压通常能救回大部分文件。还有一种可能是跨平台传输时编码问题文件标题里的中文在部分 Linux 系统中会导致解压工具解析失败可以试试用 Python 的zipfile模块手动解压import zipfile with zipfile.ZipFile(基于transformer的时间序列的预测.zip, r) as zf: # 列出所有文件检查中文文件名是否乱码 for name in zf.namelist(): print(name) zf.extractall(unpacked_data)这段代码的输出如果出现乱码文件名可以用name.encode(cp437).decode(gbk)转回中文。在 Windows 上解压一般没问题但 Linux 服务器上经常遇到这类编码问题。5.4 用环境隔离避免依赖冲突解压成功之后运行训练脚本最常见的报错是ModuleNotFoundError或CUDA out of memory。前者好解决按requirements.txt安装即可后者需要调小batch_size或d_model。我建议你永远用虚拟环境来跑这类项目因为它依赖的torch、numpy、pandas版本很可能和你其他项目冲突。一条命令搞定python -m venv ts_env source ts_env/bin/activate pip install torch numpy pandas scikit-learn matplotlib用虚拟环境的好处还有如果这个 zip 项目里附带了requirements.txt你可以直接pip install -r requirements.txt避免手动逐个安装时的版本不对齐问题。跑完验证效果后想换模型或改参数在同一个环境里操作即可不影响机器上的其他项目。本文还有配套的精品资源点击获取
RELATED

相关推荐

悬臂梁振动主动控制:PID闭环仿真与参数调优实践

悬臂梁振动主动控制:PID闭环仿真与参数调优实践

简介:这是一份面向机械工程、自动化及振动控制初学者的MATLAB/Simulink仿真资源,围绕悬臂梁振动主动控制问题,涵盖动力学建模、振动力学分析、振动微分方程推导与PID控制仿真等核心环节,可直接用于课程设计、毕业设计或科研预研。…

📅 2026/9/10 2:54:01
Python出行行为分析:从IC卡到网约车的时空建模实战

Python出行行为分析:从IC卡到网约车的时空建模实战

简介:本资源是一份面向数据科学初学者与课程设计学生的Python客流分析实战项目,聚焦城市轨道交通场景下的乘客出行行为建模与预测。项目基于南宁地铁1号线及天气等多源真实数据,构建LSTM深度学习模型实现客流量时序预测,并配套完整…

📅 2026/9/10 2:54:01
数据全生命周期管理:从采集到销毁,让数据资产不贬值

数据全生命周期管理:从采集到销毁,让数据资产不贬值

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📅 2026/9/10 2:54:01
MORE NEWS

更多资讯

📰

MCP+A2A协议驱动的企业级多智能体架构实战

1. 项目概述:这不是又一个“智能体玩具”,而是一套可落地的企业级业务中枢架构你最近是不是也刷到过“DeepAgents”这个词?不是在某个AI技术分享会上,就是在GitHub trending榜上突然冒出来,还带着一串让人眼花缭乱的缩…

📰

德承DX-1300 Ubuntu NPU驱动深度调校实战指南

1. 项目概述:为什么德承DX-1300在Ubuntu上装NPU驱动不是“照着文档点几下”就能完事的事 德承DX-1300这台工控机,我去年在某智能仓储分拣线现场第一次拆箱上电时就记住了它的金属外壳冰凉触感和风扇低沉的嗡鸣——它不是普通PC,是嵌入在产线P…

📰

Java面试场景题全解析:从库存扣减到CompletableFuture的实战框架

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

Java GUI智慧公交系统开发:Swing界面、JDBC数据与多线程调度实战

简介:一份面向Java课程设计与数据库大作业的智慧公交管理系统项目,基于Java GUI与MySQL 8.0实现,覆盖车辆、员工、线路、站点、排班等核心管理模块,并提供登录和修改密码功能。系统内置管理员、调度员、员工三种角色,不…

📰

Claude Code Router(CCR)Fusion 自定义 MCP 工具与文生图/视频生成实战指南

Claude Code Router(CCR)Fusion 自定义 MCP 工具与文生图/视频生成实战指南 【免费下载链接】claude-code-router One local control plane for every AI agent: route across models, fuse new capabilities, orchestrate tools, and stay fully in con…

📰

C++20 std::ranges 管道性能探秘:策略内联与编译期优化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬