说明:本文讨论的是把自然语言问句接进业务数据库、由模型生成可执行 SQL 的工程约束,属于 Agent 工程落地话题,不涉及具体模型版本与价格。AI 领域版本迭代极快,凡涉及版本号、价格、可用性,请以你阅读时的官方页面为准。文中代码为结构示意,请按自己的技术栈调整后再上生产。
一、为什么生成 SQL 只是整件事的十分之一
可执行语句和文本答案不是一类东西
模型输出一段自然语言,错了就是答得不好,用户自己会判断。模型输出一条 SQL,它的错误形态完全不同——语句会被数据库真正执行,消耗真实的 I/O 和 CPU,命中真实的行。一条写错的更新语句可以改掉数据,一条写错的查询可以拖垮一个实例。
这就是把自然语言接进结构化数据时最根本的变化:产物从一段文本变成了一条会被执行的指令。
结构化数据本身也比非结构化检索多出一整套约束。它有 schema,字段有类型,表与表之间有主键外键,访问有权限模型,写操作有事务语义。这些概念在相似度检索里基本不存在。所以「把 Agent 接到数据库上」不是把检索换成 SQL,而是换了一个约束完全不同的对象。
三类失败,只有一类会自己跳出来
把生成 SQL 的失败拆开看,至少有三类,发现方式和成本差得很远:
| 失败类型 | 典型表现 | 谁先发现 | 处置成本 |
|---|---|---|---|
| 语法错 | 方言不匹配、函数名不存在、引号转义错 | 数据库解析器立刻报错 | 低,改提示或重试即可 |
| 语义错 | 能跑通,但 join 关系错、过滤条件漏、聚合层级错 | 用户对账或下游用错数据时 | 高,结果已经流出去 |
| 口径错 | SQL 与语义都对,业务词的定义却与库内算法不一致 | 业务方对账时 | 高,修的是定义不是提示 |
语法错是最好办的一类,因为数据库会替你判定。难的是后两类:语义错不会报错,它会安静地返回一个看起来合理、实际不对的结果;口径错连 SQL 本身都是对的,错的是「活跃用户」这个词在两处表里算法不同,只有懂业务的人能看出来。
十分之一这个说法怎么来的
一句正确的 SQL 只是整条链路最后一个环节的输出。在它之前,要决定给模型看多少 schema,要把业务术语映射到具体字段,要判断问句有没有歧义、要不要反问,要确认执行用的连接有没有越权,要兜住超时和空结果,要把每次生成留档以便回溯。
这些环节里任何一环出错,最后那句 SQL 都会「看起来没问题但答案是错的」。生成那一步只是把已经确定下来的意图翻译成语法——它的输入质量由前面所有环节决定。这也是为什么很多团队的第一版能生成出漂亮的 SQL,上线两周后却不敢用:语法这块早就过关了,卡住的是别的部分。
二、schema 该给模型多少
全量塞进去的代价
最直接的做法是把整库的建表语句拼进提示。它对小库有效,到了中等规模就崩:几百张表的 DDL 加上字段注释,光 schema 文本就压过问题本身几十倍。
代价不只是 token 账单。上下文里塞满无关表的字段名,模型的注意力被稀释,跨表 join 时更容易选错关联对象——它会挑一个字段名刚好对得上的表,而不是语义上正确的那张。全量 schema 的隐性成本是错误率,不只是费用。
检索式投喂
折中做法是按问句先筛出候选表,只把这几张表的 DDL 给模型。筛选可以先用表名、字段名、注释里的关键词做匹配,候选一般控制在几张到十几张。
它省上下文,但引入一个新的失败点:召回错了,后面全错,而且这个错是不可见的。模型不会告诉你「你没给我那张表」,它会从给到的表里硬凑一个答案。所以检索式投喂必须配一条兜底——当模型明确表示信息不足时,要允许它请求补充 schema,而不是硬答。
语义层,以及外键与枚举要不要给
第三条路是提前把常用的指标、维度、常用 join 路径定义好,让模型面对的是逻辑模型而不是物理表。比如「成交额」这一个词直接对应一条预置表达式,模型不必自己决定 join 哪几张表。
它的稳定性最好,输出最收敛,代价是需要人工维护、覆盖面有限。适合核心指标固定、变更不频繁的场景。三条路的取舍如下:
| 策略 | 给模型什么 | 优点 | 代价 |
|---|---|---|---|
| 全量 | 整库 DDL | 不会漏表 | 上下文膨胀,注意力被稀释 |
| 检索式 | 召回的候选表 DDL | 上下文可控 | 召回错则全错,且不可见 |
| 语义层 | 预定义的指标与维度 | 口径稳定,输出收敛 | 需人工维护,覆盖有限 |
表结构里还有两样经常被一起忽略,但都是硬信息:外键和枚举。
外键关系到 join 能不能走对,不给的话模型只能靠字段名猜关联关系,猜错概率很高。枚举关系到谓词能不能写对:状态字段是数字码时,不给值域模型只能根据字段名编一个值,生成的where status = 'active'可能压根不在这个字段的取值集合里。
要注意「字段类型」和「值域」是两回事。给出varchar(16)只说明它是字符串,不说明它只取哪几个值。值域要单独给。
⚠️ 代码待验证
# 按问句筛选候选表,再把候选表的 DDL 拼成给模型的输入defbuild_schema_context(question:str,catalog:dict,top_k:int=12)->dict:# catalog: {table_name: {"ddl": str, "columns": [...], "fks": [...]}}tokens=set(question.lower().split())scored=[]forname,metaincatalog.items():hit=len(tokens&set(name.lower().split("_")))hit+=len(tokens&{c.lower()forcinmeta["columns"]})scored.append((hit,name))scored.sort(reverse=True)picked=[nameforscore,nameinscored[:top_k]ifscore>0]# 关键约束:一张表都没命中时不能硬选,要让上层走"请求补充 schema"分支enough=len(picked)>0return{"tables":{n:catalog[n]["ddl"]forninpicked},"fks":[fkforninpickedforfkincatalog[n]["fks"]],"sufficient":enough,}这段里最关键的是sufficient这个字段。筛选为空却不告诉上游,模型就会在空上下文里编 SQL。
三、术语到字段的映射表
三列登记:业务词、字段、口径
自然语言和表结构之间有三层落差。术语落差是业务说「活跃用户」,库里根本没这个字段;粒度落差是业务按一张宽表的直觉提问,实际数据分散在多张规范化表里;口径落差是同一个词在两张表里算法不同。
这三层落差里,前两层靠映射表解决,第三层靠裁决规则解决。映射表至少登记三列:业务词、库内对应的字段或表达式、口径说明。口径说明要写清时间窗口、过滤条件、聚合方式——「活跃用户」这四个字不够,得写「近 30 天内有登录记录的去重用户数」。
口径冲突时以谁为准
最麻烦的是同一个词在两处算法不同。比如「成交额」在订单表含退款、在财务表已经扣掉退款,两个数字对不上,而且都对。
这种情况不能丢给模型去选,它每次可能选不同的表,同一个问题问两遍给出两个答案。做法是把优先级提前登记下来,一般以对外报表或财务口径为准,并在映射表里显式注释「冲突时取财务表」,让生成时有一条明确规则可依。
| 业务词 | 库内字段 / 表达式 | 口径说明 | 冲突裁决 |
|---|---|---|---|
| 活跃用户 | user.last_login_at | 近 30 天有登录的去重用户数 | 无冲突 |
| 成交额(对外) | finance.gmv | 已扣除退款 | 冲突时以此为准 |
| 成交额(内部) | order.amount | 未扣除退款 | 仅在标注「内部」时使用 |
| 新客 | user.created_at | 首单前 0 天注册的用户 | 无冲突 |
映射表放检索里还是提示里
放提示里最稳定,模型每次都能看到,但问句类型一多,映射表本身就会把上下文撑爆;放检索里按问句召回相关条目,省上下文,但需要维护召回质量,召不回来跟没有一样。
可行的折中是分层:高频核心词常驻提示,长尾词走召回。核心词一般不超过几十条,长尾词随业务增长进索引。判断某个词该放哪一层的标准是它被问到的频次,而不是它的重要程度。
⚠️ 代码待验证
# 业务词到字段的映射登记,三列结构 + 裁决规则-term:活跃用户target:user.last_login_atwindow:30dagg:count_distinct(user_id)note:近 30 天内有登录记录的去重用户数-term:成交额variants:-target:finance.gmvscope:externalnote:已扣除退款-target:order.amountscope:internalnote:未扣除退款precedence:finance.gmv# 未显式指定 scope 时取这条-term:新客target:user.created_atexpression:"订单时间 - 注册时间 <= 0d"precedence这一行是治理口径冲突的入口:只要映射表里有它,模型就不需要在两条都能跑通的路径之间做选择。
四、歧义与追问
哪些问句必须反问
不是所有模糊问句都值得打断用户。判据只有一条:两种合理解释会不会给出差距很大的数字。会,就必须反问;不会,就按默认值走并标注。
必须反问的有四种。时间范围缺省——「上个月增长多少」里的「上个月」可能是自然月,也可能是滚动 30 天。聚合维度不明——「按地区看销售额」里的地区可能是收货地,也可能是注册地。口径冲突未裁决——问句命中两个没有precedence的定义。一词多字段——「用户数」既可能是注册数,也可能是活跃数。
| 触发条件 | 例子 | 不反问的后果 |
|---|---|---|
| 时间范围缺省 | 「上个月」未指明自然月还是滚动 30 天 | 两个口径的数字完全不同 |
| 聚合维度不明 | 「按地区」未说明收货地还是注册地 | 分组结果对不上 |
| 口径冲突未裁决 | 命中两个「成交额」定义 | 每次生成可能选不同表 |
| 一词多字段 | 「用户数」 | 注册数与活跃数差距可达数倍 |
只做一次反问的代价
反问是有成本的,它打断用户。所以原则是把一轮里所有歧义点合并成一次提问,而不是发现一个问一个。三种歧义凑在一次里问清楚,用户只被打断一次;连环追问三次,体验直接崩掉。
反问的形态也有讲究:给选项和默认值,而不是开放问题。不要问「你想怎么查」,要问「是按自然月,还是按滚动 30 天?默认按自然月」。给默认值的关键作用是——用户可以直接说「就按你说的」,一步跳过。
反问的结果必须写回会话,后续沿用。否则下一句追问又要把同样的选项重问一遍。
反问的另一个用途:避免猜
反问不只是为了体验,它直接降低错误率。模型在信息不足时不会停,它会猜,猜出来的 SQL 语法正确、能跑通、结果看着合理,但口径是错的——这正是最难发现的那类失败。一次反问的成本,远低于一次口径错流到下游的成本。
⚠️ 代码待验证
defdecide_clarify(question:str,parsed:dict,mapping:dict)->dict:gaps=[]ifparsed.get("time_range")isNone:gaps.append({"slot":"time_range","options":["自然月","滚动 30 天"],"default":"自然月"})ifparsed.get("group_by")isNoneandparsed.get("aggregate"):gaps.append({"slot":"group_by","options":["收货地","注册地"],"default":"收货地"})hits=[tfortinmappingiftinquestionandlen(mapping[t].get("variants",[]))>1]forterminhits:ifnotmapping[term].get("precedence"):gaps.append({"slot":f"scope:{term}","options":["对外","内部"],"default":"对外"})# 关键约束:多个歧义点合并成一次提问,不要逐条追问return{"need_clarify":len(gaps)>0,"questions":gaps}五、权限与只读边界
连接必须是只读账号
生成的 SQL 不可信,权限是最后一道闸。执行连接应该用只读账号:只授予SELECT,只对白名单内的库表,而不是「虽然流程只查,但账号有写权限」。
不要因为「这条链路只做查询」就复用业务主账号。模型生成的语句不受你控制,一次提示注入或者一次改写失控,写权限就会变成可执行的风险面。数据库侧还可以再加两道:连接级别的超时,以及事务级别的只读声明,让这条连接在数据库层面也写不进去。
⚠️ 代码待验证
# 为查询链路单独建只读账号,只给白名单表的 SELECT# 具体语法按目标数据库调整,这里只示意授权的粒度CREATE ROLE agent_ro LOGIN PASSWORD'<from-secret-store>';GRANT CONNECT ON DATABASE biz TO agent_ro;GRANT USAGE ON SCHEMA public TO agent_ro;GRANT SELECT ON public.orders, public.users, public.finance_gmv TO agent_ro;-- 不给写权限,不给未在白名单内的表行级过滤由谁加
多租户或多角色的场景下,同一张表不同用户只能看到自己的数据。这类过滤条件绝不能交给模型——它依赖当前登录用户,而模型看不到这个身份,你也不能指望它每次都记得补上where tenant_id = ...。
正确的位置是执行层。三种常见做法:在 SQL 外面包一层带过滤的视图;对生成的 SQL 做结构解析后注入谓词;或者直接用数据库自带的行级安全策略,让过滤发生在引擎内部。判断标准很简单——只要过滤条件依赖当前用户身份,就必须由执行层来加。
权限写进生成约束,还是执行前改写
两种做法各有短板。写进提示,模型知道边界,生成的语句更自然,但它不可靠,会漏;执行前改写可靠,但需要对 SQL 结构做解析,复杂语句上容易改错。
稳妥的做法是两道都要:提示里说明可见范围,减少无效生成;执行前做一次强制校验,把越权访问拦下来。
| 控制点 | 放在哪 | 为什么不能只靠模型 |
|---|---|---|
| 读写权限 | 数据库只读账号 | 模型可能生成写语句 |
| 表级权限 | 白名单库表 + 视图 | 模型可能访问未授权表 |
| 行级过滤 | 执行层注入 / 行级安全策略 | 过滤条件依赖登录用户,模型不可见 |
| 列级脱敏 | 视图或结果脱敏 | 敏感列不该出现在结果里 |
字段级权限还有个容易漏的点:手机号、证件号这类敏感列,即使允许查询也不该把原文返回给模型。要么在视图层脱敏,要么在结果回传前处理掉。
完整版资料清单:本文用到的权限落点对照表与实测记录都整理在里面了,扫码即可获取:
六、执行与兜底
超时、行数上限、代价预估
模型生成的 SQL 可能全表扫描。一个没走索引的 join 就足以把实例拖慢。执行前要设三道闸。
第一道是代价预估:先对生成的语句做一次执行计划分析,代价超过阈值就不执行,把计划信息回给模型让它改写。第二道是语句超时:给执行设置明确的超时上限,超时后终止并把结构化错误回给模型。第三道是行数上限:返回行数要在执行层强制,不能只靠模型在 SQL 里写LIMIT——它可能不写。
这里有个容易被忽略的细节:被行数上限截断的结果,不能当成完整结果回答。结果里必须带一个「已截断」标记,否则用户会把前 N 行当成全量数据。
结果为空不等于没有
空结果有两种来源。一种是业务上确实是 0,另一种是查错了:过滤条件写得太严、join 关联不上、时间窗口算偏。这两种在返回层面长得一模一样,都是空集合。
直接把空结果说成「没有数据」是错的。最少要补一次诊断:去掉部分过滤条件看是不是条件问题,或者先统计基础表的行数,确认表里到底有没有数据。至少要能把「执行成功但返回空」和「查询失败」在表示层分开——它们给用户的下一步动作完全不同。
错误怎么回给模型且不泄露内部结构
数据库的原始报错里带着表名、列名、索引名甚至连接信息。直接回给模型,这些内容会进日志、进对话记录、进用户可见的界面。必须先把错误分类。
可以安全回传的是语法层面的信息:错误位置、类型不匹配、函数名不存在。不可回传的是结构信息:表结构、连接串、堆栈。回给模型时要转成结构化字段,比如error_type、message、suggested_fix,让模型能据此改写,同时不暴露库的内部形状。
重试也要设上限。同一个问句反复改写不收敛时就停下来,把问题交回用户,继续重试只是在烧预算。
| 风险 | 控制手段 | 关键判据 |
|---|---|---|
| 全表扫描 | 执行前做代价预估 | 代价超阈值先改写,不执行 |
| 结果过大 | 执行层强制行数上限 | 截断结果必须标记「已截断」 |
| 慢查询 | 语句级超时 | 超时后回结构化错误 |
| 空结果 | 补一次诊断查询 | 区分「真是 0」与「条件太严」 |
| 报错泄露结构 | 错误分类后转译 | 表名、连接串不回传 |
⚠️ 代码待验证
defexecute_guarded(sql:str,conn,*,cost_limit:float,row_limit:int,timeout_s:int):plan=conn.explain(sql)ifplan.estimated_cost>cost_limit:# 不执行,把计划信息交回上层让模型改写return{"status":"rejected","reason":"cost","hint":plan.summary}try:rows=conn.fetch(sql,timeout=timeout_s,max_rows=row_limit+1)exceptDBErrorase:return{"status":"error","error":translate_error(e)}# 已脱敏truncated=len(rows)>row_limit rows=rows[:row_limit]ifnotrows:# 空结果不代表"没有",标记出来让上层决定是否补诊断查询return{"status":"empty","truncated":False,"needs_probe":True}return{"status":"ok","rows":rows,"truncated":truncated}⚠️ 代码待验证
{"status":"error","error_type":"syntax","safe_message":"near 'GROUPP BY': syntax error at position 42","suggested_fix":"检查聚合子句的关键字拼写","retry_count":1,"retry_budget_left":1,"internal_details_omitted":true}safe_message与真实报错之间隔着一次转译:保留能帮助改写的部分,去掉任何暴露库结构的字段。internal_details_omitted这个标记的作用是让上层能统计「有多少次错误是因为结构信息被挡掉而没能自愈」。
七、验证与观测
生成语句要留档
每次生成都要留档:原始问句、这次用了哪些表的 schema、生成的 SQL 原文、执行结果摘要、耗时、返回行数。留档的核心目的是回溯——上个月报表里的一个数字不对,你要能翻出当时到底跑了哪条 SQL。
关键是要存 SQL 本身,而不是只存结果。结果对了不代表 SQL 对:一次过滤条件恰好没影响最终数字,换个数据分布就会出事。只留结果的系统,在出问题时无法判断是数据变了还是语句错了。
用人工标准答案做回归
建一批问题集,每条配一条人工写的标准 SQL 或标准答案。回归时拿同一批问题跑一遍新的提示、新的映射表或者新的 schema 供给策略,比较的是结果而不是 SQL 文本。
SQL 写法可以完全不同而结果一致,比对文本会把正确的改写误判成回归失败。口径类问题天然带容差,比如金额四舍五入的差异,判定时要定容差范围,否则每次回归都会报一堆假告警。这套问题集要在每次改提示、改映射、改 schema 之后都跑一遍。
口径错的样本单独归一类
三类失败里,语法错能自动发现(数据库报错),语义错大多靠对账发现,口径错必须要人判断。所以口径错的样本要单独归一类,因为它修的东西不一样——它要修的是映射表、是定义,不是提示词。
观测指标也要分开看:语法错率、语义错率、口径错率各算各的。混成一个「准确率」看,一次口径错率上升会被误读成整体质量下降,然后团队去调提示,但问题其实在定义层。分开之后,每一类上升指向的修复动作都是明确的。
执行留档的结构可以直接支撑这三类统计,关键在于错误分类字段要落库,而不是只记一个成功或失败。
完整版资料清单:本文用到的回归问题集模板与观测指标定义都整理在里面了,扫码即可获取:
附表 A:关键取舍一览
本文涉及的所有工程判断集中在这里,方便按需回看。
| 取舍 | 本文结论 | 判断依据 | 位置 |
|---|---|---|---|
| 生成 SQL 在整件事里的权重 | 只是最后一环,约束在前 | 前面环节错则语句看起来对但结果错 | 第一章 |
| 三类失败怎么对待 | 语法错可自动发现,语义错与口径错要人判 | 只有语法错会触发报错 | 第一章 |
| schema 给多少 | 按场景选全量、检索式或语义层 | 上下文体积与准确率互相拉扯 | 第二章 |
| 检索式投喂的兜底 | 允许模型请求补充 schema | 召回错时模型会硬凑答案 | 第二章 |
| 外键与枚举 | 都给,且值域单独给 | join 路径与谓词合法性靠它们 | 第二章 |
| 术语映射登记几列 | 三列:业务词、字段、口径 | 三层落差需要落地的对应关系 | 第三章 |
| 口径冲突以谁为准 | 提前登记优先级,不交给模型 | 否则同一问题每次答案可能不同 | 第三章 |
| 映射表放哪 | 核心词常驻提示,长尾走召回 | 按被问频次分层,不按重要度 | 第三章 |
| 什么时候必须反问 | 两种解释给出差距很大的数字时 | 反问成本低于口径错流出成本 | 第四章 |
| 反问几次 | 一轮里所有歧义合并为一次 | 连环追问会让体验崩掉 | 第四章 |
| 执行连接 | 只读账号 + 白名单表 | 生成的语句不可信 | 第五章 |
| 行级过滤 | 由执行层注入,不给模型 | 过滤条件依赖登录用户身份 | 第五章 |
| 权限约束放哪 | 提示里说明 + 执行前强校验 | 单靠任何一道都会漏 | 第五章 |
| 昂贵语句怎么拦 | 执行前代价预估,超阈值不执行 | 全表扫描会拖慢实例 | 第六章 |
| 行数上限 | 执行层强制并标记「已截断」 | 截断结果会被当成全量 | 第六章 |
| 空结果怎么答 | 不直接答「没有」,先补诊断查询 | 空可能是条件写得太严 | 第六章 |
| 报错怎么回模型 | 分类转译,去掉结构信息 | 原始报错会泄露表与连接信息 | 第六章 |
| 留档存什么 | 存 SQL 原文而非只存结果 | 结果对不代表语句对 | 第七章 |
| 回归比什么 | 比结果,不比 SQL 文本 | 写法不同结果可以一致 | 第七章 |
| 观测怎么分类 | 语法错、语义错、口径错分开算 | 混算会误判问题所在层 | 第七章 |
附表 B:术语速查表
| 术语 | 含义 |
|---|---|
| 可执行语句 | 会被数据库真正执行的 SQL,错误代价是资源与数据状态 |
| 语法错 | 语句无法被解析执行,由数据库直接报错 |
| 语义错 | 语句能跑通但查询意图不对,不报错且难发现 |
| 口径错 | 语句正确但业务词定义与库内算法不一致 |
| schema 供给 | 决定把哪些表的建表信息给模型 |
| 检索式投喂 | 按问句先筛候选表,只给这几张表的 DDL |
| 语义层 | 预定义的指标与维度,模型面对逻辑模型而非物理表 |
| 值域 | 字段允许的取值集合,与字段类型是两件事 |
| 术语落差 | 业务用词在库内无对应字段 |
| 粒度落差 | 业务按宽表提问,数据实际分散在规范化表中 |
| 口径落差 | 同一个词在不同表中算法不同 |
| 映射表 | 登记业务词、对应字段、口径说明的对照结构 |
| precedence | 口径冲突时指定以哪条定义为准的规则 |
| 行级过滤 | 按登录用户身份限制可见行的过滤条件 |
| 代价预估 | 执行前分析执行计划,判断语句是否昂贵 |
| 语句超时 | 给单条查询设置的时间上限 |
| 行数上限 | 执行层强制的最大返回行数 |
| 留档 | 记录问句、上下文、SQL、结果摘要与耗时 |
| 回归集 | 一批配有人工标准答案的问题,用于验证变更 |
| 失败分类 | 把语法错、语义错、口径错分开统计的做法 |
写在最后:这篇用到的资料
写这篇文章时,我把几个模型侧与数据库侧的文档、口径表和实践记录都对了一遍,顺手整理成几份配套的东西:
- 大模型学习路线图:从 LLM 基础到 Agent 开发,各阶段该学什么、用什么资料
- 大模型全套教程:按主题分好的视频与文档清单
- 大模型实战好书:24 本,附每本适合的阶段
资料是我自己整理的,放在下面这个码上,扫码即可获取:
添加时备注「大模型」,优先通过。
拿到之后建议先看学习路线图那一份,先定位自己在哪个阶段,再决定学什么,比一上来就啃框架效率高得多。