一、先算一笔扫兴的账
最近在控制热量,最大的痛苦不是饿,是「站在楼下不知道吃什么,又怕吃超」。
于是想做一个工具:给它一个位置,它把附近餐厅的推荐菜列出来,逐道算个大概多少卡, 标上「吃得下」还是「超了」。但真开始做才发现,它得先回答一个更基本的问题: 你愿意为这顿饭走多远?
我把这件事折算成额度:
额度 = 450 + (步行米数 ÷ 80 + 1) × 4
算出来是这样的:
| 步行 | 额度 | 较基础 450 |
|---|---|---|
| 200 米 | 462 卡 | +12 |
| 500 米 | 478 卡 | +28 |
| 800 米 | 494 卡 | +44 |
| 1 公里 | 502 卡 | +52 |
| 1.3 公里 | 518 卡 | +68 |
走 800 米(大概 10 分钟),换来 44 卡 —— 差不多半个苹果。
更扎心的是:800 米半径内,最近和最远的店,额度只差 36 卡(454 → 490)。
也就是说,「多走两步就能多吃点」在物理上是真的,但幅度小得可怜。 大部分工具会顺着用户的期待,把这件事讲得美好一点。这个工具的选择是:把账算清楚,包括算不清的部分。
后文分两块:① 数据上踩到的坑(这部分对做任何"估算类"工具都通用); ② 工程上值得说的几个取舍。
先交代一个背景,不然后面突然出现"麦当劳"会很突兀:
写这个的时候,正好赶上麦当劳中国办了一个基于 MCP 的开发者比赛, 要求用他们官方开放的 MCP 能力做点东西。而我本来就要解决的这个问题里, 最难的一块恰恰是"精确热量从哪来" —— 麦当劳正好把官方营养表、菜单、门店、 优惠券都开成了 MCP 工具。两件事撞上了,就顺手做成了参赛作品。
需要说明的是:比赛只是给了我一个"必须交付出来"的理由,问题本身不是为比赛编的。
二、份量:一个没人告诉你的坑
要算「一道菜多少卡」,直觉是去找菜谱语料。我用了下厨房的公开语料(约 155 万条菜谱), 把「菜名 → 一份热量」抽成一张表,最后并到 9658 条。
然后就撞上了第一个坑:
绝大部分菜谱没有准确的分量。
注意措辞:不是「一条都没有」。有少数菜谱确实会写「2 人份」「约 4 个」, 但一是比例很低,二是标准完全不统一 —— 同样写「一人份」,有人按 300 克肉算, 有人按一小碗算。所以我们没有采用这批标注,而是另找办法(见第五节的下文)。
而且就算拿到了分量,还有第二层问题:配料和调料的用量,有时候比主料影响还大。
同一道「土豆烧牛肉」:
- 土豆 200g + 牛肉 150g,主料大约 350 卡
- 但家常做法再下 2 汤匙油(约 250 卡)、1 汤匙糖(约 50 卡)→ 直接到 650 卡
同一道菜、同一份主料,「350」和「650」都是真话,区别只在于谁做、放多少油。
这不是"数据没有",而是这道菜本身就没有唯一答案。
这意味着两件事:
- 所谓"精确热量",对中餐而言基本不存在。网上那些看起来干净整齐的整数, 背后大概率是行业平均值,或者干脆是拍的。
- 真正的误差量级是 ±25% 起,翻倍也不稀奇。
所以我做了两个决定:
- 不给"精确值":能算的标误差,算不出的直接说算不出;
- 把不确定性写进结构,而不是藏在注释里 —— 这就是下一节的三层 + 一层弃权。
三、三层估算 + 一层弃权
一道菜从"命中"到"给数",走四层,每层都带来源标签和误差区间:
| 层 | 判据 | 界面标签 | 误差 |
|---|---|---|---|
| L1 | 菜名在热量表里命中(含掐头去尾) | 菜谱推算 | ±25% |
| L2 | 菜名关键词 → 品类(面/饭/粥…) | 品类估算 | ±35% |
| L3 | 门店业态 → 品类(咖啡厅/面馆…) | 同类估算 | ±45% |
| L4 | 都不中 | 弃权(显示"算不出") | — |
代码骨架大概是这样:
def estimate(dish, store_type=None):
# L2:菜名关键词 → 品类
k, name = dish_prior(dish)
if k:
return {'kcal': k, 'tier': 'L2', 'label': f'品类估算({name})'}
# 素菜/配菜单独一档(否则会被门店档估成"一餐正餐"的量级)
if is_veg_item(dish):
k, name = veg_prior(dish)
return {'kcal': k, 'tier': 'L2', 'label': f'品类估算({name})'}
# L3:门店业态 → 品类
k, name = store_prior(store_type)
if k:
return {'kcal': k, 'tier': 'L3', 'label': f'同类估算({name})'}
return None # L4:弃权
为什么"弃权"要作为一等公民
最开始我也觉得:弃权就是失败,界面一片"算不出"很难看。
但做下来发现,弃权是唯一能防住"看着正常的错数"的手段。 一道菜如果三条线索都不沾(比如某家店的「爆浆芝士流心卷边」), 你硬按"西点 300 卡"给它一个数,用户会当成真的。
实测下来弃权率只有 1.9%(6 个演示点位、1236 条「店-菜」), 剩下 98.1% 都能给出数值 —— 但给的是带误差的数值,不是假装精确的数值。
素菜必须单独一档
这是踩出来的:L3 给的是一餐正餐的量级(中餐 450 / 日料 380)。 它只知道"这家店是中餐",不知道"这道菜是素的"。于是:
白灼生菜 → 450 卡(实际 50~80) ← 高估 5~9 倍
高丽菜丝 → 380 卡(实际 30~50)
所以插了一层:命中了素菜词、且不含主食词/荤腥词时,按凉拌 100 / 清炒 150 / 重油 250 给。 必须排除的两类反例(都来自实测):
- 主食词:「青菜肉丝炒面」(是面,450+),「南瓜饼」
- 荤腥词:「皮蛋拌茄子」「大白菜炒蛰头」「芦笋百合炒澳带」
排除词里不能放裸「包」「卷」 —— 会命中「包菜」「卷心菜」,把素菜自己排除掉。
四、先验值不该是手拍的整数
L2/L3 要给"典型值"。第一版我手写了一组:
奶茶 350 · 饭类 600 · 面/粉 450 · 西点 350 · 汤羹 230 …
用户看完回了一句话,我觉得挺到位:
「估出来的热量怎么都是 350 / 250 这种整数,还有什么参考意义?」
他说得对。这组数字看起来就像拍的,而且没法解释为什么是 350。
后来改成:对每条关键词规则,在热量表里找出所有命中的菜名,取中位数。
# 值 = 该规则命中菜名的中位数(样本 n < 8 时回退手写值)
(u'奶茶|奶盖|乳茶|珍奶|奶绿|奶白|波波|芝芝|厚乳|雪顶', 252, u'奶茶'), # n=37
(u'饭|盖浇|煲仔', 490, u'饭类'), # n=368
(u'拉面|捞面|拌面|汤面|炒面|刀削面|凉面|面$', 440, u'面类'), # n=383
(u'饺|馄饨|云吞|抄手|烧卖|小笼', 309, u'饺类'), # n=161
(u'汤|羹|炖|煲|双档', 204, u'汤羹'), # n=899
好处是三个:
- 数字不再是整数 —— 252 / 490 / 440,一眼看得出是算出来的;
- 每个数都能复现:同一条正则 + 同一张表 → 同一个中位数;
- p25~p75 顺手就有了(比如面类 387~498),和界面上的 ±35% 互相印证。
顺手挖出来的三个"标签与事实不符"
取中位数的时候,一定要逐条看一眼命中的都是些什么菜 —— 我就在这一步发现了三个 bug:
| 规则 | 问题(全库实测) | 改法 |
|---|---|---|
| 饭类含「焗」 | 含「焗」的 10 条里 8 条不是饭:盐焗鸡 ×3、生焗排骨、芝士焗烤三文鱼、油盐煎焗银鳕鱼头、焗烤南瓜酱 | 拿掉「焗」 |
| 西点含裸「酥」 | 命中的是菜不是西点:小酥肉 ×9、盐酥鸡、酥脆鸡排;「酥油茶」更是喝的 | 改词尾/组合锚定,另开「炸物」一档接住它们 |
| 面食含裸「包」 | 会误伤「锅包肉」(×10,「包」在中间);而「生煎包/灌汤包/牛角包」都是词尾 | 改 包$ |
这几条都是「规则看起来对、全库一跑就露馅」的典型。所以改词表之前,一定要先在全库上跑一遍、把清单打出来人工过目。
还有一个反直觉的发现:越往边疆,「直接命中」越少、「掐头去尾」越关键。 上海 6 个点位精确命中 61.7%,而远端 10 个点位(新疆/西藏/内蒙/吉林延边/东北/云南) 直接命中只有 35.0%,靠剥离前缀(「二哥招牌菠萝牛肉」→「菠萝牛肉」)救回 26.0% 个百分点。
五、两条链:最容易出错的地方
这个项目有一个"一份内核三个壳"的设计:Agent Skill / MCP Server / Bot 共用同一套算法。
代价是:出图链和文案链是两套独立的实现(一个渲染 SVG,一个生成文字)。 改任一侧,都要问一句"另一侧怎么办" —— 我们因为这件事错过三次。
典型症状:文案里说"已隐去 12 家",图上却画了 15 家;或者同一家店, 卡片上写"吃得下"、图上标红。用户不会告诉你哪边错了,只会觉得这个工具不可信。
后来加的纪律是:每条链各写一个测试,比同一份逻辑。
六、麦当劳:MCP 调 MCP
回到第一节那个背景。麦当劳中国把营养表、菜单、门店、优惠券都开成了 MCP 工具 —— 对这件事而言,它补上的正是我最缺的那块"精确值": 在一堆只能估算的中餐菜里,麦当劳是唯一能拿到官方营养表的地方。
我用到的:
query-nearby-stores—— 门店(不收经纬度,只吃keyword地标,返回的经纬度恒为 0)list-nutrition-foods—— 官方营养表(158 条,含蛋白/脂肪/碳水/钠/钙)query-meals/query-my-coupons—— 套餐与券
这里的坑在于:MCP 不知道用户在哪。所以要先把自己手里的坐标翻译成文字地标 (借地图的逆地理编码),再拿地标去问。这层翻译放在麦当劳自己的适配层里, 不去污染通用接口 —— 通用接口一律用经纬度,某家只吃文字是它的缺陷,不是接口的设计。
有意思的是架构上:自家 MCP Server 内部再去调麦当劳官方 MCP(MCP 调 MCP)。 好处是宿主(WorkBuddy / Claude / 任何 MCP 客户端)只需要配一个入口。
关于"官方数据"的一个诚实说明
官方营养表是 158 条,但我内置的快照只收录了 22 条(demo 用到的那批), 全部按官方实时表逐条校准过 —— 其中有 7 条同名同物的值不一致,最离谱的是 「板烧鸡腿堡」原值 517、官方 391(差 126 卡)。不配 Token 就用这 22 条, 配了就实时拉全表。
这类数字必须写准。README 里写过"内置 158 条",是错的(实际 22 条),后来改掉了 ——
因为评委一行 len() 就能看穿。
七、实测
6 个真实点位、1236 条「店-菜」(卡片上真实出现的推荐菜,按店-菜计):
| 层 | 占比 |
|---|---|
| L1 菜谱推算(精确 61.7 + 掐头去尾 8.8) | 70.6% |
| L2 品类估算 | 12.5% |
| L3 同类估算 | 15.0% |
| L4 弃权 | 1.9% |
远端 10 个点位(2136 条)L1 只有 61.0% —— 命中率主要看业态,不是看城市: 正餐区能到 78~90%,茶饮/烘焙区只有 50% 左右(语料是中式家常菜谱,天然不含奶茶和可颂)。
所以「茶饮 / 西式烘焙」是这个数据源的天然盲区, 该走品类估算或弃权,不该硬猜 —— 这一点我认。
同一个工具还顺手测了覆盖率的口径问题:文档里长期挂着一句「只用语料库可算 79%」,
后来发现怎么都复现不出来(当年那份场景快照被重新采集过、口径也换过)。
现在的做法是:文档里的百分数只由脚本产出,脚本自带口径自检(分类结果必须与
provider.heat() 逐条一致,不一致就退出码 1)。
八、自己跑一下
git clone https://github.com/adexbn/walk-kcal && cd walk-kcal
# 不需要任何 Key,用仓库里存好的演示点位直接出图(1~2 秒)
python walkkcal/render.py
# 离线可交互的网页演示
open demo/explore.html
依赖方面只有一个刻意设计:出图是可选增强。不装任何第三方包 → 输出 SVG,退出码仍是 0;
装了 Pillow + pillow_lunasvg → 多一张 PNG。降级不是故障,不用重试、不用 pip install。
想接自己的数据源也很轻:地图换一家 = 加一个 providers/geo_xxx.py;
语料换一份 = 导出 {"菜名": 卡数} 再设一个环境变量。主流程 0 行改动。
九、写在最后
这个项目最开始只是为了解决"我自己的晚饭纠结",做完之后最大的收获反而是两句话:
- 算不准的时候,说算不准比给一个漂亮的数字难,也更值钱。 弃权 1.9% 是我最满意的部分。
- 先验值要有出处。 手写的 350 和算出来的 252,用户一眼就能感觉出差别。
代码是 MIT 的,随便看。如果算错了哪个城市的菜,欢迎直接在评论区或者 Issue 里骂 —— 这比 star 对我更有用。
- 仓库:github.com/adexbn/walk…
- 在线试试(不用装):adexbn.github.io/walk-kcal/