【NL2SQL 实战 08】主链路:从提问到出数,图里跑了哪些节点

1. 先看全图:八段河道

问数不是一个 Prompt 加一次 SQL 执行。从问句进来到用户看见答案,我们切了八段。每段是一组 LangGraph 节点,有自己的输入输出和失败出口。

flowchart LR Q[用户提问] --> N[归一化] N --> M[记忆与偏好] M --> DG{需要澄清?} DG -->|是| CL[追问用户] DG -->|否| R[多路召回] R --> P[规划] P --> G[生成 SQL] G --> V[校验 + 纠正] V --> E[执行] E --> VF[语义核对] VF --> A[图表 + 回答]

八段:归一化 → 记忆 → 对话门 → 召回 → 规划 → 生成 → 校验执行 → 核对回答。中间任何一段有权终止整条流------追问、拒绝、纠错失败、权限不足都是合法出口。

这不是线性 Pipeline。LangGraph 的 StateGraph 让每个节点返回状态更新,条件边决定下一跳。规划说「复杂题」就走 Agent 循环,说「简单题」就直接生成 SQL;核对说「不对劲」就走纠正;纠正次数到上限就终止。


2. 为什么不用一条 chain

刚开始确实是一条 chain。问题出在第三周。

一条 chain 意味着:要么全跑完,要么全失败。没有中间状态。前端不知道「现在跑到哪了」,用户等三十秒没反应,分不清是在想还是挂了。纠错只能在外面包一层 retry,而且不知道该 retry 哪一步------重跑整条 chain 浪费钱。

LangGraph 给了三个 chain 给不了的东西:

  1. 节点粒度的进度事件。 每个节点起跑时发 progress(node, running),结束时发 progress(node, done, duration_ms)。前端画时间线。
  2. 条件路由。 Plan 说 low complexity → 跳过 Agent loop 直接走 generate_sql。这条边不是代码里硬写的 if-else,是图上声明式的条件边。
  3. 失败局部化。 validate_sql 挂了走 correct_sql,不是整条 chain 从头跑。纠错达到 MAX_CORRECTION_ROUNDS 才真正终止。

另外一个隐性收益:节点是纯函数 (state) → partial_state。测试可以只喂 state 片段进某个节点,不用启动整条图。B3 评测里 validate_sql 单测就是这么做的。


3. 八段拆解

3.1 归一化(normalize_question)

把用户输入做最小清洗:去首尾空格、统一中英文标点、处理多余换行。不改语义,只消除噪声。

为什么单独拆一个节点而不是在入口做?因为后面记忆合并可能把历史上下文拼入问句,拼之前和拼之后归一化的时机不同。节点分开更好测。

3.2 记忆与偏好(load_session_memory / load_user_preference / process_memory_context)

三个子节点:

  • load_session_memory:从对话历史取最近 N 轮,包含用户说了什么、系统回了什么、上次用的 SQL。
  • load_user_preference:取用户偏好(默认时间范围、粒度、列别名习惯)。
  • process_memory_context:决定记忆怎么拼进当前问句。不是全量塞入------权重低于元数据,多了会带偏。

B2 讲过:记忆槽位会包壳,防止历史 SQL 被当成新指令复用。这里就是包壳的位置。

3.3 对话门(route_dialogue / reply_chat / ask_clarification)

问句分三类:

  • 数据查询:继续往下走。
  • 闲聊 / 超范围reply_chat 回一句友好拒绝,流终止。
  • 缺槽位 / 歧义ask_clarification 向用户追问,流挂起等用户补充。

这是用户体验层面的第一道分流。不做分流就会出现:「你好」被当成 SQL 需求送去生成,模型乖乖写出 SELECT '你好'

3.4 多路召回(extract_keywords → recall_tables → recall_metrics → recall_field_values → merge → recall_sql_examples → build_context → select_l1)

召回不是一次向量搜索。分了多路:

