受众:有 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"这个动作分两层:
- 代码规则层(你能读懂的 Python):根据问题里的关键词,挑出"可能相关的几张表",只把这几张表的字典发给模型(避免模型看 11 张表眼花)。
- 模型推理层(模型黑盒):在给定字典 + 业务口径的前提下,把自然语言翻译成 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"能力)在内部做几步推理:
- 意图识别 :"支出情况" → 要统计费用总额及分布 → 对应
fin_cost.amount - 字段映射 :字典里
fin_cost.amount就是支出金额;要显示科目名称 → 需JOIN dim_account - 时间映射 :"去年" + 当前 2026 年 → 去年 = 2025 →
month LIKE '2025%'(因为 month 是'YYYY-MM-01'文本) - 组装 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.py 的 recommend_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_path、finance_sql_allowed_tables、sql_row_limit 等配置 |
13. 一句话收尾
Agent 判断 SQL = 代码先用关键词规则挑出相关表(你能读懂的 Python),再把"建表字典 + 业务口径 + 你的问题"交给大模型翻译成 SQL,最后用护栏+重试兜底。 判断的"智能"在模型,流程的"安全"在代码。