罗德岛通讯与技术部

一个人做一个 AI 伴侣应用:架构与取舍

A solo-built AI companion app: 211 API routes, 97 backend modules, 45 features — and the architecture decisions (and refusals) that kept a one-person project shippable for four months.

罗德岛通讯与技术部

1783 个提交、四个月、一个人。这一篇讲架构,但重点不是"用了什么", 而是"拒绝用什么"—— 因为一个人的项目里,最大的成本永远是维护成本

问题

一个人要同时做产品、设计、后端、前端、运维和客服。任何一个"以后再说"的技术选择, 都会变成你半夜三点要爬起来修的东西

而 AI 伴侣这个品类还有额外约束:它要长期记住一个人(记忆是核心资产), 要有性格且不重复(提示词工程),要能一直聊(成本与延迟), 还要让用户相信她记得(产品承诺与实现的落差会成为投诉)。

约束

约束 来自哪
一个人维护 没有测试队伍、没有运维、没有 code review ✗
单机部署 一台 VPS(nginx + systemd),没有 K8s 也没有云服务预算
用户自带 API key 我们不能替所有用户付模型费,所以 key、模型、端点都是用户级
移动优先 用户主要在手机浏览器里用;桌面是增强,不是前提
四个月要能持续发版 每天都要能上线,不能有"发版窗口"

决策

1. 单进程单体,三层目录边界

app/api/       ← 只做 HTTP:参数校验、鉴权、SSE
app/systems/   ← 业务:记忆、关系、世界、叙事、实验
app/core/      ← 基础设施:路径、配置、LLM 客户端、日志

没有微服务、没有消息队列、没有 ORM 抽象层。 理由:单机 + 一个人 ⇒ 分布式带来的 每一点复杂度都是净负债 ✗。但目录边界是硬的systems 不许碰 HTTP, api 不许直接读文件路径。

结果:211 条路由 / 25 个 api 模块 / 97 个 systems 模块,一次 git push 就是一次发布。

2. 记忆分层,但每一层只干一件事

L1 短期:最近的原始对话(会话骨架)
L2 中期:按话题压缩的切片
L3 长期:事实与设定("她答应过什么")
+ 叙事层:这条关系走到哪了(core_narrative / 最近事件)

一开始我们把同一段经历写进了三层(每层一个 LLM 在不同时间压缩)—— 结果是三份措辞不同的摘要互相打架,模型读到"三份都在说这件事"就反复说它 ✗。 后来定的规则是:每一层只负责一个时间尺度,同一件事不许在两层里各讲一遍。

3. 关系模型:一个(用户, 干员, 身份)= 一条不可逆的关系

这是产品决策,但它决定了数据模型:

  • 不可逆 ⇒ 不能"回到过去",因此没有"时间线分支"(我们删掉了做了一半的时间线功能);
  • 唯一 ⇒ 一个身份只能有一条线,聊天记录按身份隔离(换身份 = 换一条关系);
  • 深聊是模式而不是一次性会话 ⇒ 进对话自动续上未结束的深聊。

这带来一个反直觉的工程后果:很多功能是"删掉"而不是"加上" ✓ —— 我们主动移除了 时间线分支、线管理路由、全局判重这些"看起来很强"的东西 ✗。

4. 前端:不用框架,用纪律代替

  • 5 套主题、全部 token 化(换主题只改变量 ✓);
  • 缓存串 ?v= 全量统一(否则修复到不了用户 ✗ —— 这条我们栽过,见下文);
  • 规则用测试钉住:字号 ≥16px(iOS 不缩放)、图标不被 JS 抹掉、内联样式不许超标…
  • 移动优先,桌面是同一个 DOM 的另一种排布(不是两套实现 ✓)。

5. 运维:一个人能维护的极限

  • 发布 = push_bundle.sh(git bundle 送提交)+ deploy.sh(merge + 重启 + 验证);
  • 先在自己实例上验,再上线上:数据路径是相对路径 ⇒ 换个工作目录就是一份隔离数据, 于是"实验"和"生产"共用一套代码、两套数据 ✗(这是这一夜我才补上的基建);
  • 每个不变量都配一条守卫测试:现在有 90+ 条失败基线之外的测试盯着"曾经踩过的坑"。

结果

数字
代码规模 211 条路由 / 97 个后端模块 / 5 套主题
提交 1783 次,横跨 4 个月,88 个有提交的日子
最高产的一天 117 个提交(群聊做深)
发布方式 一次 push ≈ 一次上线,无停机窗口
前端体积 无框架运行时;单页 CSS + 12 个 ES 模块

踩坑与教训

  • "代码里有" ≠ "线上在跑":反重复的两层写了、测了、守卫全绿 —— 但它们只覆盖了两条路里的一条 ✗。后来我把"能力存在"与"能力被接线"分开当两件事验 ✓;
  • 缓存版本号必须提交:我改了前端却没把 index.html 的版本号一起提交 ⇒ 回访用户一直拿旧 JS ✗,表现为"我这边什么变化都没有" —— 这类事故看起来像产品问题,其实是构建问题 ✓;
  • 不要用规则代替数据:我们争论过"要不要开思考链",最后靠 A/B 定:情感戏更好、延迟从 2.6s 涨到 13.8s ✓ —— 有数字就不吵了 ✓;
  • 删掉一个仪表要给它替代品:去掉信赖数值时,用户第一反应是"好丑" ✗ —— 后来换成她自己的话 ✓(产品承诺不能留空坑 ✓);
  • 警告:一个人的项目里,"能跑"和"能维护"是两件事。所以每条守卫测试的注释里都写着当初是怎么错的 —— 那是给未来的自己看的 ✓。

如果重来

  • 会更早建独立实验实例(这一夜才补)—— 因为"在生产上验"等于把用户当测试环境 ✗;
  • 会更早把**"为什么"写进数据**(每次状态变化都记来源与理由),而不是只记数值;
  • 更早删掉那些"看起来很强但没人用"的功能(时间线分支就是)✓。

本站就是这套东西的一部分:静态生成、无框架运行时、与主应用共用同一套设计 token。