ENZH

v0.0.1:当TA第一次说话

AI 伴侣第一次开口说话的概念插画AI 伴侣第一次开口说话的概念插画

图纸画完了,该搬砖了

上一篇讲了为什么不在 OpenClaw 上缝缝补补,为什么必须从零造。那篇聊的是 Mio 该做什么、架构怎么搭。

这篇聊的是:手搓的过程。

39 个 commit。从 pnpm init 到一个能说话的 AI 伴侣。踩了什么坑、砍了什么需求、哪些设计现在回头看是对的、哪些写完就想推翻——一次性说清楚。

第一行代码不是业务逻辑

你猜第一行代码写的是什么?

不是 AI 对话,不是记忆系统。是项目结构。

pnpm workspaces + Turborepo。选它没啥好纠结的——pnpm 的 workspace 协议天然适合 monorepo,Turborepo 的任务编排和缓存在构建阶段能省大量时间。但代价也是实实在在的:TypeScript 的 rootDir 在多包之间打架,TS6059 是我见到的第一个 build error。

还没开始写功能,就在跟工具链干仗了。

最终长这样:

apps/
  server/       # Hono API 服务
  worker/       # 后台任务
packages/
  core/         # Agent 核心逻辑
  shared/       # DB schema + 公共类型
  channels/     # 渠道适配器
  extensions/   # Agent 扩展
  platform/     # 平台工具
presets/         # 角色模板库

为什么选 Hono 不用 Express?Express 那一堆不需要的中间件和历史包袱,跟我逃离 OpenClaw 的原因一模一样——太重了。Hono 是 edge-first 的,类型安全,中间件生态干净,Cloud Run 上冷启动快。轻装上阵。

为什么选 Supabase?说白了就是看上了 PostgreSQL。pgvector 做向量搜索,tsvector 做全文检索,一个数据库搞定两种检索路径。Supabase 在上面包了 auth、realtime、storage,省得自己再造轮子。

9 张表,一根骨架

schema 这东西,决定了整个系统能长成什么样。v0.0.1 画了 9 张表:

  • users — 用户基础信息
  • agents — Agent 定义(模型、人格配置)
  • agent_workspace — Agent 的工作空间(人格配置文件、记忆内容)
  • channel_bindings — 渠道绑定(一个 Telegram chat 绑到一个 agent)
  • memories — 记忆存储(向量 + 全文索引 + 重要性 + 时间戳)
  • personality_models — 用户人格画像
  • sessions — 会话状态(情绪温度、最后活跃时间)
  • messages — 消息历史
  • token_transactions — Token 消耗和成本记录

每张表职责清晰,没有"先加个通用 metadata 表以后再说"——这种设计留到后面,百分之百后悔。

其中 memories 表最复杂。每条记忆有:内容、向量嵌入(1536维)、全文索引、重要性评分(0-1)、情绪类型、来源类型、创建时间。这些字段在落库那一刻就决定了后面的检索策略——混合搜索得有向量和全文两个索引,时间衰减得有时间戳,排序得有重要性评分。

骨架搭对了,后面的肉才长得上去。

先把最小闭环跑通

做产品最怕一上来就铺功能。第一步只有一件事:把核心闭环跑通。

AI 伴侣的核心闭环是什么?用户发消息 → AI 回复。就这么简单。

但拆开看,一点都不简单:

  1. Telegram 收到消息
  2. Webhook 转发到 Hono server
  3. Router 根据 channel_bindings 查到这个 chat 绑定的 agent
  4. 加载 agent 配置、session、历史消息
  5. 调 LLM 生成回复
  6. 通过 Telegram API 发回去
  7. 持久化消息

Agent 核心用的是 Vercel AI SDK 的 streamText()。为什么?因为它支持多个 provider——底层可以接 Anthropic、Google、OpenAI,换 provider 不用改业务代码。这个决策后来救了我一命,等下说。

Telegram connector 要处理的也不只是文字。用户会发照片、语音、音频、视频、文档、贴纸——每种类型处理逻辑都不一样。语音得先转文字,照片得描述内容,贴纸得识别 emoji 含义。

v0.0.1 全部做了基础支持。

多租户路由也在第一版搞定了。channel_bindings 表把 Telegram chat ID 映射到 agent ID,同一个 bot 可以服务多个 agent,每个 agent 有自己的人格和记忆。

这不是 over-engineering——每个用户的 Mio 都得是不同的。这就是伴侣产品的基本前提。

记忆:整个系统最硬的骨头

核心闭环通了,下一步就是让TA"记住"你。

