Text-to-SQL 评测体系建设

AI Agent 工程实践教程 · 第 11 章

围绕 SQL 正确性、执行结果、用例集和指标,建立 Text-to-SQL 的评测闭环。

返回系列目录

第十一天:Text-to-SQL 评测体系建设

前面几天我们已经把 Text-to-SQL 主流程跑起来了。

用户输入一个自然语言问题,系统会经过:

问题路由
  ↓
业务识别和业务 Skill 加载
  ↓
指标检索和表结构检索
  ↓
SQL 生成
  ↓
SQL 安全校验和执行
  ↓
结果分析

这条链路跑通以后,系统已经从 Demo 进入了“可用”的阶段。

但真实项目里,“可用”还不够。我们还要继续回答几个更难的问题:

这次改 Prompt 以后,效果有没有变好?
这次改业务 Skill 以后,原来正确的问题有没有被改错?
这次新增表结构以后,模型会不会误选表?
这次澄清逻辑调整以后,模型是更谨慎了,还是更容易过度追问?
这次最终回答看起来差不多,语义上到底算不算正确?

这些问题不能靠感觉回答,也不能靠开发同学手动问两三个问题回答。

我们需要一套可以长期沉淀、重复执行、自动对比的机制。

这就是今天要建设的内容:Text-to-SQL 评测体系

Text-to-SQL 评测闭环


1. 为什么 Text-to-SQL 必须做评测

普通后台接口大多是确定性的。

固定输入
  ↓
固定代码逻辑
  ↓
固定输出

但 Text-to-SQL 不是这样。

Text-to-SQL 中间有大模型参与,模型会做很多判断:

判断环节 可能出错的地方
问题路由 没识别成数据查询,或者把普通问答误判成查询
业务识别 订单问题识别成学生问题
Skill 使用 没遵守业务里的统计口径
表结构检索 找到了 prepay_order,但实际应该用 order_payment
SQL 生成 字段、条件、聚合、时间范围写错
SQL 执行 SQL 语法错误,或者查询慢、超时
澄清判断 信息不足时没有追问,或者不该追问时过度追问
结果分析 SQL 查对了,但最终解释说错

例如用户问:

统计上个月订单状态的分类占比

这句话看起来很简单,但系统需要判断:

订单指的是支付订单还是预支付订单?
上个月用 created_at 还是 pay_success_time?
分类占比按 order_status 还是 status?
是否需要先澄清?
如果不澄清,应该选择哪张表?

所以评测不能只看:

SQL 能不能执行?

更应该看:

整条 Text-to-SQL 链路中的关键判断是否符合预期?

2. 当前项目的评测总览

当前项目的评测体系已经不再是“写一个 JSON 去比对 State”的简单版本,而是拆成了几层:

模块 作用
运行记录 保存真实运行、评测运行、最终 State、SQL、回答、步骤记录
人工反馈 把真实问题中的错误沉淀成候选样本
评测样本 保存可复用的问题、业务、目标、场景和判断标准
判断标准 每条样本可以配置多条断言规则
评测任务 选择正式样本,异步批量运行完整 Text-to-SQL graph
评测结果 保存每条样本的实际 State、SQL、回答和通过情况
断言结果 保存每条判断标准的实际值、得分、原因和是否通过
评测报告 展示通过率、失败样本、失败原因分布

整体架构是:

当前评测架构

现在的核心思想是:

样本只负责描述“要测什么”
断言负责描述“怎么判定”
评测任务负责“真实跑一遍”
评测结果负责“留下证据”

3. 从真实运行到评测样本

评测样本最好不要完全靠拍脑袋录入。

真实项目里,最有价值的样本通常来自这几类场景:

来源 说明
MANUAL 人工设计的典型问题
RUN_HISTORY 从真实运行记录中加入样本
FEEDBACK 从用户反馈或测试反馈中沉淀样本

一条真实运行记录里有:

原始问题
最终 State
生成 SQL
执行 SQL
最终回答
每一步 graph 状态

如果人工发现这次结果有问题,比如:

应该查 order_payment,不应该查 prepay_order。

