前端转 AI · 第 04 篇|P3:工程化——异步入库、权限、审计与可观测
本文是系列第 4 篇。上一篇 P2:混合检索、rerank 与可量化的 RAG 评测 让答案从"能答"变成"答得准",还能用数字证明;再往前是 P1 MVP 和 P0 地基;整套路线见 总纲。
但 P2 结束时我说过一句实话:这还是个玩具级实现——元数据是 JSON 文件、入库是同步阻塞、没有登录没有权限、出问题看不到日志。这一篇就是把这些"没有"一个个补上。
阅读约定:
⚠️ 易错点都是真实踩过的坑,紧跟的✅ 解决方案可以直接照抄。代码已脱敏,但结构和参数与真实实现一致。
1. 这篇要做出什么
先把"玩具"和"工程"的差距列清楚,P3 的任务清单就从这份差距里来:
| 玩具(P2 及之前) | 工程(P3 目标) |
|---|---|
元数据存 meta.json,一个文件管全库 |
PostgreSQL + SQLAlchemy 2.0 async + Alembic 迁移 |
| 上传 10MB PDF,API 卡住几十秒 | 异步入库:API 只编排,重活交给 ARQ Worker |
| 没有账号,人人可读所有 KB | JWT 登录 + KB 级 RBAC(owner/manager/viewer) |
| 文档进了索引就能被检索 | 发布门禁:draft → ready → published,只有 published 入检索 |
| 出问题只有 print | 结构化 JSON 日志 + request_id + 健康检查 + token 费用统计 |
| 谁上传的、谁提问的,无据可查 | 审计日志埋点(登录/上传/发布/删除) |
做完后架构长这样(Compose 里五个服务):
Browser ──▶ Next.js (web)
│ rewrite /api/* ──▶ FastAPI (api) ──只做校验与编排──┐
│ │ │
│ │ enqueue_job(doc_id) ▼
│ └──────────▶ Redis ──▶ ARQ Worker
▼ │
PostgreSQL ◀──── 状态/进度回写 ─────────┤
(元数据) ▼
▲ 解析→切分→embed
│ │
└──────── 检索时回查 status ── Chroma (向量)
└──▶ LLM API
一句话概括职责边界:API 进程只做校验与编排,解析/切分/embedding 全部在 Worker;元数据进 PG,向量进 Chroma,检索在 Chroma 命中后回查 PG 实时状态。 后面每个坑几乎都和这条边界有关。
2. 元数据搬家:JSON → PostgreSQL
P0~P2 的 meta.json 是个 MetaStore 接口的实现,这次迁移只是换一个实现——这验证了当初抽象接口的价值。但搬家本身有一堆坑,其中一个还是这几天真实炸过一次的。
⚠️ 易错点 1(真实事故):本地起了后端,注册用户再登录,返回 500 Internal Server Error。查了半天代码没有 bug——最后发现
data/app.db这个 SQLite 文件存在但里面一张表都没有:迁移从来没跑过,SELECT ... FROM users直接抛OperationalError,而路由层只 catch 了业务异常AuthError,数据库异常整个逃逸成了 500。
✅ 解决方案:两层兜底。① Compose 里 API 容器启动命令先跑alembic upgrade head再起 uvicorn;② 本地开发在 FastAPI lifespan 里加 dev 模式自动建表:@asynccontextmanager async def lifespan(_app: FastAPI): validate_jwt_secret() if settings.app_env == "dev": # 生产仍必须显式 alembic upgrade head async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) yield教训是:"有迁移工具"不等于"迁移被跑过"。空库 + 只 catch 业务异常 = 最难排查的 500。
存量数据迁移(meta.json → PG)我锁定了三步安全网,缺一步都可能丢数据:
- 先备份:
meta.json→meta.json.bak.<时间戳>,备份成功才允许写 PG; - 一致性校验:PG 里每个
document_id必须能在 Chroma 对应 collection 查到 node(计数抽查),不一致报错终止、不删任何数据; - 回滚方案:校验失败恢复 bak,
INGEST_MODE=inline可退回旧路径继续服务;旧store.py的删除必须等迁移验收通过后的独立 commit。
另外一个容易忽略的点:存量文档迁移后统一标 published,否则迁完发现"全库不可检索",半夜上线的人会崩溃。
单测用 SQLite(aiosqlite)跑同一套 ORM 模型,不强制本机装 PG——conftest 里 override get_db + create_all 就够了,CI 也不用起数据库容器。
3. 鉴权:一次换库引发的加固
注册/登录本身不复杂,复杂的是围绕 JWT 的一串决策。
⚠️ 易错点 2:随手抄了教程里的
python-jose+passlib。前者停更多年且带着 CVE(包括 alg=none 类签名绕过风险),后者停更且与 bcrypt≥5 不兼容——passlib会先报 version 检测错误再静默失败。
✅ 解决方案:换 PyJWT(解码时显式锁定algorithms=["HS256"],防算法混淆)+ pwdlib[bcrypt](FastAPI 官方推荐,API 等价替代 passlib):pwd = PasswordHash((BcryptHasher(),)) def create_access_token(sub: str, expires_min: int = 30) -> str: exp = datetime.now(timezone.utc) + timedelta(minutes=expires_min) return jwt.encode({"sub": sub, "exp": exp}, settings.jwt_secret, algorithm="HS256") def decode_access_token(tok: str) -> str | None: try: return jwt.decode(tok, settings.jwt_secret, algorithms=["HS256"])["sub"] except jwt.PyJWTError: return None
⚠️ 易错点 3:
.env照抄.env.example,JWT_SECRET=CHANGE_ME_JWT_SECRET直接上了线——等于全员 token 可伪造,而且没有任何报错提示。
✅ 解决方案:启动防呆。应用 lifespan 一开始就校验密钥非空且不等于占位值,不满足直接拒绝启动(APP_ENV=test除外):def validate_jwt_secret() -> None: if not value or value == JWT_SECRET_PLACEHOLDER: raise RuntimeError("JWT_SECRET must be set to a non-placeholder value")配置错误的系统应该起不来,而不是带病运行。
⚠️ 易错点 4:启用鉴权后 CORS 还留着
allow_origins=["*"]配合allow_credentials=True——这在标准里就是非法组合,浏览器行为不可预期。
✅ 解决方案:收紧为前端来源白名单(CORS_ORIGINS= http://localhost:3000 ,...),逗号分隔写进.env。
两个显式接受的取舍(写进了决策笔记,不装看不见):
- Token 续期:只做 30 分钟短 TTL access token,过期重登;不做 refresh token 和服务端黑名单。登出 = 前端清 token,旧 token 在 TTL 内仍有效——接受该风险换实现简单。
- localStorage 存 JWT:XSS 可盗 token。缓解手段是 CORS 白名单 + 短 TTL + 不把 token 打日志;httpOnly Cookie 留给 P4 评估。
4. RBAC:把权限收进一个依赖工厂
角色模型很朴素:KB 成员三角色,权限点三个,kb:review 留给 P4 审批流。
| 角色 | kb:upload | kb:publish | kb:delete | 成员管理 |
|---|---|---|---|---|
| owner | ✅ | ✅ | ✅ | ✅ |
| manager | ✅ | ✅ | ✅ | ✅ |
| viewer | ❌ | ❌ | ❌ | ❌ |
关键是实现方式。FastAPI 的 Depends 天然适合做统一入口:
def require_kb_permission(perm: str):
async def dep(kb_id: str, current: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)) -> User:
if not await membership_service.has_permission(db, current.id, kb_id, perm):
raise HTTPException(status_code=403, detail="forbidden")
return current
return dep
⚠️ 易错点 5:在每个路由函数里手写
if not has_permission(...): raise 403。写到第五个接口必有一处忘写——漏一个就是越权漏洞。
✅ 解决方案:权限校验只存在于依赖工厂一处,路由只声明Depends(require_kb_permission("kb:publish"))。想漏都漏不了,而且单测只需要对着工厂测角色矩阵(越权 403 / 正常 200),不用逐接口铺。
另一个细节:登录防爆破的限流优先级高于一切业务限流——裸露的登录端点比任何接口都危险。这个放第 8 节细说。
5. 异步入库:API 只编排,重活进 Worker
选型是 ARQ + Redis(asyncio 原生、任务函数就是 async def、与 FastAPI 同生态;Celery 更重,这个阶段收益不足)。链路拆成两半:
ingest.py:算 sha256 → 查重 → 建draft记录 → 原始文件落盘 → 入队;ingest_worker.py:从磁盘读文件 → 更新 progress(10/30/60/90/100)→ 解析 → 切分 → embedding → 清旧 node → 写 Chroma →ready。
这条链路上踩了四个坑,每个都值得单独说:
⚠️ 易错点 6:把
file_bytes直接塞进 ARQ 任务参数,想着"少一次磁盘 IO"。ARQ 的参数要经 Redis 序列化,Worker 是另一个进程——大数据序列化又慢又占内存,而且任务一旦重试,内存里的字节早没了。
✅ 解决方案:队列里只传doc_id。上传时先把原始文件落盘(uploads/{kb_id}/{doc_id}_{filename}),Worker 拿 id 从磁盘读。简单、可重试、可回放。
⚠️ 易错点 7:Redis 宕机时
enqueue_job抛异常,但draft记录已经写进 PG 了——于是库里永远挂着一条"pending 中"的僵尸文档,进度条永远不动。
✅ 解决方案:入队失败当场置failed并返回 503("入库队列暂不可用,请稍后重试"),绝不留悬空记录:try: await redis.enqueue_job("ingest_job", doc.id) except Exception as exc: doc.status = DocumentStatus.failed doc.error = f"enqueue failed (redis unavailable): {exc}" await db.commit() raise HTTPException(503, "入库队列暂不可用,请稍后重试") from exc
⚠️ 易错点 8:Worker 失败重试后,同一文档在 Chroma 里出现两份向量——检索时同一句话被引用两次,用户立刻发现"这系统有 bug"。
✅ 解决方案:入库成功写 Chroma 之前,先按document_id清掉该文档的全部旧 node(幂等),再写新 node。重试多少次都不会产生重复。
⚠️ 易错点 9:Worker 进程被 kill -9,
draft记录停在 progress=60,ARQ 侧任务也没了——没有任何机制会再来管它。
✅ 解决方案:僵尸任务自愈。ARQ 配job_timeout(10 分钟);另加一个每 30 分钟的 cron:扫status=draft 且 updated_at 超过 timeout×2的记录,置failed并写 error="worker 超时",允许用户手动重试。进度条永远不会"永远卡住"。
还有一个开发体验的设计:INGEST_MODE=inline。单测和本地快速演示不起 Redis,请求进程内同步跑同一个 run_ingest_job(doc_id)——同一套函数,两种执行模式,不是两份实现。CI 里 125 个测试没有一个依赖 Redis。
进度查询就是个小接口,前端离开页面再回来照样能看到 10 → 100:
GET /api/kbs/{kb_id}/documents/{doc_id}/progress → {status, progress, error}
6. 发布门禁:取消发布必须"立刻"消失
这是 P3 里设计上最反直觉的一块。先看文档状态机(锁定,不许改):
draft ──(worker 成功)──▶ ready ──(人工发布)──▶ published
published ──(取消发布)──▶ ready
draft ──(worker 失败)──▶ failed ──(重试)──▶ draft
注意两条铁律:Worker 永远不自动 publish(只到 ready,发布必须由人触发,否则门禁形同虚设);取消发布必须立刻从问答中消失——不是"下个版本生效"。
⚠️ 易错点 10(本阶段最关键的架构坑):最初的方案是把
doc_status写进 Chroma node 的 metadata,检索时用 metadata 过滤。写完单测就发现不对:metadata 是入库时刻的快照。文档发布之后入库、再被取消发布,向量里的 metadata 不会跟着变,检索照样命中它——门禁是漏的。
✅ 解决方案:过滤依据改成 PG 里的实时状态。Chroma 命中后回查 PG,只留status == published的文档;metadata 顶多当缓存。发布/取消发布只改 PG 一行状态、完全不触碰 Chroma——所以是毫秒级生效,不用重建索引:# retrieve.py 内(真实实现的核心三行) doc_ids = [c.document_id for c in results] published_ids = published_doc_ids_sync(kb_id, doc_ids) # SELECT ... status='published' results = [c for c in results if c.document_id in published_ids]
⚠️ 易错点 11:先取 top_k 再过滤——过滤剔掉未发布的 chunk 后,最终结果可能只剩两三条,top_k 静默缩水,回答质量下降还找不到原因。
✅ 解决方案:过量召回再截断。门禁开启时召回top_k × 2(RETRIEVE_OVERFETCH = 2,混合检索的向量/关键词两路同样放大),过滤后再截回top_k。
配套的还有删除链路,必须三处一致,缺一即残留:
DELETE /api/kbs/{kb_id}/documents/{doc_id} (需 kb:delete)
① Chroma:按 document_id 删全部 node
② uploads:删原始文件
③ PG:删 Document 行 ← 必须最后删
顺序有讲究:PG 行最后删。行还在,前端就能看到"删除中/失败"并可重试;PG 先删,Chroma 里就留下无主的"幽灵向量",永远没人再来清它。另外上传时算的 sha256 内容哈希在这里复用:同 KB 内重复上传直接 409 拒绝,从源头减少重复向量。
7. 审计与可观测:出问题能回答"谁、何时、干了什么"
三件事:结构化日志、健康检查、用量统计。
日志用 python-json-logger,中间件给每个请求注入 request_id 和耗时。从此排查问题不再 grep 文本,而是按 request_id 把一次请求的全部日志串起来,接 Loki/ELK 零改造:
{"timestamp": "2026-08-15T01:44:14.130", "level": "INFO", "name": "app.request",
"message": "request", "request_id": "943332e9...", "method": "POST",
"path": "/api/auth/login", "status_code": 401, "duration_ms": 35.51}
健康检查四个端点,Compose 的健康探针和运维巡检都用它们:
/health → {status: ok}
/health/db → PG ping
/health/redis → redis ping
/health/worker → ARQ 队列深度 / 最近心跳
用量统计在每次 chat 后记 tokens_in/out/cost,GET /api/admin/stats 聚合近 7 日 + 在途任务数,前端 /admin 页画图。费用公式锁定为 cost = tokens_in × price_in + tokens_out × price_out,单价来自 .env 的 MODEL_PRICING JSON——未配置则 cost=0 并打 warn,绝不硬编码价格进代码。
审计埋点在登录/上传/发布/删除/对话五处,一张 audit_logs 表。两个刻意的设计:
⚠️ 易错点 12:审计把用户提问的全文存进日志。一旦库里有敏感问题(薪酬、合同、离职),审计表本身变成新的敏感数据源,权限和保留策略全要跟着升级。
✅ 解决方案:对话审计默认只记动作不记内容;AUDIT_LOG_QUERY=true时也只存截断到 80 字的问题片段。隐私默认值从紧,放开是显式选择。
⚠️ 易错点 13:审计写入失败(比如表锁超时)把主业务一起拖挂——用户上传成功却收到 500,因为记审计的那行抛异常了。
✅ 解决方案:审计是 best-effort:整个log()包在 try/except 里,失败记一条日志、rollback、返回 None,绝不影响主流程。审计是保障措施,不能变成新的故障点。
顺手把所有列表接口统一成 Page[T] 分页(?page=1&size=20 → {items, total, page, size})——大 KB 一次返回全量文档列表,是迟早要炸的隐患。
8. 安全收尾:上传限制与限流
两件小事,但都是上线前必须有:
上传限制:MAX_UPLOAD_BYTES=20_000_000(超容 → 413)+ 扩展名白名单 pdf,md,txt,docx(非白名单 → 415)。白名单写成 .env 逗号分隔,加类型不用改代码。
限流用 slowapi(内存令牌桶,单实例够用,多实例再换 Redis 后端)。配比有讲究:
RATE_LIMIT_AUTH=5/minute # 登录/注册:防爆破,第一优先级
RATE_LIMIT_API=20/minute # 聊天/上传:防滥用与成本失控
⚠️ 易错点 14:先给聊天和上传加了限流,登录端点裸奔——因为"登录 QPS 低,不用限"。低 QPS 恰恰说明它扛不住爆破:5/min/IP 的成本几乎为零,却能挡住字典攻击的绝大部分流量。
✅ 解决方案:鉴权类端点必须是限流清单的第一行,而不是想起来才加。
9. 验收:这次没有空表格
P2 那篇我如实交代过"对比表还没跑出来"。P3 的验收这次是实打实全绿的:
自动化:cd backend && uv run pytest → 125 passed(约 35 秒),覆盖鉴权、RBAC 角色矩阵、发布门禁(含"取消发布立刻不可见")、ingest worker 状态机与重试、健康检查、审计、JSON→PG 迁移的备份/一致性失败路径、限流、上传限制、用量统计。
Compose 人工联调也逐项过了,几条关键的:
- 10MB 级 PDF 上传后离开页面,回来看进度到 100%,状态是
ready(不是自动 published); - 故意传坏文件 →
failed可见、可重试; - 无成员权限的账号访问他人 KB → 403、列表不可见;
- 未发布文档不被检索;发布后可引用;取消发布后立刻拒答(验证的是 PG 实时过滤,不是 metadata 快照);
- 删除后 Chroma / uploads / PG 三处零残留;重复上传同内容 409;
/health/db|redis|worker全 200。
P3 之后,这个系统终于是"能给陌生人演示、敢给同事用"的东西了。
10. 下一步:P4 企业能力
单组织做得再稳,企业客户进门第一句还是:"我们有两百家子公司,怎么隔离?接不接 SSO?文档上线要不要审批?"——这三个问题就是下一篇的全部内容:
- 多租户:
tenant_id数据隔离,查询层强制过滤,跨租户一律 404(不泄露存在性); - SSO:OIDC(authlib + Keycloak 本地体验),与本地账号并存;
- 完整审批流:状态机加
pending_review,编辑提交、reviewer 通过/驳回,待审队列; - 连接器自动同步、可配置入库管线、审计导出。
P4 的细节下一篇展开。如果你正在做自己的 RAG,我的建议是:P3 这一坨"不性感"的工程活,恰恰是 Demo 和产品的分水岭——评委看 P2,客户看 P3。
系列文章会陆续更新,有问题欢迎评论区交流。下一篇将讲 P4 企业能力(多租户 / SSO / 审批流 / 连接器),敬请期待。
如果你觉得本文还可以,那就点击一下推荐,让更多人看到吧!
限于本人水平,如果文章和代码有表述不当之处,还请不吝赐教。

浙公网安备 33010602011771号