对应代码:
app/agent/tools.py、app/agent/guardrails.py
一、工具即 Schema
python
Tool(
name="sql_query",
description="BI 数据查询(Text2SQL):项目成本、缺陷趋势、交付效率等指标统计。只读。",
parameters={"type": "object", "properties": {
"question": {"type": "string", "description": "要查询的业务问题,中文描述即可"}},
"required": ["question"]},
fn=_tool_sql_query,
risk="low", # low | high
)
注册表对外提供三个能力:
python
registry.specs() # JSON Schema 清单(给 API / 前端)
registry.describe_for_prompt() # 自然语言清单(自动塞进 Plan 的 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):
python
_ALLOWED_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ...}
def _safe_eval(expr):
return _eval(ast.parse(expr, mode="eval"))
三、Text2SQL:生成 → 校验 → 执行 → 报错回炉
python
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 里的关键约束:
sql
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 护栏
python
def validate_sql(sql, allowed_tables, row_limit, readonly=True):
# ① 清洗:去 markdown 代码块、去注释
clean = strip_sql_comments(raw)
# ② 语法层:禁多语句
if _MULTI_STMT.search(clean): raise GuardrailError("检测到多语句执行")
# ③ 关键字层:必须以 SELECT/WITH 开头
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 ...
# ④ 权限层:正则提取 from/join 后的表名,必须在白名单
tables = set(_TABLE_RE.findall(clean))
if illegal := tables - allowed: raise GuardrailError(f"引用了未授权的表:{illegal}")
# ⑤ 兜底:没 LIMIT 自动补
if not re.search(r"\blimit\s+\d+", low): clean += f" LIMIT {row_limit}"
再加一层数据库侧保护:只读连接
python
con = sqlite3.connect(f"file:{db_path}?mode=ro") # SQLite 只读模式
(生产换成只读账号)
实测拦截效果:
| 输入 | 结果 |
|---|---|
| "删掉成本表" | ✅ 被关键字黑名单拦截 |
| "SELECT * FROM secret_salary" | ✅ 被表白名单拦截 |
| "SELECT ...; DROP TABLE x" | ✅ 被多语句检测拦截 |
五、Human-in-the-loop
python
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 里说的"权限边界"。
六、另外两个护栏
提示词注入检测
python
INJECTION_PATTERNS = [
r"忽略(?:以上|上面|之前).{0,6}(?:指令|规则|提示)",
r"ignore\s+(?:all\s+)?previous\s+instructions",
r"输出(?:你的|系统).{0,4}(?:提示词|prompt)",
...
]
命中后不中断服务,而是降级为"只读检索模式"并发一条 warn 事件------ 粗暴拒绝会伤害正常用户体验。
PII 脱敏(出网前)
python
_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:
python
with tracer.span(trace_id, f"tool.{name}", args=args, risk=tool.risk):
result = tool.fn(args, trace_id)
同时推送 tool_start / tool_end 事件给前端, 所以你能在执行链路时间线上看到每一步工具调用的参数、耗时和结果预览。
下一篇:07 · 记忆、可观测与成本控制