☰
MAC-SQL实战:多智能体协作破解复杂Text-to-SQL查询难题
2026/10/21 14:22:57 网站建设 项目流程

Text-to-SQL 这活儿,干过的都知道,难度从来不在 SQL 语法本身,而在于你没法用一个提示词,让模型在一个几十张表、上百个字段的数据库里精准定位那张对的表、那几列,同时把三层嵌套子查询一次写对。我在做一个内部数据分析助手时被反复毒打过:单表查询还行,一旦涉及多表 Join 加聚合加时间范围过滤,生成结果就开始飘,要么表选错,要么条件漏掉,要么直接报语法错。后来接触到 MAC-SQL 多智能体协作框架,思路一下子通了——不再逼一个 Agent 全包全揽,而是把"读题、选表、拆题、写 SQL、自查"拆给不同角色去做,每个角色只专注一件事。这篇文章就围绕 MAC-SQL 的设计思路、流水线拆解、工程落地和踩坑记录展开,想给业务搭一套可用的 Text-to-SQL 服务的同学,可以直接拿来当参考。

1. 单一Agent的瓶颈:为什么复杂库表查询总在最后一步崩掉

1.1 业务库表的"十选一"难题:全量注入与注意力稀释

先看一个最普遍的场景。公司的业务库通常有几十张表,光订单中心可能就拆了订单主表、订单明细、支付流水、退款记录、物流信息……你要让大模型直接生成 SQL,第一步就会面临 schema 膨胀问题。

大多数人的第一版做法,是把所有表的建表语句拼成一大段作为上下文,让大模型自己挑。实际效果却很惨:prompt 动不动几千 token,模型在处理时注意力被大量无关表名和字段名稀释。比如让模型查"华东区每个部门的销售冠军是谁",它可能在度量的过程中被商品表、库存表里的字段干扰,最终 Join 了错误的表,或者把 dept_id 写成 product_id。这不是模型笨,是人给的信息结构就有问题——你让一个人在一堆无关材料里找答案,他也会被干扰。

MAC-SQL 解决这个问题的核心思路是:在生成 SQL 之前,先做一个专门的"选表"环节,让一个独立 Agent 只干一件事——从全部 schema 里挑出与问题相关的表和字段,再给下游。这样,写 SQL 的 Agent 看到的上下文是精简过后的小 schema,注意力集中,错误率自然下降。

1.2 复杂问题需要多步推理,一步到位必然出错

第二个瓶颈是推理链断裂。一个真正的业务问题,比如"2023 年华东区每个部门中销售额最高的员工是谁",拆开看至少有四步:

  1. 通过区域映射表找到华东区对应的员工;
  2. 按员工聚合 2023 年的订单金额;
  3. 关联员工所属部门;
  4. 用窗口函数或嵌套子查询求每个部门的最高值。

如果只给一个大模型一个"生成 SQL"的 prompt,它是靠一口气完成这四步的。模型内部确实具备推理能力,但一次性输出结果的稳定性很差——任何一步的中间结果偏了,最终 SQL 就错。更麻烦的是,一旦 SQL 报错或者执行结果不对,你没有办法定位是哪一步出了问题。错误是黑盒的,排查成本极高。

MAC-SQL 的分工方式很直接:引入"分解器"角色,先不写 SQL,而是把用户问题拆成若干可以独立验证的子问题,形成执行计划。每个子问题单独写成 SQL 片段,最后再拼装。这就像做菜先列菜单,而不是边炒菜边想下一步放什么。

1.3 没有反馈闭环的生成是"盲写"

第三个痛点,也是很多初版方案彻底翻车的地方:模型生成 SQL 之后,系统直接返回结果给用户,没有任何自查机制。你可能遇到过这种场景——SQL 语法完全正确,执行也不报错,但查出来的数据明显不对。比如问题里写着"2023 年",SQL 里写的是 EXTRACT(YEAR FROM order_date) = 2023,看起来没错,但如果库里的 order_date 存的是下单时间而非支付时间,业务上要的口径是支付时间,结果就完全不是一个意思。

