StoryForge 深度评测:2 Star 的 AI 人生故事记录应用,2571 行代码里 24 处接缝,Docker 两条部署路径都断

如果你最近刷到过「AI 两小时写完一个全栈应用」的演示,心里大概都闪过同一个疑问:演示跑通了,交付的那堆东西真能给人用吗。

这个问题很难被认真回答,因为大多数演示项目根本没交付。StoryForge 是个例外——它把答案留在了仓库里。

这是一个用 AI 引导你回忆、再把对话整理成人生故事的应用。前端 React,后端 Express,数据库用 sql.js(一个把整个数据库放在内存里、需要靠整库回写文件来保存的 SQLite 变体)。仓库里 90 个文件、2571 行 TypeScript,界面有五个页面,中英双语,还带语音输入。

GitHub 上 2 个 Star,0 个 fork,0 个 issue,没有 LICENSE 文件。创建于 2026 年 4 月 6 日,五个提交全部发生在同一天的两个小时里,此后 213 天没有再动过。它凉透了。

但正因为凉透、无人维护、也没人包装过,它是一具很干净的标本。我把它装进沙箱(一台隔离出来的临时机器,跑什么都碰不到我的生产环境),按「装得上、存得住、对得齐」三条线逐条验,跑出 24 处接缝——每一处都能用一条命令复现。

它到底做了什么

一句话概括:它不是笔记本,是一个会追问的采访者。

打开首页,你点「开始新故事」,进到一个聊天界面。对面那头的 AI 被一段固定的开场白(系统提示词)交代了身份:温和地提问、用开放式问题挖细节、不过度追问敏感话题。你讲一段,它接一句,聊够了点「结束对话」,它把整段对话交给模型,要求以第一人称写成一篇文章,存进「故事」里。

设计上有三处心思值得记一笔。

一是主题引导。首页给了三个推荐入口——童年、求学、职业,点了直接带着题目进聊天页,省掉「从哪儿说起」这一步。这个细节做得很懂产品,因为写回忆录最大的阻力正是不知道从哪开始。

二是语音输入。输入框旁边有个麦克风,用的是浏览器自带的语音识别,普通话识别开了连续模式。对着一台电脑讲二十年前的院子,比打字自然得多——这是这个应用真正的入口形态。

三是导出双格式。写成后能导出 Markdown 或纯文本,本地保存。对一个「记录人生」的东西来说,导出意味着你没有把记忆抵押给某个网站。

架构本身是教科书式的分层:路由、控制器、服务、模型四层,每层一个目录,索引文件负责把同类文件汇总导出。打开任何一个文件,你都能立刻看懂它在干什么。这是 AI 生成代码最讨喜的一面:命名规范、结构完整、没有历史包袱。

问题出在层与层的接缝上。

第一类裂缝:装不上去

README 给了两条安装路径,本地开发和 Docker。我把两条都试了。

本地开发这条路基本能走通。后端装依赖 223 个包、前端 359 个包,都没有报错。前端执行构建,类型检查通过,打包出 418 KB 的脚本(压缩后 135 KB),能正常产出。后端把服务起起来,接口也确实响应。

但后端的构建命令退出码是 2。TypeScript 编译器报了两个类型错误,都在数据库模型的取值转换上:一种从查询结果里取数据的写法,被编译器判定为「这两种形状不可能对得上,你可能写错了」。

有意思的是,构建失败了,产物照样生成。因为编译配置里没有开「出错就别输出」这一项,所以报错归报错,dist 目录里还是躺着一份能跑的代码。这就形成一个很别扭的状态:说你构建成功吧,命令退出码是失败;说你失败吧,服务真能起来。团队里只要有人的自动化流程依赖退出码,这条链就断了。

Docker 这条路是彻底断的。Docker 编排文件(也就是 Docker Compose 配置,一份写明「要起哪几个服务、各自的代码在哪」的说明)里,两个服务的构建目录写成了 ./backend 和 ./frontend,听起来没问题——但那个编排文件本身在 docker/ 子目录里,路径是相对于它解析的,于是变成去 docker/backend 找代码。那个目录不存在。我在沙箱里跑构建,报错原文是 unable to prepare context: path .../docker/backend not found,退出码 1。

同一个坑踩了两遍:编排文件里的前端服务也一样找不到。README 里那句「编辑 .env 填入 AI 配置,然后一条命令起服务」,对任何一个照做的读者都是死路。

顺手还挑出三处文档和现实对不上的地方。README 两个语言版本里的克隆命令都是 yourusername/storyforge,占位符没替换;许可证丙边写着 MIT,仓库里既没有 LICENSE 文件,GitHub 接口返回的许可证字段也是空;两个子项目的包名一个叫 apstory-backend、一个叫 apstory-frontend,接口自我介绍叫 Apstory API,README 叫 StoryForge——四个地方三个名字,看着像从某个旧项目改名改了一半。

