06 · 工具调用:Text2SQL 与四层护栏
英文标题:06 · 工具调用:Text2SQL 与四层护栏
本文介绍以 Schema 注册表统一管理 Agent 工具的方式,使新增工具仅需修改注册表,编排层无需改动。当前系统注册了 5 个工具,并通过 AST 白名单实现安全计算,避免使用 `eval`。Text2SQL 流程采用“生成、校验、执行、报错回炉”的闭环,结合 SQL 多语句、读写关键字、表白名单和 LIMIT 等四层护栏降低风险。对于高风险工具,系统通过 Human-in-the-loop 人工确认机制控制权限边界,并补充提示词注入检测、PII 脱敏与全链路可观测能力。
8.9 KB
中文
对应代码:
app/agent/tools.py、app/agent/guardrails.py
一、工具即 Schema
Tool(
name="sql_query",
description="BI 数据查询(Text2SQL):项目成本、缺陷趋势、交付效率等指标统计。只读。",
parameters={"type": "object", "properties": {
"question": {"type": "string", "description": "要查询的业务问题,中文描述即可"}},
"required": ["question"]},
fn=_tool_sql_query,
risk="low",
)
注册表对外提供三项能力:
registry.specs()
registry.describe_for_prompt()
registry.execute(name, args, trace_id)
收益:新增工具只需修改一处,编排层无需改动。
二、当前已注册的 5 个工具
| 工具 | 风险 | 说明 |
|---|---|---|
search_knowledge |
low | 知识库检索(RAG) |
sql_query |
low | BI 数据 Text2SQL |
send_alert |
high | 推送告警(需人工确认) |
calculator |
low | 安全四则运算 |
get_today |
low | 解析“本月/上周”等相对时间 |
calculator 使用 AST 白名单实现(绝不使用 eval):
_ALLOWED_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ...}
def _safe_eval(expr):
return _eval(ast.parse(expr, mode="eval"))
三、Text2SQL:生成 → 校验 → 执行 → 报错回炉
for attempt in range(2):
prompt = TEXT2SQL_TMPL.format(schema=schema, question=question, limit=row_limit)
if last_err:
prompt += f"\n上一次生成的 SQL 报错:{last_err}\n请修正后只输出 SQL。"
raw_sql = llm.chat([{"role": "user", "content": prompt}], temperature=0)
try:
safe_sql = validate_sql(raw_sql)
return execute(safe_sql)
except GuardrailError as e:
last_err = str(e)
踩坑记录:第一版没有将数据库报错回传给模型,导致模型一旦生成错误的 SQL 字段名就会持续失败;加入“报错回炉”后,一次修正成功率显著提升。
Prompt 中的关键约束:
1. 只能输出一条 SELECT,不要解释、不要 markdown 代码块
2. 禁止 INSERT/UPDATE/DELETE/DROP/ATTACH/PRAGMA
3. 必须带 LIMIT 200
4. 日期字段为 TEXT(YYYY-MM-DD),本月使用 date('now','start of month')
5. 只能使用给定的表和字段
+ 一个少样本示例
四、四层 SQL 护栏
def validate_sql(sql, allowed_tables, row_limit, readonly=True):
clean = strip_sql_comments(raw)
if _MULTI_STMT.search(clean): raise GuardrailError("检测到多语句执行")
if readonly and not low.startswith(("select", "with")): raise ...
for kw in ("insert","update","delete","drop","attach","pragma",...):
if re.search(rf"\b{kw}\b", low): raise ...
tables = set(_TABLE_RE.findall(clean))
if illegal := tables - allowed: raise GuardrailError(f"引用了未授权的表:{illegal}")
if not re.search(r"\blimit\s+\d+", low): clean += f" LIMIT {row_limit}"
再增加一层数据库侧保护:只读连接。
con = sqlite3.connect(f"file:{db_path}?mode=ro")
(生产环境改用只读账号。)
实测拦截效果:
| 输入 | 结果 |
|---|---|
| “删掉成本表” | ✅ 被关键字黑名单拦截 |
| “SELECT * FROM secret_salary” | ✅ 被表白名单拦截 |
| “SELECT ...; DROP TABLE x” | ✅ 被多语句检测拦截 |
五、Human-in-the-loop
if tool.risk == "high" and settings.hitl_enabled and not auto_approve_high_risk:
return ToolResult(False, error="HITL_REQUIRED", meta={"need_human": True})
前端收到 hitl 事件 → 渲染橙色确认卡片 → 用户点击“确认执行” → 携带 approved=true 重新请求 → 后端从 _pending[thread_id] 中取出待执行操作并执行。
这就是 JD 中所说的“权限边界”。
六、另外两个护栏
提示词注入检测
INJECTION_PATTERNS = [
r"忽略(?:以上|上面|之前).{0,6}(?:指令|规则|提示)",
r"ignore\s+(?:all\s+)?previous\s+instructions",
r"输出(?:你的|系统).{0,4}(?:提示词|prompt)",
...
]
命中后不会中断服务,而是降级为“只读检索模式”,并发送一条 warn 事件——粗暴拒绝会损害正常用户体验。
PII 脱敏(出网前)
_PII_RULES = [
(re.compile(r"1[3-9]\d{9}"), "[PHONE]"),
(re.compile(r"\b\d{17}[\dXx]\b"), "[ID_CARD]"),
(re.compile(r"\b\d{16,19}\b"), "[CARD]"),
(re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+"), "[EMAIL]"),
(re.compile(r"(?:sk-|api[_-]?key\s*[:=]\s*)[A-Za-z0-9_\-]{16,}"), "[SECRET]"),
]
为什么必须做:企业场景中的用户可能会随手粘贴客户手机号、身份证号。一旦这些信息被发送至第三方模型,就可能构成合规事故。
七、工具执行的可观测性
每次工具调用都会被 Tracer 封装为一个 Span:
with tracer.span(trace_id, f"tool.{name}", args=args, risk=tool.risk):
result = tool.fn(args, trace_id)
系统还会向前端推送 tool_start / tool_end 事件,因此可以在执行链路时间线上看到每一步工具调用的参数、耗时和结果预览。
下一篇:07 · 记忆、可观测与成本控制
English / 原文
对应代码:
app/agent/tools.py、app/agent/guardrails.py
一、工具即 Schema
Tool(
name="sql_query",
description="BI 数据查询(Text2SQL):项目成本、缺陷趋势、交付效率等指标统计。只读。",
parameters={"type": "object", "properties": {
"question": {"type": "string", "description": "要查询的业务问题,中文描述即可"}},
"required": ["question"]},
fn=_tool_sql_query,
risk="low",
)
注册表对外提供三个能力:
registry.specs()
registry.describe_for_prompt()
registry.execute(name, args, trace_id)
收益:新增工具只改一处,编排层零改动。
二、目前注册的 5 个工具
| 工具 | 风险 | 说明 |
|---|---|---|
search_knowledge |
low | 知识库检索(RAG) |
sql_query |
low | BI 数据 Text2SQL |
send_alert |
high | 推送告警(需人工确认) |
calculator |
low | 安全四则运算 |
get_today |
low | 解析"本月/上周"相对时间 |
calculator 用 AST 白名单实现(绝不用 eval):
_ALLOWED_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ...}
def _safe_eval(expr):
return _eval(ast.parse(expr, mode="eval"))
三、Text2SQL:生成 → 校验 → 执行 → 报错回炉
for attempt in range(2):
prompt = TEXT2SQL_TMPL.format(schema=schema, question=question, limit=row_limit)
if last_err:
prompt += f"\n上一次生成的 SQL 报错:{last_err}\n请修正后只输出 SQL。"
raw_sql = llm.chat([{"role": "user", "content": prompt}], temperature=0)
try:
safe_sql = validate_sql(raw_sql)
return execute(safe_sql)
except GuardrailError as e:
last_err = str(e)
踩坑记录 : 第一版没有把数据库报错回传,模型生成的 SQL 字段名写错就永远失败; 加上"报错回炉"后,一次修正成功率显著提升。
Prompt 里的关键约束:
1. 只能输出一条 SELECT,不要解释、不要 markdown 代码块
2. 禁止 INSERT/UPDATE/DELETE/DROP/ATTACH/PRAGMA
3. 必须带 LIMIT 200
4. 日期字段为 TEXT(YYYY-MM-DD),本月用 date('now','start of month')
5. 只能用给定的表和字段
+ 一个少样本示例
四、四层 SQL 护栏
def validate_sql(sql, allowed_tables, row_limit, readonly=True):
clean = strip_sql_comments(raw)
if _MULTI_STMT.search(clean): raise GuardrailError("检测到多语句执行")
if readonly and not low.startswith(("select", "with")): raise ...
for kw in ("insert","update","delete","drop","attach","pragma",...):
if re.search(rf"\b{kw}\b", low): raise ...
tables = set(_TABLE_RE.findall(clean))
if illegal := tables - allowed: raise GuardrailError(f"引用了未授权的表:{illegal}")
if not re.search(r"\blimit\s+\d+", low): clean += f" LIMIT {row_limit}"
再加一层数据库侧保护:只读连接
con = sqlite3.connect(f"file:{db_path}?mode=ro")
(生产换成只读账号)
实测拦截效果 :
| 输入 | 结果 |
|---|---|
| "删掉成本表" | ✅ 被关键字黑名单拦截 |
| "SELECT * FROM secret_salary" | ✅ 被表白名单拦截 |
| "SELECT ...; DROP TABLE x" | ✅ 被多语句检测拦截 |
五、Human-in-the-loop
if tool.risk == "high" and settings.hitl_enabled and not auto_approve_high_risk:
return ToolResult(False, error="HITL_REQUIRED", meta={"need_human": True})
前端收到 hitl 事件 → 渲染橙色确认卡 → 用户点「确认执行」→ 带 approved=true 重新请求 → 后端从 _pending[thread_id] 取出并执行。
这就是 JD 里说的"权限边界"。
六、另外两个护栏
提示词注入检测
INJECTION_PATTERNS = [
r"忽略(?:以上|上面|之前).{0,6}(?:指令|规则|提示)",
r"ignore\s+(?:all\s+)?previous\s+instructions",
r"输出(?:你的|系统).{0,4}(?:提示词|prompt)",
...
]
命中后不中断服务 ,而是降级为"只读检索模式"并发一条 warn 事件—— 粗暴拒绝会伤害正常用户体验。
PII 脱敏(出网前)
_PII_RULES = [
(re.compile(r"1[3-9]\d{9}"), "[PHONE]"),
(re.compile(r"\b\d{17}[\dXx]\b"), "[ID_CARD]"),
(re.compile(r"\b\d{16,19}\b"), "[CARD]"),
(re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+"), "[EMAIL]"),
(re.compile(r"(?:sk-|api[_-]?key\s*[:=]\s*)[A-Za-z0-9_\-]{16,}"), "[SECRET]"),
]
为什么必须做 :企业场景用户会随手粘贴客户手机号、身份证号。 一旦发到第三方模型,就是合规事故。
七、工具执行的可观测
每个工具调用都会被 Tracer 包成一个 Span:
with tracer.span(trace_id, f"tool.{name}", args=args, risk=tool.risk):
result = tool.fn(args, trace_id)
同时推送 tool_start / tool_end 事件给前端, 所以你能在执行链路时间线上看到每一步工具调用的参数、耗时和结果预览 。
下一篇:07 · 记忆、可观测与成本控制