这种"逻辑正确但口径错误"的问题,单靠模型自己是发现不了的。MAC-SQL 的做法是加一个精炼器,在拿到 SQL 初稿后,结合执行反馈(比如是否报错、返回行数是否异常)回来重新审视 SQL,必要时让前置的 Selector 或 Decomposer 重新调整。整个链路不是一条直线走到黑,而是带反馈回环的协作流程。

这三板斧——专注选表、显式拆解、执行反馈自校正,就是 MAC-SQL 多智能体协作框架的出发点。理解了这个动机,再看它的架构就很顺了。

2. 三个智能体的分工与协作:Selector、Decomposer、Refiner 的角色边界

2.1 三种角色的职责定义

MAC-SQL 的核心由三个智能体组成,很多人喜欢把它们类比成"项目经理 + 技术主管 + 资深开发"的组合,我实际体验下来还挺贴切。

Selector(选择器):负责 schema 剪枝。输入是数据库全量 schema 和用户问题,输出是候选表、候选字段以及筛选理由。它相当于一个懂得读题的同学,先把资料范围缩小。

Decomposer(分解器):负责把复杂问题拆成可执行的子问题列表。输入是用户问题、Selector 筛出的相关 schema,输出是若干步骤,每个步骤都对应一个可独立计算的子查询目标。它不写具体 SQL,只写"做什么"。

Refiner(精炼器):负责生成 SQL 初稿、执行校错、必要时重写。输入是子问题列表和相关 schema,输出是最终 SQL。同时它还要负责在收到执行反馈后修正 SQL。

下面这个表可以直观看出三者差异:

角色输入输出核心技能失败代价
Selector全量 schema + 用户问题相关表/字段/理由信息过滤与相关性判断表选错,后面全错
Decomposer用户问题 + 筛选后 schema子问题步骤列表任务拆解与依赖规划步骤不可执行,生成跑偏
Refiner子问题列表 + 筛选后 schema可执行 SQL语法生成与执行纠错SQL 报错或口径错误

2.2 协作数据流:线性主干 + 条件回退

三者协作的主流模式是一条线性主干:用户问题先交给 Selector,输出相关 schema 后交给 Decomposer,Decomposer 输出步骤后由 Refiner 逐步骤生成 SQL 片段并拼装。这是核心骨架,跑通之后,再看回环部分。

回环指的是:Refiner 执行 SQL 如果报错或结果异常,它可以带着错误信息回到两个地方——一是自己调整 SQL;二是如果发现是 schema 支持不够,比如遗漏了某个关键表,那就把信息返回给 Selector,让 Selector 补充表后再重新走一遍分解和生成。这种条件回退在真实工程里非常重要,因为业务问题千奇百怪,第一次选表重来都是不可能全对的。

我见过一些团队做多智能体时,把流程设计得特别复杂,互相之间反复传递消息,结果 token 消耗巨大且响应慢。MAC-SQL 的价值在于它守住了主干的简洁性:正常情况下就三级依次执行,异常时再触发回退,而不是让三个 Agent 全程同步对话。

2.3 为什么是"选择-分解-精炼"这个顺序,而不是"生成-检查-再生成"

有人可能会问:Selector 确实需要前置,但 Decomposer 和 Refiner 为什么不能合并成一个 Agent?它俩都属于"理解问题并产出结果"的阶段。

我的实测体会是:如果合并,模型会偷懒,直接跳过拆解步骤去写 SQL。分开之后,Decomposer 的输出被强制物化为中间结构,等于给推理过程加了一个检查点。一旦最终 SQL 出错,你可以回看 Decomposer 的步骤定义,判断到底是拆解方向错了,还是 SQL 实现错了,错误定位成本大大降低。

这个设计还有一个隐性好处:子问题列表天然就是"执行计划",Refiner 可以一次只写一个步骤对应的 SQL 片段,写完后拼装。每一步的上下文负担小,正确率比一次性生成整条 SQL 高得多。这里我强烈建议,不要轻易把这三级压缩成两级,除非你只处理非常简单的单表查询。

3. 一次完整查询的拆解过程:从自然语言到 SQL 的流水线走读

3.1 一个贯穿案例:多表聚合的完整链路

