系列第三篇:前面我们完成 Text‑to‑SQL 基础搭建、复杂多表关联查询数据库设计,本篇聚焦工程化架构。很多同学跑通 Demo 之后直接上线,结果遇到安全漏洞、大模型幻觉、接口报错、前端交互混乱等一堆线上问题。本文站在后端工程师视角,拆解一套可部署上线的 Text‑to‑SQL 完整系统,包含分层架构、代码实现、安全防护、性能调优、未来扩展思路。

前言

最近在做内部智能问数平台,调研和实现了一版 Text‑to‑SQL 系统。相信很多小伙伴和我一样,跟着教程跑通 Demo 时感觉一切美好:输入一句自然语言,大模型唰的一下吐出 SQL,数据库返回结果,页面展示数据,整个流程行云流水。

但是一旦往生产环境迁移,坑就接踵而至:

  1. 大模型时不时返回DROPALTER这类危险语句,一旦执行直接事故;
  2. AI 输出格式乱七八糟,带 markdown 代码块、多余前缀注释,直接丢给数据库直接报语法错误;
  3. 单表查询、多表关联查询混在同一个接口,业务逻辑耦合严重,后续维护改不动;
  4. 没有统一异常处理,LLM 调用超时、数据库报错直接抛出堆栈到前端页面;
  5. 没有缓存,相同业务问题反复调用大模型 API,Token 成本暴涨,接口响应慢;
  6. 前端简陋,没有示例引导,业务人员不知道该怎么提问,使用门槛很高CSDN博...。

Demo 可以只关注功能实现,生产系统必须兼顾可维护、安全、可观测、性能、用户体验

这套系统采用 SpringBoot + Thymeleaf 前端 + 硅基流动大模型 API + PostgreSQL,严格遵循 MVC 分层思想,把请求接收、业务编排、大模型调用、数据库执行、安全校验做模块隔离。本篇会把整体架构、核心源码、配置、踩坑优化全部分享出来。

一、系统架构概览

整套系统遵循标准后端分层思想:前端展示层 → Controller 控制层 → Service 业务层 → 数据访问层,外部对接大模型 API 与 PostgreSQL 数据库。为了区分简单查询和复杂关联查询,我们把单表检索、关联检索做接口与代码隔离,两种场景使用完全不同的 Prompt,提升 SQL 生成准确率。

1.1 整体架构图

简单梳理完整请求链路:

用户打开网页输入自然语言问题 → 前端 fetch 发送 POST 请求 → Controller 接收,做基础参数校验 → 调用Text2SqlService → 调用LlmService,传入对应场景的 System Prompt,请求大模型 API 拿到原始 SQL → 清洗 AI 返回的多余 markdown 标记 → 执行 SQL 安全校验,拦截危险操作 → JdbcTemplate 执行只读查询,捕获全部异常 → 组装统一格式返回给前端 → 前端渲染生成的 SQL 语句和表格数据。

1.2 核心模块职责

模块 职责 关键类 / 文件
前端展示层 页面渲染、用户交互、示例引导、结果表格渲染、复制 SQL index.html, single.html, join.html, chat.html
后端控制层 页面路由跳转、接收 AJAX 请求、基础参数非空校验、统一响应封装 SingleQueryController, JoinQueryController
业务服务层 业务流程编排、调用大模型、SQL 清洗、安全校验、异常捕获、结果组装;独立封装大模型 API 调用 Text2SqlService, LlmService
数据访问层 动态 SQL 执行、数据库交互,返回结构化数据集 JdbcTemplate
外部依赖 大模型推理能力、业务数据存储 硅基流动 API, PostgreSQL

设计思考:为什么要把单表和关联查询分开两套 Prompt 和接口? 如果把全部三张表结构一股脑全部丢给大模型做单表查询,上下文 Token 变多,模型容易混淆表之间字段,出现幻觉,明明只查 member 表,却莫名其妙带上 JOIN 关联其他表。分开之后,单表场景只传入 member 表结构,关联场景传入三张表 + 关联关系,针对性 Prompt,有效提升 SQL 准确率,降低上下文长度,减少 API 成本。

二、后端分层实现

2.1 控制器层(Controller)

Controller 层只做三件事:页面跳转路由、接收 http 请求、基础参数校验,不处理复杂业务逻辑,业务全部下沉到 Service 层。这是 SpringMVC 最佳实践,避免 Controller 写大量业务代码,后续单元测试、接口复用都会很麻烦。

