简单一点的健康
前言
我用过几个记录健康的 App。称重、拍照、打卡,数据收进去,隔天回我一条建议。我追问过一句,为什么今天让我吃这些。得不到回答,这类产品自己也不知道。
后来我自己写了一个。README 第一行写着 LLM = OFF,所有建议由 TypeScript 纯函数算出,写成 y = f(x)。同样的输入配上同样的规则版本,跑一万次都是逐位一致的输出。这条规矩从第一次提交保持到最后一次,中间没破过例。
项目从 9 月 24 日的 Initial commit 干到 10 月 8 日收尾,两周,24 次提交,12 套契约测试。下面先看它跑起来的样子。
一、先上效果
先上桌面 1440 的全页截图,单页流从刊头一直到底。

同一份代码放到 390 宽的手机上,栏目次序不变,全部纵排。全页有三千九百多像素高,这张只截开头一节,右下角那枚方钮是新加的悬浮章节目录,只在窄屏出现,点章名即滚到该栏。

录事一共三张表,各自从自己那一节的入口打开,表头先告诉你这一笔会归到哪里。归哪儿由保存时刻的时钟判定,表里干脆不问餐别。

二、它是干什么的
它是一本会算账的健康手记。你只负责记事实,账由它算,算完把每条建议的依据、假设和局限摊开放着。
页面只有一页,从上到下五块。刊头是时段问候加干支日期;配对行一放今日之事与今日之练,桌面用折缝分栏,移动端纵排相随;体征通栏夹在两行中间;配对行二放营养摄入与统计。
它回答三个问题。
下一餐吃什么。 今日之标依你的体征和目标算出来,每日热量、每日蛋白落在 1.4 到 2.0 克每公斤的区间,练日按 ×1.1 且至多加 400 千卡上浮。下一膳在今日缺口里排序推荐,给的是热量与蛋白质的区间,点「照准」才计入今日所食。
今天练什么。 睡眠、精力、酸痛、今天是否已经练过,四个输入判出 REST、RECOVERY、LIGHT、NORMAL 四种状态,课表由肌群账本、既往历史和每日时间预算拼出来。食物档案库 115 品、动作库 44 个,全在本地,不联网。
身体往哪走。 近七日均重、三十日端点变化、回归斜率三行分开列,末尾给四周、八周、十二周的情景区间。
架构上记一句话就够,UI 走到 HealthRepository 接口,接口后面是工厂,工厂按有没有配 VITE_SUPABASE_* 两行环境变量,决定走 localStorage 还是走 Supabase。默认零配置就是本地模式,换后端只改工厂这一处。云端那条路用 fetch 直连 PostgREST,@supabase/supabase-js 都没装,引擎始终在浏览器里跑。
代码在 jiahui09/Personal-Health-Coach,最新提交 5465377。
三、开发理念
3.1 所有建议都是 y = f(x)
项目里没有一处调用大模型。每条建议都能写成纯函数,输入是记录,输出是建议,中间的推导过程一并返回。同样输入跑两次输出逐字节相同,这件事有 verifyDeterminism() 盯着。引擎读时间也只从注入的 HealthContext.now 读,测试和真实页面拿的是同一个钟。
确定性换掉的是灵活性,我认为这笔账划算。生成式模型给建议时,顺手把措辞甚至数值一起生成了,你没法保证明天的版本和今天说的是一件事。健康建议偏偏需要这种保证,它要能存档、能回看,真出了分歧还能对账。
3.2 证据三级
每条规则被强制标注成三级之一。
| 分类 | 含义 | 例子 |
|---|---|---|
evidence_derived |
文献里的直接公式 | Mifflin-St Jeor (1990) 静息代谢方程 |
evidence_constrained |
综述指南给出的约束区间 | Morton 2018 蛋白质 1.4 至 2.0 g/kg/天 |
engineering_heuristic |
我为产品写的启发式 | 酸痛 ≥4 或精力 ≤2 → 恢复日 |
证据登记表只收 10 条可核实的来源,每条写明 claim 和 limitations,没有编造 DOI。启发式不伪装成医学结论,训练状态判定旁边挂着非临床级指标的声明。三条线画清楚,读者才知道哪句话有多硬。
3.3 先定语义,再排版
数据分四层。Raw 是你录的原始事实,Derived 是 src/domain/ 下纯函数算出的派生值,Decision 是叠了阈值的裁决,Presentation 只负责措辞。组件里零业务算术、零阈值字面量、零 new Date(),这三条由 presentationContract 测试锁死。
窗口不混用。本周从周一零点起算,近七日和近三十日是滚动窗口,谁也不冒充谁。端点变化是末个有效日减首个有效日,回归斜率是对每日代表值做 OLS 再乘七,页面上是两行字。
任何数字在落笔之前,先要能指名它是哪个函数算的、用了哪个窗口、属于哪一层。算不出来的,宁可显示数据不足。
3.4 页面不解释自己
方法学说明全从页面上撤了,一共删掉 21 处,口径和阈值收进 README 的术语与口径。页面只留事实和一句短批。
版面同样用测试守。章节头收敛到 SectionHead 一个组件,眉行、题行、2px 墨线的规格全站一致;两处配对行的横线必须跨栏落在同一个 y,改版前三对横线差 0、38、120 像素,改完是 0;390 宽下标题左缘原本 88 和 38 混着来,改完全页统一 38。这些断言写在 layoutContract 里,谁改坏了谁红。新加的移动端悬浮章节目录也守同一套规矩,章名与锚点的对照全站只有一份 PAGE_SECTIONS,组件和页面谁都不另抄。
四、我的哲学思考
4.1 建议要能被追问
一条建议摆在面前,你有权问它凭什么。LLM 的回答经不起这一问,它能把话说得很顺,却指不出这句话来自哪个输入、哪条规则。
这个项目里每条推荐都带 trace 和 evidenceTraces,输入快照、推导值、前提、局限、证据出处全在数据层留着。页面上那个稽核弹窗后来删了,嫌它占地方,痕迹没删。追问一条建议,一路能走回 Mifflin-St Jeor 的方程和 Morton 2018 那篇综述。
工具的信用就建立在这条路上。它经得起问,你敢用它的答案;它经不起问,再顺嘴的答案也只能当参考。
4.2 诚实比聪明重要
数据出问题时,最省事的做法是替用户把数圆过去,或者干脆别显示。这个项目把数原样留着,旁边挂一条朱红色的批注,让你自己去看。判定写在 validateWeightMeasurement 里,用的是留一基线,剔掉今天这个值再算近七日均值,免得异常把自己拉向自己。
1 | const looBaseline = (rollingMean7d * n - latest) / (n - 1); |
未建档就不出人体数字,缺什么列什么,绝不拿演示数据冒充。睡眠近七日只按有记录的夜数平均,记了一夜就是 1/7 夜,不伪造完整窗口。下面这张图是我把今日体重改成 57 公斤后截的,日常在 68 上下。

