摘要:传统知识图谱故障诊断查得到但看不懂,纯LLM又乱猜。我花了两个月搭建了一套"符号-神经"混合系统:用CNN解析设备SQL日志自动构建图谱,GNN补全缺失关系,LLM做最后一步的"人话翻译"和根因推理。上线后,产线故障平均定位时间从47分钟降到8分钟,准确率达92%。关键是整套方案不打标、不微调,3000行Python代码跑在两张T4上。
一、噩梦开局:CTO的"既不要人工,也不要黑盒"
今年Q1,我们工厂的MES系统每天有2000+条报警日志,运维团队7个人轮班看。CTO给我两个要求:
-
不准人工打标:"我没钱请人给20年历史的SQL日志做标注"
-
不准瞎猜:"LLM胡说八道会让我们停产,必须每一步可解释"
我试了三种方案全翻车:
-
纯知识图谱:能查出"电机过载→跳闸"这种已知路径,但用户问"为什么这批产品厚度不均?",它返回20条可能原因,没有优先级
-
GPT-4直接分析日志:token数爆炸,且把"温度传感器校准"和"环境温度高"混为一谈
-
向量RAG:检索到相关日志,但丢失设备间的拓扑关系
最终路线:用图谱搞定"结构",用LLM搞定"语义",用规则引擎搞定"因果"。
二、SQL日志→知识图谱:不打标的自动构建
2.1 从脏日志里"闻"出实体
MES日志长这样:
sql
-- 2024-03-15 14:32:11
INSERT INTO alarm_log (device_id, error_code, description)
VALUES ('EX-2031-P321', 'E301', '挤出机压力异常');
INSERT INTO sensor_data (device_id, timestamp, temperature, pressure)
VALUES ('EX-2031-P321', '2024-03-15 14:32:10', 235, 12.3);
传统NLP方案用BIO标注,但我不想打标。我的方法:用SQL语法树+设备拓扑约束:
python
# kg_construction.py
import sqlparse
from collections import defaultdict
class SqlLogParser:
def __init__(self, device_topology: dict):
# 设备拓扑:EX-2031-P321 → 属于 → 挤出生产线B
self.device_to_line = device_topology
# 预定义错误模式(不用打标,运维口述10条就够)
self.error_patterns = {
'E\d{3}': 'equipment_failure',
'W\d{3}': 'quality_warning',
'P\d{3}': 'parameter_deviation'
}
def parse_insert_statement(self, sql: str) -> dict:
"""
从INSERT语句提取三元组
"""
parsed = sqlparse.parse(sql)[0]
tokens = [t for t in parsed.flatten() if not t.is_whitespace]
triples = []
# 找VALUES关键字的位置
values_idx = next(i for i, t in enumerate(tokens) if t.value == 'VALUES')
# 提取字段名
fields_start = tokens.index('(')
fields_end = tokens.index(')')
fields = [t.value for t in tokens[fields_start+1:fields_end] if t.ttype is None]
# 提取值
values_start = values_idx + 2 # 跳过VALUES和(
values_end = len(tokens) - 1 # 跳过最后的)
values = []
for t in tokens[values_start:values_end]:
if t.value in (',', '(', ')'):
continue
values.append(t.value.strip("'"))
# 构造三元组
if 'alarm_log' in sql:
device_id = values[fields.index('device_id')]
error_code = values[fields.index('error_code')]
desc = values[fields.index('description')]
# 实体1: 设备
triples.append({
"subject": device_id,
"predicate": "has_error",
"object": error_code,
"timestamp": self._extract_timestamp(sql)
})
# 实体2: 错误类型
triples.append({
"subject": error_code,
"predicate": "description",
"object": desc,
"timestamp": self._extract_timestamp(sql)
})
# 知识增强:根据拓扑补全关系
if device_id in self.device_to_line:
line = self.device_to_line[device_id]
triples.append({
"subject": line,
"predicate": "contains_device",
"object": device_id
})
return triples
def build_kg_from_logs(self, log_file: str, batch_size=1000) -> list:
"""
批量解析日志文件
"""
all_triples = []
# 按时间窗口切分日志(避免内存爆炸)
for chunk in self._read_log_chunks(log_file, batch_size):
for sql in chunk:
try:
triples = self.parse_insert_statement(sql)
all_triples.extend(triples)
except Exception as e:
# 脏数据跳过,不中断流程
continue
return all_triples
# 坑1:SQL日志里95%是正常数据,直接解析会淹没异常
# 解决:只解析INSERT INTO alarm_log/quality_log,过滤sensor_data
# 图谱构建速度从3小时降至15分钟,图谱大小从1.2GB降至87MB
2.2 GNN关系补全:让图谱自己"长"出推理链
解析后的图谱很稀疏,比如只记录了"电机过载→跳闸",但缺少"电机过载→电流异常→跳闸"的中间关系。
python
# gnn_completion.py
import dgl
import torch.nn as nn
from dgl.nn import GraphConv
class RelationCompletionGNN(nn.Module):
def __init__(self, num_entities, num_relations, hidden_dim=128):
super().__init__()
self.entity_embed = nn.Embedding(num_entities, hidden_dim)
# 图卷积层:补全一跳关系
self.conv1 = GraphConv(hidden_dim, hidden_dim)
self.conv2 = GraphConv(hidden_dim, hidden_dim)
# 关系分类器:预测边类型
self.relation_classifier = nn.Sequential(
nn.Linear(hidden_dim * 2, 256),
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(256, num_relations)
)
# 关键:基于设备拓扑的掩码矩阵
# 只保留同生产线内的实体间可能的边
self.register_buffer('topology_mask', self._build_topology_mask())
def _build_topology_mask(self):
# 例如:EX-2031-P321 只能和 EX-2031-* 的设备产生关系
mask = torch.ones(num_entities, num_entities)
for i, ent_i in enumerate(entity_list):
for j, ent_j in enumerate(entity_list):
if not self._same_production_line(ent_i, ent_j):
mask[i, j] = 0
return mask
def forward(self, g, entity_ids):
"""
g: DGL图
entity_ids: 实体ID列表
"""
h = self.entity_embed(entity_ids)
# 图卷积
h = self.conv1(g, h)
h = F.relu(h)
h = self.conv2(g, h)
# 预测所有可能的关系
num_nodes = len(entity_ids)
relation_scores = torch.zeros(num_nodes, num_nodes, num_relations)
for i in range(num_nodes):
for j in range(num_nodes):
if self.topology_mask[i, j] == 1:
pair_feat = torch.cat([h[i], h[j]], dim=-1)
relation_scores[i, j] = self.relation_classifier(pair_feat)
return relation_scores
# 自监督训练:用已知关系预测未知关系
def gnn_train_step(model, g, entity_ids, known_edges):
"""
known_edges: (head, tail, relation_type)
目标:预测未知实体对之间的关系
"""
# 随机mask 20%的边作为训练目标
masked_edges, train_edges = random_mask_edges(known_edges, mask_rate=0.2)
# 用train_edges构建训练图
train_g = dgl.graph(train_edges)
# 前向
logits = model(train_g, entity_ids)
# 只在masked_edges上计算loss
loss = 0
for head, tail, rel in masked_edges:
loss += F.cross_entropy(logits[head, tail], rel)
return loss / len(masked_edges)
# 坑2:补全关系时产生大量不合理边,如"传送带故障→产品厚度不均"(实际无直接关系)
# 解决:加入行业知识约束(因果图先验),只有3类关系可传递
# 效果:补全准确率从43%提升至81%
三、LLM符号推理:让图谱说"人话"
3.1 子图采样:只给LLM看"关键证据"
直接把整个图谱(87MB)塞给LLM不现实。我设计了一个问题驱动的子图剪枝算法:
python
# subgraph_sampler.py
import networkx as nx
class QuestionDrivenSampler:
def __init__(self, kg: nx.MultiDiGraph):
self.kg = kg
def sample_by_question_type(self, question: str, entities: list) -> nx.Graph:
"""
根据问题类型采样不同范围的子图
"""
# 1. 识别问题意图(不用LLM,用关键词匹配,快)
if any(word in question for word in ['为什么', '原因', '导致']):
# 因果类问题:向上游追溯3跳
return self._sample_upstream_causal(entities, hops=3)
elif any(word in question for word in ['怎么解决', '处理', '修复']):
# 解决方案类:向下游找action
return self._sample_downstream_actions(entities, hops=2)
elif any(word in question for word in ['影响', '关联', '关系']):
# 关联类:双向BFS
return self._sample_bidirectional(entities, hops=2)
else:
# 默认:ego-graph
return self._sample_ego_graph(entities, radius=2)
def _sample_upstream_causal(self, entities, hops=3):
"""
上游追溯:找根本原因
"""
subgraph = nx.MultiDiGraph()
for ent in entities:
# 反向DFS,只遍历"导致"、"引发"类型的边
for predecessor in nx.traversal.dfs_predecessors(
self.kg.reverse(),
source=ent,
depth_limit=hops
):
# 过滤边的类型
edges = self.kg.get_edge_data(predecessor[1], predecessor[0])
for edge_data in edges.values():
if edge_data['predicate'] in ['caused_by', 'triggered_by', 'result_of']:
subgraph.add_edge(
predecessor[1], predecessor[0],
**edge_data
)
return subgraph
def _prune_by_attention(self, subgraph: nx.Graph, question: str) -> nx.Graph:
"""
用轻量级模型给子图节点打分,只保留Top-K
"""
# 用SentenceTransformer编码问题和节点描述
q_vec = self.sentence_encoder.encode(question)
node_scores = {}
for node in subgraph.nodes():
node_desc = self.kg.nodes[node].get('description', '')
node_vec = self.sentence_encoder.encode(node_desc)
# 计算相关性
score = np.dot(q_vec, node_vec) / (np.linalg.norm(q_vec) * np.linalg.norm(node_vec))
node_scores[node] = score
# 保留Top-15节点
top_nodes = sorted(node_scores, key=node_scores.get, reverse=True)[:15]
return subgraph.subgraph(top_nodes)
# 坑3:子图太小丢失关键路径,太大淹没LLM
# 解决:动态调整hops,如果初始结果置信度低,自动扩大采样范围
# 平均子图大小从200节点降至18节点,LLM响应时间从8秒降至1.2秒
3.2 LLM符号推理:把图谱变成"推理草稿"
我不用LLM生成答案,而是让它生成Python代码来遍历图谱,实现可解释推理:
python
# symbolic_reasoning.py
from llama_cpp import Llama
class SymbolicReasoner:
def __init__(self, model_path="llama-2-13b-code.Q4_K_M.gguf"):
self.llm = Llama(
model_path=model_path,
n_gpu_layers=40,
n_ctx=4096,
verbose=False
)
# 定义图谱查询DSL(领域特定语言)
self.dsl_prompt = """
你是一位符号推理引擎。请根据知识图谱和问题,生成Python代码来找出答案。
图谱API:
- kg.get_neighbors(node, relation_type=None) → 获取邻居节点
- kg.get_edge_data(u, v) → 获取边属性
- kg.find_paths(start, end, max_hops=3) → 找所有路径
要求:
1. 代码必须打印推理过程
2. 优先找最短因果链
3. 如果没有直接证据,返回"信息不足"
示例:
问题:挤出机EX-2031-P321压力异常,可能原因是什么?
生成代码:
```
# 1. 找直接原因
direct_causes = kg.get_neighbors('EX-2031-P321', relation_type='caused_by')
print(f"直接原因: {direct_causes}")
# 2. 如果没有直接原因,向上追溯2跳
if not direct_causes:
paths = kg.find_paths('EX-2031-P321', 'root_cause', max_hops=2)
print(f"追溯路径: {paths}")
```
"""
def generate_reasoning_code(self, question: str, entities: list) -> str:
"""
LLM生成推理代码
"""
prompt = f"{self.dsl_prompt}\n\n问题: {question}\n实体: {entities}\n\n生成代码:"
output = self.llm(
prompt,
max_tokens=512,
temperature=0.2,
stop=["```"],
echo=False
)
return output['choices'][0]['text']
def execute_reasoning(self, code: str, kg: nx.Graph, entities: list):
"""
沙箱执行生成的代码(安全隔离)
"""
# 创建受限环境
safe_globals = {
'kg': kg,
'entities': entities,
'print': print
}
try:
# 捕获打印的推理过程
import io
import sys
output = io.StringIO()
sys.stdout = output
exec(code, safe_globals)
sys.stdout = sys.__stdout__
reasoning_process = output.getvalue()
return reasoning_process
except Exception as e:
return f"推理失败: {str(e)}"
# 示例运行
reasoner = SymbolicReasoner()
code = reasoner.generate_reasoning_code(
"挤出机压力异常E301,最可能原因?",
["EX-2031-P321", "E301"]
)
process = reasoner.execute_reasoning(code, kg, entities)
print(process)
输出示例:
直接原因: []
追溯路径:
EX-2031-P321 --[caused_by]--> 滤网堵塞
滤网堵塞 --[results_in]--> 压力异常
根因: 滤网堵塞
置信度: 0.78 (基于历史频次)
然后LLM再做最后一步:把符号推理结果翻译成自然语言
python
# 最终答案生成
def generate_human_answer(symbolic_result: str, kg: nx.Graph):
"""
LLM基于符号推理结果生成可解释答案
"""
prompt = f"""
你是故障诊断专家。根据以下符号推理结果,生成给产线工人的回答。
要求:
1. 用口语化中文,不要术语
2. 说明推理依据
3. 给出具体解决步骤
符号推理结果:
{symbolic_result}
生成回答:
"""
output = self.llm(prompt, max_tokens=256, temperature=0.3)
return output['choices'][0]['text']
# 最终答案:
# "根据系统记录,挤出机P321压力异常最可能是滤网堵塞导致的。
# 因为过去30天里有12次同类报警,9次都是滤网问题。
# 解决步骤:1. 停机;2. 打开侧面盖板;3. 拆下滤网用清水冲洗..."
四、效果对比:从黑盒到白盒
在200个真实故障案例上测试:
| 指标 | 传统专家系统 | 纯LLM | **图谱+符号推理** |
| ---------- | ------- | ----- | ------------ |
| 定位准确率 | 68% | 61% | **92%** |
| 平均响应时间 | 3分钟 | 12秒 | **1.5秒** |
| 可解释性 | 高(规则透明) | 低(黑盒) | **高(推理链可视)** |
| 维护成本(人天/月) | 15 | 2 | **3** |
| 未知故障识别率 | 12% | 78% | **85%** |
典型场景:
-
传统系统:"E301错误,请检查压力"(废话)
-
纯LLM:"可能是电机问题或传感器问题,建议更换"(瞎蒙)
-
本系统:"E301的直接原因是滤网堵塞(置信度0.78),依据是过去30天12次同类报警的关联分析。解决步骤..."(精准+可操作)
五、踩坑实录:血泪换来的经验
坑4:图谱质量差,LLM推理越推越偏
-
现象:初期图谱里有"温度高→产品厚"这种错误关系,LLM沿着它推理出"开空调解决厚度问题"
-
解决 :加入因果校验层,只有时间先后且统计显著的边才保留
python# 因果校验:要求因必须早于果出现,且互信息>阈值 if timestamp_cause >= timestamp_effect or mutual_info < 0.3: kg.remove_edge(u, v)坑5:LLM生成的代码有安全漏洞
-
风险 :如果问题里有
kg.delete_all(),LLM可能真的生成毁灭性代码 -
解决:沙箱执行+白名单API,禁掉所有写操作
pythonsafe_globals = { 'kg': ReadOnlyGraphView(kg), # 只读包装 'print': print }坑6:冷启动时图谱太稀疏,什么都查不到
-
解决:混合检索,图谱找不到时自动fallback到向量RAG,并生成"知识补全"任务
pythonif len(subgraph.nodes()) < 5: # fallback到日志向量检索 docs = vector_retriever.search(question) # 触发异步图谱构建 background_tasks.add_task(build_kg_from_docs, docs)六、下一步:让图谱自己会"进化"
当前系统依赖日志解析规则,下一步:
-
在线学习:用户每次采纳/拒绝建议,自动调整边的权重
-
主动探测:对高频未知问题,自动生成测试用例,主动触发日志收集
-
多模态融合:接入设备振动频谱图,用视觉编码器补充图谱