单表检索控制器
/**
 * 单表检索控制器
 * 
 * 职责:
 * - 提供单表检索页面路由
 * - 接收前端AJAX请求
 * - 参数校验和响应封装
 */
@Controller
@RequestMapping("/single")
public class SingleQueryController {
    private final Text2SqlService text2SqlService;
    public SingleQueryController(Text2SqlService text2SqlService) {
        this.text2SqlService = text2SqlService;
    }
    /**
     * 页面路由 - 渲染单表检索界面
     */
    @GetMapping
    public String singlePage(Model model) {
        return "single";  // 返回Thymeleaf模板名称
    }
    /**
     * API接口 - 处理单表查询请求
     * 
     * 请求体: { "question": "查询所有金卡会员" }
     * 响应体: { "success": true, "data": [...], "sql": "..." }
     */
    @PostMapping("/api/query")
    @ResponseBody
    public Map<String, Object> query(@RequestBody Map<String, String> request) {
        String question = request.get("question");
        
        // 参数校验
        if (question == null || question.trim().isEmpty()) {
            return Map.of("success", false, "error", "问题不能为空");
        }
        
        // 委托给Service层处理
        return text2SqlService.querySingle(question);
    }
}
关联检索控制器
/**
 * 关联检索控制器
 * 
 * 职责:
 * - 提供关联检索页面路由
 * - 接收前端AJAX请求
 * - 参数校验和响应封装
 */
@Controller
@RequestMapping("/join")
public class JoinQueryController {
    private final Text2SqlService text2SqlService;
    public JoinQueryController(Text2SqlService text2SqlService) {
        this.text2SqlService = text2SqlService;
    }
    /**
     * 页面路由 - 渲染关联检索界面
     */
    @GetMapping
    public String joinPage(Model model) {
        return "join";
    }
    /**
     * API接口 - 处理关联查询请求
     */
    @PostMapping("/api/query")
    @ResponseBody
    public Map<String, Object> query(@RequestBody Map<String, String> request) {
        String question = request.get("question");
        
        if (question == null || question.trim().isEmpty()) {
            return Map.of("success", false, "error", "问题不能为空");
        }
        
        return text2SqlService.queryJoin(question);
    }
}

设计要点总结:

  1. 单一职责原则:每个 Controller 只负责一类查询,后续如果要对关联查询做权限拦截,只需要修改 JoinQueryController;
  2. 页面渲染和 API 接口共存:@GetMapping返回页面,@PostMapping+@ResponseBody返回 JSON;
  3. Controller 只做简单非空校验,复杂业务校验、SQL 安全校验交给 Service;
  4. 统一 JSON 返回格式,前端只需要一套逻辑处理成功 / 失败场景。

2.2 业务服务层(Service)

Service 层是整个 Text‑to‑SQL 的核心编排层,这里串联整个业务链路:分发查询类型、调用大模型、清洗 AI 输出、安全校验、执行 SQL、捕获异常、组装返回结果。

Text2SqlService - 核心业务编排
/**
 * Text-to-SQL核心业务服务
 * 
 * 职责:
 * - 协调LLM服务和数据库操作
 * - SQL安全校验
 * - 结果格式化封装
 */
