数据库Agent架构

要点

  • 数据库 Agent 要解决的核心问题是:用户用自然语言描述需求,Agent 把它翻译成数据库操作并执行,再把结果转回自然语言
  • 数据库 Agent 的典型场景有三类:自然语言查询(「上个月销量最高的商品是什么」)、数据分析(「对比这两个月的用户增长趋势」)、数据写入(「把这条订单状态改成已发货」)
  • 架构分四层:意图理解、SQL 生成、安全校验、执行与结果格式化,每一层职责独立
  • 工具设计按操作类型拆分为 query、insert、update、delete 四种,每种工具有不同的安全约束
  • 安全防护必须做:SQL 注入检测、读写权限分离、操作白名单、敏感表保护、结果行数限制
  • 错误处理需要区分 SQL 语法错误、权限拒绝、超时等不同类型,把错误信息返回给 LLM 让它自行修正
  • 数据库 Agent 不宜直接暴露给外部用户,更适合作为内部工具或经过中间层过滤后使用

1. 什么是数据库Agent

前面几篇已经搭建了 Agent 的基本框架:LLM 负责推理,工具负责执行,循环把两者串起来。到目前为止,我们的工具都是调用外部 API——查天气、搜索网页。但在实际业务中,有一类需求出现频率非常高,值得单独拿出来讨论:让 Agent 直接操作数据库。

数据库 Agent 的工作方式是这样的:

  1. 用户用自然语言描述需求:「帮我查一下上个月注册了多少新用户」
  2. Agent 理解意图,生成对应的 SQL 语句
  3. 在执行前检查安全性:是只读查询还是写操作?有没有越权风险?
  4. 执行 SQL,拿到原始结果
  5. 把结果格式化后交给 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 需要四个工具:

工具名操作类型风险等级说明
querySELECT只读查询,默认返回最多 100 行
insertINSERT插入数据,需要指定表名和字段
updateUPDATE更新数据,必须有 WHERE 条件
deleteDELETE删除数据,必须有 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']
    }
  }
]

insertupdatedelete 三个工具没有直接让 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}`
}

executeUpdateexecuteDelete 的结构类似,都需要检查表名是否在白名单中、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 角色可以使用全部四个工具,普通用户只能查数据。这个角色判断发生在工具注册阶段——模型根本看不到 insertupdatedelete 的定义,自然也不会调用它们。

敏感表保护

有些表不应该被 Agent 触及,比如用户密码表、系统配置表、审计日志表。allowedTables 白名单已经起到了这个作用——不在名单里的表,Agent 无法访问。

维护这张白名单时需要注意:

  1. 不要把 users 表整体放进去——如果只需要查用户名和邮箱,考虑建一个视图 user_profiles,只暴露非敏感字段
  2. 定期审查白名单,确认没有多余的表
  3. 写操作的工具(insertupdatedelete)的 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 按预定义的步骤执行复杂的多阶段任务。