# 02 | 搭骨架:用 LangGraph 编排 12 步工作流(思路)

02 | 搭骨架:用 LangGraph 编排 12 步工作流(思路)

项目地址:github.com/frontzhm/n2...

每一步对应的完整代码都在仓库里,跟着文档卡住了就去翻源码。

这是一篇系列文,请按照顺序阅读。

本文主要用langgraph 搭建主要流程。 先看看流程图。

介绍流程

整个流程的终态是:用户输入一句自然语言,系统输出一张查询结果表

倒推回来,要达成这个目标需要回答三个核心问题:

  1. 数据在哪? ------ 数据库里有几十上百张表、成千上万个字段,用户不可能知道。必须有一套自动定位"该查哪些表、用哪些列"的机制。

  2. SQL 怎么写? ------ 拿到表结构后,需要把用户的口语化问题转成严谨的 SQL 语句。这一步最容易出错:表名写错、字段名记混、JOIN 条件遗漏。

  3. SQL 对吗? ------ LLM 生成的 SQL 不能直接拿去执行。现实场景中,一条错误的 SQL 可能扫全表、锁表,甚至把线上服务拖垮。必须先验证、再执行。

这三个问题决定了流程的主干:

sql 复制代码
用户的自然语言
    ↓
【找数据】定位该查哪些表和字段
    ↓
【写 SQL】将表和字段信息喂给 LLM,生成 SQL
    ↓
【验 SQL】用真实数据库验证语法,错了就修正
    ↓
【执行并返回】执行 SQL,把结果推送给前端

具体展开为 12 个节点。下面按执行顺序、以终为始来逐一介绍每个节点的设计思路。


介绍节点

终态回顾:一张流程图看全貌

sql 复制代码
用户自然语言
  │
  ├─ ① 抽取关键词          ← 第一步:从自然语言里抓到核心词
  │
  ├─ ② 召回字段 ─┐         ← 三路并行召回:用关键词去向量库/ES 里搜相关数据
  ├─ ③ 召回指标 ─┤
  ├─ ④ 召回值   ─┘
  │
  ├─ ⑤ 合并召回信息          ← 把三路召回结果拼接、分组、补全
  │
  ├─ ⑥ 过滤表 ─┐            ← LLM 甄别:召回的东西哪些真有用?
  ├─ ⑦ 过滤指标 ─┘
  │
  ├─ ⑧ 添加额外上下文        ← 补充日期、数据库元信息
  │
  ├─ ⑨ 生成 SQL             ← 把所有上下文喂给 LLM,写出 SQL
  │
  ├─ ⑩ 验证 SQL             ← 用 EXPLAIN 在真实数据库上验证语法
  │     │ ✅ 通过
  │     └──────→ ⑫ 执行 SQL → 返回结果
  │     │ ❌ 失败
  │     └──────→ ⑪ 修正 SQL(把报错信息喂给 LLM 改)
  │                    │
  │                    └──→ ⑫ 执行 SQL → 返回结果

下面逐一拆解每个节点的做什么、为什么这么做


① 抽取关键词 | extract_keywords

  • 输入query(用户原始自然语言)
  • 输出keywords(关键词列表)

做什么:用 jieba 分词从用户输入中提取名词、动词、形容词等实义词,作为后续召回的"锚点"。

为什么这么做

  • 用户可能说"帮我查一下上个月上海地区男女销售额分别是多少",这句话里真正跟数据库有关的是"上海、男、女、销售额、上个月"。直接拿整句话去向量库搜,噪声太大。
  • 为什么不用 LLM 做这一步? 分词是确定性任务,jieba 在 1ms 内处理完毕;用 LLM 需要网络调用 + 延迟,纯属杀鸡用牛刀。
  • 提取出的关键词会同时喂给三步召回(字段、指标、值),相当于"一份输入,三路并行"。

② 召回字段 | recall_column

  • 输入keywordsquery
  • 输出retrieved_column_infos(召回的字段列表)

做什么 :用关键词去字段向量库 里做语义检索,找到跟用户问题相关的数据库字段(如 sales_amountgender)。

为什么这么做

  • 数据库字段名往往是英文缩写(order_amtsku_id),用户说的是中文("订单金额")。用关键词向量化 + 向量库检索,本质是在做跨语言语义匹配 ------字段的向量库是在导入时用字段的中文描述(name + comment)生成的,所以"订单金额"能召回 order_amt
  • 为什么还要用 LLM 扩展关键词? jieba 抽出来的词太"原生态",比如用户说"上个月",jieba 只能拆出"上"和"个月"。需要 LLM 对业务词做同义扩展------"上个月"→"上月、当月、近期"等,提升召回覆盖度。

③ 召回指标 | recall_metric

  • 输入keywordsquery
  • 输出retrieved_metric_infos(召回的指标列表)

做什么 :用关键词在指标向量库 中检索相关指标(如"销售额"→ total_sales、"退款率"→ refund_rate)。