检查项 我跑的命令 结果
后端构建 npm run build 退出码 2(2 个类型错误),dist 仍生成
后端静态检查 npm run lint 退出码 2(没有 eslint 配置文件)
前端静态检查 npm run lint 退出码 2(同上)
Docker 构建 docker compose build backend 退出码 1,docker/backend 不存在
许可证文件 ls LICENSE* 无此文件,接口返回 license: null
测试文件 排除依赖目录后搜索 0 个

最后一行值得单说。整个仓库没有任何测试。我一开始在依赖目录里搜到六个测试文件,全是第三方包自带的。2571 行代码,零测试,零 lint 配置——但两个包都规规矩矩写了 lint 脚本。这是典型的「结构齐全,内容为空」。

第二类裂缝:数据会丢

这一类的严重程度远超构建失败,因为用户看不见。

sql.js 的工作方式决定了它的写入模型:数据库整个活在内存里,每次改动之后要把整个文件重新导出、再写回磁盘。这是这个库的固有设计,用它就得接受。

问题在于写回频率和多人同时写。我给它挂了一个只观测不改代码的钩子(记录每次写数据库文件),数出来:发 20 条消息,触发 42 次整库写回,累计向磁盘写了 1,089,536 字节——而那会儿数据库文件本身只有 32 KB。

也就是说,你聊二十句话,磁盘被写掉了最终文件大小的 33 倍。对一块固态硬盘这不算什么,但含义要看清:写入成本跟「你说了多少」无关,只跟「数据库整个多大」有关。用户攒了三百段回忆之后,改一个标题也要把几十兆重新写一遍。真正长期使用的产品不会这么做,这不是恶意的设计,是「能用就行」的设计。

更硬的问题在多人同时写同一个文件。我起了两个后端进程,指向同一个数据库文件,各建三个故事,一共应该六个。两个进程各自都看到了自己那三个——各自都觉得自己是唯一的。 然后我杀掉它们,重新起第三个进程读磁盘:只剩三个,全是后写那个进程建的。先写的那份,被后写的整库覆盖,静默消失,没有任何报错。

对一个「记录人生」的应用来说,这是最不能有的一种失败:不报错、不提示、用户以为自己写下来了。它现在的单机单人设定下也许碰不到,但 Docker 编排文件里本来就声明了可以同时起多个副本,这个坑就摆在那儿。

同一类还有几处:

  • 不存在的故事删得掉。删除接口对任何 id 都返回成功。我删了一个从没存在的 id,返回 204——「已删,无内容」。源码里那句「删不掉就报错」的判断永远走不到,因为模型层的删除函数永远返回真。
  • 报错全是 500。文档写的是「找不到资源返回 404」,实际所有「故事不存在」都返回 500 服务器错误。原因是抛的是普通错误,没带状态码。
  • 只改正文会把标题抹掉。更新接口要求标题和正文一起传,只传正文的话标题变成空值,撞上数据库的非空约束,返回 500。前端编辑器总是两个字段一起传,所以界面上看不见,但接口层面是坏的。
  • 失败的消息会留下半截。我把模型服务停掉再发一句,接口返回 500。用户那句话已经先落库了——因为代码是先存用户消息、再调模型。于是对话历史里留着一句没人回的独白,界面上也没有任何「这条失败了」的标记,既不能重试也不能撤回。

第三类裂缝:接口各说各话

这一类最隐蔽,因为它编译能过、运行不报错、只是结果不对。

前端定义的故事对象用驼峰命名(createdAt、updatedAt),后端返回的是下划线(created_at、updated_at)。两边各自都对,TypeScript 也管不到——它只检查单个项目内部,跨端的数据形状没有任何东西在核。

真实后果是:首页的每张故事卡片时间显示「刚刚」,永远「刚刚」。因为我甩了个空日期给格式化函数,算出来的差值不是数,所有「几天前」的判断全部落空,一路掉到最后一个 else 分支——也就是「刚刚」。详情页更直接,显示「Invalid Date」。

你写下了一段童年回忆,界面上告诉你这是「刚刚」发生的。功能没坏,但产品在这一刻失语了。

位置 前端以为收到 后端实际发出 用户看到
故事卡片时间 createdAt created_at 永远「刚刚」
详情页副标题 createdAt created_at Invalid Date
语言下拉「繁體中文」 有 zh-TW 语言包 只有中英两套 选繁体,界面回落简体
生成故事后的标题 模型写的标题 原标题不变 永远是「无标题故事」

最后两行值得展开。

