工业级 AI问数方案 + 三层安全校验体系完整整合
工业级 AI问数 AST语法 + 知识图谱语义+ 质量规则 三层安全校验 架构设计与实现
上一篇文章,尼恩介绍的工业级AI问数架构中的 LangGraph状态图+真实/虚拟工具的Agent编排框架;
这一篇文章,叠加 工业级三层安全校验体系,包括 语义层+语法+数据质量规则库三层校验,两套能力组合构成生产级Text‑to‑SQL系统。
核心区分:LangGraph Agent负责流程编排、思考循环、工具调度;三层校验负责SQL与结果正确性,校验不是向量检索,向量只是辅助信号。
一、面试题与标准答案:AI 问数拿到数据后,怎么确定是想要的、怎么规范化?
在项目里是靠**语义层校验 + 语法级 校验 + 数据质量规则库(128+ 规则覆盖完整性 / 准确性 / 一致性 / 时效性四大维度)**来做规范化的, 保证数据 准确性。
尼恩团队,为大家设计 了三层校验机制:
-
语法级校验(生成阶段):Text-to-SQL 生成后用 SQL 解析器做 AST 校验,检查关联条件是否合理、聚合维度是否存在、是否遗漏软删除标记 / 数据生效时间等通用过滤条件,语法错误自动修正。
-
逻辑级语义校验(口径对齐):通过指标知识图谱 / 语义层校验查询逻辑是否符合业务口径。比如"销售额"在企业里可能有"含税 / 不含税"、"订单金额 / 实付金额"多种口径,必须和语义编织层对齐后再执行。
-
结果级数据质量校验(数据画像比对):
- **结构化Schema校验:**预先定义当前查询对应的输出数据结构(Pydantic V2约束字段名、数据类型、数值范围、枚举),工具返回数据先做结构化解析,字段缺失/类型错乱直接判定数据无效,触发重试、换工具、反问用户补全条件;
- 数值校验:返回数值是否落在历史区间的合理性范围内(如某门店日销突然 10 亿显然异常)
- 完整性校验:必填字段、时间序列连续性
- 一致性校验:跨表 SUM 是否对得上、占比是否超过 100%
- 向量相似度辅助:把"用户问题 + SQL 查询结果"再做一次语义对齐,相似度低于阈值则触发人工澄清或重规划
兜底策略:多层校验全部不通过时,Domain Agent触发二次查询、修正查询条件,超过重试次数返回人工复核提示。
如果面试官追问"准确率怎么量化":
可以答"通过这三层校验,复杂场景问数准确率从 50% 提升到 85%,建立评测集 200 条人工标注定期回归"。
二、详细介绍 底层原理和实现。
原始文章
基于Langgraph的AI问数 源码分析,以及虚拟工具原理 和执行流程
二、回顾原有 尼恩团队 GraphAgent AI问数 原有能力
- 状态驱动
AgentState,节点拆分:初始化、记忆检索、think思考、get_schema查元数据、generate_sql生成SQL、execute_sql执行SQL、save_memory记忆落库、execute_tools真实工具、finalize收尾。 - 虚拟工具机制 :
query_schema_metadata / generate_sql / execute_current_sql- 只向LLM暴露tool schema,不注册到
tool_registry; _router_analyze_response识别tool_call,路由到独立业务节点;- 每个节点可读写专属状态字段
schema_metadata / generated_sql / sql_result,输出UI组件,执行完回流think,强制交给大模型重新决策。
- 只向LLM暴露tool schema,不注册到
- 记忆:向量记忆仅做Few‑shot案例召回,给LLM参考。
- 防护:
tool_iterations迭代计数器防死循环、ui_queue异步UI解耦。
尼恩团队 原有框架短板:只靠这套,正确率上不去
- LLM生成SQL语法会出错,表关联、where条件、软删除、时间过滤容易漏写;
- 业务口径混乱:同一个指标(销售额)多套定义,LLM仅凭schema和历史案例容易选错口径;
- SQL跑出来结果数值异常、数据缺失、时间断层、汇总对不上,Agent无法识别结果是错的;
- 仅靠向量记忆召回历史正确SQL,属于模仿,不能保证业务逻辑正确,向量匹配只是辅助,不能作为正确性判定依据。
所以项目叠加:语法级校验 + 语义层校验(指标知识图谱) + 128+条数据质量规则库,形成三层校验,嵌入到LangGraph节点链路中。
三、三层校验 如何嵌入 原LangGraph 链路
原链路:
initialize → memory_search → think → generate_sql节点 → execute_sql节点 → save_memory → think
嵌入 原LangGraph 链路 的 位置
- 语法级校验:放在
generate_sql节点内部,SQL生成完成之后、输出SQL卡片之前。 - 语义层(口径/指标知识图谱校验):紧跟语法校验之后,
generate_sql输出之后;其实,这个语义校验,也可以前置到get_schema获取schema阶段,做指标、维度映射。 - 结果 质量 校验:放在
execute_sql执行SQL拿到sql_result之后,save_memory记忆持久化之前。
关键点:
- 校验失败不要直接抛给用户 ,把校验错误信息写入
AgentState.messages消息列表 - 回流
think节点,让大模型重新规划、修正SQL; - 多次重试超限才返回人工复核提示。
链路更新后完整流程
initialize
↓
memory_search(向量记忆,仅做案例召回,不做正确性校验)
↓
think
↓路由
generate_sql【虚拟工具节点】
├─ LLM输出原始SQL
├─👉语法级校验:AST解析,检查语法、关联条件、软删除、时间过滤;语法错误自动修正,失败写入state
├─👉语义层校验:指标知识图谱/语义层对齐口径;识别指标、维度是否匹配业务定义;口径冲突输出校验异常
└─输出SQL UI卡片,写入generated_sql,tool_iterations+1
↓回到think
think判断:校验有无报错
├─校验报错 → 重新生成SQL
└─校验通过 → 调用execute_current_sql虚拟工具,走到execute_sql节点
execute_sql节点
├─执行数据库查询,拿到sql_result
└─👉结果级校验:128+数据质量规则库
·完整性、准确性、一致性、时效性四大维度
·Pydantic结构化schema约束输出字段
·数值区间合理性校验、时间连续性、汇总一致性校验
·向量相似度:【仅辅助信号】用户问题vs结果语义对齐,相似度低标记风险
↓
# 校验结果分支
if 全部校验通过:
→ save_memory节点:把question+sql+result存入向量记忆库
→ 回流think,整理答案返回用户
elif 校验不通过,未超过最大重试次数:
→ 将校验失败原因追加进messages
→ 回流think,Agent二次规划、重写SQL
elif 超过重试阈值:
→ finalize,返回人工复核提示
四、三层校验详细实现说明
1)语法级校验(生成阶段,AST解析)
执行时机:_node_generate_sql,拿到LLM输出SQL之后。使用SQL解析器生成AST抽象语法树,不执行SQL,只做静态分析。
校验点:
- SQL语法合法性;
- join关联条件是否完备,避免笛卡尔积;
- group by聚合维度与select字段匹配;
- 是否遗漏软删除标记、业务生效时间过滤条件;
- 禁止高危语法:drop、alter等。
如何出现错误,则把错误信息丢入messages,交给think节点大模型重写SQL。
注意:这一步不访问数据库,纯静态解析。
2)语义层校验(逻辑/口径对齐,指标知识图谱)
解决:表字段只是物理层,业务指标有业务口径,LLM容易理解错业务语义。
- 执行时机:generate_sql语法校验通过之后。
- 依赖:语义层/指标知识图谱,维护指标、维度、数据表、字段、口径说明映射关系。
例子:
用户问"销售额",知识图谱记录:销售额分含税实付、含税订单金额、不含税;对应不同表、不同字段、不同过滤条件。
校验逻辑:
- 解析AST,提取SQL用到的指标、维度;
- 和指标知识图谱比对:确认使用的字段、过滤条件,是否匹配该指标官方口径;
- 如果检测口径不匹配,输出校验异常,回流think,让Agent修正SQL。
这里的知识图谱语义和向量语义的区别:
- 这里不是拿历史SQL做相似度匹配,是基于业务元数据做逻辑校验, 业务元数据 用知识图谱存储更加 方便 。
- 向量只是memory_search阶段拿来找相似案例给LLM参考,不作为正确性判定。
3)结果级校验(数据质量规则库,128+规则,四大维度)
执行时机:execute_sql拿到查询结果sql_result之后,保存记忆之前。
分为4大类规则:完整性、准确性、一致性、时效性。
- 结构化Schema校验:Pydantic V2约束输出字段名、类型、枚举、是否必填;字段缺失、类型错乱判定无效。
- 完整性校验:必填字段不为空;时间序列数据不能出现大量时间断点。
- 准确性校验:数值合理性,对比历史画像区间;例如门店日销出现10亿,判定异常。
- 一致性校验:汇总求和校验,明细sum和汇总表是否对齐;占比不能大于100%等。
- 时效性校验:数据是否是最新分区,是否查询到过期快照。
- 向量相似度(仅辅助) :用户原始问题 和 SQL返回结果做语义对齐;相似度低仅标记风险,不直接判定错误。
重试&兜底策略
校验失败不是直接返回报错给用户:
- 将校验失败详情写入AgentState的messages消息列表;
- 回到think节点,大模型拿到校验报错,重新规划查询条件,重写SQL;
- 复用已有的
tool_iterations迭代计数器做重试上限控制; - 达到最大迭代次数,进入finalize,返回人工复核提示,而不是返回错误结果。
五、三层校验体系伪代码
嵌入到原有 LangGraph节点:
1.语法校验、语义层校验 → _node_generate_sql 内部
2.结果数据质量校验 → _node_execute_sql执行完SQL之后,save_memory节点之前
校验统一返回结构体:CheckResult,包含是否通过、错误类型、错误描述、是否可自动修复。
校验失败信息写入state["messages"],回流think节点进行重试;只有全部通过才允许落记忆。
第一层:语法级校验 AST静态校验
python
from typing import TypedDict, Optional, List
# 校验结果统一结构体
class CheckResult(TypedDict):
passed: bool # 是否校验通过
error_type: Optional[str] # 错误分类:grammar / semantic / data_quality
message: str # 错误详情,给到LLM做修正参考
can_auto_fix: bool # 是否支持自动修复
fixed_sql: Optional[str] # 修复后的SQL(如果可修复)
# ====================== 第一层:语法级校验 AST静态校验 ======================
class SqlGrammarChecker:
"""语法级校验:AST解析,静态分析,不执行SQL"""
def check(self, raw_sql: str) -> CheckResult:
try:
# 1. SQL解析生成AST抽象语法树
ast_tree = sql_parser.parse(raw_sql)
except SqlParseError as e:
# SQL语法直接报错
return {
"passed": False,
"error_type": "grammar",
"message": f"SQL语法解析失败:{str(e)}",
"can_auto_fix": False,
"fixed_sql": None
}
errors: List[str] = []
can_fix = True
fixed_sql = raw_sql
# 校验1:禁止高危DDL语句 drop / alter / truncate
if ast_tree.has_ddl_risk():
errors.append("禁止执行DDL高危语句")
# 校验2:join关联条件校验,防止笛卡尔积
for join_node in ast_tree.joins:
if not join_node.has_join_condition():
errors.append(f"JOIN缺少关联条件,存在笛卡尔积风险,table:{join_node.table_name}")
# 校验3:group by 与select字段一致性校验
if not ast_tree.check_groupby_consistency():
errors.append("GROUP BY聚合维度与SELECT查询字段不匹配")
# 校验4:检查是否缺失软删除条件 is_deleted=0
table_list = ast_tree.get_all_tables()
for table in table_list:
if table.has_soft_delete_col and not ast_tree.has_soft_delete_filter():
errors.append(f"表{table.table_name}缺失软删除过滤条件 is_deleted=0")
# 简单场景自动追加where条件
if can_fix:
fixed_sql = self._append_soft_delete_filter(fixed_sql, table)
# 校验5:检查业务时间分区条件是否缺失
for table in table_list:
if table.has_partition_col and not ast_tree.has_time_partition_filter():
errors.append(f"表{table.table_name}缺失业务时间分区过滤条件")
if len(errors) == 0:
return {
"passed": True,
"error_type": None,
"message": "语法校验通过",
"can_auto_fix": True,
"fixed_sql": raw_sql
}
else:
return {
"passed": False,
"error_type": "grammar",
"message": ";".join(errors),
"can_auto_fix": can_fix,
"fixed_sql": fixed_sql if can_fix else None
}
第二层:语义层校验(指标知识图谱/口径校验)
# ====================== 第二层:语义层校验(指标知识图谱/口径校验) ======================
class SemanticLayerChecker:
"""
语义层校验:指标知识图谱,口径对齐
不是向量匹配,是元数据逻辑校验
"""
def __init__(self, metric_knowledge_graph):
self.metric_kg = metric_knowledge_graph # 指标知识图谱:指标、维度、物理表、字段、标准过滤条件映射
def check(self, sql_ast, user_question:str) -> CheckResult:
# 从SQL AST提取用到的指标、维度、表、字段、where条件
used_metrics = sql_ast.extract_metrics()
used_dims = sql_ast.extract_dimensions()
used_tables = sql_ast.get_all_tables()
errors = []
for metric_name in used_metrics:
# 查询知识图谱拿到指标官方口径
metric_meta = self.metric_kg.get_metric(metric_name)
if not metric_meta:
errors.append(f"业务指标[{metric_name}]未在指标图谱定义,无法识别口径")
continue
# 1.校验使用字段是否匹配标准口径字段
if not sql_ast.match_metric_column(metric_meta.standard_column):
errors.append(
f"指标【{metric_name}】口径不匹配,标准字段:{metric_meta.standard_column},SQL使用字段:{sql_ast.get_used_column(metric_name)}"
)
# 2.校验该指标必须携带的过滤条件是否齐全(例如销售额必须排除取消订单)
missing_filters = metric_meta.check_mandatory_filters(sql_ast)
if missing_filters:
errors.append(f"指标【{metric_name}】缺失强制过滤条件:{','.join(missing_filters)}")
# 校验维度合法性:维度必须在该指标允许维度集合内
for dim in used_dims:
if not self.metric_kg.is_valid_dimension(dim, used_metrics):
errors.append(f"维度[{dim}]不能与当前查询指标组合使用")
if len(errors) ==0:
return {
"passed": True,
"error_type": None,
"message": "语义口径校验通过",
"can_auto_fix": False,
"fixed_sql": None
}
else:
return {
"passed": False,
"error_type": "semantic",
"message": ";".join(errors),
"can_auto_fix": False,
"fixed_sql": None
}
替代Neo4j 的开源知识图谱 + 分布式图数据库 / 图谱组件 技术选型
基于 NebulaGraph 图数据库,进行 语义层校验(指标知识图谱/口径校验) 的存储
NebulaGraph 图数据库存储 参考案例如下
节点类型:
Metric指标节点 (M001: 销售额_实付含税)PhysicalTable物理表节点 (order_pay)Dimension维度节点 (dt、province)Column物理字段节点 (actual_pay_amount)
关系:
- (Metric)-:BIND_TABLE->(PhysicalTable)
- (Metric)-:USE_COLUMN->(Column)
- (Metric)-:ALLOW_DIM->(Dimension)
- (Metric)-:MANDATORY_WHERE->(Filter{cond:"is_cancel=0"})
第三层:结果级数据质量规则库校验
# ====================== 第三层:结果级数据质量规则库校验(128+规则) ======================
class DataQualityChecker:
"""
数据质量规则库:四大维度:完整性、准确性、一致性、时效性
向量相似度仅做辅助风险标记,不作为判定依据
"""
def __init__(self, rule_set, metric_profile_store):
self.rules = rule_set # 128+数据质量规则集合
self.metric_profile = metric_profile_store # 指标历史画像:数值区间、时间序列基线
self.embedding_model = embedding_service
def check(self, query_result:list[dict], user_question:str, used_metrics:List[str]) -> CheckResult:
errors = []
risk_warns = []
# 1.结构化schema校验 Pydantic V2:字段名、类型、必填、枚举
schema_check = self._pydantic_struct_check(query_result)
if not schema_check.passed:
errors.append(f"结构化校验失败:{schema_check.msg}")
# 2.完整性校验:必填字段非空、时间序列连续性
integrity_ret = self._check_integrity(query_result)
if not integrity_ret.passed:
errors.append(integrity_ret.msg)
#3.准确性校验:数值合理性,对比历史画像区间
accuracy_ret = self._check_value_reasonable(query_result, used_metrics)
if not accuracy_ret.passed:
errors.append(accuracy_ret.msg)
#4.一致性校验:sum汇总校验、占比<=100%,明细与汇总对齐
consistency_ret = self._check_consistency(query_result)
if not consistency_ret.passed:
errors.append(consistency_ret.msg)
#5.时效性校验:检查查询数据分区是否最新
time_ret = self._check_data_timeliness(query_result)
if not time_ret.passed:
errors.append(time_ret.msg)
# =====向量相似度【仅辅助风险标记,不决定是否通过】=====
sim_score = self._calc_semantic_similarity(user_question, query_result)
if sim_score < 0.65:
risk_warns.append(f"语义相似度偏低{sim_score:.2f},结果存在风险,请留意")
full_msg = ";".join(errors + risk_warns)
if len(errors) == 0:
return {
"passed": True,
"error_type": None,
"message": f"数据质量校验通过,风险提示:{';'.join(risk_warns)}",
"can_auto_fix": False,
"fixed_sql": None
}
else:
return {
"passed": False,
"error_type": "data_quality",
"message": full_msg,
"can_auto_fix": False,
"fixed_sql": None
}
可以用 mysql 表 dq_rule 存储规则
| rule_id | rule_type | rule_desc | apply_metric_ids | rule_params_json | severity | is_enable |
|---|---|---|---|---|---|---|
| R001 | integrity | 时间序列不能连续 3 天为空 | "M001","M002" | {"max_continue_null":3} |
error | 1 |
| R002 | accuracy | 指标不能超过历史 99 分位 3 倍 | "M001" | {"multiplier":3,"percentile":99} |
error | 1 |
| R003 | consistency | 占比字段不能大于 100 | "M005 毛利率" | {"max_value":100} |
error | 1 |
| R004 | consistency | 明细 sum 和汇总表差值不能超过 5% | "M001" | {"tolerance_rate":0.05} |
error | 1 |
| R005 | timeliness | 数据分区必须是 T‑1,不能查询 7 天前旧分区 | "M001" | {"max_delay_day":7} |
warn | 1 |
| R006 | schema | 返回结果必须包含 dt、shop_id 字段 | "M001" | {"required_fields":["dt","shop_id"]} |
error | 1 |
把三层校验嵌入LangGraph节点伪代码
1.嵌入 _node_generate_sql 节点
python
async def _node_generate_sql(self, state: AgentState) -> PartialAgentState:
# ---省略LLM生成原始sql逻辑---
raw_sql: str = llm_output_sql
# =========第一层:语法校验=========
grammar_check_result = self.grammar_checker.check(raw_sql)
if not grammar_check_result["passed"]:
# 如果可以自动修复,使用修复后的SQL
if grammar_check_result["can_auto_fix"] and grammar_check_result["fixed_sql"]:
raw_sql = grammar_check_result["fixed_sql"]
else:
# 校验失败,把错误信息追加消息,回流think,交给大模型重试
err_msg = f"【语法校验失败】{grammar_check_result['message']}"
state["messages"].append(LlmMessage(role="user", content=err_msg))
return {
"tool_iterations": state["tool_iterations"] + 1
}
# 拿到AST给语义校验使用
sql_ast = sql_parser.parse(raw_sql)
# =========第二层:语义层口径校验=========
semantic_check_result = self.semantic_checker.check(sql_ast, state["message"])
if not semantic_check_result["passed"]:
err_msg = f"【语义口径校验失败】{semantic_check_result['message']}"
state["messages"].append(LlmMessage(role="user", content=err_msg))
return {
"tool_iterations": state["tool_iterations"] +1
}
# 校验全部通过,写入状态,输出UI卡片
await ui_queue.put(UiComponent(sql_card=raw_sql))
return {
"generated_sql": raw_sql,
"tool_iterations": state["tool_iterations"] + 1
}
2.嵌入 _node_execute_sql 节点(执行SQL之后,save_memory之前)
python
async def _node_execute_sql(self, state: AgentState) -> PartialAgentState:
generated_sql = state["generated_sql"]
# 执行SQL,拿到结果
exec_result = await self._run_sql_tool_execute(state, generated_sql)
sql_result = exec_result.result_for_llm
query_data_rows = exec_result.metadata.get("results", [])
# =========第三层:数据质量规则校验=========
used_metrics = sql_parser.parse(generated_sql).extract_metrics()
dq_check_result = self.dq_checker.check(query_data_rows, state["message"], used_metrics)
# 将校验结果写入messages,给后续think节点可见
state["messages"].append(LlmMessage(role="user", content=f"【数据质量校验结果】{dq_check_result['message']}"))
# 将校验标记存入state,给save_memory节点读取
state["dq_check_passed"] = dq_check_result["passed"]
return {
"sql_result": sql_result,
"dq_check_passed": dq_check_result["passed"],
"tool_iterations": state["tool_iterations"] + 1
}
3.修改 save_memory节点:只有三层全部通过,才存入向量记忆
python
async def _node_save_memory(self, state: AgentState) -> PartialAgentState:
# 关键:三层校验全部通过,才保存样本,防止脏数据污染向量记忆库
if not state.get("dq_check_passed"):
logger.info("校验未通过,跳过记忆保存,不写入向量库")
return {}
# 执行保存 question+sql+result 到agent_memory向量记忆
save_tool = await self.tool_registry.get_tool("save_question_tool_args")
# ...省略保存逻辑
return {}
完整重试逻辑依靠原有LangGraph能力
- 校验失败,校验错误信息写入
messages,节点返回,graph边自动回流think节点; tool_iterations计数器自动+1;- 进入
_router_check_limit路由,如果没超过最大迭代次数,继续循环; - 如果迭代超限,路由走到
finalize,返回人工复核提示。
没有新增额外循环代码,复用Agent原有迭代控制能力。
六、记忆save_memory节点的变化
只有语法校验 +语义口径校验 +数据质量校验 全部通过,才会把(用户问题,SQL,执行结果)存入向量记忆库。
避免脏数据入库:错误的SQL不要存进记忆,防止后续向量召回把错误案例给LLM参考,越学越错。
面试回答话术(面试官提问专用)
Q:你们Text‑to‑SQL怎么做结果正确性保证?向量召回准确率高吗?
答:
我们没有把正确性寄托在向量匹配,向量记忆只是辅助信号,用来召回历史相似案例做Few‑shot参考。真正保障正确性靠三层校验,嵌入LangGraph Agent流程中:
- 语法级校验:SQL生成后做AST静态解析,校验语法、关联条件、软删除、时间过滤,能自动修复就修复,不能修复返回错误信息给Agent重写。
- 语义层校验:基于指标知识图谱做口径对齐,解决同一个指标多套业务定义的问题,核对SQL里面用到的指标、维度、过滤条件是否匹配业务口径,避免LLM选错字段。
- 结果层数据质量规则库,128+规则覆盖完整性、准确性、一致性、时效性;包含Pydantic结构化约束、数值区间画像校验、时间连续性、跨表汇总一致性校验;向量相似度仅作为风险辅助标记。
校验失败后不会直接抛给用户,把校验异常信息丢回消息上下文,回到think节点,Agent自动重试修正SQL;超过迭代上限,返回人工复核提示。
经过这套体系,复杂业务场景Text‑to‑SQL准确率从50%提升到85%;我们维护200条人工标注评测集,持续回归迭代。
Q:三层校验在LangGraph架构中放在哪里?
答:
语法校验、语义层校验放在generate_sql虚拟工具节点,SQL生成完成后执行;
数据质量结果校验放在execute_sql执行完成之后、save_memory记忆落库之前。
校验失败把错误写入state.messages,回流think节点复用Agent原有迭代循环做重试;
并且只有三层校验全部通过,才会把样本存入向量记忆库,防止错误案例污染记忆。