先看出事的那一刻
2026 年 9 月,自动驾驶知识站决定下线所有配图。正文里的配图标记删干净了,校验通过,前端也重新构建了。可在本地打开页面,[figure: 这样的标记原样躺在正文里,像是什么都没改。
校验和构建都通过了,问题不像出在代码里。于是换了一个问法:回答页面请求的,到底是谁? 前端开发服务器把 /api 请求转给 8787 端口。一查,占着 8787 的是一个 7 月 28 日启动的进程,它的脚本文件已经不存在了。
这节课要回答的是:为什么本地好好的,一上线就不对?又为什么明明改了,页面却纹丝不动?
装备几个词
为什么要懂
AI 替你做了
一句“帮我跑起来看看”,AI 就会启动开发服务器、模拟接口,需要时再起一个数据库,还会把它们留在后台,方便你随时打开。
留给你的
分清眼前的页面来自哪个进程、哪个端口、哪一份数据。AI 在后台启动过什么,它自己下一次未必记得。
不懂的代价
对着一份旧的东西反复改、反复验证,越改越乱;或者本地验收全部通过,上线才发现两边根本不是一回事。
同一份代码,两场演出
本地是彩排,线上是正式演出。剧本是同一份,舞台、灯光、演员却不一样。以 E2E Review 为例:
| 本地(这台电脑) | 线上 | |
|---|---|---|
| 谁在运行 | 开发服务器,改了代码自动刷新 | 构建好的产物和容器,放在托管平台上 |
| 前端怎么找到后端 | 开发服务器把 /api 转给 localhost:8787 | Cloudflare 上的转发脚本把 /api/ 转给 Railway |
| 内容从哪来 | 后端进程启动时读进内存的那一份 | 部署时打进镜像的那一份 |
| 数据库 | 得自己在电脑上启动(这台电脑上没有) | 平台提供的 Postgres |
| 配置 | .env 文件 | 平台后台的环境变量,数据库地址和端口由平台注入 |
| Node 版本 | 25 | 22,写死在镜像里 |
| 谁能访问 | 只有你 | 所有人 |
表里每一行,都可能造成一次"本地没问题、线上出问题",或者反过来。上线前最稳妥的做法,是按线上的方式构建一次、在本地预览构建结果,而不是只看开发服务器的效果。
端口上的那个人是谁
开发服务器的代理只认端口,不认人:谁占着 8787,/api 请求就交给谁。它不会告诉你对面是什么时候启动的、跑的是哪个版本。
查端口上是谁,两条命令就够:
lsof -nP -iTCP:8787 -sTCP:LISTEN # 谁占着 8787
ps -o pid=,lstart=,command= -p 进程号 # 它什么时候、用什么命令启动的
9 月那天查到的是:
82587 Tue Jul 28 15:54:00 2026 bun /private/tmp/…/scratchpad/mock-api.mjs
再去找这个脚本,文件已经不存在了。
撕掉菜谱,菜照样在炒
进程启动时,会把代码和要用的数据读进内存,之后就按内存里的那一份运行。E2E Review 的后端是这样读内容的:
const raw = JSON.parse(readFileSync(PATH, "utf8"));
只在启动时读一次。之后内容文件怎么改,它都不知道,除非重启;脚本文件被删,也不影响已经在内存里运行的它。
那个模拟接口,是 7 月 28 日 AI 在一次工作会话里临时起的:真后端需要数据库,这台电脑上没有,只好先起一个只读内容的替身。它用 nohup … & 放进了后台,这种写法的意思就是"关掉终端也别停"。AI 当天在发给站长的优先级清单里写过一句:这个替身是会话临时目录里的脚本,目录被清或者机器重启,它就没了;要么换回正式的后端,要么把脚本正式放进仓库,发布前必须二选一。结果只说对了一半:目录确实被清了,进程却一直活着。
深潜为什么两个月都没被发现+
7 月的旧前端会把正文里的 [figure:…] 画成配图。旧前端配旧内容,看起来一切正常;两个月里,内容本身也只改了三行。
9 月删配图时,前端先换成了新的(开发服务器会自动刷新),不再认识这些标记,后端却还是 7 月那一份。新前端配上旧数据,标记才原样露了出来。
换句话说,要不是这次恰好让两边对不上,这个进程还会一直安静地运行下去。
深潜环境变量:同一份菜谱,墙上贴着不同的当班说明+
E2E Review 需要七个环境变量:数据库地址、登录签名密钥、端口、运行模式和三项大模型配置。本地写在 .env 文件里;线上在 Railway 后台配置,其中数据库地址和端口由平台自动注入。
两边只要有一项不同,行为就可能不同。更隐蔽的是带默认值的变量:缺了不报错,程序照常启动,只是悄悄换了一种行为。第 11 课会讲一次真实事故:.env 里的配置被这台电脑上已有的同名变量悄悄盖掉,结果是一连串 401。
找茬
下面是 E2E Review 本地开发相关的真实代码和命令(节选)。它们合在一起,让一个两个月前的旧进程冒充了后端,而且没有人察觉。站长的需求很简单:本地看到的,应该就是刚改完的内容。
这段 JS / Shell 里埋了 6 处问题。点击你觉得有问题的行,至少找出 3 处再揭晓。
·第 2 行中危
代理不管对面是谁
开发服务器把
/api交给 8787 端口上的任何进程,不检查它是谁、什么时候启动、跑的哪个版本。端口上换了人,页面照样显示,只是内容不对。该问的话:8787 端口上现在是哪个进程?它是什么时候启动的?
·第 6 行低危
没配密钥,就用人人都看得到的默认值
生产环境有检查,缺了签名密钥会直接退出;但判断是不是生产环境只看
NODE_ENV。哪天换个方式部署、忘了设它,登录签名就会悄悄用上这个写在代码里的默认值。该问的话:如果线上忘了设置 NODE_ENV,登录签名用的是哪把密钥?
·第 7 行中危
注释里的本地环境并不存在
仓库里从来没有过 docker compose 文件,照着这句注释没法在本地把数据库跑起来。本地起不来真后端,才有了那个临时模拟接口。
该问的话:照着这句注释,我怎么在本地把数据库跑起来?文件在哪?
·第 11 行高危
内容只在启动时读一次
进程启动时读一次内容,之后文件怎么改它都不知道。模拟接口复用了这段代码,于是两个月后还在返回 7 月的内容。
该问的话:内容更新以后,这个服务要重启才能看到吗?
·第 14 行中危
快照不带时间
内容快照的生成时间永远是空的。一个正在运行的进程拿着哪一版内容,从返回结果里看不出来,只能靠猜。
该问的话:页面上现在的内容,是什么时候生成的?
·第 17 行高危
不受会话管理、没有日志的后台进程
nohup … &让进程脱离当前会话,输出全部丢进/dev/null,脚本放在会被清空的临时目录里。会话结束、目录被清,它照样在跑,而且不留任何痕迹。该问的话:你在后台启动过哪些进程?任务结束时,它们停了吗?
查看代码与答案(6 处问题)
1 // web/vite.config.js(前端开发服务器)
2 server: { port: 5199, proxy: { "/api": "http://localhost:8787" } },
3
4 // server/config.mjs(后端配置,节选)
5 port: Number(process.env.PORT ?? 8787),
6 jwtSecret: process.env.JWT_SECRET ?? "dev-only-change-me",
7 // Railway 注入 DATABASE_URL;本地开发用 docker compose 起的 pg
8 databaseUrl: need("DATABASE_URL"),
9
10 // server/content.mjs(读取内容快照,节选)
11 const raw = JSON.parse(readFileSync(PATH, "utf8"));
12
13 // pipeline/validate.mjs(生成内容快照,节选)
14 builtAt: null,
15
16 # 7 月 28 日,AI 在后台启动本地模拟接口的命令
17 nohup bun /private/tmp/…/scratchpad/mock-api.mjs > /dev/null 2>&1 &- 第 2 行 · 中危 · 代理不管对面是谁:开发服务器把
/api交给 8787 端口上的任何进程,不检查它是谁、什么时候启动、跑的哪个版本。端口上换了人,页面照样显示,只是内容不对。 该问的话:8787 端口上现在是哪个进程?它是什么时候启动的? - 第 6 行 · 低危 · 没配密钥,就用人人都看得到的默认值:生产环境有检查,缺了签名密钥会直接退出;但判断是不是生产环境只看
NODE_ENV。哪天换个方式部署、忘了设它,登录签名就会悄悄用上这个写在代码里的默认值。 该问的话:如果线上忘了设置 NODE_ENV,登录签名用的是哪把密钥? - 第 7 行 · 中危 · 注释里的本地环境并不存在:仓库里从来没有过 docker compose 文件,照着这句注释没法在本地把数据库跑起来。本地起不来真后端,才有了那个临时模拟接口。 该问的话:照着这句注释,我怎么在本地把数据库跑起来?文件在哪?
- 第 11 行 · 高危 · 内容只在启动时读一次:进程启动时读一次内容,之后文件怎么改它都不知道。模拟接口复用了这段代码,于是两个月后还在返回 7 月的内容。 该问的话:内容更新以后,这个服务要重启才能看到吗?
- 第 14 行 · 中危 · 快照不带时间:内容快照的生成时间永远是空的。一个正在运行的进程拿着哪一版内容,从返回结果里看不出来,只能靠猜。 该问的话:页面上现在的内容,是什么时候生成的?
- 第 17 行 · 高危 · 不受会话管理、没有日志的后台进程:
nohup … &让进程脱离当前会话,输出全部丢进/dev/null,脚本放在会被清空的临时目录里。会话结束、目录被清,它照样在跑,而且不留任何痕迹。 该问的话:你在后台启动过哪些进程?任务结束时,它们停了吗?
快测
1. 你改了后端的内容,刷新页面没有变化。开发服务器显示前端已经重新加载过。最先该确认什么?
对了。前端会自动刷新,普通后端进程不会。改了没重启,或者端口上根本是另一个旧进程,页面都不会变。所以先看回答的是谁、它什么时候启动的。
你可能是这么想的:重新构建一遍,总能保证用的是最新代码。可前端早就是新的,旧的是后端;重新构建做完,你会觉得“该做的都做了”,转而去怀疑代码。
你可能是这么想的:页面没变,多半是浏览器缓存了旧内容。缓存可能是原因之一,但开发服务器已经确认前端重新加载过,数据是后端给的。先确认回答请求的后端是不是新的。
改了没生效,先问“回答我的是谁”,再问“它是哪一版”。
查看选项与答案
- A. 确认是哪个进程在回答 /api 请求(正确)——前端会自动刷新,普通后端进程不会。改了没重启,或者端口上根本是另一个旧进程,页面都不会变。所以先看回答的是谁、它什么时候启动的。
- B. 重新构建一遍前端,确保页面用的是最新的代码——重新构建一遍,总能保证用的是最新代码。可前端早就是新的,旧的是后端;重新构建做完,你会觉得“该做的都做了”,转而去怀疑代码。
- C. 清空浏览器缓存,再强制刷新页面,排除旧缓存的干扰——页面没变,多半是浏览器缓存了旧内容。缓存可能是原因之一,但开发服务器已经确认前端重新加载过,数据是后端给的。先确认回答请求的后端是不是新的。
改了没生效,先问“回答我的是谁”,再问“它是哪一版”。
2. localhost:5199 在你电脑上能正常打开。你把这个链接发给同事,同事打开会看到什么?
你可能是这么想的:开发服务器会自动把改动发到线上。可它只在你这台电脑上运行,线上是另一套:构建好的产物放在托管平台上。localhost 和线上域名没有任何关系。
对了。同事的电脑上没有这个服务。要给别人看,得部署到公开地址,或者用托管平台的预览部署。
你可能是这么想的:同一个 Wi-Fi 里,localhost 应该大家都能用。可 localhost 永远指向打开它的那台电脑,同事打开的是自己的电脑,和是不是同一个网络无关。
本地地址只对本机有效。
查看选项与答案
- A. 多半是线上的版本,因为开发服务器会同步到线上——开发服务器会自动把改动发到线上。可它只在你这台电脑上运行,线上是另一套:构建好的产物放在托管平台上。localhost 和线上域名没有任何关系。
- B. 多半打不开,localhost 指向同事自己的电脑(正确)——同事的电脑上没有这个服务。要给别人看,得部署到公开地址,或者用托管平台的预览部署。
- C. 和你看到的一样,只要你们在同一个 Wi-Fi 或局域网里——同一个 Wi-Fi 里,localhost 应该大家都能用。可 localhost 永远指向打开它的那台电脑,同事打开的是自己的电脑,和是不是同一个网络无关。
本地地址只对本机有效。
3. 后端启动时发现数据库地址没配。下面哪种处理最好?
对了。E2E Review 就是这样做的:缺数据库地址就退出,数据库连不上也不“假装健康”。越早失败,越容易查。
你可能是这么想的:有个默认地址,服务就能先跑起来。可带默认值的配置缺了不报错,程序照常启动,只是悄悄换了一种行为;带着错误的配置运行,问题要很久以后才暴露。
你可能是这么想的:别的功能还能用,总比整体报错强。可一部分功能悄悄失效,比整体报错更难发现,出了问题也很难联想到是缺了一项配置。
配置缺失时,明确地失败,比悄悄地凑合好。
查看选项与答案
- A. 启动时就明确报错,说清楚缺了哪一项,然后退出(正确)——E2E Review 就是这样做的:缺数据库地址就退出,数据库连不上也不“假装健康”。越早失败,越容易查。
- B. 自动换成一个默认地址,先跑起来再说——有个默认地址,服务就能先跑起来。可带默认值的配置缺了不报错,程序照常启动,只是悄悄换了一种行为;带着错误的配置运行,问题要很久以后才暴露。
- C. 不报错,跳过数据库相关的功能,其余照常运行——别的功能还能用,总比整体报错强。可一部分功能悄悄失效,比整体报错更难发现,出了问题也很难联想到是缺了一项配置。
配置缺失时,明确地失败,比悄悄地凑合好。
判断时刻
你在本地删掉了正文里的配图标记,页面上却还显示着标记原文。AI 查了一圈,给了三个建议。
你先做哪一个?
考察:排查顺序
代价小,值得一试,但这次没用:前端早就是新的,旧的是后端。更麻烦的是,做完它你会觉得“该做的都做了”,转而去怀疑代码。
考察:绕开还是查清
页面马上就正常了,问题看似解决。可旧进程还在后台占着 8787,别的脚本、别的人照样会连上它;你也永远不知道,刚才到底是谁在回答。
考察:根因
多花一分钟,直接找到根因:一个 7 月 28 日启动、脚本已被删除的旧进程。停掉它,换成读取最新内容的服务,问题就不会换个样子再出现。
三个做法都可能让页面“看起来好了”,区别在于你知不知道刚才是谁在回答。遇到“改了没生效”,先确认回答你的进程,再动代码。
三个选项各自的代价
- A. 清空浏览器缓存,重新构建前端——考察排查顺序:代价小,值得一试,但这次没用:前端早就是新的,旧的是后端。更麻烦的是,做完它你会觉得“该做的都做了”,转而去怀疑代码。
- B. 让后端换一个端口重新启动,再把前端代理改过去——考察绕开还是查清:页面马上就正常了,问题看似解决。可旧进程还在后台占着 8787,别的脚本、别的人照样会连上它;你也永远不知道,刚才到底是谁在回答。
- C. 先用 lsof 查 8787 端口上是谁,看它的启动时间和命令——考察根因:多花一分钟,直接找到根因:一个 7 月 28 日启动、脚本已被删除的旧进程。停掉它,换成读取最新内容的服务,问题就不会换个样子再出现。
三个做法都可能让页面“看起来好了”,区别在于你知不知道刚才是谁在回答。遇到“改了没生效”,先确认回答你的进程,再动代码。
带走
下次让 AI 做这件事时,问它
- 这个项目本地要启动哪些服务?各占哪个端口,谁把请求转给谁?
- 你在后台帮我启动过哪些进程?现在还在跑吗?任务结束时,请停掉不再需要的。
- 本地和线上有哪些不同:环境变量、数据来源、数据库、Node 版本、构建方式?
- 内容或配置更新以后,哪些服务需要重启才能生效?
自己验证
lsof -nP -iTCP:端口号 -sTCP:LISTEN:查端口上是哪个进程。ps -o pid=,lstart=,command= -p 进程号:看它什么时候、用什么命令启动。- 上线前按线上的方式构建一次,再在本地预览构建结果(比如
npm run build之后npm run preview),别只看开发服务器的效果。
改了没生效时,先问一句:回答我的,是哪个进程?
