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

一、为什么 Java 团队必须关注 Text-to-SQL
Text-to-SQL 不是新概念,但 2026 年它重新变得炙手可热。原因很现实:大模型的语义理解能力上来了,业务侧对"自助取数"的诉求也上来了。过去一套固定 BI 报表改一次需求要排期一周,现在产品经理、运营、财务希望能直接问系统:"华东区 Q2 销售额 Top10 的 SKU 是哪些?""为什么上月客单价涨了但总 GMV 没涨?"
对 Java 团队来说,这件事有两个天然优势:
- 数据就在 Java 服务后面:企业核心业务库、数据仓库、权限体系、审计体系早就是 Java 生态的一部分,用 Python 另起炉灶反而要跨语言对接。
- 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,组件会调用内部方法从 DataSource 的 DatabaseMetaData 自动提取所有表结构并生成 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_hash、salary、audit_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.userSELECT 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。正确做法是:
- 创建一个只读账号,只能访问脱敏后的分析库或从库。
- 手动维护
databaseStructure,只暴露业务需要的表和列。 - 在 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();
}
}
注意:这里显式去掉了 id、created_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_users、SELECT sleep(10) 等仍能执行。
正确做法:
- 在业务层再加一层 SQL 黑名单关键字(
information_schema、mysql.、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 SqlDatabaseContentRetrieverAPI 文档与源码- JSqlParser 安全校验实践
- Spring AI Alibaba 在"AI 问数"场景中的落地方案