一个五千万级别的大型程序,怎么管理"有的功能给用户、有的功能还在试验"?怎么打包才能又小又快又省内存?怎么让它跑得流畅?怎么测试一个会聊天、会改文件的程序?本篇讲这四件工程上的事。
11.1 一份代码怎么变出不同版本:内部新功能怎么藏起来
项目里有很多功能处于不同成熟度:有的稳定面向所有用户,有的还在试验、只给内部用,有的按市场区域分批开放。如果靠"发不同版本的代码"来管理,会变成维护噩梦------改一个 bug 要同步到好几个版本。
项目的解法是功能开关(feature flag):所有功能的代码都写在同一份代码里,但每个功能挂一个开关,打包时决定哪些开关打开、哪些关闭。
开关怎么生效:打包时直接"摘掉"
这里的关键是:开关不是"运行时判断"(程序跑起来再 if 一下要不要用这功能),而是打包时就把关闭的功能代码彻底删掉。
打个比方:这不是"杂志印了所有文章,用黑条涂掉不让看的那几篇",而是"印刷厂根本没排那几篇的版"。关闭的功能代码不会出现在你下载的程序里------既省体积,又防止别人翻出来,还能保证运行时没有任何性能负担。
技术上,代码里这样写(示意):
markdown
如果「主动提醒功能」开关是开的:
加载这段功能代码
否则:
什么都没有
打包工具在编译阶段会检查每个开关:开着的,把这段代码保留;关着的,整段删掉(这个"编译时删除无用代码"的手法叫死代码消除,你理解成"打包时自动摘掉用不到的零件"即可)。
为什么对开关的写法有严格规矩
要让打包工具能在打包时就确定"这段要不要删",开关的用法必须写得让工具能一眼看懂。所以项目里有条铁律:开关只能直接用在"如果......就......"的条件位置,不能先存进变量、不能藏在函数里绕一圈。因为工具只认那种最简单直接的写法,写复杂了它判断不了,就不敢删(或者删错)。
这就像填报销单:必须按标准格式填,财务才能一眼审批;你写得绕来绕去,财务只能打回来。代码里到处能看到类似的注释提醒"这里必须这样写,打包工具才能正确处理"。
多道开关
有些敏感功能不只挂一道开关。比如某些内部工具,除了功能开关,还额外检查"当前是不是内部环境"------两道门都过了才出现。普通用户就算误触了开关,也拿不到内部功能。
这套机制的好处
- 一份代码,打包出不同配置的发布物(稳定版、内测版、内部版);
- 新功能可以先把代码合进来、开关关着,等做好了再打开,避免长期维护半成品分支;
- 出问题时可以"关掉开关"快速撤回一个功能,而不用回滚代码;
- 普通用户的程序里压根没有试验代码,更轻、更安全。
11.2 打包发布:为什么一个大文件要拆成几百个小文件
代码写完要打包成用户能下载运行的东西。这一章讲一个非常反直觉、但影响巨大的工程决策:把代码拆成几百个小文件,而不是打成一个大文件。
先讲"打成一个大文件"为什么看起来很美
打包最朴素的做法是把所有代码拼成一个大文件发给用户。好处明显:就一个文件,好分发、好启动(不用到处找文件)。早期项目也确实是这么做的。
问题:大文件会让内存爆炸
但实测发现一个严重问题:一个 17MB(约等于几百万字)的单文件程序,跑起来内存占用(RSS,即程序实际占着的内存)能冲到 1GB 以上,而它真正干活时很多代码根本用不到。
为什么会这样?因为运行环境(Bun 底层的 JavaScript 引擎)加载一个大文件时,会对整个文件做解析、编译准备------哪怕这次运行只用其中查版本号这一个功能。就像你为了查一个字,被迫把整本辞典搬进内存摊开,而不是只抽那一页。
解法:代码分割(code splitting)
改成把代码拆成几百个小文件(小块),每块包含一部分功能:
- 启动时只加载"马上要用"的那几块;
- 其他块等真用到了再加载(比如你第一次用 MCP 功能,才加载 MCP 那一块);
- 用过的块会被缓存,不重复加载。
效果非常显著(项目实测的量级):
| 场景 | 单大文件 | 拆成小块 |
|---|---|---|
| 查版本号内存占用 | 约 1GB | 约 35MB |
| 完整使用内存占用 | 1.5GB 左右 | 200-500MB |
查版本号这个对比最能说明问题:单文件时哪怕只查版本,整文件都得进内存;拆块后,查版本只加载入口那一小块,内存差了近 30 倍。
代价是什么
拆块不是没成本:
- 启动时要加载多个块,理论上比单文件"一次性读入"稍微多一点点启动开销(实际被"加载的块少得多"抵消了,净效果反而快);
- 打包配置更复杂,要定义哪些代码进哪个块;
- 要处理"块之间互相引用"的关系。
但相比内存省 30 倍、普通功能更轻,这些代价完全值得。
和前面机制的配合
代码分割不是孤立的,它和前面讲的机制环环相扣:
- 功能开关(11.1)在打包时删掉关闭的功能,那些块压根不存在;
- 懒加载(斜杠命令用到才加载、工具按需加载、插件先读清单)在运行时决定"现在要不要加载某块";
- 快速通道(第 2 篇)让查版本这种简单功能在加载任何大块之前就结束。
这些机制合起来的共同目标是:让程序"加载的代码量"尽可能贴近"当前实际要用的代码量"。
11.3 让程序跑得又快又省:启动速度、内存占用的优化手段
除了代码分割,项目里还有一整套性能优化。这一章挑最能体现思路的讲。
性能垫片必须最先加载(呼应第 2 篇)
第 2 篇讲过,程序第一行就是校准计时器。这里从性能角度再说一次为什么:计时数据被用来监控整个程序的性能(哪段慢、内存涨了多少)。如果计时器在校准前就被某些代码用了,那些代码的性能数据就是错的;而且底层引擎在长会话中会因为计时器问题导致内部数据结构无节制增长。一个看似不起眼的"第一行导入",实际是整个性能监控体系的地基。
界面渲染的优化(呼应第 6 篇)
- 双缓冲 diff:只重画变化的格子,不整屏擦写;
- 帧合并:16 毫秒内的变化攒一批画一次,凑够 60 帧/秒即可,不做无用功;
- 宽度缓存:算过的字符串宽度记住,不重复算;
- 长列表虚拟化:几百条历史消息只渲染屏幕上可见的那几十行;
- 跳过没变的组件:React 自己就会跳过数据没变的界面部分。
这些合起来让"逐字输出 + 实时工具进度 + 动画"同时进行也不卡。
别重复做无用功:缓存思维
"算过就记住、能复用就复用"这个思路在项目里到处都是:
- 提示缓存(第 5 篇):不重复给模型发相同内容,省钱;
- 文件状态缓存:记过文件状态就不重复检查磁盘;
- 配置缓存:读过的配置缓存起来,配置变了才清空重读;
- 记忆/工具的按需加载:不一次性全读进来。
内存治理
- 大工具结果截断(第 4 篇),不让一份十万行输出赖在内存和上下文里;
- 上下文压缩(第 5 篇),及时腾退不用的历史;
- 提供"导出内存快照"的诊断手段,内存异常时能查看是哪部分占着不放。
一个通用原则
这些优化背后是同一条原则:别为不会用到的东西付费------不管这个"费"是内存、启动时间、token 钱还是屏幕重绘。代码分割、懒加载、缓存、截断、虚拟化,都是这条原则在不同地方的体现。值得注意的是,项目不是一上来就过度优化,而是先有度量(性能垫片、各种监控),定位到真问题(比如单文件 1GB 内存)再针对性解决。
11.4 怎么测试一个会聊天、会改文件的程序
普通程序测试相对直接:给定输入,断言输出。但这个程序有几个麻烦:它要联网调模型、它会真的改文件、它是终端界面、它有大量异步流式行为。怎么测?
分层测试
和任何大项目一样,测试分层:
- 小块单元测试:纯函数最好测。比如"字符宽度计算对不对""工具名字规范化对不对""配置五层级叠加结果对不对""压缩摘要生成逻辑对不对"。这些不依赖网络、不依赖界面,给输入查输出即可,占测试的大多数;
- 集成测试:测几个模块协作,比如"工具执行→结果进入消息→压缩触发"这条链;
- 端到端测试:模拟完整的一次对话流程。
怎么处理"要联网调模型"
不能在测试里真的调远端模型(慢、贵、不稳定、结果还会变)。做法是**"伪造"模型层**:测试时把"发送请求、接收流式事件"这一层换成一个假的实现,它不联网,而是按预设脚本吐出一连串假的模型回复(比如"先回一段文字,再回一个工具调用请求")。
这样就能确定性地测核心循环:给它喂假的模型事件,断言它有没有正确地执行工具、有没有正确地把结果放进消息、工具拒绝时有没有正确处理。核心循环完全不知道对面是真模型还是假模型------这正是第 7 篇"内部统一、边界可替换"设计带来的测试红利。
怎么处理"会真改文件"
测试文件操作时,不能在真实项目里乱改。用临时目录:每个测试在一个临时沙盒文件夹里造好假文件,工具在这个假环境里读写改,测完删掉整个临时文件夹。既隔离又可重复。
终端界面怎么测
界面测试有专门手段:把"渲染结果"捕获成文本快照------给定一个界面状态,渲染层输出的字符画面是什么样,存成期望快照;以后改动后重新渲染,和快照对比,不一样就说明界面变了(可能是 bug,也可能是有意改动,人工确认后更新快照)。
一个真实的坑:测试里的"模拟"会互相污染
项目用 Bun 跑测试,遇到一个具体的坑:Bun 的"模块模拟"功能(就是上面说的"把某个模块换成假实现")在某些版本里是全局生效的------你在 A 测试里把网络层换成假的,同一个测试进程里的 B 测试可能也拿到这个假的,导致莫名其妙的失败。
应对办法:
- 尽量只模拟最底层(比如网络请求),不要模拟上层业务模块(影响面大);
- 需要强隔离的测试用"独立模块环境",让每个测试在隔离的加载环境里跑;
- 有专门的检测手段扫描哪些测试可能造成这种污染。
这个坑很有代表性:测试工具本身的行为也要被理解,否则你会花很多时间查一个"明明代码没问题、测试却挂了"的假 bug。
本篇小结
- 功能开关让一份代码打包出不同版本:开关直接写在条件判断里,打包时关闭的功能被整段删除(不是运行时隐藏),敏感功能挂多道开关;好处是不用维护多份代码、可快速撤回功能。
- 代码分割把大文件拆成几百个小块、用到才加载,解决了单文件 17MB 导致内存 1GB 的问题(查版本内存从 ~1GB 降到 ~35MB);它和功能开关、懒加载、快速通道共同服务于"加载量贴近实际用量"。
- 性能优化贯穿全项目:性能垫片最先加载、界面双缓冲+帧合并+虚拟化、处处缓存、大结果截断、内存快照诊断;核心原则是"别为用不到的东西付费",且先度量再优化。
- 测试靠分层(单元/集成/端到端)+ 可替换边界:模型层用假实现喂脚本化事件、文件操作在临时沙盒、界面用文本快照;要警惕测试工具的模拟污染问题。
最后一篇,把前面所有内容串成一条完整的线。