Skip to content

第 29 章 实战项目三:数据分析助手(Text2SQL) ​

29.1 需求与场景 ​

数据分析助手面向企业内部分析师、运营与产品人员,以自然语言查询关系型数据库。用户输入中文业务问题(如"上个月华东地区订单金额 Top10 的品类是哪些"),系统自动生成 SQL、执行、返回表格结果,并以自然语言解释业务含义与给出可视化建议。流程以单图线性循环实现,结构简单、易调试,展示"简单任务不要过度工程化"的架构原则。

典型使用路径:分析师在 BI 聊天框输入问题;系统加载相关表结构(含字段注释与示例行);LLM 生成 SQL;经过安全校验层确认是只读查询后在只读账号上执行;成功则将结果集转为自然语言回答并给出图表建议,失败则将数据库错误信息回喂 LLM 自动修正,最多 3 轮;超过上限则给出友好错误并建议改写问题或联系数据团队。

功能需求:数据库元数据加载(表结构、列名、注释、示例数据、主外键关系);SQL 生成(结合问题与 schema 提示,结构化输出);SQL 校验与安全检查(禁止 DROP/DELETE/UPDATE/INSERT/ALTER/TRUNCATE/CREATE 等写操作);只读连接执行 SQL(带 statement_timeout);错误自纠正(SQL 报错返回 LLM 修正,最多 3 次);结果可视化建议(按数据特征推荐折线/柱状/饼图/表格/KPI 卡片);自然语言回答;查询历史记录写入 Store 供后续相似问题召回。

非功能需求:单查询端到端 P95 < 8 秒(不含慢查询触发超时报错的路径);强制只读,禁止写操作与跨库访问;单次返回行数 ≤ 1000;错误自纠正成功率 ≥ 80%;每一次问题、SQL、执行结果、token 消耗均可审计;支持同时连接多个数据库实例(analytics/marketing/finance)通过 configurable 切换。

为什么选择单图而非多 Agent:Text2SQL 任务线性明确,schema 加载 → SQL 生成 → 校验 → 执行 → 解释是强顺序,不存在并行路径与角色分工;多 Agent 只会引入额外的通信开销、状态同步复杂度与调试成本。这一选择体现 LangGraph 设计理念——图的节点粒度由任务本身决定,不必为了"Agent"概念过度工程化。对于复杂场景(如跨库联合分析、多步推理、自动可视化),可在单图基础上新增节点扩展,而无需一开始就构建多 Agent 体系。

29.2 架构设计 ​

┌────────┐  HTTP/SSE  ┌─────────────┐   ┌───────────────────────────────────────────────┐
│ Client │ ─────────▶ │  FastAPI    │   │  Text2SQL Graph (单 StateGraph)                │
│ BI/IM  │ ◀───────── │  /ask/stream│   │                                               │
└────────┘            └─────────────┘   │  START → load_schema → generate_sql           │
                                        │                         │                     │
                                        │                         ▼                     │
                                        │                    validate_sql                │
                                        │                      │       │                │
                                        │                   不通过    通过                │
                                        │                      │       │                │
                                        │                      ▼       ▼                │
                                        │                 error_cor? execute_sql         │
                                        │                      │       │                │
                                        │             attempts<3?│   成功/失败            │
                                        │                ┌Yes   │   │   │               │
                                        │                │      ▼   ▼   │               │
                                        │                └──▶generate_sql (loop)        │
                                        │                │No       │                    │
                                        │                ▼         ▼                    │
                                        │           friendly_err explain_result ─▶ END  │
                                        └──────────────────┬────────────────────────────┘
                                                           │
                                            ┌──────────────┼──────────────┐
                                            ▼              ▼              ▼
                                      ┌──────────┐  ┌──────────┐  ┌───────────┐
                                      │Memory/PG │  │ Readonly │  │ Postgres  │
                                      │Saver     │  │ PG DB    │  │ Store     │
                                      └──────────┘  └──────────┘  └───────────┘

技术选型:单 StateGraph + 条件路由循环;结构化输出以 Pydantic 模型 SQLQuery 校验 LLM 输出;不引入多 Agent 编排,节点线性串联,便于调试与性能优化;MemorySaver 即可满足单次查询 checkpoint 需求(查询在秒级完成,无需跨断点持久化);查询历史写入 PostgresStore(namespace=("sql_history", user_id))供相似问题召回与运营审计;模型选择 gpt-4o 以获得更好的 SQL 生成准确率,简单查询可降级 gpt-4o-mini。