语言包这件事做得其实挺细:89 条文案,代码里引用了 76 条,一条不缺,没有一个「找不到文案」的空洞。这是 AI 生成代码的强项——它会把翻译文件一次补齐。但设置页给了三个选项:简体、繁体、英文。语言目录里只有简体和英文两份。选了繁体,界面不会报错,只是悄悄退回简体。用户以为自己切换成功了。

生成故事这一环更可惜。它把对话交给模型,要求写成第一人称的文章,模型也照做了——但它把生成结果写回「正文」字段,标题原样保留。也就是你聊了半小时,得到一篇标题叫「无标题故事」的文章。导出 Markdown 时,标题会被拼在正文前面,而模型生成的正文本身常以二级标题开头,于是导出文件里出现「# 无标题故事」下面紧接「## 童年」——两级标题各说各话,一个来自系统默认值,一个来自模型。

还有一处结构性浪费:生成故事时,它把整段对话压平成「用户:…/AI:…」的纯文本,塞进一条消息里发给模型,而不是像聊天时那样按角色分条发送。两种调法对模型的效果差别不小——分条时模型能分清谁在说话,压平时它得靠前缀猜。同一个文件里两种调用方式并存,说明这两块代码是分头写的。

对话上下文也没有任何裁剪。我连发 40 轮,模型每次收到的请求体从 285 字符涨到 5386 字符,18.9 倍。真实用户讲一小时远超这个量。没有截断、没有摘要、没有「只带最近 N 轮」——涨到模型窗口装不下那天,功能会突然整体失效。

安全面得单独提一句。这个应用宣称「数据完全存储在本地,保护隐私」,但它没有任何身份验证:不带任何凭证就能列出全部故事、删掉任意一条。我实测列出并删除了一条标题为「我父亲的葬礼」的记录,一路畅通。跨域也是全开的,任何网页都能读你本机这个服务的数据。仓库里其实写了一份「生产环境禁止跨域」的中间件,但它没被挂上去——入口文件直接用了默认全开的写法,那份文件成了没人 import 的死代码。同名死代码还有一份重复的路由定义。

默认配置也和「本地优先」的说法相反:不填任何环境变量时,模型服务商默认指向 DeepSeek 的云接口。真本地的选项(Ollama、LM Studio)要自己手动切。

24 处接缝,逐条编号

前面按「装不上、存不住、对不齐」分了三类,下面把 24 条按原顺序列全。每条都是我实际跑出来的,不是读代码猜的。这个仓库凉了,这些缺陷不会被修,所以它反而成了一份可以对照的标准底稿。

# 位置 现象 属于
1 后端构建 npm run build 退出码 2(两个类型转换错误),且报错后仍把产物写进 dist 装不上
2 后端静态检查 npm run lint 退出码 2,没有配置文件 装不上
3 前端静态检查 npm run lint 退出码 2,同上 装不上
4 Docker 编排 两个服务的构建目录都解析到不存在的 docker/backend、docker/frontend 装不上
5 README 两个语言版本的克隆命令都是 yourusername/storyforge 占位符 装不上
6 许可证 文中写 MIT,仓库无 LICENSE 文件,接口返回空 装不上
7 全仓 2571 行代码,零测试文件 装不上
8 命名 包名 apstory-*、接口自称 Apstory、README 叫 StoryForge,四处三个名字 装不上
9 删除接口 删除从不存在的 id 返回 204 成功(模型层删除函数永远返回真,报错分支永不触发) 存不住
10 错误处理 「故事不存在」一律返回 500,而非文档写的 404 存不住
11 更新接口 只传正文会把标题写成空值,撞非空约束返回 500 存不住
12 对话链路 模型调用失败后,用户那句话已先落库,留下无人回应的独白 存不住
13 存储模型 发 20 条消息触发 42 次整库回写,共 1,089,536 字节(库仅 32 KB) 存不住
14 同时写 双进程写同一文件,6 条记录只剩 3 条,静默覆盖无报错 存不住
15 安全 无任何鉴权,无凭证即可列出并删除全部故事 存不住
16 跨域 默认全开,任意网页可读本机服务数据 存不住
17 跨域 写了「生产环境禁跨域」的中间件,但没人引入(死代码) 存不住
18 路由 一份重复的路由定义文件,无人引入(死代码) 存不住
19 默认配置 不填环境变量时模型服务商默认指向云端,与「本地优先」的说法相反 存不住
20 字段契约 前端要驼峰,后端给下划线,卡片时间永远「刚刚」、详情页显示 Invalid Date 对不齐
21 语言包 设置页给三种语言,语言目录只有两份,选繁体静默回落简体 对不齐
22 生成故事 模型写出正文,标题仍是「无标题故事」;导出后两级标题互相打脸 对不齐
23 生成故事 生成时把对话压平成一条消息,与聊天时按角色分条发送不一致 对不齐
24 上下文 40 轮对话请求体涨 18.9 倍,无截断、无摘要、无保留窗口 对不齐