同一时刻,统计一节的情景外推直接不出数,写的是「今录存疑或数据不足 · 暂不出推演」。趋势图上那个断崖也是真实数据画出来的,它不替我修图。

还有一档更微妙的情况。一周体验优化时我提过两条反馈,说计量条在某些状态下该红。复核色路后发现两条都不成立,未达标走墨灰、达标才着朱,结论写进文档,代码不动。被证伪的东西照实记下来,比硬改一处更划算。
4.3 计划、预测、实测互不冒充
这三样在这个项目里各走各的道。任务行的「拟 45 分」是计划,可改可删;建议课表的「约 20 分」标着估算;只有你自己计时的才叫实际计时。下一膳没录之前标「未入账」,点了「照准」写进 MealRecord 才算吃过。
预测更要小心。情景外推带着 method、模型版本和依据日数,算出来是区间,不是承诺,数据存疑就暂阙,而且永远不会在第二天变成一条实测值。
早期版本确实混过。任务用时直接当成实际时长显示,计划膳不加区分就进今日所食,这两处都是后来拆开的。混用的代价很小,看不出来,但账本一旦说谎就全篇作废。
4.4 墨书朱批的克制
视觉上这个项目挑了一个有点怪的比喻,皇帝批奏折。臣工以墨书事,正文、数据、事实一律墨色;皇帝以朱批裁,状态、裁决、回执一律朱色。全页只有一个强调色 #a63a2b。
这套约束是减出来的。全页只留一张图表,30 日体重折线,其余数字都写进句子里。KPI 卡、瓦片墙和环形进度一样都找不到,栏目不用卡片容器,正文直接落在带纤维纹的纸面上。墨色四档对纸底的对比度是 16.4、6.7、4.9、4.6,全部过 4.5,字号地板 12px,点击目标不小于 24px。
代价是信息密度低了一截,一屏放不下六个指标。换来的是每行字都读得完,扫一眼知道今天该干什么。
五、它到底有什么用
对个人,它把「我是不是该瘦点」这类问题换成了一本账。每天打开知道下一餐吃什么、今天练什么、趋势往哪走,翻回两周前还能看到当时那条建议和它背后的依据。它也会提醒你数据本身有问题,掉了两斤又涨回来的事它不替你圆,标出来让你去看。
对做工具的人,这套方法可以整套搬走。证据分级那套标注方式,任何给建议的软件都用得上;用契约测试锁版式,任何怕改版改坏的页面都值得抄。数据四层那部分写在 README 里,组件里开始长业务逻辑的时候可以去翻。
对我自己,它更像是练手。两周里我把方案先行、最小实现、验证要可复跑这些话真正做了一遍,npm run lint、npm test、npm run build 三条门禁,收尾时全绿。
六、总结
这篇讲了一个 LLM = OFF 的健康手记,从演示截图看到单页流和三张录事表。开发理念落在确定性引擎、证据三级、数据语义四层和用测试守住的版面,哲学部分是可追问、不改数、计划预测实测分开、墨书朱批的克制。项目本身在 GitHub 上,最新提交 5465377,剩下没做的是 Lint 和 CI。