1. 先看全图:八段河道
问数不是一个 Prompt 加一次 SQL 执行。从问句进来到用户看见答案,我们切了八段。每段是一组 LangGraph 节点,有自己的输入输出和失败出口。
八段:归一化 → 记忆 → 对话门 → 召回 → 规划 → 生成 → 校验执行 → 核对回答。中间任何一段有权终止整条流------追问、拒绝、纠错失败、权限不足都是合法出口。
这不是线性 Pipeline。LangGraph 的 StateGraph 让每个节点返回状态更新,条件边决定下一跳。规划说「复杂题」就走 Agent 循环,说「简单题」就直接生成 SQL;核对说「不对劲」就走纠正;纠正次数到上限就终止。
2. 为什么不用一条 chain
刚开始确实是一条 chain。问题出在第三周。
一条 chain 意味着:要么全跑完,要么全失败。没有中间状态。前端不知道「现在跑到哪了」,用户等三十秒没反应,分不清是在想还是挂了。纠错只能在外面包一层 retry,而且不知道该 retry 哪一步------重跑整条 chain 浪费钱。
LangGraph 给了三个 chain 给不了的东西:
- 节点粒度的进度事件。 每个节点起跑时发
progress(node, running),结束时发progress(node, done, duration_ms)。前端画时间线。 - 条件路由。 Plan 说 low complexity → 跳过 Agent loop 直接走
generate_sql。这条边不是代码里硬写的 if-else,是图上声明式的条件边。 - 失败局部化。
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_table、run_probe_sql、get_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 系列写了六篇,不再展开。概括:
validate_sql:只读断言 + sqlglot 解析 + 表白名单 + 列检查 + LIMIT 注入。- 校验不过 →
correct_sql:把错误码喂给模型,让它改写。改写后再过validate_sql。循环 ≤MAX_CORRECTION_ROUNDS(默认 2)。仍不过 → 终止,返回失败。 apply_policy:注入行级数据范围(DataScope),C 系列会拆。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...」「正在执行...」。
这不是装饰。作用是:
- 区分卡顿和死亡。 节点在跑就不是挂了。真挂了有
event: error。 - 告诉用户瓶颈在哪。
generate_sql耗 4 秒 → 是模型慢,不是系统慢。 - 纠正循环可见。 如果校验失败走了纠正,前端会多显示一轮「正在修正 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_sql 或 step_results |
校验 |
| 校验 | final_sql, error_code |
执行、纠正 |
| 执行 | columns, rows |
核对、图表、回答 |
| 核对 | verify_passed, answer |
终端输出 |
字段不互相覆盖。raw_sql 是模型吐的原始字符串,final_sql 是过了闸门和 LIMIT 注入后的最终版。两个都留着,方便 trace 和评测对比。
6. 失败不是异常,是路径
整条图里,合法的终止路径至少有六条:
- 对话门判定闲聊 →
reply_chat→ 结束 - 追问用户补槽位 →
ask_clarification→ 挂起 - 召回空 → 告知超范围 → 结束
- 校验失败 + 纠正到上限 → 返回失败码 → 结束
- 权限不足(
NO_DATA_SCOPE) → 拒绝 → 结束 - 执行超时或异常 → 错误 → 结束
只有一条是正常出数。这不是 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...