子节点 召回什么 为什么分开
do_recall_tables 相关表(名、注释、字段子集) 60 张表不能全塞 Prompt
do_recall_metrics 业务指标定义(公式、口径) 「活跃用户」这种词要落到 SQL 表达式
do_recall_field_values 枚举值映射 「篮球」→ sport_type = 3
do_recall_sql_examples L1 样例 SQL Few-shot 精选

merge_retrieved_info 把多路结果合并,如果召回太弱或冲突就走追问。build_llm_context 最终组装出一段给 LLM 的上下文文本------包含表结构、指标口径、方言提示、样例。

这段最耗工程量。E 系列会专门讲。本篇只需要知道:出了这段,state 里有一个 context_text,长度在 2000~8000 token 之间。

3.5 规划(plan_question)

让模型判断这个问句的 complexity:

  • low :一条 SQL 能搞定。跳过 Agent loop,直接 generate_sql
  • medium / high :需要多步工具探索或多条 SQL 拼装。走 agent_loop

Plan 还会输出 multi_sql(是否对比类问题要拆多条)、visualization(需要什么图表)、ready_to_execute(是否还缺信息)等结构化判断。

快路径省延迟:简单问题不进 Agent,少两三轮 LLM 调用,体感从 8 秒降到 3 秒。H 系列会深拆规划 Prompt 的设计。

3.6 生成(generate_sql / agent_loop → generate_sql_step)

两条路:

简单路径generate_sql 一次性出 SQL。Prompt 里已有全部上下文,模型直接输出。

复杂路径agent_loop 先跑工具循环(describe_tablerun_probe_sqlget_metric_definition 等),探完之后 generate_sql_step 按 Plan 的步骤逐条生成。多条 SQL 各自执行、各自校验,最后 assemble_result 汇总。

I 系列会讲 Agent loop 怎么控步数、空工具调用怎么处理。这里只需要知道:出了这段,state 里有 raw_sql(或多条 step_results)。

3.7 校验 + 纠正 + 执行(validate_sql → correct_sql → apply_policy → execute_sql)

这段 B 系列写了六篇,不再展开。概括:

  1. validate_sql:只读断言 + sqlglot 解析 + 表白名单 + 列检查 + LIMIT 注入。
  2. 校验不过 → correct_sql:把错误码喂给模型,让它改写。改写后再过 validate_sql。循环 ≤ MAX_CORRECTION_ROUNDS(默认 2)。仍不过 → 终止,返回失败。
  3. apply_policy:注入行级数据范围(DataScope),C 系列会拆。
  4. execute_sql:只读连接执行,拿回 columns + rows。

3.8 核对 + 图表 + 回答(verify_answer → build_chart → format_answer)

执行拿到数据后,不是直接丢给用户。

  • verify_answer:语义校验。问「上个月活跃人数」,SQL 里有没有时间条件?结果列有没有包含「人数」?别名对不对?如果核对不通过,走纠正重来。
  • build_chart:Plan 如果说了需要柱状图 / 饼图,这里生成图表 spec。
  • format_answer:把 rows 写成自然语言回答。不是只丢一张表------要有结论句。

这段直接影响用户体感。L 系列会讲。


4. 前端时间线怎么跑

用户按下回车后,前端不是白屏等。每个节点起跑和完成都通过 SSE 推一帧:

vbnet 复制代码
event: progress
data: {"node": "do_recall_tables", "status": "running"}

event: progress
data: {"node": "do_recall_tables", "status": "done", "duration_ms": 320}

前端维护一个节点列表,依次亮灯。用户能看见「正在召回...」「正在生成 SQL...」「正在执行...」。

这不是装饰。作用是:

  1. 区分卡顿和死亡。 节点在跑就不是挂了。真挂了有 event: error
  2. 告诉用户瓶颈在哪。 generate_sql 耗 4 秒 → 是模型慢,不是系统慢。
  3. 纠正循环可见。 如果校验失败走了纠正,前端会多显示一轮「正在修正 SQL...」,用户不会以为在原地转圈。

