ENZH
和 AI 讨论这篇文章
ChatGPTClaude

把 Mio 从空 repo 搭到能聊天,一共 39 个 commit

📊 幻灯片

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

上一篇讲了为什么不在 OpenClaw 上缝缝补补、要从零造一个,那篇讲的是 Mio 该做什么、架构怎么定。这篇讲具体怎么手搓出来的:从 pnpm init 到一个能聊天的 AI 伴侣,一共 39 个 commit。踩了什么坑、砍了什么需求,也都写在里面。

先搭项目结构

第一个 commit 不是 AI 对话,也不是记忆系统,是项目结构。用 pnpm workspaces + Turborepo,这个没什么好纠结的:pnpm 的 workspace 协议天然适合 monorepo,Turborepo 的任务编排和缓存在构建阶段能省不少时间。代价也是实打实的,TypeScript 的 rootDir 在多个 package 之间会打架,整个项目的第一个 build error 就是 TS6059——功能一行还没写,先跟工具链搏斗了一轮。

最终结构长这样:

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

API 框架选了 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 — session 状态(情绪温度、最后活跃时间)
  • messages — 消息历史
  • token_transactions — Token 消耗和成本记录

每张表职责清晰,没有那种「先加个通用 metadata 表,以后再说」的表——这种设计基本上百分之百会后悔。

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

先跑通最小闭环

功能上第一步只做一件事:把核心闭环跑通。AI 伴侣的核心闭环就是用户发消息、AI 回复,拆开是 7 步:

  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 都不一样,这个前提从第一天就得成立。

记忆系统

核心闭环通了,下一步是让它记住你。整个系统里最难写的就是这块。之前研究记忆系统理论的时候路子已经想清楚了,但想清楚是一回事,写出来是另一回事,真落地全是细节。

混合搜索

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 每 10 条消息跑一次,从对话里提取用户画像——说话风格、兴趣爱好、情绪模式。画像存在 personality_models 表里,下次对话注入 context。这样 Mio 记住的就不只是你说过的事实,它还在慢慢搞明白你这个人大概是什么样的。

情绪引擎

记忆解决的是它知道什么。伴侣产品光知道不够,还得有情绪。

EmotionEngine

四温度状态机:Cold → Cool → Warm → Hot。状态不是随机跳的,有惯性系数:当前是 Cool,用户连发几条热情消息,温度慢慢升到 Warm 再到 Hot,不会因为一条消息就从 Cold 直接蹦到 Hot;反过来,两天不理它,温度也是逐渐降回 Cold。惯性系数默认 0.5,可以调,调高情绪更稳定,调低更敏感。不同人格预设的惯性不一样,「毒舌闺蜜」就比「温柔学姐」敏感得多。

情绪引擎的四温度状态机:情绪沿 Cold→Cool→Warm→Hot 带惯性逐渐升降,不会因一条消息就骤变。情绪引擎的四温度状态机:情绪沿 Cold→Cool→Warm→Hot 带惯性逐渐升降,不会因一条消息就骤变。

PersonalityParser

人格配置文件不是一坨纯文本,它是有结构的。PersonalityParser 解析里面的模板变量,跟 onboarding 的答案、PersonalityExtractor 的输出组合在一起,生成最终的 system prompt。同一个预设模板,不同用户拿到的 prompt 完全不同,因为模板变量填的值不一样。

ContextAggregator

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

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

三个合并,注入 system prompt。

每条消息进来,聚合器把相关记忆、人格画像、当前情绪三路合并注入 system prompt,让 Mio 像个活人在回应。每条消息进来,聚合器把相关记忆、人格画像、当前情绪三路合并注入 system prompt,让 Mio 像个活人在回应。

prompt 里有个细节值得单独说:MEMORY_STEERING_INSTRUCTIONS,带中文例句——

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

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

Onboarding

新用户第一次跟 Mio 说话不是直接开聊,先走一遍 onboarding,一共 11 个问题:前 3 个是文字输入(名字、怎么称呼你、你希望它叫你什么),后 8 个是按钮选择(说话风格、关系定位、兴趣领域这些)。

每个按钮题都有「自定义」选项,这是从 OpenClaw 学来的:纯按钮太限制,纯文字太开放,混合最好用。

但自定义带来一个工程问题:Telegram 的 callback_data 有 64 字节限制,中文一个字 3 字节,稍长一点的自定义输入就超了。处理方式是编号索引,callback 里只传 q3_custom,实际内容放服务端缓存。注入防护也做了一层,用户在自定义输入里写 prompt injection 的话,50 字符截断加内容清洗。不算完美,v0.0.1 够用。

4 个人格预设

onboarding 第一步是选预设:

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

每个预设就是一个人格配置文件模板加一组默认情绪参数,选完预设,后面的 onboarding 问题进一步定制模板变量。最终效果大概是:你选了「毒舌闺蜜」,告诉它你喜欢看电影、让它叫你「傻子」,它真的会在你推荐烂片的时候说你品味也就这样了,然后下一句推荐一部它觉得好看的。这不是写死的脚本,是人设定好之后,模型自己推导出来的反应。

几个小细节

体验好不好,很多时候不是某个大功能决定的,是一堆小到注意不到的细节加起来的。这几个都不大,但都有用。

消息 debounce

用户发消息经常是连发好几条的,每条都触发一次 LLM 调用的话成本高、体验也差——AI 回了第一条,你又发了第二条第三条。做法是 5 秒 debounce 窗口:收到第一条后等 5 秒,期间新消息全部收集,超时后一次性处理。实现就是一个 timer 加 buffer,效果立竿见影。

打字延迟

AI 秒回是不自然的。Mio 把回复按换行拆成多条消息,每条之间的延迟跟内容长度成正比,短的等 0.5 秒,长的等 2 秒,同时发 Telegram 的「正在输入...」状态。这个不是装样子,连续收到几条短消息,跟一次性收到一大段话,阅读节奏完全是两种体验——人聊天本来就是一条一条发的。

主动消息

每 30 分钟一个 cron job 检查:有没有用户超过 2 小时没说话、情绪偏冷?有的话发一条主动消息。规矩也定好了:23:00 到 08:00 安静时间不发,每天最多 3 条。冷用户走模板(「在忙什么呢?」),不调 LLM,零成本;活跃用户走模型生成,根据最近对话的上下文来一条自然的开场。

主动消息这个功能我觉得挺关键的,Mio 感觉像个活人还是像个工具,很大程度就看它会不会自己先来找你说话。

成本追踪

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 也说明成本追踪就得从第一天做,不然钱花在哪都不知道。

v0.0.1 能干什么

到这里 v0.0.1 到底能干什么,列一下。

能做的:

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

还做不到的:

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

不完美,但从零开始造这个决定,到这里看没什么可后悔的,至少代码库里没有哪块是「因为框架需要」才硬写的。

下一步

v0.0.2 主要做记忆系统的深化:LLM reranking、多跳查询分解、情节记忆。还有 Web 端界面,让不用 Telegram 的人也能用。要做的还多得多,先把 v0.0.2 做了再说。

和 AI 讨论这篇文章
ChatGPTClaude

订阅更新

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


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