v1 的器官移植报告
软件器官移植手术的技术概念插画
动手重建 Mio 之前,我把 packages/core 里的 import 路径逐个追了一遍,想搞清楚 v1 到底有多少东西要扔。结果比预想的好:大部分模块根本不在意 persona 是什么,接收参数,返回结果,调用方碰巧传了人设数据进去,函数本身对人设无感。最后盘出来的比例是,大约 60-70% 的核心基础设施直接搬过来,另外 15% 需要改造,剩下 15-20% 彻底删除。
为什么 v1 的人格化路线必须砍掉,第一篇写过;新方向长什么样,第二篇也画过:有性格但没有物理身份的陪伴,参照系是 Her,不是 Character.AI。这篇只处理工程问题:哪些模块留下,哪些删掉,为什么。
每个模块过三道筛子
给 v1 的每个模块过了三道筛子:
- 直接读身份定义文件、人格配置文件或行为规则文件的,删。
- 假设了多 agent 架构、要在多个人格或会话间做路由的,删。
- 核心逻辑在一用户一伴侣的世界里还有用的,留。
标准听起来简单,实际要完整审计一遍代码库才能给出确定答案:有些模块看着耦合,实则松散;有些看着干净,底下藏着隐性假设。
直接搬走的 60-70%
这批模块是纯基础设施,不知道也不关心调用它们的是什么类型的 companion。记忆系统是其中工程投入最大的一块:packages/core/memory/ 处理记忆提取、embedding 生成、基于 pgvector 的语义搜索、重要性评分和衰减,零人设耦合。接收 user ID 和对话内容,提取记忆,带向量 embedding 存起来,需要时按语义相关度检索,每行代码都带得走。v1 系列第三篇写过它的重建过程,架构没变:每次对话后提取记忆,768 维向量做 embedding,余弦相似度检索。v2 的变化只是记忆从 agent 绑定变成 user 绑定,改个列名的事,算不上架构变更。
媒体管线全是纯函数。TTS、视觉理解、语音转写、URL 浏览,都在 core/media/ 下:
tts.ts:输入文本和声线 ID,输出音频vision.ts:输入图片,输出描述transcribe.ts:输入音频,输出文字browse.ts:输入 URL,输出页面内容
这四个模块都不知道调用方是谁。整个管线从第一天就设计成纯函数,第六篇(媒体管线)和第八篇(URL 浏览)里记录过,当时的设计决策现在拿到了回报。
订阅系统完全独立于 companion 层。层级定义、用量追踪、功能门控都在 core/subscription/ 里,它知道 free/starter/pro/max 和每日额度,不知道 persona 是什么。
成本追踪是纯会计逻辑。core/cost/ 记录每次 LLM 调用、TTS 合成、视觉分析的 token 数和美元成本,单位经济学那篇的数据就来自这个系统,原封不动搬过去。
模型配置没有人设依赖。models.ts 定义可用的 LLM 模型、定价、上下文窗口和路由规则,模型路由架构原样保留。
系统提示词构建器也是纯函数。agent/system-prompt.ts 接收性格描述、情绪状态、相关记忆、用户上下文,拼装出系统提示词字符串。以前调用方传的是身份定义文件里的人设数据,现在传的是用户自定义的性格描述。函数不变,变的只是传进去的东西。
要改造的 15%
这批模块核心逻辑是好的,但身上带着 v1 的假设,得切干净才能用。情绪引擎(soul/emotion.ts)就是典型:核心情绪模型很扎实,追踪效价(valence)、唤醒度(arousal)和一组离散情绪,每次交互后更新。问题在于 v1 里它和日程系统缠在一起,Mio 晚上"觉得累",依据是日程表,跟对话动态没关系。修法是拔掉所有日程引用,让情绪纯粹由对话和时间感知驱动:情绪模型留下,假装有生活的触发器拆掉。
主动消息(soul/proactive.ts)决定什么时候、为什么发无提示消息。v1 里它从 proactive.json 拉人格专属触发器,比如"刚下班"或"要做瑜伽了"。新引擎更简单也更诚实,调度的基础设施保留,换掉的是假装有生活的内容生成。触发器只剩四类:
- 时间感知:"晚上了,今天过得怎么样?"(知道现在几点,不假装有日程)
- 记忆驱动:"你上次说要去面试,怎么样了?"(从存储的记忆里提话题)
- 情绪延续:"昨天聊完感觉你心情不太好,今天好点了吗?"(读上次的情绪状态)
- 单纯关心:"好几天没聊了,想你了"(检测对话间隔)
上下文聚合器(context/aggregator.ts)是编排层,调 LLM 前把记忆、最近消息、情绪状态、用户上下文拉到一起。v1 里它还会拉 agent 专属的背景故事和关系动态。简化方案是去掉多 agent 路由和关系类型查找,只留记忆检索和上下文拼装。
彻底删掉的 15-20%
剩下这些模块都建立在"预设人格"这个前提上,前提没了,它们也就没有存在的理由。首当其冲是预设文件:五个人格目录下的每一份身份定义文件、人格配置文件、行为规则文件,定义了"成都咖啡师小萌"是谁、"研究生学姐"喜欢什么、"中年大叔"怎么说话,数百行精心打磨的背景故事,全部删除。v2 里性格从对话中涌现,没有文件可读。
参考图片。每个 persona 原本都配了一组用于自拍生成的参考照片。v2 的 companion 不假装是人类,也就不需要伪造一张脸。
日程系统。media/schedule-*.ts 模拟日常作息:Mio 会 9 点到 6 点"上班",晚上"去健身",午夜后"睡觉"。这是 v1 里最难做好的部分,也是对留存贡献最小的部分。用户产生依赖,靠的是 companion 记得他们说过什么,跟假装做瑜伽没有关系。
人格风格模块。media/persona-style.ts 和 relationship-dynamics.ts 根据预定义的关系类型(朋友、恋人、知己)塑造回复风格。v2 里关系是什么样就是什么样,自然涌现。
关系进化。relationship/evolution-*.ts 用显式的阶段推进逻辑追踪关系状态。这是对一个简单事实的过度工程:companion 记得你、回应带温度,关系自然会加深,用不着状态机来推。
数据库从 10 张表砍到 4 张
架构简化在数据库这层最直观。v1 走到后期积了差不多 10 张表,v2 砍到 4 张核心表加 1 张辅助表。
v1 Schema (~10 张表)
users — 复杂,带 agent 关联
agents — 每个用户多个,绑预设,含 customStory、relationshipType
sessions — 多 agent、多渠道路由
messages — 绑定到 session
memories — 绑定到 agent
token_transactions — 不变
channel_bindings — Telegram/web 渠道路由
onboarding_states — 多步骤状态机
telegram_allowlist — Telegram bot 访问控制
account_link_tokens — 跨平台账号关联 token
v2 Schema (4 张核心表 + 1 张辅助表)
-- 用户:简化,去掉 agent 关联
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE,
timezone TEXT DEFAULT 'Asia/Shanghai',
subscription_tier TEXT DEFAULT 'free',
trial_expires_at TIMESTAMPTZ,
daily_usage JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- 伴侣:每个用户一个,就这么简单
CREATE TABLE companions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID UNIQUE REFERENCES users(id), -- UNIQUE 强制一对一
name TEXT NOT NULL,
voice_id TEXT,
personality TEXT, -- LLM 生成的几句性格描述
emotion_state JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- 消息:扁平化,绑定到用户,没有 session 概念
CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
role TEXT NOT NULL, -- 'user' | 'assistant'
content TEXT,
media_urls TEXT[],
emotion_state JSONB, -- 回复时的情绪快照
created_at TIMESTAMPTZ DEFAULT now()
);
-- 记忆:和 v1 几乎一样
CREATE TABLE memories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
type TEXT NOT NULL,
content TEXT NOT NULL,
embedding vector(768), -- pgvector
importance REAL DEFAULT 0.5,
access_count INT DEFAULT 0,
last_accessed TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 费用追踪:和 v1 一样
CREATE TABLE token_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
operation_type TEXT NOT NULL,
model_id TEXT,
input_tokens INT DEFAULT 0,
output_tokens INT DEFAULT 0,
cost_usd NUMERIC(10,6) DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);
关键约束是 companions.user_id 上的 UNIQUE:一个用户只能有一个 companion。这一个约束消灭了 agent 选择、session 路由和渠道绑定的全部需求,整个多 agent 基础设施坍缩成一个外键。
砍掉的表
| 砍掉的表 | v1 中为什么存在 | v2 中为什么不要了 |
|---|---|---|
agents | 每个用户多个 persona | 一用户一 companion,存在 companions 表 |
sessions | 多 agent、多渠道路由 | 没有 session,消息是每个用户一条扁平流 |
channel_bindings | 按 agent 做 Telegram/web 渠道路由 | 单平台 native app |
onboarding_states | 多步骤分支状态机 | 对话式 onboarding,状态活在聊天里 |
telegram_allowlist | Telegram bot 访问控制 | 不做 Telegram 了 |
account_link_tokens | 跨平台账号关联 token | 单一认证系统 |
消息从 session 绑定变成 user 绑定。v1 里一条消息属于一个 session,session 属于一个 agent,agent 属于一个 user,回答"这个用户说了什么"要做三次 JOIN;v2 里消息直接挂 user_id,一次查询就拿到。记忆同理,属于用户本人,跟具体哪个人设实例无关。
WebSocket 替代 SSE
v1 用 Server-Sent Events 推 LLM 的流式响应。SSE 能用,但只有单向:服务器推给客户端,客户端要发消息得另开一条 HTTP 请求。v2 换成 WebSocket,理由有四条。
一是天然双向。路线图的终点是实时语音对话,SSE 做不了双向音频流,WebSocket 可以。与其现在建在 SSE 上、以后再拆,不如直接从 WebSocket 开始。
二是主动消息更干净。v1 里主动消息需要客户端维护轮询连接或单独的 SSE 通道;WebSocket 下,服务器推主动消息和推聊天回复走同一个通道、同一个协议。
三是 Expo 支持更好。React Native 的 WebSocket 支持成熟、文档完善;SSE 在 React Native 里要打 polyfill,重连还有边缘情况。WebSocket 的重连是已解决问题,有 reconnecting-websocket 这样的现成库。
四是心跳内置。WebSocket 自带 ping/pong 帧做连接健康检测;SSE 只能靠应用层 keepalive,在移动网络上更脆弱。协议本身很简单,一共六种消息类型:
Client → Server: { type: "message", text, mediaIds? }
Server → Client: { type: "token", text } // 流式
Server → Client: { type: "done", messageId, emotionState }
Server → Client: { type: "voice", audio } // base64
Server → Client: { type: "proactive", text, emotionState }
Server → Client: { type: "emotion", state } // 光球状态更新
每个用户一条 WebSocket 连接,整个实时通信层就这么多。
砍掉 Telegram
这个决定有点疼。Telegram 是 Mio v1 的主渠道,用户真正和 companion 聊天的地方。砍掉它不是因为 Telegram 不好,而是新架构让它失去了意义。
致命问题是聊天历史不会同步到 Telegram 的界面里。用户在 native app 里和 companion 聊了几周,打开 Telegram,一片空白:companion 什么都知道,对话记录却一条看不见。这种体验已经算坏掉了,谈不上精简版。
onboarding 把矛盾放得更大。v2 的 onboarding 是对话式的,companion 通过对话诞生,不可能在 Telegram 里完成这个流程再让用户切 app。既然必须下载 app 做 onboarding,为什么还要回 Telegram?
所以 v0-v1 不做 Telegram。以后跨平台触达真变得重要了,做 Apple Watch 或 Android 的 widget,都比在别人平台里塞一个聊天 bot 更有意义。
技术栈对比
留下和换掉的放在一张表里看:
| 层 | v1 | v2 |
|---|---|---|
| 前端 | Next.js web + Telegram bot | Expo / React Native |
| 动画 | CSS | React Native Skia |
| 服务端 | Hono | Hono(保留) |
| 实时通信 | SSE | WebSocket |
| 数据库 | Supabase + Drizzle | Supabase + Drizzle(保留) |
| 向量搜索 | pgvector | pgvector(保留) |
| ORM | Drizzle | Drizzle(保留) |
服务端基本没变:Hono 轻量好用,Supabase + Drizzle 的数据库层已经验证过,pgvector 是记忆系统的依赖,都留着。大变化在客户端,从 Next.js web app 换成 Expo native app。仿微信改版是扎实工程,但整个 UI 范式被替换了,那 30 多个组件一个都带不走。新客户端就是一个聊天屏幕加一颗动画光球,从仿微信换成了更接近 GPT 语音模式的形态。
第一个 commit 不从零开始
mio-v2 的起点是一个已经包含以下内容的 packages/core 目录:
- 带 pgvector 语义搜索的完整记忆系统
- 完整媒体管线(TTS、STT、视觉、浏览)
- 订阅和计费系统
- 成本追踪系统
- 模型配置系统
- 接受参数的系统提示词构建器
这一堆基础设施都不用重新造。真正要新写的是六件事:
- 新数据库 schema(4 张表,上面写好了)
- 新 WebSocket 服务端(替换 SSE 端点)
- 新 Expo 客户端(聊天屏幕 + 动画光球)
- 对话式 onboarding(性格从对话中涌现)
- 改造后的情绪引擎(去掉日程,纯对话驱动)
- 改造后的主动消息(去掉假装有生活的触发器)
清单列出来才发现,这次重建里真正费劲的,是忍住不去重写那些已经能用的东西。Schema 定了,模块清单做完了,留、改、删的决策全部到位,接下来就是搭 Expo app、接通 WebSocket,看光球第一次脉动起来。那是第四篇的事了。