news 2026/10/14 2:24:39

Agent 查数据库:自然语言问句怎么变成 SQL

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Agent 查数据库:自然语言问句怎么变成 SQL

说明:本文讨论的是把自然语言问句接进业务数据库、由模型生成可执行 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 本,附每本适合的阶段

资料是我自己整理的,放在下面这个码上,扫码即可获取:

添加时备注「大模型」,优先通过。

拿到之后建议先看学习路线图那一份,先定位自己在哪个阶段,再决定学什么,比一上来就啃框架效率高得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 2:23:58

OpenClaw 本地智能体实操分享,Windows 端一键安装详解

Windows 一键部署 OpenClaw 教程&#xff5c;5 分钟搞定本地 AI 智能体&#xff0c;告别复杂配置 说明&#xff1a;本文适配 CSDN 发布&#xff0c;全部版本号、链接、文件大小已替换&#xff0c;删除标签 / 日期 / 阅读量 / 示意图&#xff0c;规避禁用词汇&#xff0c;首次全…

作者头像 李华
网站建设 2026/10/14 2:21:59

卡牌DP优化实战:从O(n²)到权值线段树

有一次我盯着一道标题写着“卡牌对战游戏&#xff08;DP权值线段树&#xff09;”的题卡了大半个晚上。卡点不在 DP 理解上&#xff0c;而是当我把“每张卡可以选或跳过、且出战序列攻击力严格递增”的状态转移写出来后&#xff0c;发现复杂度直接 O(n)。后来我真正把“权值线段…

作者头像 李华
网站建设 2026/10/14 2:21:21

RTMP Flash播放插件替代方案:flv.js迁移实战与参数调优

简介&#xff1a;这份资源是面向需要在网页中播放RTMP流媒体的开发者与运维人员的一套Flash播放插件实现方案&#xff0c;核心解决旧系统或特定场景下通过Flash技术播放RTMP直播、点播流的问题。压缩包共5个文件&#xff0c;约1.15MB&#xff0c;包含2个swf播放器与安装检测组件…

作者头像 李华
网站建设 2026/10/14 2:21:19

微信小程序仿智行火车票开发实战:云开发+数据离线化方案

简介&#xff1a;本资源是一套完整的微信小程序仿智行火车票12306系统实战项目&#xff0c;面向Java后端与小程序前端初学者及全栈学习者&#xff0c;聚焦铁路订票核心业务场景&#xff0c;涵盖用户查询、车次管理、订单生成等全流程功能实现。项目采用SSM&#xff08;SpringSp…

作者头像 李华