v0.0.1:当TA第一次说话
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 回复。就这么简单。
但拆开看,一点都不简单:
- Telegram 收到消息
- Webhook 转发到 Hono server
- Router 根据
channel_bindings查到这个 chat 绑定的 agent - 加载 agent 配置、session、历史消息
- 调 LLM 生成回复
- 通过 Telegram API 发回去
- 持久化消息
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 做三件事:
- 检索相关记忆(混合搜索 + 重排序)
- 加载用户人格画像
- 读取当前情绪状态
三者合并,注入 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真正"懂你",路还长。
但地基打对了。这比什么都重要。