就可以把它加入评测样本,变成以后每次都能复跑的回归用例。

样本沉淀流程

样本状态分为:

状态 说明
DRAFT 草稿样本,还在整理,不参与评测
ACTIVE 正式样本,可以被评测任务选择
DISABLED 暂停使用

只有 ACTIVE 样本会进入评测任务。


4. 评测样本应该保存什么

一条评测样本不是简单保存一个问题。

它至少要回答四件事:

测什么问题?
属于哪个业务?
主要测哪段能力?
怎么判断通过?

当前样本主表是 text_to_sql_eval_question,重点字段如下:

字段 说明
question 评测问题
business_id 业务标识,例如 order
business_name 业务名称
eval_target 评测目标
sample_category 样本场景
source_type 样本来源
source_run_id 来源运行记录 ID
source_conversation_id 来源会话 ID
generated_sql 来源运行生成 SQL
executed_sql 来源运行执行 SQL
final_answer 来源运行最终回答
feedback_result 来源反馈结果
feedback_error_type 来源反馈错误类型
feedback_comment 来源反馈说明
judge_note 人工判断说明
remark 备注
status 样本状态

这里要注意一个变化:

新版已经不再使用 expected_state_json 作为主要判断方式。

原因是 expected_state_json 虽然灵活,但对页面不友好,用户要手写 JSON,不适合长期维护。

新版改成了“表格化判断标准”:

期望 Key
判断方式
期望 Value
失败原因
是否必过
语义判断参考答案
关键要点
禁止内容
最低分

这样业务人员和测试人员不需要写 JSON,也能维护评测标准。


5. 评测目标和样本场景

不是所有样本都测端到端。

同一个问题可以用于验证不同能力:

评测目标 重点检查
END_TO_END 整条链路最终是否可用
ROUTING 是否识别为正确的问题类型
BUSINESS_SKILL 是否命中正确业务和业务规则
METRIC_RETRIEVAL 是否找到正确指标口径
TABLE_SCHEMA_RETRIEVAL 是否选到正确表结构
SQL_GENERATION 是否生成正确 SQL
CLARIFICATION 是否在信息不足时正确澄清
RESULT_ANALYSIS 是否正确解释查询结果

样本场景用于标记这条样本属于哪类测试:

样本场景 说明
NORMAL 正常样本
BOUNDARY 边界样本
REGRESSION 回归问题
AMBIGUOUS 歧义澄清
NEGATIVE 负向样本

这些选项不建议写死在前端。

当前项目把它们放在动态配置里:

text.to.sql.eval.metadata

前端通过接口读取配置,用于渲染:

评测目标
样本场景
判断方式
失败类型

这样后续新增一个评测目标或失败类型,不需要改前端代码。


6. 判断标准:从 JSON 改成断言规则

新版评测判断标准使用独立表:

text_to_sql_eval_assertion

一条样本可以有多条判断标准。

例如问题:

统计上个月每天支付成功的订单数量

可以配置两条标准:

期望 Key 判断方式 期望 Value 失败原因 必过
used_tables 包含 order_payment 表选择错误
final_answer 语义判断 - 回答质量错误

其中第一条是客观判断:

实际 state.used_tables 必须包含 order_payment。

第二条是主观判断:

最终回答不要求逐字一致,但要语义上覆盖参考答案和关键要点。

断言表关键字段:

字段 说明
eval_question_id 属于哪条评测样本
actual_key 从实际 State 里取哪个字段
operator 判断方式
expected_value 客观判断的期望值
required 是否必过
failure_type 失败后归类到哪种错误
reference_answer 语义判断参考答案
key_points 语义判断必须覆盖的要点,按行分隔
forbidden_points 语义判断禁止出现的内容,按行分隔
min_score 语义判断最低通过分
remark 备注

支持的判断方式:

判断方式 用途
EQ 实际值等于期望值
CONTAINS 实际数组或文本包含期望值
NOT_CONTAINS 实际数组或文本不包含期望值
EXISTS 字段存在
NOT_EMPTY 字段不为空
REGEX 正则匹配
SEMANTIC 调用大模型做语义评分

