ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从黑名单到实时识别:深度学习钓鱼检测系统全栈落地

从黑名单到实时识别:深度学习钓鱼检测系统全栈落地

简介:一套基于深度学习的在线钓鱼页面检测系统完整项目源码与交付文件,分为 Chrome 插件前端、Django RESTful 后端与 TensorFlow 预测模块。后端从 URL 与页面中提取连接数量、外链比例、域名注册时间等特征,送入模型预测后将拦截或放行结论返回前端,构成从数据采集、特征提取、模型推理到浏览器拦截的完整链路。资源共 187 个文件,压缩包约 42.55MB,主体为 25 个 py 源码、30 组 data/meta/index 模型分片、4 个 checkpoint,另含 phishProtect.crx 插件安装包、CSS/JS/HTML 前端资源、sqlite3 数据库与说明文档,目录按 BackEnd_django_restful_api 和 FrontEnd_browser_plug_in 清晰拆分,便于对照理解前端采集逻辑、后端特征工程与模型调用流程,整体呈现工程化交付特征,训练产物、后端服务与浏览器插件被明确分置,下载解压后可对系统结构进行整体浏览与二次开发。已有 283 人学习下载,适合安全方向学生、全栈开发者及希望把深度学习落地为浏览器安全插件的工程人员参考。

1. 深度学习做钓鱼检测:从「黑名单」到「看见恶意」

一个钓鱼页面从上线到被安全社区收录,平均存活时间只有几个小时。传统黑名单方案的响应窗口完全跟不上攻击者的换域名节奏:等名单更新推送到网关,那批恶意页面早关停了。这也是 webPish_detect 这类基于深度学习的钓鱼页面检测系统存在的根本原因——它不再等「有人举报」,而是拿到一个 URL 就能实时判断页面有没有恶意倾向。本文要拆的是这套系统的完整落地路径:数据怎么喂、模型怎么选、前后端架构怎么搭、上线后哪些坑一定会踩。适合手里有安全业务、想自建检测能力或正在调研检测方案的技术团队,读完可以直接对标自己的工程现状。

2. 钓鱼页面数据集与特征工程:模型吃什么、怎么喂

2.1 为什么规则引擎不够用,深度学习在吃哪几类特征

很多团队第一次做钓鱼检测,直觉是先上规则:查域名年龄、查 WHOIS、查关键词命中。这套方案在对抗批量注册的垃圾域名时确实有效,但面对「钓鱼者租用合法主机、放一个高仿登录页」的场景就会集体翻车。域名是合法的、SSL 证书是真的、页面内容只有一张图和两个输入框,任何统计规则都看不出毛病。只有让模型去看「页面长什么样、URL 字符序列和正常站点差多远」,才能抓到这类伪装。

深度学习在这里吃的特征和图像分类不太一样,它处理的是三类异构信号。第一类是 URL 本身的字符序列:短链接、带 @ 符号、连续数字、随机子域名,这些模式在字符级都能体现。第二类是 HTML 结构:钓鱼页面的 DOM 往往极简,几乎没有导航栏、没有页脚、没有外链,script 和 iframe 的分布和正常页面差异显著。第三类是视觉渲染结果:把页面截图后缩小成固定尺寸的图片,模型从中学习「这看起来像一个登录页」的视觉模式。

常见做法是让三个特征分别走各自的编码器,在特征层拼接后做分类。注意一个容易被忽略的细节:HTML 结构特征需要先做归一化,把动态生成的随机属性名去掉,否则模型学到的是「这个模板长什么样」而不是「钓鱼页面长什么样」。我一般会先做一轮 HTML 标签序列抽取,丢弃属性值,只保留标签名序列和若干关键属性的存在性标记。

2.2 数据集构建与样本均衡:先解决「背景噪声」问题

深度学习检测系统的效果上限不是由模型架构决定的,而是由数据集质量决定的。钓鱼检测的数据集构建比图像分类痛苦得多:正样本存活期短、分布变化快、负样本极度多样(正常互联网页面长什么样都有)。我先说正样本怎么拿:行业通行做法是从公开的恶意 URL 情报库拉取实时条目,拿到 URL 后立即抓取页面快照。这里的核心约束是「快」——情报库给出的钓鱼 URL 可能几小时后就失效了,所以抓取队列要短、并发要可控、失败后只重试一次。