条件路由循环机制说明:error_correction 节点只做计数器自增,不直接连回 generate_sql,而是通过 should_revise 条件边判断 retry/fail 两个分支,这样做的好处是循环边界显式化,每一轮都会经过 validate_sql 与 execute_sql,不会绕过安全校验;attempts 字段也会被 checkpoint 保存,即使在循环中中断(例如 execute_sql 超时触发 interrupt 等待用户确认"是否继续"),恢复后也能准确知道当前轮次而不会重置计数。对于希望让用户在长 SQL 执行前确认的场景,可以在 validate_sql 和 execute_sql 之间增加 await_user_confirm 节点调用 interrupt,用户确认后再 resume 到 execute_sql。

数据库连接池配置要点:psycopg 的 ConnectionPool 应在应用启动时初始化一次并复用,避免每个请求新建连接池;pool_size 按 worker 数 × 每 worker 峰值并发设置,典型值为 10-20;只读账号的 statement_timeout 通过连接串 options 传递让数据库层强制超时,这是安全兜底,应用层 asyncio.wait_for 不能替代,因为 Python 侧超时无法中断数据库侧正在执行的查询。

项目目录结构:

text2sql/
├── app/
│   ├── main.py              # FastAPI + SSE
│   ├── graph.py             # Text2SQL 图构建
│   ├── state.py
│   ├── nodes/
│   │   ├── schema_loader.py
│   │   ├── sql_gen.py
│   │   ├── validator.py
│   │   ├── executor.py
│   │   ├── corrector.py
│   │   └── explainer.py
│   ├── tools/
│   │   └── db.py            # 只读连接池与 schema 读取
│   └── utils/
│       ├── security.py      # SQL 安全校验
│       └── prompts.py
├── tests/
│   ├── test_security.py
│   ├── test_error_correction.py
│   ├── test_golden_set.py
│   └── test_concurrency.py
├── docker-compose.yml
├── requirements.txt
└── .env

依赖安装命令:

bash
pip install "langgraph>=1.2,<1.3" langchain langchain-openai \
    psycopg[binary,pool] fastapi uvicorn sqlparse pydantic pyyaml

Schema 检索策略:当数据库表数量较少(< 15 张)时直接将全部 DDL 塞入 schema_info,模型准确率最高;当表数量较大(15-100 张)时,先对问题做关键词提取,再从 schema_embeddings 表(预先存储表/列注释的 embedding)中召回 Top-K 相关表(默认 K=8),仅将相关表 DDL 注入 prompt;当表数量超大(> 100 张)时采用分阶段策略,先让 LLM 根据表名与列名粗选候选表,再注入候选表详细 DDL 生成最终 SQL。schema_embeddings 的更新由 DDL 迁移脚本触发,保持与数据库结构一致。

29.3 State 与节点设计 ​

python
# state.py
from typing import Optional, List, Dict, Any
from typing_extensions import TypedDict

class SQLState(TypedDict):
    question: str
    db_name: str
    schema_info: str               # DDL + 注释 + 示例行 + JOIN 提示
    generated_sql: str
    validation_error: str
    query_result: List[Dict[str, Any]]
    answer: str
    chart_suggestion: str         # line/bar/pie/table/kpi
    attempts: int
    last_db_error: str

节点职责:

  • load_schema:首次调用时从数据库 INFORMATION_SCHEMA 读取表结构、字段注释、每表 3 行脱敏示例数据与主外键关系,组装为 schema_info;写入 Store namespace=("schema", db_name) 做缓存(TTL 1 小时)。
  • generate_sql:LLM 根据 question + schema_info 输出 SQLQuery(含 reasoning 推理过程与 sql 字段),通过 Pydantic 校验结构。
  • validate_sql:正则 + sqlparse 词法分析检查危险关键字、强制只读前缀、校验是否查询了授权表白名单、强制或追加 LIMIT 1000。
  • execute_sql:使用只读数据库账号执行,连接串设置 statement_timeout=10s;异常捕获并将数据库错误信息回填 last_db_error;成功则 query_result 为 list[dict],列名取自 cursor.description。
  • error_correction:attempts += 1,记录轮次。
  • should_retry:attempts < 3 且存在 validation_error 或 last_db_error 时回到 generate_sql,同时把错误信息作为额外 user message 注入;否则返回友好错误。
  • explain_result:将 query_result 转为自然语言回答,根据字段类型与数据分布给出图表建议;若失败超限直接输出友好错误。