@Service
public class Text2SqlService {
    private final LlmService llmService;
    private final JdbcTemplate jdbcTemplate;
    private final ObjectMapper objectMapper;
    // 构造器注入
    public Text2SqlService(LlmService llmService, 
                          JdbcTemplate jdbcTemplate, 
                          ObjectMapper objectMapper) {
        this.llmService = llmService;
        this.jdbcTemplate = jdbcTemplate;
        this.objectMapper = objectMapper;
    }
    /**
     * 单表查询入口
     */
    public Map<String, Object> querySingle(String question) {
        return executeQuery(question, QueryType.SINGLE);
    }
    /**
     * 关联查询入口
     */
    public Map<String, Object> queryJoin(String question) {
        return executeQuery(question, QueryType.JOIN);
    }
    /**
     * 通用查询执行逻辑
     * 
     * 流程:
     * 1. 调用LLM生成SQL
     * 2. 清理SQL格式
     * 3. 安全校验
     * 4. 执行查询
     * 5. 封装结果
     */
    private Map<String, Object> executeQuery(String question, QueryType type) {
        Map<String, Object> result = new HashMap<>();
        result.put("question", question);
        result.put("queryType", type.name().toLowerCase());
        try {
            // Step 1: 调用LLM生成SQL
            String sql;
            if (type == QueryType.SINGLE) {
                sql = llmService.textToSqlSingle(question);
            } else {
                sql = llmService.textToSqlJoin(question);
            }
            result.put("generatedSql", sql);
            // 检查LLM返回错误
            if (sql.startsWith("ERROR")) {
                result.put("success", false);
                result.put("error", sql);
                return result;
            }
            // Step 2: 清理SQL格式
            sql = cleanSql(sql);
            result.put("cleanedSql", sql);
            // Step 3: 安全校验
            if (!isSelectOnly(sql)) {
                result.put("success", false);
                result.put("error", "安全限制:只允许执行SELECT查询");
                return result;
            }
            // Step 4: 执行查询
            List<Map<String, Object>> data = jdbcTemplate.queryForList(sql);
            result.put("success", true);
            result.put("data", data);
            result.put("count", data.size());
        } catch (Exception e) {
            result.put("success", false);
            result.put("error", "SQL执行错误:" + e.getMessage());
        }
        return result;
    }
    /**
     * SQL清理 - 去除AI可能返回的格式标记
     * 大模型经常输出```sql ... ```markdown代码块,必须清洗,否则数据库执行报错
     */
    private String cleanSql(String sql) {
        if (sql == null) return "";
        sql = sql.replaceAll("```sql", "").replaceAll("```", "");
        sql = sql.trim();
        if (sql.toLowerCase().startsWith("sql:")) {
            sql = sql.substring(4).trim();
        }
        return sql;
    }
    /**
     * 安全校验 - 防止SQL注入
     * 只允许SELECT和WITH开头的查询,WITH对应CTE递归查询语法
     */
    private boolean isSelectOnly(String sql) {
        String upper = sql.toUpperCase().trim();
        return upper.startsWith("SELECT") || upper.startsWith("WITH");
    }
    // 辅助方法:获取会员、订单、明细列表
    public List<Map<String, Object>> getAllMembers() { ... }
    public List<Map<String, Object>> getAllOrders() { ... }
    public List<Map<String, Object>> getAllOrderItems() { ... }
}
/**
 * 查询类型枚举
 */
enum QueryType {
    SINGLE,  // 单表查询
    JOIN     // 关联查询
}

这里有一个非常容易踩坑点:大模型输出不会永远是干净的 SQL,经常附带 markdown 标记、注释、前缀文字。如果不做 cleanSql 清洗,直接交给 JdbcTemplate 执行,直接抛出 SQL 语法异常,这是很多 Demo 上线之后高频报错点

LlmService - AI 模型服务封装

把所有和大模型 API 交互的逻辑全部封装到LlmService,做到解耦。未来如果想要切换模型,比如换成本地部署开源模型,只需要修改这个类,上层业务代码几乎不用改动,符合适配器模式。

/**
 * 大语言模型服务
 * 
 * 职责:
 * - 封装与硅基流动API的交互
 * - 管理系统提示词
 * - 解析API响应、捕获网络异常
 */