负样本的收集比正样本更容易翻车。直接把「普通网站列表」当负样本用,模型会学到「这个站有备案号所以是安全的」这类伪规律。更可靠的做法是收集「看起来像目标行业但确实安全的页面」——银行、电商、社交平台的真实登录页和技术文档站。我的经验是负样本数量要压到正样本的 3 到 5 倍,太多会让模型偏向保守,把新页面一律判为安全。

样本均衡是另一个容易踩的坑。原始数据里正负样本比例可能到 1:50,直接训练会让模型把所有样本预测为负类,准确率还挺高,但完全没有检测能力。我一般这样解决:对正样本做逐条人工复核,去掉误报和已失效页面;对负样本按页面类型分层采样,保证每个大类有一定数量。最终数据集里正负比控制在 1:3 左右,训练时再用加权采样做二次均衡。这里贴一段特征提取代码,后面模型训练都依赖它。

import re import numpy as np from urllib.parse import urlparse MAX_URL_LEN = 128 CHAR_SET = "abcdefghijklmnopqrstuvwxyz0123456789.-_~:/?#[]@!$&'()*+,;=%" def url_to_tokens(url: str) -> np.ndarray: """把 URL 转成定长字符索引序列,供文本编码器使用""" url = url.lower().strip() # 只保留训练集见过的字符,其余归为 UNK seq = [CHAR_SET.find(ch) + 1 if ch in CHAR_SET else 0 for ch in url[:MAX_URL_LEN]] seq += [0] * (MAX_URL_LEN - len(seq)) return np.array(seq, dtype=np.int64) def html_to_tag_seq(html: str, max_tags: int = 256) -> np.ndarray: """抽取 HTML 标签序列,丢弃属性,归一化动态标签名""" tags = re.findall(r"<([a-zA-Z0-9]+)", html) # 动态生成的标签统一映射到 UNKNOWN_TAG,降低模板噪声 normalized = [] for t in tags: t = t.lower() if t in ("div", "span", "form", "input", "button", "iframe", "script", "img", "meta", "a", "body"): normalized.append(t) else: normalized.append("unknown") ids = [TAG_INDEX.get(t, len(TAG_INDEX)) for t in normalized[:max_tags]] ids += [0] * (max_tags - len(ids)) return np.array(ids, dtype=np.int64) def stat_features(url: str, html: str) -> np.ndarray: """计算基础统计特征:URL 长度、符号密度、输入框数量等""" parsed = urlparse(url) input_count = len(re.findall(r"<input", html)) form_count = len(re.findall(r"<form", html)) return np.array([ len(url) / 200.0, # 归一化 URL 长度 url.count("-") / max(len(url), 1), # 连字符密度 len(parsed.path) / max(len(url), 1), # 路径占比 input_count / 10.0, # 输入框数量归一化 form_count / 5.0, # 表单数量归一化 html.count("<iframe") / 20.0 # iframe 是钓鱼常用载体 ], dtype=np.float32)

逻辑说明:url_to_tokens把 URL 字符串映射为定长索引数组,长度超过 128 的截断,不足补零。html_to_tag_seq只保留预先定义的关键标签,其他全部映射到unknown,防止模板随机命名的标签干扰模型学习。stat_features提取 6 个统计维度,组合进模型的特征层,用来给文本和视觉通道提供补充信号。

参数说明:MAX_URL_LEN=128是经验值。URL 平均长度在 80 左右,超过 128 的多是携带长参数串的合法页面,截断这部分信息不影响钓鱼判断。max_tags=256一般足够覆盖整个页面的主体结构,绝大多数钓鱼页面的标签数在 100 以内。TAG_INDEX需要和模型训练时的词汇表保持一致,新增标签时要同步重建这句映射,否则上线推理和训练时会把同一页面编码成不同向量。

2.3 标注质量与一致性:决定模型上限的隐藏环节

特征工程做完,另一件很多人忽视的事是标注一致性。钓鱼检测的标注不是简单的「是/否」二分,存在大量模糊地带:一个页面是钓鱼还是「仿冒风格获奖页」?一个带跳转参数的合法电商链接算不算钓鱼?这些边界案例如果不同人标注结果不一致,模型会学到前后矛盾的目标。