Pydantic 输出模型与图构建:

python
# graph.py
from pydantic import BaseModel, Field
class SQLQuery(BaseModel):
    reasoning: str = Field(description="生成 SQL 的推理过程,说明用到了哪些表与 JOIN 关系")
    sql: str = Field(description="最终可执行的只读 SQL 语句")

from langgraph.graph import StateGraph, START, END
from .state import SQLState
from .nodes.schema_loader import load_schema
from .nodes.sql_gen import generate_sql
from .nodes.validator import validate_sql
from .nodes.executor import execute_sql
from .nodes.corrector import error_correction, should_retry
from .nodes.explainer import explain_result

builder = StateGraph(SQLState)
builder.add_node("load_schema", load_schema)
builder.add_node("generate_sql", generate_sql)
builder.add_node("validate_sql", validate_sql)
builder.add_node("execute_sql", execute_sql)
builder.add_node("error_correction", error_correction)
builder.add_node("explain_result", explain_result)

builder.add_edge(START, "load_schema")
builder.add_edge("load_schema", "generate_sql")
builder.add_edge("generate_sql", "validate_sql")
builder.add_conditional_edges("validate_sql",
    lambda s: "execute_sql" if not s.get("validation_error") else "error_correction",
    {"execute_sql":"execute_sql","error_correction":"error_correction"})
builder.add_conditional_edges("execute_sql",
    lambda s: "explain_result" if not s.get("last_db_error") else "error_correction",
    {"explain_result":"explain_result","error_correction":"error_correction"})
builder.add_conditional_edges("error_correction", should_retry,
    {"retry":"generate_sql","fail":"explain_result"})
builder.add_edge("explain_result", END)

29.4 Schema 提示、安全与错误自纠正 ​

Schema 提示工程是 Text2SQL 准确率的关键。load_schema 组装四部分信息:CREATE TABLE DDL(含列类型、NOT NULL、默认值)、COMMENT ON 字段注释、每表 3 行示例数据(对金额、姓名等敏感字段做脱敏替换)、常见 JOIN 关系提示(外键与常用业务关联)。当库表数量超过 30 张时,根据问题关键词对表/列注释做 embedding 向量检索,仅将相关表纳入 schema_info,避免 prompt 过长导致模型忽略重要信息。

示例 schema 片段:

