文章目录
- 前言
- 1. 工具即 Schema:先给 AI 发“工作证”
- 1.1 注册表对外提供三个能力
- 2. 目前注册的 5 个工具
- 2.1 calculator 用 AST 白名单,绝不用 eval
- 3. Text2SQL:生成 → 校验 → 执行 → 报错回炉
- 3.1 踩坑记录
- 3.2 Prompt 里的关键约束
- 4. 四层 SQL 护栏
- 4.1 数据库侧:只读连接
- 4.2 实测拦截效果
- 5. Human-in-the-loop:高危操作要过“领导审批”
- 6. 另外两个护栏
- 6.1 提示词注入检测
- 6.2 PII 脱敏(出网前)
- 7. 工具执行的可观测
P.S. 推荐一个大神的教程给想要了解或者学习人工智能知识的读者,这个教程里内容讲解通俗易懂且风趣幽默,对我帮助很大。我想与大家分享这个宝藏教程,请点击下方链接查看, 传送门https://blog.csdn.net/qq_74013365
前言
先说结论:给 AI 配工具这件事,本质上就是给一个“什么都会一点、但什么都不太靠谱”的员工发权限。你说它笨吧,它能写 SQL;你说它聪明吧,它敢在你的生产库上跑 DROP TABLE。这篇文章没什么别的,就是记录我怎么给这位员工上保险的。
1. 工具即 Schema:先给 AI 发“工作证”
工具要被 AI 正确调用,第一步是让它知道你有什么、每样怎么用。我的方案是把每个工具注册成一份“简历”——名字、职责、参数,全写清楚,塞进注册表。
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)1.1 注册表对外提供三个能力
registry.specs()# JSON Schema 清单(给 API / 前端)registry.describe_for_prompt()# 自然语言清单(自动塞进 Plan 的 Prompt)registry.execute(name,args,trace_id)# 统一执行入口好处就一句话:新增工具只改一处,编排层零改动。相当于公司招了新人,你只需要更新一下通讯录,不用重新装修办公室。
2. 目前注册的 5 个工具
上线跑着的工具,目前一共 5 个:
| 工具 | 风险 | 说明 |
|---|---|---|
search_knowledge | low | 知识库检索(RAG) |
sql_query | low | BI 数据 Text2SQL |
send_alert | high | 推送告警(需人工确认) |
calculator | low | 安全四则运算 |
get_today | low | 解析“本月/上周”相对时间 |
注意那个 send_alert,风险等级是 high。推送告警这种事让 AI 自己拍板,就跟让猫决定什么时候开罐头一样——它倒是开心,你心里直打鼓。
2.1 calculator 用 AST 白名单,绝不用 eval
_ALLOWED_OPS={ast.Add:operator.add,ast.Sub:operator.sub,...}def_safe_eval(expr):return_eval(ast.parse(expr,mode="eval"))有人问,为什么这么怂?我只能说,eval 一时爽,删库火葬场。你永远不知道用户会在输入框里敲什么,万一来一句__import__('os').system('rm -rf /'),那你这个月的绩效就没了。
3. Text2SQL:生成 → 校验 → 执行 → 报错回炉
让模型写 SQL,本质上等于让实习生写周报:你需求讲得再清楚,他总能给你一版“看起来很有道理、细看全是坑”的东西。
forattemptinrange(2):prompt=TEXT2SQL_TMPL.format(schema=schema,question=question,limit=row_limit)iflast_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)# ← 护栏returnexecute(safe_sql)exceptGuardrailErrorase:last_err=str(e)# 护栏拦截也算一次错误,回炉重生成3.1 踩坑记录
第一版我没把数据库报错回传,结果模型一旦写错字段名,就永远失败。它不是不努力,是每次都在同一个坑里摔倒,而且摔得很有仪式感。后来把报错塞回 prompt,一次修正的成功率肉眼可见地涨了。原来 AI 也会“吃一堑长一智”,前提是你要把那个“堑”亲手喂到它嘴边。
3.2 Prompt 里的关键约束
生成 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. 只能用给定的表和字段 + 一个少样本示例看到第 2 条没?“禁止 INSERT/UPDATE/DELETE/DROP”。这种话写出来像废话,不写出来就是事故——因为你不写,它真的会删。
4. 四层 SQL 护栏
Prompt 是软的,护栏是硬的。嘴上说“别乱来”没用,得物理上锁死。我给它上了四道锁:
defvalidate_sql(sql,allowed_tables,row_limit,readonly=True):# ① 清洗:去 markdown 代码块、去注释clean=strip_sql_comments(raw)# ② 语法层:禁多语句if_MULTI_STMT.search(clean):raiseGuardrailError("检测到多语句执行")# ③ 关键字层:必须以 SELECT/WITH 开头ifreadonlyandnotlow.startswith(("select","with")):raise...# 危险关键字黑名单forkwin("insert","update","delete","drop","attach","pragma",...):ifre.search(rf"\b{kw}\b",low):raise...# ④ 权限层:正则提取 from/join 后的表名,必须在白名单tables=set(_TABLE_RE.findall(clean))ifillegal:=tables-allowed:raiseGuardrailError(f"引用了未授权的表:{illegal}")# ⑤ 兜底:没 LIMIT 自动补ifnotre.search(r"\blimit\s+\d+",low):clean+=f" LIMIT{row_limit}"4.1 数据库侧:只读连接
con=sqlite3.connect(f"file:{db_path}?mode=ro")# SQLite 只读模式只读连接,就是物理层面的“只能看不能拿”。就像图书馆:书随便看,但休想借走一本。模型再有想法,也翻不出水花。
4.2 实测拦截效果
上线以后我拿几个经典刁钻输入试了试,全部被按住了:
| 输入 | 结果 |
|---|---|
| “删掉成本表” | ✅ 被关键字黑名单拦截 |
SELECT * FROM secret_salary | ✅ 被表白名单拦截 |
SELECT ...; DROP TABLE x | ✅ 被多语句检测拦截 |
三连拦,一个没漏。模型想删库跑路?先过关键字这关,过了还有表名白名单,白名单过了还有只读连接,只读连接过了……还有备份。反正它今天必须老老实实给我查数。
5. Human-in-the-loop:高危操作要过“领导审批”
iftool.risk=="high"andsettings.hitl_enabledandnotauto_approve_high_risk:returnToolResult(False,error="HITL_REQUIRED",meta={"need_human":True})翻译一下:高危操作必须人工确认,相当于我顺手给 AI 装了个 OA 审批流——它想发一条告警?行,先提申请,领导批了再说。前端渲染一张橙色确认卡,用户点「确认执行」,后端才把悬着的工具真正跑起来。
这就是我面试时写在简历里的“权限边界”。当初这四个字一写,面试官的眼睛都亮了。
6. 另外两个护栏
6.1 提示词注入检测
INJECTION_PATTERNS=[r"忽略(?:以上|上面|之前).{0,6}(?:指令|规则|提示)",r"ignore\s+(?:all\s+)?previous\s+instructions",r"输出(?:你的|系统).{0,4}(?:提示词|prompt)",...]有人试图让模型“忽略之前的指令”,这类输入会被模式库逮住。但注意,命中之后我不中断服务,而是降级成“只读检索模式”,再发一条 warn 事件。粗暴拒绝会误伤正常用户,这个分寸得拿捏。说白了,就像接到诈骗电话不直接挂断,而是放一首“您拨打的用户正忙”,让它自己觉得没意思。
6.2 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]"),]这一条是真的被吓出来的。企业场景里,用户会随手把客户手机号、身份证号粘进对话框,一旦这些内容发到第三方模型,恭喜你,bug 单还没提,律师函先到了。脱敏就是在数据出门前给它穿好衣服——合规这条线,谁也别想裸奔。
7. 工具执行的可观测
withtracer.span(trace_id,f"tool.{name}",args=args,risk=tool.risk):result=tool.fn(args,trace_id)每个工具调用都会被 Tracer 包成一个 Span,同时给前端推 tool_start / tool_end 事件。你在执行链路时间线上,能看到每一步工具调用的参数、耗时和结果预览。
说白了,AI 每次动手都留痕。它今天干了什么、花了多久、结果对不对,一查一个准——比家长查孩子手机使用记录还细。
下一篇聊记忆、可观测与成本控制,咱们到时候接着唠。
P.S. 推荐一个大神的教程给想要了解或者学习人工智能知识的读者,这个教程里内容讲解通俗易懂且风趣幽默,对我帮助很大。我想与大家分享这个宝藏教程,请点击下方链接查看,传送门https://blog.csdn.net/qq_74013365