LangChain4j + Java 实现企业级 Text-to-SQL 智能问数系统:从自然语言到安全可控的数据洞察

当业务部门还在因为"提数需求排期三天"而抱怨时,懂 AI 的 Java 后端已经能把"自然语言问数"做成一个可上线、可审计、可降级的企业级服务。本文从 LangChain4j 的 SqlDatabaseContentRetriever 源码出发,拆解一次 Text-to-SQL 请求的全生命周期,并给出一套可直接落地的 Spring Boot 工程方案。

一、为什么 Java 团队必须关注 Text-to-SQL

Text-to-SQL 不是新概念,但 2026 年它重新变得炙手可热。原因很现实:大模型的语义理解能力上来了,业务侧对"自助取数"的诉求也上来了。过去一套固定 BI 报表改一次需求要排期一周,现在产品经理、运营、财务希望能直接问系统:"华东区 Q2 销售额 Top10 的 SKU 是哪些?""为什么上月客单价涨了但总 GMV 没涨?"

对 Java 团队来说,这件事有两个天然优势:

  1. 数据就在 Java 服务后面:企业核心业务库、数据仓库、权限体系、审计体系早就是 Java 生态的一部分,用 Python 另起炉灶反而要跨语言对接。
  2. LangChain4j 已经补上了关键拼图 :它的 langchain4j-experimental-sql 模块把"自然语言 → SQL → 执行 → 格式化"封装成了一个可扩展的 ContentRetriever,Java 开发者不需要自己写 prompt 拼接和 SQL 校验。

但 Text-to-SQL 从 Demo 到生产,差的不是一点点代码,而是一整套工程约束:数据库只读权限、SQL 注入防护、多轮对话上下文、租户数据隔离、模型幻觉兜底、结果可解释性。本文的核心目标就是把这套约束讲清楚。

二、LangChain4j SqlDatabaseContentRetriever 源码级拆解

2.1 组件定位

SqlDatabaseContentRetriever 位于 langchain4j-experimental-sql 模块,完整类路径:

复制代码
dev.langchain4j.experimental.rag.content.retriever.sql.SqlDatabaseContentRetriever

它实现了 ContentRetriever 接口,意味着可以无缝接入 LangChain4j 的 RAG 链路。它的设计哲学是:把数据库当成一个可被大模型查询的"文档库",用户用自然语言提问,组件负责生成并执行 SQL,把结果作为文本内容返回。

官方 Javadoc 用了很大的警告字体:

WARNING! This class is dangerous to use! Do not ever use this in production! The database user must have very limited READ-ONLY permissions!

这句话不是吓唬人。因为 LLM 生成的 SQL 理论上可能做任何事,即便组件内部用 JSqlParser 校验了 SELECT,也不能保证语义无害(比如 SELECT * FROM information_schema 仍是只读,但可能泄露元数据)。

2.2 核心方法签名与执行链路

以下是源码层面的关键方法,建议配合 IDE 一起看:

java 复制代码
public class SqlDatabaseContentRetriever implements ContentRetriever {
    // 构造器:数据源、方言、库结构、提示模板、模型、重试次数
    public SqlDatabaseContentRetriever(DataSource dataSource,
                                       String sqlDialect,
                                       String databaseStructure,
                                       PromptTemplate promptTemplate,
                                       ChatModel chatModel,
                                       Integer maxRetries) { ... }

    // 入口方法:接收自然语言查询,返回 Content 列表
    @Override
    public List<Content> retrieve(Query naturalLanguageQuery) { ... }

    // 生成 SQL:会把上一次失败的 SQL 和错误信息喂给模型做修正
    protected String generateSqlQuery(Query naturalLanguageQuery,
                                      String previousSqlQuery,
                                      String previousErrorMessage) { ... }

    // 构造 System Prompt
    protected Prompt createSystemPrompt() { ... }

    // 清洗模型返回的 SQL(去掉 markdown 代码块等)
    protected String clean(String sqlQuery) { ... }

    // 校验 SQL 是否安全
    protected void validate(String sqlQuery) { ... }