为了看得清楚,我用一个内部模拟业务库作为案例。库里有五张核心表:

  • employees(员工表):id, name, department_id, hire_date
  • departments(部门表):id, dept_name
  • orders(订单表):id, employee_id, order_date, amount, status
  • regions(区域表):id, region_name
  • employee_region(员工区域关联表):employee_id, region_id

用户提出的问题是:"2023 年华东区每个部门中销售额最高的员工是谁?"

这是典型的多表 Join + 聚合 + 分组 TopN 问题,对单一 Agent 来说已经属于高难度。我们用 MAC-SQL 的完整链路走一遍。

3.2 Selector 的筛选输出长什么样

Selector 拿到全量 schema 和问题后,会分析出与问题相关的表。我的框架里会要求它以 JSON 格式输出,方便下游解析:

{ "related_tables": ["employees", "departments", "orders", "regions", "employee_region"], "related_columns": { "employees": ["id", "name", "department_id"], "departments": ["id", "dept_name"], "orders": ["employee_id", "order_date", "amount", "status"], "regions": ["id", "region_name"], "employee_region": ["employee_id", "region_id"] }, "reason": "问题需要区分2023年订单、华东区员工、每个部门、销售额最高,因此需要关联员工、部门、订单、区域以及员工区域关联五张表。" }

注意一个细节:Selector 不仅选表,还选列。这一步很关键,因为如果只选表,下游 Refiner 依然要面对每个表的全部字段,上下文压缩效果就大打折扣。列级筛选越小,下游模型的注意力越集中。比如 orders 表可能有十几列,这里只筛出四个关键列,意图非常干净。

3.3 Decomposer 如何把问题拆成可执行计划

拿到筛选后的 schema,Decomposer 开始拆解。它不写 SQL,只输出结构化子任务:

{ "steps": [ {"step_id": 1, "goal": "找到华东区对应的员工ID集合", "depends_on": []}, {"step_id": 2, "goal": "按员工聚合2023年订单金额,得到每个员工的年度销售额", "depends_on": [1]}, {"step_id": 3, "goal": "关联员工所属部门,得到每个员工的部门归属和销售额", "depends_on": [2]}, {"step_id": 4, "goal": "按部门分组,找出每个部门销售额最高的员工", "depends_on": [3]} ], "join_hint": "员工表经employee_region与区域表关联;订单表经employee_id与员工表关联;部门表经department_id与员工表关联" }

这个步骤列表的价值在于:每个步骤都是可以独立验证的中间结果。比如步骤 1,我可以单独执行一条 SQL 看看返回的员工 ID 是不是都在华东区内,如果不对,就不会往后走。对比一次生成整条 SQL 的做法,这种"分步可验证"的特性极大降低了排错难度。

3.4 Refiner 的生成链路:单步 SQL → 执行反馈 → 修正

Refiner 按步骤逐个生成 SQL 片段,然后拼装。我们看它最终生成的主查询会长什么样:

WITH regional_employees AS ( SELECT e.id AS employee_id, e.name, e.department_id FROM employees e JOIN employee_region er ON e.id = er.employee_id JOIN regions r ON er.region_id = r.id WHERE r.region_name = '华东区' ), employee_sales AS ( SELECT o.employee_id, SUM(CASE WHEN o.status != 'cancelled' THEN o.amount ELSE 0 END) AS total_sales FROM orders o JOIN regional_employees re ON o.employee_id = re.employee_id WHERE o.order_date >= '2023-01-01' AND o.order_date < '2024-01-01' GROUP BY o.employee_id ), ranked_sales AS ( SELECT es.employee_id, d.dept_name, es.total_sales, ROW_NUMBER() OVER (PARTITION BY d.dept_name ORDER BY es.total_sales DESC) AS rn FROM employee_sales es JOIN employees e ON es.employee_id = e.id JOIN departments d ON e.department_id = d.id ) SELECT employee_id, dept_name, total_sales FROM ranked_sales WHERE rn = 1;

