user-isolation-demo
这是什么
一个完整的多用户 AI 聊天应用,写它是为了验证一个具体问题:多租户隔离应该做在哪一层。
单用户的 AI 应用很容易做。一旦变成多用户,就必须回答”用户 A 绝不能看到用户 B 的会话”——而这句话可以在四个不同层次上实现,代价和可靠性差很远:
| 在哪一层隔离 | 做法 | 问题 |
|---|---|---|
| 前端 | 界面上只显示自己的会话 | 形同虚设,改个请求就穿透 |
| 应用查询 | 每次查询带上 user_id 过滤 | 靠每一处都不写漏,漏一处就泄露 |
| 网关/引擎层 | 每个用户一个专属 agent,上下文物理隔离 | 本项目采用 |
| 基础设施 | 每个用户一个容器或 VM | 最强,但成本高、启动慢 |
这个 demo 选的是第三层:注册时给每个用户分配一个专属 agent,此后他的全部对话都在那个 agent 的独立上下文里跑——隔离不依赖”每次查询都记得加过滤条件”,而是在分配那一刻就定死了。
架构
三个设计决策
① 中间层负责路由,前端从不直连 AI 引擎。
FastAPI 这一层做三件事:认证、把用户路由到自己的 agent、双向代理流式响应。
关键在于前端拿不到 AI 引擎的地址与凭据。如果让浏览器直连引擎,“用户只能访问自己的 agent”这个约束就落到了前端手里——而前端的任何约束都不是约束。隔离必须发生在用户改不到的地方,这是整个 demo 的核心。
② 对话正文不进业务数据库。
PostgreSQL 只存元数据:账号、会话列表、会话标识。真正的消息历史由 AI 引擎的会话存储持有。
这个决定当时是为了避免状态双写——同一份对话存两处,迟早不一致。后来我在 Agent 工程系列写状态所有权时,把这件事想得更清楚了:那份只增不减的消息历史必须有唯一的权威持有者,谁持有它谁就同时获得了 checkpoint、审计与信任边界。分散存两份,等于两边都不权威。
③ 全双工 WebSocket 而不是请求-响应。
一条持久连接同时承载三件事:流式 token、工具调用过程的可视化、会话管理。用轮询或者 SSE 加上另一条上行通道也能做,但连接状态会分散在多处。
顺带一个小优化:WebSocket 连上时,会话列表加载与 AI 引擎握手是并发做的(asyncio.gather),而不是串行等待——首屏延迟直接少一个来回。
其它
支持多会话切换、首条消息自动命名会话、登录后历史恢复、前后端两侧的指数退避自动重连。认证用 JWT 加 bcrypt。
技术栈:Python 3.12 / FastAPI / SQLAlchemy 2.0 async / PostgreSQL;前端 React 18 / TypeScript / Vite / Zustand / Tailwind。
顺带说,“隔离该做在哪一层”这个问题在别处也反复出现——沙箱系列整个系列讨论的其实是同一件事的另一个粒度:那里隔离的是不可信代码,靠的是内核与硬件边界;这里隔离的是租户数据,靠的是网关层的 agent 分配。共同点是:隔离越靠近底层越可靠,代价也越大。