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