生成之后,Refiner 会执行这条 SQL。如果执行成功但返回空集,它会检查是不是区域名称写错了、订单日期过滤是否有问题;如果报错,它会把数据库返回的错误信息一起丢回给模型,提示模型重写。这套逻辑完成后,才算完成一次完整服务。

4. 让协作真正跑起来的关键设计:Prompt 构造、上下文预算与自校正机制

4.1 每个 Agent 的 Prompt 模板怎么搭

很多人在复现多智能体框架时,觉得难点在于"智能体通信",实际上最影响效果的往往是 prompt 模板设计。MAC-SQL 的三个 Agent 各有一套独立的 prompt,核心是让每个角色明白自己的职责边界,不要越界。

Selector 的 prompt 我建议强调三点:只做信息过滤、不做任何 SQL 生成、输出必须是 JSON。还要给出"如果问题模糊,保守优先"的规则——不确定的表宁可多选,也别漏选,因为多选只浪费一点 token,漏选则会导致整个 SQL 错误。

Decomposer 的 prompt 要强调"步骤必须可执行、可验证"。很多模型会把步骤拆成"分析订单情况"这种抽象描述,这是不合格的。合格步骤应该是"按员工聚合 2023 年订单金额"。我在实测中发现,给 Decomposer 一两个好的 few-shot 示例,比反复强调要有效得多。模型模仿能力很强,示例里的风格直接被复刻。

Refiner 的 prompt 最关键,要求它"每一步只生成一个 SQL 片段,最后再组装"。如果让它一口气生成完整 SQL,效果会退回单 Agent 的老路。另外要明确提示它在收到错误信息后的处理策略:先判断报错类型(语法错误、字段不存在、逻辑异常),再决定重写还是回退。

4.2 上下文与 token 预算分配:不要让后段 Agent 被历史淹没

一个容易被忽视的问题是:整条链路跑下来,如果每一步都把用户原始问题、之前所有 Agent 的输出全部塞给下一个 Agent,越到后面上下文越长,模型越容易被无关信息干扰。MAC-SQL 的工程化实现里,必须做上下文裁剪。

我给一个可复用的预算分配策略。假设单次请求总 token 预算为 8k:

环节Token 预算主要承载内容
Selector 输入3k全量 schema + 用户问题
Selector 输出0.5k筛选后的表/列 JSON
Decomposer 输入1k筛选后 schema + 用户问题
Decomposer 输出1k子问题步骤列表
Refiner 输入2k步骤列表 + 相关列定义
Refiner 输出0.5kSQL 片段与最终 SQL

这个分配方案的核心思想:越到下游,输入越精简,让最需要精准输出的 Refiner 用最少的上下文。实际落地时,你可以根据模型窗口大小等比缩放。

4.3 自校正反馈还能怎么加码

MAC-SQL 里的 Refiner 自校正不自于一句话,而是一套反馈信号。除了最基本的"SQL 执行报错信息回传"之外,我强烈建议加两个反馈源:

第一个是结果行数校验。先让 Refiner 额外生成一条 COUNT(*) 辅助查询,与最终 SQL 一起执行。比如案例里的"每个部门 Top1",正常的返回行数应该等于部门数。如果返回 200 行,明显异常,说明分组条件写错了。这种数量级的异常判断,模型看一眼就能反应过来。

第二个是断言校验。对于用户问题中明确出现的数值条件,比如"销售额大于 500 万"中的 500 万,可以让 Refiner 在生成 SQL 前先输出一个断言:SQL 中是否包含该阈值的比较条件。如果断言失败,直接判定 SQL 错误,触发重写。这个方法对数字条件多的金融、销售类查询特别有效。

4.4 模型选型与成本权衡

三个 Agent 不一定用同一个模型。Selector 和 Decomposer 的任务偏"理解 + 归纳",相对简单,可以用参数量较小的模型,或者同一模型的不同低配版本;Refiner 需要较强的编码能力和自我纠错能力,应该用能力最强的模型。

我测试过一个组合:Selector 和 Decomposer 用中等能力的模型,Refiner 用能力更强的模型,整体效果不降,成本却下降了约三成。当然,这种搭配依赖具体的模型能力和业务复杂度,建议你根据自己的接入方式来评估,没有绝对最优解。