@Service
public class LlmService {
    @Value("${siliconflow.api.url}")
    private String apiUrl;
    @Value("${siliconflow.api.key}")
    private String apiKey;
    @Value("${siliconflow.api.model}")
    private String model;
    private final ObjectMapper objectMapper;
    /**
     * 单表查询 - 仅提供member表结构
     */
    public String textToSqlSingle(String question) {
        String systemPrompt = buildSinglePrompt();
        return callApi(question, systemPrompt);
    }
    /**
     * 关联查询 - 提供三表结构和关联关系
     */
    public String textToSqlJoin(String question) {
        String systemPrompt = buildJoinPrompt();
        return callApi(question, systemPrompt);
    }
    /**
     * 构建单表查询提示词
     */
    private String buildSinglePrompt() {
        return """
                你是SQL生成助手。根据以下member表结构,生成单表查询SQL:
                
                表:member(会员信息表)
                - id: 会员ID
                - name: 会员姓名
                - level: 等级(普通/银卡/金卡/钻石)
                - points: 积分
                - status: 状态(活跃/冻结/注销)
                
                要求:
                1. 只返回SQL语句
                2. 只能使用member表
                3. 中文值用单引号
                
                示例:查询金卡会员
                SELECT * FROM member WHERE level = '金卡会员'
                """;
    }
    /**
     * 构建关联查询提示词
     */
    private String buildJoinPrompt() {
        return """
                你是SQL生成助手。根据以下表结构和关联关系,生成查询SQL:
                
                表1:member(会员表)- id, name, level
                表2:orders(订单表)- id, member_id, total_amount, status
                表3:order_item(明细表)- id, order_id, product_name
                
                关联关系:
                - orders.member_id = member.id
                - order_item.order_id = orders.id
                
                要求:
                1. 只返回SQL语句
                2. 使用JOIN关联
                3. 使用表别名
                
                示例:查询张三的订单
                SELECT o.* FROM orders o 
                JOIN member m ON o.member_id = m.id 
                WHERE m.name = '张三'
                """;
    }
    /**
     * 通用API调用方法
     */
    private String callApi(String question, String systemPrompt) {
        try {
            // 构建请求
            Map<String, Object> body = new HashMap<>();
            body.put("model", model);
            body.put("messages", List.of(
                Map.of("role", "system", "content", systemPrompt),
                Map.of("role", "user", "content", question)
            ));
            body.put("max_tokens", 512);
            // temperature设置0.1,降低随机性,SQL生成任务不适合高温度
            body.put("temperature", 0.1);
            String json = objectMapper.writeValueAsString(body);
            // HTTP请求
            HttpClient client = HttpClient.newHttpClient();
            HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(apiUrl))
                .header("Authorization", "Bearer " + apiKey)
                .header("Content-Type", "application/json")
                .timeout(Duration.ofSeconds(60))
                .POST(HttpRequest.BodyPublishers.ofString(json))
                .build();
            HttpResponse<String> response = client.send(request, 
                HttpResponse.BodyHandlers.ofString());
            // 解析响应
            if (response.statusCode() == 200) {
                JsonNode root = objectMapper.readTree(response.body());
                return root.get("choices").get(0)
                    .get("message").get("content").asText().trim();
            }
            return "ERROR:API调用失败";
        } catch (Exception e) {
            return "ERROR:" + e.getMessage();
        }
    }
}

提示词小技巧:SQL 生成任务,temperature不要设置太高,推荐 0.1‑0.2,降低大模型随机创造性,尽量输出稳定可执行 SQL。同时 Few‑shot 示例一定要写在 Prompt 内部,给模型参考输出格式,显著降低幻觉概率CSDN博...。

三、前端页面实现

本项目前端使用 Thymeleaf 模板 + 原生 JS 实现,不需要 Vue、React 这类重型前端框架,快速开发原型工具。整套系统 4 个页面:

页面 路由 模板文件 功能说明
数据总览 / index.html 展示会员、订单、明细数据
单表检索 /single single.html 会员表单表查询
关联检索 /join join.html 三表关联查询
智能问答 /chat chat.html 通用问答(默认关联查询)

前端的设计目标不仅仅是把请求发出去,更要照顾业务用户体验:

  1. 直观展示当前场景下数据表关系,让用户知道可以查哪些内容;
  2. 内置示例问题,点击直接填充输入框并且发起查询,降低使用门槛;
  3. 展示大模型生成后的清洗完成 SQL,方便开发人员排查问题,业务人员也可以看到背后执行逻辑;
  4. 动态渲染查询结果表格,支持一键复制 SQL;
  5. 区分两套主题配色,单表绿色渐变,关联查询粉色渐变,视觉上区分两种查询模式。