为什么单独建指标库而不是都放在字段库?

  • 指标是计算逻辑,不是简单字段。"客单价 = 总销售额 / 订单数",如果不把指标定义(公式、相关字段、聚合方式)预先建好,生成 SQL 时 LLM 可能算出错误公式。
  • 指标库的元信息包含 relevant_columns(该指标依赖哪些字段),这个在后续合并步骤里用来补全字段列表------LLM 写 SQL 时不仅要知道"有这么个指标",还要知道"这个指标要用到哪些底层字段"。

④ 召回值 | recall_value

  • 输入keywordsquery
  • 输出retrieved_value_infos(召回的数据值列表)

做什么 :用关键词去 Elasticsearch 中模糊搜索数据值,比如用户说"上海",需要召回数据库中已有的地区值 ['上海市', '浦东新区', ...]

为什么用 ES 而不是向量库?

  • 值是精确文本,不是语义概念。"上海市"三个字就是"上海市",不存在"北京"跟"上海"语义相似这种需求。
  • ES 的模糊匹配(fuzzy)天然适合值搜索------用户输入"上海",数据库里存的是"上海市",ES 的模糊查询能直接命中,而向量库要先把"上海"和"上海市"各自编码再算余弦距离,多余且不准。
  • 召回的值会附在对应字段的 examples 里,例如给 region 字段附上 ['上海市', '浦东新区'],生成 SQL 的 LLM 看到这些真实示例后,写 WHERE 条件时就不会瞎编值。

⑤ 合并召回信息 | merge_retrieved_info

  • 输入retrieved_column_infosretrieved_metric_infosretrieved_value_infos
  • 输出table_infos(按表分组的字段信息)、metric_infos(格式化的指标信息)

做什么:把三路召回(字段、指标、值)的结果拼成一张完整的"候选数据地图"------按表分组、补全主键/外键、把值挂到字段上。

为什么这步不可或缺?

三路召回是并行的,各跑各的,召回来的信息是碎片化的

  • 字段召回告诉你 sales_amount 跟"销售额"有关,但不知道它属于哪张表;
  • 指标召回告诉你 total_sales 依赖 order.sales_amount,但如果你恰好没召回这个字段呢?
  • 值召回告诉你"上海市"是 region 字段的一个值,但 region 属于 stores 表。

合并做四件事:

  1. 字段挂表 :每个字段都带上它所属的 table_id,按表分组。
  2. 指标补字段 :每个指标的 relevant_columns 所引用的字段,即使原来没被召回,也补进去------指标里写了要用 order.quantity,就不能漏。
  3. 值挂字段 :把召回的取值追加到对应字段的 examples 列表。
  4. 补主键和外键 :每张已召回的表,强制补上它的主键和外键列。向量召回容易漏掉 id 这种"不语义化"的字段,但 JOIN 必须用到它们。

做完这一步,候选数据的全貌就出来了。


⑥⑦ 过滤表 & 过滤指标 | filter_table & filter_metric

  • ⑥ filter_table
    • 输入:table_infosquery
    • 输出:table_infos(过滤后的表信息,同时裁剪了每张表的字段列表)
  • ⑦ filter_metric
    • 输入:metric_infosquery
    • 输出:metric_infos(过滤后的指标列表)

做什么:把合并后的候选表和指标丢给 LLM,让它根据用户原始问题判断"哪些真需要、哪些是噪声"。

为什么需要过滤?

向量检索是高召回的(宁可多找、不能漏掉),但代价是噪声大。用户问"销售额",可能召回 5 张表、20 个指标,其中 3 张表跟"销售额"只是间接相关。

这里的核心设计思想是"召回 + 过滤"二段式:

阶段 工具 特点
召回 向量库 / ES 高召回、但噪声大
过滤 LLM 高精度、能理解语义

LLM 拿到原始问题 + 候选表/指标列表后,能基于语义判断哪张表才是真正要用的。这比在召回阶段就过滤有效得多------向量库不理解你的问题,它只算余弦相似度;但 LLM 读得懂**"用户问的是商品维度的销售额还是门店维度的"**。

过滤表和过滤指标并行执行,互不依赖。


⑧ 添加额外上下文 | add_extra_context

  • 输入:无(完全从运行时环境读取:当前日期 + 数据库元信息)
  • 输出date_info(当前日期、星期、季度)、db_info(数据库版本、方言)

做什么:获取当前日期(年月日、星期几、第几季度)以及数据库的基本信息,注入到 State 中。

为什么需要这一步?

用户经常说相对时间 :"上个月"、"本周"、"Q3"。但 SQL 要的是绝对时间 WHERE date BETWEEN '2026-06-01' AND '2026-06-30'。如果不把真实日期注入,LLM 凭空猜当前日期,生成的 SQL 就错了。

db_info 包含数据库类型、时区等元信息,帮助 LLM 写对函数(MySQL 用 DATE_SUB,PostgreSQL 用 INTERVAL)。


⑨ 生成 SQL | generate_sql

  • 输入querytable_infosmetric_infosdate_infodb_info
  • 输出sql(一条完整 SQL 语句)

