数据库Agent架构
要点
- 数据库 Agent 要解决的核心问题是:用户用自然语言描述需求,Agent 把它翻译成数据库操作并执行,再把结果转回自然语言
- 数据库 Agent 的典型场景有三类:自然语言查询(「上个月销量最高的商品是什么」)、数据分析(「对比这两个月的用户增长趋势」)、数据写入(「把这条订单状态改成已发货」)
- 架构分四层:意图理解、SQL 生成、安全校验、执行与结果格式化,每一层职责独立
- 工具设计按操作类型拆分为 query、insert、update、delete 四种,每种工具有不同的安全约束
- 安全防护必须做:SQL 注入检测、读写权限分离、操作白名单、敏感表保护、结果行数限制
- 错误处理需要区分 SQL 语法错误、权限拒绝、超时等不同类型,把错误信息返回给 LLM 让它自行修正
- 数据库 Agent 不宜直接暴露给外部用户,更适合作为内部工具或经过中间层过滤后使用
1. 什么是数据库Agent
前面几篇已经搭建了 Agent 的基本框架:LLM 负责推理,工具负责执行,循环把两者串起来。到目前为止,我们的工具都是调用外部 API——查天气、搜索网页。但在实际业务中,有一类需求出现频率非常高,值得单独拿出来讨论:让 Agent 直接操作数据库。
数据库 Agent 的工作方式是这样的:
- 用户用自然语言描述需求:「帮我查一下上个月注册了多少新用户」
- Agent 理解意图,生成对应的 SQL 语句
- 在执行前检查安全性:是只读查询还是写操作?有没有越权风险?
- 执行 SQL,拿到原始结果
- 把结果格式化后交给 LLM,生成自然语言回复:「上个月共注册了 1,247 个新用户,比前一个月增长了 12%」
本质上,数据库 Agent 在用户和数据库之间加了一层自然语言接口。用户不需要会写 SQL,也不需要知道表结构长什么样。他只需要说出想要什么,Agent 负责翻译和执行。
2. 数据库Agent的典型场景
在动手写代码之前,先看看哪些场景需要数据库 Agent。场景不同,对工具设计和安全策略的要求也不同。
自然语言查询
这是最常见的场景。用户说「本月销售额超过 1 万的客户有哪些」,Agent 生成 SELECT 语句,执行后把结果用自然语言描述出来。
特点:只读操作,风险相对可控,重点在查询准确性和结果格式化。
数据分析
用户说「对比一下 Q1 和 Q2 各品类的销售趋势,画个图表」。Agent 可能需要执行多条 SQL——先查 Q1 的数据,再查 Q2 的数据,甚至需要做分组、排序、计算同比环比——然后把结果整理成 LLM 能分析的格式,再生成分析报告。
特点:可能涉及多轮查询和中间计算,Agent 需要根据第一次查询结果决定下一步查什么。
数据写入
用户说「把订单 #20260621 的状态改成已发货」。Agent 生成 UPDATE 语句并执行。
特点:写操作风险高,必须有权限控制和操作审计。不是所有用户都应该有写权限,也不是所有表都应该允许被 Agent 修改。
这三类场景对安全的要求递进:查询 < 分析 < 写入。后面设计工具和权限时,需要按场景分别处理。
3. 架构分层
把数据库 Agent 的工作流程拆开,可以归纳出四层结构。每一层职责独立,方便单独测试和替换。
用户输入(自然语言)
↓
意图理解层:理解用户想做什么(查询?分析?写入?)
↓
SQL 生成层:根据表结构和意图生成 SQL
↓
安全校验层:检查 SQL 是否合法、是否有越权操作
↓
执行与格式化层:执行 SQL,把结果交给 LLM 生成回复
意图理解层
这一层由 LLM 承担。LLM 根据用户的自然语言和系统 Prompt 中提供的表结构信息,判断用户想做什么类型的操作,需要哪些表、哪些字段。
关键点是系统 Prompt 的设计。你需要把数据库的表结构告诉 LLM,但不能把所有表都给出去——给得太多,模型容易选错表;给得太少,模型生成不了正确的 SQL。
// src/agents/database/prompt.ts
// 只暴露允许 Agent 访问的表和字段
const DATABASE_SCHEMA = `
可用的表结构:
- users: id(integer), email(text), name(text), role(text), created_at(timestamp)
- orders: id(integer), user_id(integer), status(text), total(decimal), created_at(timestamp)
- products: id(integer), name(text), price(decimal), stock(integer), category(text)
规则:
1. 只能查询上述表,不能访问其他表
2. 写操作(INSERT/UPDATE/DELETE)需要用户明确授权
3. 查询结果默认限制 100 行
4. 不要生成 DROP、TRUNCATE、ALTER 等 DDL 语句
`SQL 生成层
LLM 在理解意图后,根据表结构生成 SQL。这一层不需要你写额外代码——它是 LLM 的推理结果。你需要做的是在 Prompt 里给出足够的约束,让模型生成的 SQL 尽可能正确。
一个常见的做法是在 Prompt 中放几个 few-shot 示例:
// src/agents/database/prompt.ts
const SQL_EXAMPLES = `
示例 1:
用户:上个月注册了多少新用户
SQL:SELECT COUNT(*) AS new_users FROM users WHERE created_at >= date('now', '-1 month')
示例 2:
用户:销售额最高的 5 个商品
SQL:SELECT p.name, SUM(o.total) AS total_sales FROM orders o JOIN products p ON o.product_id = p.id GROUP BY p.name ORDER BY total_sales DESC LIMIT 5
示例 3:
用户:把订单 12345 的状态改成已发货
SQL:UPDATE orders SET status = 'shipped' WHERE id = 12345
`有了表结构和示例,LLM 生成 SQL 的准确率会明显提升。
安全校验层
这一层是数据库 Agent 和普通工具调用最大的区别。普通工具调用的参数结构是固定的(JSON Schema),校验规则明确。但 SQL 是一段自由文本,可能的变化非常多,必须在执行前做严格检查。
后面第 6 节会单独展开。
执行与结果格式化层
SQL 通过安全检查后,执行并拿到结果。原始结果通常是行和列的数组,需要转成 LLM 能理解的格式:
// src/agents/database/formatter.ts
// 把数据库原始结果转成 LLM 能理解的文本
export function formatQueryResult(
columns: string[],
rows: unknown[][],
): string {
if (rows.length === 0) return '查询结果为空,没有匹配的数据。'
// 转成 CSV 格式,LLM 对这种格式的理解比较好
const header = columns.join(' | ')
const separator = columns.map(() => '---').join(' | ')
const body = rows
.map((row) => row.map((v) => String(v ?? 'NULL')).join(' | '))
.join('\n')
return `查询结果(共 ${rows.length} 行):\n${header}\n${separator}\n${body}`
}把格式化后的文本塞回对话历史,LLM 就能基于数据生成自然语言回复了。
4. 工具设计
第 2 节提到三类场景,每类场景对应不同的工具。按操作类型拆分,数据库 Agent 需要四个工具:
| 工具名 | 操作类型 | 风险等级 | 说明 |
|---|---|---|---|
query | SELECT | 低 | 只读查询,默认返回最多 100 行 |
insert | INSERT | 中 | 插入数据,需要指定表名和字段 |
update | UPDATE | 高 | 更新数据,必须有 WHERE 条件 |
delete | DELETE | 高 | 删除数据,必须有 WHERE 条件 |
工具定义
// src/agents/database/tools.ts
import type { ToolDefinition } from '../../tools/types'
export const databaseTools: ToolDefinition[] = [
{
name: 'query',
description: '执行只读 SQL 查询,返回结果。用于查询数据、统计分析。只能执行 SELECT 语句。',
parameters: {
type: 'object',
properties: {
sql: {
type: 'string',
description: 'SELECT 语句,例如:SELECT * FROM users WHERE role = \'admin\' LIMIT 10'
},
reason: {
type: 'string',
description: '执行这条查询的原因,用于审计日志'
}
},
required: ['sql', 'reason']
}
},
{
name: 'insert',
description: '向数据库插入一条记录。用于新增数据。',
parameters: {
type: 'object',
properties: {
table: { type: 'string', description: '目标表名' },
data: { type: 'object', description: '要插入的字段和值' },
reason: { type: 'string', description: '插入原因' }
},
required: ['table', 'data', 'reason']
}
},
{
name: 'update',
description: '更新数据库中的记录。必须提供 WHERE 条件,不能无条件更新全表。',
parameters: {
type: 'object',
properties: {
table: { type: 'string', description: '目标表名' },
data: { type: 'object', description: '要更新的字段和新值' },
where: { type: 'string', description: 'WHERE 条件,例如:id = 123' },
reason: { type: 'string', description: '更新原因' }
},
required: ['table', 'data', 'where', 'reason']
}
},
{
name: 'delete',
description: '删除数据库中的记录。必须提供 WHERE 条件,不能无条件删除全表。',
parameters: {
type: 'object',
properties: {
table: { type: 'string', description: '目标表名' },
where: { type: 'string', description: 'WHERE 条件,例如:id = 123' },
reason: { type: 'string', description: '删除原因' }
},
required: ['table', 'where', 'reason']
}
}
]insert、update、delete 三个工具没有直接让 LLM 写完整 SQL,而是把操作拆成了结构化参数(表名、数据、条件)。这样做的好处是:你的代码可以用参数拼装 SQL,而不是拼接 LLM 生成的自由文本,从根源上降低注入风险。
query 工具比较特殊——它需要 LLM 直接生成 SQL 语句,因为 SELECT 的变化实在太多,很难用结构化参数覆盖所有场景。这也意味着 query 工具的安全校验要更严格。
工具执行
// src/agents/database/executor.ts
import type { ToolContext } from '../../tools/types'
import { validateSQL, isReadOnly } from './security'
type DatabaseAgentContext = ToolContext & {
db: D1Database
allowedTables: string[]
}
export async function executeDatabaseTool(
toolName: string,
args: Record<string, unknown>,
ctx: DatabaseAgentContext
): Promise<unknown> {
switch (toolName) {
case 'query':
return executeQuery(args.sql as string, ctx)
case 'insert':
return executeInsert(args.table as string, args.data as Record<string, unknown>, ctx)
case 'update':
return executeUpdate(
args.table as string,
args.data as Record<string, unknown>,
args.where as string,
ctx
)
case 'delete':
return executeDelete(args.table as string, args.where as string, ctx)
default:
return { error: `未知工具:${toolName}` }
}
}
async function executeQuery(sql: string, ctx: DatabaseAgentContext) {
// 第一步:检查是否为只读语句
if (!isReadOnly(sql)) {
return { error: 'query 工具只允许执行 SELECT 语句' }
}
// 第二步:安全校验
const validationError = validateSQL(sql, ctx.allowedTables)
if (validationError) {
return { error: validationError }
}
// 第三步:强制加 LIMIT 防止返回过多数据
const limitedSQL = enforceLimit(sql, 100)
// 第四步:执行查询
try {
const result = await ctx.db.prepare(limitedSQL).all()
return {
columns: result.columns ?? Object.keys(result.results?.[0] ?? {}),
rows: result.results,
rowCount: result.results?.length ?? 0
}
} catch (err) {
return { error: `查询执行失败:${err instanceof Error ? err.message : '未知错误'}` }
}
}
async function executeInsert(
table: string,
data: Record<string, unknown>,
ctx: DatabaseAgentContext
) {
if (!ctx.allowedTables.includes(table)) {
return { error: `不允许操作表:${table}` }
}
const columns = Object.keys(data)
const placeholders = columns.map(() => '?').join(', ')
const sql = `INSERT INTO ${table} (${columns.join(', ')}) VALUES (${placeholders})`
try {
await ctx.db.prepare(sql).bind(...Object.values(data)).run()
return { success: true, message: `已向 ${table} 插入 1 条记录` }
} catch (err) {
return { error: `插入失败:${err instanceof Error ? err.message : '未知错误'}` }
}
}
// enforceLimit:如果 SQL 没有 LIMIT 子句,自动追加
function enforceLimit(sql: string, maxRows: number): string {
if (/LIMIT\s+\d+/i.test(sql)) return sql
return `${sql.replace(/;?\s*$/, '')} LIMIT ${maxRows}`
}executeUpdate 和 executeDelete 的结构类似,都需要检查表名是否在白名单中、WHERE 条件是否非空。这里不再重复列出。
5. SQL生成与执行流程
把第 3 节的架构分层和第 4 节的工具设计串起来,一个完整的数据库 Agent 请求流程如下:
// src/agents/database/agent.ts
import { Hono } from 'hono'
import type { AppEnv } from '../../types'
import { databaseTools } from './tools'
import { executeDatabaseTool } from './executor'
import { DATABASE_SCHEMA, SQL_EXAMPLES } from './prompt'
const dbAgentApp = new Hono<AppEnv>()
dbAgentApp.post('/query', async (c) => {
const { message, userId } = await c.req.json<{
message: string
userId: string
}>()
const systemPrompt = `你是一个数据库查询助手。根据用户的需求生成并执行 SQL,然后用自然语言回复结果。
${DATABASE_SCHEMA}
${SQL_EXAMPLES}
注意事项:
1. 查询操作使用 query 工具
2. 插入操作使用 insert 工具
3. 更新操作使用 update 工具,必须提供 WHERE 条件
4. 删除操作使用 delete 工具,必须提供 WHERE 条件
5. 不要编造数据,所有事实必须来自工具返回的结果
6. 如果查询结果为空,如实告知用户`
const messages = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: message }
]
// Agent 循环
const MAX_ROUNDS = 5
for (let i = 0; i < MAX_ROUNDS; i++) {
const response = await callLLM(messages, databaseTools)
// 没有工具调用,LLM 已生成最终回复
if (!response.toolCalls?.length) {
return c.json({ reply: response.text })
}
// 执行工具调用
messages.push({
role: 'assistant',
content: response.text,
tool_calls: response.toolCalls
})
for (const toolCall of response.toolCalls) {
const result = await executeDatabaseTool(
toolCall.name,
toolCall.arguments,
{
userId,
db: c.env.DB,
allowedTables: ['users', 'orders', 'products'],
signal: new AbortController().signal,
env: c.env as unknown as Record<string, string>
}
)
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify(result)
})
}
}
return c.json({ error: '达到最大执行轮次' }, 500)
})
export default dbAgentApp这段代码的结构和前面第 03 篇的工具调用循环一致,区别在于工具换成了数据库操作工具,执行器换成了数据库执行器。Agent 的框架不需要重新发明——换一组工具和执行器,就能适配不同的领域。
6. 安全防护
数据库 Agent 的安全问题比一般工具调用更突出。一个生成自由 SQL 的 Agent,如果防护不到位,可能导致数据泄露、数据被篡改、甚至整个数据库被删除。
以下是几道必须设的防线。
SQL 注入检测
query 工具允许 LLM 直接生成 SQL。如果 LLM 被用户的输入误导(prompt injection),可能生成包含恶意操作的 SQL。在执行前需要做一层检查:
// src/agents/database/security.ts
// 禁止出现在 SQL 中的关键词
const FORBIDDEN_KEYWORDS = [
'DROP', 'TRUNCATE', 'ALTER', 'CREATE',
'GRANT', 'REVOKE', 'EXEC', 'EXECUTE',
'xp_', 'sp_'
]
export function validateSQL(
sql: string,
allowedTables: string[]
): string | null {
const upperSQL = sql.toUpperCase()
// 检查禁止关键词
for (const keyword of FORBIDDEN_KEYWORDS) {
if (upperSQL.includes(keyword)) {
return `SQL 包含禁止操作:${keyword}`
}
}
// 检查是否引用了未授权的表
const tablePattern = /(?:FROM|JOIN|INTO|UPDATE|TABLE)\s+(\w+)/gi
let match
while ((match = tablePattern.exec(sql)) !== null) {
const tableName = match[1].toLowerCase()
if (!allowedTables.includes(tableName)) {
return `不允许访问表:${match[1]}`
}
}
// 检查是否包含子查询中的写操作
if (/\b(INSERT|UPDATE|DELETE)\b/i.test(sql)) {
return 'SELECT 语句中不允许包含写操作'
}
return null
}
export function isReadOnly(sql: string): boolean {
const trimmed = sql.trim().toUpperCase()
return trimmed.startsWith('SELECT') || trimmed.startsWith('WITH')
}这里的关键词检查是黑名单策略,适合挡住大部分明显危险的操作。但黑名单总有漏网之鱼,所以还需要结合白名单(allowedTables)一起使用。
读写权限分离
不是所有用户都应该有写权限。可以在工具注册阶段按用户角色过滤可用工具:
// src/agents/database/permissions.ts
import { databaseTools } from './tools'
import type { ToolDefinition } from '../../tools/types'
const READ_ONLY_TOOLS = ['query']
const WRITE_TOOLS = ['insert', 'update', 'delete']
export function getToolsForRole(userRole: string): ToolDefinition[] {
if (userRole === 'admin') {
return databaseTools
}
// 普通用户只能查询
return databaseTools.filter((t) => READ_ONLY_TOOLS.includes(t.name))
}admin 角色可以使用全部四个工具,普通用户只能查数据。这个角色判断发生在工具注册阶段——模型根本看不到 insert、update、delete 的定义,自然也不会调用它们。
敏感表保护
有些表不应该被 Agent 触及,比如用户密码表、系统配置表、审计日志表。allowedTables 白名单已经起到了这个作用——不在名单里的表,Agent 无法访问。
维护这张白名单时需要注意:
- 不要把
users表整体放进去——如果只需要查用户名和邮箱,考虑建一个视图user_profiles,只暴露非敏感字段 - 定期审查白名单,确认没有多余的表
- 写操作的工具(
insert、update、delete)的allowedTables应该比读操作更严格
结果行数限制
一条 SELECT * FROM orders 可能返回百万行数据。这些数据全部塞进 LLM 的上下文窗口,既浪费 token 又可能超出限制。enforceLimit 函数在第 4 节已经实现——自动给没有 LIMIT 的查询追加一个上限。
7. 错误处理
数据库操作的错误类型比普通工具更多。按错误来源分类:
SQL 语法错误
LLM 生成的 SQL 不一定合法——少了个括号、拼错了字段名、用了不存在的函数。这类错误的特征是数据库返回了明确的错误信息。
处理策略:把数据库的错误信息原样返回给 LLM。大多数情况下,模型看到 no such column: emial 之后能自行修正为 email 并重试。
// src/agents/database/executor.ts
// 在 executeQuery 的 catch 块中
catch (err) {
const errorMessage = err instanceof Error ? err.message : '未知错误'
return {
error: `SQL 执行失败:${errorMessage}`,
hint: '请检查 SQL 语法、表名和字段名是否正确'
}
}加上 hint 字段给 LLM 一个修正方向的提示,比单纯返回错误信息更有效。
安全校验拒绝
SQL 被安全检查层拦截——引用了未授权的表、包含禁止关键词、写操作缺少 WHERE 条件。
处理策略:告诉 LLM 被拒绝的原因,但不要透露太多安全检查的细节(避免给 prompt injection 提供线索)。
// 返回给 LLM 的错误信息
{ error: '该操作不被允许:引用了未授权的表。请只使用允许范围内的表进行查询。' }
// 而不是
{ error: '安全检查失败:表 user_secrets 不在 allowedTables 白名单 ["users", "orders", "products"] 中' }前者告诉 LLM 什么能做,后者把安全策略的细节暴露给了可能的攻击者。
超时
数据库查询可能因为表数据量大、锁竞争、索引缺失等原因变慢。需要给每次查询设一个超时时间:
// src/agents/database/executor.ts
async function executeWithTimeout<T>(
fn: () => Promise<T>,
timeoutMs = 5000
): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`查询超时(${timeoutMs}ms),请缩小查询范围或添加筛选条件`)),
timeoutMs
)
fn()
.then((result) => { clearTimeout(timer); resolve(result) })
.catch((err) => { clearTimeout(timer); reject(err) })
})
}超时错误返回给 LLM 后,模型通常会尝试简化查询——加分WHERE 条件、减少 JOIN、加 LIMIT。这也是一种有效的自我修正。
空结果
查询成功但没有匹配的数据。这不是错误,但需要让 LLM 知道:
// 在 executeQuery 中
if (result.results?.length === 0) {
return {
message: '查询成功,但没有匹配的数据。请确认查询条件是否正确,或者告知用户没有找到相关数据。',
rowCount: 0
}
}不返回任何提示的话,LLM 可能会反复用不同的 SQL 重试,浪费 token 和时间。
总结
回顾一下这篇的要点:
- 数据库 Agent 在用户和数据库之间加了一层自然语言接口:用户说需求,Agent 生成 SQL、执行、用自然语言回复结果
- 三类典型场景——自然语言查询、数据分析、数据写入——对安全的要求递进
- 架构分四层:意图理解、SQL 生成、安全校验、执行与结果格式化,每层职责独立
- 工具按操作类型拆分为 query、insert、update、delete;写操作用结构化参数代替自由 SQL,降低注入风险
- 安全防护需要多层配合:SQL 注入检测、读写权限分离、表白名单、敏感表保护、结果行数限制
- 错误处理按类型区分:语法错误把原始信息返回给 LLM 让它修正;安全拒绝只告诉 LLM 不能做什么,不暴露检查细节;超时提示模型缩小查询范围
- 数据库 Agent 不适合直接暴露给外部用户——更适合作为内部管理工具,或者在中间层做一层意图过滤后再放行
下一篇讨论工作流 Agent 架构,看看如何让 Agent 按预定义的步骤执行复杂的多阶段任务。