3.2 关联检索页面实现

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <title>关联检索 - Text-to-SQL</title>
    <style>
        /* 页面背景 - 粉色渐变 */
        body {
            background: linear-gradient(-135deg, #f093fb 0%, #f5576c 100%);
            min-height: 100vh;
        }
        
        /* 导航栏样式 */
        .nav a.active {
            background: linear-gradient(135deg, #f5576c, #f093fb);
            color: white;
        }
        
        /* 卡片立体效果 */
        .card {
            background: rgba(255, 255, 255, 0.95);
            border-radius: 20px;
            box-shadow: 
                0 20px 60px rgba(0, 0, 0, 0.15),
                inset 0 1px 0 rgba(255, 255, 255, 0.4);
        }
    </style>
</head>
<body>
    <div class="container">
        <!-- 页面标题 -->
        <div class="header">
            <h1>🔗 关联检索</h1>
            <p>支持会员、订单、商品多表关联查询</p>
        </div>
        <!-- 导航栏 -->
        <div class="nav">
            <a href="/">📊 数据总览</a>
            <a href="/single">📋 单表检索</a>
            <a href="/join" class="active">🔗 关联检索</a>
            <a href="/chat">💬 智能问答</a>
        </div>
        <!-- 主查询卡片 -->
        <div class="card">
            <!-- 表关系图示 -->
            <div class="schema-diagram">
                <h3>📐 数据表关系</h3>
                <div class="tables">
                    <div class="table-member">member (会员表)</div>
                    <div class="arrow">1:N →</div>
                    <div class="table-orders">orders (订单表)</div>
                    <div class="arrow">1:N →</div>
                    <div class="table-item">order_item (明细表)</div>
                </div>
            </div>
            <!-- 问题输入区 -->
            <div class="input-section">
                <textarea id="questionInput" 
                          placeholder="例如:查询张三的所有已完成订单"></textarea>
                <button onclick="submitQuery()">🚀 智能查询</button>
            </div>
            <!-- 示例问题 -->
            <div class="examples">
                <h4>💡 试试这些问题:</h4>
                <div class="example-list">
                    <span onclick="useExample(this)">查询张三的所有订单</span>
                    <span onclick="useExample(this)">统计每个会员的消费总额</span>
                    <span onclick="useExample(this)">查询数码类商品的订单</span>
                </div>
            </div>
            <!-- SQL展示区 -->
            <div id="sqlResult" class="sql-box" style="display:none;">
                <h4>🔍 生成的SQL语句</h4>
                <pre id="sqlCode"></pre>
                <button onclick="copySql()">📋 复制SQL</button>
            </div>
            <!-- 结果展示区 -->
            <div id="queryResult" class="result-box" style="display:none;">
                <h4>📊 查询结果</h4>
                <div id="resultTable"></div>
            </div>
        </div>
    </div>
    <script>
        // AJAX提交查询
        async function submitQuery() {
            const question = document.getElementById('questionInput').value.trim();
            if (!question) return;
            try {
                const response = await fetch('/join/api/query', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ question })
                });
                const data = await response.json();
                displayResult(data);
            } catch (error) {
                alert('查询失败:' + error.message);
            }
        }
        // 展示查询结果
        function displayResult(data) {
            if (!data.success) {
                alert('错误:' + data.error);
                return;
            }
            // 展示SQL
            document.getElementById('sqlResult').style.display = 'block';
            document.getElementById('sqlCode').textContent = data.cleanedSql;
            // 展示数据表格
            document.getElementById('queryResult').style.display = 'block';
            renderTable(data.data);
        }
        // 动态渲染表格
        function renderTable(data) {
            const container = document.getElementById('resultTable');
            if (!data || data.length === 0) {
                container.innerHTML = '<p>暂无数据</p>';
                return;
            }
            // 获取列名
            const columns = Object.keys(data[0]);
            
            // 构建HTML表格
            let html = '<table><thead><tr>';
            columns.forEach(col => {
                html += `<th>${col}</th>`;
            });
            html += '</tr></thead><tbody>';
            data.forEach(row => {
                html += '<tr>';
                columns.forEach(col => {
                    html += `<td>${row[col] ?? ''}</td>`;
                });
                html += '</tr>';
            });
            html += '</tbody></table>';
            
            container.innerHTML = html;
        }
        // 使用示例问题
        function useExample(el) {
            document.getElementById('questionInput').value = el.textContent;
            submitQuery();
        }
        // 复制SQL
        function copySql() {
            const sql = document.getElementById('sqlCode').textContent;
            navigator.clipboard.writeText(sql);
            alert('SQL已复制!');
        }
    </script>
</body>
</html>

单表页面逻辑大体一致,只是更换主题配色、只展示 member 单张表、示例问题换成单表相关提问,接口请求地址改为/single/api/query,这里就不再重复贴完整代码。

四、配置与安全策略

⚠️ 安全是 Text‑to‑SQL 系统重中之重!千万不要直接把大模型输出的 SQL 直接丢给数据库执行! 大模型会受提示词注入攻击,可能生成DROP TABLEDELETE FROM等高危语句,如果数据库账号权限过大,直接会造成数据灾难。很多网上 Demo 完全忽略安全校验,只能本地玩,绝对不能直接上线。