断言判断流程


7. 客观判断:每条规则都要有结果

早期实现容易犯一个错误:

遇到第一条失败规则就直接返回。

这样虽然能判断这条样本失败,但排查体验很差。

因为用户看不到:

其他规则到底有没有过?
是只错了表?
还是业务、动作、SQL、回答都错了?

所以当前实现是:

所有判断标准都会执行
所有判断结果都会保存
最终样本是否通过,由必过规则决定

判定逻辑可以理解为:

for 每条断言规则:
    从 actual_state_json 里按 actual_key 取值
    按 operator 判断
    保存一条 assertion_result

如果存在 required = true 且未通过的规则:
    样本失败
    失败类型取第一条必过失败规则
否则:
    样本通过

这样做的好处是:

好处 说明
排查完整 每条规则都有实际值和结果
报告更准 可以统计哪类规则最容易失败
适合调参 改 Prompt 后能看每个环节的变化
支持非必过规则 可以观察但不阻断通过

8. 主观判断:用大模型评估回答质量

有些结果不能用“完全相等”判断。

尤其是最终回答:

数据覆盖 9 个有订单的日期,累计支付成功订单 21 单。
6 月 25 日达到峰值 8 单,其余交易日集中在 1 至 3 单,整体呈零星分布态势。

这类回答只要意思对,就不应该要求 100% 字符串一致。

所以新增了 SEMANTIC 判断方式。

语义判断会把下面这些内容发给 Python 服务:

输入 说明
question 原始问题
actual_answer 本次实际回答
expected_value 可选的额外期望
reference_answer 参考答案
key_points 必须覆盖的关键要点
forbidden_points 禁止出现的内容
min_score 最低通过分

Python 服务调用大模型,返回:

{
  "passed": true,
  "score": 95,
  "reason": "实际回答准确涵盖了所有关键统计数据,与参考答案语义一致。",
  "coveredPoints": ["主要的数据要正确"],
  "missedPoints": [],
  "forbiddenHits": []
}

Java 后端会保存:

字段 说明
score 本次语义评分
failure_reason 判断原因。通过时也保存原因,失败时保存失败原因
actual_value_json 本次实际回答
passed 该条语义规则是否通过

语义评测流程

这里要注意:

SEMANTIC 不需要展示期望 Value。

因为它的标准不是一个简单值,而是:

参考答案 + 关键要点 + 禁止内容 + 最低分

9. 评测任务:异步批量运行正式样本

有了正式样本以后,就可以创建评测任务。

评测任务页面负责:

选择一批 ACTIVE 样本
  ↓
点击开始评测
  ↓
后端创建任务
  ↓
接口立即返回
  ↓
后台异步逐条运行完整 graph
  ↓
保存每条结果和每条断言结果
  ↓
汇总通过率和失败原因

异步评测任务流程

为什么必须异步?

因为每条样本都是真实跑一遍 Text-to-SQL graph:

模型路由
业务 Skill
检索
SQL 生成
SQL 执行
结果分析
自动判定

如果评测任务有几十条样本,同步接口会等待很久,页面体验很差,也容易超时。

现在的设计是:

  1. 点击“开始评测”。
  2. 后端创建 text_to_sql_eval_run,状态为 RUNNING
  3. 接口立即返回任务 ID。
  4. 后台线程逐条执行样本。
  5. 前端轮询任务状态和结果列表。
  6. 所有样本结束后刷新通过率、失败数和失败原因分布。

任务状态:

状态 说明
RUNNING 正在后台运行
COMPLETED 已完成,没有待澄清样本
WAITING_CLARIFICATION 有样本进入澄清态,需要人工补充
FAILED 任务级异常

10. 评测运行记录为什么要标记 EVAL

评测不是模拟判断。

每条评测样本都会真实调用当前 Text-to-SQL 流程。

所以评测也会产生运行记录:

text_to_sql_run.source_type = EVAL

真实用户提问则是:

text_to_sql_run.source_type = USER

为什么要区分?

如果不区分,评测任务跑几十条样本,就会把运行记录页面刷满,影响日常排查真实用户问题。

