1为什么不是“接个模型 API”
“接个模型 API”本身不难。难的是把它放进企业环境之后,三件会立刻发生的事:
- 成本没有闸门。 一把 API Key 被复制出去,第二天就是一张看不懂的账单。你需要的不是“提醒大家省着用”,而是每个租户独立的令牌桶、日/月预算和可回传的单次花费。
- Text-to-SQL 默认是危险的。 把自然语言直接变成 SQL 再执行,等于把数据库写权限交给一个会被提示词操纵的模型。只靠关键字黑名单不够,注释、大小写、等价写法都能绕。
- 越权查询看起来像正常查询。
SELECT id, base_salary FROM payroll_runs语法完全合法。行级权限必须是执行路径上的强制条件,而不是文档里的约定。
这个项目把三件事收进同一条请求路径:模型侧管路由、限流、缓存和成本;数据侧管“模型写出来的 SQL 到底有没有资格被执行”。两边共用鉴权、配额和审计,所以“这次调用花了多少钱”和“这次为什么被拒”能在同一张表里回答。
2一条请求经过什么
请求路径是刻意排成一条直线的:
request_id → edge(日志/指标) → body_limit → auth(key→租户)
→ policy(令牌桶 / 并发 / 日预算 / 月预算)
├─ POST /v1/chat/completions → 路由打分 → 熔断中的上游不参与 → 上游
└─ POST /v1/nl2sql → 目录 + 少样本提示 → 模型给出候选 SQL
→ 护栏 review_sql → 执行器(mode=ro + 超时 + 行数硬截 + 结果集掩码)
→ 审计落库
被护栏拒绝不是 HTTP 错误:链路完整跑完了,网关给出了一个明确判定,所以状态码是 200、判定在 verdict 里。把它做成 4xx 会让“模型写错了 SQL”和“你没权限”共用一个信号,排障时很难分清。
3护栏:执行 AST 重新生成的那一串
护栏的核心断言只有一句:被执行的字符串永远是 AST 重新生成的那一个,不是模型给的那一个。 所有规则都在判定“要不要给这条 AST 生成执行文本”,而不是扫描原文里的危险关键字。关键字黑名单可以被注释、大小写、等价写法绕过,重新生成不行。
规则按优先级跑,两个顺序上的反直觉之处都是被语料逼出来的:
| 规则 | 做什么 | 为什么排在这里 |
|---|---|---|
GRD-000 | 可解析 | 没有 AST,后面任何规则都无从检查 |
GRD-001 | 单语句 | 多语句是注入的常见形状 |
GRD-002 | 只允许 SELECT | 写语句在这里就被挡下 |
GRD-004 | 表白名单 | 先于标识符解析:目录里没有 sqlite_master,先跑列解析会把“表不在白名单”误报成“列解析失败” |
GRD-003 | 标识符可解析 | 目录是唯一真相,不借第三方库的错误文案分类 |
GRD-005 | 列白名单 | 含 SELECT * 展开;id_card 这种不暴露列到这里才被拒 |
GRD-006 | 函数白名单 | 宁可漏放几个合法函数,也不放任何能落盘/读文件的入口 |
GRD-007 | 禁止加锁与写出 | FOR UPDATE / SELECT INTO 是锁定读,不是普通 SELECT |
GRD-008 | OFFSET 上限 | 防止用深分页把数据库拖住 |
GRD-009 | RLS 谓词注入 | 行级权限在这里变成执行计划的一部分 |
GRD-010 | 外层行数上限 | 执行器再兜一层硬截 |
GRD-011 | 语句超时 | AST 层判断不了“跑多久”,交给执行器的 progress handler |
放行时返回的 canonical_sql 才是执行文本。模型原文只作为审计证据保留,不直接进数据库。
4行级权限与列级掩码
行级权限不是“在 WHERE 里碰巧写了 dept_id”,而是护栏按别名给每一个引用了权限键表的 scope 注入谓词。比如租户的 rls_dept_ids = [1],对 payroll_runs 会生成:
SELECT id, base_salary
FROM payroll_runs
WHERE dept_id IN (:rls_0)
LIMIT 200;
参数 :rls_0 的值来自租户行,模型碰不到。没有部门列的表(比如考勤)沿外键路径受限:attendance.employee_id → employees.id,再对 employees.dept_id 注入谓词。所以用户把两张表写成逗号交叉连接、或者写错 ON 条件,都拿不到别人的行。
列级掩码作用在结果值上,是纯函数、可被审计重放,而不是拼进 SQL。并且沿表达式叶子继承:改名、substr、GROUP_CONCAT、MAX 都拿不到原值。实验里那条 SELECT substr(phone, 1, 3) FROM customer_pii 就是在演示这一点。
5预算、限流与 key 泄露
每个租户有独立的令牌桶、并发上限和日/月预算。默认演示租户:60 次/分、突发 10 个令牌、日预算 $0.20、月预算 $2.00。请求先过速率与并发,再过预算;超了分别返回 GW-RATE-LIMITED(429)和 GW-BUDGET-DAILY(402)。
关键点是:key 被复制后,调用方和正常用户共享同一套桶与预算。攻击者能把额度打满,但不能凭空多出一份额度。实验里把速率拉到 120 次/分,60 秒内会同时看到限流和预算拦截——这就是这层闸门存在的意义。
本机压测(2026-09-21,无真实厂商 key,1200 条请求)两次独立跑出同一金额:计费 599 条、限流 601 条、花费 $0.004293。这不是生产容量结论,只说明计费与限流两个计数器在同一批请求上能对齐。
6实测、边界与可复现
本地已经完成的是:功能闭环、测试全绿、文档与 Dockerfile 收尾。没有完成的是“对外营业”那一半:
- 没有部署。 本机没有 Docker 引擎,
docker build与 Railway 命令在部署文档里都自陈“没跑过”,不能写成已验证。 - 没有真实模型 key。 mock 上游能验证路由、计费、护栏和执行链路,但验证不了“真实模型写 SQL 的正确率”。
execution_accuracy_pct这类数只到执行层,不代表模型能力。 - 失败路径的审计归因不完整。 502/503 路径上
stream、upstream_attempts、model还是默认值,只能说“能按错误码归因”,不能说“能按模型/流式维度归因”。 - 基准区间只是同一次运行内的下界。 跨批次漂移没有定位;计时类数字只取带区间的那批,不把单点当精度。
这个项目真正值得讲的一点不是“用了多少库”,而是把安全边界写进了执行路径:SQL 不是被扫描后放行,而是被重新生成后执行;权限不是文档约定,而是注入到 AST 里的谓词;花费不是账单出来才知道,而是随响应返回。
如果你想把这三层亲手点一遍,去 AI 治理网关实验;想看完整测试与边界,项目的 README 和 docs/SECURITY.md 是权威口径。