一个人做一个 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。