我的处理方式是给标注指南定三条硬规则。第一条:只要页面包含「输入账号密码后跳转到未知域名」的行为,一律标为钓鱼,不管页面是否真的登录框。第二条:单一品牌 logo 加表单、无任何正常站点导航的页面标为钓鱼,因为正常业务页面极少只有这两样东西。第三条:无法判断页面真实意图的样本,直接丢弃,不强行归类。这三条规则让标注结果的可复现性大幅提升,训练出来模型的预测也稳定不少。

3. 检测模型选型与训练:单模态不够用,双通道融合是底线

3.1 从文本到视觉:为什么钓鱼检测要「URL+渲染图」双通道

先给一个反直觉的结论:只靠 URL 文本的模型,对付拼写变体有效,但面对高仿页面几乎无能为力。攻击者现在会注册和原品牌相似的域名,例如把字母 l 换成数字 1,然后在页面上 1:1 复刻目标品牌的登录界面。URL 序列本身和正常站点差别不大,文本模型找不到足够强的判别信号。这时候视觉通道的价值就体现出来:高仿页面的截图和真实登录页在视觉结构上高度相似,模型可以从「这个页面长得很像某知名品牌」这个信号出发做判断。

所以常见做法是双通道融合。文本通道处理 URL 字符序列和 HTML 标签序列,视觉通道处理页面渲染截图。两个通道各自提取特征,最后拼接送入分类层。选这个结构有一个工程上的考量:视觉通道单独跑很慢,截图为 224×224 后经过轻量卷积栈,单张推理在 CPU 上也只要几十毫秒,整条链路能控制在可接受的延迟范围内。

也有人主张直接用预训练视觉模型做迁移学习,把页面截图扔进去分类。这个思路的问题在于:预训练权重是在自然图像上学的,页面截图这类「文字密集、结构化」的图像和自然照片分布差异巨大,直接微调效果很差,反而需要花大量时间调参。我更推荐从零训练一个轻量卷积栈,模型参数少、可解释性强,迭代起来也快。

3.2 双通道融合模型的结构设计与 PyTorch 实现

模型结构我拆成三块。文本塔:URL 序列过 Embedding 层后接一维卷积,捕捉「连续数字」「可疑符号组合」这类局部模式;HTML 序列同样过 Embedding 和卷积,但卷积核更大,因为标签之间的依赖关系比字符更长。视觉塔:224×224 灰度截图经过三层卷积加池化,输出视觉特征向量。最后把文本特征、视觉特征和统计特征拼接,过两层全连接层输出二分类 logit。

import torch import torch.nn as nn class URLTextEncoder(nn.Module): """URL 字符序列编码器:Embedding + 多尺寸一维卷积""" def __init__(self, vocab_size: int, embed_dim: int = 32): super().__init__() self.embed = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.convs = nn.ModuleList([ nn.Conv1d(embed_dim, 64, kernel_size=3, padding=1), nn.Conv1d(embed_dim, 64, kernel_size=5, padding=2), ]) self.pool = nn.AdaptiveMaxPool1d(1) def forward(self, x: torch.Tensor) -> torch.Tensor: emb = self.embed(x) # (B, L, D) emb = emb.transpose(1, 2) # (B, D, L) outs = [self.pool(torch.relu(c(emb))).squeeze(-1) for c in self.convs] return torch.cat(outs, dim=-1) # (B, 128) class VisionEncoder(nn.Module): """页面截图编码器:轻量卷积栈,不引入预训练权重""" def __init__(self): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(), nn.AdaptiveAvgPool2d((1, 1)), ) def forward(self, x: torch.Tensor) -> torch.Tensor: return self.features(x).flatten(1) # (B, 128) class PhishNet(nn.Module): """双通道融合模型:URL 文本 + 页面截图 + 统计特征""" def __init__(self, vocab_size: int, num_stat_features: int = 6): super().__init__() self.url_enc = URLTextEncoder(vocab_size) self.vis_enc = VisionEncoder() self.fc = nn.Sequential( nn.Linear(128 + 128 + num_stat_features, 128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, 1), ) def forward(self, url_tokens, screenshot, stat_feats): u = self.url_enc(url_tokens) v = self.vis_enc(screenshot) cat = torch.cat([u, v, stat_feats], dim=-1) return self.fc(cat) # 输出 logit,配合 BCEWithLogitsLoss

