第四章:完善 Health Tracker——记录运动、体重和断食
上一章,我们已经使用 Python、MCP SDK 2.x 和 SQLite,实现了饮食记录功能。
现在,我们可以通过 MCP Inspector 调用 record_food,将每天吃的食物保存到数据库中,还可以查询某一天的饮食记录和热量摄入情况。
但是,开发到这里,我发现一个问题:
如果我的目标是健康减脂,仅仅记录每天吃了多少热量,真的够吗?
显然不够。
例如,今天我摄入了 1800 千卡,但同时进行了 40 分钟快走。明天我可能没有运动,却只摄入了 1500 千卡。
如果只看饮食热量,很难完整了解自己的生活习惯。
除此之外,我还希望知道:
- 最近一周体重有没有下降?
- 今天做了哪些运动?
- 昨天有没有完成断食计划?
- 某一天的饮食、运动、体重和断食情况分别是什么?
所以,这一章我们继续完善 Health Tracker。
本章目标: 在已有饮食记录功能的基础上,增加运动、体重和断食记录,并开发一个综合查询工具。
4.1 先思考:这些数据应该保存在一张表里吗?
上一章,我们创建了 food_records 表。
现在又要记录运动、体重和断食。
一种看似简单的方案是,继续给 food_records 增加字段。
例如:
food_records
├── food_name
├── calories
├── exercise_type
├── duration_minutes
├── weight_kg
├── fasting_start
└── fasting_end
但是,这样很快就会遇到问题。
记录香蕉时,运动类型、运动时长、体重和断食时间都没有意义。
记录体重时,食物名称和热量又没有意义。
最终,我们会得到一张包含大量空字段的表。
因此,我决定把不同类型的数据分开保存。

