前端转 AI · 第 04 篇|P3:工程化——异步入库、权限、审计与可观测

本文是系列第 4 篇。上一篇 P2:混合检索、rerank 与可量化的 RAG 评测 让答案从"能答"变成"答得准",还能用数字证明;再往前是 P1 MVPP0 地基;整套路线见 总纲
但 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)我锁定了三步安全网,缺一步都可能丢数据:

  1. 先备份meta.jsonmeta.json.bak.<时间戳>,备份成功才允许写 PG;
  2. 一致性校验:PG 里每个 document_id 必须能在 Chroma 对应 collection 查到 node(计数抽查),不一致报错终止、不删任何数据
  3. 回滚方案:校验失败恢复 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.exampleJWT_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 × 2RETRIEVE_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/costGET /api/admin/stats 聚合近 7 日 + 在途任务数,前端 /admin 页画图。费用公式锁定为 cost = tokens_in × price_in + tokens_out × price_out,单价来自 .envMODEL_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 pytest125 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 / 审批流 / 连接器),敬请期待。

posted @ 2026-08-15 11:06  南珂丶一梦  阅读(6)  评论(0)    收藏  举报