ENZH

v1 的器官移植报告

📊 幻灯片

软件器官移植手术的技术概念插画软件器官移植手术的技术概念插画

动手重建 Mio 之前,我把 packages/core 里的 import 路径逐个追了一遍,想搞清楚 v1 到底有多少东西要扔。结果比预想的好:大部分模块根本不在意 persona 是什么,接收参数,返回结果,调用方碰巧传了人设数据进去,函数本身对人设无感。最后盘出来的比例是,大约 60-70% 的核心基础设施直接搬过来,另外 15% 需要改造,剩下 15-20% 彻底删除。

为什么 v1 的人格化路线必须砍掉,第一篇写过;新方向长什么样,第二篇也画过:有性格但没有物理身份的陪伴,参照系是 Her,不是 Character.AI。这篇只处理工程问题:哪些模块留下,哪些删掉,为什么。

每个模块过三道筛子

给 v1 的每个模块过了三道筛子:

  1. 直接读身份定义文件、人格配置文件或行为规则文件的,删。
  2. 假设了多 agent 架构、要在多个人格或会话间做路由的,删。
  3. 核心逻辑在一用户一伴侣的世界里还有用的,留。

标准听起来简单,实际要完整审计一遍代码库才能给出确定答案:有些模块看着耦合,实则松散;有些看着干净,底下藏着隐性假设。

直接搬走的 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.tsrelationship-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_allowlistTelegram 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 更有意义。

技术栈对比

留下和换掉的放在一张表里看:

v1v2
前端Next.js web + Telegram botExpo / React Native
动画CSSReact Native Skia
服务端HonoHono(保留)
实时通信SSEWebSocket
数据库Supabase + DrizzleSupabase + Drizzle(保留)
向量搜索pgvectorpgvector(保留)
ORMDrizzleDrizzle(保留)

服务端基本没变:Hono 轻量好用,Supabase + Drizzle 的数据库层已经验证过,pgvector 是记忆系统的依赖,都留着。大变化在客户端,从 Next.js web app 换成 Expo native app。仿微信改版是扎实工程,但整个 UI 范式被替换了,那 30 多个组件一个都带不走。新客户端就是一个聊天屏幕加一颗动画光球,从仿微信换成了更接近 GPT 语音模式的形态。

第一个 commit 不从零开始

mio-v2 的起点是一个已经包含以下内容的 packages/core 目录:

  • 带 pgvector 语义搜索的完整记忆系统
  • 完整媒体管线(TTS、STT、视觉、浏览)
  • 订阅和计费系统
  • 成本追踪系统
  • 模型配置系统
  • 接受参数的系统提示词构建器

这一堆基础设施都不用重新造。真正要新写的是六件事:

  1. 新数据库 schema(4 张表,上面写好了)
  2. 新 WebSocket 服务端(替换 SSE 端点)
  3. 新 Expo 客户端(聊天屏幕 + 动画光球)
  4. 对话式 onboarding(性格从对话中涌现)
  5. 改造后的情绪引擎(去掉日程,纯对话驱动)
  6. 改造后的主动消息(去掉假装有生活的触发器)

清单列出来才发现,这次重建里真正费劲的,是忍住不去重写那些已经能用的东西。Schema 定了,模块清单做完了,留、改、删的决策全部到位,接下来就是搭 Expo app、接通 WebSocket,看光球第一次脉动起来。那是第四篇的事了。


© Xingfan Xia 2024 - 2026 · CC BY-NC 4.0