1. 项目概述:一个AI教育平台的“隐形”后端架构
做后端开发这些年,我越来越认同一个观点:好的后端工程是“隐形”的。当用户流畅地使用一个应用时,他们不会去想数据库的表是怎么设计的,请求是怎么被限流的,或者记忆功能是怎么存储和检索的。他们只会觉得“这东西好用”。只有当系统出问题的时候,后端才会被“看见”——而作为一个搞砸过不少次的人,我对这种“被看见”的滋味再熟悉不过了。
最近我主导了EduRag这个AI教育平台的后端基础设施搭建。这是一个基于RAG(检索增强生成)技术,让学生能针对上传的PDF教材进行问答和讨论的平台。我的工作涵盖了从Supabase数据库Schema设计、FastAPI接口层、与Hindsight记忆服务的集成,到速率限制、WebSocket聊天室和安全中间件栈的全部内容。这部分系统从来不会出现在产品演示里,但却是整个产品能稳定运行的基石。今天,我想抛开那些光鲜的AI模型和交互设计,聊聊这些通常没人讨论、却至关重要的基础设施决策,尤其是围绕向量数据库、异步任务和系统可靠性展开的那些“坑”与收获。
2. 核心架构与数据层设计
2.1 全托管数据库策略:为什么选择Supabase PostgreSQL
项目早期我们就做了一个关键决定:完全摒弃本地数据库,所有数据持久化都交给Supabase的托管PostgreSQL。这个决策极大地简化了部署和运维复杂度——我们不需要操心数据库的安装、备份、升级和高可用。但硬币的另一面是,我们必须一次性把数据Schema设计得足够健壮,因为在生产环境进行表结构迁移的痛苦指数非常高。
我们整个平台的核心数据模型由八张表构成,它们共同支撑了用户、内容、交互和记忆四大模块:
users:存储账户核心信息,包括机构ID、姓名、经过bcrypt哈希的密码、角色枚举类型和头像偏好。这里的role枚举(如student,teacher,admin)是整个基于角色的访问控制(RBAC)系统的基石,后续所有鉴权逻辑都源于此。search_history:记录每个用户发起的每一次RAG查询。包含查询文本、时间戳和返回的结果数量。这张表是后续“热门话题”分析和个性化推荐的数据源头。- 内容三件套(
pdfs,pdf_chunks,rag_embeddings):这是RAG功能的核心。pdfs表存放PDF文件的上传元数据(如文件名、大小、上传者)和最重要的indexing_status(索引状态)。pdf_chunks表存储从PDF中提取出来的文本片段,并记录每个片段在原文中的位置信息(如页码、起始行)。rag_embeddings表则与pdf_chunks一一对应,存储每个文本片段的向量嵌入(我们使用Google Gemini的嵌入模型生成)。这里我们直接将向量以float[](浮点数数组)的形式存入PostgreSQL。
关键决策点:在数据库内计算向量相似度。最初我们考虑过将向量拉取到Python应用层,再用NumPy或专门的向量库计算余弦相似度。但考虑到一次查询可能需要比对成千上万个向量,网络I/O和序列化/反序列化的开销会非常大。最终,我们利用PostgreSQL的数组操作和立方体(cube)扩展,直接在SQL中完成向量相似度计算。一句
ORDER BY embedding <=> query_embedding LIMIT K就能高效完成最近邻搜索。这避免了在海量数据中移动数据的性能瓶颈,也是我们选择PostgreSQL而非更简单的键值存储的重要原因之一。
2.2 行级安全:将数据权限防线推进到数据库
如果说Schema设计决定了数据的形状,那么行级安全(Row Level Security, RLS)则决定了数据访问的边界。我们在Supabase中为每一张表都启用了RLS,并编写了精细的策略(Policies)。
这意味着,权限检查不再仅仅依赖于我们手写的应用层业务逻辑。即使某处API路由的代码因为疏忽忘了检查用户权限,试图执行SELECT * FROM pdfs,数据库也会根据当前连接的用户ID(通过Supabase的auth.uid()获取),自动过滤掉不属于该用户的记录。一个学生永远无法通过任何方式(包括直接连接数据库)查看到其他同学的搜索历史或上传的PDF。
实操心得:RLS的代价与收益。启用和调试RLS策略确实花了将近一天的时间,它增加了Schema设计的复杂性。但在我看来,这笔投资回报率极高。在开发后期,它就至少阻止了两次因代码逻辑不严谨而可能导致的数据泄露风险。它相当于在应用逻辑这堵“墙”后面,又加装了一道数据库级别的“铁门”,实现了深度防御。对于教育这种涉及敏感数据的领域,这种级别的安全冗余是必要的。
3. 核心服务集成与异步任务管理
3.1 记忆服务集成:让AI拥有“记忆力”
记忆功能是EduRag区别于普通问答机器人的关键。我们集成了Hindsight作为外部记忆服务,我为此构建了四个核心端点:
/memory/status:健康检查。/memory/retain(保留):在每次成功的RAG搜索后自动调用。它接收一个结构化的“事实”字符串(例如“学生查询了生物化学中的酶抑制机制”),并将其存储到该用户专属的Hindsight记忆库中。每个用户通过我们的用户ID标识其独立的记忆库。/memory/recall(回忆):在RAG检索流程中被调用。前端查询传入后,除了搜索向量数据库,还会调用此端点。Hindsight会返回与当前查询语义相关的历史记忆“事实”,这些事实会被注入到最终发给大语言模型的提示词中,使回答更具连续性和个性化。/memory/reflect(反思):一个由管理员触发的操作,请求Hindsight对某个用户的整个记忆库生成一个AI总结。教师可以通过管理面板查看某个学生学习了哪些主题、频率如何、在何处参与度下降。这个仅用约20行后端代码实现的功能,后来成了最受教师欢迎的管理功能之一。
避坑指南:外部服务调用的超时与降级。我为recall端点设置了6秒的超时(HINDSIGHT_TIMEOUT)。记忆功能是增强体验的,而不是核心路径的阻塞点。如果recall因网络或服务问题超时,查询会自动降级——继续执行常规的RAG流程,只是缺少了记忆上下文,而不是向用户返回一个错误。这确保了核心功能的可用性。
3.2 那个耗费一整天的PDF索引Bug
这是我印象最深的一个故障,根本原因在于对异步任务生命周期的错误理解。
现象:教师反馈,有些PDF在后台显示“已索引”,但学生搜索时却返回无结果。数据库pdfs表的indexed_at时间戳已更新,文件也在存储桶里,但rag_embeddings表里就是没有对应的向量数据。
排查过程:我一开始在索引流程的每一步加日志,手动跑了三遍,一切正常。向量生成和存储都没问题。无法复现故障。后来才意识到,问题只在并发时出现。当两位老师同时上传并触发索引时,其中一份的向量写入会神秘丢失。
根本原因:为了不阻塞API响应,我使用了asyncio.create_task()来将耗时的向量生成和存储操作扔到后台执行。路由处理函数立即返回200成功。然而,我没有await这个任务,也没有任何机制跟踪它的完成状态。当主请求上下文结束后,这个后台任务有时会被垃圾回收机制中断,导致其中的Supabase写入操作半途而废。任务引用丢失了,失败也是静默的。
解决方案:
- 对于小文件:我将嵌入任务改为在路由处理函数中同步执行,接受由此增加的延迟,换取确定性。
- 对于大文件:端点返回
202 Accepted和一个任务ID。前端(教师仪表板)轮询一个状态端点来获取索引进度。 - 显式化失败:在
pdfs表中增加了failed_at和error_message字段。任何索引失败都会记录在此,让问题从“隐身”变为“可见”。
核心教训:区分“尽力而为”和“必须完成”的任务。
asyncio.create_task()适合用于真正的“发后即忘”型任务,比如清理临时缓存、发送非关键的通知。而对于涉及数据持久化、状态变更的核心业务操作,必须有完整的生命周期管理和错误处理机制。要么同步执行并妥善处理超时,要么引入可靠的任务队列(如Celery、RQ)。
4. 保障系统稳定与安全的“无聊”配置
4.1 速率限制:晚做不如早做
我是在项目后期才加入速率限制的,现在想来有些后悔。我们使用SlowAPI这个库,它与FastAPI中间件链集成,实现了基于令牌桶算法的IP级限流。
如何确定“60次/分钟”这个阈值?这不是拍脑袋决定的。我们模拟了一个典型场景:一间有30个学生的实验室,在30分钟的课程内同时使用平台进行查询。峰值请求大约在每分钟30次左右。将限制设为60次/分钟,既为正常的课堂活动留出了充足余量,又能有效阻止恶意的脚本攻击或意外循环调用。我们通过压力测试发现,低于这个值,正常课堂流量偶尔会被限制;高于这个值,在遭受攻击性负载测试时,后端的Gemini API调用成本会变得不可控。
4.2 安全头与CORS:半小时能搞定的事,别拖两周
SecurityHeaders中间件负责为每个响应添加Content-Security-Policy、HSTS、X-Frame-Options等头部。这些头部能防御一系列应用层逻辑无法单独抵御的客户端攻击(如点击劫持、某些类型的XSS)。实现它只花了大约30分钟,但我却因为“不紧急”而推迟了两周。这应该是在项目第一天就完成的事情。
CORS配置的教训:我们的前端部署在Vercel上,这意味着原点(Origin)在开发环境(localhost:3000)、预览环境(随机的Vercel子域名)和生产环境(固定域名)下是不同的。为了让CORS在所有环境正常工作,我不得不在FastAPI中间件中明确配置允许的来源列表,并制定清晰的规则:预览环境的来源在 staging 阶段被允许,但在生产配置中被严格排除。事先规划好多环境策略,能省去不少调试时间。
5. 实时交互与功能降级设计
5.1 WebSocket聊天室:连接管理与自动过期
平台包含一个学生实时聊天室。我使用FastAPI的WebSocket支持,编写了一个ConnectionManager类来管理活跃连接(用一个以用户ID为键的字典存储)。身份验证在握手时通过URL查询参数传递的JWT完成,因为WebSocket连接建立后无法再设置HTTP头。
技术难点:消息的自动过期。我们不希望聊天记录成为永久数据,因此设计了消息在存活一定时间(TTL)后自动删除的机制。我实现了一个每60秒运行一次的后台任务,删除Supabase中过期的消息。这里我再次使用了asyncio.create_task()——是的,就是导致索引Bug的那个模式——但在这里是合适的。因为消息过期是一个真正的“尽力而为”的操作。即使某次清理循环错过了,也不会造成数据不一致或功能损坏,只是消息留存得久了一点。这再次印证了那个原则:关键数据操作必须可靠,“锦上添花”的操作可以异步化。
5.2 功能开关:HINDSIGHT_ENABLED的启示
开发过程中有一个下午,Hindsight API服务不可用大约两小时。我发现,仅仅因为每次搜索后自动调用的retain端点抛出未处理的异常,就导致整个RAG搜索接口返回500错误。记忆功能本应是增强项,却拖垮了核心功能。
我当即引入了HINDSIGHT_ENABLED这个环境变量作为功能开关。当设置为false时,所有对记忆服务的调用都会被静默跳过。系统进入“无状态”模式:RAG搜索照常工作,推荐功能回退到全局热门话题,记忆相关的UI显示友好的空状态。没有错误,没有功能降级,只是暂时少了些个性化特性。
设计原则:外部依赖必须可隔离。任何引入外部依赖的功能,都不应该以牺牲核心系统的可靠性为代价。必须为运维人员提供一个干净的“开关”,使其能在依赖服务出现问题时,快速切断故障点,保障主体服务可用。如果你的功能无法被干净地关闭,那么你就将系统的可靠性绑在了第三方服务的稳定性上。
6. 隐私保护与数据脱敏
在将数据发送到外部记忆服务(Hindsight)之前,我们强制进行了一次PII(个人身份信息)脱敏处理。retain路径上集成了一個函数,通过正则表达式扫描即将存储的“事实”字符串,剥离其中的邮箱地址、电话号码等敏感信息。
这个函数不是可选的,它被硬编码在写入路径中。将包含学生PII的数据存储到外部服务,是一个绝不能靠文档约定或开发者自觉来保障的风险点。隐私保护必须通过代码路径来强制实施,而不是依靠可能没人会仔细阅读的文档。这是我们在处理教育数据时的一条铁律。
7. 总结与可复用的经验清单
回顾整个EduRag后端基础设施的构建过程,以下这些经验我认为具有普适性,值得传递给其他开发者:
- 数据库权限要“深”:像Supabase的行级安全(RLS)这样的特性,虽然增加了一些设计复杂度,但能提供应用层无法比拟的数据安全保证。对于多租户或涉及敏感数据的应用,值得投入。
- 异步任务需“慎”用:严格区分任务的紧要程度。对于需要保证完成的核心操作(如订单创建、数据索引),避免使用
asyncio.create_task()这类“发后即忘”的模式。采用同步执行(配合超时)或引入可靠的消息队列。 - 失败要“可见”:对于任何代表异步操作状态的数据库表(如
pdfs),增加failed_at和error_message这样的字段。将静默失败转化为显式失败,是快速定位和修复问题的前提。 - 安全与限流要“早”:速率限制和安全头部这类基础设施,实现成本低,但事后补充的心理压力和风险更高。应该在项目初期就作为基础组件引入。
- 外部依赖要“可切”:为集成的外部服务设计功能开关。确保在第三方服务不可用时,你的核心功能可以优雅降级,而不是完全崩溃。
- 隐私处理要“硬编码”:涉及用户隐私的数据流出(如发送到外部API),脱敏逻辑应该作为代码路径中的强制步骤,而不是可配置或可选的选项。
后端的工作很少成为演示的焦点,没人会在产品发布会上讲解RLS策略或令牌桶算法。但这没关系。我们的职责就是让那些能上演示的功能正确、可靠地运行,并确保当问题出现时——问题总会出现的——故障是可见的、可控的、且可恢复的。EduRag的记忆基础设施是我最满意的部分,并非因为它技术多复杂,而是因为它直接改变了产品能为学生带来的价值。能产生可见用户价值的基础设施,就是最好的基础设施。