逻辑说明:URLTextEncoder用两个不同尺寸的卷积核并行提取字符 n-gram 特征,AdaptiveMaxPool1d(1)把每个卷积输出的特征图压成单点,拼起来得到 128 维向量。VisionEncoder是三层卷积加池化的轻量栈,AdaptiveAvgPool2d((1,1))把空间维度压平。融合层把三路特征拼接后过全连接,输出一个 logit。这里特意不用预训练视觉模型,是为了让整条推理链路不依赖外部权重文件,部署时只需拷一个模型文件。

参数说明:embed_dim=32对字符级 Embedding 足够,URL 字符集通常只有几十个,再大的维度只会增加过拟合风险。Dropout(0.3)在融合层而不是编码器后,因为钓鱼检测的负样本多样性高,融合层最容易记住「同时出现的特征组合」,所以在这里做随机失活收益最大。AdaptiveMaxPool1d比Flatten更适合文本卷积:它能把不同长度的输入统一到固定维度,训练和推理时即便遇到超长 URL 也不会报维度错误。

3.3 训练参数与收敛判断:一组能用的起点配置

模型结构定好后,训练配置直接决定你能不能收敛。我给出用过的一组起点参数,均针对上面这个模型结构调过,可以直接作为基线,再根据你自己的数据集微调。

参数值说明
Batch size32视觉塔输入为 224×224,显存占用可控
学习率1e-3AdamW 配合 warmup,前 2 个 epoch 线性升到 1e-3
Weight decay1e-4抑制融合层过拟合
损失函数BCEWithLogitsLoss二分类任务,接受 logit 输入
正样本权重3.0缓解正负样本不均衡,等价于把正样本损失放大 3 倍
Early stoppatience=5验证集 loss 连续 5 个 epoch 不下降就停
混合精度开启训练速度提升明显,精度损失可忽略

训练时最值得关注的是验证集 loss 的曲线形态。如果训练 loss 持续下降但验证 loss 在第 8 个 epoch 左右反弹,说明模型在过拟合——这时候优先增大Dropout到 0.5,而不是调低学习率。另一个常见现象是验证集准确率一直卡在 95% 上下不涨,这往往是负样本里混入了「看起来很像钓鱼但实际安全」的页面,模型学到了错误信号,需要回去清洗数据,而不是继续调参。

收敛后还需要做一次阈值调整,这步很多团队会漏掉。模型输出的 logit 经过 sigmoid 才是概率,默认以 0.5 为分界线,但对钓鱼检测来说,我通常把阈值调到 0.3 左右。原因是漏报一个钓鱼页面的代价远高于误报一个正常页面,调低阈值会牺牲一部分准确率换取更高的召回率。后面在前后端架构里,这个阈值会作为可配置项暴露给运维同学。

4. 前后端架构设计:模型服务化、API 与检测页面的完整链路

4.1 架构分层:为什么推理服务要和业务服务拆开

模型训练完只完成了一半工作,webPish_detect 真正能对外服务靠的是前后端架构设计。整套系统的层次从上到下依次是:前端检测面板、API 网关层、检测服务、模型推理引擎、特征工程模块和样本缓存。我的分层原则是推理引擎必须独立成一个进程,不跟业务服务混在一起,原因有三个。

第一是故障隔离。推理引擎如果被某个异常请求拖垮(比如传了一个超大的 HTML),业务服务要能继续运行,不能一个线上事故导致整个检测能力不可用。第二是弹性扩缩容。钓鱼攻击通常在某个时段集中爆发,推理引擎可以单独扩到多副本,而业务服务保持原有规模。第三是模型热更新。换模型时重启推理进程,不影响 API 网关和前端。这些需求在单体架构里全部会很被动。

API 网关层负责限流、鉴权和请求分发。网关限流阈值我一般按单实例推理能力的 60% 来设定,留出缓冲。检测服务是无状态的,只负责组装请求、调用推理引擎、缓存结果。样本缓存层放一个按 URL 哈希做键的 KV 缓存,同一个 URL 短时间内重复查询直接命中缓存,省掉一次推理。