5. 实测对比与踩坑记录:哪些失败模式最值得警惕

5.1 同一批数据上的效果对比

为了验证多智能体协作的实际增益,我在模拟业务库上做了一轮小规模评测。测试集包含 50 条查询,分为三类:简单单表查询、多表 Join 查询、复杂聚合嵌套查询。对比了三种方案:

方案简单单表多表 Join复杂聚合嵌套
单 Agent 直接生成72%41%22%
单 Agent + 执行反馈自纠正76%52%31%
MAC-SQL 三智能体线性协作81%63%47%
MAC-SQL + 条件回退闭环84%71%55%

这个数据说明两个问题。第一,多智能体的增益主要集中在复杂查询上,简单查询反而因为链路变长而收益有限,甚至有轻微延迟成本。第二,只做线性协作还不够,接入回退闭环后多表 Join 和复杂聚合的提升幅度最明显。如果业务场景大多是单表查询,你确实不需要上完整的 MAC-SQL,这是很务实的结论。

5.2 失败模式一:Selector 漏选关键表

最常见的失败原因是 Selector 漏选表。比如用户问"每个产品品类的库存周转天数",Selector 可能选了产品表、库存表,却漏了采购明细表,导致下游无法计算周转天数。排查时发现 Decomposer 写出的步骤已经包含了"计算采购量"这一步,但因为核心表没选进来,Refiner 只能硬编一个不存在的关联,最终 SQL 执行报错。

我后来在 Selector 的 prompt 里强制加了一条规则:对于包含"每个""各个""按 XX 分组"这类表达的问题,如果 schema 中存在对应的维度表,必须一并选入。加了这条规则后,漏选率下降了近一半。这说明 Selector 的设计不能只靠大模型悟性,还要把业务中的常见模式沉淀成显式规则。

5.3 失败模式二:Decomposer 分解出不可执行的步骤

第二个高频问题在 Decomposer 输出的步骤过于抽象。例如它写出"分析 2023 年华东区的销售情况",这种步骤没法直接对应到 SQL 片段。原因在于模型的训练数据里,这类步骤通常会出现在文本分析场景,而不是 SQL 生成场景。

解决办法是给 Decomposer 配置一个"步骤质量检查器",或者至少在 prompt 里明确每次输出的步骤必须包含具体表名和字段名。我在实际操作中,会让步骤描述强制采用"动词 + 对象 + 条件"的句式,比如"按员工聚合 2023 年的订单金额",而不是"分析销售情况"。这个句式要求写进 prompt 之后,Decomposer 的步骤可执行性明显提升。

5.4 失败模式三:Refiner 的"过度修正"

第三种情况很有意思,不是模型不够聪明,而是它太聪明了。Refiner 在拿到执行反馈后,有时会因为一次报错而大规模重写 SQL,把本来正确的部分也改坏了。比如 SQL 本身只是少了左括号,模型收到报错后却把整个 JOIN 结构都换了,结果语法对了,业务逻辑错了。

我处理这个问题的办法是给 Refiner 的重写设置边界:每次修正时,先定位错误发生在哪个子步骤,只允许修改对应的 SQL 片段,不允许重写其他步骤的产物。同时在生成时限定重写版本号,如果连续两次修正后仍然报错,就触发回退到 Decomposer,重新拆解,而不是让 Refiner 无限自旋。加了这两条约束之后,过度修正的比例大幅度下降。

5.5 调参心得:temperature、few-shot 与重试上限

最后汇报几个可操作的经验值。Temperature 建议统一设在 0 到 0.2 之间,尤其 Decomposer 和 Refiner,低温能显著减少随机性输出。Refiner 的 few-shot 示例控制在 2 到 3 个,多了会挤占上下文,少了风格不稳定。整个链路的重试上限我建议设为 3 次:到达上限后把中间产物和原始问题返回给用户侧,由人工介入,而不是无限烧 token 在模型自旋上。

6. 把 MAC-SQL 搬进业务系统:工程化落地与二次开发建议

6.1 业务 SQL 接口的配套约束