4.1 应用配置 application.yml

# application.yml
server:
  port: 8080
spring:
  application:
    name: text2sql
  datasource:
    url: jdbc:postgresql://localhost:5432/text2sql_db
    username: postgres
    password: your_password
    driver-class-name: org.postgresql.Driver
  sql:
    init:
      mode: always
      schema-locations: 
        - classpath:schema.sql
        - classpath:schema_order.sql
      data-locations:
        - classpath:data.sql
        - classpath:data_order.sql
# 硅基流动AI配置
siliconflow:
  api:
    url: https://api.siliconflow.cn/v1/chat/completions
    key: ${SILICONFLOW_API_KEY}
    model: Qwen/Qwen2.5-7B-Instruct

生产环境建议 API Key 使用环境变量注入,不要硬编码写死在配置文件,防止密钥泄露。数据库账号尽量使用只读账号,即使安全校验被绕过,也无法修改、删除业务数据,这是最后一道防线。

4.2 SQL 安全校验器

这里独立抽离一个SqlSecurityValidator组件,多层防护:白名单校验开头语句、黑名单拦截危险关键字、SQL 长度限制。注意简单判断字符串包含关键字有局限(注释绕过等),更严谨的生产方案可以引入JSQLParser做 SQL 语法 AST 解析,真正解析 SQL 抽象语法树判断操作类型。

/**
 * SQL安全校验器
 * 
 * 多层安全防护:
 * 1. 白名单验证 - 只允许SELECT/WITH开头
 * 2. 关键字过滤 - 禁止DROP、DELETE、UPDATE
 * 3. 长度限制 - 防止超长SQL攻击
 */
@Component
public class SqlSecurityValidator {
    private static final Set<String> DANGEROUS_KEYWORDS = Set.of(
        "DROP", "DELETE", "UPDATE", "INSERT", 
        "ALTER", "CREATE", "TRUNCATE", "GRANT", "EXEC"
    );
    /**
     * 验证SQL安全性
     */
    public ValidationResult validate(String sql) {
        // 1. 非空检查
        if (sql == null || sql.trim().isEmpty()) {
            return ValidationResult.fail("SQL语句不能为空");
        }
        // 2. 长度限制
        if (sql.length() > 10000) {
            return ValidationResult.fail("SQL语句过长");
        }
        // 3. 只允许SELECT查询
        String upperSql = sql.toUpperCase().trim();
        if (!upperSql.startsWith("SELECT") && !upperSql.startsWith("WITH")) {
            return ValidationResult.fail("只允许SELECT查询语句");
        }
        // 4. 危险关键字检查
        for (String keyword : DANGEROUS_KEYWORDS) {
            if (upperSql.contains(keyword)) {
                return ValidationResult.fail("SQL中包含危险操作");
            }
        }
        return ValidationResult.success();
    }
}
@Data
class ValidationResult {
    private boolean valid;
    private String message;
    
    public static ValidationResult success() {
        return new ValidationResult(true, "验证通过");
    }
    
    public static ValidationResult fail(String msg) {
        return new ValidationResult(false, msg);
    }
}

4.3 全局异常处理

如果没有全局异常处理器,当 LLM 调用超时、数据库报错,会直接把 Java 堆栈抛给前端,用户体验差,还泄露内部代码信息。增加@RestControllerAdvice统一捕获异常,返回标准化 JSON 错误信息。

/**
 * 全局异常处理器
 */
@RestControllerAdvice
public class GlobalExceptionHandler {
    /**
     * 处理LLM调用异常
     */
    @ExceptionHandler(LlmServiceException.class)
    public Map<String, Object> handleLlmException(LlmServiceException e) {
        return Map.of(
            "success", false,
            "error", "AI服务异常:" + e.getMessage()
        );
    }
    /**
     * 处理SQL执行异常
     */
    @ExceptionHandler(SqlExecutionException.class)
    public Map<String, Object> handleSqlException(SqlExecutionException e) {
        return Map.of(
            "success", false,
            "error", "数据库错误:" + e.getMessage()
        );
    }
    /**
     * 处理通用异常
     */
    @ExceptionHandler(Exception.class)
    public Map<String, Object> handleException(Exception e) {
        return Map.of(
            "success", false,
            "error", "系统异常:" + e.getMessage()
        );
    }
}