这样,每张表只负责保存一种类型的数据。
未来增加新的健康指标,例如睡眠、饮水量,也可以采用相同的设计方式。
4.2 创建运动记录表
首先,我们来开发运动记录功能。
我希望记录这样的信息:
今天快走了 40 分钟,估计消耗 180 千卡。
那么,一条运动记录至少需要包含运动日期、运动名称、运动时长和估算消耗热量。
设计 exercise_records 表
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER | 唯一编号 |
| record_date | TEXT | 运动日期 |
| exercise_type | TEXT | 运动类型 |
| duration_minutes | INTEGER | 运动时长,分钟 |
| calories_burned | REAL | 估算消耗热量,kcal |
| notes | TEXT | 备注 |
打开上一章创建的 db.py,找到 init_db() 函数。
在创建 food_records 表的 SQL 后面,继续增加:
conn.execute("""
CREATE TABLE IF NOT EXISTS exercise_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
record_date TEXT NOT NULL,
exercise_type TEXT NOT NULL,
duration_minutes INTEGER NOT NULL,
calories_burned REAL,
notes TEXT
)
""")
注意,这段代码要放在原来 with get_connection() as conn: 的代码块内。
CREATE TABLE IF NOT EXISTS 不会删除原来的饮食记录,也不会清空已有数据。
编写数据库插入函数
继续在 db.py 中增加:
def insert_exercise(
record_date: str,
exercise_type: str,
duration_minutes: int,
calories_burned: float | None = None,
notes: str | None = None,
) -> int:
with get_connection() as conn:
cursor = conn.execute(
"""
INSERT INTO exercise_records (
record_date,
exercise_type,
duration_minutes,
calories_burned,
notes
)
VALUES (?, ?, ?, ?, ?)
""",
(
record_date,
exercise_type,
duration_minutes,
calories_burned,
notes,
),
)
return cursor.lastrowid
这段代码与上一章的 insert_food() 非常相似。
区别只是数据表和字段不同。
这里仍然使用 SQL 参数占位符,避免直接拼接用户输入。
注册 record_exercise 工具
打开 main.py,增加导入:
from db import insert_exercise
然后添加 MCP 工具:
@mcp.tool()
def record_exercise(
record_date: str,
exercise_type: str,
duration_minutes: int,
calories_burned: float | None = None,
notes: str | None = None,
) -> dict:
"""记录一次运动,包括日期、类型、时长和估算消耗热量。"""
try:
valid_date(record_date)
if not exercise_type.strip():
raise ValueError("运动类型不能为空")
if duration_minutes <= 0:
raise ValueError("运动时长必须大于 0")
check_nonnegative(
calories_burned=calories_burned
)
record_id = insert_exercise(
record_date=record_date,
exercise_type=exercise_type.strip(),
duration_minutes=duration_minutes,
calories_burned=calories_burned,
notes=notes,
)
return {
"success": True,
"record_id": record_id,
"message": "运动记录保存成功",
}
except ValueError as exc:
return {
"success": False,
"error": str(exc),
}
这里用到了两个辅助函数:valid_date() 和 check_nonnegative()。
上一章我们已经写过日期和数值校验逻辑,现在多个工具都需要它们,所以没有必要每次复制相同的代码。
在 main.py 中添加:
from datetime import date
import math
def valid_date(value: str) -> None:
parsed = date.fromisoformat(value)
if parsed.isoformat() != value:
raise ValueError("日期格式必须为 YYYY-MM-DD")
def check_nonnegative(**values) -> None:
for name, value in values.items():
if value is None:
continue
if not math.isfinite(value) or value < 0:
raise ValueError(
f"{name} 必须是有限的非负数"
)
后续也可以把上一章 record_food() 和 get_food_records() 中重复的校验逻辑改为调用这些函数。
使用 Inspector 测试
重新启动 MCP Server:
uv run python main.py
连接 MCP Inspector,刷新工具列表。
选择 record_exercise,输入:
{
"record_date": "2026-10-20",
"exercise_type": "快走",
"duration_minutes": 40,
"calories_burned": 180,
"notes": "运动消耗热量为估算值"
}
正常情况下,应该返回:
{
"success": true,
"record_id": 1,
"message": "运动记录保存成功"
}
现在,我们已经能够保存运动记录。
不过要注意:运动消耗热量与体重、运动强度和个人身体状况有关。这里的 180 千卡只是测试数据,不代表每个人快走 40 分钟都会消耗相同热量。
4.3 开发体重记录工具
接下来,我们记录体重。
在减脂过程中,体重是一个很重要的观察指标。
但我不希望系统只保存一个“当前体重”。
因为我还想知道过去一周、一个月,甚至半年的变化趋势。
所以,每次称重都应该保存成一条独立记录。
创建 weight_records 表
继续在 init_db() 中增加:
conn.execute("""
CREATE TABLE IF NOT EXISTS weight_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
record_date TEXT NOT NULL,
weight_kg REAL NOT NULL,
notes TEXT
)
""")
这里使用 weight_kg 保存公斤数。
如果用户输入的是中国常用的“斤”,后面可以让 ChatGPT 将其换算成公斤,再调用工具。
例如:
170 斤 = 85 kg
统一存储单位有一个重要好处:后续进行体重趋势分析时,不必再判断每条记录使用什么单位。
编写数据库函数
在 db.py 中添加:
def insert_weight(
record_date: str,
weight_kg: float,
notes: str | None = None,
) -> int:
with get_connection() as conn:
cursor = conn.execute(
"""
INSERT INTO weight_records (
record_date,
weight_kg,
notes
)
VALUES (?, ?, ?)
""",
(record_date, weight_kg, notes),
)
return cursor.lastrowid
注册 record_weight 工具
在 main.py 中导入:
from db import insert_weight
然后添加:
@mcp.tool()
def record_weight(
record_date: str,
weight_kg: float,
notes: str | None = None,
) -> dict:
"""记录指定日期的体重,单位为公斤 kg。"""
try:
valid_date(record_date)
if not math.isfinite(weight_kg) or weight_kg <= 0:
raise ValueError("体重必须是有限的正数")
record_id = insert_weight(
record_date=record_date,
weight_kg=weight_kg,
notes=notes,
)
return {
"success": True,
"record_id": record_id,
"message": "体重记录保存成功",
}
except ValueError as exc:
return {
"success": False,
"error": str(exc),
}
测试体重记录
在 MCP Inspector 中调用:
{
"record_date": "2026-10-20",
"weight_kg": 85.1,
"notes": "早晨空腹称重"
}
如果保存成功,就说明我们已经能够记录体重。
这里还有一个值得思考的问题:
如果同一天称重两次,应该覆盖第一次,还是保存两条记录?
目前我们的设计会保存两条。
这是有意保留的灵活性。
因为早晨和晚上称重,数值可能不同。
以后如果要统计每天的体重趋势,可以规定使用早晨空腹体重,或者进一步增加称重时间字段。
本章先不增加这些复杂功能。
4.4 开发断食记录工具
除了饮食、运动和体重,我还希望记录断食情况。
例如:
昨天晚上 6 点开始断食,今天早上 8 点结束。
这里有一个与前面不同的地方。
饮食和运动通常只需要记录某一天。
但断食可能跨越两天。
因此,仅仅保存一个日期是不够的,我们需要保存完整的开始和结束时间。
创建 fasting_records 表
在 init_db() 中增加:
conn.execute("""
CREATE TABLE IF NOT EXISTS fasting_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
duration_hours REAL NOT NULL,
notes TEXT
)
""")
这里使用:
YYYY-MM-DDTHH:MM:SS+08:00
这样的 ISO 8601 格式保存带时区偏移的时间。
例如:
2026-10-19T18:00:00+08:00
这样可以避免未来服务器部署到其他地区后,因为时区不同导致断食时长计算错误。
编写数据库插入函数
在 db.py 中添加:
def insert_fasting(
start_time: str,
end_time: str,
duration_hours: float,
notes: str | None = None,
) -> int:
with get_connection() as conn:
cursor = conn.execute(
"""
INSERT INTO fasting_records (
start_time,
end_time,
duration_hours,
notes
)
VALUES (?, ?, ?, ?)
""",
(
start_time,
end_time,
duration_hours,
notes,
),
)
return cursor.lastrowid
注册 record_fasting 工具
在 main.py 中增加:
from datetime import datetime
from db import insert_fasting
然后添加:
@mcp.tool()
def record_fasting(
start_time: str,
end_time: str,
notes: str | None = None,
) -> dict:
"""
记录一次已经结束的断食。
start_time 和 end_time 必须是包含时区偏移的
ISO 8601 日期时间,例如:
2026-10-19T18:00:00+08:00
"""
try:
start = datetime.fromisoformat(start_time)
end = datetime.fromisoformat(end_time)
if start.utcoffset() is None or end.utcoffset() is None:
raise ValueError("开始和结束时间必须包含时区")
if end <= start:
raise ValueError("结束时间必须晚于开始时间")
duration_hours = round(
(end - start).total_seconds() / 3600,
2,
)
record_id = insert_fasting(
start_time=start.isoformat(),
end_time=end.isoformat(),
duration_hours=duration_hours,
notes=notes,
)
return {
"success": True,
"record_id": record_id,
"duration_hours": duration_hours,
"message": "断食记录保存成功",
}
except ValueError as exc:
return {
"success": False,
"error": str(exc),
}
注意,我们没有要求客户端传入断食时长。
因为开始和结束时间已经确定,Python 完全可以自己计算。
这体现了一个重要的接口设计原则:
能由程序准确计算的数据,就不要依赖客户端重复提供。
测试跨天断食
在 MCP Inspector 中调用:
{
"start_time": "2026-10-19T18:00:00+08:00",
"end_time": "2026-10-20T08:00:00+08:00",
"notes": "晚餐后开始,次日早餐前结束"
}
返回结果类似:
{
"success": true,
"record_id": 1,
"duration_hours": 14.0,
"message": "断食记录保存成功"
}
这样,跨天断食也可以准确计算。
需要说明的是,这里只实现记录功能,并不意味着推荐每个人采用相同的断食时长。是否适合断食,应根据个人健康状况判断。
4.5 开发每日健康汇总工具
到这里,我们已经能够保存四种数据:
- 饮食
- 运动
- 体重
- 断食
但如果每次查询都要分别调用四个工具,就显得有些麻烦。
例如,我想知道:
10 月 20 日,我的健康记录有哪些?
如果客户端分别调用饮食、运动、体重和断食查询工具,就会产生多次请求。
所以,我决定增加一个新的工具:
get_daily_summary(record_date)
它负责一次性返回指定日期的综合健康数据。
第一步:编写数据库查询函数
打开 db.py,添加:
from datetime import date, datetime, time, timedelta, timezone
def select_daily_summary(record_date: str) -> dict:
with get_connection() as conn:
foods = conn.execute(
"""
SELECT *
FROM food_records
WHERE record_date = ?
ORDER BY id
""",
(record_date,),
).fetchall()
exercises = conn.execute(
"""
SELECT *
FROM exercise_records
WHERE record_date = ?
ORDER BY id
""",
(record_date,),
).fetchall()
weights = conn.execute(
"""
SELECT *
FROM weight_records
WHERE record_date = ?
ORDER BY id
""",
(record_date,),
).fetchall()
fastings = conn.execute(
"""
SELECT *
FROM fasting_records
ORDER BY id
"""
).fetchall()
# 断食记录可能跨越多天,因此根据时间区间判断
# 此处先按北京时间定义查询日期的起止边界。
tz = timezone(timedelta(hours=8))
day_start = datetime.combine(
date.fromisoformat(record_date),
time.min,
tzinfo=tz,
)
day_end = day_start + timedelta(days=1)
matched_fastings = []
for row in fastings:
start = datetime.fromisoformat(row["start_time"])
end = datetime.fromisoformat(row["end_time"])
if start < day_end and end > day_start:
matched_fastings.append(dict(row))
food_list = [dict(row) for row in foods]
exercise_list = [dict(row) for row in exercises]
known_foods = [
row["calories"]
for row in food_list
if row["calories"] is not None
]
known_exercises = [
row["calories_burned"]
for row in exercise_list
if row["calories_burned"] is not None
]
return {
"record_date": record_date,
"foods": food_list,
"exercises": exercise_list,
"weights": [dict(row) for row in weights],
"fastings": matched_fastings,
"summary": {
"food_count": len(food_list),
"exercise_count": len(exercise_list),
"total_calories": (
round(sum(known_foods), 2)
if known_foods else None
),
"total_exercise_calories": (
round(sum(known_exercises), 2)
if known_exercises else None
),
"food_calories_complete": (
len(known_foods) == len(food_list)
),
"exercise_calories_complete": (
len(known_exercises) == len(exercise_list)
),
},
}
这段代码虽然较长,但核心逻辑并不复杂。
首先,分别查询当天的饮食、运动和体重记录。
然后,查询与当天存在时间重叠的断食记录。
最后,计算已知的饮食热量和运动消耗热量。
这里有两个细节需要注意。
第一,断食记录可能跨天。
例如,10 月 19 日晚上开始,10 月 20 日早上结束。
因此,这条记录与 19 日和 20 日都有关。
本例为了便于理解,使用北京时间 UTC+08:00 定义查询日期边界。正式项目中,应将用户时区作为配置,而不是写死在代码里。
第二,饮食热量与运动消耗不能简单等同于人体的热量盈余。
因为人体还存在基础代谢、日常活动等能量消耗。
所以,本章只分别返回饮食摄入和运动消耗,不把两者的差值称为“热量缺口”。
第二步:注册 get_daily_summary
打开 main.py,增加导入:
from db import select_daily_summary
然后添加:
@mcp.tool()
def get_daily_summary(record_date: str) -> dict:
"""
查询指定日期的综合健康记录。
包括饮食、运动、体重和与当天重叠的断食记录。
"""
try:
valid_date(record_date)
data = select_daily_summary(record_date)
return {
"success": True,
**data,
}
except ValueError as exc:
return {
"success": False,
"error": str(exc),
}
保存后,重新启动 MCP Server。
4.6 使用 MCP Inspector 完整测试
现在,我们来模拟一天的健康记录。
测试日期统一使用:
2026-10-20
测试一:记录饮食
调用上一章的 record_food:
{
"record_date": "2026-10-20",
"meal_type": "breakfast",
"food_name": "香蕉",
"calories": 105,
"weight_g": 118,
"protein_g": 1.3,
"carbs_g": 27,
"fat_g": 0.4
}
测试二:记录运动
调用 record_exercise:
{
"record_date": "2026-10-20",
"exercise_type": "快走",
"duration_minutes": 40,
"calories_burned": 180
}
测试三:记录体重
调用 record_weight:
{
"record_date": "2026-10-20",
"weight_kg": 85.1
}
测试四:记录断食
调用 record_fasting:
{
"start_time": "2026-10-19T18:00:00+08:00",
"end_time": "2026-10-20T08:00:00+08:00"
}
测试五:查询当天健康记录
最后调用:
get_daily_summary
传入:
{
"record_date": "2026-10-20"
}
如果之前没有其他同日记录,返回的核心数据应当类似:
{
"success": true,
"record_date": "2026-10-20",
"foods": [
{
"id": 1,
"record_date": "2026-10-20",
"meal_type": "breakfast",
"food_name": "香蕉",
"calories": 105,
"weight_g": 118,
"protein_g": 1.3,
"carbs_g": 27,
"fat_g": 0.4,
"notes": null
}
],
"exercises": [
{
"id": 1,
"record_date": "2026-10-20",
"exercise_type": "快走",
"duration_minutes": 40,
"calories_burned": 180,
"notes": null
}
],
"weights": [
{
"id": 1,
"record_date": "2026-10-20",
"weight_kg": 85.1,
"notes": null
}
],
"fastings": [
{
"id": 1,
"start_time": "2026-10-19T18:00:00+08:00",
"end_time": "2026-10-20T08:00:00+08:00",
"duration_hours": 14.0,
"notes": null
}
],
"summary": {
"food_count": 1,
"exercise_count": 1,
"total_calories": 105.0,
"total_exercise_calories": 180.0,
"food_calories_complete": true,
"exercise_calories_complete": true
}
}
这里的记录 ID 和具体返回数量取决于你的数据库中已有的数据。
如果重复执行测试,会生成多条记录。
这不是 MCP Inspector 的问题,而是因为我们目前的工具每次调用都会执行一次数据库插入。
4.7 重新理解 MCP 工具的设计
现在,我们已经开发了多个 MCP 工具。
你可能注意到了一个特点:
每个工具都只负责完成一件明确的事情。
例如:
| MCP 工具 | 职责 |
|---|---|
record_food | 保存一条饮食记录 |
record_exercise | 保存一条运动记录 |
record_weight | 保存一条体重记录 |
record_fasting | 保存一条断食记录 |
get_daily_summary | 查询某一天的综合健康数据 |
为什么不设计一个万能工具:
save_health_data(...)
然后让它根据不同参数判断要保存什么?
当然可以,但这会让工具参数越来越复杂。
例如,记录体重不需要食物名称,记录断食不需要运动时长。
如果全部塞进一个函数,很多参数就只能设置为可选,工具描述也会变得不清晰。
而当我们接入 ChatGPT 后,模型需要根据工具名称、描述和参数 Schema 判断应该调用哪个工具。
因此,对于不同业务操作,使用职责清晰的工具通常更容易理解、测试和维护。
不过,也不是工具越多越好。
像 get_daily_summary 这样经常需要一起查询的数据,就可以设计成一个综合工具,减少客户端多次调用。
这也是我们在开发 MCP Server 时需要不断权衡的问题:工具应该足够明确,同时避免不必要的碎片化。
4.8 常见问题
问题一:为什么新增数据表后,数据库里没有变化?
首先确认 init_db() 中已经添加新的 CREATE TABLE 语句。
然后确认程序启动时执行了:
init_db()
如果数据库文件已经存在,SQLite 仍然可以创建新的表。
但要注意:CREATE TABLE IF NOT EXISTS 不会自动修改已经存在的表结构。
例如,后续想给 weight_records 增加一个称重时间字段,就需要使用数据库迁移或 ALTER TABLE 等方式。
问题二:为什么体重使用公斤,而不是斤?
因为统一单位有利于后续统计。
在数据库中保存公斤,在用户界面中根据需要显示为斤即可。
例如:
weight_jin = weight_kg * 2
这比在数据库中混合保存公斤和斤更可靠。
问题三:为什么断食时间要包含时区?
因为断食可能跨越日期,而且未来服务器可能部署在不同地区。
如果只保存:
2026-10-19 18:00
我们无法确定它属于哪个时区。
而:
2026-10-19T18:00:00+08:00
就明确表示这个时间使用 UTC+08:00。
这可以减少跨时区部署和统计时的歧义。
问题四:为什么每日汇总不直接计算减脂效果?
因为减脂不能只通过一天的饮食热量和运动消耗判断。
我们目前的数据还不足以精确计算人体全天总能量消耗。
所以,系统应该先准确保存和汇总已知数据,再由 ChatGPT 结合用户的身体信息、活动情况和长期趋势进行分析,并明确说明估算的不确定性。
4.9 本章总结
这一章,我们进一步完善了 Health Tracker。
在上一章饮食记录的基础上,新增了三张 SQLite 数据表:
exercise_recordsweight_recordsfasting_records
同时开发了四个 MCP 工具:
record_exerciserecord_weightrecord_fastingget_daily_summary
现在,我们已经能够通过 MCP Inspector 保存和查询四种健康数据。
项目的整体结构仍然非常简单:
health-tracker-agent/
├── main.py
├── db.py
├── health.db
├── pyproject.toml
└── uv.lock
其中,main.py 负责 MCP 工具接口,db.py 负责 SQLite 数据操作。
到这里,我们已经完成了一套基础的健康数据管理服务。
但是,目前所有操作仍然需要手动填写参数。
例如,记录运动时,我们需要输入:
{
"record_date": "2026-10-20",
"exercise_type": "快走",
"duration_minutes": 40,
"calories_burned": 180
}
而我真正想要的是直接告诉 ChatGPT:
今天快走了 40 分钟,帮我估算消耗的热量并记录。
让 ChatGPT 自动理解我的意思,生成 MCP 工具参数,再调用我们编写的 Python 函数。
这正是下一章要解决的问题。
下一章预告:将本地 MCP Server 连接到 ChatGPT
第五章,我们将开始使用 OpenAI Tunnel,将本地运行的 Health Tracker MCP Server 连接到 ChatGPT,并创建自己的私人插件。
届时,我们将第一次不通过 MCP Inspector,而是直接使用自然语言调用自己开发的工具。
从下一章开始,Health Tracker 将真正具备 AI 交互能力。
想系统学习这门课程?
你已阅读完本文。需要更完整的知识体系,可以继续了解「Vibe Coding 实战课程」。
查看课程详情