全部文章

为了创建一个猫娘我们都做了什么

创建于 更新于 预计阅读 16 分钟0 条评论

AI 总结

从 Next.js 到 Nuxt 再到 TanStack Start,5 天内用 Durable Objects、Just Bash 虚拟文件系统、VRM 桌面宠物和自研的 DTP 对话协议,从零搓出一个猫娘 Luna。文章记录了架构选型的取舍、6 小时 Agent 驱动的大规模迁移,以及为什么 Bash 就是 Harness 的全部。

起因

我和我的两个朋友组队,三个人报了知乎的 2026 黑客马拉松。

我们聊了一圈,决定做一个猫娘,她叫 Luna,我们的灵感来自于酒馆式角色聊天🍺,再加上龙虾那种能动手干活的智能体🦐,再配上养成。于是就有了她。

在生活上,他是你的好伙伴,你的每一个爱好,他都会记到他的记忆里,我们认真设计了 LUNA 的记忆系统,下次再见面,她还记得你喜欢什么。

在工作上,他还可以帮你处理一些复杂的程序,你可以添加 agent skill,甚至是上传附件,由它调用 python 去解决。

在娱乐上,她是你的好玩伴,我们做了养成和成就系统:喂她吃饭、给她喝水、帮她清洁,亲密度会慢慢提高。

我们还为 LUNA 做了一个模型,它也会跟着动。她会先想一想,再一条一条地回你,会发贴纸,会做表情,就好像她真的出现在你面前一样。

Luna demo screenshot

成品在这里:LUNA,所以为了创建一个猫娘,我们都做了什么?

技术设计

为了做出这个产品,我们下了很多功夫,从架构定型、设计整个 Harness,到前端等等,我们都有慎重的考虑,而且做了很多新东西。

尽管如此,我们这个项目是不到 5 天内做完的,非常仓促。在这期间,我们大概用了 1000 刀的 tokens。

我们从一开始就把它定为一个完整的全栈 Web App,挑选一个前后端一体框架非常重要,我们本项目最开始的架构是 Next.js,是 OpenNext 驱动的 Next.js。但是有很多原因,我们先后迁到了 Nuxt,到最后的 TanStack Start 框架,这一路的心路历程和背后的取舍,待会儿我都会讲到。

基于 Cloudflare Durable Objects 设计的 Agent 架构

我们从一开始就把它定为一个完整的前后端一体 Web App,在最开始这个项目定型之前,我们一直把它部署在 OpenNext 驱动的 Next.js 中,部署在 Cloudflare 上。

我们在第一次开发完成的时候,发现它非常慢,而且 Next.js 的 Adapter API 没有受到 Cloudflare 的支持,所以这让我下定了决心,第一次重构放弃了 Next.js,转向了 Vue 框架的 Nuxt.js,由于 Nuxt 的原语统一了 KV、Database 以及 Blob,所以我们自然选择了 Nuxt。

Cloudflare 与 Vercel 不同,它是一个集大成的东西,偏向基础设施,一个服务掌控了一切,比如有

  1. SQL database:比如说 D1 SQLite
  2. NoSQL database:比如说 Key-Value 的 KV
  3. 类 S3 的存储:R2 Blob

我们一开始设计的架构非常简单,而且符合大众的思考,应该把所有的用户数据、会话数据存到 SQL database 中。用户的附件应该存到 R2 的 Blob 中。

但是 Cloudflare 是一家 Serverless 服务商,在 Serverless 这种情况下可能会遇到问题。

第一,与 Supabase 不同,Cloudflare D1 它的服务分布在全球各地,如果我们用的是 D1 的全球副本,数据的复制是异步的。我们在存入数据时,副本可能还没有完成更新,所以读取这些副本会有延迟。

第二,我们 AI 会话的本身需要并发地大量处理各种的状态更新,用 D1 难以保持它的一致性。

所以,这就隐性造成了很多问题,特别是在同步会话和信息时,会造成非常大的隐患。我们尝试过把会话数据以 JSON 或其他格式存在 R2 中,写入时加一个锁来保证一致性,但是 R2 的 IO 开销比较大,而且它毕竟是远程存储,不适合频繁地把整个 Session 读出来、修改、写回。

