Project · 本科毕业设计 · AI 应用 · 已完成
校园问数:权限不交给大模型的中文 NL2SQL 系统
本科毕业设计。用中文提问校园数据,系统生成 SQL、只读执行,并返回表格、图表和结论。重点在数据权限:模型生成的 SQL 先经 sqlglot 语法树校验,再由服务端注入按角色的行级过滤和字段脱敏,学生只能查本人数据,教师只能查所授课程和所带班级。在 77 个构造的绕过场景中越权行数为 0,同样的语句不经校验会读出约 376 万行范围外数据。
校园问数是我的本科毕业设计:用中文问校园数据,比如“本学期各学院挂科人数”“我上个月在食堂花了多少钱”,系统生成 SQL,用只读账号执行,再给出表格、图表和一句结论。后端、前端、知识库和评测实验都由我独立完成。
这类系统做出“能跑”的演示不难,难的是让不同身份的人只能查到自己该看的数据。这个项目的大部分工作都花在这件事上:权限不靠提示词约束模型,而是在模型生成的 SQL 上强制执行。
要解决的问题
校园数据里既有公开信息(学院、课程、课表、教室),也有个人数据(成绩、一卡通消费、借阅、奖助学金、身份证号和手机号)。系统有三种角色:
学生:只能看本人的成绩、消费、借阅和奖助学金。
教师:能看所授课程的选课和成绩,以及所带班级、所授课程里的学生,看不到学生的消费和借阅。
管理员:能看全部业务数据,手机号在结果里脱敏,身份证号任何角色都不能查。
如果只在提示词里写“学生只能查自己的数据”,用户换个说法、模型多写一层子查询,规则就可能失效。所以我把“模型负责写 SQL”和“系统负责管权限”拆开:模型写出的 SQL 无论长什么样,都要经过同一个校验器(Guard)才能执行。
关键设计:权限放在 SQL 语法树上
Guard 用 sqlglot 把 SQL 解析成语法树,依次做这几件事:
只允许单条 SELECT。写操作、DDL、多语句、锁、会话变量、
LOAD_FILE、SLEEP这类函数一律拒绝。按角色检查表白名单,禁止跨库访问。
用“该角色可见的列”展开
*并解析每个列引用,引用了无权查看的列就拒绝。把受限表替换成带行级过滤的子查询。过滤条件里的用户编号由服务端填入,不来自模型或用户输入。
沿字段血缘追溯每个结果列的来源,哪怕手机号经过了改别名、
CONCAT、子查询、CTE 或 UNION,结果列也会被标记并脱敏。给最外层补上行数上限,再交给只读事务执行,并设置超时。
以学生查询选课表为例(示意,已简化):
-- 模型生成
SELECT AVG(e.score) FROM enrollment e;
-- Guard 改写后实际执行
SELECT AVG(e.score)
FROM (SELECT * FROM enrollment WHERE (student_id = 1024)) AS e
LIMIT 1000;替换发生在表引用这一层,所以不论模型写的是 JOIN、子查询、CTE 还是 UNION,每一处对 enrollment 的引用都会被过滤。这也意味着学生问“全校平均分”时,只会得到自己的平均分,因此总结阶段会强制说明统计范围;全校、学院层面的人数统一走公开的统计视图,避免给出一个看似合理、实际只算了自己的小数字。
Guard 的拒绝分成两类,处理方式不同:
FORBIDDEN:写操作、越权、危险函数。直接向用户说明权限范围,不给模型重试的机会。INVALID:语法错误、列名写错。把错误信息交回模型重写,最多重试两次。
让模型少犯错的几处处理
权限之外,答案对不对主要取决于模型拿到的上下文。我在调试中逐个定位问题的根因,下面这些改动都是从具体的错误回答里来的:
身份映射:提示词给出当前用户在库里的内部编号,表结构标注每个外键指向哪张表,避免模型把学号当成
student_id用。相对时间由代码换算:“本月”“上个月”“本学期”在服务端换算成具体日期范围或学期编号,不让模型自己推算日期。
知识库检索:表字段说明、业务术语(如“挂科”指成绩低于 60 分,缺考和缓考不算)、SQL 示例存放在系统库,管理员可以在后台维护,按向量相似度检索后放进提示词;没有向量模型时降级为中文 2-gram 关键词匹配。
多轮改写:“其中女生呢”“上学期的呢”这类追问会先改写成完整问题,再进入检索和生成。
结论基于全量统计:总结时提供所有行的合计、平均、极值,以及在同一条已注入过滤的 SQL 上算出的真实去重总数,按实际执行的查询下结论,而不是照搬问题的说法。
空结果也说明范围:受行级过滤的查询返回空结果时,提示当前角色能看到什么,避免把“看不到”说成“没有”。
测了什么,结果如何
毕业论文里我做了四组实验,数据都来自本地的模拟校园库(15 张业务表):
校验矩阵:66 条构造的 SQL,覆盖写操作与 DDL、多语句、文件和危险函数、越权访问表和列、CTE 与真实表同名、语法错误,以及 13 条应当通过的合法查询。66 条的判定全部符合预期。
行级过滤绕过:11 种查询形态(
OR 1=1、自连接、UNION ALL、CTE、IN和EXISTS子查询、带库名前缀等)乘以 7 组“角色与表”,共 77 个场景。经过 Guard 的 63 个场景返回 7,760 行,越权行数为 0,另外 14 个被判为INVALID;同样的语句不经 Guard 直接执行,70 个可执行场景全部读出了范围外数据,合计约 376 万行。脱敏:手机号以 7 种方式出现在结果里,全部被脱敏;
COUNT(phone)、LENGTH(phone)等 3 个不该脱敏的用例没有被误伤。开销:Guard 单次校验约 1 到 6 毫秒,SQL 执行的中位数是 3 毫秒,而一次 SQL 生成的中位耗时约 3.6 秒。用户等待的时间几乎全部来自模型调用。
开发期间的 496 条问答日志里,92.7% 正常完成,372 次 SQL 执行没有出现数据库报错,16 条消息触发了自动纠错。这份日志混合了调试和自动化测试请求,期间代码也在迭代,只能说明系统运行稳定,不能当作准确率。
没做到的
没有在 Spider、BIRD 等公开基准上评测执行准确率,也没有人工标注日志里答案的对错,所以我给不出端到端准确率。“SQL 都能执行”不等于“答案都对”。
Guard 对标识符大小写比较严格,
FROM ENROLLMENT这种写法会被判为INVALID。这是偏安全的失败方向,不会越权,但用户要多等一次重试。检索实验只有我自己编写的 30 个问题,业务库也只有 15 张表,结论放到更大的库上不一定成立。
权限策略写在代码里,管理后台还不能编辑;系统表用
create_all创建,没有引入迁移工具。
技术栈
后端:Python 3.12、FastAPI、SQLAlchemy 2(async)+ aiomysql、sqlglot、PyJWT、bcrypt;通过 SSE 推送“理解、检索、生成、校验、执行、总结”各阶段。
模型:任意 OpenAI 兼容接口,向量模型可选。
前端:Vue 3 + TypeScript、Vite、Element Plus、ECharts、Pinia。“改成饼图”这类请求在前端识别,直接用上一次结果重画,不再调用模型。
数据库:MySQL 8,业务库与系统库分离,业务库用只读账号连接。
测试:后端 pytest(含 Guard 的单元测试和连库测试),前端 vitest。