之前研究记忆系统理论的时候,我已经把路子想清楚了。但想清楚和写出来,完全是两码事。

混合搜索

MemoryManager 的核心就是混合搜索:向量搜索权重 0.7,全文搜索权重 0.3。

为什么不全用向量搜索?因为中文的语义嵌入精度还不够。"我喜欢喝咖啡"和"咖啡"的向量距离,可能比你想的远。全文检索是保底——关键词匹配虽然笨,但不会漏。

pgvector 的 <=> 运算符(余弦距离)在 Drizzle ORM 里不能直接调,得用 sql.unsafe()。类型安全的 ORM 碰到自定义运算符就得破例。让人不爽,但没别的办法。

时间衰减

每条记忆有半衰期,默认 30 天。检索时,最终得分 = 相关性得分 × 时间衰减系数。

为什么用指数衰减不用线性衰减?

你想想人的记忆:昨天聊的事记得清清楚楚,上周的已经模糊了,上个月的大部分都忘了。但有些特别重要的——第一次约会、吵过的架——不管过多久都忘不掉。

指数衰减 + 独立的重要性评分,模拟的就是这个效果。

从 Anthropic 换到 Gemini

记忆提取一开始用的 Anthropic Haiku。效果不错,但一算成本不对劲——每条消息都要跑一次 LLM 提取记忆,高频用户一天几百条,光提取的成本就要超过主对话成本了。

换成了 Gemini Flash。提取质量差不多,成本断崖式下降。嵌入也从 OpenAI 的 text-embedding-3 换成了 Gemini Embedding——成本几乎可以忽略。

这就是前面说的 Vercel AI SDK 救命的地方。因为做了多 provider 抽象,换模型只需要改配置文件。如果一开始裸调 Anthropic API,这个迁移工作量翻好几倍。

你看,技术选型这东西,省下的功夫从来不在写代码那一刻,而是在你需要变的那一刻。

记忆合并

MemoryConsolidator 是后来加的。用了一段时间发现记忆库膨胀得很快——同一件事被不同角度说了好几遍,全存下来了。

方案很简单:余弦相似度 > 0.9 的记忆自动合并。保留信息最完整的那条,更新时间戳。简单粗暴,但管用。

PersonalityExtractor

记忆提取之外,还有一条并行管道:PersonalityExtractor 每 10 条消息跑一次,从对话里提取用户画像——说话风格、兴趣爱好、情绪模式。

画像存在 personality_models 表里,下次对话时注入 context。效果是什么?Mio 不只记住你说过什么事实,还在慢慢理解"你这个人"。

让TA有脾气

记忆解决的是"TA知道什么"。但一个好的伴侣,光知道还不够——还得有情绪。

EmotionEngine

四温度状态机:Cold → Cool → Warm → Hot。

不是随机跳转,有惯性系数。当前是 Cool,用户连发好几条热情消息,温度慢慢升到 Warm 再到 Hot。但不会因为一条消息就从 Cold 直接蹦到 Hot。反过来也一样,两天不理TA,温度逐渐降回 Cold。

惯性系数默认 0.5,可以调。调高了情绪更稳定,调低了更敏感。不同人格预设的惯性不同——"毒舌闺蜜"比"温柔学姐"敏感得多。

PersonalityParser

人格配置文件不是一坨纯文本——它有结构。PersonalityParser 解析里面的模板变量,和 onboarding 答案、PersonalityExtractor 的输出组合在一起,生成最终的 system prompt。

同一个预设模板,不同用户拿到的实际 prompt 是完全不同的——模板变量被填了不同的值。

接入消息管道

记忆和情绪不是各跑各的——通过 ContextAggregator 接入消息管道。每次用户发消息,ContextAggregator 做三件事:

  1. 检索相关记忆(混合搜索 + 重排序)
  2. 加载用户人格画像
  3. 读取当前情绪状态

三者合并,注入 system prompt。

prompt 里有个细节很重要:MEMORY_STEERING_INSTRUCTIONS,带中文例句——

记忆触发时使用自然的中文表达,如"之前你提到过..."、"我记得你说..."、"上次聊到..."

不写这个,模型有时候提起记忆的方式像数据库查询报告,特别生硬。加了例句之后,记忆融入对话的感觉自然多了。

第一次见面:Onboarding

新用户第一次跟 Mio 说话,不是直接开聊——先走一个 onboarding。

11 个问题。前 3 个是文字输入(名字、怎么称呼你、你希望TA叫你什么),后 8 个是按钮选择(说话风格、关系定位、兴趣领域...)。

