
简介基于CNN与LSTM的流量分析识别系统完整实现面向网络人工智能、深度学习与安全分析方向的开发者能够实时识别正常业务流量、恶意软件流量与网络攻击流量并对流量随时序的变化进行可视化展示。系统采用CNN提取空间特征、LSTM捕捉时序依赖通过将思博伦官方流量pcap包解析为URL进行训练在官方测试流量集上达到93.5%准确率。压缩包共24个文件体积仅23.58MB包含6个Python源码文件涵盖CNN、LSTM、CLSTM分类器以及数据预处理、训练、测试等脚本、3个CSV格式的训练与预测数据、TensorFlow模型检查点与词汇表、网络架构图、PDF设计报告以及依赖清单和参数配置文件目录划分明确模型与数据均已打包。下载后可加载训练好的模型直接推理也可借助使用说明重新训练、调整参数PDF报告对系统框架、实验设置与结果分析有完整记录。已有573人学习下载可作为网络流量分类、入侵检测课题设计、毕业设计及AI安全入门学习的完整参考实现。1. 基于CNN和LSTM的流量分析识别系统从源码到能复现的完整链路这类压缩包在毕设、课设圈子里的热度一直很高——基于CNN和LSTM的流量分析识别系统源码、训练好的模型和PDF报告打包在一起看着什么都有实际能跑通的人却不多。多数人卡在同一个地方不知道pcap文件怎么变成模型输入更不知道训练脚本里那些参数为什么是那个取值。这篇笔记按一线落地顺序拆开讲先说明CNN和LSTM在流量识别里各自承担什么再给出pcap预处理、模型定义、训练与保存的完整做法最后把最容易翻车的五个坑列清楚。适合要交毕设、做课设以及想入门深度流量识别的工程师跟着复现。2. 流量识别为什么用CNNLSTM结构选型与参数起点2.1 流量样本的两种形态raw payload与流级序列流量分析的第一步不是选网络而是确定一条“样本”到底长什么样。以流为单位的识别任务里常用两种输入形态。第一种是原始载荷矩阵把一条TCP流或一个会话的前N个包截出来每个包取前L个字节拼成一个(N, L)的矩阵。这个矩阵很接近自然语言处理里的句子矩阵每个包相当于一个token每个字节相当于token里的一个位置。Wireshark流量分析里大家习惯顺着TCP Stream看应用层内容模型其实也是这么看数据的——只是它看到的不是人能读懂的文字而是字节级别的0到255的数值。第二种是流级序列特征按时间顺序记录每个包的长度、方向、到达间隔组成若干条一维序列。比如上行包长序列[1460, 52, 1460]方向序列[1, 0, 1]时间间隔序列[0.3, 12.8, 0.2]。这些序列短、维度低、训练极快但信息量比原始载荷少对加密流量的区分能力也更有限。完整可落地的系统通常不二选一而是把两者拼起来。原始载荷矩阵送进卷积网络提取局部字节模式包的方向、长度归一化后单独编码成特征通道再和卷积输出拼接一起进入LSTM。这样模型既看得到“每个包里有什么”也看得到“包的节奏是怎样的”。2.2 CNN负责空间局部模式LSTM负责包与包的顺序依赖CNN在流量识别里的作用相当于在字节序列上做n-gram特征提取。原始载荷里很多判别信息是局部出现的HTTP请求头里的方法名和路径、TLS握手阶段固定偏移处的协议版本、某些远控木马在载荷开头写死的魔数。这些pattern与它在包里的绝对位置关系不大一维卷积天然具备这种平移不变性。常见的做法是用Conv1d在包内字节维度上滑动而不是把包矩阵当图像做二维卷积——二维卷积默认了相邻包之间有稳定的空间位置关系这在流量数据里并不成立。LSTM负责的是另一件事包与包之间的顺序依赖。流量识别里有一类模式只有放在序列里才看得出来比如一个交互式会话往往是短请求包后跟一个较大响应包再跟一个确认包再比如某些攻击流量先发送大量半连接然后才有明显的数据包特征。这些规律本身不是“某个字节长什么样”而是“下一个事件取决于之前若干个事件”。LSTM的门控结构让模型能从包序列里学出这种时间依赖。把两者串起来是性价比最高的组合每个包先经过卷积和池化被压缩成一个携带局部字节特征的向量这个向量序列再送入LSTM建模包间关系。我在实际项目里也试过在LSTM后面再接一层attention效果有小幅提升但对几万样本的课程设计来说收益不明显反而容易在注意力权重上过拟合。先跑通CNNLSTM基线永远是更聪明的第一步。2.3 参数起点窗口长度、卷积核、隐藏层维度怎么定这里给一组经过验证的起点参数之后按自己的数据量调整即可。参数推荐起点说明每流包数 n_packets3220~50均可包太多会让补零比例升高每包字节数 n_bytes6464能跑通256效果更好1024显存压力大卷积核大小7 和 5第一层大核看长模式第二层小核细化CNN输出通道64 - 128数据量小别一上来就256起步LSTM hidden_size12864太弱256在样本少时会过拟合LSTM num_layers1两层只在数据量超过10万时考虑batch_size128显存受限就64配合梯度累积优化器AdamW比Adam多了规范的权重衰减学习率1e-3损失不下降后降到1e-4微调评估指标Macro-F1别只盯着准确率流量数据集几乎都不平衡这里单独说一下LSTM的方向。很多入门教程默认用BiLSTM认为双向能多看未来信息识别效果确实会好一点但代价是模型必须等一条流全部到达才能输出结果。这在实际流量识别场景里往往不可接受——你需要在流还在进行时就给出判定。所以在线识别系统通常用单向LSTM取最后一个有效包位置的隐状态作为整条流的表示。至于Transformer它在流量识别上有潜力公开的大规模流量数据集上甚至能超过LSTM但Transformer需要的数据量远大于课程设计能拿到的几万样本。几千到几十万样本的规模下LSTM收敛更实在而且CPU上推理速度也快。除非你的数据集到了百万级否则不建议一上来就换Transformer架构。3. 把pcap变成能训练的张量数据准备与预处理细节3.1 数据集选型UNSW-NB15和CICIDS2017怎么取舍公开数据集是这类项目的主力数据来源。我在实际做的时候在UNSW-NB15和CICIDS2017之间的选择比较明确如果论文强调的是网络攻击流量分类UNSW-NB15够用它有10类流量自带特征CSV不用自己抓包但它的官方特征基本是统计特征缺少原始载荷做端到端的CNNLSTM会有点别扭。CICIDS2017更适合本主题它提供完整pcap2017年那套抓包数据覆盖了良性流量和多种攻击切流、提特征、做载荷矩阵都自由。需要提醒的是CICIDS2017文件很大下载后先别急着全量解析。先用Wireshark打开几个原始包文件确认标签类型按天挑选包含目标攻击类型的文件再用tshark按五元组分流避免一次性加载上百GB导致内存崩掉。另一个现实问题是包里有大量背景流量和重复重传。预处理阶段就要把重传包过滤掉保序排列再按流聚合。有些同学在Wireshark流量分析里看着Follow TCP Stream没问题结果把一次完整会话拆成了几十条半截流模型自然学不到完整上下文。3.2 流切分、截断与补齐一个可直接改的数据预处理函数下面这段代码把一条pcap文件编码成模型输入张量。这里的pcap假定已经用tshark按流ID切好一行代码处理一个会话文件。import numpy as np from scapy.all import rdpcap N_PACKETS 32 # 每条流取前32个包 N_BYTES 64 # 每个包最多保留64字节 def encode_session(pcap_path): packets rdpcap(str(pcap_path)) feature np.zeros((N_PACKETS, N_BYTES), dtypenp.float32) mask np.zeros((N_PACKETS,), dtypenp.float32) for i, p in enumerate(packets[:N_PACKETS]): raw np.frombuffer(bytes(p), dtypenp.uint8).astype(np.float32) length min(len(raw), N_BYTES) feature[i, :length] raw[:length] mask[i] 1.0 return feature, mask这段代码的核心逻辑是rdpcap把文件读成包列表每个包转成字节数组不足64字节的部分保留为0超过64字节的直接截断。同时生成一个mask向量记录这条流里真实存在哪些包后面LSTM分组计算时要用它来跳过补零的位置。如果你只有原始pcap而没有按流切好的文件先用下面这行把大文件拆开tshark -r capture.pcap -Y tcp -w output/tcp_only.pcap然后按五元组源IP、源端口、目的IP、目的端口、协议分组导出。样本量在几万级别时用scapy还能接受几十万包以上就换tshark的-T fields直接输出字段能节省大量内存和处理时间。3.3 三类特征表示怎么选原始载荷、包长方向和统计特征流量识别的特征工程有三级路线按数据条件和识别目标取舍。原始载荷矩阵适合有GPU、样本量超过五万的情况。它不依赖人工经验CNN能自己找字节模式而且对跨网络环境的适应能力相对强。缺点是训练慢而且如果抓包时被截断了应用层数据效果会明显下降。包长加方向序列适合样本量小、以加密流量为主的场景。加密后载荷内容几乎不可读但包长和方向仍然能反映出应用的行为模式——视频流是持续的大包聊天软件是密集的小包。这两条序列本身就很适合LSTM特征维度低CPU就能训练。统计特征是最省事、也最保守的做法。每条流算平均包长、包长标准差、上行下行比例、SYN/FIN包计数等再用随机森林或轻量MLP分类。它在一两万样本上就能达到不错的效果但上限明显低于前两种而且对IP和端口的硬编码特征敏感换一个网络环境就失效。我的常用做法是混合输入载荷矩阵和包长序列各走一条分支载荷过CNN包长序列过LSTM最后拼接分类。这也是这个标题对应的最完整方案。3.4 训练验证集划分按流分组不按包随机切预处理完成后先别急着训练。把样本按照会话ID分组再基于分组做训练验证切分。推荐用GroupKFold代码量很小from sklearn.model_selection import GroupKFold # samples: 特征列表, session_ids: 每个样本对应的会话ID gkf GroupKFold(n_splits5) for train_idx, val_idx in gkf.split(samples, y, groupssession_ids): # 训练集用train_idx验证集用val_idx pass这么做的原因是同一条流里的相邻包高度相关如果按包随机切分训练集和验证集里会出现同一个会话的“兄弟样本”模型等于偷看了验证集答案。这个问题在流量识别里极其隐蔽后面第5章会专门讲它的翻车现场。4. 搭建CNNLSTM流量识别模型核心代码与训练参数调整4.1 一个能直接改的PyTorch模型包级卷积池化再接LSTM模型设计遵循一个原则每个包先独立过CNN再让LSTM看包的序列。PyTorch实现如下。import torch import torch.nn as nn class FlowCNNLSTM(nn.Module): def __init__(self, n_bytes64, hidden128, num_classes10, num_layers1): super().__init__() self.hidden hidden self.cnn nn.Sequential( nn.Conv1d(1, 64, kernel_size7, padding3), nn.ReLU(inplaceTrue), nn.MaxPool1d(2), nn.Conv1d(64, 128, kernel_size5, padding2), nn.ReLU(inplaceTrue), nn.AdaptiveAvgPool1d(1) ) self.lstm nn.LSTM(128, hidden, num_layersnum_layers, batch_firstTrue, bidirectionalFalse) self.dropout nn.Dropout(0.3) self.fc nn.Linear(hidden, num_classes) def forward(self, x, maskNone): # x: (batch, packet_count, n_bytes) b, t, l x.shape x x.reshape(b * t, 1, l) # 每个包独立过CNN feat self.cnn(x).squeeze(-1) # (b*t, 128) feat feat.view(b, t, -1) # (batch, packet_count, 128) if mask is not None: lengths mask.sum(dim1).long().clamp(min1) packed nn.utils.rnn.pack_padded_sequence( feat, lengths, batch_firstTrue, enforce_sortedFalse) out, _ self.lstm(packed) out, _ nn.utils.rnn.pad_packed_sequence(out, batch_firstTrue) idx (lengths - 1).unsqueeze(1).expand(-1, self.hidden).unsqueeze(1) last out.gather(1, idx).squeeze(1) else: out, _ self.lstm(feat) last out[:, -1, :] return self.fc(self.dropout(last))forward前半段把batch_size和packet_count合并成一个大batch让一维卷积直接作用在每个包的字节序列上。AdaptiveAvgPool1d把任意长度的卷积输出压成128维这样每个包最终得到一个固定长度的特征向量。reshape回(batch, packet_count, 128)之后LSTM看到的是一条包的序列。mask不为空时用pack_padded_sequence告诉LSTM每个序列的真实长度避免尾部的全零补丁参与隐状态更新。这是本模型比较关键的一步也是后面第5章那条“全零补丁高置信度”问题的正解。推理时如果不想处理mask可以直接传mask为None取最后一个位置的隐状态即可。4.2 训练循环与超参调整AdamW、余弦退火和Macro-F1早停训练脚本我通常写成一个函数每轮返回Macro-F1而不是准确率因为流量数据集几乎都是不平衡的。import numpy as np from sklearn.metrics import f1_score def train_epoch(model, loader, optimizer, criterion, device): model.train() preds, labels, total_loss [], [], 0.0 for x, y, mask in loader: x, y, mask x.to(device), y.to(device), mask.to(device) optimizer.zero_grad() logits model(x, mask) loss criterion(logits, y) loss.backward() optimizer.step() total_loss loss.item() * y.size(0) preds.extend(logits.argmax(dim1).cpu().tolist()) labels.extend(y.cpu().tolist()) macro_f1 f1_score(labels, preds, averagemacro, zero_division0) return total_loss / len(labels), macro_f1配合AdamW时学习率从1e-3开始使用CosineAnnealingLR让学习率在训练轮数内平滑下降。类别权重按照1 / 类别样本数计算并做归一化避免少数类的权重过大导致训练震荡。早停则看验证集Macro-F1连续8轮没有创新高就回滚到最佳轮次的参数。别等最后一步再保存训练过程中每轮评估完就把最优权重写进best_flownet.pth。这就是后悔药——哪怕后面训练彻底跑飞手里始终有最优版本。4.3 训练好的模型如何保存state_dict与预处理参数分开存训练完成后保存模型不只是写一个文件而是写两个。模型权重存成一个文件预处理参数和标签映射存成json。python train.py --data ./cache --epochs 60 --batch-size 128 --lr 1e-3脚本运行结束后目录里应该出现这三个文件best_flownet.pth存state_dictpreprocess.json存n_packets、n_bytes、标签列表、是否归一化载荷predict.py是独立推理脚本。之所以不直接torch.save(model)是因为整个模型对象包含网络定义和类路径换环境后经常加载失败。只存权重的兼容性要好得多加载时先实例化模型再load state_dict即可。从这套项目结构看压缩包里“训练好的模型”只是结果能不能复现关键在于preprocess.json里的参数是否和训练时完全一致。我收到过不少下载来的工程模型跑出来的概率全是一个值排查到最后都是n_bytes不匹配模型期望64字节推理脚本给了256字节。推理脚本还有几个容易踩的暗坑加载后要调用model.eval()否则BatchNorm和Dropout还在训练模式推理过程要包在torch.no_grad()里否则会构建计算图显存和耗时都翻倍。这两步是基础中的基础但翻车率常年不低。5. 五条最有价值的踩坑记录流量识别模型的常见问题与排查5.1 准确率95%但小类别全漏检类别不平衡开出了假分数现象训练日志里准确率一路涨到95%看着很漂亮。打开混淆矩阵才发现占样本量90%的Web流量几乎全对剩下的攻击类流量大部分被判断成Web流量Macro-F1只有0.4。原因交叉熵在多类别不平衡时倾向把样本全判到多数类准确率依然很高但这恰好是安全类选题最忌讳的结果——漏报一个攻击样本的代价远高于误报一个正常样本。解决评价指标从accuracy换成macro-F1损失函数用加权交叉熵或focal loss。加权交叉熵改一行就行class_weights 1.0 / torch.bincount(torch.tensor(train_labels)).float() class_weights class_weights / class_weights.sum() criterion nn.CrossEntropyLoss(weightclass_weights.to(device))注意权重归一化是必要的否则样本极少的类别权重会冲到几十倍训练损失剧烈抖动反而学不进去。加了权重后验证时继续用macro-F1看整体表现。5.2 验证集分数比训练集还高同一个会话被切进了两个集合现象验证集Macro-F1比训练集还高两个点这基本不是模型强而是数据出了问题。打印几条训练集和验证集的样本发现很多样本的源IP、目的IP完全相同只是取的时间窗不同。原因做数据划分时按“包样本”随机切分同一个TCP会话的前半段进了训练集后半段进了验证集。包之间的相关性太强验证集会呈现出“半开卷考试”的效果。解决回到第3章的GroupKFold做法按会话ID分组划分。如果数据已经是矩阵形式就在生成特征时把session_id也存下来划分时用GroupShuffleSplit或GroupKFold保证同一个会话的全部包只出现在一个集合里。5.3 损失下降很好看推理却慢到没法用现象训练时一切正常到部署阶段发现识别一条流要0.8秒完全顶不住实时场景。排查后发现90%的时间不是花在模型上而是花在预处理上。原因推理脚本每次获取一条原始pcap之后现场调用scapy逐包解析再切流、截断、补齐。scapy本身对每层协议都要做一遍完整解码开销很大更别说有些配套代码还在循环里反复打开文件。解决把预处理从推理链路中拆出去。线上抓包落盘后由独立进程离线处理将结果缓存成npy推理脚本只负责读取numpy数组并调用模型。python preprocess.py --pcap_dir ./live_pcaps --out ./cache python predict.py --cache ./cache --model best_flownet.pth --batch 64这样每条流的预处理和推理都走批处理单条延迟能从秒级降到毫秒级。5.4 换到真实环境准确率骤降分布偏移不是玄学现象公开数据集上Macro-F1有0.90拿到客户内网流量上一测只有0.72。一开始怀疑是代码环境问题检查后确认模型加载一致、特征一致但效果就是回不来。原因流量识别在真实环境遇到的是明显的分布偏移。公开数据集的服务器指纹、端口分布、协议使用比例和真实环境差别很大更隐蔽的是标签口径不同比如某些数据集把P2P下载全标成“恶意”真实环境里同一类应用则被标成“未知”。解决不要把公开集训练的模型直接上线。最有效的办法是拿真实环境的一小批标注样本做增量微调学习率降到1e-4只调最后两层和LSTM层。同时检查特征里是否硬编码了IP、端口这类环境相关字段有的话先去掉否则换网络就失效。5.5 全零补丁教会了模型输出高置信度现象测试时输入一条实际只有5个包的会话剩余27个位置全是补零模型竟然打出0.99置信度分类是某个明确的类别。把真实包删掉只留补零模型输出依然可复现。原因训练阶段统一截断和补零全零区域在所有样本里完全相同模型很容易学到“尾部存在大片全零”和“某个类别”之间的伪相关性。尤其当某类样本普遍较短时模型甚至不需要看真实载荷只看零的数量就能猜对这个捷径在测试时同样生效。解决训练时对每个序列做随机截断让真实包的比例在样本间差异更大模型前向计算时对真实长度低于阈值比如10%的窗口长度的样本直接拒绝预测返回低置信度。LSTM部分则坚持使用pack_padded_sequence让补零完全不参与隐状态更新。这比单纯在输入端加噪更治本。if mask is not None and mask.sum() mask.numel() * 0.1: return torch.full((b, num_classes), 0.05, devicex.device)6. 进阶用滑窗序列把离线模型改成实时流量识别前面所有流程默认基于“一条完整的流”做分类落地到网关或旁路监控时流还没结束就得给结论于是需要改成滑窗式推理。维护一个固定长度的包序列窗口窗口长度与训练时的n_packets一致。每来一个新包把它的编码结果追加到窗口尾部如果窗口超出长度就从头部弹出最早的包。窗口内的数据直接送进模型进行预测。这就是很多时间序列预测项目的标准做法代码量不大但能彻底改变系统的使用方式。import numpy as np import torch WINDOW_SIZE 32 HISTORY_SIZE 5 # 最近5个窗口的概率做平均 def rolling_predict(new_feature, window, history, model, device): window.append(new_feature) if len(window) WINDOW_SIZE: window.pop(0) x torch.tensor(np.stack(window), dtypetorch.float32).unsqueeze(0).to(device) with torch.no_grad(): logits model(x) proba torch.softmax(logits, dim-1).cpu().numpy().squeeze(0) history.append(proba) if len(history) HISTORY_SIZE: history.pop(0) avg_proba np.mean(history, axis0) return avg_proba.argmax(), avg_proba.max()这里对连续多个窗口的概率取平均是降低标签来回跳的常用手段等于给模型输出做了一次平滑滤波。窗口滑动时只有新到的包才走编码和推理窗口内其他包的结果可以复用这也是滑窗方案比每次整段重新计算高效的原因。验证改造是否正确我的习惯是把一份抓包文件按时间回放离线阶段对每个完整会话算一次结果滑窗模式对每个到达事件算一次结果最后按时间对齐比较两者的一致率。如果滑窗在会话早期和晚期输出差异过大优先检查窗口编码和训练预处理是否完全一致尤其是包方向要不要单独编码、载荷要不要归一化这些细节差一个字符线上效果就差一截。我最后保留的一个习惯是每次上线前都会把wireshark抓包和模型输出的时间戳对齐做一遍人工抽查亲眼看到一条长流从“未判定”到“判定”的过程。这类系统出问题从来不是模型结构差而是数据口径和推理边界没对齐。希望这篇笔记能帮你在复现时少走几段弯路。本文还有配套的精品资源点击获取