4.2 后端推理服务:用 FastAPI 封装检测接口

推理服务是这套架构里最核心的进程。它加载模型权重,接收特征工程模块产出的张量,跑一次前向推理,返回置信度。我用 FastAPI 做这个服务是因为它原生支持异步接口和高并发请求,部署时配合 Gunicorn 多 worker 就能扛住常规流量。

import torch import uvicorn from fastapi import FastAPI, HTTPException from pydantic import BaseModel from feature_pipeline import url_to_tokens, html_to_tag_seq, stat_features from model import PhishNet app = FastAPI() class DetectRequest(BaseModel): url: str html: str | None = None screenshot_base64: str | None = None class DetectResponse(BaseModel): url: str score: float # 0~1 之间的置信度 verdict: str # "phishing" 或 "clean" processing_ms: int _model = None _device = "cuda" if torch.cuda.is_available() else "cpu" def load_model(path: str): global _model state_dict = torch.load(path, map_location=_device, weights_only=True) _model = PhishNet(vocab_size=80).to(_device) _model.load_state_dict(state_dict) _model.eval() @app.post("/api/detect", response_model=DetectResponse) async def detect(req: DetectRequest): if _model is None: raise HTTPException(status_code=503, detail="model not loaded") # 特征工程统一走同步函数,FastAPI 线程池自动补偿 tokens = torch.tensor([url_to_tokens(req.url)]).to(_device) stat = torch.tensor([stat_features(req.url, req.html or "")]).to(_device) # 截图为空时用纯零张量代替,视觉塔输出退化为常数 if req.screenshot_base64: from io import BytesIO from PIL import Image import base64 raw = base64.b64decode(req.screenshot_base64) img = Image.open(BytesIO(raw)).convert("L").resize((224, 224)) vis = torch.tensor([list(img.getdata())], dtype=torch.float32) vis = vis.view(1, 1, 224, 224).to(_device) else: vis = torch.zeros((1, 1, 224, 224)).to(_device) with torch.no_grad(): logit = _model(tokens, vis, stat) prob = torch.sigmoid(logit).item() verdict = "phishing" if prob >= 0.3 else "clean" return DetectResponse( url=req.url, score=round(prob, 4), verdict=verdict, processing_ms=0, # 简化,实际在网关层统计 ) if __name__ == "__main__": load_model("./checkpoints/phishnet_v3.pt") uvicorn.run(app, host="0.0.0.0", port=8000, workers=2)

逻辑说明:接口接收 URL、HTML 和可选截图,特征工程模块把原始输入转成三个张量后送入模型。weights_only=True的目的是反序列化时只加载权重、不执行 pickle 里的任意代码,杜绝模型文件被投毒的隐患。截图为空时用零张量占位,视觉塔输出一个固定向量,模型仍然可以正常前向传播,只是这条样本的判别只依赖 URL 和统计特征。阈值 0.3 在这里写死,实际生产中我建议做成环境变量,方便运维在灰度阶段动态调整。

参数说明:workers=2是单机部署的保守配置,每个 worker 持有独立模型副。如果你用 GPU 推理,worker 数建议固定为 1,因为多进程共享一块卡反而会竞争显存和 CUDA context。map_location在 CPU 机器上加载 GPU 权重时是必填项,不写会直接报错。PIL的resize((224, 224))会拉伸原始截图,对钓鱼检测影响不大,因为模型关注的是布局结构而非边角细节。

4.3 前端检测面板与批量检测:从一次查一个到一次查一批

前端是给安全运营同学操作的入口,功能就两个:单条 URL 检测和批量文件检测。单条检测的交互是输入 URL,点击按钮后展示置信度条和判定结果。批量检测则走异步任务:前端上传一个 URL 列表文件,后端创建任务 ID,前端轮询任务状态,完成后下载结果 CSV。异步设计是为了避免 HTTP 长连接占用推理引擎资源。

前端用一个极简的页面实现,不必引入大型框架。

