ENZH
和 AI 讨论这篇文章
ChatGPTClaude

重建 Mio,六成代码直接搬过来了

📊 幻灯片

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

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

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

每个模块过三条标准

具体的做法大概是这样的,我给 v1 的每个模块过三条判断标准:

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

标准本身很简单,但实际操作的时候,你得把整个 codebase 完整 audit 一遍才敢下结论。因为有的模块看着耦合得很紧,真追进去才发现其实很松。也有反过来的,代码本身看着挺干净,结果底下藏着隐性假设。不看完你判断不了。

v1 每个模块过两道判断,分诊成直接搬、改造、删掉三堆v1 每个模块过两道判断,分诊成直接搬、改造、删掉三堆

直接搬走的 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 模型、定价、context window 和路由规则,模型路由架构原样保留。

系统提示词构建器也是纯函数。agent/system-prompt.ts 接收性格描述、情绪状态、相关记忆和 user context,拼出系统提示词字符串。以前调用方传进来的是身份定义文件里的人设数据,现在传的是用户自定义的性格描述。函数一行不改,变的只是传进去的东西。

要改造的 15%

这批模块核心逻辑是好的,但身上带着 v1 的假设,得切干净才能用。

情绪引擎soul/emotion.ts)是典型。核心情绪模型本身很扎实:追踪 valence(效价)、arousal(唤醒度)和一组离散情绪,每次交互后更新。问题在于 v1 里它跟日程系统缠在一起——Mio 晚上「觉得累」,依据是日程表,跟对话动态没关系。修法就是把所有日程引用拔掉,让情绪纯粹由对话和时间感知驱动。情绪模型留下,假装有生活的那部分触发器拆掉。

主动消息soul/proactive.ts)负责决定什么时候、为什么发无提示消息。v1 里它从 proactive.json 拉人格专属的触发器,比如「刚下班」「要做瑜伽了」。新引擎简单得多,或者说更实在:调度的基础设施保留,换掉的是假装有生活的内容生成。触发器只剩四类:

  • 时间感知:「晚上了,今天过得怎么样?」(知道现在几点,不假装有日程)
  • 记忆驱动:「你上次说要去面试,怎么样了?」(从存储的记忆里提话题)
  • 情绪延续:「昨天聊完感觉你心情不太好,今天好点了吗?」(读上次的情绪状态)
  • 单纯关心:「好几天没聊了,想你了」(检测对话间隔)

context aggregatorcontext/aggregator.ts)是编排层,调 LLM 之前把记忆、最近消息、情绪状态、user context 拉到一起。v1 里它还要拉 agent 专属的背景故事和关系动态。改造就是把多 agent 路由和关系类型查找去掉,只留记忆检索和 context 拼装。

彻底删掉的 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 的基础设施最后就剩一个外键。

一个 UNIQUE 约束把十张表的多 agent 架构塌缩成四张表一个 UNIQUE 约束把十张表的多 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 用 SSE(Server-Sent Events)推 LLM 的流式响应。SSE 能用,但它只有单向:服务器推给客户端,客户端要发消息得另开一条 HTTP 请求。v2 换成 WebSocket,理由有四条:

  1. 天然双向。roadmap 的终点是实时语音对话,SSE 做不了双向音频流,WebSocket 可以。与其现在建在 SSE 上、以后再拆一遍,不如直接从 WebSocket 开始。
  2. 主动消息更干净。v1 里主动消息需要客户端维护轮询连接,或者单独开一条 SSE 通道;WebSocket 下,服务器推主动消息和推聊天回复走同一个通道、同一个协议。
  3. Expo 支持更好。React Native 的 WebSocket 支持很成熟,文档也全;SSE 在 React Native 里要打 polyfill,重连还有一堆边缘情况。WebSocket 的重连是已解决问题,有 reconnecting-websocket 这种现成库。
  4. 心跳内置。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 接通。光球第一次动起来的部分,留给第四篇写。

和 AI 讨论这篇文章
ChatGPTClaude

订阅更新

新文章发布时发到你的邮箱,不发别的。


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