做什么:将过滤后的表信息、指标信息、日期信息、数据库信息全部喂给 LLM,让它生成一条完整 SQL。

为什么放在这一步而不是更早?

这是一个"漏斗收口"设计:

sql 复制代码
关键词 → 召回(膨胀,拿到一切可能相关的) → 过滤(收缩,只留确定的) → 生成 SQL

如果跳过召回直接让 LLM 凭空写 SQL,LLM 只能靠自己的模型知识猜表名和字段名,大概率出错。有了向量库兜底,LLM 的输入从"无中生有"变成"有据可依"------表名、字段名、字段类型、真实值示例全都列在 prompt 里,LLM 只负责组织和连接 ,不需要记忆


⑩ 验证 SQL | validate_sql

  • 输入sql
  • 输出error(验证失败时为报错信息,验证成功时为 None)

做什么 :把 LLM 生成的 SQL 丢到真实数据库上跑 EXPLAIN。语法对就通过,语法错就把报错信息记下来。

为什么不用 LLM 自己验证?

LLM 是语言模型 ,不是数据库引擎。它看不出 JOIN orders ON orders.user_id = users.iduser_id 到底存不存在。只有真正连到数据库跑一次 EXPLAIN,才能确保:

  • 表存在
  • 字段名正确
  • JOIN 条件和聚合语法合法

这是典型的 "LLM + 确定性工具" 组合拳:LLM 负责创造,数据库负责验证。


⑪ 修正 SQL | correct_sql

  • 输入sqlerrorquerytable_infosmetric_infosdate_infodb_info
  • 输出sql(修正后的 SQL)

做什么 :如果验证失败,把原始 SQL + 数据库报错信息 + 所有上下文重新喂给 LLM,让它修正。

为什么不是简单的重新生成?

重新生成意味着丢掉前一次的所有信息,等于从零开始,大概率犯同样的错。而"修正模式"把具体报错给 LLM------

arduino 复制代码
✅ 把错误信息给 LLM:
"Table 'sales_amount' doesn't exist" → LLM 知道是表名写错了,改成 actual_table

❌ 不把错误信息给 LLM:
LLM 不知道哪里错了,可能重写一遍还是错的

修正完的 SQL 会再次进入验证循环。这里还有一个保护:修正最多循环一次,避免 LLM 陷入无限修正的死循环。


⑫ 执行 SQL | run_sql

  • 输入sql
  • 输出 :通过 SSE 推送查询结果到前端(stream_writer),不修改 State

做什么:执行通过验证的 SQL,把查询结果通过 SSE 推送给前端。

为什么放在最后才执行?

经过了验证和修正两道关卡,SQL 的语法安全已有保障。但最终执行仍然存在风险(比如 SQL 写对了但数据量太大、查询超时),因此需要在数据库层面做好超时和资源限制。

执行结果直接写入 runtime.stream_writer,前端实时看到最终表格,整个流程闭环结束。


用"终为始"总结

sql 复制代码
我们要的:用户问一句话 → 看到查询结果表

要看到结果表 → 必须执行 SQL(⑫)
要安全执行 SQL → 必须先验证语法(⑩),失败了还要修正(⑪)
要生成能验证的 SQL → 必须先有精准的表/字段/指标/日期信息(⑨)
要拿到精准信息 → 必须先过滤掉向量召回的噪声(⑥⑦),并补上缺失的上下文(⑧)
要开始过滤 → 必须先把三路召回的碎片拼成完整视图(⑤)
要开始召回 → 必须先从用户的话里提取出关键搜索词(①)

最后你会发现:每一步的存在,都是为了"上一步的输入足够干净和完整"。
这不是一个线性流水线,而是一个漏斗------从宽到窄,逐步收敛,直到产出一条精确的 SQL。

搭建图的骨架

先装依赖:

shell 复制代码
uv add langgraph

新建 app/agent/ 目录,然后创建 graph.py。我们从"空壳"开始,分三步走。


第一步:搭一个能画出来的空壳

先把 12 个节点声明好、边连好。每个节点暂时只写 return state,不干实事------目的是验证图结构本身是否正确。

创建 app/agent/graph.py

python 复制代码
import asyncio
from langgraph.graph import START, END, StateGraph
from typing import Any, TypedDict


# ---------- State ----------

# 图中所有节点间流转的共享状态
# total=False 表示所有字段均为可选,每个节点只返回自己更新的字段
class State(TypedDict, total=False):
    error: Any  # SQL 校验错误信息,None 表示通过


# 运行时上下文,承载数据库连接、LLM 客户端等长生命周期对象
RuntimeContext = dict


# ---------- 节点函数(空壳) ----------

def extract_keywords(state: State) -> State:
    return state

def recall_column(state: State) -> State:
    return state

def recall_metric(state: State) -> State:
    return state

def recall_value(state: State) -> State:
    return state

def merge_retrieved_info(state: State) -> State:
    return state

def filter_table(state: State) -> State:
    return state

def filter_metric(state: State) -> State:
    return state

def add_extra_context(state: State) -> State:
    return state