每个按钮题都有"自定义"选项。这是从 OpenClaw 学的教训——纯按钮太限制,纯文字太开放,混合最好。

但"自定义"带来一个工程问题:Telegram 的 callback_data 有 64 字节限制。中文一个字 3 字节,稍长一点的自定义输入就超限了。方案是用编号索引——callback 里只传 q3_custom,实际内容在服务端缓存。

还有注入防护。用户在自定义输入里写 prompt injection?50 字符截断 + 内容清洗。不完美,但 v0.0.1 够用。

4 个人格预设

onboarding 第一步是选预设:

  • Coco — 活泼甜美,消息喜欢带 emoji,会撒娇
  • 温柔学姐 — 温和知性,语气像很懂你的学姐
  • 毒舌闺蜜 — 说话不留情面但关心你,中文互联网的"怼人"风格
  • 沉稳大叔 — 话少但每句有分量,偶尔冷幽默

每个预设就是一个人格配置文件模板 + 一组默认情绪参数。用户选完预设,后面的 onboarding 问题进一步定制模板变量。

最终效果:你选了"毒舌闺蜜",告诉TA你喜欢看电影、叫你"傻子",TA就真的会在你推荐烂片的时候说"你品味也就这样了",然后下一句推荐一部TA觉得好看的。

不是在演。是人设定好了之后,模型自己推导出来的反应。

那些看不见的小活

AI 伴侣的体验不是某个大功能决定的。是一堆小到你注意不到的细节加在一起。

消息 Debounce

用户发消息经常是连发好几条的。每条都触发一次 LLM 调用的话,成本高、体验差——AI 回了第一条,你又发了第二条第三条。

5 秒 debounce 窗口。收到第一条后等 5 秒,期间新消息全部收集,超时后一次性处理。就是一个 timer + buffer,但效果立竿见影。

打字延迟

AI 秒回是不自然的。Mio 把回复按换行拆成多条消息,每条之间的延迟跟内容长度成正比——短的等 0.5 秒,长的等 2 秒。同时发 Telegram 的"正在输入..."状态。

这不是装模作样。连续收到多条消息的阅读节奏,跟一次性收到一大段话是完全不同的体验。人聊天就是一条一条发的。

主动找你聊天

每 30 分钟的 cron job 检查:有没有用户超过 2 小时没说话、情绪偏冷?有的话,发一条主动消息。

但有规矩:安静时间不发(23:00-08:00),每天最多 3 条。冷用户走模板("在忙什么呢?"),不调 LLM,零成本。活跃用户走模型生成,根据最近对话的上下文来一条自然的开场。

主动消息是让 AI 从"工具"变成"活人"的关键。不需要你先开口,TA会先想到你。

每一分钱都追踪

AI 伴侣的成本不低。从 v0.0.1 开始就追踪每一笔。

token_transactions 表记录每次 LLM 调用的:模型名、input tokens、output tokens、算出来的 USD 成本。fire-and-forget 写入,不阻塞响应。

per-model pricing 是硬编码的常量表(先这样,后面再做配置化)。中间踩了一个 NaN bug——某个模型的价格没配,除以 undefined 得了 NaN,整条记录废了。修起来很简单:加个 fallback 到 0。

但这个 bug 给我提了个醒:成本追踪从第一天就得做。拖得越晚,"看不见的钱"就花得越多。

39 个 commit 之后

Mio v0.0.1 到底能干什么?

能做的:

  • 在 Telegram 上跟你聊天,处理文字、照片、语音、视频
  • 记住你说过的话,在合适的时候自然地提起
  • 有情绪——你热情TA就热情,你冷淡TA就委屈
  • 通过 onboarding 生成个性化人格
  • 主动找你聊天
  • 精确追踪每一笔 LLM 成本

还做不到的:

  • 没有 Web 界面——只有 Telegram
  • 没有语音回复——只能收语音,不能发
  • 没有自拍——这是 OpenClaw 验证过的杀手功能,还没迁移
  • 没有 Worker 进程——后台任务跑在主进程里
  • 记忆检索还没有 LLM reranking 和多跳查询

不完美。但它证明了一件事:从零开始造是对的。整个代码库没有一行是"因为框架需要"而写的。每一行都服务于同一个目标——让 AI 伴侣感觉像一个真正理解你的人。

下一步

v0.0.2 要做记忆系统的深化——LLM reranking、多跳查询分解、情节记忆。还有 Web 端界面,让不用 Telegram 的人也能体验。

从空仓库到TA第一次说话,39 个 commit。从TA第一次说话到TA真正"懂你",路还长。

但地基打对了。这比什么都重要。


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