<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>webPish_detect</title> <style> body { font-family: sans-serif; max-width: 720px; margin: 40px auto; } .score-bar { height: 12px; background: #eee; border-radius: 4px; } .score-fill { height: 12px; background: #d33; border-radius: 4px; } .verdict { font-weight: bold; margin-top: 8px; } .phishing { color: #d33; } .clean { color: #090; } </style> </head> <body> <h2>URL 检测</h2> <input id="url" type="text" placeholder="输入需要检测的 URL" style="width: 80%; padding: 8px;"> <button onclick="detect()">检测</button> <div id="result" style="margin-top: 16px;"></div> <script> async function detect() { const urlInput = document.getElementById('url').value.trim(); if (!urlInput) return; const resp = await fetch('/api/detect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: urlInput }) }); const data = await resp.json(); const fill = document.querySelector('.score-fill'); fill.style.width = (data.score * 100) + '%'; document.getElementById('result').innerHTML = ` <div class="score-bar"><div class="score-fill" style="width:0"></div></div> <div class="verdict ${data.verdict}">${data.verdict === 'phishing' ? '疑似钓鱼' : '正常'}</div> <div>置信度:${data.score.toFixed(4)}</div> `; // 重新渲染后更新进度条宽度 document.querySelector('.score-fill').style.width = (data.score * 100) + '%'; } </script> </body> </html>

逻辑说明:前端通过fetch调用后端/api/detect接口,拿到 JSON 结果后渲染置信度条和判定文案。注意这里有个细节:先插入 DOM 再设置进度条宽度,如果顺序反了,浏览器会拿渲染前的空元素去计算宽度,导致进度条永远停在 0。

参数说明:前端不直接接触模型、不接触特征工程,所有敏感逻辑都在后端。这样做的好处是前端页面可以直接交给不熟悉深度学习的同学维护,也能在页面层统一做跨域限制。批量检测的接口设计同理,前端只拿任务 ID 轮询,避免大量 URL 同时涌入时把 HTTP 连接打满。

5. 钓鱼检测架构落地中的 5 个坑:误报、样本过期与推理延迟排查

5.1 坑一:全新页面批量误判为钓鱼,原因在视觉通道过敏感

现象:上线后运营同学反馈,凡是结构简单、没有样式的页面——比如纯文字公告、404 页面——几乎全部被判为钓鱼。查看置信度分布,大量样本集中在 0.45 到 0.7 之间。

原因:视觉塔在学习「简陋页面 = 钓鱼页面」。训练集里的钓鱼页面为了快速搭建,确实多数是极简样式,但正常站点也会有「暂时无法访问」「维护中」这类临时页。模型没有学到「登录表单 + 品牌特征 + 极简结构」的组合,只学到了「极简结构」这个单维特征。

解决:给视觉塔加归一化正则,在训练时随机对截图做不同程度的模糊和裁剪,打断模型对「页面简单程度」的直接依赖。另一个有效手段是负样本里特意加入一批「简朴但正常」的页面,比如本地文档、纯文本说明页,让模型知道简单不等于恶意。我在实践里把这类负样本的占比从 5% 提到 15% 后,误报率下降了六成。

5.2 坑二:品牌官网被反复误杀,难负样本挖掘缺失

现象:几个知名电商平台的主站被判定为钓鱼,运营同学拿着截图来质问模型是不是坏了。

原因:负样本覆盖不够。训练集里的负样本多是普通内容站,缺少「大型品牌官网首页」这类与钓鱼页面视觉结构相似的页面。模型没见过足够多的高质量合法站点,自然学到一条错误边界:凡是有大 logo、有登录框的页面就是钓鱼。

解决:去公开的站点排行里抓取前几百个主流品牌官网加入负样本,标注时只标「正常」并人工确认页面可访问。这句话听起来像是废话,但实际执行中很多团队会因为嫌麻烦而跳过。更进一步的方案是做难负样本挖掘:先用当前模型跑一遍全部负样本数据,把所有被误判为钓鱼的样本提取出来,人工复核后重新放入训练集。这个流程循环两轮,误报率能明显下降。

5.3 坑三:爬取样本时发现页面已失效,导致数据集越用越脏

现象:训练集里一批样本在模型评估时表现很好,但过了一个月再跑准确率掉了一大截。查看原始数据后发现问题出在「快照」上:当时爬下来的 HTML 里全是跳转脚本和验证码页面,真正的内容没有保存下来。

原因:钓鱼站点的反爬机制。不少恶意站点检测到无头浏览器访问时会返回一个「跳转中」的临时页面,或者干脆弹验证码。如果抓取脚本直接把响应保存下来,这些无效快照会被当成真实页面进入训练集。

解决:抓取过程增加两步校验。第一步检查 HTTP 状态码和响应长度,长度小于 500 字节的页面直接废弃;第二步检查最终渲染后的标题和原文 HTML 的标题是否一致,不一致说明发生了 JS 跳转,需要重抓。我给抓取脚本设置的最大重试次数是 1 次,超过就放弃这条样本,不硬凑数据。

5.4 坑四:推理延迟高,接口被批量检测任务拖垮

现象:批量检测一跑起来,单条检测的接口也跟着变慢,单个请求耗时从 200ms 涨到 2 秒。监控显示推理引擎的 CPU 被打满,队列堆积严重。

原因:批量任务和单条任务共用了同一个推理进程,没有做资源隔离。批量检测一次提交几千个 URL,特征工程里的 HTML 解析是纯 CPU 操作,把 CPU 占满后,单条请求排队等着拿锁,延迟自然飙升。

解决:把批量检测拆成独立队列和独立 worker 组。单条检测走同步接口,批量任务丢进消息队列,由专门的消费进程执行推理。另外在推理引擎层做小 batch 合并:单条请求到达时不等满 batch 就直接跑,但批量任务可以切分成 16 个一批,用 torch 的 batch 推理一次处理,吞吐量翻几倍。

5.5 坑五:模型上线后效果越用越差,概念漂移在作祟

现象:模型刚上线时检测率 94%,运行三个月后降到 85%。重跑测试集发现准确率并没有降,降的是「对新出现钓鱼页面」的检出能力。

原因:钓鱼攻击的形态在持续变化。攻击者会参考现有检测规则调整自己的模板,这个月流行用二维码钓鱼、下个月流行用云文档跳转。模型的知识停留在训练时刻的场景,对新型态页面缺乏判别力。

解决:建立「影子模式」闭环。把模型预测结果和人工复判结果都落库,每周抽一批「模型低置信但用户点击了」的 URL 做人工复核,复核结果自动进入增量训练集。攒够一批就触发增量训练,替换线上模型。这个流程我在实践中固定为每两周跑一次,每次增量样本 5000 条左右,模型效果能一直保持在 90% 以上。

6. 把单模型 95% 准确率推到 99%:级联推断与置信度分档

单模型做到 95% 看起来还行,但生产环境里 5% 的误报每天能带来几十条工单。要把准确率推到 99% 附近,我采用的方案是级联推断加置信度分档,而不是无限调模型。整套推断流程分三段走。

第一段是规则快路径。拿到 URL 先查本地缓存和基础黑名单,命中直接拦截,不进入模型。再算几条硬规则:URL 是不是纯 IP、域名是不是刚注册的、是不是知名短链接服务。命中这些规则的样本直接标记为高危,进入复核队列。这一段能拦下约 20% 的流量,几乎不消耗推理资源。第二段是深度模型判分,输出置信度。把结果分成三档:置信度高于 0.7 的直接判定为钓鱼,低于 0.1 的直接放行,中间 0.1 到 0.7 的标为「存疑」。第三段是存疑队列人工复核,复核结论回流到增量训练集。这套架构上线后,模型判断的准确率没有变,但最终响应的准确率接近 99%,误报大量减少。

置信度分档的另一个好处是给了「不知道」的出口。模型不需要对每个样本都强行二分类,知道什么时候该说「我不确定」的检测系统,在实际安全运营里比一个盲目自信但偶尔翻车的模型可靠得多。

最后说一个我自己的血泪经验:模型权重文件和特征工程代码必须一起做版本管理。我踩过的一次坑是只更新了模型文件,没更新配套的html_to_tag_seq映射表,上线后所有页面都被预测成同一个分数,排查了半天才发现特征根本没对齐。现在的习惯是每次发版前跑一遍「回放测试」:拿旧版本特征产出的向量和新版本对同一批 URL 的输出做比对,向量不一致直接阻断上线。希望这篇笔记能帮你在做钓鱼检测落地时少踩几个我踩过的坑。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进