def generate_sql(state: State) -> State:
    return state

def validate_sql(state: State) -> State:
    return state

def correct_sql(state: State) -> State:
    return state

def run_sql(state: State) -> State:
    return state


# ---------- 搭图 ----------

graph = (
    StateGraph(state_schema=State, context_schema=RuntimeContext)
    # 注册所有节点
    .add_node(extract_keywords)
    .add_node(recall_column)
    .add_node(recall_metric)
    .add_node(recall_value)
    .add_node(merge_retrieved_info)
    .add_node(filter_table)
    .add_node(filter_metric)
    .add_node(add_extra_context)
    .add_node(generate_sql)
    .add_node(validate_sql)
    .add_node(correct_sql)
    .add_node(run_sql)
    # 连线
    .add_edge(START, "extract_keywords")
    # 扇出:关键词 → 三条检索并行
    .add_edge("extract_keywords", "recall_column")
    .add_edge("extract_keywords", "recall_metric")
    .add_edge("extract_keywords", "recall_value")
    # 扇入:三条检索 → 汇聚到合并
    .add_edge("recall_column", "merge_retrieved_info")
    .add_edge("recall_metric", "merge_retrieved_info")
    .add_edge("recall_value", "merge_retrieved_info")
    # 扇出:合并 → 过滤表、过滤指标并行
    .add_edge("merge_retrieved_info", "filter_table")
    .add_edge("merge_retrieved_info", "filter_metric")
    # 扇入:过滤结果 → 汇聚后补充上下文
    .add_edge("filter_table", "add_extra_context")
    .add_edge("filter_metric", "add_extra_context")
    # 直线:上下文 → 生成 SQL → 校验
    .add_edge("add_extra_context", "generate_sql")
    .add_edge("generate_sql", "validate_sql")
    # 条件分支:校验通过就执行,失败就修正后执行
    .add_conditional_edges(
        source="validate_sql",
        path=lambda state: "run_sql" if state["error"] is None else "correct_sql",
        path_map={"run_sql": "run_sql", "correct_sql": "correct_sql"},
    )
    .add_edge("correct_sql", "run_sql")
    .add_edge("run_sql", END)
    .compile()
)


# ---------- 验证图结构 ----------

if __name__ == "__main__":
    mermaid = graph.get_graph().draw_mermaid()
    print(mermaid)

执行:

shell 复制代码
uv run python app/agent/graph.py

控制台输出一张 Mermaid 流程图------图和之前介绍的那张完全一致,说明骨架搭对了:

--- config: flowchart: curve: linear --- graph TD; __start__([<p>__start__</p>]):::first extract_keywords(extract_keywords) recall_column(recall_column) recall_metric(recall_metric) recall_value(recall_value) merge_retrieved_info(merge_retrieved_info) filter_table(filter_table) filter_metric(filter_metric) add_extra_context(add_extra_context) generate_sql(generate_sql) validate_sql(validate_sql) correct_sql(correct_sql) run_sql(run_sql) __end__([<p>__end__</p>]):::last __start__ --> extract_keywords; add_extra_context --> generate_sql; correct_sql --> run_sql; extract_keywords --> recall_column; extract_keywords --> recall_metric; extract_keywords --> recall_value; filter_metric --> add_extra_context; filter_table --> add_extra_context; generate_sql --> validate_sql; merge_retrieved_info --> filter_metric; merge_retrieved_info --> filter_table; recall_column --> merge_retrieved_info; recall_metric --> merge_retrieved_info; recall_value --> merge_retrieved_info; validate_sql -.-> correct_sql; validate_sql -.-> run_sql; run_sql --> __end__; classDef default fill:#f2f0ff,line-height:1.2 classDef first fill-opacity:0 classDef last fill:#bfb6fc

小技巧:把输出的 Mermaid 代码贴到 mermaid.live 可以在浏览器里看到可交互的流程图。


第二步:给每个节点加上进度推送

第一步的图能"画出来",但节点都是空壳,用户不知道系统在干嘛。接下来让每个节点在运行时向前端推送当前进度。

改动点:

  1. 节点函数从 def 改成 async def,加上 runtime 参数
  2. 加一个 STEP_NAMES 字典,把函数名映射为用户能看懂的中文步骤名
  3. 每个节点在干活的起止点调用 runtime.stream_writer 推送进度
  4. 下游运行时在 graph.astream 里通过 stream_mode="custom" 接收这些推送

app/agent/graph.py 更新为:

python 复制代码
import asyncio
import json
from langgraph.graph import START, END, StateGraph
from langgraph.runtime import Runtime
from typing import Annotated, Any, TypedDict


# ---------- 配置 ----------

# 节点函数名 → 用户可见的中文步骤名
STEP_NAMES: dict[str, str] = {
    "extract_keywords": "抽取关键词",
    "recall_column": "召回字段",
    "recall_metric": "召回指标",
    "recall_value": "召回值",
    "merge_retrieved_info": "合并召回信息",
    "filter_table": "过滤表",
    "filter_metric": "过滤指标",
    "add_extra_context": "添加额外上下文",
    "generate_sql": "生成 SQL",
    "validate_sql": "验证 SQL",
    "correct_sql": "修正 SQL",
    "run_sql": "执行 SQL",
}


