AI 驱动的链上异常检测:交易图谱构建与图神经网络的风险识别方案

发布时间:2026/7/7 15:54:48
AI 驱动的链上异常检测:交易图谱构建与图神经网络的风险识别方案 AI 驱动的链上异常检测交易图谱构建与图神经网络的风险识别方案一、规则引擎的终点当攻击模式无法用正则表达2024-2026 年的链上攻击呈现出一个明显趋势攻击不再是单一合约级别的漏洞利用而是跨协议、多步骤、利用短期价格窗口的闪电贷拼装式攻击。一条典型的攻击路径可能涉及 5-8 个协议的临时交互在 1-2 个区块内完成资金转移、清算触发和收益提取。传统安全监控的规则引擎在面对这类攻击时几乎完全失效。规则引擎可以检测单笔交易中这个地址调用了flashLoan但它理解不了flashLoan → swap → deposit → borrow → swap → repay → withdraw这条六步序列的整体风险语义。它只能看到孤立的函数调用看不到交易之间形成的攻击图谱。图神经网络GNN为这个问题提供了一个结构化的分析框架。核心思路是把链上交易建模为异构图Heterogeneous Graph其中地址是节点交易是边。通过 GNN 的消息传递机制模型可以从已知的恶意地址出发沿着交易链路传播风险信号识别出隐藏在正常交易模式中的异常行为簇。flowchart TD A[链上原始数据] -- B[交易图谱构建] B -- C1[地址节点] B -- C2[交易边] B -- C3[合约调用子图] C1 -- D[节点特征工程] C2 -- D C3 -- D D -- E[GNN 图神经网络] E -- F[节点风险嵌入] F -- G[异常检测分类器] G -- H{风险分级} H --|高风险| I[实时告警 阻断] H --|中风险| J[延时监控] H --|低风险| K[归档] subgraph 图谱构建层 A -- B end subgraph 推理层 E -- F -- G -- H end二、交易图谱的建模与 GNN 的消息传递原理2.1 异构图建模交易图谱的核心是正确建模三种节点和两种边。节点类型EOAExternally Owned Account普通用户钱包地址。节点特征包括交易频率、平均交易金额、合约交互数、部署合约数、活跃天数等。合约地址已部署的智能合约。节点特征包括合约类型DEX/借贷/NFT/桥、部署时间、被调用频次、关联的攻击事件历史。资产 Token可选节点类型如果关注代币流向。特征包括代币类型、持有者数量分布。边类型交易边EOA 发起交易调用合约。边特征包括ETH 转账额、Gas 价格、调用函数签名、交易是否成功。内部调用边合约间通过 CALL/DELEGATECALL/STATICCALL 产生的内部消息。边特征包括调用深度、是否转账 ETH、调用是否 revert。2.2 GNN 消息传递选择 GraphSAGE 作为主干模型它在归纳式学习任务上表现稳定且支持大图的 mini-batch 训练。消息传递的核心操作是每一层 GNN每个节点都会聚合其邻居节点的特征结合自身特征做一个非线性变换。经过 2-3 层消息传递后一个节点的 embedding 就包含了其 k 跳邻居的图结构信息。在异常检测场景中这意味着一个地址的风险评分不仅依赖于它自身的行为还依赖于它与哪些地址发生了交互、那些地址又连接到了什么已知的恶意实体。sequenceDiagram participant Indexer as 链上索引器 participant GraphDB as 图数据库Neo4j participant Train as 离线训练管线 participant Infer as 在线推理服务 participant Alert as 告警系统 Indexer-GraphDB: 增量写入新区块交易图 loop 每 24 小时 Train-GraphDB: 导出全量训练数据 Train-Train: 特征工程 GraphSAGE 训练 Train-Infer: 更新模型权重 end Indexer-Infer: 推送最新交易 Infer-Infer: 在已加载的图上做推理 Infer-Alert: 风险评分 阈值 → 告警 Alert-Alert: 通知安全团队 / 自动化阻断三、工程实现从图谱构建到在线推理3.1 交易图谱构建 graph_builder.py — 链上交易图谱构建 设计考量 - 使用 NetworkX 构建内存图谱快照Neo4j 做持久化存储 - 节点和边的 ID 使用地址哈希而非原始地址降低内存占用同时避免 PII 风险 - 批量写入 Neo4j 使用 UNWIND 模式单条 CREATE 语句在大量数据下性能极差 - 图谱按时间窗口切分全量链上数据图太大数亿节点训练时使用滑动窗口截取 from typing import Optional from datetime import datetime, timezone, timedelta from dataclasses import dataclass import hashlib from neo4j import GraphDatabase, Driver def _hash_address(addr: str) - str: 地址哈希生成固定长度节点 ID return hashlib.sha256(addr.lower().encode()).hexdigest()[:16] dataclass class GraphNode: node_type: str # eoa | contract | token address: str features: dict dataclass class GraphEdge: from_addr: str to_addr: str edge_type: str # transaction | internal_call block_number: int features: dict class TransactionGraphBuilder: def __init__(self, neo4j_uri: str, neo4j_user: str, neo4j_password: str): self._driver: Driver GraphDatabase.driver( neo4j_uri, auth(neo4j_user, neo4j_password), # 连接池配置图写入是瓶颈操作需要较大的连接池 max_connection_pool_size20, connection_acquisition_timeout30, ) # 内存缓存避免对每个新区块都做 Neo4j 查询 self._node_cache: dict[str, GraphNode] {} def add_transaction(self, tx_data: dict) - None: 处理单条交易提取节点和边 from_addr tx_data.get(from, ).lower() to_addr tx_data.get(to, ).lower() if not from_addr or not to_addr: return block tx_data.get(blockNumber, 0) # 构建 EOA 节点 if from_addr not in self._node_cache: self._node_cache[from_addr] GraphNode( node_typeeoa, addressfrom_addr, features{ tx_count: 1, first_seen_block: block, last_seen_block: block, }, ) else: node self._node_cache[from_addr] node.features[tx_count] 1 node.features[last_seen_block] block # 构建合约/EOA 节点 if to_addr not in self._node_cache: self._node_cache[to_addr] GraphNode( node_typecontract if tx_data.get(toIsContract) else eoa, addressto_addr, features{tx_count: 1, first_seen_block: block}, ) # 注册交易边暂存批量写入时统一处理 self._pending_edges.append(GraphEdge( from_addrfrom_addr, to_addrto_addr, edge_typetransaction, block_numberblock, features{ value_wei: int(tx_data.get(value, 0), 16), gas_price: int(tx_data.get(gasPrice, 0), 16), success: tx_data.get(status) 0x1, }, )) _pending_edges: list[GraphEdge] [] def flush_to_neo4j(self) - int: 批量写入 Neo4j返回写入的节点边总数 count 0 with self._driver.session() as session: # 批量写入节点 if self._node_cache: nodes_batch [ { id: _hash_address(addr), type: n.node_type, addr: n.address, tx_count: n.features.get(tx_count, 0), } for addr, n in self._node_cache.items() ] session.run( UNWIND $batch AS node MERGE (n:Address {id: node.id}) SET n.type node.type, n.address node.addr, n.tx_count coalesce(n.tx_count, 0) node.tx_count , batchnodes_batch, ) count len(nodes_batch) # 批量写入边 if self._pending_edges: edges_batch [ { from_id: _hash_address(e.from_addr), to_id: _hash_address(e.to_addr), block: e.block_number, value: e.features.get(value_wei, 0), } for e in self._pending_edges ] session.run( UNWIND $batch AS edge MATCH (f:Address {id: edge.from_id}) MATCH (t:Address {id: edge.to_id}) MERGE (f)-[r:TRANSACTED]-(t) SET r.block edge.block, r.value edge.value , batchedges_batch, ) count len(edges_batch) self._node_cache.clear() self._pending_edges.clear() return count def close(self): self._driver.close()3.2 图神经网络训练 gnn_trainer.py — 图神经网络训练管线 设计考量 - 使用 PyTorch Geometric 代替原始 DGL生态更成熟与 PyTorch 训练管线集成更好 - 训练集正负样本比例 1:10链上数据天然严重不平衡恶意地址占比极低 - 加权损失函数正样本权重是负样本的 5 倍迫使模型关注异常模式而非盲目预测正常 import torch import torch.nn as nn import torch.nn.functional as F from torch_geometric.nn import SAGEConv from torch_geometric.data import Data, DataLoader from sklearn.metrics import precision_recall_fscore_support class AddressRiskGNN(nn.Module): 地址风险评分 GNN 模型 架构2层 GraphSAGE 1层全连接分类头 为什么选 GraphSAGE 而非 GAT - GraphSAGE 的归纳式学习能力更适合增量图场景每天新增数万地址 - GAT 的注意力机制在超大图上计算成本过高且容易过拟合到训练集的小邻域 def __init__(self, in_channels: int, hidden_channels: int 128): super().__init__() self.conv1 SAGEConv(in_channels, hidden_channels, aggrmean) self.conv2 SAGEConv(hidden_channels, hidden_channels, aggrmean) self.classifier nn.Sequential( nn.Linear(hidden_channels, 64), nn.ReLU(), nn.Dropout(0.3), nn.Linear(64, 2), # 二分类正常 / 异常 ) def forward(self, x, edge_index): x F.relu(self.conv1(x, edge_index)) x F.dropout(x, p0.2, trainingself.training) x F.relu(self.conv2(x, edge_index)) return self.classifier(x) def train_epoch( model: AddressRiskGNN, loader: DataLoader, optimizer: torch.optim.Optimizer, device: torch.device, ) - float: model.train() total_loss 0.0 # 正样本权重设为 5负样本 1缓解天然类别不平衡 pos_weight torch.tensor([5.0], devicedevice) criterion nn.CrossEntropyLoss(weighttorch.tensor([1.0, 5.0], devicedevice)) for batch in loader: batch batch.to(device) optimizer.zero_grad() out model(batch.x, batch.edge_index) loss criterion(out, batch.y) loss.backward() # 梯度裁剪防止恶意构造的图谱数据导致的梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() total_loss loss.item() return total_loss / len(loader) def evaluate(model: AddressRiskGNN, data: Data, device: torch.device) - dict: model.eval() with torch.no_grad(): out model(data.x.to(device), data.edge_index.to(device)) pred out.argmax(dim1).cpu() precision, recall, f1, _ precision_recall_fscore_support( data.y.cpu(), pred, averagebinary, pos_label1 ) return {precision: precision, recall: recall, f1: f1} # 训练主循环 # 数据准备从 Neo4j 导出为 PyG Data 对象 # 特征包括节点度数、交易频率、交易金额分布、合约交互多样性等 16 维特征 # 标签来自已知的安全事件标注如被标记的钓鱼地址、攻击合约等3.3 在线推理服务 inference_server.py — 在线风险推理服务 设计考量 - 模型加载为单例避免每个请求重新加载模型权重 - 推理使用 torch.inference_mode() 而非 torch.no_grad()关闭 autograd 引擎降低内存 - 图更新使用增量策略新区块交易追加到内存子图中无需重建全图 - 风险评分阈值分层0.8 即时告警0.5-0.8 加入观察列表 import torch from torch_geometric.data import Data import asyncio from collections import defaultdict class RiskInferenceServer: HIGH_RISK_THRESHOLD 0.8 MEDIUM_RISK_THRESHOLD 0.5 def __init__(self, model_path: str, device: str cpu): self._device torch.device(device) self._model: AddressRiskGNN | None None self._model_path model_path # 内存子图不存储全链数据仅维护活跃窗口最近 7 天 self._active_graph: dict { nodes: {}, # node_id - feature_vec edges: [], # [(src, dst)] node_to_idx: {}, # address - contiguous index } self._lock asyncio.Lock() # 图更新与推理之间的并发控制 async def load_model(self): self._model AddressRiskGNN(in_channels16) self._model.load_state_dict(torch.load(self._model_path, map_locationself._device)) self._model.to(self._device) self._model.eval() async def add_transactions(self, txs: list[dict]): 增量更新内存交易图 async with self._lock: for tx in txs: from_addr tx[from] to_addr tx[to] for addr in (from_addr, to_addr): if addr not in self._active_graph[node_to_idx]: # 初始化节点特征向量 idx len(self._active_graph[node_to_idx]) self._active_graph[node_to_idx][addr] idx self._active_graph[nodes][idx] [0.0] * 16 src self._active_graph[node_to_idx][from_addr] dst self._active_graph[node_to_idx][to_addr] self._active_graph[edges].append((src, dst)) async def predict_risks(self, addresses: list[str]) - dict[str, float]: 对指定地址列表做风险评分 if self._model is None: await self.load_model() async with self._lock: # 构建 PyG Data 对象 num_nodes len(self._active_graph[node_to_idx]) x torch.zeros((num_nodes, 16), deviceself._device) for idx, feat in self._active_graph[nodes].items(): x[idx] torch.tensor(feat, deviceself._device) edge_index torch.tensor( self._active_graph[edges], deviceself._device ).t().contiguous() data Data(xx, edge_indexedge_index) with torch.inference_mode(): logits self._model(data.x, data.edge_index) probs torch.softmax(logits, dim1) results {} for addr in addresses: idx self._active_graph[node_to_idx].get(addr) if idx is not None: # prob[1] 是异常类的概率 results[addr] float(probs[idx][1]) else: results[addr] -1.0 # 未知地址 return results四、边界分析数据标注是最大的瓶颈。GNN 的异常检测效果高度依赖标注数据的质量。链上恶意地址的标签来源分散Etherscan 标签、慢雾攻击库、OpenChain 的 AML 数据、以及安全团队的内部情报。这些标签覆盖面和更新速度有限模型对新型攻击模式的泛化能力取决于标注库的及时扩展。图的时效性问题。链上交易图是高度动态的。训练时使用的图结构和推理时的图结构存在时间差新出现的合约交互模式在训练图中不存在GNN 消息传递无法利用这些新连接。解决方案是采用频繁重训练策略如每 6 小时增量训练但这需要持续的计算资源投入。特征工程的领域依赖。节点特征的构造直接影响 GNN 的表达能力。如果将合约交互多样性这类高级特征写成硬编码的统计指标模型会错过未知的特征组合模式。更好的做法是使用 Node2Vec 或 Graph Autoencoder 做图结构的无监督预训练让模型自动学习节点在交易图中的拓扑角色。不适用场景只有几笔交易的全新地址没有足够图谱结构供 GNN 推理退化为随机猜测非交易型链上行为如 NFT Mint 的签名验证、零知识证明验证等不产生交易图结构的操作需要逐笔交易实时拦截的极高吞吐场景GNN 推理延迟毫秒级起步不适合交易所级别的吞吐五、总结维度要点核心思路将链上交易建模为异构图通过 GNN 消息传递传播风险信号图建模EOA / 合约 / Token 三类节点交易边 内部调用边特征含交易频率和金额分布模型选型GraphSAGE 适合大规模增量图2层卷积 MLP 分类头工程管线Neo4j 持久化 PyG 训练 增量内存图推理两大瓶颈标注数据覆盖面和时效性、模型对新交互模式的泛化能力适用边界适合批量异常检测和大规模地址风险评分不适合单笔实时拦截和全新地址评估