当前设计是:

来源 页面行为
USER 运行记录页面默认展示
EVAL 默认过滤掉,需要排查评测失败时再筛选

评测结果里保存 conversation_id

如果某条样本失败,可以这样排查:

评测结果
  ↓ conversation_id
EVAL 运行记录
  ↓ run_id
运行步骤 State History

评测结果追踪


11. 评测结果:保存样本级结果

每条样本运行后,会保存一条:

text_to_sql_eval_run_result

它保存的是“这条样本这次整体跑得怎么样”。

关键字段:

字段 说明
eval_run_id 属于哪次评测任务
eval_question_id 对应哪条评测样本
question 本次评测问题
business_id 业务标识
eval_target 评测目标
sample_category 样本场景
conversation_id 本次 graph 会话 ID
passed 样本是否通过
failure_type 样本失败类型
failure_reason 样本失败原因
actual_state_json 本次实际 State
actual_sql 本次实际 SQL
actual_answer 本次实际回答
execution_error 执行错误
duration_ms 耗时

这里的 actual_state_json 是统一后的实际 State。

也就是说,评测会尽量把 Python 服务返回值和 Java RouteRes 中常用字段整理成统一 Key,例如:

question_type
business_id
business_name
action
sql
executed_sql
used_tables
used_fields
missing_info
interrupted
final_answer

这样前端填写断言时就可以直接写:

business_id
used_tables
action
final_answer

不需要用户关心 Python 内部字段到底叫 sql_action 还是 Java 里映射成 action


12. 断言结果:保存每条规则的证据

样本级结果只能告诉我们:

这条样本通过还是失败。

但排查时我们更想知道:

哪条规则过了?
哪条规则没过?
实际值是什么?
语义判断得了多少分?
大模型为什么给这个分?

所以新增:

text_to_sql_eval_run_assertion_result

关键字段:

字段 说明
eval_run_id 评测任务 ID
eval_run_result_id 样本级结果 ID
eval_question_id 样本 ID
eval_assertion_id 判断标准 ID
actual_key 判断字段
operator 判断方式
expected_value 期望值
actual_value_json 实际值
required 是否必过
passed 该规则是否通过
failure_type 失败类型
failure_reason 判断原因,通过和失败都会保存
score 语义判断得分

页面上“规则”弹框会把两部分放在一起:

样本定义的规则
  +
本次运行的实际回答、实际值、评分、判断原因

这样看主观题时,不需要在“规则”和“详情”之间来回切。

评测数据模型


13. 澄清样本怎么评测

Text-to-SQL 里有一类很重要的场景:需要澄清

例如:

统计上个月订单状态占比

如果业务里同时有预支付订单和支付订单,这句话可能信息不足。

系统应该先追问:

请问要统计预支付订单,还是支付订单?

这种样本不能简单判定为失败。

它应该进入:

WAITING_CLARIFICATION

当前设计是:

  1. 第一轮评测运行问题。
  2. 如果 graph 返回需要澄清。
  3. 当前结果标记为 CLARIFICATION_REQUIRED
  4. 任务状态变成 WAITING_CLARIFICATION
  5. 页面上对这条结果显示“补充澄清”。
  6. 人工输入这条样本的补充回答。
  7. 后端立即把该结果标记为 CLARIFICATION_RUNNING 并返回。
  8. 后台异步用这条结果自己的 conversation_id 调用 continue。
  9. 更新这条结果的实际 State、SQL、回答和断言结果。
  10. 所有待澄清结果处理完后,任务变成 COMPLETED

澄清继续评测流程

为什么要一条一条澄清?

因为每条样本都有自己的 graph thread:

样本 A -> conversationId A -> 补充回答 A -> continue A
样本 B -> conversationId B -> 补充回答 B -> continue B
样本 C -> conversationId C -> 补充回答 C -> continue C

不同样本缺的信息不同,会话上下文也不同。

所以不能把所有澄清答案一起发送。


14. 页面使用流程

当前后台主要有三个相关页面。

页面使用流程

14.1 评测样本页面

评测样本页面负责维护样本。