# 推送进度的辅助函数
def push_progress(runtime: Runtime, step_name: str, status: str) -> None:
    runtime.stream_writer({"type": "progress", "step": step_name, "status": status})


# ---------- State ----------

# total=False:所有字段可选,并行分支用 Annotated + reducer 处理冲突
class State(TypedDict, total=False):
    error: Annotated[Any, lambda a, b: b]  # last-value-wins


class RuntimeContext(TypedDict, total=False):
    pass


# ---------- 节点函数(带进度推送) ----------

async def extract_keywords(state: State, runtime: Runtime) -> State:
    push_progress(runtime, STEP_NAMES["extract_keywords"], "running")
    await asyncio.sleep(1)  # TODO: 实际调用 jieba 分词
    push_progress(runtime, STEP_NAMES["extract_keywords"], "success")
    return state

async def recall_column(state: State, runtime: Runtime) -> State:
    push_progress(runtime, STEP_NAMES["recall_column"], "running")
    await asyncio.sleep(1)  # TODO: 向量检索召回字段
    push_progress(runtime, STEP_NAMES["recall_column"], "success")
    return state

async def recall_metric(state: State, runtime: Runtime) -> State:
    push_progress(runtime, STEP_NAMES["recall_metric"], "running")
    await asyncio.sleep(1)
    push_progress(runtime, STEP_NAMES["recall_metric"], "success")
    return state

async def recall_value(state: State, runtime: Runtime) -> State:
    push_progress(runtime, STEP_NAMES["recall_value"], "running")
    await asyncio.sleep(1)
    push_progress(runtime, STEP_NAMES["recall_value"], "success")
    return state

async def merge_retrieved_info(state: State, runtime: Runtime) -> State:
    push_progress(runtime, STEP_NAMES["merge_retrieved_info"], "running")
    await asyncio.sleep(1)
    push_progress(runtime, STEP_NAMES["merge_retrieved_info"], "success")
    return state

async def filter_table(state: State, runtime: Runtime) -> State:
    push_progress(runtime, STEP_NAMES["filter_table"], "running")
    await asyncio.sleep(1)
    push_progress(runtime, STEP_NAMES["filter_table"], "success")
    return state

async def filter_metric(state: State, runtime: Runtime) -> State:
    push_progress(runtime, STEP_NAMES["filter_metric"], "running")
    await asyncio.sleep(1)
    push_progress(runtime, STEP_NAMES["filter_metric"], "success")
    return state

async def add_extra_context(state: State, runtime: Runtime) -> State:
    push_progress(runtime, STEP_NAMES["add_extra_context"], "running")
    await asyncio.sleep(1)
    push_progress(runtime, STEP_NAMES["add_extra_context"], "success")
    return state

async def generate_sql(state: State, runtime: Runtime) -> State:
    push_progress(runtime, STEP_NAMES["generate_sql"], "running")
    await asyncio.sleep(1)
    push_progress(runtime, STEP_NAMES["generate_sql"], "success")
    return state

async def validate_sql(state: State, runtime: Runtime) -> State:
    push_progress(runtime, STEP_NAMES["validate_sql"], "running")
    await asyncio.sleep(1)
    push_progress(runtime, STEP_NAMES["validate_sql"], "success")
    return state

async def correct_sql(state: State, runtime: Runtime) -> State:
    push_progress(runtime, STEP_NAMES["correct_sql"], "running")
    await asyncio.sleep(1)
    push_progress(runtime, STEP_NAMES["correct_sql"], "success")
    return state

async def run_sql(state: State, runtime: Runtime) -> State:
    push_progress(runtime, STEP_NAMES["run_sql"], "running")
    await asyncio.sleep(1)
    # 模拟查询结果
    runtime.stream_writer({
        "type": "result",
        "data": [
            {"gender": "男", "sales_amount": 135370.5},
            {"gender": "女", "sales_amount": 143789.0},
        ],
    })
    push_progress(runtime, STEP_NAMES["run_sql"], "success")
    return state


# ---------- 搭图(和第一步完全一样) ----------