这个时候我在寻找解决方案时,想到了 Cloudflare 的 Durable Objects。

DO 用 JavaScript class 去描述,有全局唯一的实例身份,同一个 Durable Object ID 在全局同时只会有一个实例运行,并且每个实例都有自己私有的、事务性的、强一致性的持久化存储,还提供完整的 SQLite 存储 API。

export class MyDurableObject extends DurableObject<Env> {
  async sayHello() {
    let result = this.ctx.storage.sql
      .exec("SELECT 'Hello, World!' as greeting")
      .one();
    return result.greeting;
  }
}

export default {
  async fetch(request, env, ctx): Promise<Response> {
    const stub = env.MY_DURABLE_OBJECT.getByName(USER_ID);
    await stub.sayHello();
  }
}

我们可以把一个用户或者一个会话直接映射成一个 Durable Object,让这个对象本身成为状态的持有者,并负责协调对这个状态的访问。

Durable Objects 以 Serverless 形态运行,按需唤醒,接到请求或者其他 Worker 的 RPC 调用时启动,并在一段时间内销毁。

Durable Objects 还实现了 Alarm,符合我们未来要做的定时任务的需求。

Durable Objects 只有 Cloudflare 才有,所以我们主动放弃了平台无关性,我们分了三层来重新构思这个持久化设计:

  1. 第一层是基于 D1 SQLite 的用户和验证:我们使用 Better Auth 和 Drizzle 来完成
  2. 第二层是基于 Durable Objects 的用户数据,比如说记忆、定时任务、配置,他们存储在 User DO 的 ctx.storage.sql 中。
  3. 第三层是基于 Durable Objects 的会话设计,我们使用 Cloudflare Agents SDK 来完成会话设计
           AI Chat
               │
      ┌────────┴────────┐
      │                 │
     D1                DO
      │                 │
     Auth        ┌──────┴──────┐
                 │             │
               User ──────> Sessions
                 │             │
              State A       State B

这样下来,我们让 SQL 负责相对稳定的全局数据,把真正需要强一致、并发协调的状态交给 Durable Objects。

具体到 Luna,就是两个 DO 各司其职:

  1. UserDO 按用户维度持有记忆、配置、宠物数值、技能清单和文件索引。
  2. ChatDO 按会话维度持有对话历史和模型轮次。

同一个用户或同一个会话在全球同时只有一个实例在运行,并发写冲突由 DO 单例本身协调。

Worker 收到用户消息后唤醒对应的 ChatAgentChatAgent 再去 UserAgent 拉取记忆构成 system prompt,然后一边流式调用模型、一边把模型的增量输出翻译成结构化帧落入存储,最后以 SSE 流回浏览器。

使用 Agent 在 6 个小时从 Nuxt 迁移到了 TanStack Start

之前说过,我们项目最开始是用 OpenNext 的 Next.js 来实现的,然后我们迁移到了 Vue 的 Nuxt.js,因为我们看中了 Nuxt Hub 的平台无关性。

但是,当我们确定 DO 架构时,发现 Next.js 并不能真正处理 Cloudflare Durable Objects 的绑定,这是一个 Cloudflare 的已知 issue

Cloudflare DO binding not supported in Nuxt

UserDO / ChatDO 在 Nuxt 的适配层里根本拿不到,缺少 DO 绑定就无法继续推进。

所以我们决定:用 Agent 把整个项目从 Nuxt 搬到 TanStack Start。

这次迁移花了大概 6 个小时,思路是分而治之、ReAct 与 测试。

第一步,先把依赖的映射关系理清楚。

Nuxt 是一个重度约定的前后端一体框架,字体、图片、UI、内容、动画都有官方模块。我们开了多个 subagent 并行去找 React 生态的对位替代——比如 Nuxt Content 对应 Content Collections,Nuxt Hub 对应 Cloudflare Vite 插件,motion-v 对应 motion,TresJS 对应 React Three Fiber,Nuxt UI 对应 shadcn/ui。React 生态足够大,这一步几乎没有卡点。

