02 | 搭骨架:用 LangGraph 编排 12 步工作流
项目地址:github.com/frontzhm/n2...
每一步对应的完整代码都在仓库里,跟着文档卡住了就去翻源码。
这是一篇系列文,请按照顺序阅读。
本文主要用langgraph 搭建主要流程。 先看看流程图。

介绍流程
整个流程的终态是:用户输入一句自然语言,系统输出一张查询结果表。
倒推回来,要达成这个目标需要回答三个核心问题:
-
数据在哪? ------ 数据库里有几十上百张表、成千上万个字段,用户不可能知道。必须有一套自动定位"该查哪些表、用哪些列"的机制。
-
SQL 怎么写? ------ 拿到表结构后,需要把用户的口语化问题转成严谨的 SQL 语句。这一步最容易出错:表名写错、字段名记混、JOIN 条件遗漏。
-
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
- 输入 :
keywords、query - 输出 :
retrieved_column_infos(召回的字段列表)
做什么 :用关键词去字段向量库 里做语义检索,找到跟用户问题相关的数据库字段(如 sales_amount、gender)。
为什么这么做:
- 数据库字段名往往是英文缩写(
order_amt、sku_id),用户说的是中文("订单金额")。用关键词向量化 + 向量库检索,本质是在做跨语言语义匹配 ------字段的向量库是在导入时用字段的中文描述(name + comment)生成的,所以"订单金额"能召回order_amt。 - 为什么还要用 LLM 扩展关键词? jieba 抽出来的词太"原生态",比如用户说"上个月",jieba 只能拆出"上"和"个月"。需要 LLM 对业务词做同义扩展------"上个月"→"上月、当月、近期"等,提升召回覆盖度。
③ 召回指标 | recall_metric
- 输入 :
keywords、query - 输出 :
retrieved_metric_infos(召回的指标列表)
做什么 :用关键词在指标向量库 中检索相关指标(如"销售额"→ total_sales、"退款率"→ refund_rate)。
为什么单独建指标库而不是都放在字段库?
- 指标是计算逻辑,不是简单字段。"客单价 = 总销售额 / 订单数",如果不把指标定义(公式、相关字段、聚合方式)预先建好,生成 SQL 时 LLM 可能算出错误公式。
- 指标库的元信息包含
relevant_columns(该指标依赖哪些字段),这个在后续合并步骤里用来补全字段列表------LLM 写 SQL 时不仅要知道"有这么个指标",还要知道"这个指标要用到哪些底层字段"。
④ 召回值 | recall_value
- 输入 :
keywords、query - 输出 :
retrieved_value_infos(召回的数据值列表)
做什么 :用关键词去 Elasticsearch 中模糊搜索数据值,比如用户说"上海",需要召回数据库中已有的地区值 ['上海市', '浦东新区', ...]。
为什么用 ES 而不是向量库?
- 值是精确文本,不是语义概念。"上海市"三个字就是"上海市",不存在"北京"跟"上海"语义相似这种需求。
- ES 的模糊匹配(fuzzy)天然适合值搜索------用户输入"上海",数据库里存的是"上海市",ES 的模糊查询能直接命中,而向量库要先把"上海"和"上海市"各自编码再算余弦距离,多余且不准。
- 召回的值会附在对应字段的
examples里,例如给region字段附上['上海市', '浦东新区'],生成 SQL 的 LLM 看到这些真实示例后,写 WHERE 条件时就不会瞎编值。
⑤ 合并召回信息 | merge_retrieved_info
- 输入 :
retrieved_column_infos、retrieved_metric_infos、retrieved_value_infos - 输出 :
table_infos(按表分组的字段信息)、metric_infos(格式化的指标信息)
做什么:把三路召回(字段、指标、值)的结果拼成一张完整的"候选数据地图"------按表分组、补全主键/外键、把值挂到字段上。
为什么这步不可或缺?
三路召回是并行的,各跑各的,召回来的信息是碎片化的:
- 字段召回告诉你
sales_amount跟"销售额"有关,但不知道它属于哪张表; - 指标召回告诉你
total_sales依赖order.sales_amount,但如果你恰好没召回这个字段呢? - 值召回告诉你"上海市"是
region字段的一个值,但region属于stores表。
合并做四件事:
- 字段挂表 :每个字段都带上它所属的
table_id,按表分组。 - 指标补字段 :每个指标的
relevant_columns所引用的字段,即使原来没被召回,也补进去------指标里写了要用order.quantity,就不能漏。 - 值挂字段 :把召回的取值追加到对应字段的
examples列表。 - 补主键和外键 :每张已召回的表,强制补上它的主键和外键列。向量召回容易漏掉
id这种"不语义化"的字段,但 JOIN 必须用到它们。
做完这一步,候选数据的全貌就出来了。
⑥⑦ 过滤表 & 过滤指标 | filter_table & filter_metric
- ⑥ filter_table
- 输入:
table_infos、query - 输出:
table_infos(过滤后的表信息,同时裁剪了每张表的字段列表)
- 输入:
- ⑦ filter_metric
- 输入:
metric_infos、query - 输出:
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
- 输入 :
query、table_infos、metric_infos、date_info、db_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.id 里 user_id 到底存不存在。只有真正连到数据库跑一次 EXPLAIN,才能确保:
- 表存在
- 字段名正确
- JOIN 条件和聚合语法合法
这是典型的 "LLM + 确定性工具" 组合拳:LLM 负责创造,数据库负责验证。
⑪ 修正 SQL | correct_sql
- 输入 :
sql、error、query、table_infos、metric_infos、date_info、db_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 流程图------图和之前介绍的那张完全一致,说明骨架搭对了:
小技巧:把输出的 Mermaid 代码贴到 mermaid.live 可以在浏览器里看到可交互的流程图。
第二步:给每个节点加上进度推送
第一步的图能"画出来",但节点都是空壳,用户不知道系统在干嘛。接下来让每个节点在运行时向前端推送当前进度。
改动点:
- 节点函数从
def改成async def,加上runtime参数 - 加一个
STEP_NAMES字典,把函数名映射为用户能看懂的中文步骤名 - 每个节点在干活的起止点调用
runtime.stream_writer推送进度 - 下游运行时在
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 联动的完整闭环已经打通。后续文章逐一填充节点里的真实逻辑,结构完全不用动。