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:#a5d6a7

Schema检索是链路中最关键但最容易被忽视的环节。一个典型的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示例库。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