常用操作:

操作 说明
录入样本 手动新增一个问题
从运行记录加入 把真实运行记录转成候选样本
整理样本 修改问题、业务、目标、场景
维护判断标准 添加客观规则和语义规则
查看来源 查看来源 SQL、回答、反馈说明
启用样本 DRAFT 改成 ACTIVE

这个页面回答的是:

哪些问题可以用来评测?
每个问题应该怎么判断?

14.2 评测任务页面

评测任务页面负责真正跑评测。

常用操作:

操作 说明
新建评测任务 选择一批正式样本
开始评测 后端异步运行
查看通过率 看样本数、通过数、失败数
查看失败分布 看哪类错误最多
查看结果详情 看实际 State、SQL、回答
查看规则 看样本规则和本次断言结果
补充澄清 CLARIFICATION_REQUIRED 结果继续评测
跳转样本 从结果回到样本定义

这个页面回答的是:

这次系统整体表现怎么样?
具体失败在哪里?

14.3 运行记录页面

运行记录页面默认看真实用户提问,也就是:

source_type = USER

如果要排查评测失败,可以筛选:

source_type = EVAL

然后根据评测结果里的 conversation_id 找到完整运行步骤。


15. 一个完整例子

假设我们有一条样本:

统计上个月每天支付成功的订单数量

这条样本期望使用支付订单表,并且最终回答要说清楚主要统计结果。

可以配置两条判断标准:

期望 Key 判断方式 期望 Value 必过 失败类型
used_tables 包含 order_payment 表选择错误
final_answer 语义判断 - 回答质量错误

语义判断里再配置:

字段 示例
参考答案 数据覆盖 9 个有订单的日期,累计支付成功订单 21 单,其中 6 月 25 日达到峰值 8 单
关键要点 主要的数据要正确
禁止内容 不能编造不存在的状态
最低分 80

评测运行后,可能得到:

used_tables = ["order_payment"]
final_answer = "数据共覆盖 9 个有订单的日期,累计支付成功订单 21 单..."

断言结果:

规则 结果 说明
used_tables contains order_payment 通过 实际 used_tables 包含 order_payment
final_answer semantic 通过 得分 95,覆盖关键要点,语义与参考答案一致

样本通过。

如果实际用了 prepay_order

used_tables = ["prepay_order"]

第一条规则失败:

failure_type = TABLE_SELECTION_ERROR
failure_reason = used_tables CONTAINS 期望 order_payment,实际 ["prepay_order"]

样本失败。


16. 课程练习

今天的练习建议按这个顺序做:

  1. 在“数据分析”页面提出一个订单统计问题。
  2. 查看“运行记录”,观察最终 State 和每步 State History。
  3. 把这条运行记录加入“评测样本”。
  4. 在“评测样本”页面整理: - 评测目标 - 样本场景 - 判断标准 - 判断说明
  5. 添加一条客观规则,例如: - used_tables - 包含 - order_payment
  6. 添加一条语义规则,例如: - final_answer - 语义判断 - 参考答案 - 关键要点 - 最低分
  7. 把样本状态改成 ACTIVE
  8. 在“评测任务”页面新建任务,选择这条正式样本。
  9. 开始评测,查看结果。
  10. 打开“规则”弹框,查看每条规则的实际值、得分和原因。
  11. 如果进入待澄清,补充澄清答案继续评测。
  12. 到“运行记录”页面筛选 EVAL,查看评测产生的完整 graph 记录。

17. 小结

今天这节课的重点不是某张表,也不是某个按钮。

重点是建立一套 AI 应用的工程闭环:

真实问题
  ↓
运行记录
  ↓
人工反馈
  ↓
评测样本
  ↓
判断标准
  ↓
异步评测任务
  ↓
客观判断 + 语义判断
  ↓
断言结果明细
  ↓
澄清继续评测
  ↓
失败分析和回归优化

有了这套机制以后,每次改 Prompt、改 Skill、改表结构、改澄清策略,都可以通过评测任务验证。

Text-to-SQL 也就从:

能跑通

升级为:

能验证、能回归、能持续优化。