graph = (
    StateGraph(state_schema=State, context_schema=RuntimeContext)
    .add_node(extract_keywords)
    .add_node(recall_column)
    .add_node(recall_metric)
    .add_node(recall_value)
    .add_node(merge_retrieved_info)
    .add_node(filter_table)
    .add_node(filter_metric)
    .add_node(add_extra_context)
    .add_node(generate_sql)
    .add_node(validate_sql)
    .add_node(correct_sql)
    .add_node(run_sql)
    .add_edge(START, "extract_keywords")
    .add_edge("extract_keywords", "recall_column")
    .add_edge("extract_keywords", "recall_metric")
    .add_edge("extract_keywords", "recall_value")
    .add_edge("recall_column", "merge_retrieved_info")
    .add_edge("recall_metric", "merge_retrieved_info")
    .add_edge("recall_value", "merge_retrieved_info")
    .add_edge("merge_retrieved_info", "filter_table")
    .add_edge("merge_retrieved_info", "filter_metric")
    .add_edge("filter_table", "add_extra_context")
    .add_edge("filter_metric", "add_extra_context")
    .add_edge("add_extra_context", "generate_sql")
    .add_edge("generate_sql", "validate_sql")
    .add_conditional_edges(
        source="validate_sql",
        path=lambda state: "run_sql" if state["error"] is None else "correct_sql",
        path_map={"run_sql": "run_sql", "correct_sql": "correct_sql"},
    )
    .add_edge("correct_sql", "run_sql")
    .add_edge("run_sql", END)
    .compile()
)


# ---------- 演示:执行图并观察各节点输出的进度 ----------

if __name__ == "__main__":
    initial_state: State = {"error": None}  # 无错误 → 走校验通过路径

    async def run_demo():
        async for event in graph.astream(initial_state, stream_mode="custom"):
            print(json.dumps(event, ensure_ascii=False))

    asyncio.run(run_demo())

执行:

shell 复制代码
uv run python app/agent/graph.py

控制台会逐行输出每个节点的运行状态------三路召回的 success 顺序可能不同(并行执行),但 running 一定是三个几乎同时发出:

json 复制代码
{"type": "progress", "step": "抽取关键词", "status": "running"}
{"type": "progress", "step": "抽取关键词", "status": "success"}
{"type": "progress", "step": "召回字段", "status": "running"}
{"type": "progress", "step": "召回指标", "status": "running"}
{"type": "progress", "step": "召回值", "status": "running"}
{"type": "progress", "step": "召回字段", "status": "success"}
{"type": "progress", "step": "召回指标", "status": "success"}
{"type": "progress", "step": "召回值", "status": "success"}
{"type": "progress", "step": "合并召回信息", "status": "running"}
{"type": "progress", "step": "合并召回信息", "status": "success"}
{"type": "progress", "step": "过滤指标", "status": "running"}
{"type": "progress", "step": "过滤表", "status": "running"}
{"type": "progress", "step": "过滤指标", "status": "success"}
{"type": "progress", "step": "过滤表", "status": "success"}
{"type": "progress", "step": "添加额外上下文", "status": "running"}
{"type": "progress", "step": "添加额外上下文", "status": "success"}
{"type": "progress", "step": "生成 SQL", "status": "running"}
{"type": "progress", "step": "生成 SQL", "status": "success"}
{"type": "progress", "step": "验证 SQL", "status": "running"}
{"type": "progress", "step": "验证 SQL", "status": "success"}
{"type": "progress", "step": "执行 SQL", "status": "running"}
{"type": "result", "data": [{"gender": "男", "sales_amount": 135370.5}, {"gender": "女", "sales_amount": 143789.0}]}
{"type": "progress", "step": "执行 SQL", "status": "success"}

注意看"召回字段"、"召回指标"、"召回值"三条的 running 是连续出现的,说明它们并行启动了。同样"过滤指标"和"过滤表"也是并行的。


第三步:让图和 FastAPI 接口联动

目前 main.py 里的 sse_stream() 还在用写死的数组模拟进度。现在图已经能产出真实的进度流了,把它接上去即可。

打开 main.py,把 sse_stream 改成:

python 复制代码
async def sse_stream():
    """调用 LangGraph DAG,通过 stream_mode='custom' 接收节点推送的进度"""
    from app.agent.graph import graph, State

    initial_state: State = {"error": None}
    async for event in graph.astream(initial_state, stream_mode="custom"):
        yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"

重启后端:

shell 复制代码
uv run fastapi dev main.py

用 curl 验证:

bash 复制代码
curl -N -X POST http://127.0.0.1:8000/api/query \
  -H "Content-Type: application/json" \
  -d '{"query":"测试"}'

一行一行地输出进度和结果------流式接口通了!

去浏览器打开 http://localhost:5173,输入任意问题发送。你会看到步骤条依次亮起,最后弹出一张结果表格。

此时节点还是 await asyncio.sleep(1) 的占位逻辑,但图骨架 + 进度推送 + 前后端 SSE 联动的完整闭环已经打通。后续文章逐一填充节点里的真实逻辑,结构完全不用动。

科普langgragh

一句话理解

LangGraph 是 LangChain 生态里用来编排多步骤 AI 工作流的框架。你可以把它理解为"给 LLM 调用加一个有状态、有分支、有循环的控制流引擎"。

如果 LangChain 的 Chain 是一根水管(A → B → C 线性执行),那 LangGraph 就是一张交通路网------节点之间可以分支、可以汇聚、可以根据条件决定走哪条路、甚至走回头路循环。


为什么需要 LangGraph?

假设你写一个简单的 AI 应用:用户问 → 调 LLM → 返回答案。这用 LangChain 的 Chain 就够了,一条线。

但 n2sql-agent 的需求远不止一条线:

