资讯动态

Text2SQL 全链路实战:Schema 召回、SQL 生成与 LangGraph Agent 编排

发布时间:2026/9/19 3:08:49 来源:尧图企业网站定制
1. 从自然语言到可执行 SQL 的完整链路拆解Text2SQL 这件事表面上看是把一句话翻译成 SQL但真正落地到 Agent 场景里它其实是一条包含意图识别、Schema 召回、SQL 生成、校验、执行、纠错、结果解释的完整链路。任何一个环节掉链子最终用户看到的都是查不出来或者查错了。我在实际项目里踩过最多的坑不是模型不会写 SQL而是模型根本不知道有哪些表、哪些字段、字段之间怎么关联。先明确一下这个链路的核心角色。用户输入一句自然语言比如上个月华东区销售额排名前五的产品Agent 需要完成的事情包括判断这是不是一个查询请求、找到相关的表和字段、理解上个月对应的时间范围、知道华东区存在哪个字段里、生成符合方言的 SQL、检查 SQL 是否安全、执行并返回结果。这一整套流程才是 Text2SQL 全链路的真实面貌。为什么要把链路拆得这么细因为每个环节的失败模式完全不同。Schema 召回错了后面全错时间解析错了结果偏差但 SQL 语法没问题SQL 方言不对执行直接报错。如果不拆开你根本不知道问题出在哪只能笼统地说模型不行。拆开之后每个环节都可以单独优化、单独评估。这条链路适合谁来参考如果你正在做数据分析 Agent、BI 助手、数据库问答机器人或者任何需要让非技术用户用自然语言查数据的场景这套拆解都适用。哪怕你用的是 LangGraph 这类框架来编排 Agent底层的链路逻辑是一样的框架只是帮你把状态流转管起来。我个人的经验是Text2SQL 项目 80% 的精力应该花在 Schema 理解和上下文构建上而不是花在换更大的模型上。模型再强你给它一堆无关的表结构它也写不出正确的 SQL。反过来如果你能把相关的表、字段、枚举值、示例查询精准地喂给模型哪怕用中等规模的模型效果也能做得很好。2. Schema 召回决定 Text2SQL 上限的关键一步2.1 为什么 Schema 召回比模型选型更重要很多人做 Text2SQL 的第一反应是我要用最强的模型但实际跑下来会发现模型能力的差异远没有 Schema 召回的差异大。原因很简单数据库里可能有几百张表每张表几十个字段你不可能把所有 Schema 都塞进 Prompt。就算塞得下模型也会被无关信息干扰生成错误的 JOIN 或者用错字段。Schema 召回要解决的问题是给定用户的问题从庞大的数据库 Schema 中找出真正相关的那几张表、那几个字段。这一步做准了模型只需要在很小的范围内做选择准确率自然高。做不准模型就是在噪音里找信号再强也没用。我见过一个真实案例一个电商数据库有 300 多张表用户问最近一周退款最多的商品。如果全量 Schema 塞进去模型很容易把退款表和订单表、商品表搞混甚至去 JOIN 物流表。但如果我们能召回退款表、订单明细表、商品表这三张模型生成 SQL 的准确率能从 40% 提升到 85% 以上。这个提升幅度换任何模型都做不到。2.2 基于向量检索的 Schema 召回实操最常见的做法是把表名、字段名、字段注释、枚举值描述都向量化存到向量库里用户问题来了先做相似度检索。这个思路没问题但有几个细节决定了效果好坏。第一不要只向量化表名和字段名。字段名往往是缩写比如amt、qty、stat光看名字根本不知道是什么。一定要把字段的中文注释、业务含义一起向量化。如果数据库没有注释那就得人工补或者从历史 SQL 里反推字段含义。第二召回粒度要控制。按表召回还是按字段召回我的建议是两级先按表召回 Top-N 张表再在这些表里按字段召回 Top-M 个字段。这样既能保证表级别的关联性又能避免字段太多导致 Prompt 过长。第三要加入业务术语映射。用户说的销售额可能对应gmv字段说的客单价可能对应avg_order_amount。这些映射关系如果只靠向量相似度经常召不回。更好的做法是维护一个业务术语词典先做术语归一化再去做向量检索。# Schema 召回的核心逻辑示意 def recall_schema(question, vector_store, top_k_tables5, top_k_columns20): # 第一步业务术语归一化 normalized normalize_business_terms(question) # 第二步按表召回 table_hits vector_store.search_tables(normalized, top_ktop_k_tables) # 第三步在召回的表内按字段召回 column_hits [] for table in table_hits: cols vector_store.search_columns( normalized, table_filtertable.name, top_ktop_k_columns // top_k_tables ) column_hits.extend(cols) # 第四步补充外键关联表 related_tables expand_by_foreign_keys(table_hits) return build_schema_context(table_hits related_tables, column_hits)2.3 外键扩展别让模型自己猜关联关系Schema 召回有一个容易被忽略的点召回了订单表但没召回用户表模型想查下单用户的等级就无从下手。这时候需要做外键扩展——根据召回表的外键关系把直接关联的表也带进来。但外键扩展不能无限扩散否则又会引入噪音。我的做法是只扩展一跳而且只扩展那些在历史查询中被频繁 JOIN 的表。可以统计历史 SQL 里表与表的共现频率共现高的才扩展。这样既保证了关联性又控制了噪音。还有一个细节很多数据库根本没有外键约束尤其是数据仓库场景。这时候就得靠人工维护关联关系或者从历史 SQL 的 JOIN 语句里挖掘。我一般会写个脚本把历史 SQL 解析一遍统计哪些表经常一起出现形成一张隐式外键表。这张表在 Schema 召回时非常有用。3. SQL 生成Prompt 设计与方言适配的实战细节3.1 Prompt 里到底该放什么Schema 召回完成后接下来就是构造 Prompt 让模型生成 SQL。Prompt 的设计直接决定生成质量我总结下来必须包含这几块数据库方言说明、相关表结构、字段枚举值、示例查询、输出格式约束。数据库方言说明经常被忽略。同样是分页MySQL 用LIMITSQL Server 用OFFSET FETCH或者TOPOracle 用ROWNUM。如果你不告诉模型用哪种方言它可能生成一个语法不对的 SQL。我一般会在 Prompt 开头明确写你正在为 MySQL 8.0 生成 SQL这一句话能减少很多方言错误。字段枚举值也很关键。比如订单状态字段status值是1/2/3/4分别代表待支付、已支付、已发货、已完成。如果 Prompt 里不写清楚用户问已支付的订单模型可能生成status 已支付直接查不出数据。把枚举值映射写进 Prompt这类错误基本能避免。示例查询是另一个提升准确率的利器。给模型 2-3 个相似问题的正确 SQL它就能模仿着写。示例不用多但要有代表性覆盖常见的查询模式单表过滤、多表 JOIN、聚合分组、时间范围查询。3.2 输出格式约束与结构化解析让模型直接输出 SQL 字符串解析起来很麻烦因为模型可能加解释、加 Markdown 代码块。更好的做法是要求模型输出结构化 JSON包含sql、explanation、tables_used等字段。这样解析稳定也方便后续做校验。SQL_GENERATION_PROMPT 你正在为 MySQL 8.0 生成 SQL。 相关表结构 {schema_context} 字段枚举值 {enum_mappings} 示例 问题查询最近7天已支付的订单数量 SQLSELECT COUNT(*) FROM orders WHERE status 2 AND created_at DATE_SUB(NOW(), INTERVAL 7 DAY) 请以 JSON 格式输出包含以下字段 - sql: 生成的 SQL 语句 - explanation: 简要说明查询逻辑 - tables_used: 使用的表名列表 - confidence: 你对这个 SQL 的置信度0-1 用户问题{question} 置信度这个字段很有用。模型给出低置信度时可以触发人工确认或者走更保守的查询策略。虽然模型的置信度不一定准但作为一个参考信号比没有强。3.3 多轮对话中的上下文继承实际使用中用户很少一次就把问题说清楚。经常是查一下上个月的销售额然后那华东区呢再然后按产品分组看看。这种多轮对话场景Agent 需要继承上下文把省略的信息补全。我的做法是维护一个对话状态记录上一轮的 SQL、涉及的表、时间范围、过滤条件。新一轮问题来了先判断是全新查询还是对上一轮的修改。如果是修改就把上一轮的 SQL 作为基础只改动用户提到的部分。这里有个坑不要直接把上一轮的 SQL 丢给模型让它改因为模型可能会改错地方。更好的做法是把上一轮的查询意图结构化比如{metric: 销售额, time_range: 上月, filters: [], group_by: []}新一轮只更新这个结构然后重新生成 SQL。这样更可控。4. SQL 校验与安全防护别让 Agent 变成删库工具4.1 语法校验与方言检查模型生成的 SQL 不能直接执行必须先过校验。第一层是语法校验用对应数据库的解析器检查 SQL 是否合法。比如 MySQL 可以用sqlparse或者直接EXPLAIN一下SQL Server 可以用SET PARSEONLY ON。语法都不对的 SQL直接打回让模型重新生成。第二层是方言检查。有时候 SQL 语法是对的但用了别的数据库的函数。比如在 MySQL 里用了NVLOracle 的函数语法解析可能过但执行会报错。我一般会维护一个函数白名单检查 SQL 里用到的函数是否在白名单内。第三层是 Schema 一致性检查。检查 SQL 里引用的表名、字段名是否真的存在。这一步能拦住很多模型幻觉出来的字段。实现方式很简单把 SQL 解析成 AST提取所有表名和字段名和真实 Schema 做比对。4.2 危险操作拦截Text2SQL 场景下Agent 应该只有只读权限。但即便如此也要在 SQL 层面做拦截防止模型生成DELETE、UPDATE、DROP这类语句。我的做法是维护一个危险关键词黑名单生成 SQL 后先做关键词匹配命中就直接拒绝。但关键词匹配不够因为 SQL 可以混淆比如用注释分割关键词。更可靠的做法是解析 SQL 的 AST判断语句类型。只允许SELECT和WITHCTE其他一律拒绝。这样即使模型被诱导生成危险 SQL也执行不了。还有一个容易被忽略的点即使都是SELECT也要防止全表扫描。用户问查一下订单模型可能生成SELECT * FROM orders几千万行的表直接查挂。我一般会强制加LIMIT或者在 Prompt 里要求模型必须加时间范围或分页条件。4.3 执行前的成本预估在真正执行之前可以先EXPLAIN一下看看预估的扫描行数。如果扫描行数超过阈值比如 100 万行就先不执行而是提示用户这个查询范围太大请缩小时间范围或增加过滤条件。这样能避免慢查询拖垮数据库。这个阈值怎么定要看你的数据库承载能力。我的经验是OLTP 库单查询扫描行数控制在 10 万以内OLAP 库可以放宽到 1000 万。超过阈值就拦截让用户细化问题。这个策略在实际使用中能拦掉大部分一句话查全表的请求。5. 执行纠错SQL 报错之后 Agent 该怎么办5.1 错误分类与自动重试策略SQL 执行报错是常态关键是 Agent 能不能自动纠错。我一般把错误分成三类语法错误、Schema 错误、运行时错误。语法错误最好处理把错误信息连同原 SQL 一起丢回给模型让它修正。Schema 错误比如字段不存在也是类似处理但要把正确的 Schema 信息一起给模型避免它再次幻觉。运行时错误比如除零错误、类型转换失败这类需要更谨慎因为可能是数据问题而不是 SQL 问题。自动重试不能无限次我一般设置最多 2 次重试。第一次重试带上错误信息第二次重试如果还失败就放弃并返回错误给用户。无限重试不仅浪费 token还可能陷入死循环。def execute_with_retry(sql, max_retries2): for attempt in range(max_retries 1): try: result db.execute(sql) return {success: True, data: result} except SyntaxError as e: if attempt max_retries: return {success: False, error: str(e)} sql regenerate_sql(sql, errorstr(e), error_typesyntax) except SchemaError as e: if attempt max_retries: return {success: False, error: str(e)} sql regenerate_sql(sql, errorstr(e), error_typeschema) except RuntimeError as e: # 运行时错误不自动重试直接返回 return {success: False, error: str(e)}5.2 空结果的处理比报错更棘手的是SQL 执行成功但返回空结果。这时候用户会问为什么没数据Agent 需要判断是本来就没数据还是查询条件写错了。我的做法是做一个宽松查询验证把一些可能过严的条件去掉再查一次。比如用户问上周华东区销售额返回空。那就去掉华东区再查如果还是空说明上周可能真没数据如果有数据说明华东区这个条件有问题可能是字段值不匹配。这个策略能区分真没数据和条件写错。但要注意宽松查询也要控制范围不能把时间条件也去掉否则可能查全表。5.3 结果解释与可视化建议SQL 执行出结果后Agent 不应该只返回一个表格而应该用自然语言解释结果。比如上周华东区销售额为 123 万元环比下降 5%。这样用户不用自己看数字。如果结果是多行多列可以建议可视化方式。比如时间序列建议折线图分类对比建议柱状图。这个建议不用真的画图只要告诉用户这个结果适合用折线图展示就行。前端拿到建议后可以自动渲染。6. 用 LangGraph 编排 Text2SQL Agent 的状态流转6.1 为什么选 LangGraph 而不是简单链式调用Text2SQL 链路有分支、有循环、有状态。比如 Schema 召回后可能要走需要澄清的分支SQL 执行失败后要回到生成节点重试。这种带循环的流程用简单的链式调用很难表达用 LangGraph 就很自然。LangGraph 的核心概念是状态图定义状态结构定义节点定义边。节点之间可以条件跳转可以循环。Text2SQL 的每个环节就是一个节点环节之间的流转就是边。状态里存用户问题、召回的 Schema、生成的 SQL、执行结果、错误信息等。和 LangChain 的区别在于LangChain 更偏向线性的 ChainLangGraph 更适合有状态、有分支、有循环的 Agent 场景。Text2SQL 恰好是后者。如果你的流程是召回→生成→执行一条直线那用 LangChain 也行但一旦要加重试、澄清、多轮LangGraph 的优势就出来了。6.2 状态设计与节点划分状态设计是 LangGraph 编排的核心。我一般会定义这些字段question当前问题、history对话历史、schema_context召回的 Schema、generated_sql生成的 SQL、execution_result执行结果、error错误信息、retry_count重试次数、need_clarification是否需要澄清。节点划分上我倾向于拆得细一点normalize_question问题归一化、recall_schemaSchema 召回、generate_sqlSQL 生成、validate_sqlSQL 校验、execute_sql执行、handle_error错误处理、explain_result结果解释。每个节点职责单一方便单独测试和优化。from langgraph.graph import StateGraph, END from typing import TypedDict, List class Text2SQLState(TypedDict): question: str history: List[dict] schema_context: str generated_sql: str execution_result: dict error: str retry_count: int need_clarification: bool def build_graph(): graph StateGraph(Text2SQLState) graph.add_node(normalize, normalize_question) graph.add_node(recall, recall_schema) graph.add_node(generate, generate_sql) graph.add_node(validate, validate_sql) graph.add_node(execute, execute_sql) graph.add_node(handle_error, handle_error) graph.add_node(explain, explain_result) graph.set_entry_point(normalize) graph.add_edge(normalize, recall) graph.add_edge(recall, generate) graph.add_edge(generate, validate) graph.add_conditional_edges( validate, lambda s: execute if s[valid] else handle_error ) graph.add_conditional_edges( execute, lambda s: explain if s[success] else handle_error ) graph.add_conditional_edges( handle_error, lambda s: generate if s[retry_count] 2 else END ) graph.add_edge(explain, END) return graph.compile()6.3 条件边与循环控制LangGraph 的条件边是实现分支和循环的关键。比如校验节点之后根据校验结果决定是走执行还是走错误处理。执行节点之后根据执行结果决定是走结果解释还是走错误处理。错误处理节点之后根据重试次数决定是回到生成节点还是结束。循环控制要特别注意退出条件。重试次数是最常见的退出条件但也要考虑其他情况。比如如果错误是权限不足重试多少次都没用应该直接结束。如果错误是字段不存在重试一次可能就修好了。所以错误处理节点里要根据错误类型决定是否值得重试。还有一个实践细节LangGraph 的状态是共享的每个节点返回的更新会合并到全局状态。所以节点函数要返回增量更新而不是完整状态。比如生成节点只返回{generated_sql: sql}而不是整个状态对象。这样代码更清晰也避免意外覆盖其他字段。7. 评估与迭代怎么知道你的 Text2SQL Agent 好不好用7.1 构建评估集的方法Text2SQL 没有评估集就等于盲人摸象。评估集要包含问题、标准 SQL、标准结果三部分。构建方法有三种从历史查询日志里挖、人工标注、用模型生成后人工校验。从历史日志挖是最实用的。把用户真实问过的问题和对应的 SQL 收集起来去掉重复和错误的就是一批很好的评估样本。人工标注成本高但质量最好适合做核心测试集。模型生成后人工校验适合快速扩充评估集但要注意校验质量。评估集要覆盖不同的查询类型单表过滤、多表 JOIN、聚合、子查询、时间范围、排序分页。还要覆盖不同的难度简单查询、中等复杂、复杂嵌套。我一般会构建一个 200-500 条的评估集按类型和难度分层。7.2 评估指标的选择评估指标不能只看SQL 是否完全匹配因为同一个查询可能有多种写法。我一般用三个层次的指标执行结果匹配、SQL 语义匹配、组件匹配。执行结果匹配是最宽松的只要 SQL 执行出来的结果和标准结果一致就算对。这个指标最贴近用户感知。SQL 语义匹配是中等SQL 结构可以不同但语义要一致。组件匹配是最严格的表、字段、条件、聚合函数都要对。实际评估中我主要看执行结果匹配率这是最终指标。同时看组件匹配率用来定位问题。如果执行结果匹配率低但组件匹配率高说明可能是数据问题如果组件匹配率也低说明是生成问题。7.3 基于错误分析的迭代方向评估的目的是迭代。每次评估后把错误的 case 拿出来分析归类错误原因Schema 召回错、方言错、字段幻觉、JOIN 错、条件错、聚合错。然后针对性地优化。Schema 召回错就优化召回策略加术语映射加外键扩展。方言错就在 Prompt 里强化方言说明。字段幻觉就加强 Schema 一致性校验。JOIN 错就在 Prompt 里加 JOIN 示例。条件错就补充枚举值映射。聚合错就加聚合函数的示例。我一般会维护一个错误案例库每次迭代后看错误率有没有下降。如果某个类型的错误一直降不下来就说明当前的优化方向不对需要换思路。比如字段幻觉一直降不下来可能不是 Prompt 的问题而是 Schema 召回不精准模型只能靠猜。8. 几个实际踩过的坑和应对经验第一个坑Schema 召回用了向量检索但效果不好。排查后发现是字段注释质量太差很多字段注释是空的或者写的是字段1这种无意义内容。后来花了两周时间把核心表的字段注释补全召回准确率直接翻倍。这件事让我意识到Text2SQL 的效果很大程度上取决于数据治理的质量模型只是放大器。第二个坑模型生成的 SQL 在测试环境跑得好好的上线后频繁报错。排查后发现是生产库和测试库的 Schema 有细微差异比如字段类型不同、枚举值不同。后来建立了 Schema 版本管理每次上线前自动比对差异Prompt 里的 Schema 信息从生产库实时拉取问题才解决。第三个坑多轮对话时用户说再按产品分组看看Agent 把上一轮的 SQL 完全重写了结果把时间范围也改了。后来改成结构化意图继承只更新group_by字段其他保持不变问题解决。这个坑的教训是多轮场景下不要直接操作 SQL 字符串要操作结构化的查询意图。第四个坑SQL 校验只做了语法检查没做 Schema 检查结果模型幻觉出来的字段直接执行报错。后来加了 Schema 一致性校验把 SQL 解析成 AST提取所有表名字段名和真实 Schema 比对幻觉字段在生成阶段就被拦住了。第五个坑没有限制查询范围用户问查一下订单模型生成全表扫描直接把数据库拖慢。后来加了EXPLAIN预估扫描行数超过阈值就拦截提示用户缩小范围。这个策略上线后慢查询告警下降了 90% 以上。这些坑的共同点是都不是模型能力问题而是工程问题。Text2SQL 落地模型选型只是起点真正的功夫在 Schema 治理、Prompt 工程、校验防护、错误处理这些工程细节上。把这些问题解决好中等模型也能做出很好的效果解决不好再强的模型也白搭。

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

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

免费获取报价