资讯动态

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

发布时间:2026/10/14 2:24:42 来源:尧图企业网站定制
说明本文讨论的是把自然语言问句接进业务数据库、由模型生成可执行 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:int12)-dict:# catalog: {table_name: {ddl: str, columns: [...], fks: [...]}}tokensset(question.lower().split())scored[]forname,metaincatalog.items():hitlen(tokensset(name.lower().split(_)))hitlen(tokens{c.lower()forcinmeta[columns]})scored.append((hit,name))scored.sort(reverseTrue)picked[nameforscore,nameinscored[:top_k]ifscore0]# 关键约束一张表都没命中时不能硬选要让上层走请求补充 schema分支enoughlen(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:订单时间 - 注册时间 0dprecedence这一行是治理口径冲突的入口只要映射表里有它模型就不需要在两条都能跑通的路径之间做选择。四、歧义与追问哪些问句必须反问不是所有模糊问句都值得打断用户。判据只有一条两种合理解释会不会给出差距很大的数字。会就必须反问不会就按默认值走并标注。必须反问的有四种。时间范围缺省——「上个月增长多少」里的「上个月」可能是自然月也可能是滚动 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:fscope:{term},options:[对外,内部],default:对外})# 关键约束多个歧义点合并成一次提问不要逐条追问return{need_clarify:len(gaps)0,questions:gaps}五、权限与只读边界连接必须是只读账号生成的 SQL 不可信权限是最后一道闸。执行连接应该用只读账号只授予SELECT只对白名单内的库表而不是「虽然流程只查但账号有写权限」。不要因为「这条链路只做查询」就复用业务主账号。模型生成的语句不受你控制一次提示注入或者一次改写失控写权限就会变成可执行的风险面。数据库侧还可以再加两道连接级别的超时以及事务级别的只读声明让这条连接在数据库层面也写不进去。⚠️ 代码待验证# 为查询链路单独建只读账号只给白名单表的 SELECT# 具体语法按目标数据库调整这里只示意授权的粒度CREATE ROLE agent_ro LOGIN PASSWORDfrom-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):planconn.explain(sql)ifplan.estimated_costcost_limit:# 不执行把计划信息交回上层让模型改写return{status:rejected,reason:cost,hint:plan.summary}try:rowsconn.fetch(sql,timeouttimeout_s,max_rowsrow_limit1)exceptDBErrorase:return{status:error,error:translate_error(e)}# 已脱敏truncatedlen(rows)row_limit rowsrows[: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 本附每本适合的阶段资料是我自己整理的放在下面这个码上扫码即可获取添加时备注「大模型」优先通过。拿到之后建议先看学习路线图那一份先定位自己在哪个阶段再决定学什么比一上来就啃框架效率高得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