目录
- 一、从玩具到真实业务
- 二、为什么 describe_table 比 run_query 更重要
- 三、把数据库注释变成模型上下文
- 四、工具描述工程
- 五、分层架构:换库只改一个文件
- 六、测试数据为什么要故意造一张大表
- 七、跑起来
- 八、常见误区
- 九、自检清单
- 附:延伸阅读
一、从玩具到真实业务
前两篇的工具是加法和一个假的天气查询。它们能验证链路,但说明不了这套东西有什么用。
这一篇换一个真场景:
让 AI 能用自然语言查数据库。
你问:「9 月份领用数量最多的 5 个备件是什么?分别用在哪个部门?」
它自己完成:看有哪些表 → 看表结构 → 写 SQL → 执行 → 组织答案。
暴露 4 个工具:
| 工具 | 输入 | 输出 | 作用 |
|---|---|---|---|
list_tables | 无 | 表名 + 行数 + 中文说明 | 「有什么数据可查」 |
describe_table | 表名 | 字段、类型、可空、主键、中文注释 | 「这张表什么结构」 |
run_query | SELECT 语句 | Markdown 结果表格 | 主角 |
explain_query | SELECT 语句 | 执行计划 | 防止写出全表扫描 |
跑起来长这样:
用户: 9 月份领用数量最多的 5 个备件是什么?分别用在哪个部门?
[调用] list_tables {}
[调用] describe_table {table_name: spare_usage}
[调用] describe_table {table_name: spare_part}
[调用] run_query {sql: SELECT p.code, p.name, SUM(u.quantity) AS total ...}
[结果] | CODE | NAME | TOTAL |
|---|---|---|
| MF-0002 | O型密封圈 | 200 |
...
Claude: 9 月份领用最多的是 O型密封圈,共 200 个,主要用于装配车间……
注意中间那三行。 模型先看有哪些表,再看表结构,最后才写 SQL。
这个顺序不是我们教的,是工具描述引导出来的。 这是这一篇最重要的内容。
二、为什么 describe_table 比 run_query 更重要
先说一个容易被忽略的事实:
模型看不见你的数据库。
你问它「上个月领用最多的备件」,它不知道:
- 有
spare_usage这张表 - 字段叫
used_at而不是create_time part_id要 JOIN 到spare_partquantity是「本次领用数量」还是「剩余数量」
它唯一的信息来源就是你通过工具给它的描述。
所以:
run_query 能不能查对,
九成取决于模型有没有看懂表结构。
所以 describe_table 的质量,比 run_query 本身还重要。
这个结论一开始让我有点意外。我以为难点在「生成正确的 SQL」,实际上难点在「让模型知道该生成什么样的 SQL」。
三、把数据库注释变成模型上下文
那怎么让模型「看懂」表结构?靠数据库自己的注释。
CREATE TABLE spare_usage (...);
COMMENT ON TABLE spare_usage IS '备件领用记录,一行代表一次领用';
COMMENT ON COLUMN spare_usage.used_at IS '领用日期';
COMMENT ON COLUMN spare_usage.dept IS '领用部门。可能为空,表示未登记';
这些注释不是装饰,是给模型看的。
describe_table 通过 JDBC 标准接口把它们读出来:
DatabaseMetaData meta = conn.getMetaData();
try (ResultSet rs = meta.getColumns(null, conn.getSchema(), actualTable, "%")) {
while (rs.next()) {
String name = rs.getString("COLUMN_NAME");
String type = rs.getString("TYPE_NAME");
String remark = rs.getString("REMARKS"); // ← 就是 COMMENT ON 的内容
...
}
}
REMARKS 字段就是 COMMENT ON COLUMN 写进去的内容。
用 DatabaseMetaData 而不是查 information_schema,好处是完全跨数据库——PostgreSQL / MySQL / Oracle 都不用改代码。
3.1 输出给模型长这样
表 SPARE_USAGE —— 备件领用记录,一行代表一次领用
| 字段名 | 类型 | 可空 | 主键 | 说明 |
|---|---|---|---|---|
| ID | INTEGER | 否 | ✓ | — |
| PART_ID | INTEGER | 否 | | 关联 spare_part.id |
| QUANTITY | INTEGER | 否 | | 本次领用数量 |
| DEPT | CHARACTER VARYING(32) | 是 | | 领用部门。可能为空,表示未登记 |
| OPERATOR | CHARACTER VARYING(32) | 是 | | 领用人 |
| USED_AT | DATE | 否 | | 领用日期 |
对比一下没有注释的情况:
| DEPT | CHARACTER VARYING(32) | 是 | | — |
模型只能靠字段名猜 DEPT 是什么。
实操建议:对接真实库时,如果表字段没有注释, 先去补注释,效果比调 prompt 明显得多。 这是投入产出比最高的一步。
3.2 两个实现细节
① 输出用 Markdown 表格而不是 JSON
因为模型读表格比读 JSON 更准,而且 token 更省(不用重复字段名)。
② 表名要做大小写归一
H2 / PostgreSQL 里未加引号的标识符会被转成大写,而模型不知道这个细节(它会传 spare_usage)。所以要先去元数据里找出真实表名,找不到时返回友好错误 + 引导,而不是抛异常:
String actualTable = resolveTableName(conn, table);
if (actualTable == null) {
return "错误:找不到表 " + table + "。可用 list_tables 查看有哪些表。";
}
四、工具描述工程
这是这一篇最「反直觉」的部分。
看这两句描述:
// list_tables
.description("列出数据库中所有的表,包含表名、行数和中文说明。"
+ "在写任何查询之前,如果不确定有哪些表可用,先调用这个工具。")
// describe_table
.description("查看某张表的完整字段结构,包含字段名、类型、是否可空、主键标记和中文注释。"
+ "写查询前如果不确定字段名或字段含义,必须先调用这个工具——"
+ "不要凭猜测写字段名。")
正是这两句话让模型形成了「先看表 → 再看结构 → 最后写 SQL」的习惯。
如果没有这两句,模型会直接凭直觉写 SQL,然后因为表名或字段名不对而失败。
4.1 写描述的公式
description = 「做什么」 + 「什么时候用」
对比一下:
| 写法 | 效果 |
|---|---|
"数据库工具" | ❌ 模型只能瞎猜 |
"查询数据库" | ⚠️ 知道做什么,不知道什么时候用 |
"执行一条只读 SELECT 查询。只允许 SELECT,任何写操作都会被拒绝。结果最多返回 200 行。建议:先用 list_tables 和 describe_table 确认表结构,再写查询。" | ✅ 完整 |
4.2 几个常用句式
| 场景 | 句式 |
|---|---|
| 有前置依赖 | 在描述里指明顺序:「如果……请先调用 X」 |
| 有硬约束 | 明确写出来:「只允许 SELECT」「最多返回 200 行」 |
| 参数有歧义 | 在参数 description 里解释:「表名,可先用 list_tables 查看有哪些表」 |
| 想防某种错误 | 直接说不要做什么:「不要凭猜测写字段名」 |
最后一条特别有效。模型对「不要……」这类明确禁令的遵从度很高。
4.3 一句值得记住的话
description是模型判断「要不要用这个工具」的唯一依据。 它看不到你的代码,只有这一句话。
我在搭这个 Demo 时,这几句描述改了好几版才稳定下来。
五、分层架构:换库只改一个文件
┌──────────────────────────────────────────────────────┐
│ DbMcpServer.java 工具定义 + handler │ ← 跟具体是什么库无关
├──────────────────────────────────────────────────────┤
│ SqlGuard.java SQL 白名单 / 强制 LIMIT │ ← 跟具体是什么库无关
│ AuditLog.java 审计日志 │
│ SchemaIntrospector 表结构自省 │
├──────────────────────────────────────────────────────┤
│ Db.java 数据源 + 演示数据 │ ← ★只有这层要换
└──────────────────────────────────────────────────────┘
换库只改 Db.java,而且如果之前配了环境变量,连这一层都不用改:
DB_URL=jdbc:postgresql://test-db:5432/sparedb
DB_USER=mcp_readonly
DB_PASSWORD=******
设了 DB_URL 就自动切到外部库模式,代码一行不用动。
为什么能这样? 因为上面两层只依赖两个跟数据库无关的东西:
- JDBC 标准接口(
DatabaseMetaData) - 一份 SQL 白名单
5.1 一个真实的踩坑记录
Db.java 里有个看起来多余的常量,值得说一下:
// 管理连接:带 DB_CLOSE_DELAY=-1,让内存库在整个 JVM 生命周期内存活
private static final String ADMIN_URL = "jdbc:h2:mem:sparedb;DB_CLOSE_DELAY=-1";
// ★ 只读连接:注意这里【没有】DB_CLOSE_DELAY
private static final String READONLY_URL = isDemoMode() ? "jdbc:h2:mem:sparedb" : ENV_URL;
我第一版是把 URL 抽成一个常量复用的,结果所有只读连接全部失败:
Admin rights are required for this operation;
SQL statement: SET DB_CLOSE_DELAY -1
原因是 H2 里 SET DB_CLOSE_DELAY 需要管理员权限。它是数据库级设置,由管理员连接设好之后就一直有效,后续连接不需要(也不能)再设一遍。
这个坑的教训是:看似可以复用的配置,有时候恰恰不能复用。
六、测试数据为什么要故意造一张大表
演示库里有 5 张表,其中一张是 5000 行的 device_log。
这不是为了凑数。 它存在的唯一理由是:
只有数据量够大,才能验证这些机制到底有没有生效:
· LIMIT 有没有被强制追加
· 结果长度有没有被截断
· 模型面对 200 行返回时会不会自己加聚合条件
小数据量下这些机制全都是隐形的。
比如你测 SELECT * FROM spare_usage(15 行),返回 15 行——你没法判断是因为「表里就 15 行」还是因为「LIMIT 生效了」。
测 SELECT * FROM device_log(5000 行)返回 200 行 + 一行截断警告,这才是有效验证。
这个思路可以推广:要验证一个防御机制,你得先造出能触发它的场景。 第五篇的攻击用例也是同一个道理。
七、跑起来
7.1 先自检(不需要 API Key)
mvn -q compile exec:exec -DmainClass=com.example.mcp.db.DbAgentMain -DappArgs=check
目标数据源: 内置演示库 jdbc:h2:mem:sparedb(测试数据,可随意折腾)
已连接 spare-db,加载 4 个工具:[list_tables, describe_table, run_query, explain_query]
✅ 自检通过:MCP Server 连接正常,工具已全部加载。
建议第一次先用这个。 它验证的是 MCP 侧,不花钱。免得把一个配置问题误判成「模型不好用」。
7.2 实际提问
set ANTHROPIC_API_KEY=sk-ant-...
mvn -q compile exec:exec -DmainClass=com.example.mcp.db.DbAgentMain
7.3 交互模式
mvn -q compile exec:exec -DmainClass=com.example.mcp.db.DbAgentMain -DappArgs=chat
一次连接,连续提问。
⚠️ 注意每次 ask() 都是从空的对话历史开始,所以没有跨轮记忆。要做多轮对话,需要把 messages 提升为 agent 的字段——这个留给你自己改。
八、常见误区
误区一:以为难点在「生成正确的 SQL」。
真正的难点是让模型知道该生成什么样的 SQL。模型看不见你的库,你不告诉它有哪些表、字段叫什么、业务含义是什么,它只能猜。
所以真正要投入的地方是:表注释、工具描述、以及把数据库报错原样回传给模型让它自我纠正。
误区二:觉得「表注释」是可有可无的文档工作。
在传统开发里,注释是给人看的,写得随意一点问题不大。
在 AI 场景里,注释是模型理解你数据结构的唯一途径。它直接决定了查询能不能写对。这个投入产出比比调 prompt 高得多。
误区三:工具描述写得越简短越好。
恰好相反。描述是模型判断「什么时候用这个工具」的唯一依据。写得含糊,模型要么不用,要么乱用。
写得具体、写明使用时机、写明约束,才是正确方向。
误区四:用一个通用账号连数据库。
代码能跑,但你在安全上什么都没做。数据库账号应该是只读的——这一条在下篇会展开,它是整个安全模型里唯一不可替代的一环。
误区五:测试数据用小表就够。
小表测不出限流、截断这些机制。要验证防御,先造出能触发它的场景。
九、自检清单
- 为什么
describe_table的质量比run_query更影响最终效果? -
DatabaseMetaData.getColumns()里哪个字段是列注释? - 为什么输出用 Markdown 表格而不是 JSON?
- 工具描述的写法公式是什么?
- 这个架构里,换成公司真实的库要改几个文件?
- 为什么演示库要造一张 5000 行的大表?小表不行吗?
- 为什么演示库的只读连接不能复用管理员连接的 URL?
附:延伸阅读
相关资料
| 资源 | 说明 |
|---|---|
| JDBC DatabaseMetaData 文档 | 本篇章结构自省用的全是这里的标准接口 |
| PostgreSQL COMMENT 语法 | 接真实库时补注释用,H2 / MySQL 语法类似 |
| MCP Inspector | 调工具描述时用它反复试,比改代码重启快得多 |
| pg_class / information_schema 统计行数 | 生产环境取代 COUNT(*) 拿近似行数的做法 |
关联笔记
- MCP 实战(三):把 Claude 接进来 —— 那篇讲的是链路,这篇讲的是内容质量
- MCP 实战(五):给 MCP 加三道安全墙 —— 能用之后必须考虑安全
- RAG 实战(二):最简 RAG 实现 —— 同样在解决「模型不知道你的私有数据」,一个靠检索,一个靠工具
最后更新:2026-10-08