为什么 AI 写的全栈特别容易这样

把 24 处接缝摊开看,会发现一个规律:几乎每一处都不在模块内部,而在两个模块交接的地方。

单独看任何一块,质量都不差。数据库模型的增删改查写得很干净,服务层的职责划分很清楚,前端的组件拆得合理,语言包的键值和代码引用严丝合缝——这一项 89 对 76,缺 0 个,是整份代码里完成度最高的部分。每个文件单独审,都能过。

坏的是一致性:前端以为是驼峰(createAt 这种首字母小写的写法)、后端给的是下划线(created_at);编排文件以为是相对仓库根、实际是相对自己所在目录;README 以为是 MIT、实际没有许可证文件;设置页以为有三种语言、语言目录里只有两种;删除函数以为会返回失败、模型层永远返回成功。

这类问题的共同点是:没有任何一个文件内部出了问题,所以逐文件审查一定漏掉它们。

这解释了为什么「AI 两小时写完全栈」的演示能成立,而交付物往往不堪用。AI 生成代码时是分块生成的,每块都在自己的上下文里自洽。它不会在写完前端类型定义之后,回头去改后端的序列化;也不会在写完编排文件之后,去验证一下路径解析规则。这些恰恰是人类开发者天天在做、而且做得不自觉的事——因为我们是在一个真实的运行环境里迭代,跑一次就知道接不上。

所以这份代码最值得学的地方,不是它哪里写错了,而是它暴露了「验收标准」该怎么定。如果你的验收标准是「能不能跑起来」,它合格——服务真能起,聊天真能接,故事真能存。如果你的验收标准是「交付给一个会踩到所有边角的人」,它有 24 处不合格。

顺便说一句,这个项目的作者显然也没打算让它被使用。五个提交里四个是删除操作,删的是需求文档、中文文档、架构说明和开发日志。它主动把「为什么这么做」的部分抹掉了,只留下代码。这也是一个信号:它更像一次生成实验的存档,而不是一个想被人装起来用的东西。

我的判断

值得看,不值得用。

值得看,是因为它是目前我见过最完整的一份「AI 生成全栈应用」的验尸材料。2571 行、90 个文件、五个页面、双语、语音输入、Docker 编排、分四层的后端——该有的结构一样不缺,接缝一处没对上。你要做的东西如果也是 AI 起的骨架,这份代码里的 24 条问题基本就是一张检查清单:构建命令的退出码、跨端字段命名、时间字段、语言包齐全度、错误状态码、删除的返回值、多人同时写文件会不会覆盖、有没有鉴权。

如果你是想给自己家人写一本回忆录,别用它。数据会丢、时间显示错误、生成的文章没有标题、Docker 装不上——四个问题里任何一个都足以毁掉一次「把记忆交给它」的尝试。

如果你是这个方向的开发者,它反而有点价值:它的产品直觉是对的(主题引导降低开口门槛、语音输入贴合口述场景、导出保证数据可带走),只是实现没兑现。这三条设计判断值得抄,代码一行都别抄。

对这个仓库本身,我也不建议提什么 bug 修复——213 天无人维护,作者删文档的动作已经说明了态度。真要用,我建议的做法是拿它的界面和数据模型当参考,后端换成一个真正的数据库和一个真正的服务端,从头写一遍。以这份代码的完成度看,重写的时间不会比修补长。

想看更多同类的开工前检查清单,可以参考这两篇:Cindy 深度评测(8158 Star 的客户端,4.9 万行规范里五条红线自己没守住)和 MoneyPrinterTurbo 深度评测(13.7 万 Star 的剪片工具,测试全绿但素材授权是空的)。

—

核实说明:本文所有数字均为实测。仓库按 --depth 1 克隆到隔离沙箱,依赖安装、构建、静态检查、接口测试、双进程同时写入测试全部在沙箱内完成,未接触生产环境。模型服务使用本地模拟服务(返回固定文本),因此「AI 回复的具体内容」不在评测范围内,测的是链路是否接通。实测项与命令:npm run build(后端退出码 2 / 前端通过)、npm run lint(前后端均退出码 2)、docker compose build backend(退出码 1)、40 轮对话请求体增长曲线、20 条消息的整库写回计数(42 次 / 1,089,536 字节)、双进程同时写入后的数据留存(6 → 3)、无凭证访问列表与删除接口(200 / 204)。

局限:未接真实模型服务,因此回复质量、长对话的实际截断表现无法评估;未在真实硬件上久跑,写入放大的长期影响(数据库涨到几十兆后)是推断而非实测;仓库无测试,本项目没有可复跑的基准,所有结论来自黑盒接口测试与源码静态核对。

发表评论