用Function Call构建SQL生成Agent:自然语言转数据库查询
用Function Call构建SQL生成Agent:自然语言转数据库查询
适读人群:有LLM基础,希望落地Text-to-SQL实战的Java后端工程师 | 阅读时长:约20分钟
开篇故事
有一次参加公司内部的AI项目展示,有个团队做了一个"数据查询助手":用户用自然语言描述需求,系统自动生成SQL并执行,返回结果。现场演示时,产品经理随手说了一句"给我看看上个月销售额最高的10个商品",系统直接给出了数据,连SQL都不用写。
我当时很好奇技术实现,会后去跟他们聊。发现核心就是Function Call——LLM先"探索"数据库结构(调用get_table_schema工具),再生成SQL(调用execute_sql工具),两步完成自然语言到结果的转化。
这个架构我后来自己实现了一版,踩了不少坑,也积累了一些优化经验。今天完整讲一遍。
一、架构设计
SQL生成Agent的核心思路:给LLM提供两类工具:
- 元数据工具:让LLM了解有哪些表、每个表有哪些字段
- 执行工具:让LLM执行生成的SQL并获取结果
LLM的工作流程:探索schema → 理解表结构 → 生成SQL → 执行 → 解读结果 → 回答用户
二、完整实现代码
2.1 工具定义和数据库元数据工具
@Configuration(proxyBeanMethods = false)
public class SqlAgentToolsConfig {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private ToolArgumentSanitizer sanitizer; // 安全校验,来自上一篇
// 工具1:列出所有表
@Bean
@Description("列出数据库中所有可用的表名。" +
"在开始查询之前,先调用此工具了解数据库有哪些表。")
public Function<ListTablesRequest, ListTablesResponse> listTables() {
return request -> {
List<String> tables = jdbcTemplate.queryForList(
"SELECT table_name FROM information_schema.tables " +
"WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'",
String.class
);
return new ListTablesResponse(tables);
};
}
// 工具2:获取表结构
@Bean
@Description("获取指定表的字段结构信息,包括字段名、类型、是否为空、注释等。" +
"在生成SQL之前,必须先了解相关表的结构。" +
"可以一次查询多个表,用英文逗号分隔。")
public Function<GetSchemaRequest, GetSchemaResponse> getTableSchema() {
return request -> {
Map<String, List<ColumnInfo>> schemas = new LinkedHashMap<>();
for (String tableName : request.tableNames()) {
// 安全检查:表名只允许字母数字下划线
if (!tableName.matches("[a-zA-Z_][a-zA-Z0-9_]*")) {
continue;
}
List<ColumnInfo> columns = jdbcTemplate.query(
"SELECT column_name, data_type, is_nullable, column_comment " +
"FROM information_schema.columns " +
"WHERE table_schema = DATABASE() AND table_name = ? " +
"ORDER BY ordinal_position",
(rs, rowNum) -> new ColumnInfo(
rs.getString("column_name"),
rs.getString("data_type"),
"YES".equals(rs.getString("is_nullable")),
rs.getString("column_comment")
),
tableName
);
schemas.put(tableName, columns);
}
return new GetSchemaResponse(schemas);
};
}
// 工具3:执行只读SQL(最重要的安全控制)
@Bean
@Description("执行SQL查询并返回结果。" +
"只支持SELECT语句,不允许INSERT/UPDATE/DELETE/DROP等修改操作。" +
"结果最多返回100行,如需更多请使用LIMIT和OFFSET分页。" +
"复杂查询请先验证表结构再生成SQL,避免字段名错误。")
public Function<ExecuteSqlRequest, ExecuteSqlResponse> executeReadonlySql() {
return request -> {
String sql = request.sql().trim();
// 安全检查1:只允许SELECT
if (!sql.toUpperCase().startsWith("SELECT")) {
return new ExecuteSqlResponse(false, null,
"Only SELECT statements are allowed");
}
// 安全检查2:SQL注入检测
Set<String> dangerousKeywords = Set.of(
"DROP", "DELETE", "TRUNCATE", "UPDATE", "INSERT",
"ALTER", "CREATE", "EXEC", "EXECUTE", "INTO OUTFILE"
);
String upperSql = sql.toUpperCase();
for (String kw : dangerousKeywords) {
if (upperSql.contains(kw)) {
return new ExecuteSqlResponse(false, null,
"Forbidden keyword detected: " + kw);
}
}
// 安全检查3:强制添加LIMIT(如果没有)
if (!upperSql.contains("LIMIT")) {
sql = sql + " LIMIT 100";
}
try {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql);
return new ExecuteSqlResponse(true, rows, null);
} catch (DataAccessException e) {
return new ExecuteSqlResponse(false, null,
"SQL execution error: " + e.getMessage());
}
};
}
}2.2 参数类型定义
// list_tables工具(无参数,但需要一个占位类型)
@JsonClassDescription("列出所有表的请求(无需参数)")
public record ListTablesRequest() {}
@JsonClassDescription("表名列表响应")
public record ListTablesResponse(
@JsonPropertyDescription("数据库中所有表的名称列表")
List<String> tables
) {}
// get_table_schema工具
@JsonClassDescription("获取表结构的请求参数")
public record GetSchemaRequest(
@JsonProperty(required = true, value = "table_names")
@JsonPropertyDescription("需要查询结构的表名列表,多个表用英文逗号分隔后传入数组。" +
"例如:['orders', 'products'] 表示同时查orders和products两个表的结构。")
List<String> tableNames
) {}
// execute_sql工具
@JsonClassDescription("执行SQL查询的请求参数")
public record ExecuteSqlRequest(
@JsonProperty(required = true, value = "sql")
@JsonPropertyDescription("要执行的SQL查询语句。" +
"必须是SELECT语句。" +
"如果可能返回大量数据,请加LIMIT限制行数(最大100行)。" +
"确保表名和字段名与get_table_schema返回的完全一致。")
String sql
) {}
@JsonClassDescription("SQL执行结果")
public record ExecuteSqlResponse(
boolean success,
List<Map<String, Object>> rows,
String errorMessage
) {}2.3 SQL Agent的主流程
@Service
public class SqlQueryAgent {
@Autowired
private ChatClient chatClient;
@Autowired
private SqlQueryAuditLogger auditLogger;
private static final String SYSTEM_PROMPT = """
你是一个数据分析助手,能够帮助用户通过自然语言查询数据库。
工作流程:
1. 先调用 list_tables 了解有哪些表可用
2. 调用 get_table_schema 了解相关表的字段结构
3. 根据用户需求生成正确的SQL语句
4. 调用 execute_readonly_sql 执行SQL
5. 用清晰、友好的语言解释查询结果
重要限制:
- 只能执行SELECT查询,不能修改数据
- 如果用户的请求需要修改数据,礼貌地拒绝并说明原因
- 不要向用户展示完整的SQL语句(除非用户明确要求)
- 如果查询结果为空,要告知用户
- 对数字类型的结果要格式化展示(如金额保留两位小数)
""";
public SqlQueryResult query(String userId, String userQuestion) {
long startTime = System.currentTimeMillis();
try {
String response = chatClient.prompt()
.system(SYSTEM_PROMPT)
.user(userQuestion)
.functions("listTables", "getTableSchema", "executeReadonlySql")
.call()
.content();
auditLogger.log(userId, userQuestion, response,
System.currentTimeMillis() - startTime);
return SqlQueryResult.success(response);
} catch (Exception e) {
log.error("SQL Agent failed for user: {}, question: {}", userId, userQuestion, e);
auditLogger.logError(userId, userQuestion, e.getMessage());
return SqlQueryResult.error("查询失败,请稍后再试");
}
}
}三、踩坑实录
坑1:LLM生成的SQL字段名大小写不一致
现象:表结构里字段是orderDate,LLM生成了ORDER_DATE,SQL执行报错。
解决:在system prompt里强调"字段名必须与get_table_schema返回的完全一致,包括大小写"。还可以在get_table_schema返回结果里加上示例SQL片段。
坑2:表之间的关联关系LLM不知道
现象:用户问"每个客户的订单总数",LLM生成的SQL缺少JOIN条件,导致笛卡尔积,数据量爆炸。
解决:在getTableSchema的返回结果里,增加外键关系信息;或者专门增加一个get_table_relationships工具。
坑3:大表全表扫描打垮数据库
LLM可能生成没有WHERE条件的SELECT,对大表全表扫描。
解决:在SQL执行前检查执行计划,或者对没有WHERE条件且表数据量超过阈值的查询拒绝执行:
// 执行前估算影响行数
String explainSql = "EXPLAIN " + sql;
List<Map<String, Object>> explainResult = jdbcTemplate.queryForList(explainSql);
// 检查rows列(MySQL EXPLAIN的rows列估算扫描行数)
long estimatedRows = ((Number) explainResult.get(0).get("rows")).longValue();
if (estimatedRows > MAX_SCAN_ROWS) {
return new ExecuteSqlResponse(false, null,
"Query would scan too many rows (" + estimatedRows + "). Please add more filters.");
}坑4:对话上下文丢失导致多轮查询失败
用户第一轮问了A,第二轮说"再给我看B的数据",但"B"是相对第一轮的上下文。如果每次都是独立对话,LLM不知道B是什么。
解决:维护会话上下文,把历史对话保存并传给LLM。Spring AI的ChatMemory可以帮助实现。
五、总结与延伸
Text-to-SQL是Function Call的典型应用场景,核心三要素:
- 元数据工具:让LLM知道数据库结构
- 执行工具:让LLM能执行SQL并看到结果
- 安全控制:严格限制只读,防止SQL注入
这套架构可以扩展到任何"自然语言操作结构化数据"的场景:API调用、表格分析、报表生成等。
下一篇聊多步工具调用(Multi-step Function Call):如何协调多个工具完成复杂任务。