    // 判断是否 SELECT 语句
    protected boolean isSelect(String sqlQuery) { ... }

    // 执行 SQL 并格式化结果
    protected String execute(String sqlQuery, Statement statement) throws SQLException { ... }
}

一次 retrieve(Query) 调用的完整流程如下:

复制代码
用户提问
  ↓
createSystemPrompt() 组装系统提示(含数据库 DDL、方言、安全约束)
  ↓
generateSqlQuery() 调用 ChatModel 生成 SQL
  ↓
clean() 去掉 ```sql 等标记
  ↓
validate() → isSelect() 用 JSqlParser 校验是否仅为 SELECT
  ↓
execute() 执行查询并格式化结果
  ↓
若失败且未达 maxRetries,将错误信息回传,再次 generateSqlQuery()
  ↓
返回 List<Content>

这个流程本身已经覆盖了"元数据提取 → SQL 生成 → 安全校验 → 执行 → 错误修正"的完整闭环,但默认实现离生产还有距离。

2.3 元数据提取:generateDDL() 的隐患

如果没有显式传入 databaseStructure,组件会调用内部方法从 DataSourceDatabaseMetaData 自动提取所有表结构并生成 CREATE TABLE 语句。源码层面大概长这样(简化版):

java 复制代码
protected String generateDDL(DataSource dataSource) {
    try (Connection connection = dataSource.getConnection()) {
        DatabaseMetaData metaData = connection.getMetaData();
        ResultSet tables = metaData.getTables(null, null, "%", new String[]{"TABLE"});
        StringBuilder ddl = new StringBuilder();
        while (tables.next()) {
            String tableName = tables.getString("TABLE_NAME");
            // 提取列、主键、外键...
            ddl.append("CREATE TABLE ").append(tableName).append("(...);\n");
        }
        return ddl.toString();
    }
}

隐患很明显:

  • 表太多时 prompt 爆炸:一个中台库几百张表,全部塞进 prompt 会让模型迷失,token 成本也飙升。
  • 敏感表暴露 :即使只读,把 user_password_hashsalaryaudit_log 等表结构直接喂给 LLM 也不符合数据安全规范。
  • 注释缺失 :纯 DDL 没有业务注释,模型很难理解 status = 3 代表什么。

所以生产环境一定要手动维护 databaseStructure,而不是依赖自动提取。

2.4 安全校验:isSelect() 并非万能

validate() 内部用 JSqlParser 解析 SQL,判断是否是 SELECT。关键源码逻辑:

java 复制代码
protected void validate(String sqlQuery) {
    if (!isSelect(sqlQuery)) {
        throw new IllegalArgumentException("Only SELECT statements are allowed");
    }
}

protected boolean isSelect(String sqlQuery) {
    Statement statement = CCJSqlParserUtil.parse(sqlQuery);
    return statement instanceof Select;
}

这能拦住 INSERT/UPDATE/DELETE/DROP,但拦不住:

  • SELECT * FROM mysql.user
  • SELECT LOAD_FILE('/etc/passwd')(某些 MySQL 配置)
  • 通过子查询或 UNION 绕过数据权限
  • 慢 SQL 拖垮数据库(比如无条件的 SELECT * FROM huge_table

因此生产上必须叠加:数据库只读账号、SQL 执行超时、表/列白名单、结果集行数限制、SQL 审计日志。

三、实战:Spring Boot + LangChain4j + 通义千问构建企业级问数 Agent

3.1 项目依赖

xml 复制代码
<properties>
    <java.version>21</java.version>
    <langchain4j.version>1.0.0-beta4</langchain4j.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>dev.langchain4j</groupId>
        <artifactId>langchain4j-spring-boot-starter</artifactId>
        <version>${langchain4j.version}</version>
    </dependency>
    <dependency>
        <groupId>dev.langchain4j</groupId>
        <artifactId>langchain4j-experimental-sql</artifactId>
        <version>${langchain4j.version}</version>
    </dependency>
    <dependency>
        <groupId>dev.langchain4j</groupId>
        <artifactId>langchain4j-community-dashscope</artifactId>
        <version>${langchain4j.version}</version>
    </dependency>
    <dependency>
        <groupId>com.zaxxer</groupId>
        <artifactId>HikariCP</artifactId>
    </dependency>
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

3.2 数据建模:零售销售分析场景

为了贴近真实业务,我们假设一个零售企业数据中台,核心表如下:

sql 复制代码
CREATE TABLE sales_record (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    tenant_id BIGINT NOT NULL,
    region_code VARCHAR(32) NOT NULL COMMENT '区域编码:EC/SC/NC/NW',
    product_category VARCHAR(64) NOT NULL COMMENT '品类',
    sku_code VARCHAR(64) NOT NULL,
    amount DECIMAL(18,2) NOT NULL COMMENT '销售金额',
    quantity INT NOT NULL COMMENT '销售数量',
    sale_date DATE NOT NULL,
    channel VARCHAR(32) NOT NULL COMMENT '渠道:online/offline/distributor',
    KEY idx_tenant_date_region (tenant_id, sale_date, region_code),
    KEY idx_tenant_category_date (tenant_id, product_category, sale_date)
) COMMENT='销售事实表';

CREATE TABLE region_dict (
    region_code VARCHAR(32) PRIMARY KEY,
    region_name VARCHAR(64) NOT NULL COMMENT '区域中文名'
) COMMENT='区域维表';

3.3 生产级配置:只读数据源 + 白名单 DDL

不要直接把生产主库交给 AI。正确做法是:

  1. 创建一个只读账号,只能访问脱敏后的分析库或从库。
  2. 手动维护 databaseStructure,只暴露业务需要的表和列。
  3. 在 HikariCP 里设置严格的连接池、查询超时。
java 复制代码
@Configuration
public class AiDataSourceConfig {

    @Bean("aiReadOnlyDataSource")
    public DataSource aiReadOnlyDataSource() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:mysql://readonly-analytics-db:3306/bi_report?useSSL=true");
        config.setUsername("ai_readonly");
        config.setPassword("xxx");
        config.setMaximumPoolSize(5);
        config.setConnectionTimeout(2000);
        // 最关键:查询超时,防止慢 SQL 拖库
        config.addDataSourceProperty("socketTimeout", "10000");
        return new HikariDataSource(config);
    }

    @Bean
    public SqlDatabaseContentRetriever sqlDatabaseContentRetriever(
            @Qualifier("aiReadOnlyDataSource") DataSource dataSource,
            ChatLanguageModel chatLanguageModel) {

        String databaseStructure = """
            CREATE TABLE sales_record (
                tenant_id BIGINT NOT NULL,
                region_code VARCHAR(32) NOT NULL COMMENT '区域编码:EC华东/SC华南/NC华北/NW西北',
                product_category VARCHAR(64) NOT NULL COMMENT '产品品类,如手机、笔记本、配件',
                sku_code VARCHAR(64) NOT NULL,
                amount DECIMAL(18,2) NOT NULL COMMENT '订单销售金额,单位元',
                quantity INT NOT NULL COMMENT '销售数量',
                sale_date DATE NOT NULL COMMENT '销售日期',
                channel VARCHAR(32) NOT NULL COMMENT '销售渠道:online线上/offline线下/distributor分销'
            );
            CREATE TABLE region_dict (
                region_code VARCHAR(32) PRIMARY KEY,
                region_name VARCHAR(64) NOT NULL COMMENT '区域中文名'
            );
            """;

        return SqlDatabaseContentRetriever.builder()
                .dataSource(dataSource)
                .sqlDialect("MySQL")
                .databaseStructure(databaseStructure)
                .chatModel(chatLanguageModel)
                .maxRetries(2)
                .build();
    }
}

注意:这里显式去掉了 idcreated_at 等敏感/无关列,并补充了中文 COMMENT,让模型更准确地理解业务语义。

3.4 自定义提示模板:从"能跑"到"跑得准"

默认提示模板比较通用,生产上建议覆盖。核心诉求是:

  • 强制只生成 SELECT
  • 要求 SQL 必须兼容 MySQL 8.0
  • 要求金额保留两位小数
  • 要求关联维表时使用中文名
  • 禁止查询 tenant_id = 0 的测试数据
java 复制代码
PromptTemplate promptTemplate = PromptTemplate.from("""
    你是一个资深的 MySQL 数据分析师,擅长把业务问题转换为精确的 SQL 查询。

    数据库方言:{{sqlDialect}}

    表结构:
    {{databaseStructure}}

    业务规则:
    1. 只允许生成 SELECT 查询,禁止任何写操作。
    2. 所有查询必须包含 WHERE tenant_id = <当前租户ID>,因为数据是多租户的。
    3. 当用户提到区域中文名时,先 JOIN region_dict 获取 region_code。
    4. 金额字段使用 DECIMAL(18,2),结果保留两位小数。
    5. 时间范围优先使用 sale_date,支持近 N 天/月/季度/年。
    6. 如果问题无法映射到现有表结构,返回:SELECT '暂不支持该查询' AS msg;

    {{#if previousSqlQuery}}
    上一次生成的 SQL 执行失败:
    SQL: {{previousSqlQuery}}
    错误:{{previousErrorMessage}}
    请修正后重新生成。
    {{/if}}

    用户问题:{{naturalLanguageQuery}}

    请只输出 SQL,不要解释。
    """);

然后在 builder 里替换 .promptTemplate(promptTemplate)

3.5 封装服务层:带租户隔离、审计、降级

直接用 SqlDatabaseContentRetriever.retrieve() 还不够,必须封装一个服务层:

java 复制代码
@Service
@RequiredArgsConstructor
@Slf4j
public class SmartQueryService {

    private final SqlDatabaseContentRetriever sqlRetriever;
    private final ChatLanguageModel chatLanguageModel;
    private final AuditLogRepository auditLogRepository;

    public SmartQueryResult query(Long tenantId, Long userId, String sessionId, String question) {
        long start = System.currentTimeMillis();
        String requestId = UUID.randomUUID().toString();

        try {
            // 1. 注入租户 ID:把问题改写为带租户过滤的明确表述
            String tenantAwareQuestion = String.format(
                "租户ID=%d。%s", tenantId, question);

            // 2. 调用 Text-to-SQL 检索器
            List<Content> contents = sqlRetriever.retrieve(Query.from(tenantAwareQuestion));

            if (contents.isEmpty()) {
                return SmartQueryResult.fail("未查询到相关数据");
            }

            String sqlResult = contents.get(0).textSegment().text();

            // 3. 二次校验:确保返回结果里不暴露内部字段
            if (sqlResult.contains("tenant_id") || sqlResult.contains("password")) {
                return SmartQueryResult.fail("查询结果包含敏感字段,已被拦截");
            }

            // 4. 用 LLM 生成自然语言解释
            String explanation = chatLanguageModel.generate(
                UserMessage.from("基于以下查询结果,用一句话回答用户问题:\n" +
                    "用户问题:" + question + "\n查询结果:\n" + sqlResult)
            ).content().text();

            // 5. 写审计日志
            auditLogRepository.save(AuditLog.builder()
                .requestId(requestId)
                .tenantId(tenantId)
                .userId(userId)
                .sessionId(sessionId)
                .question(question)
                .sqlResult(sqlResult.substring(0, Math.min(sqlResult.length(), 2000)))
                .latencyMs(System.currentTimeMillis() - start)
                .build());

            return SmartQueryResult.ok(explanation, sqlResult);

        } catch (Exception e) {
            log.error("[SmartQuery] requestId={}, tenantId={}, question={} failed",
                requestId, tenantId, question, e);

            // 6. 降级:返回静态 FAQ 或引导语
            return SmartQueryResult.fail(
                "当前查询无法完成,建议联系数据团队,或尝试更明确的指标/时间范围。");
        }
    }
}

这里的关键设计:

  • 租户 ID 前置注入 :大模型不一定可靠,我们在业务层先把 tenant_id 写进问题描述,并在 prompt 里反复强调 WHERE tenant_id = ?
  • 结果二次过滤:防止模型返回了不该出现的内部字段。
  • 审计日志:记录谁、问了什么、返回了什么、耗时多久,满足合规要求。
  • 优雅降级:任何异常都不应该直接抛给前端,而是返回可操作的提示。

3.6 Controller 与多轮对话

java 复制代码
@RestController
@RequestMapping("/api/smart-query")
@RequiredArgsConstructor
public class SmartQueryController {

    private final SmartQueryService smartQueryService;
    private final ChatMemoryStore chatMemoryStore;

    @PostMapping
    public ResponseEntity<SmartQueryResult> ask(@RequestBody @Valid QueryRequest req) {
        // 从 JWT/网关上下文中获取租户和用户
        Long tenantId = TenantContext.getTenantId();
        Long userId = TenantContext.getUserId();

        // 多轮对话:先把历史上下文补进问题
        String contextualQuestion = buildContextualQuestion(req.getSessionId(), req.getQuestion());

        SmartQueryResult result = smartQueryService.query(
            tenantId, userId, req.getSessionId(), contextualQuestion);

        // 记录本轮对话
        chatMemoryStore.add(req.getSessionId(), req.getQuestion(), result.getAnswer());

        return ResponseEntity.ok(result);
    }

    private String buildContextualQuestion(String sessionId, String question) {
        List<ChatMessage> history = chatMemoryStore.get(sessionId, 3);
        if (history.isEmpty()) return question;

        StringBuilder sb = new StringBuilder("历史对话:\n");
        history.forEach(m -> sb.append(m.role()).append(": ").append(m.text()).append("\n"));
        sb.append("当前问题:").append(question);
        return sb.toString();
    }
}

多轮对话这里要特别注意:不要把无限历史都塞进 prompt,而是只保留最近 3 轮,并定期做摘要。否则 token 成本和延迟都会失控。

四、生产踩坑清单:从"能跑"到"敢上线"

4.1 权限隔离是第一道红线

:用业务主库账号直接给 AI 用。

后果 :模型一旦生成 SELECT * FROM order 全表扫描,或者误关联大表,可能拖垮主库。

正确做法

  • 创建专用只读账号,只授权必要的视图(view)。
  • 优先访问只读从库或 BI 数仓。
  • 对表/列做白名单, prompt 里只暴露脱敏后的结构。

4.2 SQL 注入风险并未消失

:以为 JSqlParser 的 isSelect() 就够了。

后果UNION SELECT password FROM admin_usersSELECT sleep(10) 等仍能执行。

正确做法

  • 在业务层再加一层 SQL 黑名单关键字(information_schemamysql.performance_schema 等)。
  • 使用数据库账号的 max_execution_time 限制。
  • 对结果集行数做上限(如最多 1000 行)。

4.3 大模型幻觉导致 SQL 错误

:用户问"上个月华东区手机品类退货率",但表里没有退货表,模型可能硬编一个 refund_rate 列。

后果:SQL 执行失败,进入重试循环,反复调模型,烧钱且体验差。

正确做法

  • prompt 里明确写"如果无法映射到现有表结构,返回固定 SQL"。
  • 在业务层维护"指标口径知识库",对高频指标做预定义 SQL 模板。
  • 对失败率高的查询,自动沉淀为 bad case,人工补 few-shot 样例。

4.4 多轮上下文导致 token 爆炸

:把完整对话历史全部塞进每次请求。

后果:当对话超过 10 轮后,单次请求 token 可能破万,成本和延迟都不可接受。

正确做法

  • 只保留最近 N 轮(如 3 轮)。
  • 对历史做 LLM 摘要,只保留关键事实("当前比较华东 vs 华南""时间范围是近 6 个月")。
  • 会话设置 TTL,Redis 中过期自动清理。

4.5 慢 SQL 与连接池耗尽

:AI 生成的 SQL 没有索引概念,容易全表扫描。

后果:高并发下连接池被占满,整个服务无响应。

正确做法

  • HikariCP 最大连接数设小(5-10),并开启连接超时快速失败。
  • 数据库层设置 max_execution_time = 5000(5 秒)。
  • 对慢查询做监控告警,自动加入黑名单。
  • 高频查询用物化视图或预计算指标表兜底。

4.6 结果解释不可控

:直接把 SQL 结果交给 LLM 自由发挥。

后果:模型可能"过度解读",比如把正常的季节性波动说成"异常",误导业务决策。

正确做法

  • 解释层 prompt 里限定"只基于数据事实,不猜测原因"。
  • 对关键指标设置阈值规则,只有超出阈值才触发"异常提醒"。
  • 提供"查看原始 SQL"和"查看原始数据"的入口,让用户可验证。

4.7 版本与依赖兼容性

langchain4j-experimental-sql 还在 experimental 阶段,API 可能变化。

后果:升级 LangChain4j 版本后编译失败。

正确做法

  • SqlDatabaseContentRetriever 做一层适配器封装,不直接依赖实现类。
  • 升级前先在 staging 环境跑回归测试。
  • 关注 GitHub release note,尤其是 experimental 模块的破坏性变更。

五、总结

Text-to-SQL 是 Java 后端切入 AI 应用最务实的场景之一。它不像 Agent 那样天马行空,也不像纯 RAG 那样只能"找知识",而是直接解决"业务自助取数"的真实痛点。

LangChain4j 的 SqlDatabaseContentRetriever 已经把核心链路串起来了,但从源码可以看到,它仅仅是一个"实验性"组件,默认配置远不能满足生产要求。真正上线时,你需要叠加:

  • 只读数据源 + 表列白名单
  • 自定义 prompt + 租户隔离
  • SQL 执行超时 + 结果行数限制
  • 审计日志 + 降级策略
  • 多轮记忆裁剪 + 慢 SQL 监控

把这些工程约束做到位,Text-to-SQL 才能从一个"好玩的 Demo"变成业务部门愿意每天使用的生产工具。

对于 Java 程序员来说,这正是一个绝佳的转型切入点:你不用转 Python,不用抛弃 Spring Boot,只需要把大模型当成一个"会理解意图、会生成代码、需要被严格约束"的协作者,然后用你熟悉的工程化能力把它封装好、治理好、上线好。

AI 时代,能把 AI 能力"焊"进企业系统的 Java 开发者,依然稀缺,也依然值钱。


参考与延伸阅读

  • LangChain4j 官方文档:https://docs.langchain4j.dev
  • SqlDatabaseContentRetriever API 文档与源码
  • JSqlParser 安全校验实践
  • Spring AI Alibaba 在"AI 问数"场景中的落地方案
相关推荐
Escalating_xu1 小时前
【C++ STL简介】从六大组件到容器、迭代器与算法协作
java·c++·算法
何以解忧,唯有..1 小时前
Python os模块详解:文件与目录操作指南
java·服务器·python
前沿在线2 小时前
WRC2026丨当机器开始理解人的意图,人机交互走向更多场景
人工智能·ai·大模型
CoderJia程序员甲2 小时前
GitHub 热榜项目 - 周榜(2026-08-22)
ai·大模型·llm·github·ai教程
Csxyzj2 小时前
kubernetes集群部署方法
java·linux·kubernetes
SQL-First布道者2 小时前
全面解构传统持久层框架,拥抱真正的 SQL-First
java·数据库·spring boot·sql·spring·mybatis·spring jdbc
俊哥V2 小时前
每日 AI 研究简报 · 2026-08-22
人工智能·ai
AI导出鸭PC端2 小时前
文心怎样生成word文档?一键智能排版,AI导出鸭解决格式错乱痛点
人工智能·ai·word·豆包·deepseek·ai导出鸭
小蒜学长2 小时前
vue旅游攻略网站(代码+数据库+LW)
java·数据库·vue.js·spring boot·后端·旅游