Nuxt to TanStack Start migration flow

第二步,配置 Skills 和 Hooks。

通过 Context7 抓取目标库的最新文档,并用 npx skills findbunx @tanstack/intent 安装官方 Skills,避免 Agent 靠编写错误的 API。

然后我们还配置了 ESLint 和 Prettier,并设置了 hooks,使它在每一步完成后,都能及时地进行类型检查、lint 检查以及格式化检查。

第三步,拆解层级,定好 monorepo 的边界。

Nuxt 的 layers/(按 00.site / 01.auth / 06.chat … 数字前缀决定优先级)被拆成 packages/@luna/*apps/web 作为唯一可部署的 TanStack Start 应用,其余包只通过子路径导出对外暴露,禁止跨层反向依赖。

Monorepo directory structure

Nuxt 的自动发现被显式的 workspace 依赖取代,结构反而更清晰了。

第四步,UI 体系从 Nuxt UI 切到 shadcn。

列出所有用到的组件,逐一映射到 shadcn 的 Components,我们既然都 monorepo 了,就都放在 @luna/ui 子 repo 中。

第五步,主 Agent 列出包含 subagent 顺序计划,开始实现

这一步是关键,由于各种 layer 相互错综复杂,所以我们在制定计划时,必须要考虑 subagent 的顺序,哪一步是并行,哪一步是先后的。

我们必须综合速度和质量,考虑各个 monorepo 的顺序来重构,这样才能重构成功。

最后,启动服务,使用 Chrome MCP 测试。

127.0.0.1:3000 拉起开发服务器,用浏览器自动化走一遍完整链路,确认 DO 的 RPC 和 SSE 能够正常连通。

我们没有写任何 codemod 全部由 AI 来搞定,我们使用 Grok 4.6 来做到这一切,6 小时后,已经可以在本地跑通完整的对话链路。

Three.js 与 VRM 的宠物

为了拟人感,也是为了有虚拟现实的体验,我们一开始就打算使用一些模型,比如说 Live2D 或者是 3D 的模型。

VRM model preview

这个 VRM 模型是我们队的美术担当花一个下午制作出来的,从 VRoid Studio 捏人、调整骨骼,到导出 .vrm,再到在 Three.js 里点亮,前后大概 4 个小时。

VRM 本质上是带扩展的 glTF,@pixiv/three-vrm 会处理 humanoid 骨骼、人形约束和表情 blendshape。

思路上我们把「渲染」和「状态」分开。

渲染是一个 R3F 场景,负责加载 VRM、计算包围盒自适应相机、混合动画过渡;状态侧是一个小的状态机,管理 idle / happy / sleep 等动作的切换。

为了性能,Three.js 场景只在宠物面板展开时才加载,收起时只保留数值面板。

状态有一套数值系统(饱食度、心情、亲密度),存在 UserDO 里,每次对话结束会微调。

Pet status panel

关于动作,我们使用下面要讲到的 Dialogue Transport Protocol 来在会话时执行 VRMA 动作。

在喂食和清洁等操作时,我们还预设了一些动作,我们还在内部维护了一个状态机,在 idle 时也会偶尔执行动作,以免用户无聊。

Bash Tool 就是你 Harness 的全部

然后我们开始设计整个 Harness,在 Agent Loop 就是老的 Pi 的那一套,存储消息,估算 token,达到预算后压缩。

但是我们真正要考虑的是 function calling。

我们每次请求 AI,请求之间的往返是有时间成本的。如果我们把所有的聊天工具、宠物工具以及其他工具都做成 tool, API 的次数会很多,而且会造成很大的时间成本,所以关于这个设计,我们有一个消息协议和三个工具。

协议等会儿我们再讲,我们先说这三个工具。

  • view:AI 的眼睛,可以通过指定行数来查看大文本,它还支持多模态媒体,比如图片、视频和音频
  • patch:AI 的手,可以一次性更改多个文件
  • bash:AI 的心脏,其他所有的功能都会通过 Bash 解决

一个虚拟的 FS

我们希望 Luna 拥有一个“像真实电脑一样”的文件空间,这样他可以参与复杂任务。

有目录、有文件、能安装技能、能保存附件。但 Cloudflare Workers 没有本地文件系统,R2 是对象存储,DO SQLite 也不适合直接存放大块文件内容。

所以我们做了一层虚拟文件系统(VFS),把「索引」和「内容」分开。

索引是一份 manifest,存在 User 的 Durable SQLite 里,记录每个路径对应的对象 key、大小和类型。

真正的文件内容作为不可变对象存在 R2 里,每次写入生成一个新的 UUID key,旧对象由后台任务回收。

目录也做了权限划分,/luna/system 是只读的系统技能,/luna/user/<id>/skills/luna/user/<id>/attachments 才是用户可写的空间,其他路径一律拒绝。

/luna/system
    └── READ ONLY

/luna/user/<id>/
    ├── skills/       ← READ + WRITE
    └── attachments/  ← READ + WRITE

其他路径
    └── DENY

并发则用 CAS(compare-and-swap)保证:每次提交带上预期的版本号,冲突就返回错误并重试,避免两个轮次同时写入把索引破坏。

限制也写得很明确,单文件 5 MiB、最多 100 个文件、总计 20 MiB。

再给一个虚拟 Bash

有了文件系统,下一步就是让 Agent 能使用更加强大的工作,bash 承担 90% 的能力,AI 经过的训练已经包含大部分的 Bash 语料,所以它本身就已经熟悉 Bash。因此,我们需要给 AI 一个 Bash 工具。

但是我们不能真的给 Agent 做一个真实的 Bash。因为有两点:

  1. 成本:如果我们每一个对话都给 Agent 分配一个 container,那么成本会很大
  2. 安全:如果给他真实的 Bash 环境,那么权限和安全就变得不可控,

所以我们给 Agent 一个虚拟的 Bash,Bash 的运行时是 Just Bash,一个能在 JS 环境里运行的 Bash 模拟器,我们运行在 Cloudflare Workers 里。

它的文件系统就是上面那套 VFS,网络开放了 curl,执行有配额限制,每次对话都在一个隔离的 shell 里执行,不会污染其他轮次。

因为是虚拟 Bash,单一能力的工具就没有保留的必要,我们设计了一个自定义的工厂函数,他可以自定义 command。

比如我们可以设计一个 skill 工具,可以让他搜索和读取 skills。

我们可以给 Agent 添加新功能的方式就是自定义 command + skills 的做法

想让 Luna 记住一件事,直接往文件里追加一行,想安装一个 skill,用 curl 拉取后保存到技能目录,想查询天气、搜索网页、拉取知乎热榜,这些在 Luna 里都注册为 Bash 的自定义命令,既可以在 shell 里以 CLI 形式调用,也可以被模型以工具形式调用——同一套注册表,两套入口。

甚至有些是 client command,部分命令需要浏览器配合,比如获取定位、发送通知、切换主题,它们会通过 ChatDO 的 WebSocket 往返到当前标签页,这种感觉真的很奇妙,这可能就是「人机恋」的魅力吧。

我们把 Harness 做成了文件系统 + Bash,模型学会使用 Bash,就学会了操作 Luna 的整个世界。

介绍我们研发的新协议 Dialogue Transport Protocol

刚才我说了,每一次 AI 的 API 往返都是有时间和价值成本的,所以我希望它一次可以返回多个信息。

我们最开始设计的方案是 JSONL,每一行都是一个独立的 JSON。

{type: "text", content: "hello\nworld"}
{type: "sticker", id: "smile"}

但是我们很快就发现一些问题。既然让 AI 来输出,它的输出就是不稳定的。

比如 JSONL,我们每行都是一个 JSON,看起来实现很方便。但是,所有的换行都需要 AI 主动用转义符转义,AI 很难做到这一点,所以很难解析成功。

所以我们要设计一个全新的协议,需要满足以下条件:

  1. 不能与大部分常见的语言重合

因为 AI 输出是不稳定的,我们不能去直接设计成 JSON 或者是 XML 的格式,因为如果我们直接让 AI 去输出代码,它们可能会直接用这些格式,直接会造成格式冲突。

  1. 支持多条消息:

AI 直接生成协议文本,我们希望可以包含多个消息、多个事件,而且可以自定义属性。

消息有很多类型,例如文字、图片、语音、视频。

那么事件也有可能包含多个类型,比如反应、投票。

  1. 足够熟悉、简单:

门槛不能太高,而且可以容错,例如像 wired HTML 一样,我们即使标签没有闭合,即使它的属性带有引号或者是属性,或者是嵌套错误、重复嵌套,也能够解析。

  1. 解析简单

便于跨平台、跨语言迁移。

  1. 人类可读

AI 输出的是文本,人类阅读的也是文本。

所以我们设计了我们自己的 frame,我们把它称作 Dialogue Transport Protocol(DTP) 协议。

┌──────────────────────────────────────────┐
│ <|message                                │
│   id=msg_123                             │  ← Header / Envelope
│   type=text                              │
│   reply=msg_100                          │
│   target=user_42                         │
│   ...                                    │
│ |>                                       │
├──────────────────────────────────────────┤
│                                          │
│  Hello!                                  │
│                                          │  ← Payload
│  This is **Markdown**.                   │
│                                          │
│  ```ts                                   │
│  console.log("hello")                    │
│  ```                                     │
│                                          │
├──────────────────────────────────────────┤
│ <|/message|>                             │  ← End
└──────────────────────────────────────────┘

协议长什么样

DTP 的每一帧都是这样的文本——分隔符用 <| ... |>,几乎不会和 Markdown、代码块、HTML 冲突,模型不需要转义就能稳定生成。

帧头是类 HTML 属性的键值对,id / type / reply / target 等信封字段由协议定义,其他字段由具体类型自行扩展。

载荷就是普通的 Markdown 文本。

<|message id=a type=text |>
Hello
<|/message|>

<|message id=b type=sticker |>

<|message id=c type=reaction target=a emoji=❤️ |>

<|message id=d type=vote target=a options="A,B,C" |>

流式解析

模型是逐 token 流式输出的,DTP 提供了流式解析器,每收到一段就喂进去,结束时做隐式闭合和引用解析。

它和批量解析共享同一套词法和语法,行为完全一致。

在 Luna 的对话链路里,流式解析器就夹在模型输出和前端渲染之间,把文本流实时翻译成结构化帧,再分发到 SSE 推送、DO 存储,然后 dispatch。

历史消息先被反序列化为 DTP 文本塞进 system prompt,让模型看到用哪些 id 去回复。

模型生成时,流式解析器把增量文本翻译成帧,并把临时 id 重映射为服务端稳定 id。

最后根据帧类型发送前端,然后分发出对应的消息和事件。

整个过程对前端是透明的,收到的就是一条条带类型的消息。

尾声

5 天 3 个人花了 1000 刀 tokens做了一个我们的 OC 猫娘,路程真的是艰辛。

  • 为了强一致的会话,我们放弃了平台无关性,选择 all-in Cloudflare Durable Objects。
  • 为了让 Harness 足够通用,我们放弃了一堆零散的工具,只保留 Bash。
  • 为了让协议满足需要,我们放弃了 JSON,选择了我们设计的消息协议。
  • 为了让架构真正跑通,我们放弃了 Nuxt 的舒适区,用 Agent 在 6 小时内搬完了整个应用。

这些取舍让 Luna 拥有了自己的文件系统、自己的身体、自己的记忆和技能,以及自己的通信协议。

如果你也想养一只 Luna,欢迎来 luna.htu.me/chat 找她聊天。

Luna chat interface

它开源在 LUNA 中。

讨论

继续讨论。

欢迎问题、勘误和经过思考的不同意见。你的邮箱不会公开。

发表评论

必填
可选
可选
可选
必填

支持 GFM Markdown / 最多 5,000 字符

评论

0 条评论

还没有回应,来开始这段讨论。