内容
# 🍟 McFit · 每天吃麦当劳,制造 100 大卡缺口
> 输入体重和性别,算出你今天该吃多少大卡;然后把这份预算翻译成**在麦当劳具体点什么**。
> 不戒麦当劳,不饿肚子,只做 **100 大卡**的缺口。
>
> 而且每一份热量都会换算成**跑步里程**——"少一份大薯条 = 今天不用跑 3.67 km"。
> 数字看得见,才坚持得下去。
一个基于**麦当劳中国官方 MCP** 的营养决策 Skill。所有餐品价格来自当店实时菜单,
所有热量与营养素来自麦当劳官方营养表——不猜、不估、不发明数据。
---
## 为什么不直接"少吃点"?
因为"少吃点"不可执行。你站在点餐屏前,需要的是**一个具体的动作**。
这个项目的全部设计都围绕一个动作:**把 A 换成 B,省下 X 大卡。**
| 把 | 换成 | 省下 | 相当于(70 kg 的人)跑了 | 价格变化 |
|:--|:--|--:|:--|--:|
| 薯条 | 苹果片 | **−257 kcal** | 3.67 km | −¥6.50 |
| 可乐 | 纯悦 | **−147 kcal** | 2.10 km | −¥2.00 |
| 麦旋风 | 圆筒冰淇淋 | **−173 kcal** | 2.47 km | −¥10.50 |
而这些替换不是拍脑袋挑的:替换引擎只允许在**同一形态家族**内替换
(薯条换薯条、汉堡换汉堡),跨形态只放行营养学上站得住的几种定向替换
(薯条→苹果片/玉米杯、含糖饮料→纯悦/红茶/鲜萃咖啡)。
**它不会建议你把早餐换成一根香肠**——虽然那样"省"得更多。
---
## 为什么是 100 大卡?
因为 100 大卡是一个**你今天就能做到**的数字,而它的复利很可观:
```
100 kcal/天 × 365 天 = 36,500 kcal
36,500 ÷ 7700 kcal/kg ≈ 4.74 kg
```
(1 kg 人体脂肪约等于 7700 kcal。这是能量守恒下的理论上限,
真实减重会受水分、肌肉量变化、代谢适应等因素影响。)
这也是麦麦官方营养表里一杯**可乐**的热量。少一杯可乐,一年差将近 4.7 公斤。
---
## 100 大卡到底有多少?——换成跑步你就有体感了
"4.74 公斤"仍然抽象。所以我们把每一份热量都翻译成**跑步里程**。
跑步的净能量消耗有个很好用的法则:**1 kcal / 每公斤体重 / 每公里**。
它不是拍脑袋来的,而是 ACSM 跑步代谢方程的推论(推导见
[`scripts/energy.py`](scripts/energy.py) 的注释):
```
净 VO2 (ml/kg/min) ≈ 0.2 × 速度(m/min) ← 静息项 3.5 已扣掉
1 L 氧气 ≈ 5 kcal
⇒ kcal ≈ 距离(km) × 体重(kg) ⇒ km = kcal ÷ 体重
```
这个模型有个漂亮的性质:**里程只取决于体重,与配速无关**(配速只决定要跑多久)。
所以它对每个人都不同——这正是它适合做个性化换算的原因。
同是 100 kcal,不同体重的差距:
| 体重 | 每天 100 kcal 相当于 | 一年累积(36,500 kcal) |
|--:|:--|:--|
| 55 kg | 跑步 **1.82 km**(操场 4.5 圈) | **664 km** ≈ 15.7 场马拉松 |
| 70 kg | 跑步 **1.43 km**(操场 3.6 圈) | **521 km** ≈ 12.4 场马拉松 |
| 85 kg | 跑步 **1.18 km**(操场 2.9 圈) | **429 km** ≈ 10.2 场马拉松 |
**最关键的一句:你根本不用去跑。** 只要把点单换个组合,就等于抵掉了这段距离。
这就是本项目要传达的体感——
> 少一份大薯条(−257 kcal),等于今天不用跑 **3.67 km**。
> 📏 里程换算是**量级参考**,不是精确值。真实消耗受跑姿、坡度、风阻、跑步经济性影响,
> 人体能量代谢也有个体差异。用它建立体感没问题,拿它当运动处方不合适。
---
## 四个核心能力
### 1️⃣ 能量账本:体重 + 性别 → 今天该吃多少
采用 **Mifflin-St Jeor** 公式(临床营养界对健康成年人最常用的估算式):
```
男性 BMR = 10×体重 + 6.25×身高 − 5×年龄 + 5
女性 BMR = 10×体重 + 6.25×身高 − 5×年龄 − 161
TDEE = BMR × 活动系数 目标摄入 = TDEE − 100
```
只给体重和性别也能跑:身高/年龄会退化为同性别中位数,但程序会**如实标注
"这是估算值"**,并提示误差可达 ±200~300 kcal。补上身高年龄后标注自动消失。
### 2️⃣ 三层楼规划:跨四餐做一次全局最优
不是"早餐推荐几个、午餐推荐几个",而是把一天当作一个整体来解:
- 全天热量按 **早 25% / 午 35% / 晚 30% / 加餐 10%** 分配
- 每餐先枚举候选组合(主食 × 小食 × 饮品),再跨四餐跑一遍 DP
- 目标函数:**让全天热量最贴近目标**,同热量下优先"搭配完整、蛋白质更高"
实测在 1500–2500 kcal 区间内,方案偏差稳定在 **±60 kcal** 以内,
示例场景可以做到 **偏差 0**。
### 3️⃣ 省热量替换:不让你少吃,让你换个点法
对方案里每一项,找形态相近、热量更低的替代品,并按"能省多少"排序。
这是整个项目的主张落地点——你不需要理解热量表,只需要看一张替换表。
### 4️⃣ 热量 → 跑步里程:让每个数字都有体感
"257 kcal"没有体感,"相当于今天不用跑 3.67 km"就有。
所以每一条替换、每一个缺口,都会同步给出跑步等价里程(`km = kcal ÷ 体重`),
并附三种配速下的耗时:
```
| 配速 | 要跑多久 | 距离 |
|:--|--:|--:|
| 慢跑 8 km/h(7'30"/km) | 10.7 分钟 | 1.43 km |
| 常规跑 10 km/h(6'00"/km) | 8.6 分钟 | 1.43 km |
| 快跑 12 km/h(5'00"/km) | 7.1 分钟 | 1.43 km |
```
换算的地基只有一行公式,但它把"热量"这个看不见的单位,
变成了"操场几圈""一年几场马拉松"这种谁都懂的尺度。
---
## 效果示例(真实运行输出)
输入:男 / 70 kg / 175 cm / 30 岁 / 轻度活动 / 缺口 100 kcal
```
| 餐次 | 建议热量 | 点单 | 实际热量 | 蛋白质 | 小计 |
|:--|--:|:--|--:|--:|--:|
| 早餐 | 542 kcal | 图林根香肠早安营养卷、阳光柠檬红茶 | 542 kcal | 14.0 g | ¥25.50 |
| 午餐 | 758 kcal | 牛肉堡单品精选、薯条、浓浓黑巧 | 759 kcal | 30.0 g | ¥41.40 |
| 晚餐 | 650 kcal | 牛肉堡单品精选、薄皮焦香V翅、燕麦奶铁 | 653 kcal | 37.0 g | ¥39.90 |
| 加餐 | 217 kcal | 麦乐鸡5块 | 213 kcal | 12.0 g | ¥14.00 |
全天合计 2167 kcal(目标 2167 kcal,偏差 +0 kcal) 蛋白质 93.0 g 餐费 ¥120.80
```
同一份输入产出的**热量 → 跑步**换算:
```
### 🏃 这点热量,相当于跑了多远?
按你的体重 70 kg 换算。跑步的净消耗有个好用的法则:
1 kcal / 每公斤体重 / 每公里。
| 配速 | 要跑多久 | 距离 |
|:--|--:|--:|
| 慢跑 8 km/h(7'30"/km) | 10.7 分钟 | 1.43 km |
| 常规跑 10 km/h(6'00"/km) | 8.6 分钟 | 1.43 km |
| 快跑 12 km/h(5'00"/km) | 7.1 分钟 | 1.43 km |
放大到一年:365 × 100 = 36,500 kcal,对应跑步 521 km,
也就是约 12.4 场马拉松(按 42.195 km/场算)。
```
关键对照——你的**午餐预算只有 758 kcal**,所以"随手下个套餐"通常是超的:
| 常见套餐 | 热量 | 占全天目标 | 相比午餐预算 |
|:--|--:|--:|--:|
| 巨无霸 + 薯条 + 可乐 | 949 kcal | 44% | 超出 191 kcal |
| 麦辣鸡腿汉堡 + 薯条 + 可乐 | 921 kcal | 43% | 超出 163 kcal |
| 安格斯厚牛堡 + 薯条 + 可乐 | 1143 kcal | 53% | 超出 385 kcal |
完整输出见 [`examples/demo_output.md`](examples/demo_output.md)。
---
## 快速开始
**无需任何第三方依赖,只要 Python 3.9+。**
```bash
git clone <this-repo> && cd mcd-100kcal
# 只看体重和性别(身高/年龄会被估算并标注)
python scripts/cli.py --sex male --weight 70
# 给出完整参数,结果最准
python scripts/cli.py --sex female --weight 55 --height 162 --age 28 --activity light
# 换个缺口(会受安全下限保护)
python scripts/cli.py --sex male --weight 85 --height 180 --age 35 --activity moderate --deficit 300
# 要 JSON,便于接进别的流程
python scripts/cli.py --sex male --weight 70 --height 175 --age 30 --format json
# 跑测试
python scripts/test_planner.py
```
`--activity` 可选:`sedentary`(久坐) / `light`(轻度,默认) / `moderate` / `active` / `very_active`
### 换一家店 / 换一份菜单
`examples/menu.json` 是 2026-10-10 在**麦当劳上海黄浦华旭国际大厦餐厅**实测抓取的真实菜单
(早餐时段 + 午餐时段两次调用 `query-meals`),并合并了官方营养表。
换门店只需替换这个文件,保持同样的字段:
```json
{
"menu": [
{
"code": "1100",
"name": "巨无霸",
"price": 25.5,
"kcal": 513,
"protein": 27,
"fat": 26,
"carbohydrate": 42,
"sodium": 961,
"calcium": 171,
"slot": "main"
}
]
}
```
| 字段 | 来源 | 说明 |
|:--|:--|:--|
| `code` | `query-meals` → `meals.{code}` | 餐品编码 |
| `name` | `query-meals` → `.name` | 餐品名称 |
| `price` | `query-meals` → `.currentPrice` | **单位:元**(内部转分运算) |
| `kcal` 等营养字段 | `list-nutrition-foods` | 按餐品名匹配合并 |
| `slot` | 程序自动推断 | `main` / `breakfast_main` / `side` / `drink` / `dessert`,也可手填 |
> ⚠️ **套餐不要直接入库。** 套餐的热量需要先用 `query-meal-detail` 拆出子项(`rounds[].choices[]`),
> 再逐项累加官方营养值。`examples/menu.json` 因此只收录**单品**,避免凭空估算。
---
## 工作原理
```
┌──────────────── 用户输入 ────────────────┐
│ 性别 · 体重 ·(身高 · 年龄)· 活动水平 │
└──────────────────┬──────────────────────┘
▼
scripts/energy.py ┌─────────────┐
────────────────── │ 能量账本 │ BMR → TDEE → −100 kcal → 目标摄入
│ 安全下限保护 │ 永不低于 max(BMR, 男1500/女1200)
└──────┬──────┘
▼ 目标摄入(kcal)
scripts/planner.py ┌─────────────┐
────────────────── │ 三层楼规划 │ 每餐枚举 → 跨餐 DP → 全天最优
└──────┬──────┘
▼
┌─────────────┐
│ 替换引擎 │ 形态家族约束 → 省热量清单
└──────┬──────┘
▼
┌─────────────┐
│ 体感翻译 │ kcal ÷ 体重 → 跑步里程 / 操场圈数 / 马拉松
└──────┬──────┘ (scripts/energy.py)
▼
Markdown / JSON
```
「体感翻译」这一步是横切的:它既作用于每天的缺口,也作用于替换表里的每一行,
把"省 257 kcal"统一翻译成"少跑 3.67 km"。
数学全部在本地做,**不依赖大模型算数**。同一份输入必然得到同一份输出——
这条性质由 `scripts/test_planner.py` 里的 `test_plan_is_deterministic` 守住。
### 安全边界(不容妥协的部分)
体重管理领域的共识底线是"不要长期吃到基础代谢以下"。程序强制:
```
目标摄入 >= max(BMR, 男性 1500 kcal / 女性 1200 kcal)
```
当你想要的缺口会让摄入跌破这条线时,程序会**自动收窄缺口**并告诉你原因,
而不是默默给你一个危险的数字。缺口超过 500 kcal 时也会额外提醒。
---
## 项目结构
```
mcd-100kcal/
├── README.md ← 你正在看的
├── CONTEST_DECLARATION.md ← 参赛声明(内容未做任何修改)
├── MCP_INTEGRATION.md ← 用了哪些 MCP Tool、调用链、业务价值
├── mcp-config.example.json ← 脱敏配置示例(仅环境变量占位符)
├── workbuddy.md ← WorkBuddy 对话上下文
├── SKILL.md ← WorkBuddy Skill 定义(Agent 编排说明)
├── LICENSE
├── scripts/
│ ├── energy.py ← 能量账本(BMR / TDEE / 缺口 / 安全下限)+ 跑步里程换算
│ ├── planner.py ← 三层楼规划 + 替换引擎
│ ├── cli.py ← 命令行入口
│ └── test_planner.py ← 46 个单元测试
└── examples/
├── menu.json ← 真实菜单(58 项,含官方营养值)
└── demo_output.md ← 一次完整运行的真实输出
```
---
## 测试
```bash
python scripts/test_planner.py
```
46 个测试,覆盖公式正确性、安全下限、替换合法性、规划确定性、跑步里程换算。
其中几个是**针对真实踩过的坑写的回归测试**:
- `test_burger_never_swaps_to_a_drink` —— 早期版本把「双层猪柳蛋麦满分 → 德式图林根香肠」
当作合法替换(都是早餐类),省 426 kcal 但等于没吃早饭。
- `test_no_swap_suggests_eating_almost_nothing` —— 保证任何替换后当天热量不跌破目标的 60%。
- `test_plan_is_deterministic` —— 防止引入随机性导致结果不可复现。
- `test_distance_is_pace_independent_but_time_is_not` —— 守住「里程只由体重决定」
这条模型性质,防止有人"顺手"把配速塞进距离公式里。
- `test_year_of_100_kcal_is_both_kg_and_km` —— 4.74 kg 脂肪与 521 km 跑步必须自洽,
因为它们本来就是同一份能量。
---
## ⚠️ 免责声明
本项目基于**公开营养数据**与**公开公式**做估算,**不构成医疗、营养或其他专业建议**。
- 个体差异、疾病、用药、孕期/哺乳期等情况都会显著改变实际需求
- 未成年人、孕期/哺乳期女性、有慢性病或进食障碍史的人群,请勿自行套用热量缺口
- 任何体重管理计划,请以专业医师或注册营养师的判断为准
- 餐品信息、价格及供应状态以麦当劳官方渠道的实时结果为准
本项目为**麦当劳程序员节创意开发大赛参赛作品**,由参赛者独立开发,非麦当劳官方产品。
---
## License
MIT
连接信息
猜你喜欢
暂无推荐服务器