sql 复制代码
用户问"上个月上海男女销售额对比"
  │
  ├─ 要先去向量库搜字段、搜指标、搜值(三条路并行,互相不依赖)
  ├─ 搜完要合并
  ├─ 合并完又要并行过滤表和指标
  ├─ 生成 SQL 后要验证
  ├─ 验证通过 → 执行;验证失败 → 修正后再执行(条件分支)

如果手写 if/else + asyncio.gather 来串联这些步骤,代码会迅速变成一坨嵌套的回调地狱。LangGraph 把你从这些控制流细节里解放出来,让你只关心"节点做什么",框架帮你管"什么时候该跑谁、谁的结果该传给谁"


核心概念(用本项目代码对照理解)

打开项目里的 app/agent/graph.py,对照着看。

1. State ------ 图中所有节点共享的数据

State 是一个 TypedDict,相当于一个全局背包,每个节点在里面放东西、取东西。

python 复制代码
class State(TypedDict, total=False):
    error: Annotated[Any, lambda a, b: b]  # last-value-wins

total=False 表示所有字段都是可选的------节点只返回自己更新的字段,不用每次都塞满所有字段。

2. 节点(Node)------ 干活的函数

每个节点是一个 async def xxx(state: State, runtime: Runtime) -> State 函数。它读 state,做自己的事,返回更新后的 state(框架会自动合并)。

python 复制代码
async def extract_keywords(state: State, runtime: Runtime) -> State:
    # 1. 从 state 里取输入
    # 2. 做自己的工作(调 LLM、查数据库......)
    # 3. 返回自己产出的字段
    return {"keywords": ["上海", "男", "女", "销售额", "上个月"]}

关键心智模型 :每个节点独立工作,不主动知道"谁在我前面、谁在我后面"。这一点和传统写代码完全不同------你不需要手动 await next_node(state),连接关系是注册在"边"上的。

3. 边(Edge)------ 节点之间的路

边告诉你"这个节点跑完,下一个跑谁":

python 复制代码
# 简单边:A 跑完 → 跑 B
.add_edge("extract_keywords", "recall_column")

LangGraph 有两种边:

类型 写法 含义
普通边 .add_edge(A, B) A 跑完一定跑 B
条件边 .add_conditional_edges(A, path_fn, path_map) A 跑完,根据 state 里的值决定跑 B 还是 C
4. 条件边(Conditional Edge)------ AI 工作流的分岔路口

这是 LangGraph 最有价值的特性之一。本项目在验证 SQL 后用到了它:

python 复制代码
.add_conditional_edges(
    source="validate_sql",
    path=lambda state: "run_sql" if state["error"] is None else "correct_sql",
    path_map={"run_sql": "run_sql", "correct_sql": "correct_sql"},
)

翻译成人话:验证完 SQL 后看一眼 state["error"]------没报错就去执行,有报错就去修正。

没有 LangGraph 的话,你得自己写:

python 复制代码
if error:
    sql = await correct_sql(sql, error, context)
    await run_sql(sql)
else:
    await run_sql(sql)

看起来不复杂?但当你的分支嵌套三层、且每个分支都可能要回退到之前的节点重试时,手写的 if-else 很快就会失控。

5. 并行 ------ 一个节点同时启动多个下游

这是本项目图里最巧妙的设计之一。看这段:

python 复制代码
# 抽取关键词 → 三条检索通道同时跑
.add_edge("extract_keywords", "recall_column")
.add_edge("extract_keywords", "recall_metric")
.add_edge("extract_keywords", "recall_value")

当一个节点有多条指向下游的边时,LangGraph 会把它们并行执行(字段检索、指标检索、值检索跑的是三个不同的向量库,互不依赖,可以同时启动)。

同理,汇聚也是自动的:

python 复制代码
# 三条通道的结果都到位后,才启动 merge
.add_edge("recall_column", "merge_retrieved_info")
.add_edge("recall_metric", "merge_retrieved_info")
.add_edge("recall_value", "merge_retrieved_info")

只有当 recall_columnrecall_metricrecall_value 三个都跑完merge_retrieved_info 才会被触发。这个"扇出 → 扇入"模式不需要你写任何 Promise.allasyncio.gather,加边就行。

6. Runtime ------ 长生命周期对象的载体

Runtime 承载了那些不应该放在 State 里传来传去的东西------数据库连接池、LLM 客户端、向量库客户端等。这些东西在每个节点里都要用,但它们是工具,不是业务数据。

python 复制代码
async def recall_column(state: State, runtime: Runtime) -> State:
    embedding_client = runtime.context["embedding_client_manager"]
    # 用嵌入客户端把关键词向量化,查向量库......

概念全景对照(一张表看懂)

