Skip to content

开源项目产品化改造:从单用户 Demo 到多租户服务

提炼自把开源数字人项目 Open-LLM-VTuber(单用户、本地文件存储的桌面应用)改造成多租户、可计费、有长期记忆、云端部署的 AI 陪伴服务后端的完整实践。已脱敏,只保留通用模式。 最新更新: 2026-07-20


核心结论

  1. 单用户开源项目改多用户,核心不是加用户表,而是划清"共享/独立"边界:配置和无状态引擎共享,有状态引擎(Agent、情感)必须 per-connection 副本
  2. 存储改造的顺序是:本地文件 → PostgreSQL 持久层 → Redis 缓存层,"数据库是唯一真实数据源,缓存失败不影响正确性"
  3. contextvars 在新 asyncio task 里会丢上下文——用户身份传递必须设计多级回退,这是异步 Python 多租户改造最隐蔽的坑
  4. 计费要并发安全:行锁(FOR UPDATE)+ 版本号乐观锁双保险
  5. 不敢改上游源码时的"启动热修补"(文本替换+猴子补丁)能救急,但它就是技术债的显性化——正确做法是配置全部走环境变量

一、改造全景

新增子系统解决的问题
PostgreSQL + Redis 存储层聊天历史从本地 JSON → 持久化+缓存;会话/消息/积分/定价表
长期记忆系统LLM 判重要性 → 向量库 + 知识图谱双写,跨会话记住用户
情感/好感度系统分级好感度影响人设 prompt 和 Live2D 表情
多用户会话管理身份提取、会话隔离、并发控制
BFF 集成层JWT 认证、用户表、积分仓库、REST API
成本计量系统LLM token / TTS 字符级计费,与积分打通
MCP 工具编排原生/框架双模式,智能触发检测
部署工程国内网络构建、跨国推镜像、健康检查

二、存储改造

三层架构与缓存纪律

应用层 → 数据访问层(SessionMgr/MessageMgr/CreditRepo)
      → Redis 缓存层(命名空间前缀 + TTL 3600s)
      → PostgreSQL 持久层
  • 读穿透:缓存 miss → 查 PG → 回填;写穿透:先写 PG 成功再更新/失效缓存
  • 缓存"未命中记录"也要缓存(防止不存在的 key 反复打库——缓存穿透)
  • Redis 键统一命名空间前缀,datetime 自定义序列化,批量写用 pipeline

表设计要点

  • 主键用 Snowflake ID(41 位时间戳 + 机器位 + 序列,线程锁 + 时钟回拨等待):趋势递增利于 B+ 树索引,无外部依赖
  • 会话表带 custom_title / is_pinned / deleted(软删),消息表同样软删
  • 积分表:五种积分类型 + 消耗优先级(活动→每日→月度→附加→免费)+ version 字段
  • 定价表用 JSONB 存多维价格(LLM 按千 token input/output,TTS 按千字符/分钟)
  • updated_at 用触发器自动维护

计费并发安全(双保险)

sql
SELECT ... FOR UPDATE;                 -- 行锁
UPDATE ... WHERE version = %s;         -- 乐观锁,不匹配返回"并发冲突请重试"

反面教材:启动热修补