五、系统集成与部署

5.1 完整项目结构

text2sql/
├── src/main/
│   ├── java/com/example/text2sql/
│   │   ├── controller/
│   │   │   ├── SingleQueryController.java
│   │   │   ├── JoinQueryController.java
│   │   │   └── DataOverviewController.java
│   │   ├── service/
│   │   │   ├── Text2SqlService.java
│   │   │   └── LlmService.java
│   │   ├── config/
│   │   │   └── WebConfig.java
│   │   ├── security/
│   │   │   └── SqlSecurityValidator.java
│   │   └── Text2SqlApplication.java
│   └── resources/
│       ├── templates/
│       │   ├── index.html      # 数据总览
│       │   ├── single.html     # 单表检索
│       │   ├── join.html       # 关联检索
│       │   └── chat.html       # 智能问答
│       ├── schema.sql          # 会员表结构
│       ├── schema_order.sql    # 订单表结构
│       ├── data.sql            # 会员数据
│       ├── data_order.sql      # 订单数据
│       └── application.yml
└── pom.xml

5.2 启动与测试命令

# 1. 克隆项目
git clone https://github.com/your-repo/text2sql.git
# 2. 配置环境变量,设置大模型密钥,不要硬编码
export SILICONFLOW_API_KEY=your_api_key
# 3. 构建项目
cd text2sql
mvn clean package
# 4. 运行应用
java -jar target/text2sql-1.0.0.jar
# 5. 访问页面
# 数据总览:http://localhost:8080/
# 单表检索:http://localhost:8080/single
# 关联检索:http://localhost:8080/join
# 智能问答:http://localhost:8080/chat

六、性能优化建议

这套基础架构跑 Demo 没问题,一旦并发上来,就会遇到大模型接口慢、数据库查询慢、重复提问消耗大量 Token 等问题,下面从数据库、应用层、AI 模型三个维度给出优化方案。

6.1 数据库层面优化

  • 索引优化:针对 JOIN 关联字段、高频过滤条件建立索引,避免全表扫描。
-- 为常用JOIN字段创建索引
CREATE INDEX idx_orders_member_id ON orders(member_id);
CREATE INDEX idx_order_item_order_id ON order_item(order_id);
-- 为常用WHERE条件创建索引
CREATE INDEX idx_member_level ON member(level);
CREATE INDEX idx_orders_status ON orders(status);
  • 业务上尽量避免SELECT *,只查询需要字段;
  • 使用EXPLAIN分析大模型生成 SQL 的执行计划,识别全表扫描风险;
  • 复杂统计类查询,可提前构建物化视图,减少实时计算压力。

6.2 应用层面优化

  • 缓存策略:相同业务问题重复提问,直接缓存 SQL + 结果,避免重复调用付费大模型 API。这里使用 Guava Cache 做本地缓存,生产环境可以替换 Redis 分布式缓存。
@Service
public class QueryCacheService {
    
    private final Cache<String, List<Map<String, Object>>> cache = 
        CacheBuilder.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(30, TimeUnit.MINUTES)
            .build();
    /**
     * 带缓存的查询
     */
    public List<Map<String, Object>> queryWithCache(String sql) {
        return cache.get(sql, () -> 
            jdbcTemplate.queryForList(sql)
        );
    }
}
  • 异步处理:大模型 API 推理时间比较长,可以使用 Spring @Async异步接口,前端做轮询或者 WebSocket 推送结果,用户页面不会卡住等待。
@Service
public class AsyncQueryService {
    
    @Async("taskExecutor")
    public CompletableFuture<QueryResult> asyncQuery(String question) {
        QueryResult result = text2SqlService.queryJoin(question);
        return CompletableFuture.completedFuture(result);
    }
}
  1. 增加接口超时控制,防止大模型接口卡死,线程耗尽。

6.3 AI 模型层面优化

  1. 提示词缓存:系统 SystemPrompt 固定不变,可以预编译缓存,不需要每次调用 API 都重新构建字符串;
  2. 超时设置:大模型 API 超时设置 30‑60 秒,根据业务调整;
  3. 重试机制:网络抖动、限流场景,增加 2‑3 次自动重试;
  4. Token 控制:不要把全量库表 Schema 全部塞进 Prompt,表多之后 token 暴涨,会降低生成准确率,后续可以引入 RAG,根据用户问题检索相关表结构,只把相关 Schema 丢给大模型。