概念 比喻 本项目对应
StateGraph 整张交通路网 StateGraph(state_schema=State)
节点 Node 路上的一个加油站 extract_keywordsgenerate_sql......
边 Edge .add_edge(A, B)
条件边 Conditional Edge 分岔路,看路牌选方向 validate_sql → run_sql 或 correct_sql
并行 Fan-out 一个路口分三条车道同时通行 ②③④ 三路召回、⑥⑦ 过滤
汇聚 Fan-in 三条车道重新合并成一条 ⑤ 合并召回信息
State 每个节点都能读写的全局背包 {query, keywords, sql, error, ...}
Runtime 工具箱,装螺丝刀扳手等工具 数据库连接、LLM 客户端

从入门到进化:你需要知道什么

阶段一:能跑起来

看懂并改得动下面这段代码,就入门了:

python 复制代码
from langgraph.graph import START, END, StateGraph

# 1. 定义 State ------ 各节点共享的数据结构
class State(TypedDict):
    query: str
    result: str

# 2. 定义节点 ------ 每个节点是一个 async 函数
async def step_one(state: State):
    return {"query": state["query"].upper()}  # 转大写

async def step_two(state: State):
    return {"result": f"处理结果: {state['query']}"}

# 3. 搭图 ------ 注册节点 + 连线
graph = (
    StateGraph(State)
    .add_node(step_one)
    .add_node(step_two)
    .add_edge(START, "step_one")
    .add_edge("step_one", "step_two")
    .add_edge("step_two", END)
    .compile()
)

# 4. 运行
async def main():
    async for event in graph.astream({"query": "hello"}):
        print(event)

asyncio.run(main())

到这里你就掌握了 LangGraph 的最小核心循环:定义 State → 定义节点函数 → 注册节点并连线 → compile → astream 运行

阶段二:理解条件分支

在阶段一的基础上,把 .add_edge 换成 .add_conditional_edges

python 复制代码
def decide(state: State) -> str:
    return "path_a" if len(state["query"]) > 10 else "path_b"

graph.add_conditional_edges(
    "step_one",
    decide,
    {"path_a": "step_a", "path_b": "step_b"},
)

这是 LangGraph 区分于普通 Chain 的关键:图的走向不是写死的,而是由运行时 State 决定的

阶段三:掌握并行与汇聚

给同一个节点加多条向下的边 → 并行。给同一个节点加多条向上的边 → 汇聚。

不需要学新 API,它靠的是 graph 的拓扑结构自动推导。这正是 LangGraph 的设计哲学------你能画出流程图,就能搭出代码

阶段四:理解循环与 Human-in-the-Loop

n2sql-agent 只用了一次验证→修正循环(最多跑一圈),但 LangGraph 原生支持多轮循环:

python 复制代码
# 修正完回到验证,形成"验证 ↔ 修正"循环
.add_edge("correct_sql", "validate_sql")

加上 interrupt 机制,你还能在关键节点暂停流程、等待人工审批后再继续------这就是"Human-in-the-Loop"模式,在企业级场景里非常重要。


本项目的图结构回顾

对照着 app/agent/graph.py 里的实际代码再看一遍:

sql 复制代码
START
  │
  ▼
extract_keywords          ← 节点:抽关键词
  ├──────────┬──────────┐   ← 扇出:三路并行召回
  ▼          ▼          ▼
recall_col  recall_met  recall_val
  └──────────┼──────────┘   ← 扇入:汇聚到合并
             ▼
     merge_retrieved_info
        ┌──────┴──────┐     ← 扇出:并行过滤
        ▼              ▼
   filter_table   filter_metric
        └──────┬──────┘     ← 扇入:汇聚后补上下文
               ▼
       add_extra_context
               │
               ▼
         generate_sql       ← 节点:生成 SQL
               │
               ▼
         validate_sql       ← 节点:验证 SQL
          ╱        ╲
    无错 ╱          ╲ 有错   ← 条件分支
        ▼            ▼
    run_sql      correct_sql
        │            │
        │            ▼
        │      validate_sql  ← 修正后回验证(循环一次)
        │         ╱
        ▼        ╱
       END ←───┘

所有概念都在这张图里了:节点、边、条件边、并行扇出、汇聚扇入、循环。你项目已经用了 LangGraph 80% 的核心能力。


相关链接

相关推荐
颜酱1 小时前
02 | 搭骨架:用 LangGraph 编排 12 步工作流
前端·人工智能·后端
码上解惑1 小时前
从 Dify 工作流说起:常用节点怎么选、怎样组合?
java·人工智能·ai·agent·dify·智能体·spring ai
小陈phd1 小时前
QAnything 阅读优化策略05——检索
人工智能·python·机器学习
zhangfeng11332 小时前
CVOCA 卷积模型,《Nature》子刊 特征提取技术突破性研究的综合分析报告
人工智能
炎火星2 小时前
直播切片素材杂乱,怎么用 AI 剪辑做成可用带货视频?
人工智能
像风一样的男人@2 小时前
python --fastapi推流AI推理
人工智能·python·fastapi
行走的小派2 小时前
从个人开发到企业部署:OPi AI Station四大应用场景与选型指南
人工智能·边缘计算·香橙派·边缘ai
天云数据2 小时前
从“雇佣一个人”到“组建一个团队”:AI生产力的组织方式被重写
人工智能