简介面向区块链安全与智能合约方向的毕业设计场景这份资源提供了一套基于深度学习的漏洞检测完整方案。项目采用长短时记忆网络LSTM与双向长短时记忆网络BLSTM作为主干并引入注意力机制聚焦漏洞关键特征可对Solidity合约代码进行自动预测适合承担相关课题或课程设计的高校学生、开发者参考。压缩包共1428个文件包含1388个sol合约样本、11个Python脚本及日志、图表、配置文件等整体大小仅3MB便于本地部署与快速运行。随附源码与部署教程可让用户直接启动系统界面简洁美观、操作易上手帮助理清数据预处理、特征提取、模型训练与漏洞预测的完整链路。当前已有133人学习浏览该资源整套资料在智能合约安全分析与深度学习序列建模方面具有较高实践价值目录结构清晰适合作为毕业设计或课程设计的起点也便于后续在此基础上进行功能扩展与改进。1. 为什么把合约漏洞检测改写成「序列分类任务」别一上来就盯着合约源码逐行读很多漏洞并不会在单行代码里露出破绽。真正上线跑的是编译后的字节码反汇编出的每一条操作码就像指令流漏洞的特征藏在 CALL、SSTORE、BALANCE 这些关键操作码的组合顺序里。比如一个典型的重入漏洞往往表现为“余额更新操作码出现在外部调用之后”这种上下文关系用人眼找容易漏用序列模型反而能稳定总结出来。这篇笔记讲的正是毕业设计里最常见的一条闭环基于深度学习的智能合约漏洞检测把操作码序列当文本输入用 LSTM、BLSTM、BLSTM注意力机制三种模型做分类数据准备、训练、本地部署一条线走完。适合正在找题目、希望方案能快速出效果的同学也适合已经有一个粗糙数据集、想换成序列模型做对比的人。2. 操作码序列数据集的构建从源码到可训练的样本这个方向能不能跑通七成取决于数据怎么造。模型吃的是序列原文是合约的二进制字节码这两者之间还隔着一道反汇编的工序。先把这一步走稳后面的模型才不会被垃圾输入带偏。2.1 从编译产物到操作码序列反汇编与词表映射我的习惯是先积累一批合约源码用编译器统一版本编译拿到字节码之后反汇编成操作码序列。为什么不用源码直接喂模型因为源码经过编译器优化最终执行语义都在字节码里而且不同合约的源码风格差异很大直接建模源码等于让模型先学会“读不同人的代码风格”对一个小数据集来说太奢侈。# 反汇编把十六进制字节码拆成一条条可读的操作码 from pyevmasm import disassemble_all BIN_CODE 60806040526004361061004b576000357f... # 编译器输出的合约字节码 ops [str(op) for op in disassemble_all(BIN_CODE)] print(ops[:8]) # 输出类似 [PUSH1, 0x80, PUSH1, 0x40, MSTORE, CALLVALUE, DUP1, ISZERO]这段代码只做一件事把十六进制字节码变成人类能读的操作码助记符。disassemble_all返回的是指令对象str()转出来的文本正好可以作为序列元素。注意我没有保留操作数比如PUSH1 0x80只取PUSH1这个助记符原因在后面词表小节里说明。下一步是把操作码文本映射成数字 ID。常见做法是维护一张固定词表0留给 padding1留给未知操作码。这样训练和推理时用的是同一套映射不会出现训练集有一套词表、现场部署又另起一套的尴尬。# 固定词表示意0 是 padding1 是 UNK其余按 EVM 操作码规范排 OPCODE2ID { PAD: 0, UNK: 1, STOP: 2, ADD: 3, MSTORE: 4, SSTORE: 5, CALL: 6, BALANCE: 7, JUMPI: 8, # ... 按需要覆盖完整操作码集合 } def ops_to_ids(op_list): return [OPCODE2ID.get(op, OPCODE2ID[UNK]) for op in op_list]这里把词表固定下来很重要。很多初学者喜欢从训练集里动态统计操作码再建词表结果遇到新的编译器版本、新的合约写法操作码对不上号就崩了。固定词表配合UNK兜底虽然单条未知指令丢失了信息但模型至少不会因为输入维度不一致直接报错。为什么不把PUSH的不同长度当成不同词PUSH1、PUSH2在语义上确实不同但也有不少方案只保留PUSH助记符。我自己的经验是PUSH 后面的立即数承载了跳转目标、地址等重要信息但直接把操作数当词表成员会让词表膨胀一堆无意义的数字所以更稳妥的做法是把PUSH1、PUSH2这种带长度的助记符保留下来丢弃后面的十六进制数值。这样词表仍然可控信息也损失得最少。2.2 滑窗切分与定长填充窗口长度怎么选一整份合约反汇编出来可能有几百上千条操作码不能整条喂给模型。常见做法是滑窗切分每个窗口就是一个训练样本。窗口长度太短漏洞上下文被截断太长训练时间和显存翻倍。我用下来的经验是128 是一个性价比比较高的起点50 到 200 之间都可以试但不要一上来就追 256。import torch from torch.utils.data import Dataset SEQ_LEN 128 def seq_to_ids(op_list, seq_lenSEQ_LEN): ids ops_to_ids(op_list) if len(ids) seq_len: return ids[:seq_len] return ids [OPCODE2ID[PAD]] * (seq_len - len(ids)) class OpcodeDataset(Dataset): def __init__(self, samples, labels, seq_lenSEQ_LEN): self.samples [seq_to_ids(seq, seq_len) for seq in samples] self.labels labels def __len__(self): return len(self.samples) def __getitem__(self, idx): x self.samples[idx] y self.labels[idx] return torch.tensor(x, dtypetorch.long), torch.tensor(y, dtypetorch.float)这个数据集的逻辑很简单切窗、截断、补齐最后返回(token_ids, labels)。__getitem__里把标签转成float是为了后面用BCEWithLogitsLoss多标签场景下标签就是 0/1 向量不需要转成 long。滑窗的步长也是一个能影响结果的小参数。步长等于窗口长度时样本之间没有重叠数据量最小步长短一些会有重叠相当于做了数据增强。我一般用步长等于窗口长度的一半128 的窗口配 64 的步长。再小就很不好看了相邻样本几乎一样训练集和测试集的真实差异会变小评估结果虚高。2.3 多标签标注与按合约划分防同源数据泄漏一个合约片段可能同时存在多个漏洞比如重入之外还带未受保护的selfdestruct。标注就不能只给一个整数类别而应该给一个与漏洞类型数量等长的 0/1 向量。7 种常见漏洞就是 7 维向量某一位是 1 表示这个窗口命中了对应类别。这种多标签设定下损失函数必须用BCEWithLogitsLoss不能随手拿CrossEntropyLoss顶上去否则模型只会学到“每个样本恰好一个类”的强假设。数据划分上有一条血泪经验先按合约划分再切窗顺序不能反。如果先切窗再把所有窗口打乱划分训练集和测试集同一个合约的相邻窗口会同时出现在两边。这相当于把题目和答案一起放进考场序列模型对这种“邻居片段”的识别能力又特别强结果就是验证集 F1 高得离谱到真实场景一测就崩。from sklearn.model_selection import train_test_split contracts list(contract_data.keys()) train_cids, test_cids train_test_split(contracts, test_size0.2, random_state42) train_samples [] train_labels [] test_samples [] test_labels [] for cid in train_cids: for seq, label in contract_data[cid][windows]: train_samples.append(seq) train_labels.append(label) for cid in test_cids: for seq, label in contract_data[cid][windows]: test_samples.append(seq) test_labels.append(label)这段代码的关键点在于先按合约 ID 拆再取窗口。这样测试集里出现的任何窗口片段都不会在训练集里有一个“几乎一模一样的邻居”。尤其要注意的是窗口重叠生成的样本也属于同一个合约必须在拆分之前做好分组。3. 三种序列模型的结构拆解LSTM、BLSTM、BLSTM注意力机制代码层面三个模型不是三个互不相干的文件而是同一个骨架上的三种配置。我更喜欢把它们做成可以切换的实现这样训练脚本只需要传一个名字就能跑出三组结果。本章先把三种结构的差异讲清楚下一章直接落到可复现的训练命令。3.1 LSTM 基线门控单元怎么读操作码上下文LSTM 的核心单元有三道门遗忘门决定丢掉多少旧信息输入门决定写入多少新信息输出门决定放出多少信息到下一步。在操作码序列里一个CALL出现之后是否跟着SSTORE以及中间隔了多少步正是这种门控设计擅长处理的上下文关系。LSTM 不需要像 N-gram 那样硬扫固定窗口它能把更早之前的位置信息保留在隐状态里所以对“前面埋了个可疑操作几步之后才暴露”的模式更友好。import torch.nn as nn class LSTMDetector(nn.Module): def __init__(self, vocab_size, embed_dim128, hidden_dim128, num_layers2, num_labels7): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM( input_sizeembed_dim, hidden_sizehidden_dim, num_layersnum_layers, batch_firstTrue, dropout0.3 if num_layers 1 else 0, ) self.classifier nn.Sequential( nn.Dropout(0.5), nn.Linear(hidden_dim, num_labels), ) def forward(self, x): emb self.embedding(x) # (batch, seq_len, embed_dim) out, (h_n, c_n) self.lstm(emb) last_h h_n[-1] # 取最后一层的最后一个时间步 return self.classifier(last_h)h_n[-1]取的是 LSTM 最后一层最后的隐状态。有一个容易被忽视的细节h_n的形状是(num_layers, batch, hidden_dim)如果num_layers2h_n[0]是第一层的信息h_n[1]才是第二层的最终输出。直接取h_n[-1]是稳妥的。dropout只在层数大于 1 时生效这是 PyTorch 的规则单层 LSTM 传dropout会被静默忽略。多标签分类的最后一层不用加 sigmoid因为训练时用BCEWithLogitsLoss它内部已经包含 sigmoid 和数值稳定处理推理时再单独sigmoid。3.2 BLSTM双向上下文为什么更适合漏洞定位LSTM 只能看到过去的信息但对操作码序列来说一个位置是否危险往往右文比左文更关键。比如SSTORE之前的检查条件写在更早的位置而“这个 CALL 是不是外部合约调用”要从后面的指令行为推断。BLSTM 的常见做法是正序和反序各跑一个 LSTM输出在特征维度上拼接让每个时间步同时拥有前后两个方向的上下文。class BLSTMDetector(nn.Module): def __init__(self, vocab_size, embed_dim128, hidden_dim128, num_layers2, num_labels7): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM( input_sizeembed_dim, hidden_sizehidden_dim, num_layersnum_layers, batch_firstTrue, bidirectionalTrue, dropout0.3 if num_layers 1 else 0, ) self.classifier nn.Sequential( nn.Dropout(0.5), nn.Linear(hidden_dim * 2, num_labels), ) def forward(self, x): emb self.embedding(x) out, _ self.lstm(emb) # (batch, seq_len, hidden_dim * 2) batch_size, seq_len, hidden_x2 out.shape hidden_dim hidden_x2 // 2 # 正序取序列最后一步反序取序列第一步拼出一个整体 last_forward out[:, -1, :hidden_dim] last_backward out[:, 0, hidden_dim:] feat torch.cat([last_forward, last_backward], dim-1) return self.classifier(feat)这里没有直接取h_n[-1]而是把输出张量按位置切开拼。原因在于双向模型里正向和反向的“最后一步”不是同一个时间点正向的最后一步是序列末尾反向的最后一步其实是序列开头。如果直接拿h_n[-1]乍一看维度是对的但内部等于是把两个方向各自最后一个时间点的信息拼起来语义上容易混淆。out[:, -1, :hidden_dim]取正序在序列末尾的隐状态out[:, 0, hidden_dim:]取反序在序列开头的隐状态这样拼出来的特征才各自覆盖了两个方向的终点信息。双向带来的代价是显存几乎翻倍、训练时间接近两倍。如果你的实验 GPU 只有 4G 显存建议先跑 LSTM 基线把流程打通再切 BLSTM 调参。3.3 注意力机制让模型自己指出关键操作码BLSTM 最后只用两个隐状态代表整条序列信息量还是太“挤”。注意力机制可以看作是对全部时间步做加权求和权重由模型自己学哪几个操作码对判定漏洞贡献大权重就高。这一步不只提升 F1更大的价值是可解释性——拿到注意力权重你能告诉别人模型是因为哪条CALL或SSTORE判了重入漏洞。class AttentionPooling(nn.Module): def __init__(self, hidden_dim): super().__init__() self.W nn.Linear(hidden_dim, hidden_dim, biasFalse) self.v nn.Linear(hidden_dim, 1, biasFalse) def forward(self, lstm_out, maskNone): # lstm_out: (batch, seq_len, hidden_dim) energy torch.tanh(self.W(lstm_out)) scores self.v(energy).squeeze(-1) # (batch, seq_len) if mask is not None: scores scores.masked_fill(mask 0, float(-inf)) attn_weights torch.softmax(scores, dim-1) # (batch, seq_len) context (attn_weights.unsqueeze(-1) * lstm_out).sum(dim1) return context, attn_weights注意masked_fill(mask 0, float(-inf))这一行。padding 位置的注意力分数不能参与 softmax否则模型会把“什么都没有”的位置当成重要特征。这个 mask 可以直接从输入 token 里生成mask (x ! PAD_IDX)。有了AttentionPoolingBLSTM注意力模型的结构就很清晰了class BLSTMAttnDetector(nn.Module): def __init__(self, vocab_size, embed_dim128, hidden_dim128, num_layers2, num_labels7): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM(...) # 配置与 BLSTMDetector 相同bidirectionalTrue self.attn AttentionPooling(hidden_dim * 2) self.classifier nn.Sequential( nn.Dropout(0.5), nn.Linear(hidden_dim * 2, num_labels), ) def forward(self, x): mask (x ! PAD_IDX).unsqueeze(-1) emb self.embedding(x) out, _ self.lstm(emb) # (batch, seq_len, hidden_dim * 2) context, attn_weights self.attn(out, mask.squeeze(-1)) return self.classifier(context), attn_weightsAttentionPooling的hidden_dim传入的是hidden_dim * 2因为 BLSTM 的输出特征翻倍了。推理时除了 logits 还会拿到attn_weights这个输出保留下来后面做漏洞定位解释时有用。4. 本地训练与结果对比稳定复现的一套参数模型结构定下来之后训练环节要解决的是“怎么省事地跑出三组结果”。我通常的做法是写一个带模型名字参数的单文件训练脚本三组实验的随机种子、数据加载、优化器设置完全一致然后只改模型名。4.1 数据加载器与训练循环的最小骨架三个模型只有一个构造函数的区别可以用一个工厂函数统一创建避免在三份脚本里复制粘贴模型参数然后改漏一处。def build_model(model_name, vocab_size, num_labels): if model_name lstm: return LSTMDetector(vocab_size, 128, 128, 2, num_labels) elif model_name blstm: return BLSTMDetector(vocab_size, 128, 128, 2, num_labels) elif model_name blstm_attn: return BLSTMAttnDetector(vocab_size, 128, 128, 2, num_labels) raise ValueError(funknown model: {model_name})训练循环里的默认参数我建议统一为batch_size64、lr1e-3、epochs30、weight_decay1e-4。不是最优但足够稳先跑通再调。from torch.utils.data import DataLoader from torch.optim import AdamW import torch.nn as nn model build_model(blstm_attn, len(OPCODE2ID), 7) optimizer AdamW(model.parameters(), lr1e-3, weight_decay1e-4) criterion nn.BCEWithLogitsLoss() loader DataLoader(train_ds, batch_size64, shuffleTrue) for epoch in range(30): model.train() total_loss 0.0 for x, y in loader: optimizer.zero_grad() logits, _ model(x) loss criterion(logits, y.float()) loss.backward() # 梯度裁剪双向模型很容易在训练早期梯度爆炸 nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() print(fepoch {epoch:02d} loss {total_loss / len(loader):.4f})这里有个细节BLSTM注意力模型返回的是(logits, attn_weights)所以训练时要用logits, _ model(x)解包。如果只写logits model(x)而不解包PyTorch 在反向传播时会把attn_weights也当作输出张量虽然不一定直接报错但损失计算和梯度传播的边界会变得很混乱。梯度裁剪clip_grad_norm_是双向模型里很实用的技巧。BLSTM 反向传播路径变长早期梯度容易过大导致 loss 直接变成nan。设置max_norm5.0能避免大部分翻车但不建议设太小太小会让模型收敛变慢。4.2 三个必调参数与经验范围参数参考值我的判断seq_len128从 64 加到 128 效果提升明显从 128 加到 256 收益递减训练时间接近翻倍hidden_dim128数据集在几千个样本这个规模时256 的 hidden_dim 容易过拟合提升有限lr1e-33e-4 到 2e-3 都是可以接受的范围建议先用 1e-3 跑 10 个 epoch 看 loss 是否持续下降batch_size不算最敏感的参数但要注意它和显存的关系。BLSTM 在 128 序列长度下batch_size64大概需要 6G 左右的显存如果把序列长度调到 256batch_size不减半的话很容易 OOM。显存不足时优先降batch_size不要急着降序列长度因为序列长度对效果的贡献通常更大。4.3 评估指标与三组实验怎么对比多标签场景里准确率基本没有参考价值原因是类别不平衡很常见模型只需要把多数类全预测为 0准确率就能到九成以上。我更推荐看 macro-F1它给每个类别相同的权重。precision 和 recall 也要一起看这两个指标一个偏高一个偏低时说明模型在“宁可错杀”和“尽量不漏”之间需要调阈值。from sklearn.metrics import f1_score, precision_score, recall_score model.eval() all_preds, all_labels [], [] with torch.no_grad(): for x, y in loader_test: logits, _ model(x) preds (torch.sigmoid(logits) 0.5).int() all_preds.append(preds.cpu()) all_labels.append(y.cpu()) all_preds torch.cat(all_preds).numpy() all_labels torch.cat(all_labels).numpy() print(macro-F1, f1_score(all_labels, all_preds, averagemacro)) print(precision, precision_score(all_labels, all_preds, averagemacro)) print(recall, recall_score(all_labels, all_preds, averagemacro))三组实验跑出来的趋势通常是LSTM 作为基线能到七八成的 macro-F1BLSTM 在 LSTM 基础上有几个点的提升主要提升在那些依赖后续上下文的漏洞类别上BLSTM注意力机制再往上走一点并且给出可解释的注意力分数。如果 BLSTM 没有明显提升 LSTM先检查数据划分和窗口长度不用急着怀疑模型结构。4.4 保存最优模型模型参数和元信息一起存训练结束后要保存的不只是模型权重还有词表、标签名、模型名。单条推理时这三样缺一个都会卡壳。我是把一个 dict 整体存到torch.save里。torch.save({ model_name: blstm_attn, model_state_dict: model.state_dict(), opcode2id: OPCODE2ID, label_names: label_names, }, best_model.pt)model.state_dict()只存参数不存结构所以必须带上model_name加载时才能重建出相同的模型结构。opcode2id和label_names这两样经常有人漏导致部署阶段写了一大堆临时兼容代码。加载时代码是反过来的checkpoint torch.load(best_model.pt, map_locationcpu) model build_model(checkpoint[model_name], len(checkpoint[opcode2id]), len(checkpoint[label_names])) model.load_state_dict(checkpoint[model_state_dict]) model.eval()为什么要加map_locationcpu如果 checkpoint 是在 GPU 上存的直接torch.load到无 GPU 的机器会报错或者强行占用显存。加了map_location之后同一份训练产物既能在带显卡的机器上继续训练也能在验证机上做纯推理。5. 避坑与排查五个让结果翻车的真实问题这个方向看起来流程短实际上每个环节都有暗坑。下面的五条是我自己跑这个方案时踩过的按影响程度排序每一条我都给出“现象、原因、解决”三段式记录方便你对照自己的情况排查。5.1 数据泄漏窗口切完才按窗口分割现象验证集 macro-F1 高到 0.9 以上自己都觉得离谱换一批新合约一测直接跌回 0.6。原因我最早是先把所有合约的窗口全部切出来打乱之后再划分训练集和测试集。由于滑窗有重叠同一个合约的相邻窗口内容高度相似模型等于在训练时见过测试样本的“邻居片段”自然学到的不是泛化特征而是对特定字节码片段的记忆。解决改成先按合约 ID 划分再切窗。这一步改动之后验证集数字立刻变得“难看”许多但才是真实水平。5.2 词表前后不一致新测试样本直接 KeyError现象训练一切正常换一批测试合约后推理代码在OPCODE2ID.get(op)处崩溃报出某个 PyTorch 不认识的字符串。原因我最初是直接对训练集做set()构建词表训练集里见过的操作码有限测试集里出现了一个从未见过的操作码词表查不到就炸了。更隐蔽的是同一个操作码在不同 solc 版本里的助记符可能略有差异。解决固定一张按操作码规范生成的完整词表并给所有映射不到的词一个UNK兜底。不要信任动态词表所有代码里唯一允许出现dict.get默认值的地方就在这里。5.3 类别不平衡准确率虚高关键漏洞几乎全漏现象总准确率显示 0.9但打开混淆矩阵发现某一个冷门漏洞类别的 recall 是 0模型把所有样本都预测成了“无漏洞”。原因数据集中无漏洞样本或常见漏洞占了压倒比例模型学会无脑预测多数类就能拿到高准确率。交叉熵或普通 BCE 对少数类没有额外关注梯度完全被多数类主导。解决换成 macro-F1 做评估指标同时在损失函数里给少数类更高的权重。PyTorch 的BCEWithLogitsLoss有pos_weight参数可以直接按类别频率反比设置。# 假设 class_freq 是各漏洞类别在训练集里的占比列表 pos_weight torch.tensor([1.0 / f for f in class_freq]) criterion nn.BCEWithLogitsLoss(pos_weightpos_weight)pos_weight的取值逻辑很简单某个类别出现得越少权重越大。比如一个类别只占 5%那它的权重就是 20。这会逼迫模型在梯度更新时把注意力分给少数类。但注意不要把这个值设得过大否则模型会走向另一个极端把大量正常合约误报成漏洞precision 反而崩掉。5.4 序列长度和批量大小互相打架显存 OOM 频繁现象把seq_len从 128 调到 256 之后原来稳定的batch_size64在训练中途报 CUDA out of memory。原因双向 LSTM 需要保存前向后向两条传播路径的中间结果序列长度翻倍显存占用不止翻倍注意力机制还要保存所有时间步的打分矩阵显存压力进一步增大。解决先降batch_size到 32 或 16确认能跑通再观察显存利用率。如果仍需 256 的序列长度可以先用 128 调好其他参数最后再拉长序列做终版实验避免每次调参都在等 OOM 后重启。5.5 固定随机种子后结果仍然不稳定现象设置了random.seed、np.random.seed连续两次跑同一组实验loss 曲线不完全一致。原因PyTorch 的 GPU 算子本身有非确定性DataLoader 的 shuffle 也会引入随机性只固定 Python 和 NumPy 的种子覆盖不到这些环节。解决使用一个完整的种子设置函数并把 DataLoader 的随机源也固定下来。import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False如果你的模型对数字极其敏感还要把 DataLoader 的generator一起固定。torch.Generator()传入一个固定种子的示例g torch.Generator() g.manual_seed(42) loader DataLoader(train_ds, batch_size64, shuffleTrue, generatorg)注意一点cudnn.deterministic True会牺牲一部分运行速度只建议在复现最终结果时开启日常调参可以关掉让训练跑得更快。6. 把模型部署成本地推理从 checkpoint 到单条字节码预测训练只是这个方向的一半另一半是把模型真正用起来。一个模型如果不部署永远只是实验报告里的数字。本章给出一个能在本地直接运行的推理链路从加载模型到输出漏洞概率再到解释推理依据。6.1 用 checkpoint 恢复模型并做单条预测推理脚本的模型构建必须和训练时完全一致。我已经把opcode2id、label_names保存在 checkpoint 里加载时直接取用不写任何硬编码的维度数字。checkpoint torch.load(best_model.pt, map_locationcpu) model build_model( checkpoint[model_name], len(checkpoint[opcode2id]), len(checkpoint[label_names]), ) model.load_state_dict(checkpoint[model_state_dict]) model.eval() def predict_bin(bin_code, seq_len128, threshold0.5): ops [str(op) for op in disassemble_all(bin_code)] ids [OPCODE2ID.get(op, OPCODE2ID[UNK]) for op in ops] ids ids[:seq_len] [OPCODE2ID[PAD]] * max(0, seq_len - len(ids)) x torch.tensor([ids], dtypetorch.long) with torch.no_grad(): logits, attn_weights model(x) probs torch.sigmoid(logits).numpy()[0] result {} for i, label in enumerate(checkpoint[label_names]): result[label] float(probs[i]) return result, attn_weights单条推理的关键是全程不计算梯度用torch.no_grad()包住前向传播显存占用会小一个量级。推理速度一般在毫秒到十几毫秒取决于序列长度和模型维度本地部署完全够用。6.2 阈值不必固定 0.5注意力权重是解释入口多标签输出经过 sigmoid 之后每个类别的概率是一个 0 到 1 之间的数字。默认取 0.5 作为判定阈值但冷门类别可能因为样本少而整体概率偏低此时可以考虑把阈值降到 0.35 或 0.4 来提升召回反过来如果误报太多就调高阈值。这个阈值应该放在测试集上试几组值选 macro-F1 最高的那个而不是拍脑袋定。注意力权重是这个模型比普通 BLSTM 多出来的红利。推理时我已经把attn_weights带出来了可以找到权重最大的那几条操作码告诉使用者模型依据什么做的判断。top_idx attn_weights[0].argmax().item() print(f模型最关注的位置: seq {top_idx}, opcode {ops[top_idx] if top_idx len(ops) else PAD})如果模型在CALL附近给了一个明显高于周围的权重那这个判断就有了可解释的依据。这一点在毕业设计答辩时非常加分也是我推荐做注意力模型而不是只做 LSTM 的最重要原因。6.3 这个方案值不值得投入以及往深走的三个方向作为毕业设计这个方向性价比很高数据构造方法清晰、三种模型对比有梯度、指标设计有讲究、部署落地不复杂足够撑起一个完整的技术报告。但也要诚实说清楚它的边界基于操作码序列的模型更像一个“模式识别器”能发现和训练集中已知漏洞相似的模式但它并不理解操作码背后的语义遇到全新类型的漏洞时它的召回能力有限。想把这个方案做得更扎实有三个方向值得投入第一个是把字节码层面的纯序列建模升级为融合语义特征的多输入模型比如把静态分析器输出的“调用图结构”和操作码序列并行输入让模型不只看到指令顺序还看到函数之间的调用关系。第二个是利用注意力权重做漏洞定位的可视化把模型认为危险的操作码高亮出来再结合人工审计确认这样模型的输出就不只是一堆概率而是可以直接进入审计工作流的提示。第三个是预训练方向用大量未标注的合约操作码序列先预训练一个序列编码器再在小规模标注数据上微调原理类似文本领域的做法。这条路能明显缓解标注样本不足的问题但训练成本和数据收集成本都比较高适合作为后续延伸研究。我自己把这个方案从数据处理到部署完整跑过一遍之后最大的体会是数据划分和词表一致性这两件事比调模型结构参数更影响最终结果。很多同学一上来就折腾模型层数结果数据泄漏导致指标虚高而不自知白白消耗了时间。走这个方向先把数据链路做干净模型部分反而是一个按部就班就能出结果的过程。希望这篇笔记能帮你少走几步弯路。本文还有配套的精品资源点击获取