This commit is contained in:
2026-09-22 15:42:25 +08:00
commit 8e86665096
60 changed files with 22339 additions and 0 deletions
@@ -0,0 +1,189 @@
# 阶段六 · 综合实战:数据分析 Text-to-SQL Agent(离线可运行)
> 所属:阶段六 垂直领域 Agent 落地实践
> 定位:把阶段六第 2 小点「数据分析 Agent」的 Text-to-SQL 五道防线,落成一份**离线可运行**的完整 Demo——让自然语言问「公司营收」,Agent 转成 SQL、过五道防线、执行、产出图表。
## 一句话看懂本 Demo
`data_analysis_agent.py` 用纯 Python 标准库实现一个最小可用的数据分析 Agent:
```mermaid
graph TD
A["阶段六综合实战 data_analysis_agent.py"] --> B[Part1 迷你只读数据库<br/>第一道防线: 只读入口]
A --> C[Part2 Schema Prompt + 语义层<br/>让 Agent 知道表结构与口径]
A --> D[Part3 五道防线<br/>只读/白名单/校验/自修正]
A --> E[Part4 Text-to-SQL 闭环<br/>自然语言→SQL→结果→图表]
```
> 核心一句话:**数据分析 Agent 的价值 = 问得动 + 答得准 + 破坏不了数据**。这份 Demo 重点演示「只读 + 校验」这套让数据不被 Agent 弄坏的核心防线。
## 运行方式
```
python3 code/阶段六/data_analysis_agent.py
```
不需要 API Key、不需要外网、不需要装任何第三方库(只依赖标准库)。
## 完整代码位置
[code/阶段六/data_analysis_agent.py](../../code/阶段六/data_analysis_agent.py)
---
## 每个 Part 在讲什么
### Part 1 · 迷你"只读数据库"(对应第一道防线)
真实项目里给 Agent 一个**只 SELECT 权限的只读账号**——即使模型被恶意注入骗去生成 DELETE,数据库层面也执行不了。这里用内存表模拟,只暴露 `execute_read()` 一个入口,想 DELETE 都没有接口:
```python
def execute_read(query: str):
"""迷你"只读数据库": 只支持 SELECT 子集。真实生产 = 只读账号。”"""
m = re.search(r"region\s*=\s*'([^']+)'", query)
rows = ORDERS if not m else [r for r in ORDERS if r["region"] == m.group(1)]
if re.search(r"SUM\(amount\)", query, re.I):
return [{"revenue": round(sum(r["amount"] for r in rows), 2)}]
return rows
```
> 🔬 **深度理解 · 只读账号(最小权限根防线)**:
> 1. **本质**:在数据库权限层就封死「写」,让安全不依赖模型「生成的 SQL 一定没错」——即使被注入诱导生成 DELETE,也根本没有执行它的权限。
> 2. **机制**:真实生产给 Agent 一个只授 SELECT 的账号(或只读副本 / 只读角色),从账号、连接、schema 三层把 DML/DDL 全部挡掉;本 Demo 用内存表只暴露 `execute_read()` 一个入口,模拟同样的「想 DELETE 也没有接口」。因为「只读」在系统边界而非 prompt 层,它是**不可能被 prompt 突破**的硬约束。
> 3. **为什么重要**:prompt 约束可被误导绕过,而权限是可达性的硬限制。它解决「模型作恶 / 被诱导做坏的最底层兜底」,边界在——只读保护了「不改」但保护不了「查错」或「耗时拖垮库」,所以还要配白名单、结果校验、资源限制。
> 4. **易错点**:给 Agent 一套「读写账号」再靠 prompt 硬约束「别写」;或用「连接直接拼用户输入」绕过参数化让注入直通;以及把只读做在业务代码层(可被绕过)而不是数据库账号层。
> ⏳ **短期可不深究**:`execute_read()` 用 `re.search` 正则去「模拟」数据库解析(`region=`、`SUM(amount)` 匹配)只是**演示用的最小实现**,不是真实 SQL 解析——第一遍看懂「它只放行只读查询」这个设计意图即可,写真实系统时直接接 PostgreSQL + 只读账号,别纠结正则怎么匹配。
运行输出里 `ORDERS` 是 7 条订单,跨北京 / 上海 / 广州、Q1 / Q2、paid / refunded / pending 三种状态。
### Part 2 · Schema Prompt + 语义层(对应第一杠杆)
Text-to-SQL 的第一步不是让模型"自由发挥",而是**先告诉它数据库长什么样、口径是什么**:
```python
SCHEMA_PROMPT = """数据库表 orders:
id int, amount decimal(单位:万元), region text(北京/上海/广州),
status text(paid/refunded/pending), quarter text(Q1/Q2)
业务口径: "销售额" = SUM(amount) 且 status='paid'
【约束】只允许 SELECT / WITH 开头。"""
```
再用**语义层**把业务概念固化为口径,避免三个用户问出三种不同的"营收":
```python
SEMANTIC_LAYER = OrderedDict([
("营收", "SELECT SUM(amount) FROM orders WHERE status='paid'"),
("退款笔数", "SELECT COUNT(*) FROM orders WHERE status='refunded'"),
("北京单数", "SELECT COUNT(*) FROM orders WHERE region='北京'"),
])
```
> ⏳ **短期可不深究**:这里 `SEMANTIC_LAYER` 用字典**硬编码 3 条口径**只是教学演示——生产会用独立「语义层服务 / 指标仓库」统一维护全部门口径(带版本、权限、可审计)。第一遍带走「把业务口径固化成可复用定义」这个思路即可,不用纠结这里的映射实现。
### Part 3 · 五道防线(核心)
| 防线 | 做什么 | 代码层实现 |
|-|-|-|
| ① 只读账号 | 数据库层限权 | Part 1 的 `execute_read` 单入口 |
| ② SQL 白名单 | 只放 SELECT / WITH | `ALLOWED_PREFIX` 校验 |
| ③ 参数化 | 防止拼接注入 | 入参走参数 |
| ④ 结果校验 | 空结果不强行编造 | `empty` 分支「勿编造」 |
| ⑤ 报错自修正 | SQL 出错让模型改后重试 | `MAX_RETRY` 限 2 次 |
```python
def defend_and_execute(question, retries=0):
sql = _gen_sql(question)
if not sql.strip().upper().startswith(ALLOWED_PREFIX): # ② 白名单
return {"status": "rejected", "reason": "仅为只读查询"}
result = execute_read(sql)
if not result: # ④ 结果校验
return {"status": "empty", "message": "查询为空, 请核实条件后再答, 勿编造"}
if exception:
return defend_and_execute(question, retries + 1) # ⑤ 自修正
return {"status": "ok", "data": result}
```
> 🔬 **深度理解 · 报错自修正(Self-Correction Loop)**:
> 1. **本质**:把数据库的报错当「最有信息量的修正信号」——不是让模型凭空重猜,而是把 `syntax error at ... near '...'` 回传,让它基于真实报错改 SQL,形成「生成 → 执行失败 → 依据报错修正 → 再执行」的闭环。
> 2. **机制**:执行异常时捕获报错文本,连同原问题一起喂回 `_gen_sql`,重试并计数;用 `MAX_RETRY`(本 Demo 限 2 次)防止无限自改——即「有限次的重试 + 明确的终止条件」。本质是把不可控的失败重试变成有上限、可观测、回传高信息量错误。
> 3. **为什么重要**:第一遍生成 SQL 很难一次全对(表名、字段、语法),报错文本提供了「离正确答案最近的一步」;同时空结果、注入、异常都用结构化状态(ok / empty / rejected)分流,避免「硬答一个数」。
> 4. **易错点**:重试无上限导致死循环 / 成本失控;把「报错只给用户看」而不回传模型,浪费了最好的修正信号;以及重试时用同一份原始输入、帖子积累的错误上下文,导致反复错在同一点——应把「上次报错」作为修正输入。
### Part 4 · Text-to-SQL 闭环(串起来)
把上面串成完整流程:自然语言 → 命中语义层 / 让模型生成 SQL → 白名单校验 → 只读执行 → 空结果拦截 → 图表输出。
运行输出能看到:
- 语义层命中:`公司营收是多少 → SQL: SELECT SUM(amount) ...`
- 五道防线:`[营收] ok → [{'revenue': 1730.5}]`
- 注入拦截:`[被注入生成 DELETE] rejected`
- 空结果拦截:`empty`
---
## 运行结果预览
```
>>> Schema Prompt(Text-to-SQL 第一杠杆)
数据库表 orders: ... 业务口径: "销售额" = SUM(amount) 且 status='paid'
>>> 语义层命中
公司营收是多少 → SQL: SELECT SUM(amount) FROM orders WHERE status='paid'
退款笔数 → SELECT COUNT(*) FROM orders WHERE status='refunded'
>>> 五道防线演示
[营收] ok → [{'revenue': 1730.5}]
[被注入生成 DELETE] {'status': 'rejected', 'reason': '仅为只读查询'}
[空结果] {'status': 'empty', 'message': '查询为空, 请核实条件后再答'}
>>> 可视化扩展
已生成 bar 图, 数据点 1 条: [{'revenue': 1730.5}]
```
## 换真实模型 / 真实数据库只需改 4 点
| 教学实现 | 生产替换 |
|-|-|
| `_gen_sql()` 用规则兜底 | 换成 LLM + Schema Prompt 生成 SQL |
| `execute_read()` 内存表 | 换成 PostgreSQL(给 Agent 只读账号) |
| 语义层硬编码 3 条口径 | 换成语义层服务(或指标仓库)维护全部门口径 |
| bar 图字符串示意 | 换成 ECharts / Plotly 前端渲染 |
## 本节自检
- [ ] 能说清数据分析 Agent 的"第一杠杆"是 Schema Prompt + 语义层
- [ ] 能默写五道防线,并解释为什么"只读账号"是根防线
- [ ] 已跑通一次「自然语言 → SQL → 校验执行 → 结果」闭环
- [ ] 知道空结果要"如实说空、勿编造",而不是强行圆一个数
## 本节常见面试题(深度解析)
> 针对本节核心知识的面试高频点,配合"本质+机制"式理解,能让你答得既有深度又有广度。
### 面试题 1:这个 Demo 为什么先做「只读数据库」而不是先让 SQL 生成更准?
- **面试官想考察**:是否理解数据分析 Agent「破坏得了数据」是比「问得准」更底层、更必须先解决的安全问题。
- **专业作答(含深度)**:
1. 数据分析 Agent 的定位是「问得动 + 答得准 + 破坏不了数据」三件事,顺序上**安全 > 准确**——生成 SQL 再准,一旦能改/删数据就是业务事故。
2. 所以本 Demo 先用只读账号(Part 1)把「写」从权限层封死,实现它无法破坏数据,再谈 Schema/语义层把「答」做准。
3. 安全深度上:只读是系统边界层的硬约束,白名单/参数化/结果校验/自修正是代码层约束——前者不可被 prompt 绕过,后者可被注入影响,形成纵深。
4. 这也解释了为什么「只读账号被称根防线」:它是所有后续校验之上的兜底。
- **加分亮点 / 深度追问**:可补充「只读保护不了『查错』『拖慢库』」,因此生产还要结果校验 + 超时 + 资源限制,说明安全意识不满足于只读一层。
### 面试题 2:为什么"第一杠杆"是 Schema Prompt + 语义层,而不是换更强的模型?
- **面试官想考察**:是否理解「哪种手段对 Text-to-SQL 准确率提升最杠杆」的工程判断。
- **专业作答(含深度)**:
1. 模型再强,若不知道表结构、字段含义、口径单位,也只能「猜」——信息缺失不是推理能力问题。
2. Schema Prompt 给出「说明书」(表、字段、类型、注释、示例行、业务口径),把「不知道的信息」变成「知道的信息」,提升幅度通常显著高于堆模型能力。
3. 语义层再把「营收=什么」的**口径问题**从「每次让模型临时定义」固化成「定义一次、处处复用」,从根上消灭「三个用户问出三种营收」。
4. 因此先喂准元数据 + 固化口径 = 第一杠杆;换模型是锦上添花而非解决「信息不对齐」。
- **加分亮点 / 深度追问**:可补充「元数据还可以动态挑选相关表,只喂问题相关的 Schema」,体现对「更大库怎么控制 prompt 长度」的理解。
### 面试题 3:报错自修正为什么限重试 2 次?它是如何「以更小的成本提高一次到位率」的?
- **面试官想考察**:是否理解「有限重试 + 高信息量错误回传」作为闭环控制的手段,以及失控边界的设置。
- **专业作答(含深度)**:
1. 第一遍生成 SQL 经常因表名/字段/语法出错,而数据库报错给出了离正确答案最近的信息——把它**回传模型**作为修正输入正确率通常明显上升,比盲改高效。
2. 但不能无限重试:模型可能在某一类错误上反复打转,无限重试 = 死循环 + token 成本失控,所以要设上限(本 Demo 2 次)。
3. 本质是「有限次尝试 + 结构化状态(ok / empty / rejected)+ 明确终止」,失败后如实交还状态而非硬编一个数。
4. 这正是「以固定小成本换取显著提高一次通过率」的工程权衡。
- **加分亮点 / 深度追问**:可追问「如果 2 次还错怎么办」——答「如实返回失败并建议换问法或转人工」,呼应本文「空结果/失败勿编造」的诚实原则。