sql
-- orders 订单表
CREATE TABLE orders (
  id BIGSERIAL PRIMARY KEY,
  user_id BIGINT NOT NULL,
  amount NUMERIC(12,2) NOT NULL,
  status VARCHAR(32) NOT NULL,
  region VARCHAR(32),
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON COLUMN orders.status IS '订单状态: created/paid/shipped/done/refunded';
COMMENT ON COLUMN orders.region IS '地区: 华东/华北/华南/西南/其他';
-- 示例行:
-- (1001, 8801, 299.00, 'done', '华东', '2026-01-10 10:00:00+08')
-- (1002, 8802, 129.00, 'shipped', '华北', '2026-01-11 14:20:00+08')
-- 相关 JOIN: orders.user_id = users.id; orders.id = payments.order_id

安全机制采用五层防线。第一层数据库层:使用独立只读账号,收回 INSERT/UPDATE/DELETE/TRUNCATE 等权限,按 schema 级别授权;连接串通过 options 参数设置 statement_timeout=10s 防止慢查询拖垮数据库。第二层 SQL 层:validate_sql 使用正则与词法分析组合校验,关键字白名单仅允许 SELECT/WITH/SHOW/EXPLAIN 开头;拦截 DROP/DELETE/UPDATE/INSERT/ALTER/TRUNCATE/CREATE/GRANT/REVOKE 等关键字;校验 FROM 后的表名必须在授权白名单内;若 SQL 未带 LIMIT 则自动追加 LIMIT 1000。第三层结果层:在 execute_sql 中使用 cur.fetchmany(1000) 截断返回行数。第四层 schema 屏蔽:password、id_card、phone、email 等敏感列在 schema 加载阶段直接从 DDL 中删除列名并在示例数据中替换为 ***,不暴露给 LLM。第五层审计层:将每次 question、generated_sql、executed_sql、query_result 行数、延迟、attempts 写入结构化日志与 LangSmith,便于事后回溯。

SQL 校验函数实现:

python
# utils/security.py
import re
FORBIDDEN = re.compile(
    r"\b(DROP|DELETE|UPDATE|INSERT|ALTER|TRUNCATE|CREATE|GRANT|REVOKE|VACUUM|COPY)\b",
    re.IGNORECASE)
READONLY_PREFIX = re.compile(r"^\s*(SELECT|WITH|SHOW|EXPLAIN)\b", re.IGNORECASE)
FROM_TABLE = re.compile(r"\bFROM\s+([a-zA-Z_][a-zA-Z0-9_]*)", re.IGNORECASE)
JOIN_TABLE = re.compile(r"\bJOIN\s+([a-zA-Z_][a-zA-Z0-9_]*)", re.IGNORECASE)
SENSITIVE_COLS = {"password","passwd","id_card","idcard","phone","mobile","email"}

def sanitize_schema(ddl: str) -> str:
    """在 schema 文本中屏蔽敏感列。"""
    for col in SENSITIVE_COLS:
        ddl = re.sub(rf"\b{col}\b\s+[A-Z]+", f"{col}_masked VARCHAR(32)", ddl, flags=re.I)
    return ddl

def validate_sql(sql: str, allowed_tables: set) -> tuple[str, str]:
    """返回 (possibly_rewritten_sql, error_message),error_message 为空表示通过。"""
    if not READONLY_PREFIX.match(sql):
        return sql, "SQL 必须以 SELECT/WITH/SHOW/EXPLAIN 开头"
    if FORBIDDEN.search(sql):
        return sql, "SQL 包含写操作或 DDL 关键字,已被安全校验拦截"
    # 表名白名单
    refs = {m.group(1).lower() for m in FROM_TABLE.finditer(sql)} | \
           {m.group(1).lower() for m in JOIN_TABLE.finditer(sql)}
    for t in refs:
        if t not in allowed_tables:
            return sql, f"查询了未授权的表: {t}"
    # 自动追加 LIMIT
    if "limit" not in sql.lower():
        sql = sql.rstrip(";").rstrip() + " LIMIT 1000;"
    return sql, ""

数据库连接与执行:

python
# tools/db.py
from psycopg_pool import ConnectionPool

READONLY_URL = ("postgresql://readonly:ro_pwd@db:5432/analytics"
                "?options=-c%20statement_timeout%3D10000")
read_pool = ConnectionPool(READONLY_URL, max_size=10)
ALLOWED_TABLES = {"orders","users","products","sessions","payments","categories"}

def execute_readonly(sql: str):
    with read_pool.connection() as conn:
        with conn.cursor() as cur:
            cur.execute(sql)
            cols = [c.name for c in cur.description]
            rows = cur.fetchmany(1000)
            return [dict(zip(cols, r)) for r in rows]

def load_schema_text() -> str:
    """从数据库读取 DDL、注释、示例行并拼接(实现略)。"""
    ...

generate_sql 节点在重试轮次会附加错误信息作为额外 user message,给模型足够的修复线索:

python
# nodes/sql_gen.py
from langchain.chat_models import init_chat_model
from langchain_core.messages import HumanMessage, SystemMessage
from ..utils.prompts import load_prompt
from ..graph import SQLQuery

async def generate_sql(state: SQLState):
    llm = init_chat_model("openai:gpt-4o").with_structured_output(SQLQuery)
    messages = [
        SystemMessage(content=load_prompt("sql_gen").format(
            schema_info=state["schema_info"])),
        HumanMessage(content=state["question"])]
    if state.get("attempts",0) > 0 and state.get("last_db_error"):
        messages.append(HumanMessage(
            content=f"上一次执行 SQL 报错:{state['last_db_error']}。请结合 schema 修正后重新生成。"))
    if state.get("validation_error"):
        messages.append(HumanMessage(
            content=f"上一次 SQL 未通过安全校验:{state['validation_error']}。请修正后重新生成。"))
    q: SQLQuery = await llm.ainvoke(messages)
    return {"generated_sql": q.sql,
            "validation_error": "",
            "last_db_error": ""}

错误自纠正机制将数据库错误原文(如 Postgres 返回的 ERROR: column "amout" does not exist LINE 1: ... 或 operator does not exist: character varying = integer)直接回填给 LLM。列名拼写错误、类型不匹配、遗漏 GROUP BY、JOIN 条件缺失等常见问题通常 1-2 轮即可修复;涉及业务逻辑误解或 schema 缺失的问题可能 3 轮都无法修复,此时应友好失败并提示用户换种问法或联系数据团队,而不是无限循环。

python
# nodes/corrector.py
async def error_correction(state: SQLState):
    return {"attempts": state.get("attempts",0) + 1}

def should_retry(state):
    if state.get("attempts",0) < 3 and (
            state.get("validation_error") or state.get("last_db_error")):
        return "retry"
    return "fail"

# nodes/executor.py
import asyncio
async def execute_sql(state: SQLState):
    from ..tools.db import execute_readonly
    try:
        rows = await asyncio.to_thread(execute_readonly, state["generated_sql"])
        return {"query_result": rows, "last_db_error": ""}
    except Exception as e:
        return {"last_db_error": str(e)[:2000], "query_result": []}

explain_result 节点除自然语言回答外,还给出可视化建议。图表类型推荐规则:结果包含时间序列字段(date/day/month)+ 数值字段 → line(折线);包含分类字段(category/region)+ 数值 → bar(柱状);分类字段 ≤ 5 且数值合计为占比 → pie(饼图);结果集行列都较小且数值需精确对比 → table;结果为单个数值(如"本月 GMV")→ kpi;其他 → none。explain 节点让 LLM 结合上述规则与数据内容决策,输出结构化 AnswerOut。

Few-shot 示例注入:为提升 SQL 生成准确率,schema_info 末尾可附带 3-5 组用户问题 → 正确 SQL 的 Few-shot 示例,示例从历史审计库中按问题相似度召回。示例选择使用 embedding 相似度(pgvector),距离 < 0.3 的示例才注入,避免低相关示例误导模型。

完整工具模块示例:

python
# tools/db.py
from psycopg_pool import ConnectionPool
from psycopg.rows import dict_row
import psycopg

READONLY_URL = ("postgresql://readonly:ro_pwd@db:5432/analytics"
                "?options=-c%20statement_timeout%3D10000")
read_pool = ConnectionPool(READONLY_URL, max_size=10, open=False)
read_pool.open()
ALLOWED_TABLES = {"orders","users","products","sessions","payments","categories"}
SENSITIVE_COLS = {"password","passwd","id_card","idcard","phone","mobile","email"}

def execute_readonly(sql: str):
    with read_pool.connection() as conn:
        with conn.cursor(row_factory=dict_row) as cur:
            cur.execute(sql)
            rows = cur.fetchmany(1000)
            return list(rows)

def load_schema_text(db_name: str = "analytics") -> str:
    """加载指定库的 DDL、注释、示例行、外键关系,拼成 schema_info。"""
    with read_pool.connection() as conn:
        with conn.cursor() as cur:
            cur.execute("""
              SELECT table_name, column_name, data_type, is_nullable,
                     col_description((table_schema||'.'||table_name)::regclass, ordinal_position) as comment
              FROM information_schema.columns
              WHERE table_schema='public'
              ORDER BY table_name, ordinal_position""")
            cols = cur.fetchall()
            # 按表分组,屏蔽敏感列,拼 DDL...(省略具体字符串拼接)
            parts = []
            current_table = None
            for table_name, column_name, data_type, is_nullable, comment in cols:
                if column_name.lower() in SENSITIVE_COLS:
                    continue
                if table_name != current_table:
                    if current_table is not None:
                        parts.append(");")
                        # 取 3 行示例并脱敏(略)
                    parts.append(f"-- {table_name}\nCREATE TABLE {table_name} (")
                    current_table = table_name
                else:
                    parts.append(",")
                line = f"  {column_name} {data_type}"
                if is_nullable == "NO":
                    line += " NOT NULL"
                if comment:
                    line += f"  -- {comment}"
                parts.append(line)
            parts.append(");")
            # 外键与示例行(略)
            return "\n".join(parts)

上述 load_schema_text 以块拼接方式构造 DDL 字符串,对敏感列直接跳过不输出,确保模型无法看到这些列名。

python
# nodes/explainer.py
import json
from langchain.chat_models import init_chat_model
from pydantic import BaseModel

class AnswerOut(BaseModel):
    answer: str
    chart_suggestion: str   # line/bar/pie/table/kpi/none

async def explain_result(state: SQLState):
    llm = init_chat_model("openai:gpt-4o").with_structured_output(AnswerOut)
    if state.get("attempts",0) >= 3 and (
            state.get("last_db_error") or state.get("validation_error")):
        return {"answer":"多次尝试未能生成可执行 SQL,建议换一种问法或联系数据团队协助。",
                "chart_suggestion":"none"}
    out: AnswerOut = await llm.ainvoke([
        {"role":"system","content":"根据 SQL 查询结果,用简洁中文回答用户问题,并根据数据字段(时间/分类/数值)推荐最合适的可视化类型。"},
        {"role":"user","content":f"问题:{state['question']}\nSQL:{state['generated_sql']}\n结果前 20 行:{json.dumps(state['query_result'][:20], ensure_ascii=False, default=str)}"}])
    return {"answer": out.answer, "chart_suggestion": out.chart_suggestion}

29.5 部署与测试 ​

部署采用 FastAPI + 异步执行 + 可选 PostgresSaver(用于审计查询历史)。数据库连接池通过 psycopg_pool 管理,避免每次查询新建连接。流式输出以 messages 模式推送自然语言回答,custom 事件报告"正在加载 schema / 正在生成 SQL / 正在执行查询 / 正在生成解释"等进度。

python
# main.py
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from langgraph.checkpoint.memory import MemorySaver
from .graph import builder
import json, uuid

app = FastAPI()
checkpointer = MemorySaver()
graph = builder.compile(checkpointer=checkpointer)

class AskIn(BaseModel):
    question: str
    db_name: str = "analytics"

@app.post("/ask/stream")
async def ask(inp: AskIn):
    run_id = str(uuid.uuid4())
    async def gen():
        config = {"configurable":{"thread_id":f"sql:{run_id}"},
                  "recursion_limit": 20}
        async for mode, payload in graph.astream(
            {"question": inp.question,
             "db_name": inp.db_name,
             "attempts": 0,
             "validation_error": "",
             "last_db_error": ""},
            config=config,
            stream_mode=["messages","custom"]):
            yield f"data: {json.dumps({'mode':mode,'preview':str(payload)[:300]}, ensure_ascii=False)}\n\n"
        yield "data: [DONE]\n\n"
    return StreamingResponse(gen(), media_type="text/event-stream")

扩展方向:增加 visualization 节点,将 query_result 送入 matplotlib 或 pyecharts 直接生成图表图片(base64 或上传对象存储返回 URL);增加 dashboard 生成能力,连续多轮查询后自动汇总为看板配置 JSON;接入 Query Plan 解释节点,对复杂 SQL 执行 EXPLAIN 并以自然语言提示成本问题(如全表扫描、缺少索引、JOIN 顺序不佳);接入 Query Library,将高频自然语言问题缓存为 SQL 模板(hash(question, schema_version) → sql),命中缓存时直接执行而不再调用 LLM,降低成本与延迟;增加权限节点,根据用户角色限制可访问的表与列(如财务人员才能访问 payments 表),在校验层动态调整 allowed_tables。

测试方案:第一层安全单测,维护 50+ 条包含写操作、跨表访问、注入攻击的 SQL 样例,断言 validate_sql 全部拦截,对合法 SELECT 样例自动追加 LIMIT 1000。第二层错误自纠正集成测试,故意构造列名错误、类型错误、JOIN 缺失三类典型错误 SQL,断言 1-2 轮内修正成功率 ≥ 80%,超过 3 轮触发友好错误。第三层 Golden Dataset 端到端测试,准备 100 条标注问题-SQL-期望结果特征三元组,mocking 数据库返回固定数据集,断言 generated_sql 与期望在语义上等价(可使用 SQL 解析树对比或执行结果对比)、answer 包含关键数据、chart_suggestion 合理。第四层并发压测,以 50 QPS 持续 5 分钟,监控 P95 延迟、数据库连接池使用率、失败率,目标失败率 < 1%。第五层失败注入测试,以 mock 数据库抛异常、LLM 返回畸形 JSON、schema 为空等场景验证 fallback 与 attempts 熔断。第六层 SQL 注入安全测试,使用常见注入 payload('; DROP TABLE orders;--、1 OR 1=1、UNION SELECT password FROM users)发起请求,断言全部被 validate_sql 拦截且数据库端只读账号无权限执行写操作。

Prompt 优化经验:schema_info 中示例数据对准确率影响显著,务必提供真实脱敏样例;字段注释必须包含枚举值与业务含义(如 status 字段的可选值);JOIN 关系提示可显著减少多表连接错误;将问题中的单位(元/万元、日/月)显式提示给模型,避免单位错算;在 prompt 中强制要求"日期范围默认近 30 天,除非用户指定",防止遗漏时间条件导致全表扫描。

load_schema 节点实现要点:节点首次进入时检查 Store 缓存,命中则直接返回 schema_info 字符串;未命中则调用 INFORMATION_SCHEMA 查询(columns 表的 column_name、data_type、is_nullable、column_default、character_maximum_length,再查 pg_description 取 comment),对每张表取 3 行示例行(SELECT * FROM table LIMIT 3)并对敏感列值脱敏,同时查 information_schema.table_constraints/key_column_usage 取外键关系生成 JOIN 提示。schema 缓存 TTL 1 小时,数据库 DDL 变更时通过管理接口主动失效。

流式输出设计:SSE 流的事件顺序为 schema_loading → sql_generating → sql_validating → sql_executing → explaining → result/done;每个阶段开始与结束都发一次 custom 事件,前端展示进度条与当前状态;最后发送 sql_preview 事件将最终生成并执行的 SQL 发给前端展示,便于分析师复核与复用;result 事件携带 query_result(前 20 行预览)、answer 文本与 chart_suggestion;前端可展示"查看完整 SQL"与"在 BI 中打开"按钮。

权限与多数据库支持:state.db_name 字段标识本次查询的目标数据库,工具层按 db_name 路由到不同连接池(analytics、marketing、finance 等),每个数据库使用独立的只读账号与 ALLOWED_TABLES 集合;load_schema 根据 db_name 加载对应 schema_embeddings;configurable 中可传入 user_role,validate_sql 按角色动态收窄可访问列(例如普通运营看不到 payments 表的 amount 字段),实现行/列级权限。

FastAPI 主程序完整端点示意:

python
# main.py
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from contextlib import asynccontextmanager
from langgraph.checkpoint.memory import MemorySaver
from .graph import builder
from .tools.db import read_pool
import json, uuid, asyncio

@asynccontextmanager
async def lifespan(app: FastAPI):
    # 启动时打开连接池
    graph = builder.compile(checkpointer=MemorySaver())
    app.state.graph = graph
    yield
    read_pool.close()

app = FastAPI(lifespan=lifespan)

class AskIn(BaseModel):
    question: str
    db_name: str = "analytics"

@app.post("/ask/stream")
async def ask(inp: AskIn):
    run_id = str(uuid.uuid4())
    async def gen():
        config = {"configurable":{"thread_id":f"sql:{run_id}"},
                  "recursion_limit": 20}
        async for mode, payload in app.state.graph.astream(
            {"question": inp.question, "db_name": inp.db_name,
             "attempts": 0, "validation_error":"", "last_db_error":""},
            config=config, stream_mode=["messages","custom"]):
            yield f"data: {json.dumps({'mode':mode,'preview':str(payload)[:500]}, ensure_ascii=False)}\n\n"
        yield "data: [DONE]\n\n"
    return StreamingResponse(gen(), media_type="text/event-stream")

@app.get("/healthz")
async def healthz():
    # 简单探活:取一次连接
    try:
        with read_pool.connection() as conn:
            with conn.cursor() as cur:
                cur.execute("SELECT 1")
        return {"ok": True}
    except Exception as e:
        raise HTTPException(500, str(e))

查询历史与相似问题召回:每次 explain_result 成功后,将 question、generated_sql、answer 摘要、时间戳写入 PostgresStore namespace=("sql_history", user_id);新问题进入 generate_sql 之前,增加一个 recall_similar 节点(可选),用 embedding 检索历史问题,若相似度 > 0.9 直接返回历史 SQL 与结果,跳过 LLM 调用,显著降低高频问题成本与延迟。

SQL 正确性评估:上线前构建 Golden Dataset 时,使用"执行结果等价"而非"SQL 文本相同"评估——两条 SQL 只要对同一数据库返回相同的结果集(列名、行数、数据一致),就视为等价。这种评估方法避免把语义等价但写法不同的 SQL 判为错误,更贴近业务需求。评估脚本对每条标注问题执行生成 SQL 与标注 SQL,将结果集排序后做 hash 对比。

动态 LIMIT 与分页:对于确实需要大结果集的分析任务(如导出明细),不要通过 Text2SQL 直接返回全量数据,而是检测到无 LIMIT 时除了自动追加 LIMIT 1000 外,还在回答中提示"结果已限制为前 1000 行,如需全量导出请使用报表功能";并提供"导出 CSV"子图分支,该分支使用专门的高权限只读账号与更长 statement_timeout(如 60s),且必须走 HITL 审批,防止拖垮数据库。

数据库连接泄漏防护:所有数据库操作都使用 with 上下文管理器确保连接归还;连接池配置 min_size/max_size 与 timeout,避免流量突增时无限等待连接;在 FastAPI shutdown 事件中显式调用 pool.close();监控 db_connections_in_use 指标,超过 max_size 80% 时告警。

Text2SQL 准确率提升 Checklist:字段注释必须填写业务含义与枚举值;数字字段注明单位;日期字段注明时区;常见 JOIN 关系在 schema 注释中列出;提供 3-5 条 Few-shot 示例;prompt 中明确要求"不要猜测不存在的列,若字段缺失请在 SQL 注释中说明";错误自纠正时把数据库错误原文传给模型;对复杂问题(含同比环比、分组 Top N、窗口函数)单独收集 few-shot 示例。

结果解释的质量:explain_result 不应简单复述 SQL 或表格数据,而应总结趋势、极值、异常点与业务含义(如"华东地区本月订单金额环比增长 23%,主要由电子产品品类拉动");图表建议给出字段映射(x 轴、y 轴、颜色维度),前端可直接根据建议渲染 ECharts 图表,减少二次配置。

SQL 审计与人工复核:对 WHERE 条件包含敏感范围(如金额 > 100 万、涉及用户明细)的查询,explain_result 之前可增加 await_human_review 节点,interrupt 等待数据分析师审核后再返回结果,防止数据泄露或误报;审计日志中记录所有生成 SQL 与执行时长,便于 DBA 发现并优化慢查询、推荐缺失索引。

Text2SQL 与 BI 工具集成:生成的 SQL 与结果可通过标准接口推送到 Superset、Metabase 等 BI 工具新建图表;explain_result 输出 chart_suggestion 的同时输出 chart_spec(Vega-Lite 或 ECharts option JSON),前端直接渲染;用户点击"保存到看板"时将 chart_spec 与 SQL 持久化为看板组件,实现从问答到看板的闭环。

Schema 演进处理:当数据库表结构变更(新增列、重命名列、删除列)时,schema 缓存必须立即失效;可通过 DDL 触发器或迁移脚本中的 webhook 调用系统的 /schemas/refresh 接口;加载新 schema 后,建议自动跑一遍 Golden Dataset 验证准确率无回退,避免因 schema 变化导致大量 SQL 生成错误。

对话式追问支持:单图单次查询模式的基础上,可追加 followup 节点处理用户对结果的追问(如"按省份再细分一下""只看华东地区"),此时将上一轮 generated_sql、query_result 与追问一并送入 generate_sql 做"基于上一次查询的增量修改",无需重新加载 schema;这要求 MemorySaver 升级为 PostgresSaver 以跨轮次保留 state,thread_id 改为会话维度而非单次 run 维度。

错误信息脱敏:返回给用户的错误信息不能直接暴露数据库表结构、IP 地址、堆栈信息;execute_sql 捕获到异常后,将原始错误存入 last_db_error 供 LLM 修正用,面向用户的 answer 中只出现"查询执行失败,请换一种问法"等友好提示,具体错误详情写入日志与审计库供排障。

Text2SQL 场景边界:涉及多步推理(先跑 A 查询再根据结果决定 B 查询)、跨库联合分析、图表自动化生成的复杂任务,单图线性架构可能不再适用,应升级为多 Agent 或子图协作架构;但在生产初期保持单图是更好的选择——架构复杂度应与实际准确率数据匹配,避免为罕见场景提前付出工程成本。

模型路由策略:简单查询(从 question_length、关键词、是否单表判断)使用 gpt-4o-mini,复杂查询(多表 JOIN、窗口函数、嵌套子查询)使用 gpt-4o;在 generate_sql 前增加 route_model 节点做轻量分类,分类本身用小模型或规则完成,成本可忽略;观察线上数据后动态调整简单/复杂阈值以平衡成本与准确率。