九:Text-to-SQL 智能数据查询与 Human-in-the-Loop 人机协作

系列第九篇:Text-to-SQL 智能数据查询与 Human-in-the-Loop 人机协作

前言

前八篇文章覆盖了 Agent 的构建、协作、评估、记忆、运维、检索、安全和推理。但在企业实践中,有两个高频需求始终被反复提及:

需求一:"用户能不能用自然语言直接查数据库?不用写 SQL、不用看报表,直接问'A 栋上个月每天用电量是多少'就能出结果?"

需求二 :"Agent 自主执行一些操作(发通知、调参数)太危险了,能不能让它做到关键步骤时停下来等人确认?"

这两个需求分别对应 Text-to-SQLHuman-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 (本篇)                 │
│           自然语言查数据库 + 关键操作人工确认                      │
│                                                                    │
│  能力进阶路线:                                                    │
│  能构建 → 能协作 → 能评估 → 能记忆 → 能运维 → 能检索            │
│  → 能防御 → 能落地 → 能查数据 + 能人机协作                       │
│                                                                    │
└────────────────────────────────────────────────────────────────────┘
相关推荐
AI科技先锋报11 分钟前
AI数据资产管理平台全景洞察:从治理基座到智能体应用
大数据·人工智能
章老师说22 分钟前
BFE v1.8.6 正式发布:AI 网关计费精细化、Claude 协议与会话亲和性升级
运维·人工智能·ai·负载均衡·ai-native
冬奇Lab36 分钟前
一天一个开源项目(第206篇):T3 Code - AI 编程 Agent 的统一控制台
人工智能·开源·资讯
IT古董36 分钟前
AI 资讯日报 | 2026年9月1日 :混元 Hy4 开源、DeepSeek 多模态登顶、可灵获国家队 14 亿注资
人工智能·开源
冬奇Lab36 分钟前
Code Agent 解剖(18):AgentTeams——TeamFanout 与 TeamCollect 的并行机制
人工智能
ai小陈43 分钟前
FramePack图生视频云端部署实战:从单图输入到视频输出的完整流程
服务器·人工智能·安全·ai·音视频·gpu算力
程序员-Benothing1 小时前
OpenAI断供Cursor:当AI巨头开始“清理门户“,开源生态的中立性还能撑多久?
人工智能·开源·大模型
光锥智能1 小时前
机器人走进大众时代加速到来:郎朗跨界合作启元机器人,消费级人形机器人开启直播发售
人工智能
Jialu.1 小时前
中文 BERT 多任务分类项目:从模型结构到训练细节
人工智能·pytorch·分类·微软·nlp·bert