Agent 如何选择与生成 SQL —— Text2SQL 全链路详解

受众:有 Python(Django / FastAPI)后端基础 + Vue / JS 前端基础的开发者。 本文不假设你懂任何 Agent / LangGraph 黑话,所有概念都用你熟悉的后端术语类比。 目标:让你彻底明白「用户用自然语言提问 → 系统怎么拿到正确的数据库数据」这件事。


0. 先给你一句"翻译"

把整个系统翻译成你熟悉的词:

Agent 黑话 你熟悉的说法
Agent 一个「会自己决定下一步调哪个函数」的程序(普通接口是你写死的 if/else,它是让模型来选)
LangGraph 状态图 一个 while 循环 + 几个 if 判断,仅此而已
节点 Node 一个 Python 函数
Plan 节点 第一次让模型「列计划:要调哪些工具」
Execute 节点 按计划调工具(查数据库)
Reflect 节点 让模型「检查信息够不够」------本质是个 if
工具 Tool 一个 Python 函数(查库、算数都算工具)
finance_metric 一个「取预置财务指标」的工具(不碰 SQL
finance_sql_query 一个「让模型现场写 SQL 查库」的工具(这就是 Text2SQL
SSE 接口不一次性返回,而是流式一段段推给前端(打字机/进度条靠它)

1. 一句话本质

Agent 并不"知道"该用什么 SQL。它把「数据库字典(建表语句)+ 你的问题」打包发给大模型,模型用训练学到的能力把它们翻译成一条 SQL,项目再校验、执行、兜底。

"判断用什么 SQL"这个动作分两层

  1. 代码规则层(你能读懂的 Python):根据问题里的关键词,挑出"可能相关的几张表",只把这几张表的字典发给模型(避免模型看 11 张表眼花)。
  2. 模型推理层(模型黑盒):在给定字典 + 业务口径的前提下,把自然语言翻译成 SQL 字符串。

2. 整体架构:两条取数路径

同一个问题,Agent 可能走两条完全不同的路。这是理解全局的关键。

sql 复制代码
用户提问
   │
   ├─► Plan 节点:让模型从"工具清单"里挑 1~3 个工具
   │
   ├─► 路径 A:finance_metric(预置指标)
   │      模型只选 metric="overview"/"trend"/... 这种参数
   │      SQL 早就写死在 service.py 里,模型根本不碰 SQL
   │      └─► 适合:看板类、口径固定的问题("经营概览""趋势")
   │
   └─► 路径 B:finance_sql_query(Text2SQL)
          模型现场写 SQL
          └─► 适合:开放、需要临时聚合的问题("去年支出情况""各产品线毛利率")

本文聚焦路径 B------因为"Agent 怎么判断用什么 SQL"这个问题,只在这一条路径里存在。路径 A 里没有"判断 SQL"这回事。


3. 路径 B 全链路时序(以"去年支出情况"为例)

scss 复制代码
① 用户:"去年支出情况"
② guard_input()         → 防注入检查(无注入则放行)
③ Plan 节点(LLM)        → 模型输出 JSON:选 finance_sql_query,参数 question="去年支出情况"
④ Execute 节点          → 调用 _tool_finance_sql(args)
     ├─ ensure_database()        → 确保 SQLite 库已生成(首次自动 seed)
     ├─ build_schema_snippet(q)  → 【代码规则层】挑相关表,返回裁剪后的字典
     ├─ LLM.chat(FINANCE_TEXT2SQL_TMPL) → 【模型推理层】模型写出 SQL
     ├─ validate_sql()           → 护栏:必须 SELECT、只能查白名单表
     ├─ fdb.query(sql)           → 执行,拿到 rows
     └─ recommend_chart()        → 推荐图表类型(折线/柱状/饼/KPI)
⑤ Reflect 节点(LLM)     → 模型判断"信息够不够" → enough=true
⑥ Answer 节点(LLM)       → 模型读数据,组织成中文回答(带 [来源] 标注)
⑦ SSE 流式推给前端        → 前端渲染文字 + ECharts 图

下面把 ③④ 里最关键的两层"判断"拆开讲。


4. 第一层判断(代码规则层,你能看懂的 Python)

4.1 动态 Schema 裁剪:挑出相关表

文件:app/finance/agent.py

模型如果同时看到 11 张表的字典,容易写错 SQL(选错表/字段)。所以项目先用关键词打分把表裁到 3 张左右:

python 复制代码
26:41:server-python/app/finance/agent.py
TABLE_HINTS: Dict[str, List[str]] = {
    "fin_revenue": ["营收", "收入", "销售额", ...],
    "fin_cost":    ["成本", "费用", "支出", "科目", "人力", "外包", "云服务", ...],
    "fin_budget":  ["预算", "超支", "结余", ...],
    ...
}
ALWAYS_TABLES = ["dim_department", "dim_account", "dim_product", "dim_customer"]
python 复制代码
46:62:server-python/app/finance/agent.py
def pick_tables(question: str, top_k: int = 3) -> List[str]:
    q = (question or "").lower()
    for table, hints in TABLE_HINTS.items():
        if table in ALWAYS_TABLES:
            continue
        score = sum(3 if h.lower() in q else 0 for h in hints)   # 关键词命中打分
        if table.lower() in q:
            score += 10                                          # 直接点名表名更高权重
    picked = [t for t, s in scores if s > 0][:top_k]             # 取 top_k
    return picked + ALWAYS_TABLES                                # 维表恒定带上(JOIN 必需)

这是你看得懂的纯规则逻辑 :问"去年支出情况" → "支出"命中 fin_cost 的关键词 → fin_cost 高分入选;4 张维表恒定带上(因为要 JOIN 出名称)。最终发给模型的字典只有 fin_cost + 4 张维表,而不是全部 11 张。

类比 Django:这就像你写查询前先 Model.objects.filter(...),先确定查哪张表,而不是把整个数据库 dump 给某个人让他猜。

4.2 黑话归一化与歧义澄清

文件:app/finance/agent.py

python 复制代码
75:93:server-python/app/finance/agent.py
BUSINESS_TERMS = {"最近": "最近 3 个月", "本月": "最新月", ...}   # 规则化黑话
AMBIGUOUS_TERMS = {                                               # 必须反问用户的歧义
    "情况": ["营收", "成本费用", "现金流", "应收账款"],
    "成本": ["总成本费用", "营业成本 COGS", "某个科目"],
    ...
}

clarify() 会在命中歧义词且无明确限定时,返回候选口径给前端做选项。注意 :当前主图(graph.py)的 run()默认没有接入 clarify 节点 ------即 Agent 默认直接信任 Plan 阶段的拆解,把澄清能力作为"已实现但可选接入"的扩展点。如果你以后想让"情况""成本"这类模糊词先反问,把 clarify() 接进 Plan 前即可。

4.3 这一步结论

"判断查哪几张表"是代码用关键词规则做的;"判断 SQL 怎么写"才交给模型。 代码层是确定性、可单测的;模型层是概率性的。


5. 第二层判断(模型推理层,黑盒)

5.1 项目代码在这里"到此为止"

文件:app/agent/tools.py

python 复制代码
135:184:server-python/app/agent/tools.py
def _tool_finance_sql(args, trace_id):
    fdb.ensure_database()
    schema, used_tables = fagent.build_schema_snippet(question)   # 第 4 节的裁剪字典
    llm = get_llm()
    for attempt in range(2):                                      # 失败回炉最多 1 次
        prompt = FINANCE_TEXT2SQL_TMPL.format(schema=schema, question=question, limit=...)
        raw_sql = llm.chat([{"role": "user", "content": prompt}], temperature=0, ...)  # ← 判断发生在这里
        safe_sql = validate_sql(raw_sql, allowed_tables=...)      # 护栏
        data = fdb.query(safe_sql)                                # 执行

注意 :这里没有任何 if 去年: ... 的判断代码 。项目只是把"字典 + 问题"拼成 prompt 发给模型,模型返回一段 SQL 字符串。真正的"判断"全在 llm.chat() 内部。

5.2 模型内部怎么"判断"(以"去年支出情况"为例)

模型接到的是这段输入(FINANCE_TEXT2SQL_TMPL,见 llm/prompts.py):

sql 复制代码
你是 SQLite 财务数据分析专家。根据表结构把用户问题转成只读 SQL。
【表结构】
CREATE TABLE fin_cost (month TEXT, dept_code, account_code, amount REAL, vendor);
CREATE TABLE dim_account (account_code, account_name, category, ...);
...(维表)

【财务口径】
- 金额单位为元;month 形如 '2026-08-01'
- 营业成本 = 科目 6004+6101+6102+6401
- 展示科目名称必须 JOIN dim_account
- 同比用 date(month,'-12 month') ...

【用户问题】去年支出情况

模型(靠训练中学到的"语言↔SQL"能力)在内部做几步推理:

  1. 意图识别 :"支出情况" → 要统计费用总额及分布 → 对应 fin_cost.amount
  2. 字段映射 :字典里 fin_cost.amount 就是支出金额;要显示科目名称 → 需 JOIN dim_account
  3. 时间映射 :"去年" + 当前 2026 年 → 去年 = 2025 → month LIKE '2025%'(因为 month 是 'YYYY-MM-01' 文本)
  4. 组装 SQL
sql 复制代码
SELECT a.account_name, SUM(c.amount) AS total
FROM fin_cost c
JOIN dim_account a ON a.account_code = c.account_code
WHERE c.month LIKE '2025%'
GROUP BY a.account_name
ORDER BY total DESC
LIMIT 200;

这是概率性推理,不是 if/else。 模型是"猜"出最可能的 SQL,不是按代码分支算出来的。所以它可能写错(比如漏了 JOIN、拼错字段),才需要下面的护栏。

5.3 提示词模板:项目"教"模型怎么判断的地方

文件:app/llm/prompts.py

python 复制代码
94:124:server-python/app/llm/prompts.py
FINANCE_TEXT2SQL_TMPL = """你是 SQLite 财务数据分析专家。根据表结构把用户问题转成只读 SQL。
【表结构】{schema}
【财务口径】1. 金额单位为元;month 形如 '2026-08-01';2. 营业成本=科目 6004+6101+6102+6401;
3. 毛利率=(营收-营业成本)/营收;... 7. 应收口径区分未清/逾期...
【约束】1. 只输出一条 SELECT;2. 禁止 INSERT/UPDATE/DELETE/DROP;3. 必须带 LIMIT;...
【示例】问题:2026-08 各产品线营收及占比 → SQL:SELECT ... JOIN dim_product ... """

模板里那一大段【财务口径】和【示例】,就是项目在"指导"模型的判断------把业务规则(什么是营业成本、毛利怎么算)写成文字塞给模型,让它照着翻译。


6. 护栏:validate_sql(防模型写崩)

模型生成的 SQL 不能直接跑。文件:app/agent/guardrails.py(在 tools.py 中以 validate_sql 导入)。它至少检查:

  • 只能是 SELECT(拒绝 INSERT/UPDATE/DELETE/DROP/ATTACH/PRAGMA
  • 只能查白名单里的表(settings.finance_sql_allowed_tables
  • 其它安全约束
python 复制代码
164:170:server-python/app/agent/tools.py
try:
    safe_sql = validate_sql(raw_sql, allowed_tables=settings.finance_sql_allowed_tables)
except GuardrailError as e:
    last_err = str(e)
    continue          # 报错 → 进入下一轮 attempt(回炉)

失败回炉(最多 2 次)

python 复制代码
153:184:server-python/app/agent/tools.py
for attempt in range(2):                       # 生成→校验→执行→失败回炉修正一次
    raw_sql = llm.chat(...)
    try:
        safe_sql = validate_sql(raw_sql, ...)
    except GuardrailError as e:
        last_err = str(e); fagent.record(sql_fail=1); continue   # 带错误再问一次模型
    try:
        data = fdb.query(safe_sql)
        fagent.record(sql_ok=1)
        return ToolResult(True, output={"sql": safe_sql, **data}, meta={...})
    except Exception as e:
        last_err = f"{type(e).__name__}: {e}"   # SQL 语法/运行错误也回炉
return ToolResult(False, error=f"财务 SQL 生成/执行失败:{last_err}")

第一次生成的 SQL 若被拒或执行报错,会把具体错误信息 拼回 prompt 再问模型一次(attempt 第 2 轮)。这就像你写 SQL 报错了,IDE 提示你哪错了,你改了重跑。


7. 字典长什么样(模型看到的全部家底)

运行 python -c "from app.finance import db; print(db.schema_text())" 得到的,就是模型每次写 SQL 前看到的字符串------就是 11 张表的 CREATE TABLE 原文(此处节选关键两张):

sql 复制代码
CREATE TABLE fin_cost (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    month TEXT NOT NULL,
    dept_code TEXT NOT NULL,
    account_code TEXT NOT NULL,
    amount REAL NOT NULL,
    vendor TEXT
);
CREATE TABLE fin_revenue (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    month TEXT NOT NULL,
    dept_code TEXT NOT NULL,
    product_code TEXT NOT NULL,
    customer_name TEXT NOT NULL,
    region TEXT NOT NULL,
    revenue_type TEXT NOT NULL,
    amount REAL NOT NULL,
    currency TEXT NOT NULL DEFAULT 'CNY'
);

模型写 SQL 时,眼里就只有这些字段名。它不"懂"财务,只是认字典里的列名,把你的中文词映射成对应列。


8. 拿到数据之后:图表推荐与结果校验

8.1 图表推荐(决定前端画什么图)

文件:app/finance/agent.pyrecommend_chart()

  • 单列单值 → KPI 卡
  • 有"time 列 + 数值列" → 折线图
  • 有"分类 + 数值" → 类别少则饼图、类别多则柱状图
  • 两个维度 → 分组柱状图

这保证模型即使走"临时 SQL"路径,前端也能自动出图,体验与"预置指标路径"一致。

8.2 结果校验(warning 不阻断)

validate_result() 检查:空结果、行数触顶、金额负值、量级异常(>100 亿)------产出人类可读的 warning 回给用户,但不阻断回答。

8.3 语义缓存

normalize_question() 把黑话归一化后做缓存 key(如"最近"→"最近3个月")。同义问题直接复用 SQL+结果,降本降延迟。TTL 600s,最多 200 条。


9. 完整演练:"去年支出情况"逐帧

阶段 谁在做 具体动作
输入 用户 "去年支出情况"
防注入 guard_input 无注入特征,放行
Plan 模型 读工具清单 → 输出 {"steps":[{"tool":"finance_sql_query","args":{"question":"去年支出情况"}}]}
选表(规则) pick_tables "支出"命中 fin_cost → 选 fin_cost+4 维表
写 SQL 模型 FINANCE_TEXT2SQL_TMPL+裁剪字典 → SELECT a.account_name, SUM(c.amount)... WHERE c.month LIKE '2025%' GROUP BY a.account_name
护栏 validate_sql SELECT + 表在白名单 → 通过
执行 fdb.query 跑 SQL,返回 rows
推荐图 recommend_chart 分类+数值 → 柱状图(或按月的折线图)
Reflect 模型 看 observations → enough=true
Answer 模型 读数据 → 组织中文:"去年(2025)总支出约 X 万元,其中人力成本占比最高..." 带 来源
推送 SSE 文字流式 + 图表配置推给前端渲染

10. 类比 Django:帮你建立直觉

你写 Django 时,查询是确定性的:

python 复制代码
FinCost.objects.filter(month__startswith="2025").values("account_code").annotate(total=Sum("amount"))

ORM 把你的 Python 表达式编译成 SQL,规则是死的。

Text2SQL 相当于:你不让 ORM 编译,而是把"表结构"和"用户的话"丢给一个懂 SQL 的大模型,让它现场当一回"临时 ORM 构建器"。 好处是用户能用自然语言问任意问题;代价是它"猜"出来的 SQL 不一定对,所以要护栏+重试+校验兜底。

结论:判断 SQL 的"智能"在模型,不在代码;代码负责"提供字典、收 SQL、加护栏、兜底"。


11. 常见误解

误解 真相
"代码里某个 if 判断了去年=2025" 没有。那是模型推理出来的,代码里无此规则
"Agent 自动连数据库写任意 SQL" 否。只允许 SELECT + 白名单表(validate_sql
"finance_metric 也在写 SQL" 否。它只选 metric 参数,SQL 写死在 service.py
"模型看的是 Python 代码" 否。模型只看 schema_text() 产出的建表语句文本
"判断一步到位" 否。execute→reflect→execute 可能循环多轮补数据

12. 相关文件索引(带路径与职责)

文件 职责
app/agent/graph.py Plan→Execute→Reflect→Answer 状态图编排
app/agent/tools.py 工具注册表;_tool_finance_sql(Text2SQL)、_tool_finance_metric(预置指标)
app/agent/guardrails.py validate_sql 护栏(SELECT 校验、白名单表)
app/finance/agent.py 问数治理层pick_tables 选表、clarify 澄清、recommend_chart 图表、validate_result 校验、语义缓存、可观测
app/finance/db.py 数据库访问层:schema_text() 产出字典、query()/scalar() 执行、只读连接
app/finance/service.py 预置指标 SQL(写死的),供 finance_metric 调用
app/finance/seed.py 虚构财务数据库生成器(建表 + 造数据),SCHEMA_SQL 即字典来源
app/llm/prompts.py 所有提示词模板:PLANNER/REFLECT/ANSWER/TEXT2SQL/FINANCE_TEXT2SQL
app/config.py finance_db_pathfinance_sql_allowed_tablessql_row_limit 等配置

13. 一句话收尾

Agent 判断 SQL = 代码先用关键词规则挑出相关表(你能读懂的 Python),再把"建表字典 + 业务口径 + 你的问题"交给大模型翻译成 SQL,最后用护栏+重试兜底。 判断的"智能"在模型,流程的"安全"在代码。

相关推荐
苍何1 小时前
把操作流程直接录成 Skill,豆包太夯了!
后端
Go_error1 小时前
在 Go 服务层中如何优雅地管理事务?
后端·go
爱读源码的大都督2 小时前
DeepSeek面试官问:多租户 RAG 系统怎样实现细粒度权限控制?
后端·面试·架构
苍何2 小时前
我们终于成立了 AgentWork 开源社区,16.4 万字豆包工作蓝皮书同步开源(建议收藏)
后端
苍何2 小时前
用 AI 做短剧出海,赚麻了!(附 Skill 及教程)
后端
积硅步致千里2 小时前
Fyne 兼容性:报错还能救,透明窗才要命
前端·后端
苍何2 小时前
国产大模型竟然干过了 Claude!!
后端
苍何2 小时前
多Agent团队都搭好了,怎么生意还是我一个人在做?
后端