ENZH

v1 的器官移植报告

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

不是重写,是器官移植

第一篇讲了为什么 v1 的人格化路线必须砍掉。第二篇画出了新方向:有性格但没有物理身份的陪伴——Her,不是 Character.AI。

现在轮到工程问题了:v1 到底有多少东西要扔?

答案出乎意料。当我逐个追踪 packages/core 里的 import 路径时,发现大部分模块根本不在意 persona 是什么。它们接收参数,返回结果。调用方碰巧传了人设数据进去,但函数本身对人设无感。

实际比例:大约 60-70% 的核心基础设施直接搬过来。另外 15% 需要改造。剩下 15-20% 彻底删除。

一场器官移植手术,不是推倒重来。

每个模块过三个问题

v1 的每个模块,我问了三件事:

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

听起来简单。但实际上需要完整审计整个代码库才能给出确定答案。有些模块看起来耦合实则松散,有些看起来干净实则藏着隐性假设。

活下来的:直接搬 (~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.tsrelationship-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_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 的 UI。 用户在 native app 里和 companion 聊了几周,打开 Telegram,一片空白。

Companion 什么都知道,但对话记录不可见。这不是"精简版"——是一个坏掉的体验。

onboarding 把这个矛盾放得更大。v2 的 onboarding 是对话式的——companion 通过对话诞生。你不可能在 Telegram 里完成这个流程然后让用户切 app。如果必须下载 app 来做 onboarding,为什么还要回 Telegram?

决定:v0-v1 不做 Telegram。如果以后跨平台触达真的重要,Apple Watch widget 或 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 的语音模式,而不是微信。

实际上意味着什么

mio-v2 的第一个 commit 不会从零开始。起点是一个已经包含以下内容的 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