AIGC标识 从自然语言到 SQL:一个 Data Agent 的检索与校验链路

最近整理了一个面向数据分析的 Data Agent 项目。它的目标不是让大模型直接“猜一条 SQL”,而是把自然语言查询转换成一条可解释、可校验、可执行的流程。项目使用 FastAPI 提供接口,使用 LangGraph 编排 Agent 节点,并结合 Qdrant、Elasticsearch 和 MySQL 完成元数据与业务数据访问。

这篇文章不讨论某个模型的参数调优,重点记录这条查询链路是如何拆开的,以及为什么每一步都不能简单省略。

一、先把自然语言查询拆成状态

Data Agent 最容易出现的问题,是把所有信息都塞在一个很大的字符串里,后续节点只能依赖提示词自己理解。这个项目在 data-agent/app/agent/state.py 中定义了明确的 DataAgentState,把流程数据分成几类:

  • 用户输入与关键词:querykeywords
  • 召回结果:字段、业务取值和指标
  • 结构化元数据:表信息和指标信息
  • 运行环境:日期信息与数据库方言、版本
  • 结果状态:生成的 SQL、错误信息和校正次数

这种状态设计的价值在于,每个节点只负责读写自己关心的字段。后面排查问题时,可以明确知道“是没召回到字段”,还是“召回正确但 SQL 校验失败”,而不是只看到最终答案不对。

二、关键词抽取只是入口,不是最终语义理解

流程从 extract_keywords 开始。对应文件是 data-agent/app/agent/nodes/extract_keywords.py,当前实现使用 jieba 的 TF-IDF 提取关键词,并保留原始查询作为一个整体关键词。

raw_keywords = jieba.analyse.extract_tags(
    query,
    allowPOS=ALLOW_POS
)
all_keywords = list(set(raw_keywords + [query]))

这里有一个很实际的取舍:分词结果适合做细粒度召回,完整原句则可以保留上下文。两者合并后,后面的字段、取值和指标召回可以同时利用局部词语和完整语义。

不过,关键词抽取不应该被当成语义理解的终点。项目在字段召回节点中还会通过 LLM 扩写召回关键词,然后再做空白字符、有效字符和长度校验,避免把无意义的输入直接送进向量服务。

三、字段、取值和指标要分开召回

data-agent/app/agent/graph.py 中,关键词抽取完成后会并行进入三个节点:

  • recall_column:从 Qdrant 召回可能相关的字段
  • recall_value:从 Elasticsearch 召回业务取值
  • recall_metric:从 Qdrant 召回指标定义

这三个对象在数据分析里承担的职责不同。字段决定 SQL 可以使用哪些列,业务取值帮助把“华东”“已支付”这类自然语言映射到真实值,指标则约束“销售额”“活跃用户数”等业务口径。

如果只做字段向量检索,模型可能找到一个名字相似的列,却不知道这个列的业务含义;如果只检索指标,又可能缺少真正的表连接关系。所以这里采用多路召回,再在后续节点统一合并。

批量向量化不能静默丢数据

recall_column.py 中还有一个值得保留的工程细节:批量向量接口返回的向量数量必须和输入文本数量一致。不能直接依赖 zip,因为当服务异常返回较少向量时,zip 会静默截断,导致部分关键词没有任何错误日志就丢失召回。

if len(vecs) != len(batch_texts):
    logger.warning(
        "批量向量化返回向量数量不匹配"
    )

for idx, text in enumerate(batch_texts):
    vec = vecs[idx]
    col_list = await column_qdrant_repo.search(vec)

项目还按批次处理关键词,并用字段 ID 做字典去重。这样可以减少重复结果,也避免同一个字段因为命中多个关键词而被反复传给后续 LLM。

四、先合并召回结果,再过滤表和指标

三路召回完成后,流程进入 merge_retrieved_info,接着并行执行 filter_tablefilter_metric。这一步不是简单地把搜索结果全部交给模型,而是先缩小候选范围。

表过滤的目标是找出真正参与查询的表及其字段,指标过滤的目标是保留与问题相关的业务指标。过滤之后,add_extra_context 还会补充日期、数据库方言和版本等信息。

这一步很关键,因为 SQL 生成质量不只取决于模型能力,还取决于输入上下文是否足够准确。给模型十张相似表,通常不如给它两张经过筛选、带有字段说明和关联关系的表。