七、系统扩展方向

这套架构预留了很好的扩展空间,当业务越来越复杂,可以往下面几个方向迭代:

7.1 多表扩展,接入 RAG 元数据检索

现在我们是硬编码写死两套 Prompt,当业务库表增长到十几张、几十张表,写死 Prompt 完全不可行。可以读取数据库元数据,把表名、字段、注释、关联关系存入向量库。用户提问之后,通过向量检索取出和问题相关的几张表结构,动态组装 Prompt,这就是 RAG+Text‑to‑SQL 的经典方案。

7.2 多模型支持,策略模式切换模型

使用策略模式抽象模型生成接口,可以自由切换在线 API 模型、本地私有化部署开源模型,不需要改动上层业务代码。

/**
 * 多模型策略模式
 */
public interface TextToSqlModel {
    String generateSql(String question, List<TableSchema> schemas);
}
@Component
public class SiliconFlowModel implements TextToSqlModel { ... }
@Component
public class LocalModel implements TextToSqlModel { ... }
@Configuration
public class ModelConfig {
    @Bean
    public TextToSqlModel textToSqlModel(
        @Value("${ai.model.type}") String type) {
        if ("local".equals(type)) {
            return localModel;
        }
        return siliconFlowModel;
    }
}

7.3 多轮对话,上下文记忆

实现会话 session,保存历史提问和生成 SQL,支持上下文理解。例如第一轮问 “查询金卡会员”,第二轮接着问 “统计他们积分总和”,系统结合上一轮上下文理解用户意图,不需要用户重复描述条件。

/**
 * 上下文感知的多轮对话
 */
@Service
public class ConversationService {
    
    private final Map<String, List<ConversationMessage>> history = 
        new ConcurrentHashMap<>();
    /**
     * 多轮查询
     */
    public Map<String, Object> multiTurnQuery(
            String sessionId, String question) {
        // 获取历史上下文
        List<ConversationMessage> context = history.getOrDefault(
            sessionId, new ArrayList<>());
        // 构建带上下文的提示词
        String prompt = buildContextPrompt(context, question);
        // 调用AI生成SQL
        String sql = llmService.callApi(question, prompt);
        // 更新历史
        context.add(new ConversationMessage(question, sql));
        history.put(sessionId, context);
        // 执行查询
        return executeSql(sql);
    }
}

7.4 企业级增强(生产平台必备)

  1. 审计日志:记录每一次用户提问、原始问题、生成 SQL、耗时、是否成功,方便排查问题;
  2. 权限控制:用户行级、表级权限,不同用户只能查询授权的数据;
  3. SQL 预检:执行 SQL 之前调用 EXPLAIN 预估扫描行数,拦截会全表扫描、消耗巨大资源的 SQL,保护数据库;
  4. 错误自动重试:SQL 语法报错时,把报错信息丢回给大模型,让 AI 自动修正 SQL 再尝试一次;
  5. 结果可视化:返回数据自动生成简单图表。

八、总结

本篇完整讲解一套可落地的 Text‑to‑SQL 系统架构,核心要点复盘:

  1. 分层设计,职责隔离:Controller 接收请求,Service 编排业务逻辑,LlmService 封装大模型调用,每个模块只干自己的事,便于维护、单元测试;
  2. 区分单表 / 关联查询场景:两套独立 Prompt,减少上下文噪声,提升 SQL 生成准确率;
  3. 安全永远第一位:不要信任大模型输出!多层校验,数据库账号使用只读权限,规避删库风险;
  4. 做好容错处理:AI 输出格式混乱要清洗,网络、数据库异常统一捕获,不要把堆栈直接抛给前端;
  5. 兼顾用户体验:前端示例引导、展示生成 SQL,降低业务人员使用门槛;
  6. 预留扩展能力:策略模式、抽象接口,后续扩展多模型、RAG 元数据检索、多轮对话,不需要大规模重构原有代码。

Text‑to‑SQL 不是银弹,大模型幻觉问题客观存在,生产环境更多适合内部业务人员做自助分析,不建议直接面向外部用户。对于关键业务报表,依然需要人工核对 SQL 逻辑。行文仓促,定有不足之处,欢迎各位朋友在评论区批评指正,不胜感激。

更多推荐