LangChain与数据库的深度集成:Agent驱动的自然语言查询与可视化实践
LangChain与数据库的深度集成:Agent驱动的自然语言查询与可视化实践
一、业务方想用自然语言查数据,SQL写不出来
数据部门的日常困境:运营团队想知道"上周通过微信渠道进来的用户,购买转化率最高的是哪三个品类",这条查询涉及多表JOIN、时间窗口筛选和聚合排序,运营自己写不出SQL,数据开发排期又要三天。更糟糕的是,即使给了SQL,非技术人员也无法理解复杂的查询逻辑,当数据不符合预期时无法判断是SQL写错了还是数据本身有问题。
让LLM将自然语言翻译为SQL(NL2SQL)不是一个新想法,但要在生产环境落地需要解决几个实际问题:数据库Schema的理解(几百张表、几千个列,LLM的上下文窗口装不下全量Schema),查询的安全性(DELETE/DROP等恶意或误操作的禁止),以及结果的准确性验证(LLM可能写出语法正确但语义错误的SQL)。Agent架构是将NL2SQL从demo提升到生产级的关键。
二、Text-to-SQL Agent的架构:Schema理解、查询生成与安全检查
一个生产级的Text-to-SQL Agent需要四个核心组件协同工作:
- Schema检索器:从完整的数据库Schema中筛选出与用户问题相关的表和列,压缩后送入LLM上下文
- SQL生成器:基于筛选后的Schema和用户问题,用Few-Shot Prompting生成SQL
- 安全检查器:对生成的SQL做语法解析和安全规则验证
- 执行与反馈:执行SQL、捕获错误、根据错误信息自动修正
flowchart TD A[用户自然语言问题] --> B[Schema检索器<br/>向量相似度筛选相关表] B --> C[Few-Shot Prompt构造<br/>Schema + 示例SQL + 问题] C --> D[LLM SQL生成] D --> E{安全检查器} E -->|含危险操作| F[拒绝执行<br/>返回错误提示] E -->|安全| G[执行SQL] G --> H{执行成功?} H -->|失败| I[错误分析与SQL修正] I --> D H -->|成功| J[结果格式化与可视化] J --> K[NL摘要 + 图表 + 数据表格] style B fill:#c8e6c9 style D fill:#fff3e0 style E fill:#ffcdd2 style K fill:#a5d6a7Schema检索是链路中最关键但最容易被忽视的环节。一个典型的OLAP数据库可能有500+张表,每张表10-100列,总计数万列。把它们全部塞进LLM的prompt不仅浪费Token,还会让模型在无关信息中"迷路"。Schema检索的一般做法是:为每张表的描述和列注释建立Embedding向量,计算用户问题与各表/列的语义相似度,只选择Top-K最相关的表和列送入LLM。这种方法在实践中能将Schema Token从数万压缩到数百。
三、一个带权限隔离的NL2SQL Agent实现
import re import sqlparse from typing import List, Dict, Optional, Tuple from dataclasses import dataclass import logging logger = logging.getLogger(__name__) @dataclass class TableInfo: name: str description: str columns: List[Dict[str, str]] # [{"name": "id", "type": "int", "comment": "..."}] class SQLSecurityChecker: """SQL安全检查器——防止危险操作和权限越界""" FORBIDDEN_KEYWORDS = [ 'DROP', 'DELETE', 'TRUNCATE', 'ALTER', 'CREATE', 'INSERT', 'UPDATE', 'GRANT', 'REVOKE', 'EXEC', ] # 允许的白名单操作 ALLOWED_KEYWORDS = ['SELECT', 'WITH', 'EXPLAIN'] def __init__(self, max_result_rows: int = 10000, query_timeout_ms: int = 30000): self.max_result_rows = max_result_rows self.query_timeout_ms = query_timeout_ms def validate(self, sql: str, user_tables: List[str]) -> Tuple[bool, str]: """验证SQL的安全性和权限""" try: parsed = sqlparse.parse(sql) if not parsed: return False, "无法解析SQL" statement = parsed[0] stmt_type = statement.get_type() # 检查操作类型 if stmt_type.upper() not in self.ALLOWED_KEYWORDS: return False, f"不允许的操作类型: {stmt_type}" # 检查是否包含禁止关键词 sql_upper = sql.upper() for keyword in self.FORBIDDEN_KEYWORDS: if re.search(r'\b' + keyword + r'\b', sql_upper): return False, f"SQL包含禁止的操作: {keyword}" # 检查表权限:用户只能查询授权表 tables_in_sql = self._extract_tables(sql) for table in tables_in_sql: if table not in user_tables: return False, f"无权访问表: {table}" # 添加资源限制 limited_sql = self._add_resource_limits(sql) return True, limited_sql except Exception as e: logger.error(f"SQL安全检查异常: {e}") return False, f"SQL安全校验失败: {str(e)}" def _extract_tables(self, sql: str) -> List[str]: """从SQL中提取表名""" tables = set() parsed = sqlparse.parse(sql) for statement in parsed: from_seen = False for token in statement.flatten(): if token.ttype is None and token.value.upper() == 'FROM': from_seen = True continue if from_seen and token.ttype is sqlparse.tokens.Name: # 去schema前缀 table_name = token.value.split('.')[-1].strip('`"\'') tables.add(table_name) from_seen = False if token.ttype is None and token.value.upper() == 'JOIN': from_seen = True return list(tables) def _add_resource_limits(self, sql: str) -> str: """为SQL添加资源限制""" sql = sql.rstrip(';').strip() limits = [] if 'LIMIT' not in sql.upper(): limits.append(f"LIMIT {self.max_result_rows}") # 添加查询超时 if 'max_execution_time' not in sql.lower(): limits.insert(0, f"SET max_execution_time={self.query_timeout_ms};") if limits: return '; '.join(limits) + ' ' + sql return sql class NL2SQLAgent: """NL2SQL Agent主控""" def __init__(self, db_schema: List[TableInfo], user_permissions: Dict[str, List[str]]): """ user_permissions: {"user_id": ["table1", "table2"]} """ self.schema = db_schema self.permissions = user_permissions self.security = SQLSecurityChecker() def query(self, user_id: str, question: str) -> Dict: """处理用户的自然语言查询""" # 1. 权限检查 user_tables = self.permissions.get(user_id, []) if not user_tables: return {"error": "无数据查询权限", "sql": None, "data": None} # 2. Schema检索 relevant_tables = self._retrieve_schema(question, user_tables) if not relevant_tables: return {"error": "未找到与问题相关的数据表", "sql": None, "data": None} # 3. 生成SQL(这里简化为调用LLM的占位符) try: schema_text = self._format_schema(relevant_tables) sql = self._generate_sql(schema_text, question) except Exception as e: return {"error": f"SQL生成失败: {str(e)}", "sql": None, "data": None} # 4. 安全检查 is_safe, result = self.security.validate(sql, user_tables) if not is_safe: return {"error": result, "sql": sql, "data": None} # 5. 返回安全的SQL(实际执行需异步处理) return { "sql": result, "original_sql": sql, "tables_used": [t.name for t in relevant_tables], "explanation": f"查询涉及 {len(relevant_tables)} 张表", } def _retrieve_schema(self, question: str, user_tables: List[str]) -> List[TableInfo]: """基于问题检索相关Schema(简化版:全返回授权表)""" return [t for t in self.schema if t.name in user_tables] def _format_schema(self, tables: List[TableInfo]) -> str: """格式化Schema为prompt文本""" lines = [] for table in tables: lines.append(f"## {table.name}: {table.description}") for col in table.columns: lines.append(f" - {col['name']} ({col['type']}): {col.get('comment', '')}") return '\n'.join(lines) def _generate_sql(self, schema_text: str, question: str) -> str: """调用LLM生成SQL(占位实现)""" prompt = f"""根据以下数据库Schema生成SQL查询语句,只返回纯SQL不要解释。 {schema_text} 用户问题: {question} SQL:""" # 实际应调用LLM API logger.info(f"SQL prompt: {prompt[:200]}...") return "SELECT 1" # 占位许可证模型的设计是权限隔离的核心。不是简单的"能查/不能查"的二元控制,而是列级别甚至行级别的精细权限。上述实现支持表级别的权限控制,在安全检查器中提取SQL中引用的所有表并逐一验证。对于金融等高合规场景,还需要增加数据脱敏规则——即使查询返回了敏感列,在输出给用户前做脱敏处理。
四、当SQL生成错误时:自动修正 vs 人工干预的边界在哪里
LLM生成的SQL有三种常见错误模式。语法错误最容易自动修正——将错误信息反馈给LLM做一次重试,大多数情况下能纠正。语义错误(SQL语法正确但逻辑不对,如选了错误的聚合列、JOIN条件遗漏)最难检测,因为SQL能正确执行返回数据,但数据不符合用户预期。性能风险在高频调用场景中尤为危险——一条缺少过滤条件的全表扫描SQL,在百亿级别的大表上可能跑几分钟还跑不完。
自动修正与人工干预的边界应当由置信度和风险等级来决定。对于低风险操作(只读、表小于10万行、执行预估小于1秒),可以自动执行并修正;中风险操作需要用户确认SQL;高风险操作(涉及超过100万行的表、预估执行时间超过10秒)要求用户审核SQL并在确认后才能执行。这个分层策略通过SQL的EXPLAIN结果和实施前代价估算来自动判定风险等级。
五、总结
NL2SQL的生产落地不是简单的"自然语言→LLM→SQL",而是Schema检索、SQL生成、安全检查、权限控制和错误修正五个环节的精密配合。Agent架构将每个环节模块化,支持独立的优化和监控。在当前的技术水平下,简单的过滤聚合查询准确率达到85%-90%,但复杂的多表JOIN和嵌套子查询准确率只有60%-70%。建议从简单查询场景起步,逐步扩展到复杂查询,同时建立用户反馈闭环来持续优化Few-Shot示例库。