说起这事,得从我们团队被合规部门“约谈”那天讲起。当时大家正在用开源的 WorkBuddy 当个人 AI 助理,查资料、写摘要、整理会议纪要不亦乐乎,直到合规同事甩过来一张截图:你们的提问内容、内部文档摘要,全都通过公网模型服务走了一遍,报价策略和客户信息就这么出去了。于是我这个平时只管业务代码的人,被迫把 WorkBuddy 从里到外拆了一遍,做了模型接入层改造、接上公司私有化的大模型服务,又顺手把多用户和 @同事 的协作机制补完。前前后后折腾了小一个月,终于把这套东西从“个人玩具”变成了“能过审的企业内网应用”。
这篇文章不打算讲太多虚的,就完整记录我是怎么想的、怎么拆的、怎么填的坑,以及最后跑起来长什么样。整个改造涉及模型接入适配、身份权限体系、协作消息通道、部署迁移这几个核心环节,下面按模块一个个说。
1. 为什么要把开源版 WorkBuddy 改造成企业版
1.1 开源版的边界和痒点
先说说 WorkBuddy 本身。它的定位很明确:个人工作助理,核心能力包括对话问答、任务待办、资料整理、轻量知识库检索。界面清爽,部署简单,下载一个二进制包,配好模型 API Key 就能跑。对于个人用户来说,这东西体验是真的好,尤其它的会话管理和插件机制,比我自己写过的几个实验脚本要成熟得多。
但放到企业环境里,它的边界也很清楚:第一,模型流量默认走公网,所有对话内容都发送到第三方模型服务,这在多数公司是过不了合规审查的;第二,它没有统一的用户体系,每个人自己注册自己的账号,账号之间互相看不到,更没法把任务指派给别人;第三,权限和审计基本为零,管理员无法知道谁在什么时候问了什么,更不用说控制谁能访问哪些业务数据。
所以当时的处境是:产品很好用,但没法直接塞进公司内网。直接弃用又可惜,从头写一个又费时费力。最合理的路径,就是在开源版本的基础上做“企业化改造”,把外层能力补上。
1.2 企业版到底多了什么
我把需求拆成了四层,每一层都是独立可交付的模块:
| 能力层 | 要解决的问题 | 落地内容 |
|---|---|---|
| 模型接入层 | 数据不出内网、统一模型入口 | 对接公司私有化模型网关,提供统一适配接口,支持多模型路由与降级 |
| 身份与权限层 | 谁是谁、谁能看什么 | 对接企业统一认证源,建立用户、部门、角色模型,实现会话级数据隔离 |
| 协作消息层 | 从个人工具变成团队工具 | 会话内 @同事、任务指派、实时通知、跨人上下文引用 |
| 运维与审计层 | 可管理、可追溯 | 管理员后台、操作日志、模型调用记录、提示词审计 |
这四层不是拍脑袋定的,而是从实际使用场景反推出来的。比如模型接入层,如果不先解决,后面所有功能都白搭,因为没人敢把内部数据往公网送;身份权限层则是协作功能的前提,没有用户体系就谈不上 @同事。
1.3 选型:改造,而不是重写
团队内部当时也争议过:要不要干脆从零开发一套?我坚定选改造,理由有三个。一是 WorkBuddy 的核心交互逻辑已经过大量用户验证,没必要重造轮子;二是开源项目的代码结构清晰,核心模型调用和会话管理都是独立模块,接口留得比较规整,改造难度没有想象中大;三是改造成本远低于重写,我们能在一个迭代周期内把企业版跑起来,单独重新开发至少要多两倍时间。
事实证明这个决策是对的。后半个多月我们就把模型接入、用户权限、协作功能全部上线,业务价值很快体现出来。当然,改造也不是没有代价,后面会提到不少兼容和迁移的坑。
2. 模型接入层:让 WorkBuddy 说“公司话”
2.1 统一模型适配接口的设计
改造的第一步,是给 WorkBuddy 换一双能“说公司话”的嘴。公司内部有一支算法团队维护了一套私有化大模型服务,对外暴露的是标准大模型 API 兼容协议。这个协议是行业通用规范,主流推理框架基本都支持,所以适配的核心不是“接上”,而是“接得稳”。
我参照 WorkBuddy 原有的模型调用模块,重写了一个适配器,把不同模型来源统一成一个接口。核心思路是:业务层只依赖一个抽象类,具体请求走哪条通道,完全由配置决定。
# adapter.py class ModelAdapter: def __init__(self, provider: str, config: dict): self.provider = provider self.config = config self.client = self._build_client() def _build_client(self): if self.provider == "internal_gateway": return CompatClient( base_url=self.config["base_url"], api_key=self.config["api_key"], timeout=self.config.get("timeout", 30), ) elif self.provider == "local_vllm": return LocalClient( endpoint=self.config["endpoint"], ) elif self.provider == "legacy_public": return PublicClient( api_key=self.config["api_key"], base_url=self.config.get("base_url"), ) else: raise ValueError(f"unknown provider: {self.provider}")这样设计的好处是:WorkBuddy 原有的问答、摘要、知识库检索逻辑完全不用动,替换的只是底层传输层。以后企业内部从 A 模型切到 B 模型,只需要改配置和适配器,不动业务代码。这一点在后面实际换模型时帮了大忙。
2.2 模型路由、降级与容错
模型网关接上以后,我又加了两个在个人版里完全没有的东西:路由和降级。
路由的逻辑很简单:不同请求走不同模型。比如日常对话用通用模型,代码相关的问题用代码专用模型,文档总结用上下文更长的模型。路由规则配置在环境变量里,是一张优先级列表:
# model_route.yaml routes: - match: ["代码", "bug", "函数", "重构"] model: code-llm fallback: chat-llm - match: ["长文", "总结", "论文"] model: long-context-llm fallback: chat-llm - match: [".*"] model: chat-llm fallback: chat-llm-small降级的逻辑是:当首选模型返回错误或超时,适配层自动切换到备用模型,再失败就把错误信息抛回给用户提示稍后重试。这个设计很关键,因为公司模型服务偶尔会有发布更新或高峰限流,如果没有降级机制,用户会直接感受到“助手挂了”。
容错方面我还踩了一个小坑:最开始没设统一的超时时间,结果某模型服务在推理大文档时经常超过默认的 10 秒超时,前端直接报错。后来把所有超时配置化,并把 30 秒作为默认值,同时在适配层加了重试逻辑:连接类错误重试 2 次,业务类错误(比如内容审核拒绝)不重试,直接返回原因。
2.3 系统提示词与内容脱敏
企业环境和个人使用还有一个重要区别:提示词需要加一层企业规范的约束。我在适配层加了一个动态系统提示词构建器,会根据当前用户角色、当前会话所在工作空间,注入不同的指令。
比如普通成员对话时,系统提示词会包含“不得输出公司未公开数据”“回答需基于公司现有资料,不确定时如实说明”;当用户处于商务空间时,还会额外增加“不得对外披露客户名称与报价细节”等规则。这些提示词不是写死在代码里的,而是存数据库,管理员可以在后台编辑,不用发版就能调整。
另外,我还在请求链路里加了一层可选的脱敏中间件。它会在提示词发送到模型之前,用正则和实体识别把手机号、邮箱、身份证号、银行卡号替换成占位符,等模型返回后再把占位符还原。最开始团队觉得没必要,后来模型偶尔会在对话里复述用户输入的手机号,合规那边又找了一次,装上脱敏才彻底解决。
3. 身份体系与权限:先分清谁是谁
3.1 对接企业统一认证源
多用户改造绕不开的第一件事就是:让用户用企业账号登录。我们公司内部有统一的目录服务,支持 LDAP 协议,网上也大量讨论过类似场景,所以第一版直接对接 LDAP,没有另建账号体系。
WorkBuddy 原版是标准的单用户登录,用的是本地账号密码表。我把它改成了两层:本地 user 表只保留业务信息(企业账号唯一标识、显示名、部门、角色),不做密码校验;实际认证委托给公司统一认证服务,认证通过后回传用户身份信息,平台再落一份本地映射。
def authenticate(login_name: str, password: str) -> User: ldap_conn = connect_ldap() entry = ldap_conn.authenticate(login_name, password) if not entry: return None user, created = User.objects.get_or_create( ldap_uid=entry.uid, defaults={ "display_name": entry.display_name, "department": entry.department, "email": entry.email, }, ) # 每次登录同步最新职位和部门信息 user.display_name = entry.display_name user.department = entry.department user.save() return user这里有个很重要的数据同步问题一定要注意到:LDAP 里的人员信息不是静态的,有人转岗、有人离职、有人改名字。所以每次登录都同步一次字段,不要只在第一次创建时写入。
3.2 数据权限与会话隔离
用户体系建好后,紧接着就是权限。原来的 WorkBuddy 只有一个用户,所有会话都是自己的;企业版里必须做到:每个人登录后只能看到自己有权限的会话和任务。
我设计了一个“工作空间”的概念,类似群组。用户可以属于多个工作空间,每个工作空间有自己的成员列表。会话、任务、知识库文档全部都挂在工作空间下面,用户访问某条数据前,系统会检查当前用户是否属于该数据所在工作空间。
权限角色分三档:普通成员可以创建会话、参与任务;空间管理员可以管理成员、编辑空间内共享资料;系统管理员可以看全部空间、查看审计日志、修改系统配置。这里不要做太细的权限矩阵,企业工具初期越简单越容易推,太复杂的权限规则反而让用户不知所措。
3.3 操作审计与日志留痕
企业应用不能只有功能,还得有“事后追溯”。我简单做了三份日志:登录日志、模型调用日志、操作日志。
登录日志记录谁在什么时间从哪里登录成功或失败;模型调用日志记录每次请求的模型、token 数量、响应时长,以及“已脱敏后的”提示词摘要;操作日志记录创建会话、删除任务、修改成员权限这类关键动作。
这里有一个经验:日志里千万不要存原始提示词,也不要存模型原文返回。一方面内容可能包含敏感用户数据,另一方面这种日志本身就成了合规风险点。我后来把所有提示词先走脱敏再落日志,日志组件的代码里也做了二次过滤,禁止写入包含手机号、身份证号规则的字符串。
4. @同事 协作链路:从单机助理到团队工作台
4.1 消息与提及的数据结构设计
接下来是最有意思的部分: @同事。个人版 WorkBuddy 只有“我”,团队版必须有“我们”。业务需求其实很朴素:在对话里 @一位同事,系统要能提醒他;还可以把一个对话片段变成一条任务指派给他,任务状态要能跟踪。
我先把消息模型扩展了,增加提及关系。原版一张 message 表能搞定,现在需要一张 mention 表来记录“这条消息提到了谁”。为了避免每次渲染都做字符串解析,我在保存消息时就把 @的人抽出来,存成结构化数据,渲染时再反查显示名。
CREATE TABLE work_message ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL, session_id UUID NOT NULL, sender_id UUID NOT NULL, content TEXT NOT NULL, content_rendered TEXT NOT NULL, msg_type VARCHAR(20) NOT NULL DEFAULT 'text', created_at TIMESTAMP WITH TIME ZONE DEFAULT now() ); CREATE TABLE work_mention ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), message_id UUID NOT NULL REFERENCES work_message(id), mention_user_id UUID NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'unread', created_at TIMESTAMP WITH TIME ZONE DEFAULT now() );@解析这块我吃了不少亏。最开始用简单的字符串匹配,把 @后面连续的汉字或者字母当作用户名,结果遇到“林小霞@设计部”这种带部门后缀的写法人名根本切不对。后来改成前端输入时实时搜索用户,选中后插入一个带用户 ID 的专用标记,比如[~uuid:~123e4567-e89b-12d3-a456-426614174000],后端存储这个标记,渲染时再替换成带链接的显示名。后端也要做一个反向兼容:如果收到纯文本的 @某某,就用模糊搜索兜底解析。
4.2 实时通知:WebSocket 还是轮询
@人 之后,被 @的人怎么收到提醒?这里我对比过两种方案:轮询和 WebSocket。
轮询最简单,前端每隔几秒拉一次未读消息数,但体验一般,尤其在消息密集时,延迟明显而且浪费资源。WebSocket 体验好,但要处理断连、心跳、重连、消息时序这些麻烦事。最终我选的是 WebSocket 加消息补偿的组合:
- 正常在线时,通过 WebSocket 实时推送提醒;
- 每次用户打开页面或刷新时,主动拉一次最近未读;
- WebSocket 断线期间的消息,靠重连后的全量拉取兜底,不会丢。
推送消息的载荷很小,只要告诉前端“有人在你所在的 XX 会话里 @了你”,前端自己决定要不要弹红点、要不要提示音。不要把整个消息内容塞进推送里,不然很容易把聊天内容全部透出,又增加一层数据暴露面。
# notify.py def push_mention_event(mention_user_id: str, session_id: str, sender_name: str): payload = { "type": "mention", "session_id": session_id, "sender_name": sender_name, "timestamp": time.time(), } ws_manager.send_to_user(mention_user_id, json.dumps(payload, ensure_ascii=False))4.3 结合模型:把“被 @的人”变成上下文
@同事 不只是“通知人”,还可以跟模型联动。我做了这样一个功能:当用户在一个任务型会话里 @了某位同事,模型在生成回复时,会把这位同事的公开资料和最近在同一个工作空间的待办摘要放进上下文。
这样做的好处是,模型不再空泛地回复“你可以找某人了解情况”,而是能说出“王工上一条关键记录显示客户端那边已经确认了部署窗口,建议直接找他确认最终时间”。当然,这里要注意控制上下文体积,我只放最近 5 条该同事的待办标题,不放完整内容,避免把整个工作空间的数据都灌给模型。
权限边界也要卡死:只有被 @的人与当前用户处于同一工作空间,系统才允许把他的资料注入上下文。跨空间 @一律拦截并提示“无法提醒该用户”。
5. 部署上线与数据迁移实操
5.1 改造后的部署架构
个人版 WorkBuddy 是单进程应用,数据默认存在本地,一个人跑起来很简单。企业版涉及多用户、WebSocket、任务调度,我把它拆成了三个服务:
| 服务 | 职责 | 建议资源 |
|---|---|---|
| web | 前端静态资源、API 入口 | 2 核 4G |
| worker | 异步任务、消息推送、定时清理 | 2 核 4G |
| db | PostgreSQL,存业务数据 | 2 核 4G 起步,磁盘按量扩容 |
部署直接用 Docker Compose 编排,配合公司已有的内网网关做反向代理。模型网关不归我们管,是统一 AI 平台提供的外部依赖,我们只需要配置好 base_url 和密钥。
5.2 环境变量与配置文件
这里给一份我最后定型的.env.example,供参考:
# 基础配置 APP_ENV=production APP_SECRET=change-me BASE_URL=https://workbuddy.internal.example.com # 数据库 DATABASE_URL=postgresql://workbuddy:workbuddy@db:5432/workbuddy # 认证 AUTH_TYPE=ldap LDAP_URI=ldap://internal-ds.example.com:389 LDAP_BASE_DN=ou=people,dc=example,dc=com LDAP_BIND_DN=cn=admin,dc=example,dc=com # 模型接入 MODEL_PROVIDER=internal_gateway MODEL_BASE_URL=http://ai-gateway.internal.example.com/v1 MODEL_API_KEY=sk-xxxx MODEL_NAME=default-chat # 降级模型 FALLBACK_MODEL_PROVIDER=internal_gateway FALLBACK_MODEL_BASE_URL=http://ai-gateway-backup.internal.example.com/v1 FALLBACK_MODEL_API_KEY=sk-xxxx FALLBACK_MODEL_NAME=small-chat # WebSocket WS_MAX_CONNECTIONS=2000 WS_HEARTBEAT_INTERVAL=30环境变量统一由部署平台注入,代码里只读环境变量,不把任何密钥写进代码仓库。这一点反复强调都不为过,企业内网环境里,代码仓库可能有很多外包、实习生能访问,密钥泄露的后果远比你想的严重。
5.3 从个人版到企业版的数据迁移
我们当时已经有用个人版跑了快两个月的同事,里面有不少有价值的对话和待办,直接丢掉太可惜。个人版用的是 SQLite,企业版换成了 PostgreSQL,数据迁移我写了一个一次性脚本,核心思路是:
- 把 SQLite 里所有会话、消息、待办导出为 JSON;
- 在 PostgreSQL 里建好用户映射表,把原来单一用户的 ID 映射到新企业用户 ID;
- 按工作空间重新归属:默认把所有历史数据归入“个人归档”空间,只有用户自己可见;
- 消息里的旧式文本 @不做迁移,统一当作普通文本处理,避免渲染出错。
迁移之后要跑一遍验证清单,我建议至少检查这些项:
- 老用户的会话列表能正常打开,历史消息完整;
- 新用户能登录,且默认看不到老用户的“个人归档”空间;
- 模型适配层能正常调用私有化模型,返回内容无乱码;
- 在会话中 @一个真实用户,对方能收到提醒;
- 管理员能查看到模型调用日志和登录日志。
6. 上线后踩过的坑与排查手记
6.1 模型 API 兼容性的那些坑
第一个坑是标准 API 兼容不等于完全兼容。公司算法团队说模型服务“支持标准协议”,结果实测时发现,它对max_tokens参数返回 400,要求必须传max_completion_tokens;另一个模型则完全不支持stream参数,如果传了,返回内容直接为空。这类问题只能在适配层做参数归一化:把上层所有参数统一成一套,适配器根据后端模型能力做字段映射和过滤,而不是把所有参数据原样透传。
我还遇到过一个诡异的 DNS 问题:容器里解析模型网关域名时有时成功有时失败,一旦失败就引发大批超时。排查很费劲,最后发现是新建容器没有立刻获取到内部 DNS 配置,平台侧有概率性问题。解决方案是显式在容器配置里写死了内部 DNS 地址,并在健康检查里加入对模型网关域名的探测。
6.2 并发与性能问题
上线第二周就出现了一次事故:午休后有大概 80 个用户同时用“批量总结”功能,每个请求携带的上下文很长,模型服务直接被压垮,返回所有请求超时。复盘后发现,我们在适配层完全没做并发控制,所有请求同时涌向模型网关。
后来我加了两个机制。一个是全局限流:每个用户在 10 秒内最多发起 5 次模型请求,超出的排队或直接返回“请稍后再试”;另一个是信号量限制:在适配层维护一个全局信号量,限制同时在途的模型请求数量,防止打爆后端。
这项调整需要解释清楚逻辑:不是限制团队规模,而是保护后端模型服务。私有化模型服务的推理资源有限,QPS 通常远低于公共云大模型,如果业务层不做限流,一个批量功能就能把整个模型网关拖垮,导致所有业务不可用。
6.3 被 @ 没提醒的排查实录
有一阵子,好几个人反馈“我明明被 @了,但完全没提醒”。我查了很久,发现是 WebSocket 的连接管理出了问题:服务端的连接 map 只记录了用户 ID 到连接的映射,但用户从两台设备登录时,后登录的设备把之前的连接顶掉了,而前一台设备还停在旧页面,收不到任何推送。
这里我用了两个改进来解决:一是连接映射改成用户 ID 到多连接列表,一台设备掉线不影响另一台;二是每次推送时对每个连接做可达性探测,探测失败就清理掉。同时前端的重连逻辑也要做好,不要只在断线时重连,还要监听“网络恢复”事件,随时主动拉一次未读。
6.4 权限越权的小漏洞
还有一个隐蔽的权限漏洞值得单独说。最开始我判断“用户能否查看某条消息”时,只看消息所在的会话 ID 是否属于该用户可见的会话集合,但没校验消息与工作空间的归属。结果同事通过构造请求,把其他工作空间的会话 ID 拼进接口路径,就能读到自己本不该看到的历史消息。虽然这只是内部工具,但也说明一个道理:接口鉴权必须在数据访问层统一拦截,而不能分散在业务代码各处写条件判断。我把所有数据访问收敛到一个 repository 层,统一传入当前用户和其可见工作空间 ID 列表,由这一层强制过滤,才算彻底堵住。
除开这些,模型超时参数、时区导致的日期错位、浏览器缓存导致的新版前端不生效等问题也零零星星遇到过,但都比较常规,就不展开了。整体来看,这次改造最大的收获不只是功能上线,而是让我对“把开源工具改造为企业应用”这件事有了完整的操作框架:先接数据通道,再管身份权限,最后做协作应用。每个环节的坑都很有代表性,尤其是“标准兼容协议并不完全兼容”这条经验,值得所有计划接私有化模型的人记在笔记里。
如果你们团队也在做类似的事情,我有一句心里话建议:不要一开始就想把功能做满,先让 10 个核心用户跑起来,把链路走通,再去批量推给整个组织。企业工具从来不缺少功能,缺少的是真正能融入日常工作流、让协作链条变短的那个切入点。