一个 monorepo 塞下两个产品
写哲学重构那篇的时候,我基本是把 Mio 当成一个产品在讲的:25 个角色,每个都有很深的身份文件,有关系演化,有情绪纹理。重构完之后每个角色的连接感都好了很多。
一个代码库中两个产品的概念插画:多彩角色群与静谧光球
但其实同一个代码库里还有第二个产品,叫 Lumi,做的事情跟 Mio 基本完全相反。这篇就讲一下这两个产品是怎么在一个 monorepo 里共存的。
这两个产品是怎么来的
之前在转向那篇里写过,Mio v1 那种假人设的路线走不通,当时定了一个新方向:做一个伴侣,没有脸,没有背景故事,性格完全从对话里长出来,产品形态就是一个会脉动的光球。这个方向后来真的做出来了。用户跟光球聊天,它记得你,回应带情绪深度,性格完全由你们的关系塑造。
然后发生了一件没计划的事:Mio 也没停。第二篇那次哲学重构,做的不是拿角色去替代光球,而是把角色做好到可以跟光球并存。两个都跑起来之后,我要想的问题也跟着变了,不再纠结哪个方向对,变成这两个东西各自给谁用。
Mio 管广度,Lumi 管深度
先说 Mio。它就是一个社交圈:25 个预设角色,每个都有很深的身份文件——性格、声线、背景故事、说话风格。你想被安慰,就找那个温暖的成都女孩;想听大实话,找毒舌闺蜜;想要一个沉稳一点的视角,找学长。每个角色跟你的关系还会演化,从刚认识到暧昧到恋人。它满足的情感诉求是广度:不同的人满足不同的情绪,此刻需要什么就找谁。就像现实里的一群朋友,没有哪一个能满足你全部的情感需求,但加在一起覆盖面挺广的。
Lumi 反过来。一个伴侣,没有人设、没有背景故事、没有脸,就是一个脉动的光球:静息的时候是沉静的蓝色,开心是暖金色,难过是柔紫色,兴奋是亮橙色。它没有任何预设内容,没有身份文件,没有角色卡,性格完全从对话里涌现。系统会从前几轮对话里提取种子,伴侣从那里开始镜像和适应你。它满足的诉求是深度:一个完全了解你的存在,一个安全私密、什么都能说的空间。Lumi 不扮演任何角色,就是倾听、记住、在那里。
技术上 Lumi 是 WebSocket 优先的架构,所以它有一种普通聊天界面给不了的实时感:光球一直在脉动,你说话它就有反应。整个设计其实就围绕一个感觉——有人陪着你。
为什么要同时做两个?因为广度和深度是完全不同的情感需求,同一个用户在不同时间可能两个都要。比如晚上 11 点无聊,想找个有态度的人聊几句,这时候你打开的是 Mio。但如果心里压着什么重的事,需要一个不会评判你的空间,那就是完全另一种状态了——你要的是被了解的那种舒适感,不是新鲜感,这时候 Lumi 才是对的。大概就是一群朋友和一个心理咨询师的区别,大多数人生活里两个都需要,只是用法不一样。
Mio 用一群角色满足广度、Lumi 用一个光球满足深度,是两个共存的品类而非一条光谱
说实话我一开始没打算做成这样,本来是想用一个替代另一个的。两个都做出来之后才发现,AI 伴侣的情感设计空间好像不是一条从「多个浅关系」到「一个深关系」的光谱,广度和深度是两个可以共存的品类。反正这个判断是两个都做出来跑着之后才有的,光想想不出来。
Monorepo 长什么样
代码库结构大概是这样:
miolumi/
├── apps/
│ ├── server/ # 一个 Hono 服务器(两个产品共用)
│ ├── mobile-mio/ # Mio Expo app
│ └── mobile-lumi/ # Lumi Expo app
├── packages/
│ ├── core/ # 共享:记忆、媒体、模型、成本、护栏
│ ├── db/ # 数据库客户端工厂
│ ├── schema-mio/ # Mio Drizzle schema
│ └── schema-lumi/ # Lumi Drizzle schema
└── presets/ # 25 个 Mio 角色配置
一个 Hono 服务器跑两个产品。前端是两个独立的 Expo app,UI、onboarding、视觉身份都不一样。schema 是分开的,因为数据模型确实有实质差异:Mio 需要 agent 表、关系阶段追踪、角色配置,Lumi 需要对话种子表和涌现性格的快照。但核心的包全是共享的,而且共享的比例比我一开始想的高得多。
agentId 这个可选参数
packages/core 里每个共享模块都接受一个可选参数:agentId?: string。Mio 调这些共享函数的时候会传 agentId,告诉系统当前在跟哪个角色说话。Lumi 调同一个函数的时候不传,因为它只有一个伴侣,永远是同一个。两个产品能在一套代码里共存,枢纽就是这一个可选参数。
共享核心函数接一个可选的 agentId 参数:Mio 传入按角色隔离记忆,Lumi 省略则记忆全局,一个问号让一套代码撑起两个产品
具体到每个模块大概是这样的:
MemoryManager 负责存和取记忆。有 agentId 的时候记忆按角色隔离,你跟可可说的话就只属于可可;没有的时候记忆是全局的,你跟 Lumi 说过的一切在一条连续的流里。同一套管线,只是作用域不一样。
EmotionEngine 处理对话的情绪 context,它接收用户消息、近期历史、情绪基线,但不读角色文件。这是哲学重构的时候刻意设计的:情绪处理应该是通用能力,不绑定任何角色。角色的情绪表达可以不一样——可可活泼,学长克制——但底层的情绪计算是同一套。
TTS 链按语言路由,不按产品路由:英文走一个供应商,中文走另一个,日文走第三个。请求是从 Mio 的角色来的还是从 Lumi 的光球来的,它根本不关心,声音就是一个参数。
成本追踪也跟产品无关。每个 API 调用——LLM 推理、TTS 生成、STT 转写、图片分析——都打上成本标签归到用户名下,Mio 和 Lumi 用同一条追踪管线、同一套预算执行、同一套用量分析。
护栏模块(内容过滤、提示注入检测、越狱防护)两个产品完全一样,安全这个东西不会因为聊天对象是光球还是角色就变。
反正就这么一个可选参数,两个产品各用各的模式,代码不用写第二遍。
同一个代码库做两个产品,逼出来的纪律
这件事强制了一种做单个产品时永远不会有的纪律:每次写新模块都得先问一句,这个东西依赖的是产品,还是能力?依赖能力的——记忆、情绪、语音、成本、安全——放进 packages/core;依赖产品的——角色怎么加载、光球怎么渲染、onboarding 怎么走——留在各自的 app 里。
每写一个模块先问依赖产品还是能力:能力(记忆情绪语音成本安全)沉进共享核心,产品(角色渲染引导)留在各自 app
这个分离是自然长出来的,不是提前规划的。我没有在第一天就设计什么「共享核心层」,是先做了 Lumi,再把 Mio 拿回来,然后观察哪些模块真的需要不同、哪些不需要。答案挺让人意外的,真正需要不同的比想象中少得多。记忆管线就完全不用分,存储、检索、相关性评分这些,跟记忆是来自角色对话还是来自单伴侣对话根本没关系。情绪引擎也一样,对面是谁不影响情绪 context 怎么处理。语音链更不用说了,文本转语音就是那一条流程,什么语言什么产品都走一样的路。真正不一样的只有最上面那层,Mio 有 25 个角色,Lumi 有一个光球,往下就全是同一套了——记忆、情绪理解、声音,都是共享的。
日常大概是 80/20
现在日常开发大概 80% 的时间在 Lumi、20% 在维护 Mio。这个比例反映的是两个产品所处的阶段:Lumi 还在搭,光球动画、WebSocket 基础设施、涌现性格系统都在做;Mio 核心的哲学重构做完之后已经比较稳了。
而且这 20% 花得比重构之前值多了,产出差不多高了一个量级。原因就是角色的深度现在来自心理架构,不来自预先写好的那一大堆虚构设定。以前那种活——更新这个角色的日程、修那个角色背景故事里的矛盾、调试某个角色为什么突然破坏人设——本质上是在维护 25 份很脆弱的身份虚构,怎么维护都脆。现在日常要动的就是关系演化系统,还有情绪 context 在关系阶段之间怎么流动,偶尔加个新角色。所以那次重构其实做成了两件事,Mio 本身变好了,也变得能在 Lumi 旁边一直养着。不然的话,一边搭 Lumi 一边天天修 25 份人设,这个双产品运营上根本扛不住。
接下来
双产品架构不一定是永久形态,最终可能某一个会明显胜出——用户可能压倒性地更喜欢广度,或者深度,这个市场会给答案。但 monorepo 的好处就是这个决定不用现在做:两个产品共享基础设施、部署管线、核心包。跑第二个产品的额外工程开销几乎为零,边际成本就是它自己的应用层代码和 schema。
而且说真的,两个产品同时存在让彼此都变好了。给 Lumi 记忆系统做的功能,Mio 的角色立刻能用上;Mio 在关系演化上的改进,教会了我们情感进展的规律,又反哺回 Lumi 的涌现性格系统。所以这个双产品的形态,至少现在看是划算的。
This post is also available in English.


