拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

企业微信智能客服的挑战与实现(四):从单机版到SaaS——多租户改造实录

企业微信智能客服的挑战与实现(四):从单机版到SaaS——多租户改造实录 摘要一套单机版 AI 客服系统要变成一个实例服务上百家企业的 SaaS最难的往往不是加一列tenant_id而是另外三件事老客户的库不能因为升级而出问题隔离不能靠每个开发者记得加 where还有「登录进来的都是自己人」这个前提已经悄悄不成立了。本文是系列续篇以冰石机器人接入微信官方客服、上架企业微信应用市场应用名「冰石AI客服」的改造为例拆解租户模型选型、运行时双模式、框架级租户隔离、真机踩坑、开通与登录的自动化和容量路线。本系列① 人机协同——机器人客服与真人客服如何共处一个会话② 专属客户群与拟人化体验——让企微客服更像一个团队在服务③ 知识库与 AI 自主学习④本篇从单机版到 SaaS多租户改造实录一、为什么是微信客服为什么现在做 SaaS前三篇讲的都是一家企业一套部署机器人跑在客户自己的 Windows 机器上通过桌面自动化接管个人微信或企业微信。这条路功能最全但有个天然的上限一个实例对应一个账号、一台机器。每多一家客户就要多装一台机器、多做一次运维。企业微信的「微信客服」提供了另一条路客户在微信里扫码或点链接进入客服会话消息通过官方回调接口推给服务商回复也走官方 API。它和前三篇的方案对比如下桌面自动化个微 / 企微 RPA微信客服官方接口运行形态依赖一台常驻的 Windows 机器纯服务端回调驱动一个实例服务一个账号任意多家企业合规性需自行把握平台规范官方开放能力第三方应用上架审核能否 SaaS 化只能做托管私有部署✅所以第一个结论很简单回调驱动的渠道进 SaaS 主线依赖桌面环境的渠道留在私有部署。两者共用一套代码用构建变体--versionwxkf隐藏 SaaS 版里不需要的渠道菜单。第二个结论是租户键不用自己造。企业在应用市场安装应用时企微会把授权企业的corpid推过来后续每条消息回调也按它路由。于是tenant_id corpid平台自己的数据、历史单机版的数据统一记为default。企业安装「冰石AI客服」后在企业微信管理后台看到的应用详情成员姓名已打码二、租户模型共表 tenant_id多租户的数据隔离有三种经典形态我们逐个算了账方案几十家几百上千家结论每租户一个实例可行每个实例常驻几百 MB分词词典、模型客户端上千个进程的编排等于自己造一个 K8s而且大量租户是闲置的❌每租户一个数据库可行每次迁移要跑 N 遍、跨租户统计要跨 N 个库、连接管理失控❌只留给有合规要求、愿意付大钱的大客户共表 tenant_id可行单库点查索引带上租户前缀即可✅正确的形态是消息入口按 corpid 给消息打上租户标后面是一个共享的无状态 worker 池。共表的最大风险是某处代码漏写了WHERE tenant_id ?一次漏写就是一次跨企业数据泄漏。第四节专门讲怎么让这件事不依赖人的记性。三、老客户不能出问题运行时双模式改造时已经有一批在跑的单机版客户。给 18 张业务表加tenant_id列意味着他们的老库和新代码之间出现了结构差。最危险的不是启动失败而是运行中失败SQLAlchemy 的create_all(checkfirstTrue)只建缺失的表、不补列。老库跑新代码启动一切正常等到某个查询带上tenant_id时才抛no such column而且是在客户正在接待客人的时候。我们的做法是不做启动自动迁移由库的实际结构决定进程跑哪种模式。defdetect_mode()-bool:colscolumns_of(users)# 每个安装都有、行数极少的哨兵表ifnotcols:# 表不存在 全新库create_all 直接建新 schemareturnTruereturntenant_idincols# 有列 已迁移过没列 未升级的老库TENANT_MODEdetect_mode()# import 时冻结迁移后必须重启进程模式决定了模型本身长什么样ifTENANT_MODE:classTenantMixin:tenant_idColumn(String(64),nullableFalse,indexTrue,defaultdefault,server_defaultdefault)else:classTenantMixin:legacy 空壳不带任何列表结构与改造前逐字节一致库的状态模式行为老库未升级legacy没有 tenant_id、不注册任何过滤事件行为与改造前完全一致手工升级过tenant多租户全量生效全新安装tenant直接建出新 schema单机版客户的数据全部归default租户功能无差别几个细节加列必带默认值ADD COLUMN tenant_id TEXT NOT NULL DEFAULT default。SQLite 只改元数据、不重写表千万行也是毫秒级。存量数据自动归入 default单机版客户永远活在 default 租户里零感知。回滚安全旧代码的 SELECT 都写明了列名会忽略多出来的 tenant_id所以旧代码跑新库也没问题。两种模式各跑一遍全量测试失败集合必须完全一致。有一个专门的测试文件锁住单渠道版跑在 tenant schema 上这条路径因为新装的个微版、闲鱼版客户库其实也是 tenant 结构。启动日志里有一行schema_mode: tenant/legacy客户报问题时先看这一行。四、隔离靠框架不靠自觉业务代码里散落着几百处db.query(...)逐个加 where 必然有遗漏新写的代码也会忘。所以隔离必须在一个地方强制执行。4.1 租户上下文ContextVar当前这段代码在为哪个租户干活放在一个ContextVar里由入口负责设置管理后台请求中间件从登录 token 的tid字段取出并设置消息回调链路定位到客服账号后按账号归属设置第六节会讲为什么不能按回调里的 corpid。关键在于没设置时怎么办defresolve_tenant():value_current_tenant.get()ifvalueis_UNSETorvalueisNone:returndefault# fail-closed忘了设只会看不到别家数据returnvalue忘记设上下文的代码只会看到 default 的数据。这种 bug 表现为功能缺失很容易被发现而且不会泄漏。反过来如果默认是不过滤那就是一次静默的跨企业泄漏。平台管理员需要跨租户查看时必须显式传入ALL_TENANTS哨兵不存在不小心看到全部。4.2 一处注册覆盖全部查询SQLAlchemy 的两个 Session 事件承担了全部执法event.listens_for(SessionLocal,do_orm_execute)deftenant_read_filter(state):ifnot(state.is_selectorstate.is_updateorstate.is_delete):returntidresolve_tenant()iftidisALL_TENANTS:# 平台视角显式声明才放开returnstate.statementstate.statement.options(with_loader_criteria(TenantMixin,lambdacls:cls.tenant_idtid,include_aliasesTrue))event.listens_for(SessionLocal,before_flush)deftenant_write_guard(session,flush_context,instances):tidresolve_tenant()forobjinsession.new:# 新行自动填 tenant_idifisinstance(obj,TenantMixin)andobj.tenant_idisNone:obj.tenant_idtidforobjin[*session.dirty,*session.deleted]:ifisinstance(obj,TenantMixin)andobj.tenant_id!tid:raisePermissionError(跨租户写被拦截)# 纵深防御with_loader_criteria会把条件加到所有挂了TenantMixin的实体上包括 join 和别名所以业务代码一行都不用改。写守卫是第二道防线正常业务根本查不到别家的行能走到这里说明有代码用ALL_TENANTS捞出了对象又切回某个租户的上下文去写这本身就是 bug。4.3 框架覆盖不到的地方ORM 事件管不到的面必须一个个列出来写进开发纪律原生 SQLtext(...)、Core insert、bulk 操作新代码禁止用裸 SQL 读写租户表FTS5 全文索引虚表不走 ORM加一列tenant_id UNINDEXED查询侧显式AND tenant_id ?性能损耗可以忽略线程池ContextVar不会自动跨executor.submit传播。回复后处理、识图这些提交到线程池的任务一旦丢了上下文就会回落到 default。所有 submit 点统一包一层executor.submit(run_with_current_tenant(fn,*args))# 内部是 contextvars.copy_context()唯一约束原来分类名唯一SKU 唯一是全局唯一。多租户后A 企业建了「售后」分类B 企业就建不了导入 FAQ 整批失败。唯一约束要全部改成(tenant_id, name)。五、登录进来的都是自己人这个前提不成立了单机版里能登录后台的就是客户自己的员工所以大量历史接口只校验是否登录。SaaS 之后每个租户都拿着一个合法 token。5.1 平台接口的收口逐个排查后发现租户拿着自己的 token 可以调用大模型对话接口消耗平台的 API Key也能驱动闲鱼、群发这些根本不属于他的渠道。逐个端点补依赖一定会漏而且新写的端点默认仍然是租户可访问。所以改成在 router 级别声明一次_PLATFORM_ONLY[Depends(platform_scope(get_current_active_user))]ifTENANT_MODEelse[]api_router.include_router(llm_router,prefix/llm,dependencies_PLATFORM_ONLY)api_router.include_router(unified_broadcast_router,prefix/unified-broadcast,dependencies_PLATFORM_ONLY)api_router.include_router(xianyu_router,prefix/xianyu,dependencies_PLATFORM_ONLY)# ... 共十几个平台专属 router例外要想清楚含有企微回调这类免登录端点的 router 不能挂闸门否则闸门会要求回调带登录态企微的推送会直接被 403。这类 router 靠端点级的权限依赖来保护。再配一条结构性测试遍历这批前缀下的所有路由断言每条都带着闸门。以后有人新增平台 router 却忘了挂闸门CI 会直接挡下。靠测试守住约定比靠文档提醒可靠得多。5.2 进程级单例多租户的隐形杀手比接口更隐蔽的是进程内的全局状态。两个真实的例子关键词匹配器原来是一个进程级单例保存关键词时整体重建。多租户之后任何一家企业保存一次关键词就会把整个进程的匹配规则换成它那一份缓存 60 秒。在这 60 秒里所有企业的客户收到的都是这家的关键词回复。修法是按租户各维护一套匹配器default 租户复用原来的单例保证单机版行为不变。大模型客户端池也是进程级的按配置 id 键控。租户点一次「设为默认」会触发刷新原来的刷新逻辑是清空后按当前可见的配置重建。而在租户上下文里可见的配置只有自己那几条结果把平台和其他所有租户的客户端全清掉了。修法是初始化时显式切到ALL_TENANTS注册全部租户的配置。教训是做多租户改造时要把所有进程级、没有租户键的状态列一张清单逐项确认它是真的全局还是只是以前恰好只有一个客户。5.3 每家企业可以用自己的大模型大模型配置表也挂上了租户过滤企业可以填自己的 Key没配置时回落到平台默认不填 Key 的租户和改造前完全一样。这里有三个容易出安全问题的点按 id 取配置时只允许命中本租户 平台的行。否则租户把配置 id 改成别人的行号就能用别人的 Key配置管理接口不用这个回落逻辑否则GET /llm/configs/{id}会把平台配置的 Key 明文返回给租户租户自填的api_base过 SSRF 闸门只允许 http/https解析出的地址不能落在回环、私网或保留网段。因为租户既能建配置又能点「测试」而测试接口会把上游的错误响应原样回显不拦的话就是一个能读内网的 SSRF。平台账号不受这个限制因为自建的 Ollama / vLLM 本来就跑在内网里。开通新企业时会把平台的整套模型配置克隆一份给他同名、同参数但 Key 为空、默认不启用。企业进来看到的是一整排熟悉的选项逐个填 Key 就能用平台的 Key 一个都不会暴露。六、真机踩坑回调里的 corpid 不可信消息链路的租户归属第一版是按回调里带的 corpid 判断的看起来天经地义。上真机后发现回调明文里的那个字段有时是 suite_id有时是空串有时是自建应用的 corpid。后果很曲折拦截 PermissionError回调到达corpid 字段是 suite_id按它推断租户推断错了拉取消息后保存消息游标 cursor写守卫:账号行属于别的租户异常被 except 吞掉cursor 永不前进同一批消息每轮重拉新消息落库归到错误的租户有意思的是写守卫拦对了。它发现了归属不一致拒绝了跨租户写。但外层有一个防止单条失败影响整批的宽泛 except把这个信号吞掉了。表现出来就是一批消息被反复拉取而日志里什么都看不出来。修法是换一个归属真源客服账号行它在授权时就绑定了所属企业。入口先用 default 起步定位到账号行之后按行上的tenant_id校正本轮上下文后续的回复、落库、后处理全部按它来。同一类问题还有一个反方向的例子平台超管的控制台里「授权企业」表的账号数恒为 0。原因是超管的上下文恒为 defaultfail-closed 的过滤把所有企业的账号行都藏起来了。这里需要显式放开只在真正的平台视角下切到ALL_TENANTS去读定位到行之后要写时再切回row.tenant_id让写守卫继续按行的真实归属生效。排查客户问题时平台超管还需要以某家企业的身份看数据。我们做了一个租户视角超管在顶栏选一家企业之后浏览器的每个请求都带上X-Tenant-Id中间件校验通过后把这次请求的租户上下文切成这家现有页面零改动就能显示这家企业的数据顶栏下方常驻一条橙色警示。校验条件是显式合取的token 签名有效、token 属于平台、数据库里这个账号确实是平台管理员、目标企业存在任何一条不满足就不切换。还有一条最容易忽略没有有效 token 时这个请求头一律忽略。消息回调端点是免登录的否则任何人 POST 时带上这个头就能冒充别家企业走回调链路。平台超管以租户视角查看某家企业的「客服账号与接待人」页顶部橙色条提示当前视角。这家企业用的是 OEM 贴牌版所以界面品牌显示为合作方自己的名称成员姓名、企业与账号 ID 已打码这两个坑放在一起看正好说明了 fail-closed 的代价和价值它会制造看不到的 bug但不会制造看到别人的 bug。前者一上真机就暴露后者可能永远没人发现。七、开通与登录让客户自己走完SaaS 能不能规模化要看开通链路里有没有需要平台人工介入的步骤。我们的目标是企业在应用市场点安装之后不需要找任何人。7.1 授权即开户企业扫码授权后回调里换取永久授权码随后自动完成建租户记录、建登录账号、同步客服账号并打上租户标、克隆模型配置。企业卸载应用时租户标记为已取消、登录账号停用已经签发的 token 在下一次请求时就会被拒每个请求都校验租户状态数据全部保留重新授权后自动恢复。这里有一个事务边界的坑克隆模型配置那一步如果和建账号放在同一个事务里一旦老库还没迁移好、查询抛出no such column回滚会把刚建的租户和账号一起带走。结果是客户授权成功了却永远登不进来日志里只有一句不影响授权流程。所以身份先单独提交附属的初始化各自用独立事务。7.2 客户拿不到自己的用户名登录账号的用户名是 corpid。但服务商模式下企微给服务商的是服务商专属的密文 corpid客户在自己的管理后台只能看到明文 corpid。也就是说客户根本不知道自己的用户名。最后做了三条登录路径都不用平台人工告知方式链路要点工作台点开应用企微内置浏览器天然带着成员身份走静默网页授权客户什么都不用输这是推荐入口电脑浏览器扫码手机企微扫码 → 手机上点「确认登录」→ 电脑端轮询换 token手机上那一下确认是防二维码钓鱼生成二维码的接口不需要登录攻击者可以自己生成一个码丢进企业群骗人扫所以放行凭证是回调时现发、只出现在扫码人页面上的一次性 token明文企业 ID 密码后端调用官方明文转密文接口定位账号只有长得像 corpid 的输入才去调接口解析失败统一报用户名或密码错误不让登录接口变成某企业是否已授权的探测器登录页默认是企业微信扫码OEM 贴牌版的品牌名、Logo 与文案可按合作方定制7.3 接待人员名单即登录授权开户时只建了一个管理员账号可每天看会话、回消息的是接待员。我们把这个企微成员在本企业的接待人员名单里直接当作登录授权管理员在后台把人加进名单这个人就能扫码登录移出名单登录权限随即失效。名单的真源在企微侧并且是按客服账号分别维护的。对账时有两条硬约束必须按全租户所有客服账号的并集来算。同一个人常常挂在多个客服账号下只按当前账号对账会把别的账号下的接待员误停名单取不全时只增不停。任何一个账号的名单拉取失败本轮就一律不停用任何人并且把本次不完整透传给操作者。否则移除了却还能登录没有人看得见。租户自己能配置的那部分接待人员按客服账号分别添加加进名单即可扫码登录转人工的提示语、关键词、服务时段都是企业级配置没设置过的项回落平台默认值企业 ID 已打码八、能撑多少家按每家企业每天 5000 次回复估算SQLite 单机的容量是这样的状态容量瓶颈代码原样5~10 家取历史记录全表扫描 SQLite 默认配置修完两个硬伤 消息归档 合并写事务约 50 家舒适100 家天花板单进程 Python、共享的 LLM 线程池、单库无故障隔离两个硬伤都很典型取会话历史走了全表扫描。查询条件是receiver_id ? OR group_id ?而表上的三个索引一个都用不上于是每次回复的成本随消息表总行数线性增长。这个 OR 有历史原因微信客服的会话 id 格式和其他渠道不同只比一个字段会让聊天界面查不到消息所以修法是给两个字段各建一个带时间的复合索引让 OR 走索引合并。生产环境的 SQLite 跑在默认配置上。journal_modeDELETEsynchronousFULL每次提交两次 fsync写期间所有读被锁。一次回复有 4~5 个写事务实际写吞吐只有每秒 50~150 个事务。改成 WAL synchronousNORMALbusy_timeout要在每个连接建立时通过 connect 事件设置因为后两个参数是按连接生效的。再往上每一步都不推翻上一步阶段客户数形态1~50单体 SQLite 修补 tenant_id 落地当前2~1000拆成接入 WorkerPostgreSQL Redis 消息队列3~1 万上 K8sWorker 按队列深度自动伸缩消息历史迁到宽表存储4~10 万分舱cell每舱一套 Worker 库分片服务约 5000 家扩容等于加舱故障关在舱内算到 10 万家时结论可能有点反直觉最大的成本和瓶颈是大模型调用费和推理并发不是基础设施。到那个量级需要一个独立的 LLM 网关层负责租户级并发池、令牌计量这就是计费依据和多供应商路由降级。为了让后面的阶段能平滑过渡现在就要埋下三颗种子tenant_id 贯穿所有数据包括全文索引、上传文件的目录素材库已经按uploads/tenants/{corpid}/分目录corpid 拼进路径前先过白名单进程内状态全部外置人工接管开关、各种进程缓存、单进程定时任务。在这之前workers1是硬前提回调入口和消息处理解耦回调只做验签、落队列、返回 200。九、总结挑战机制老客户的库不能因升级出问题按库结构决定运行模式加列带默认值两种模式各跑一遍全量测试隔离不能靠人记得加 whereContextVar with_loader_criteria自动过滤 写守卫FTS5、线程池、唯一约束逐项补齐忘了设租户怎么办fail-closed折叠为 default只会少看不会多看租户拿着合法 token平台 router 级闸门 结构性测试进程级单例按租户拆分企业用自己的大模型按 id 白名单、Key 不外泄、api_base 过 SSRF 闸门回调里的 corpid 不可信以客服账号行作为归属真源开通不能靠人工授权即开户三条登录路径接待人员名单即登录授权能撑多少家修两个硬伤到约 50 家四阶段路线到 10 万家三条贯穿始终的经验1. 默认值决定安全性。忘了设租户就折叠成 default平台视角必须显式声明新 router 默认要挂闸门。出错时选那个容易被发现、不会造成不可逆伤害的方向看不到数据的 bug 一上真机就暴露而数据一旦泄漏给别家企业就收不回来了。2. 约定要靠测试守住。“平台 router 必须挂闸门”“两种模式失败集合一致”“跨租户写必须被守卫拦下”这些都不是写在文档里的提醒而是 CI 里的断言。3. 多租户改造的难点在以前恰好只有一个客户的地方。进程级单例、全局唯一约束、登录即自己人的接口、全平台共用的配置它们以前都不是 bug只是隐含了一个已经不成立的前提。这次改造的成果就是已经上架企业微信应用市场的「冰石AI客服」。企业在应用市场搜索安装后不需要申请任何大模型 Key三步就能让 AI 接待微信客服配置客服账号与接待人直接创建客服账号或同步企业已有的账号再添加转人工时接手的员工生成提示词用提示词助手按企业情况生成机器人的岗位说明书一键应用到客服账号导入知识库上传 Word / PDF / PPT / Excel 等资料自动整理成问答审核后生效。前三篇讲到的三级应答链路、图文知识库、AI 自主学习、转人工在 SaaS 版里都可以直接使用。同一套 SaaS 也支持OEM 贴牌合作伙伴可以用自己的品牌名、Logo 和后台域名为客户提供服务上文截图里的租户就是这样一家 OEM 客户。冰石机器人官网icestonebot.com本文所述机制基于冰石AI客服 2026 年 9 月的实际部署版本配置项名称、默认值与实现细节以实际版本为准。请在遵守企业微信平台规范的前提下合规使用。
返回列表