框架跑通之后,真正落地到业务系统还有不少工程问题。首先是数据库账号的权限边界,这一点无论如何强调都不过分。给 Text-to-SQL 服务用的账号必须是最小权限,最好数据库级别只开放 SELECT 权限,并且通过只读账号连接。

其次是查询防护,在 Refiner 生成 SQL 后接入一个规则引擎兜底。规则包含:禁止 SELECT * 导出全表、必须带 LIMIT 限制返回行数、禁止执行多语句拼接(防止注入)、对涉及大表全扫的查询做超时熔断。这些规则不属于 MAC-SQL 的核心,但少了它们,再好的框架上线也让人睡不着觉。

6.2 从基准集迁移到自有库的适配点

如果你是在标准 Text-to-SQL 基准集上验证过 MAC-SQL,再迁移到业务自有库,有三个适配点不能跳过。第一,schema 表示方式要调整,基准集的 schema 通常是单库多表且结构清晰,业务库可能有视图、物化视图、分区表,这些都要在 schema 描述里体现;第二,业务字段经常有特殊含义,比如 status 字段的不同取值代表什么,需要在 schema 描述中附带字段注释,Selector 才会正确判断相关性;第三,用户问题的表达习惯差异很大,业务人员会讲"上个月卖得最好的货",而基准集都是规范表述,这需要在 Decomposer 环节增加一个"问题改写"前置步骤,把口语改写为规范查询意图。

6.3 多智能体编排的工程实现思路

代码实现上,不一定要上重量级的多智能体框架。我用最简单的 Python 状态机就能把核心跑起来。伪代码逻辑如下:

def run_mac_sql(question, full_schema, context): # 第一阶段:选表 schema_json = selector.run(question, full_schema) # 第二阶段:拆解 steps = decomposer.run(question, schema_json) # 第三阶段:精炼,支持回退 for attempt in range(MAX_RETRY): sql = refiner.generate(steps, schema_json, context) result, error = execute_sql(sql) if error is None and validate(result, steps): return sql, result context.append_error(error or result_anomaly) # 重试耗尽,回退到 Decomposer 重新拆解 steps = decomposer.run_with_feedback(question, schema_json, context) ...

这段代码里有两个小细节值得注意:一是 context 对象保存的是历史错误摘要,不是原始错误日志,避免上下文无限膨胀;二是 validate 函数做简单的行数断言校验,帮助 Refiner 判断"执行成功但结果可能错误"的情况。

6.4 扩展方向:记忆模块、工具调用与人工介入

MAC-SQL 的原始框架是基线的,实际业务中还可以继续扩展。比较推荐的方向有三个。

第一是跨请求记忆。同一个用户在会话里有多个连续问题时,直接把每一轮都跑完整链路,会让 Decomposer 把同一个库表拆解好几遍。可以把 Selector 的筛选结果和 Decomposer 的步骤列表缓存到会话级,后续问题如果涉及同一组表,直接复用中间产物。

第二是工具调用。在 Decomposer 拆解后,可以挂一个工具查询模块,自动去取字段注释或者数据库字典里的业务口径说明,补充给 Refiner。这个做法的本质是让 Refiner 不只看 schema 名字,还看业务定义,对口径错误类问题很有效。

第三是人机协同。当 Self-Refine 连续两次失败时,把中间产物(筛选表、拆解步骤、错误列表)打包发送到一个人工审核队列。有经验的 DBA 往往一眼就能看出 Schema 选错还是步骤拆错,他修正后可以再重新喂给 Refiner。

我的实际体会是,MAC-SQL 这类多智能体协作框架最值得学习的地方,不是某个具体实现细节,而是它把"一个模型全包"的思维,转化成了"流水线 + 专业化分工 + 反馈闭环"的工程思维。你不需要完全抄它的实现,只要抓住选表前置、拆解显式化、执行反馈这三个核心思想,再结合自己业务的表结构特点做适配,大概率能做出比单一提示词方案稳定几个档次的 Text-to-SQL 服务。特别是当你被复杂多表查询折磨过之后,你会发现:让每个智能体只做一件小事,并把每一步的可验证性做扎实,比任何魔法提示词都可靠得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询