五、SQL 生成要把上下文结构化

对应文件是 data-agent/app/agent/nodes/generate_sql.py。节点从状态中读取查询、表信息、指标信息、日期信息和数据库信息,再通过 PromptTemplate 组装调用链:

chain = prompt_template | get_llm() | StrOutputParser()

invoke_params = {
    "query": query,
    "table_infos": yaml.dump(table_infos, **yaml_opts),
    "metric_infos": yaml.dump(metric_infos, **yaml_opts),
    "date_info": yaml.dump(date_info, **yaml_opts),
    "db_info": yaml.dump(db_info, **yaml_opts)
}

sql_result = await chain.ainvoke(invoke_params)

这里使用 YAML 序列化结构化数据,而不是调用对象的默认字符串表示。好处是字段层级稳定、中文可读、提示词更容易复现。对于线上排查来说,同样的状态应该尽量产生同样形态的模型输入。

六、生成 SQL 后必须先校验再执行

SQL 生成完成后不会直接访问业务库,而是进入 validate_sql。这个节点会调用 DWMySQLRepository.validate_sql 做语法和执行计划校验。

sql = state.get("sql", "").strip()

if not sql:
    return {"error": "待验证 SQL 内容为空"}

try:
    await dw_mysql_repo.validate_sql(sql)
    return {"error": None}
except Exception:
    return {"error": "SQL 语法或执行计划校验不通过"}

校验的意义不只是防止 SQL 拼写错误,还可以在执行前拦截表不存在、字段不存在、语法不兼容或执行计划明显不合理等问题。生成式系统必须把模型输出当作“候选结果”,不能当作已经可信的指令。

七、校正流程要有明确上限

graph.py 中,校验失败后会路由到 correct_sql,校正完成后再进入执行节点。流程设置了最大三次校正次数:

MAX_SQL_CORRECT_RETRY = 3

if retry_cnt >= MAX_SQL_CORRECT_RETRY:
    return "__end__"
return "correct_sql"

重试上限是必须的。没有上限时,生成和校正节点可能在错误上下文下反复循环,既浪费模型调用,也会让用户一直等待。达到上限后结束流程,并把失败状态交给上层处理,通常比无休止重试更容易运营。

需要注意的是,校正不是“再问模型一次”这么简单。理想情况下,校正提示词应该包含原 SQL、校验错误、相关表结构以及已经尝试过的次数,让模型针对具体错误修改,而不是重新随机生成一条语句。

八、FastAPI 通过 SSE 暴露过程进度

项目的查询入口位于 data-agent/app/api/routers/query_router.py。接口是 POST /api/query,返回 text/event-streamQueryService.query 调用 LangGraph 的 astream,把每个节点通过 stream_writer 写出的进度转换为 JSON,再由路由包装成标准 SSE:

async def sse_event_generator(generator):
    async for data in generator:
        yield f"data: {data}\\n\\n"

return StreamingResponse(
    sse_event_generator(stream),
    media_type="text/event-stream"
)

对于数据分析场景,流式进度比一次性等待最终结果更容易理解。用户可以看到当前正在抽取关键字、召回字段、生成 SQL 还是验证 SQL;某一步失败时,也能尽快得到错误事件。

九、这条链路的核心原则

  • 先把流程状态结构化,再让模型参与决策
  • 字段、业务取值和指标分别召回,避免单一检索承担所有语义
  • 向量服务的批量返回必须做数量校验,不能让数据静默丢失
  • SQL 生成结果必须经过独立校验,不能直接执行
  • 错误校正必须设置次数上限,并保留失败原因
  • 通过 SSE 暴露阶段进度,让长流程可观察

写在最后

一个可用的 Data Agent,真正难的部分不在于把 LLM 接进来,而在于围绕模型建立检索、状态、校验、重试和执行边界。模型负责生成候选方案,确定性代码负责提供事实和约束,数据库负责验证与执行,流程编排负责把这些步骤连接起来。

当自然语言查询出现错误时,系统应该能回答:关键词是什么、召回了哪些字段、选择了哪些表、SQL 在哪一步失败、校正了几次,以及为什么最终没有执行。只有做到这一点,Data Agent 才从一次 Demo 变成可以继续维护的工程系统。

posted @ 2026-09-09 21:13  fitch_liu  阅读(11)  评论(0)    收藏  举报