上游代码硬编码 localhost 连接参数,容器化后全部失效。应急方案是一个启动补丁脚本,四种手段叠加:源码文本替换(localhost → 容器服务名,注意协议也要改如 neo4j://bolt://)、生成猴子补丁覆写默认参数、生成环境变量直连模块。能跑,但每种手段都是债——根治办法是上游配置全部走 env。


三、长期记忆系统

结构不是传统"短期/长期"分层,而是:对话历史(PG)+ 重要记忆(向量库 + 知识图谱双写)。

保存管道(每轮对话后异步触发)

  1. 重要性过滤:小模型(低温度)一次 prompt 同时产出:是否重要(9 类重要内容清单)、中文总结、权重 1-10、SPO 三元组、唯一性标记(姓名/年龄等唯一属性,并要求归一化谓语)
  2. user_id 强校验:无有效 user_id 直接拒绝保存——早期用 default_user 兜底导致用户间记忆串号,教训惨痛
  3. 查重再入库:综合相似度 = 0.4×编辑距离 + 0.6×向量余弦;唯一性三元组用更低阈值(0.75 vs 0.85)更容易判定为"同一条该更新";命中则更新不新增
  4. 双写:向量库存 payload(摘要/权重/user_id/软删标志/三元组);图谱存层级结构 主体→谓语→类别→宾语;唯一属性冲突时旧值软删、新值 MERGE(实现"用户改名后记住新名字")
  5. fire-and-forgetasyncio.create_task + 线程池包装同步保存,不阻塞回复

检索双路

  • 被动注入:关键词触发("我的名字/我的爱好")→ 检索 → 以"系统提示:已找到以下用户信息"拼进输入
  • 主动工具:检索函数注册为 Agent 工具,模型自主调用
  • 检索配额分流:60% 向量库、40% 图谱(用向量命中的三元组去图谱补查)
  • 综合排序 = 0.15×时间衰减 + 0.35×权重 + 0.25×向量分 + 0.25×三元组因子

四、情感/好感度系统

  • 状态模型:单一整数好感度(默认 50),7 级阈值,每级绑定人设 prompt 片段、性格特征、表情权重;关键节点触发里程碑通知
  • 更新链路:用户消息 → 情感分析(先规则匹配,LLM 分析预留)→ 好感度增量 → 存储 → WebSocket 通知前端 → 更新 Live2D 表情
  • 每轮注入:情感 prompt 必须每轮注入 system prompt——曾有分支(工具调用流程)漏注入,是修过的 bug
  • 写库优化:后台守护线程 + 有界队列异步持久化;同一 (角色,用户) 的任务合并减少写库;队列满丢最旧;stop 时 queue.join() 优雅收尾
  • 存储双实现同接口:文件版(开发)/ PG+Redis 版(生产),便于本地调试

五、多用户改造(最核心的一章)

ServiceContext 副本机制

原版全局单例上下文改为 per-connection 副本,create_copy() 划分边界:

类别处理例子
配置对象共享(只读)conf
无状态引擎共享ASR/TTS/VAD/翻译
有状态引擎独立副本(须实现 create_copy,否则降级共享+警告日志)Agent、情感管理器
连接引用不复制,连接时单独 setWebSocket

典型故障"用户 A 对话影响用户 B",排查手段就是打印 id(context) 看是否串了实例。

用户身份传递的多级回退

  • 连接时四级 fallback:query 参数 → Cookie → Header → 按 IP+时间戳生成临时 ID
  • contextvars 坑:ContextVar 在新 asyncio task 中取不到值。对话流程里取 user_id 做三级回退:WebSocket 用户缓存(Redis,TTL 1h)→ ContextVar → 默认值
  • 认证后同时写入 ContextVar 和用户缓存,双份保存

并发与资源清理

  • 同一客户端只允许一个对话任务:新请求先 cancel 旧 task 并 await 其 CancelledError
  • 断连清理必须放 finally(曾因漏清理导致内存持续增长)+ 每 5 分钟定时清理兜底
  • 群组对话:发言队列轮转,打断时要额外清群组全局状态,否则"打断后对话继续"

六、多引擎抽象与配置热切换

ASR(7 引擎)/TTS(15 引擎)统一模式:Interface 抽象基类 + Factory 工厂 + Pydantic 配置类 + yaml 驱动

  • 接口只约定核心方法(transcribe(np.ndarray)->str / generate_audio(text)->filepath),默认提供 asyncio.to_thread 异步包装
  • Pydantic model_validator 校验所选引擎的必填参数,配置错误启动时就暴露
  • 热切换:收到切换配置的 WebSocket 消息时,比对配置变化决定是否重建实例,不重启进程、不断其他连接
  • 新增引擎的固定步骤写成文档模板:实现类 → 工厂分支 → 配置类 → 配置字段 → 验证器

七、成本计量

  • token 计数:OpenAI 系用 tiktoken 精确计数,其他模型近似(英文 ~4 字符/token、中文 ~1.5 字符/token)
  • 定价中心化:Redis 缓存(含未命中缓存)→ PG 定价表,改价不发版
  • 重复计量坑:LLM 层和对话层各记一次产生脏数据,收敛为只在一处记录
  • 计费策略会反复调整(普通对话免费、工具调用扣积分),扣费点要做成开关而不是写死

八、部署工程(跨国、国内网络)

问题方案
国内 pip/模型下载慢国内镜像源 + 完整版基础镜像(非 slim,少踩编译坑)+ 延长超时
依赖冲突、构建失败重来分步 pip install(核心 web → torch → TTS → LLM SDK → 向量库 → 框架),失败可从中间层续
构建慢BuildKit + 代码按变动频率分层 COPY + --cache-from 旧镜像做缓存源
服务器拉 registry 慢docker save 成 tar → scp 推到服务器 → docker load(土办法但可控)
跨国传大 tar 常断rsync --partial 断点续传,无 rsync 回退 scp;询问是否保留本地 tar 供下次续传
部署后验证compose up → sleep → curl 健康检查 + pg_isready + redis-cli ping,失败自动 dump 最近 50 行日志
模型文件太大构建目录排除模型,运行时自动下载到挂载卷

反面教材:compose/部署脚本里残留明文密码和服务器 IP——密钥必须全走 .env 且不进版本库,文档里写了安全规范但代码没跟上,等于没写。


九、交接文档方法论

12 份文档 = 1 份总览 + 11 份子系统(按运行时链路切分),篇幅分布反映复杂度权重。每份统一模板:

概述 → ASCII 架构图 → 核心组件(文件路径+类+方法签名)→ 工作流程
→ 配置示例 → 使用示例 → 性能优化 → 故障排查 → 最佳实践 → 扩展建议
→ 文件清单 → 交叉引用其他文档

可取之处:

  • 文件路径锚定:每个组件给出精确路径甚至行号,新人直接跳转
  • 故障排查用"现象→原因→排查命令→解决"四段式,附真实命令
  • 最佳实践用 ✅/❌ 对比代码,比文字规则防错
  • 总览文档记录交接时刻的仓库状态(近期提交分析、未跟踪文件猜测)——交接不只讲设计,还讲"现在进行到哪"

局限:文档与代码会漂移,无自动化校验,重要结论要写"为什么"而不只是"是什么"。


相关笔记

基于 VitePress 构建