系列第九篇:Text-to-SQL 智能数据查询与 Human-in-the-Loop 人机协作
前言
前八篇文章覆盖了 Agent 的构建、协作、评估、记忆、运维、检索、安全和推理。但在企业实践中,有两个高频需求始终被反复提及:
需求一:"用户能不能用自然语言直接查数据库?不用写 SQL、不用看报表,直接问'A 栋上个月每天用电量是多少'就能出结果?"
需求二 :"Agent 自主执行一些操作(发通知、调参数)太危险了,能不能让它做到关键步骤时停下来等人确认?"
这两个需求分别对应 Text-to-SQL 和 Human-in-the-Loop------本文将深入实现。
一、Text-to-SQL:让 Agent 直接查数据库
1.1 为什么需要 Text-to-SQL?
在能源管理系统中,大量的分析需求是 临时性的、探索性的:
- "A 栋上个月每天用电量是多少?"
- "本月能耗最高的三个区域是哪些?"
- "C 栋数据中心近半年每月的电费趋势?"
- "哪个区域的空调能耗占总能耗比例最高?"
这些查询无法通过预定义的 API 或固定的 @Tool 方法覆盖------因为用户的查询维度是无限的。传统的做法是开发几十上百个查询接口,而 Text-to-SQL 提供了一种更优雅的解法:让 Agent 自主生成 SQL,直接查询数据库。
1.2 Text-to-SQL 的核心挑战
Text-to-SQL 看似简单("把中文翻译成 SQL"),实际挑战重重:
┌──────────────────────────────────────────────────────────┐
│ Text-to-SQL 四大挑战 │
│ │
│ 挑战1: Schema 理解 │
│ → 模型必须理解表结构、字段含义、表间关系 │
│ → 能源系统的表可能有几十张,字段命名不一定直观 │
│ │
│ 挑战2: 复杂查询 │
│ → 多表 JOIN、子查询、窗口函数、聚合分析 │
│ → "每月同比" 需要自连接 + 日期计算 │
│ │
│ 挑战3: SQL 方言 │
│ → 项目使用达梦数据库(DM),语法与 MySQL 有差异 │
│ → 还有 ClickHouse 做时序分析,语法差异更大 │
│ │
│ 挑战4: 安全性 │
│ → 绝不能生成 DELETE/UPDATE/DROP 等写入语句 │
│ → 必须限制查询范围(只允许特定表、必须有 LIMIT) │
│ │
└──────────────────────────────────────────────────────────┘
1.3 架构设计:三层 Text-to-SQL Pipeline
用户问题: "A栋上个月每天的用电量"
│
▼
┌──────────────────────────────────────┐
│ Layer 1: Schema Linking │
│ 确定相关表和字段 │
│ → 表: energy_daily_consumption │
│ → 字段: area_name, consume_date, │
│ electricity_kwh │
└──────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ Layer 2: SQL Generation │
│ 生成 SQL │
│ → SELECT consume_date, │
│ electricity_kwh │
│ FROM energy_daily_consumption │
│ WHERE area_name = 'A栋办公楼' │
│ AND consume_date >= '2026-07-01'│
│ AND consume_date < '2026-08-01' │
│ ORDER BY consume_date │
└──────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ Layer 3: Validation + Execution │
│ 安全校验 → 执行 → 结果格式化 │
│ → 校验通过 (SELECT only, 有 LIMIT) │
│ → 执行并返回 31 行数据 │
│ → 格式化为 Markdown 表格/图表描述 │
└──────────────────────────────────────┘
二、Schema 管理:让模型"看懂"你的数据库
2.1 Schema 信息提取
模型要生成正确的 SQL,首先必须理解数据库的结构。我们需要将 Schema 信息提取出来,以一种模型友好的方式注入 Prompt。
java
/**
* Schema 信息管理器
*
* 核心设计:
* 不是把所有表的 DDL 一股脑塞给模型(那样 Prompt 会很长,
* 而且大量无关表会干扰模型判断),而是:
*
* 1. 预构建 Schema 摘要(表名、字段、类型、注释、示例数据)
* 2. 根据用户问题动态选择相关表(Schema Linking)
* 3. 只将相关表的 Schema 注入 Prompt
*/
@Service
public class SchemaManager {
private final DataSource dataSource; // 达梦数据库
private final ClickHouseDataSource ckDataSource; // ClickHouse
private final Map<String, TableSchema> schemaCache =
new ConcurrentHashMap<>();
/**
* 启动时加载所有表的 Schema 信息
*
* 为什么用 JDBC 元数据而非硬编码?
* 因为数据库 Schema 会随业务迭代变化,
* 硬编码容易过期,从元数据动态读取最可靠。
*/
@PostConstruct
public void loadSchemas() {
try (Connection conn = dataSource.getConnection()) {
DatabaseMetaData meta = conn.getMetaData();
// 获取所有业务表(排除系统表)
ResultSet tables = meta.getTables(
null, "ENERGY", "%",
new String[]{"TABLE"});
while (tables.next()) {
String tableName = tables.getString("TABLE_NAME");
String tableComment = tables.getString("REMARKS");
// 获取字段信息
List<ColumnSchema> columns = loadColumns(
meta, tableName);
// 获取示例数据(帮助模型理解字段的实际值)
List<Map<String, Object>> sampleData =
loadSampleData(conn, tableName, 3);
schemaCache.put(tableName, new TableSchema(
tableName, tableComment, columns, sampleData
));
}
log.info("Schema 加载完成: {} 张表", schemaCache.size());
} catch (SQLException e) {
log.error("Schema 加载失败", e);
}
}
/**
* 加载表的字段信息(包含注释)
*
* 达梦数据库的字段注释通过 REMARKS 字段获取。
* 注释的质量直接影响 SQL 生成的准确性------
* 如果字段没有注释,建议手动补充。
*/
private List<ColumnSchema> loadColumns(DatabaseMetaData meta,
String tableName)
throws SQLException {
List<ColumnSchema> columns = new ArrayList<>();
ResultSet cols = meta.getColumns(
null, "ENERGY", tableName, "%");
while (cols.next()) {
columns.add(new ColumnSchema(
cols.getString("COLUMN_NAME"),
cols.getString("TYPE_NAME"),
cols.getInt("COLUMN_SIZE"),
cols.getString("REMARKS"), // 字段注释
cols.getInt("NULLABLE") == 1 // 是否可空
));
}
return columns;
}
/**
* 加载示例数据
*
* 为什么需要示例数据?
* 因为光看字段名和类型,模型可能无法理解字段的实际含义。
*
* 例如:area_code 字段,光看类型是 VARCHAR(32),
* 但示例值是 "A-BUILDING-01"、"B-BUILDING-02",
* 模型就能理解这是区域编码而非自由文本。
*/
private List<Map<String, Object>> loadSampleData(
Connection conn, String tableName, int limit) {
List<Map<String, Object>> samples = new ArrayList<>();
try {
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(
"SELECT * FROM " + tableName
+ " WHERE ROWNUM <= " + limit);
ResultSetMetaData rsMeta = rs.getMetaData();
while (rs.next()) {
Map<String, Object> row = new LinkedHashMap<>();
for (int i = 1; i <= rsMeta.getColumnCount(); i++) {
row.put(rsMeta.getColumnName(i), rs.getObject(i));
}
samples.add(row);
}
} catch (SQLException e) {
// 示例数据加载失败不影响主流程
log.debug("加载示例数据失败: {}", tableName);
}
return samples;
}
/**
* 生成模型可读的 Schema 描述
*
* 格式设计原则:
* 1. 用 CREATE TABLE DDL 格式(模型训练数据中大量存在,最熟悉)
* 2. 包含字段注释(COMMENT 子句)
* 3. 附带 2~3 行示例数据
* 4. 标注表间关系(外键或业务关联)
*/
public String formatSchemaForPrompt(TableSchema schema) {
StringBuilder sb = new StringBuilder();
// DDL 格式描述
sb.append(String.format("-- %s\n",
schema.tableComment() != null
? schema.tableComment() : ""));
sb.append(String.format("CREATE TABLE %s (\n",
schema.tableName()));
List<ColumnSchema> cols = schema.columns();
for (int i = 0; i < cols.size(); i++) {
ColumnSchema col = cols.get(i);
String comma = i < cols.size() - 1 ? "," : "";
sb.append(String.format(" %s %s%s",
col.name(), col.type(), comma));
if (col.comment() != null && !col.comment().isEmpty()) {
sb.append(String.format(" -- %s", col.comment()));
}
sb.append("\n");
}
sb.append(");\n");
// 示例数据
if (!schema.sampleData().isEmpty()) {
sb.append("\n-- 示例数据:\n");
for (Map<String, Object> row : schema.sampleData()) {
sb.append("-- ").append(row).append("\n");
}
}
return sb.toString();
}
}
public record TableSchema(
String tableName,
String tableComment,
List<ColumnSchema> columns,
List<Map<String, Object>> sampleData
) {}
public record ColumnSchema(
String name,
String type,
int size,
String comment,
boolean nullable
) {}
2.2 Schema Linking:动态选择相关表
数据库可能有几十张表,但一个查询通常只涉及 1~3 张。把所有表的 Schema 都塞进 Prompt 既浪费 Token 又干扰模型。Schema Linking 就是 智能筛选相关表。
java
/**
* Schema Linking 服务
*
* 两阶段筛选:
*
* 阶段1: 关键词匹配(快且免费)
* 从用户问题中提取关键词,匹配表名和字段名
* 例如提到"能耗" → 匹配 energy_* 相关表
*
* 阶段2: LLM 语义匹配(慢但精准)
* 将候选表的描述交给 LLM 判断是否相关
*/
@Service
public class SchemaLinkingService {
private final SchemaManager schemaManager;
private final ChatLanguageModel linkModel;
/**
* 为用户问题选择相关的表
*
* @param userQuery 用户问题
* @param maxTables 最多返回的表数量(通常 3~5 张)
* @return 相关表的 Schema 列表
*/
public List<TableSchema> linkTables(String userQuery,
int maxTables) {
// 阶段1: 关键词预筛选
List<TableSchema> candidates = keywordFilter(userQuery);
if (candidates.size() <= maxTables) {
return candidates; // 候选数已经够少,直接返回
}
// 阶段2: LLM 语义筛选
return llmFilter(userQuery, candidates, maxTables);
}
/**
* 关键词预筛选
*
* 维护一个"业务关键词 → 表名"的映射表。
* 这比让 LLM 做初筛快得多,也便宜得多。
*/
private List<TableSchema> keywordFilter(String query) {
// 业务关键词映射(可通过配置文件维护)
Map<String, List<String>> keywordTableMap = Map.of(
"能耗,用电,电量,电费,kwh,千瓦时",
List.of("energy_daily_consumption",
"energy_monthly_summary"),
"告警,预警,异常,报警",
List.of("energy_alert_log",
"energy_alert_config"),
"设备,空调,照明,电梯,变压器",
List.of("device_info",
"device_energy_consumption"),
"区域,楼栋,楼层,房间",
List.of("area_info",
"area_energy_quota"),
"工单,维修,巡检,保养",
List.of("maintenance_order",
"inspection_record"),
"趋势,同比,环比,对比",
List.of("energy_daily_consumption",
"energy_monthly_summary",
"energy_yearly_summary")
);
Set<String> matchedTables = new LinkedHashSet<>();
String lowerQuery = query.toLowerCase();
for (var entry : keywordTableMap.entrySet()) {
String[] keywords = entry.getKey().split(",");
for (String keyword : keywords) {
if (lowerQuery.contains(keyword.trim())) {
matchedTables.addAll(entry.getValue());
break;
}
}
}
// 如果没有匹配到任何表,返回最常用的核心表
if (matchedTables.isEmpty()) {
matchedTables.add("energy_daily_consumption");
matchedTables.add("area_info");
}
return matchedTables.stream()
.map(name -> schemaManager.getSchema(name))
.filter(Objects::nonNull)
.toList();
}
private List<TableSchema> llmFilter(String query,
List<TableSchema> candidates,
int maxTables) {
String tableDescriptions = candidates.stream()
.map(t -> String.format("- %s: %s (字段: %s)",
t.tableName(),
t.tableComment(),
t.columns().stream()
.map(c -> c.name() + "(" +
(c.comment() != null ? c.comment() : "") + ")")
.collect(Collectors.joining(", "))))
.collect(Collectors.joining("\n"));
String prompt = String.format("""
根据用户的问题,从以下数据库表中选择最相关的 %d 张表。
只返回表名,每行一个。
可用表:
%s
用户问题:%s
""", maxTables, tableDescriptions, query);
String result = linkModel.generate(
UserMessage.from(prompt)).content().text().trim();
return Arrays.stream(result.split("\n"))
.map(String::trim)
.map(schemaManager::getSchema)
.filter(Objects::nonNull)
.limit(maxTables)
.toList();
}
}
三、SQL 生成与安全校验
3.1 SQL 生成引擎
java
/**
* Text-to-SQL 生成服务
*
* 核心策略:
* 1. 提供完整的 Schema 上下文(DDL + 示例 + 表关系)
* 2. 明确指定 SQL 方言(达梦 DM SQL)
* 3. 提供 few-shot 示例(常见查询的 SQL 模板)
* 4. 要求模型只输出 SQL,不输出解释
*/
@Service
public class SqlGenerationService {
private final ChatLanguageModel sqlModel;
private final SchemaLinkingService schemaLinker;
/**
* 根据自然语言问题生成 SQL
*
* @param userQuery 用户的自然语言问题
* @param timeContext 时间上下文("上个月" → 具体日期范围)
* @return 生成的 SQL 和元数据
*/
public GeneratedSql generateSql(String userQuery,
TimeContext timeContext) {
// 1. Schema Linking:选择相关表
List<TableSchema> relevantSchemas =
schemaLinker.linkTables(userQuery, 4);
// 2. 构建 Schema 上下文
String schemaContext = relevantSchemas.stream()
.map(s -> schemaManager.formatSchemaForPrompt(s))
.collect(Collectors.joining("\n"));
// 3. 构建表关系说明
String relationships = buildRelationships(relevantSchemas);
// 4. 解析时间引用("上个月" → 2026-07-01 ~ 2026-07-31)
String resolvedQuery = resolveTimeReferences(
userQuery, timeContext);
// 5. 生成 SQL
String prompt = String.format("""
你是一个达梦数据库(DM SQL)专家。请根据用户的问题生成 SQL 查询。
## 数据库 Schema
%s
## 表间关系
%s
## SQL 方言要求
- 使用达梦 DM SQL 语法
- 日期函数使用:
· 当前日期: CURRENT_DATE
· 日期加减: DATEADD(day/month, -N, date)
· 月份提取: EXTRACT(MONTH FROM date)
· 日期格式化: TO_CHAR(date, 'YYYY-MM-DD')
- 分页使用: FETCH FIRST N ROWS ONLY(不用 LIMIT)
- 字符串拼接使用: ||(不用 CONCAT)
## 生成规则
1. 只生成 SELECT 查询,禁止 INSERT/UPDATE/DELETE/DROP
2. 必须包含合理的 WHERE 条件缩小数据范围
3. 结果集必须有 FETCH FIRST 限制(最大 1000 行)
4. 使用有意义的别名(AS chinese_name)
5. 对于"本月"、"上月"等时间词,今天日期是 %s
## Few-Shot 示例
问题: "A栋今天的用电量"
SQL: SELECT area_name AS 区域, electricity_kwh AS 用电量
FROM energy_daily_consumption
WHERE area_name = 'A栋办公楼'
AND consume_date = CURRENT_DATE
FETCH FIRST 100 ROWS ONLY;
问题: "本月能耗最高的5个区域"
SQL: SELECT a.area_name AS 区域,
SUM(d.electricity_kwh) AS 本月总用电量
FROM energy_daily_consumption d
JOIN area_info a ON d.area_code = a.area_code
WHERE d.consume_date >= TO_CHAR(
DATEADD(month, 0, CURRENT_DATE), 'YYYY-MM') || '-01'
GROUP BY a.area_name
ORDER BY 本月总用电量 DESC
FETCH FIRST 5 ROWS ONLY;
## 用户问题
%s
只返回 SQL,不要返回任何解释或其他内容。
""",
schemaContext,
relationships,
timeContext.currentDate(),
resolvedQuery);
String sql = sqlModel.generate(
UserMessage.from(prompt)).content().text().trim();
// 清理可能的 markdown 代码块标记
sql = sql.replaceAll("```
sql\\s*", "")
.replaceAll("```\\s*", "")
.trim();
return new GeneratedSql(userQuery, sql, relevantSchemas);
}
/**
* 构建表间关系描述
*
* 这些信息无法从 DDL 中自动提取(达梦可能没有外键约束),
* 需要通过配置文件维护。
*/
private String buildRelationships(List<TableSchema> schemas) {
// 从配置文件加载表关系
Map<String, String> relationships = Map.of(
"energy_daily_consumption.area_code → area_info.area_code",
"每日能耗表通过区域编码关联区域信息表",
"device_energy_consumption.device_id → device_info.device_id",
"设备能耗表通过设备ID关联设备信息表",
"energy_alert_log.area_code → area_info.area_code",
"告警日志通过区域编码关联区域信息表",
"maintenance_order.area_code → area_info.area_code",
"维修工单通过区域编码关联区域信息表"
);
Set<String> tableNames = schemas.stream()
.map(TableSchema::tableName)
.collect(Collectors.toSet());
return relationships.entrySet().stream()
.filter(e -> {
String key = e.getKey();
// 只显示与当前查询相关的关系
return tableNames.stream().anyMatch(key::contains);
})
.map(e -> "- " + e.getKey() + " (" + e.getValue() + ")")
.collect(Collectors.joining("\n"));
}
}
3.2 SQL 安全校验器
这是 Text-to-SQL 最关键的安全防线。 生成的 SQL 在执行前必须经过严格的安全检查。
java
/**
* SQL 安全校验器
*
* 多层安全防线:
* 1. 白名单:只允许 SELECT 语句
* 2. 黑名单:禁止所有写入/修改/删除操作
* 3. 表权限:只允许查询指定的业务表
* 4. 资源限制:强制添加 FETCH FIRST 限制
* 5. 语法校验:检测常见注入模式
*/
@Component
public class SqlSecurityValidator {
/** 允许查询的表白名单 */
private static final Set<String> ALLOWED_TABLES = Set.of(
"energy_daily_consumption",
"energy_monthly_summary",
"energy_yearly_summary",
"area_info",
"device_info",
"device_energy_consumption",
"energy_alert_log",
"energy_alert_config",
"maintenance_order",
"inspection_record"
);
/** 禁止的 SQL 关键词(写入操作) */
private static final Set<String> FORBIDDEN_KEYWORDS = Set.of(
"INSERT", "UPDATE", "DELETE", "DROP", "ALTER",
"CREATE", "TRUNCATE", "GRANT", "REVOKE",
"MERGE", "EXECUTE", "EXEC", "CALL"
);
/**
* 校验 SQL 安全性
*
* @return 校验结果(通过/拒绝 + 原因)
*/
public ValidationResult validate(String sql) {
String upperSql = sql.toUpperCase().trim();
// 检查1: 必须是 SELECT 语句
if (!upperSql.startsWith("SELECT")
&& !upperSql.startsWith("WITH")) {
return ValidationResult.rejected(
"只允许 SELECT 查询,当前语句类型: "
+ upperSql.split("\\s+")[0]);
}
// 检查2: 不包含写入操作关键词
for (String keyword : FORBIDDEN_KEYWORDS) {
// 使用词边界匹配,避免误伤(如 "update_time" 字段名)
if (Pattern.compile("\\b" + keyword + "\\b",
Pattern.CASE_INSENSITIVE)
.matcher(upperSql).find()) {
// 排除字段名中包含关键词的情况
if (!isColumnName(keyword, sql)) {
return ValidationResult.rejected(
"禁止使用 " + keyword + " 操作");
}
}
}
// 检查3: 表名必须在白名单内
Set<String> referencedTables = extractTableNames(sql);
for (String table : referencedTables) {
if (!ALLOWED_TABLES.contains(table.toLowerCase())) {
return ValidationResult.rejected(
"不允许查询表: " + table
+ "(不在白名单中)");
}
}
// 检查4: 必须有结果集限制
if (!upperSql.contains("FETCH FIRST")
&& !upperSql.contains("ROWNUM")
&& !upperSql.contains("TOP ")) {
// 自动添加限制
sql = sql.replaceAll(";\\s*$", "")
+ "\nFETCH FIRST 1000 ROWS ONLY";
}
// 检查5: 禁止子查询中的写入操作
if (containsSubqueryWrite(upperSql)) {
return ValidationResult.rejected(
"子查询中包含写入操作");
}
return ValidationResult.passed(sql);
}
/**
* 从 SQL 中提取引用的表名
*
* 使用正则解析 FROM/JOIN 子句中的表名。
* 注意:这是一个简化的解析器,复杂 SQL 建议使用 SQL Parser 库。
*/
private Set<String> extractTableNames(String sql) {
Set<String> tables = new HashSet<>();
// 匹配 FROM table 和 JOIN table 模式
Pattern fromPattern = Pattern.compile(
"(?:FROM|JOIN)\\s+([a-zA-Z_][a-zA-Z0-9_]*)",
Pattern.CASE_INSENSITIVE);
Matcher matcher = fromPattern.matcher(sql);
while (matcher.find()) {
tables.add(matcher.group(1));
}
return tables;
}
}
3.3 结果执行与格式化
java
/**
* SQL 查询执行器
*
* 执行生成的 SQL 并将结果格式化为模型可理解的文本
*/
@Service
public class SqlQueryExecutor {
private final DataSource dataSource;
private final SqlSecurityValidator validator;
/**
* 执行 SQL 并返回格式化结果
*/
public QueryResult execute(GeneratedSql generatedSql) {
// 1. 安全校验
ValidationResult validation = validator.validate(
generatedSql.sql());
if (!validation.passed()) {
return QueryResult.error(validation.reason());
}
String safeSql = validation.normalizedSql();
// 2. 执行查询(带超时保护)
try (Connection conn = dataSource.getConnection()) {
Statement stmt = conn.createStatement();
stmt.setQueryTimeout(30); // 30 秒超时
long startTime = System.currentTimeMillis();
ResultSet rs = stmt.executeQuery(safeSql);
long duration = System.currentTimeMillis() - startTime;
// 3. 解析结果集
ResultSetMetaData meta = rs.getMetaData();
int columnCount = meta.getColumnCount();
List<String> columnNames = new ArrayList<>();
for (int i = 1; i <= columnCount; i++) {
// 优先使用别名(中文别名更友好)
String label = meta.getColumnLabel(i);
columnNames.add(label != null ? label
: meta.getColumnName(i));
}
List<Map<String, Object>> rows = new ArrayList<>();
while (rs.next() && rows.size() < 1000) {
Map<String, Object> row = new LinkedHashMap<>();
for (int i = 1; i <= columnCount; i++) {
row.put(columnNames.get(i - 1), rs.getObject(i));
}
rows.add(row);
}
// 4. 格式化结果
String formatted = formatResult(columnNames, rows);
log.info("SQL 查询完成: {} 行, {}ms", rows.size(), duration);
return QueryResult.success(formatted, rows.size(), duration);
} catch (SQLTimeoutException e) {
return QueryResult.error("查询超时(30秒),请缩小查询范围");
} catch (SQLException e) {
log.error("SQL 执行失败: {}", e.getMessage());
return QueryResult.error("SQL 执行错误: " + e.getMessage());
}
}
/**
* 将查询结果格式化为 Markdown 表格
*
* 为什么用 Markdown 表格?
* 因为 LLM 对 Markdown 表格的理解和引用能力最好。
* Agent 在后续生成回答时可以方便地引用表格中的数据。
*/
private String formatResult(List<String> columns,
List<Map<String, Object>> rows) {
if (rows.isEmpty()) {
return "查询结果为空(未找到匹配数据)";
}
StringBuilder sb = new StringBuilder();
// 表头
sb.append("| ").append(String.join(" | ", columns))
.append(" |\n");
sb.append("| ").append(columns.stream()
.map(c -> "---")
.collect(Collectors.joining(" | ")))
.append(" |\n");
// 数据行(最多显示 50 行,太多会超出上下文)
int displayRows = Math.min(rows.size(), 50);
for (int i = 0; i < displayRows; i++) {
sb.append("| ");
for (String col : columns) {
Object value = rows.get(i).get(col);
sb.append(value != null ? value : "NULL")
.append(" | ");
}
sb.append("\n");
}
if (rows.size() > displayRows) {
sb.append(String.format(
"\n(共 %d 行,已显示前 %d 行)\n",
rows.size(), displayRows));
}
return sb.toString();
}
}
3.4 Text-to-SQL 集成到 Agent
java
/**
* Text-to-SQL Agent 工具
*
* 将整个 Text-to-SQL Pipeline 封装为一个 @Tool,
* Agent 可以自主决定何时使用自然语言查询数据库。
*/
@Component
public class TextToSqlTool {
private final SqlGenerationService sqlGenerator;
private final SqlQueryExecutor queryExecutor;
private final TimeContextService timeContextService;
@Tool("使用自然语言查询能源数据库。" +
"当用户需要查询能耗数据、统计分析、排名对比等" +
"需要数据库支持的问题时使用此工具。" +
"输入自然语言问题,返回查询结果表格。" +
"示例输入:'A栋上月每天用电量'、'本月能耗Top5区域'")
public String queryDatabase(String naturalLanguageQuestion) {
TimeContext timeContext = timeContextService.getCurrent();
// 1. 生成 SQL
GeneratedSql generated = sqlGenerator.generateSql(
naturalLanguageQuestion, timeContext);
log.info("Text-to-SQL 生成: '{}' → {}",
naturalLanguageQuestion, generated.sql());
// 2. 执行查询(含安全校验)
QueryResult result = queryExecutor.execute(generated);
if (!result.success()) {
// SQL 生成或执行失败时,返回错误信息
// Agent 可以根据错误信息决定重试或告知用户
return String.format("查询失败: %s\n生成的SQL: %s",
result.error(), generated.sql());
}
return String.format("""
查询问题: %s
生成的SQL: %s
查询结果 (%d 行, 耗时 %dms):
%s
""",
naturalLanguageQuestion,
generated.sql(),
result.rowCount(),
result.durationMs(),
result.formattedResult()
);
}
}
调用示例:
bash
# 用户: "A栋上个月每天的用电量是多少?排个序"
# Agent 内部执行:
# 1. 调用 TextToSqlTool.queryDatabase("A栋上个月每天的用电量,按日期排序")
# 2. 生成 SQL:
# SELECT consume_date AS 日期, electricity_kwh AS 用电量
# FROM energy_daily_consumption
# WHERE area_name = 'A栋办公楼'
# AND consume_date >= '2026-07-01'
# AND consume_date < '2026-08-01'
# ORDER BY consume_date
# FETCH FIRST 1000 ROWS ONLY
# 3. 安全校验通过
# 4. 执行并返回 Markdown 表格
# 5. Agent 基于表格数据生成自然语言回答
四、Human-in-the-Loop:关键决策让人类把关
4.1 为什么不能完全自动化?
Agent 的自主能力越强,潜在的风险也越大。在企业环境中,以下操作 不应该 由 Agent 全自动执行:
┌──────────────────────────────────────────────────────────┐
│ 需要人工确认的操作类型 │
│ │
│ 高风险操作 │
│ · 发送告警通知给领导/客户(发出去就收不回来) │
│ · 调整设备运行参数(可能影响生产安全) │
│ · 提交采购申请(涉及资金) │
│ │
│ 不可逆操作 │
│ · 删除历史数据 │
│ · 修改告警规则 │
│ · 归档/关闭工单 │
│ │
│ 模糊判断 │
│ · Agent 对自身回答不确信时(置信度低) │
│ · 多个方案需要业务判断时 │
│ · 涉及主观评价的决策 │
│ │
└──────────────────────────────────────────────────────────┘
4.2 HITL 交互模式设计
Human-in-the-Loop 有三种主要的交互模式:
模式1: 审批门控(Approval Gate)
Agent 准备好操作方案 → 暂停 → 等人审批 → 通过则执行 / 拒绝则跳过
Agent: "我准备向张工发送告警通知:
'C栋能耗8734kWh,超出均值84%'
[确认发送] [修改内容] [取消]"
模式2: 主动求助(Escalation)
Agent 遇到超出能力范围的问题 → 转交人类处理
Agent: "这个问题涉及设备硬件故障判断,
超出了我的能力范围,正在转接给运维工程师..."
模式3: 人类纠错(Correction)
Agent 给出回答 → 人类发现问题并纠正 → Agent 学习纠正
用户: "不对,B栋的负责人是李工不是张工"
Agent: "明白了,已更正。B栋负责人是李工,
我这就通知李工。"
4.3 HITL 框架实现
java
/**
* 人工确认请求
*
* 当 Agent 需要人工确认时,创建一个 PendingConfirmation
* 并暂停当前执行流程,等待人类响应。
*/
public record PendingConfirmation(
String confirmationId,
String sessionId,
String agentMessage, // Agent 展示给用户的说明
String operationDescription, // 即将执行的操作描述
ConfirmationType type, // APPROVAL / SELECTION / CORRECTION
Map<String, Object> context, // 操作上下文数据
Instant createdAt,
Instant expiresAt // 超时时间(超时则自动取消)
) {
public enum ConfirmationType {
APPROVAL, // 审批:确认 / 拒绝
SELECTION, // 选择:从多个方案中选一个
CORRECTION // 纠错:用户修改 Agent 的输出
}
}
/**
* Human-in-the-Loop 管理器
*
* 核心设计:
* 1. Agent 执行到需要人工确认的步骤时,创建 Confirmation 请求
* 2. 通过 WebSocket/SSE 推送给用户前端
* 3. 用户响应后,将结果注入 Agent 的执行流
* 4. 超时未响应:根据策略自动处理(取消/降级/通知管理员)
*/
@Service
public class HumanInTheLoopManager {
private final ConfirmationRepository confirmRepo;
private final SimpMessagingTemplate messaging; // WebSocket
private final ScheduledExecutorService scheduler;
/**
* 请求人工确认(Agent 端调用)
*
* 这个方法会阻塞 Agent 的执行线程,直到:
* - 用户响应(确认/拒绝/修改)
* - 超时自动处理
*/
public ConfirmationResponse requestConfirmation(
String sessionId,
String agentMessage,
String operationDescription,
ConfirmationType type,
Map<String, Object> context,
Duration timeout) {
// 1. 创建确认请求
PendingConfirmation confirmation = new PendingConfirmation(
UUID.randomUUID().toString(),
sessionId,
agentMessage,
operationDescription,
type,
context,
Instant.now(),
Instant.now().plus(timeout)
);
confirmRepo.save(confirmation);
// 2. 推送给前端(WebSocket)
messaging.convertAndSend(
"/topic/confirmation/" + sessionId,
confirmation);
log.info("等待人工确认: id={}, type={}, timeout={}",
confirmation.confirmationId(), type, timeout);
// 3. 阻塞等待用户响应
CompletableFuture<ConfirmationResponse> future =
new CompletableFuture<>();
pendingFutures.put(confirmation.confirmationId(), future);
// 4. 设置超时处理
scheduler.schedule(() -> {
if (!future.isDone()) {
log.warn("人工确认超时: id={}",
confirmation.confirmationId());
future.complete(
ConfirmationResponse.timeout(
confirmation.confirmationId()));
confirmRepo.updateStatus(
confirmation.confirmationId(), "TIMEOUT");
}
}, timeout.toMillis(), TimeUnit.MILLISECONDS);
try {
return future.get(timeout.toMillis() + 1000,
TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
return ConfirmationResponse.timeout(
confirmation.confirmationId());
}
}
/**
* 用户响应确认(前端回调)
*
* 前端通过 REST API 提交用户的确认/拒绝/修改操作,
* 这里唤醒等待中的 Agent 执行线程。
*/
@Transactional
public void submitConfirmationResponse(
String confirmationId,
ConfirmationResponse response) {
confirmRepo.updateStatus(confirmationId,
response.action().name());
CompletableFuture<ConfirmationResponse> future =
pendingFutures.remove(confirmationId);
if (future != null) {
future.complete(response);
}
}
}
/**
* 用户响应
*/
public record ConfirmationResponse(
String confirmationId,
Action action,
String userComment, // 用户备注(如修改意见)
Map<String, Object> modifiedContext // 修改后的上下文
) {
public enum Action {
APPROVED, // 批准执行
REJECTED, // 拒绝执行
MODIFIED, // 修改后执行
TIMEOUT // 超时
}
public static ConfirmationResponse timeout(String id) {
return new ConfirmationResponse(
id, Action.TIMEOUT, null, null);
}
}
4.4 在 Agent 工具中集成 HITL
java
@Component
public class EnergyToolsWithHITL {
private final HumanInTheLoopManager hitlManager;
private final NotificationService notificationService;
/**
* 发送告警通知(需要人工确认)
*
* 流程:
* 1. Agent 准备好通知内容
* 2. 暂停,向用户展示通知预览
* 3. 用户确认 → 实际发送
* 4. 用户修改 → 用修改后的内容发送
* 5. 用户拒绝 → 不发送
* 6. 超时 → 不发送(安全默认行为)
*/
@Tool("向指定负责人发送能耗告警通知。" +
"此操作会实际发送通知,因此需要用户确认后才能执行。")
public String sendAlertWithApproval(String recipient,
String alertLevel,
String message) {
// 获取当前会话 ID
String sessionId = SessionContext.getCurrentSessionId();
// 请求人工确认
ConfirmationResponse response =
hitlManager.requestConfirmation(
sessionId,
String.format("""
我准备发送一条%s告警通知,请确认:
接收人:%s
告警级别:%s
通知内容:%s
""", alertLevel, recipient, alertLevel, message),
"发送告警通知",
PendingConfirmation.ConfirmationType.APPROVAL,
Map.of("recipient", recipient,
"alertLevel", alertLevel,
"message", message),
Duration.ofMinutes(5) // 5 分钟超时
);
return switch (response.action()) {
case APPROVED -> {
boolean sent = notificationService.sendAlert(
recipient, alertLevel, message);
yield sent ? "告警通知已发送" : "通知发送失败";
}
case MODIFIED -> {
// 用户修改了通知内容
String modifiedMsg = (String)
response.modifiedContext().get("message");
boolean sent = notificationService.sendAlert(
recipient, alertLevel, modifiedMsg);
yield sent
? "已按修改后的内容发送告警通知"
: "通知发送失败";
}
case REJECTED -> "已取消发送告警通知";
case TIMEOUT -> "确认超时,已取消发送(如需发送请重试)";
};
}
}
4.5 前端交互组件(WebSocket 推送)
java
/**
* 确认请求的 WebSocket 推送 + REST 回调
*/
@RestController
@RequestMapping("/api/hitl")
public class HITLController {
@Autowired
private HumanInTheLoopManager hitlManager;
/**
* 用户提交确认响应
*/
@PostMapping("/confirmations/{confirmationId}/respond")
public ResponseEntity<String> respond(
@PathVariable String confirmationId,
@RequestBody ConfirmationResponse response) {
hitlManager.submitConfirmationResponse(
confirmationId, response);
return ResponseEntity.ok("确认已提交");
}
}
前端 WebSocket 监听示例(Vue/React 同理):
javascript
// 前端:监听确认请求并展示给用户
const ws = new WebSocket('ws://localhost:8080/ws');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'CONFIRMATION_REQUEST') {
// 弹出确认对话框
showConfirmationDialog({
message: data.agentMessage,
operation: data.operationDescription,
type: data.type, // APPROVAL / SELECTION / CORRECTION
onApprove: () => {
fetch(`/api/hitl/confirmations/${data.confirmationId}/respond`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
confirmationId: data.confirmationId,
action: 'APPROVED'
})
});
},
onReject: () => {
fetch(`/api/hitl/confirmations/${data.confirmationId}/respond`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
confirmationId: data.confirmationId,
action: 'REJECTED',
userComment: '不需要发送'
})
});
},
onModify: (modifiedContent) => {
fetch(`/api/hitl/confirmations/${data.confirmationId}/respond`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
confirmationId: data.confirmationId,
action: 'MODIFIED',
modifiedContext: { message: modifiedContent }
})
});
}
});
}
};
4.6 超时策略设计
java
/**
* 超时策略配置
*
* 不同操作类型的超时处理策略不同:
*
* 通知类操作:超时 → 取消(安全默认)
* 数据查询类:超时 → 自动执行(查询无风险)
* 定时任务类:超时 → 通知管理员代为决策
*/
@Component
public class TimeoutPolicyConfig {
public TimeoutAction getPolicy(String operationType) {
return switch (operationType) {
// 高风险操作:超时取消
case "send_alert", "adjust_parameter",
"submit_purchase_order" ->
TimeoutAction.CANCEL;
// 只读操作:超时自动执行
case "query_data", "generate_preview" ->
TimeoutAction.AUTO_EXECUTE;
// 定时任务:超时通知管理员
case "scheduled_report", "batch_operation" ->
TimeoutAction.ESCALATE_TO_ADMIN;
default -> TimeoutAction.CANCEL; // 默认取消
};
}
}
五、Text-to-SQL + HITL 的完整协作流程
用户: "帮我分析A栋上个月用电情况,如果偏高就通知张工"
┌─── Agent 推理链 ──────────────────────────────────────────────┐
│ │
│ Step 1: 调用 TextToSqlTool │
│ → "A栋上月每天用电量" → SQL → 查询 → 返回31行数据 │
│ │
│ Step 2: LLM 分析数据 │
│ → "A栋7月日均用电1523kWh,较6月增长18%,7月15日峰值2100kWh" │
│ │
│ Step 3: 判断是否偏高 │
│ → "18%增长超过10%阈值,判定为偏高" │
│ │
│ Step 4: 准备告警通知 │
│ → "张工您好,A栋7月日均用电1523kWh,较6月增长18%, │
│ 7月15日出现峰值2100kWh,建议排查空调系统" │
│ │
│ Step 5: [HITL] 请求用户确认 ← 暂停执行 │
│ → 推送确认请求到前端 │
│ → 用户看到: "准备发送通知给张工,内容如下... │
│ [确认发送] [修改内容] [取消]" │
│ │
│ Step 6: 用户点击 [确认发送] │
│ → Agent 继续执行 → 通知发送成功 │
│ │
│ Step 7: 最终回复 │
│ → "A栋7月用电分析完成,能耗偏高18%%,已通知张工排查。" │
│ │
└───────────────────────────────────────────────────────────────┘
六、达梦数据库的 SQL 方言适配要点
由于项目使用达梦数据库,这里特别总结一些 Text-to-SQL 中需要注意的语法差异:
java
/**
* 达梦 SQL 方言注意事项
*
* 在 Prompt 中必须明确告知模型以下差异点,
* 否则模型默认会生成 MySQL 语法。
*/
public class DmSqlDialectNotes {
public static final String DIALECT_NOTES = """
## 达梦数据库(DM)与 MySQL 的关键差异
1. 分页:
- MySQL: LIMIT 10 OFFSET 20
- DM: FETCH FIRST 10 ROWS ONLY
或 OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY
2. 日期函数:
- 当前时间: CURRENT_DATE / CURRENT_TIMESTAMP
- 日期加减: DATEADD(day, -7, CURRENT_DATE)
- 月份差: DATEDIFF(month, date1, date2)
- 格式化: TO_CHAR(date, 'YYYY-MM-DD')
- 字符串转日期: TO_DATE('2026-08-01', 'YYYY-MM-DD')
3. 字符串:
- 拼接: str1 || str2(不是 CONCAT)
- 子串: SUBSTR(str, start, length)
- 长度: LENGTH(str)
4. 条件表达式:
- IF: CASE WHEN ... THEN ... ELSE ... END
- DM 不支持 IF() 函数
5. 分组:
- GROUP BY 中的列必须在 SELECT 中出现
- 或使用分析函数: ROW_NUMBER() OVER(PARTITION BY ... ORDER BY ...)
6. 空值处理:
- NVL(column, default)(类似 MySQL 的 IFNULL)
""";
}
七、九篇文章完整知识图谱
┌────────────────────────────────────────────────────────────────────┐
│ Java AI Agent 实战系列 · 完整路线图 │
│ │
│ 第一篇 ── 单体 Agent 构建 │
│ │ Tool Calling + RAG + Memory │
│ │ │
│ 第二篇 ── Multi-Agent 协作 + MCP 协议 │
│ │ LangGraph4j 编排 + MCP 工具标准化 │
│ │ │
│ 第三篇 ── 评估体系 + A2A 协议 │
│ │ 四维评估 + LLM-as-Judge + 跨组织通信 │
│ │ │
│ 第四篇 ── 长期记忆 + 用户画像 │
│ │ 三层记忆架构 + 画像自动提取 │
│ │ │
│ 第五篇 ── 可观测性 + 成本优化 │
│ │ 链路追踪 + 会话回放 + Token 精算 │
│ │ │
│ 第六篇 ── RAG 深度优化 │
│ │ 语义切分 + 混合检索 + 重排序 │
│ │ │
│ 第七篇 ── Agent 安全攻防 │
│ │ Prompt 注入防御 + 工具权限沙箱 + 幻觉检测 │
│ │ │
│ 第八篇 ── 国产大模型 + 高级推理模式 │
│ │ 四大模型实战 + Plan-Execute + Reflection + S-C │
│ │ │
│ 第九篇 ── Text-to-SQL + Human-in-the-Loop (本篇) │
│ 自然语言查数据库 + 关键操作人工确认 │
│ │
│ 能力进阶路线: │
│ 能构建 → 能协作 → 能评估 → 能记忆 → 能运维 → 能检索 │
│ → 能防御 → 能落地 → 能查数据 + 能人机协作 │
│ │
└────────────────────────────────────────────────────────────────────┘