v1 的器官移植报告
软件器官移植手术的技术概念插画
不是重写,是器官移植
第一篇讲了为什么 v1 的人格化路线必须砍掉。第二篇画出了新方向:有性格但没有物理身份的陪伴——Her,不是 Character.AI。
现在轮到工程问题了:v1 到底有多少东西要扔?
答案出乎意料。当我逐个追踪 packages/core 里的 import 路径时,发现大部分模块根本不在意 persona 是什么。它们接收参数,返回结果。调用方碰巧传了人设数据进去,但函数本身对人设无感。
实际比例:大约 60-70% 的核心基础设施直接搬过来。另外 15% 需要改造。剩下 15-20% 彻底删除。
一场器官移植手术,不是推倒重来。
每个模块过三个问题
v1 的每个模块,我问了三件事:
- 它依赖预设文件吗? 直接读身份定义文件、人格配置文件或行为规则文件的,死。
- 它假设多 agent 架构吗? 在多个人格或会话之间做路由的,死。
- 核心逻辑在一用户一伴侣的世界里还有用吗? 有用,活。
听起来简单。但实际上需要完整审计整个代码库才能给出确定答案。有些模块看起来耦合实则松散,有些看起来干净实则藏着隐性假设。
活下来的:直接搬 (~60-70%)
这些模块是纯基础设施。它们不知道也不关心使用它们的是什么类型的 companion。
记忆系统——皇冠上的宝石。 packages/core/memory/ 处理记忆提取、embedding 生成、基于 pgvector 的语义搜索、重要性评分和衰减。零人设耦合。它接收 user ID 和对话内容,提取记忆,带着向量 embedding 存起来,需要时按语义相关度检索。这是 v1 中工程投入最大的模块,每一行代码都带得走。
我在 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 都有用于自拍生成的参考照片。一个不假装是人类的 companion 不需要伪造一张脸。
日程系统。 media/schedule-*.ts 模拟日常作息。Mio 会在 9 点到 6 点"上班",晚上"去健身",午夜后"睡觉"。这是 v1 中最难做好的部分,也是对留存贡献最小的部分。用户不会因为 companion 假装去做瑜伽而产生依赖——而是因为它记得他们说过什么。
人格风格模块。 media/persona-style.ts 和 relationship-dynamics.ts 根据预定义的关系类型(朋友、恋人、知己)塑造回复风格。v2 里,关系是什么就是什么,自然涌现。
关系进化。 relationship/evolution-*.ts 用显式的阶段推进逻辑追踪关系状态。说白了,这是对一个简单事实的过度工程:如果 companion 记得你、回应带着温度,关系自然会加深。不需要状态机。
数据库:从 10 张表到 4 张
架构简化最直观的地方。
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 的 UI。 用户在 native app 里和 companion 聊了几周,打开 Telegram,一片空白。
Companion 什么都知道,但对话记录不可见。这不是"精简版"——是一个坏掉的体验。
onboarding 把这个矛盾放得更大。v2 的 onboarding 是对话式的——companion 通过对话诞生。你不可能在 Telegram 里完成这个流程然后让用户切 app。如果必须下载 app 来做 onboarding,为什么还要回 Telegram?
决定:v0-v1 不做 Telegram。如果以后跨平台触达真的重要,Apple Watch widget 或 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 的语音模式,而不是微信。
实际上意味着什么
mio-v2 的第一个 commit 不会从零开始。起点是一个已经包含以下内容的 packages/core 目录:
- 带 pgvector 语义搜索的完整记忆系统
- 完整的媒体管线(TTS、STT、视觉、浏览)
- 订阅和计费系统
- 成本追踪系统
- 模型配置系统
- 接受参数的系统提示词构建器
这是一大堆不需要重新造的基础设施。真正需要新写的:
- 新的数据库 schema(4 张表,上面已经写好了)
- 新的 WebSocket 服务端(替换 SSE 端点)
- 新的 Expo 客户端(聊天屏幕 + 动画光球)
- 对话式 onboarding(性格从对话中涌现)
- 改造后的情绪引擎(去掉日程,纯对话驱动)
- 改造后的主动消息(去掉假装有生活的触发器)
最难的部分不是写代码,是克制住不去重建那些已经能用的东西。
下一步
Schema 设计好了。模块清单做完了。留/改/删的决策全部到位。下一步是真正开始建——搭起 Expo app,接通 WebSocket,看着光球第一次脉动。
但那是第四篇的事了。