thinking_delta(模型思考过程)和 text_delta(最终回答)是流式的:模型推理和回答逐字推到前端。实现上靠 LangGraph 的流式更新模式(astream(stream_mode="updates"))配合 runner 里的回调,节点每完成一步就往 SSE 推一帧------读者不需要关心 API 细节,只需要知道:前端看到的每一段进度,都是图上某个节点真实跑过的证据。


5. 状态机里流的是什么

LangGraph 的 State(状态)是一个类型化的字典(代码里叫 AskGraphState),50 多个字段。每个节点只读自己需要的、只写自己负责的。不存在一个"上帝对象"被所有节点乱改。

关键字段按阶段:

阶段 写入的字段 后续谁读
归一化 normalized_question 召回、规划、生成
记忆 session_memory, user_preference 规划、生成(低权重拼入)
召回 recall_tables, recall_metrics, context_text 生成
规划 plan(含 complexity, multi_sql, visualization) 路由、生成、图表
生成 raw_sqlstep_results 校验
校验 final_sql, error_code 执行、纠正
执行 columns, rows 核对、图表、回答
核对 verify_passed, answer 终端输出

字段不互相覆盖。raw_sql 是模型吐的原始字符串,final_sql 是过了闸门和 LIMIT 注入后的最终版。两个都留着,方便 trace 和评测对比。


6. 失败不是异常,是路径

整条图里,合法的终止路径至少有六条:

  1. 对话门判定闲聊 → reply_chat → 结束
  2. 追问用户补槽位 → ask_clarification → 挂起
  3. 召回空 → 告知超范围 → 结束
  4. 校验失败 + 纠正到上限 → 返回失败码 → 结束
  5. 权限不足(NO_DATA_SCOPE) → 拒绝 → 结束
  6. 执行超时或异常 → 错误 → 结束

只有一条是正常出数。这不是 bug,是设计。企业问数产品,大部分分支应该是拒绝或追问,而不是硬着头皮出一个可能错的答案。

LangGraph 的条件边让这些路径显式声明在图上,不是埋在嵌套 try-except 里。对调试和评测都更友好。


7. 这篇先到这

记住四句就够:

  • 问数链路切八段,每段有自己的失败出口,不是一条 chain 硬跑到底;
  • LangGraph StateGraph 给了节点粒度的进度事件、条件路由、失败局部化------三个 chain 给不了的东西;
  • 简单问题跳过 Agent loop 走快路径,从 8 秒降到 3 秒;
  • 前端时间线不是装饰,是可观测性的一部分。

后面按这条河继续拆:H 系列讲规划层怎么判 complexity;I 系列讲 Agent 循环怎么控步数;E 系列讲召回为什么要分多路。

开源仓库本地可以跑通(Compose + Fixture,不必先配云钥匙)。图的定义在 backend/app/agent/graph.py,状态在 backend/app/agent/state.py

GitHub: github.com/yanqiuping1...

相关推荐
Zane19941 小时前
只重写了 __eq__,为什么类突然变得不可哈希了?常用魔法方法大盘点
后端·python
程序员cxuan1 小时前
Claude Fable 5.1 的提示词又被扒了
人工智能·后端·程序员
步行cgn1 小时前
Spring Boot 绑定简单 Bean 详解
java·spring boot·后端
未秃头的程序猿1 小时前
分库分表一年后,我复盘了当时最该想清楚的三件事
java·数据库·后端
岁月如歌77861 小时前
顺序消息与幂等消费完全指南:从队列有序到接口幂等
java·后端·架构
合橱瑰1 小时前
从“假智能”到“真闭环”:Go 向量推理引擎的零硬编码调优实践
后端·go
SamDeepThinking1 小时前
不改逻辑、不拆方法:仅靠「调整代码顺序」提升可读性
java·后端·程序员
泡海椒1 小时前
适配老旧项目:JQuick-Java兼容Java8+环境改造迁移实战指南
后端
IT_陈寒1 小时前
Java的HashMap竟然不是线程安全的,现在才知道!
前端·人工智能·后端