跳到主要内容

第六章:完善 Health Tracker——实现饮食记录的查询、修改和删除

上一章,我们已经将本地 MCP Server 连接到了 ChatGPT。

现在,我们可以直接告诉 ChatGPT:

今天早餐吃了一根香蕉,帮我估算热量并记录。

ChatGPT 会识别食物、估算营养成分,再调用我们编写的 record_food 工具,将数据保存到 SQLite 数据库中。

但是,实际使用几天后,我发现了一个新问题。

昨天中午,我告诉 ChatGPT 吃了 200 克米饭。后来想起来,实际上只吃了 100 克。

这时候应该怎么办?

如果再调用一次 record_food,数据库中就会多出一条米饭记录,而不是修改原来的数据。

同样,如果不小心记录了两次香蕉,也需要能够删除其中一条。

所以,一个真正可用的 Health Tracker,不能只有新增和查询功能,还需要支持修改和删除。

本章目标:

  • 根据日期、餐次和食物名称查找记录。
  • 使用记录 ID 精确定位数据。
  • 开发 update_food 工具,修改已有记录。
  • 开发 delete_food 工具,删除指定记录。
  • 防止误修改、误删除和 SQL 注入。
  • 在 ChatGPT 中测试完整的增删改查流程。

6.1 为什么修改数据之前,必须先查找?​

我们先看一个例子。

假设 SQLite 中已经保存了以下记录:

id日期餐次食物热量
12026-10-19午餐米饭232
22026-10-19晚餐米饭116
32026-10-20午餐米饭232
42026-10-20早餐香蕉105

现在用户告诉 ChatGPT:

昨天中午的米饭不是 200 克,而是 100 克,帮我修改。

问题来了。

数据库中有三条米饭记录,究竟应该修改哪一条?

我们不能简单地根据 food_name = '米饭' 更新数据,否则可能同时修改多条记录。

正确做法应该分成两步。

第一步,查找满足条件的记录。

第二步,根据唯一的记录 ID 执行修改。

image-20261021160307365

因此,本章首先开发一个查找工具。

6.2 开发 find_food_records:查找饮食记录​

打开 db.py。

我们先编写数据库查询函数。

第一步:实现多条件查询​

def find_food_records_db(
record_date: str | None = None,
meal_type: str | None = None,
food_name: str | None = None,
limit: int = 20,
) -> list[dict]:

sql = """
SELECT *
FROM food_records
WHERE 1 = 1
"""

params = []

if record_date is not None:
sql += " AND record_date = ?"
params.append(record_date)

if meal_type is not None:
sql += " AND meal_type = ?"
params.append(meal_type)

if food_name is not None:
sql += " AND food_name LIKE ?"
params.append(f"%{food_name}%")

sql += " ORDER BY record_date DESC, id DESC LIMIT ?"
params.append(limit)

with get_connection() as conn:
rows = conn.execute(sql, params).fetchall()
return [dict(row) for row in rows]

这里有几个值得注意的知识点。

为什么使用 WHERE 1 = 1?​

SELECT *
FROM food_records
WHERE 1 = 1

1 = 1 永远成立。

这样做主要是为了方便动态拼接查询条件。

例如,用户只提供日期:

WHERE 1 = 1
AND record_date = ?

如果用户还提供餐次:

WHERE 1 = 1
AND record_date = ?
AND meal_type = ?

这样就不需要额外判断第一个条件应该使用 WHERE,还是后续条件应该使用 AND。

为什么使用 LIKE?​

food_name LIKE ?

它支持模糊匹配。

例如用户搜索:

米饭

就有可能找到:

白米饭
糙米饭
米饭

但要注意,这种模糊查询只是字符串匹配,并不具备语义理解能力。

例如,搜索“鸡肉”不一定能找到“鸡腿”。

ChatGPT 可以帮助理解用户的表达,但数据库最终仍然按照 SQL 条件查询。

为什么限制查询数量?​

limit: int = 20

因为我们不希望用户随意发起一次无限量的数据查询。

对于个人健康记录系统,一次返回最近 20 条匹配记录,通常足够用于定位某条数据。

如果后续需要浏览大量历史记录,可以再增加分页功能。

6.3 将查找函数注册为 MCP 工具​

打开 main.py,导入:

from db import find_food_records_db

然后增加:

@mcp.tool()
def find_food_records(
record_date: str | None = None,
meal_type: str | None = None,
food_name: str | None = None,
limit: int = 20,
) -> dict:
"""
根据日期、餐次和食物名称查找饮食记录。

当用户要求修改或删除饮食记录时,
应先使用本工具查找目标记录,
获取准确的记录 ID。

参数:
- record_date:日期 YYYY-MM-DD,可选
- meal_type:breakfast/lunch/dinner/snack,可选
- food_name:食物名称,支持模糊匹配,可选
- limit:最大返回数量,范围 1~100
"""

try:
if record_date is not None:
valid_date(record_date)

allowed_meals = {
"breakfast",
"lunch",
"dinner",
"snack",
}

if meal_type is not None and meal_type not in allowed_meals:
raise ValueError("无效的餐次")

if not 1 <= limit <= 100:
raise ValueError("limit 必须在 1~100 之间")

if food_name is not None and not food_name.strip():
raise ValueError("食物名称不能为空字符串")

if all(
value is None
for value in (record_date, meal_type, food_name)
):
raise ValueError("至少提供一个查询条件")

records = find_food_records_db(
record_date=record_date,
meal_type=meal_type,
food_name=food_name,
limit=limit,
)

return {
"success": True,
"count": len(records),
"records": records,
}

except ValueError as exc:
return {
"success": False,
"error": str(exc),
}

现在,ChatGPT 就可以通过日期、餐次和食物名称查找数据库中的记录。

使用 MCP Inspector 测试​

重新启动 MCP Server:

uv run python main.py

连接 Inspector,找到 find_food_records。

输入:

{
"record_date": "2026-10-19",
"meal_type": "lunch",
"food_name": "米饭"
}

假设数据库中存在对应记录,返回结果可能是:

{
"success": true,
"count": 1,
"records": [
{
"id": 1,
"record_date": "2026-10-19",
"meal_type": "lunch",
"food_name": "米饭",
"calories": 232,
"weight_g": 200,
"protein_g": 5.2,
"carbs_g": 51.8,
"fat_g": 0.6,
"notes": null
}
]
}

现在,我们已经知道应该修改的是 ID 为 1 的记录。

接下来,就可以开发修改功能。

6.4 开发 update_food:修改指定饮食记录​

我们先思考一个问题。

如果用户只说:

把这条米饭的重量修改为 100 克。

那么,其他字段应该怎么办?

例如,原来的热量是 232 千卡,蛋白质是 5.2 克。

如果只修改 weight_g,不修改热量和营养成分,就会出现数据不一致。

所以,对于饮食记录,我们需要允许同时修改:

  • 食物名称
  • 热量
  • 重量
  • 蛋白质
  • 碳水化合物
  • 脂肪
  • 备注

对于重量发生变化的情况,ChatGPT 应当重新估算相关营养成分,而不是只修改重量字段。

第一步:编写数据库更新函数​

打开 db.py,添加:

def update_food_db(
record_id: int,
updates: dict,
) -> bool:

allowed_fields = {
"food_name",
"calories",
"weight_g",
"protein_g",
"carbs_g",
"fat_g",
"notes",
}

if not updates:
raise ValueError("没有需要修改的字段")

if not set(updates).issubset(allowed_fields):
raise ValueError("包含不允许修改的字段")

assignments = ", ".join(
f"{field} = ?"
for field in updates
)

sql = f"""
UPDATE food_records
SET {assignments}
WHERE id = ?
"""

params = list(updates.values()) + [record_id]

with get_connection() as conn:
cursor = conn.execute(sql, params)
return cursor.rowcount > 0

这里使用了 SQL 的 UPDATE 语句。

例如:

UPDATE food_records
SET weight_g = ?, calories = ?
WHERE id = ?

注意,我们虽然使用了字符串拼接生成字段名称,但字段必须来自预先定义的 allowed_fields 白名单。

而真正的数据值仍然通过 SQL 参数占位符传入。

这样可以避免让客户端任意指定 SQL 字段或拼接恶意 SQL 语句。

第二步:注册 update_food 工具​

打开 main.py:

from db import update_food_db

继续添加:

@mcp.tool()
def update_food(
record_id: int,
food_name: str | None = None,
calories: float | None = None,
weight_g: float | None = None,
protein_g: float | None = None,
carbs_g: float | None = None,
fat_g: float | None = None,
notes: str | None = None,
) -> dict:
"""
根据记录 ID 修改饮食记录。

修改前应先调用 find_food_records,
确认目标记录 ID。

如果用户修改食物重量,应尽可能同步重新估算
热量、蛋白质、碳水和脂肪。

未提供的字段保持不变。
"""

try:
if record_id <= 0:
raise ValueError("record_id 必须大于 0")

if food_name is not None and not food_name.strip():
raise ValueError("食物名称不能为空")

check_nonnegative(
calories=calories,
weight_g=weight_g,
protein_g=protein_g,
carbs_g=carbs_g,
fat_g=fat_g,
)

updates = {
"food_name": food_name,
"calories": calories,
"weight_g": weight_g,
"protein_g": protein_g,
"carbs_g": carbs_g,
"fat_g": fat_g,
"notes": notes,
}

updates = {
key: value
for key, value in updates.items()
if value is not None
}

updated = update_food_db(
record_id=record_id,
updates=updates,
)

if not updated:
return {
"success": False,
"error": "没有找到对应记录",
}

return {
"success": True,
"record_id": record_id,
"updated_fields": list(updates),
"message": "饮食记录修改成功",
}

except ValueError as exc:
return {
"success": False,
"error": str(exc),
}

这里有一个设计细节:

if value is not None

意味着未提供的字段不会被修改。

例如,只传入:

{
"record_id": 1,
"notes": "午餐实际食用量为100克"
}

那么,其他营养字段都会保持原值。

但这种接口也有一个限制:目前无法通过传入 None 主动清空已有字段。

因为我们将 None 同时用于表示“没有提供参数”。

为了保持本章代码简单,暂时采用这种设计。后面如果需要支持将某个字段重置为数据库 NULL,可以使用显式的字段更新对象或单独的清空字段参数。

6.5 测试修改饮食记录​

假设数据库中原来有一条米饭记录:

字段原始值
id1
food_name米饭
weight_g200
calories232
protein_g5.2
carbs_g51.8
fat_g0.6

现在发现实际只吃了 100 克。

如果假设是同一种米饭、相同烹饪方式,可以先按照重量比例估算:

新营养值 = 原营养值 × 100 ÷ 200

因此,新热量约为:

232 × 100 ÷ 200 = 116 kcal

蛋白质、碳水和脂肪也可以按相同比例调整。

在 MCP Inspector 中调用:

update_food

输入:

{
"record_id": 1,
"weight_g": 100,
"calories": 116,
"protein_g": 2.6,
"carbs_g": 25.9,
"fat_g": 0.3,
"notes": "原记录200克,实际食用100克,营养按比例重新估算"
}

如果成功,应该返回:

{
"success": true,
"record_id": 1,
"updated_fields": [
"calories",
"weight_g",
"protein_g",
"carbs_g",
"fat_g",
"notes"
],
"message": "饮食记录修改成功"
}

之后再次调用 find_food_records,确认数据库中确实已经更新。

需要注意,这里按比例调整营养的前提是食物本身和烹饪方式没有变化。

如果用户把“米饭”改成“炒饭”,就不能简单按重量比例计算,因为炒饭可能添加了食用油、鸡蛋等配料。

6.6 开发 delete_food:删除指定记录​

接下来,我们开发删除功能。

假设用户告诉 ChatGPT:

刚才那条香蕉是测试数据,帮我删除。

这时,我们仍然需要先查找记录,确认 ID。

然后执行:

DELETE FROM food_records
WHERE id = ?

第一步:编写数据库删除函数​

在 db.py 中添加:

def delete_food_db(record_id: int) -> bool:
with get_connection() as conn:
cursor = conn.execute(
"""
DELETE FROM food_records
WHERE id = ?
""",
(record_id,),
)

return cursor.rowcount > 0

这里使用记录 ID 删除。

不会根据食物名称批量删除。

因此,即使用户一天吃了三次香蕉,也可以准确删除其中一条。

第二步:注册 MCP 删除工具​

在 main.py 中导入:

from db import delete_food_db

然后添加:

@mcp.tool()
def delete_food(
record_id: int,
) -> dict:
"""
根据记录 ID 删除一条饮食记录。

删除前必须先通过 find_food_records
确认目标记录。

如果存在多条可能匹配的记录,
应要求用户明确要删除哪一条。

此操作会从 SQLite 中永久删除记录。
"""

try:
if record_id <= 0:
raise ValueError("record_id 必须大于 0")

deleted = delete_food_db(record_id)

if not deleted:
return {
"success": False,
"error": "记录不存在",
}

return {
"success": True,
"record_id": record_id,
"message": "饮食记录删除成功",
}

except ValueError as exc:
return {
"success": False,
"error": str(exc),
}

现在,我们就拥有了删除工具。

但这里有一个重要的安全问题。

删除操作通常不可逆。

如果用户表达不明确,ChatGPT 不应该随便猜测记录 ID。

例如:

把之前那条米饭删掉。

如果数据库中存在多条米饭记录,就应该先询问用户究竟要删除哪一条。

对于可能产生严重后果的操作,还应该考虑增加二次确认、软删除或审计日志。

本课程当前采用的是直接删除,因此测试时建议使用专门创建的测试记录。

6.7 使用 ChatGPT 完成自然语言修改​

现在,我们已经完成了三个工具:

find_food_records
update_food
delete_food

接下来,需要重新启动 MCP Server:

uv run python main.py

然后在 ChatGPT 中进入 Health Tracker 插件管理页面,刷新工具定义。

因为我们新增了工具,ChatGPT 需要重新获取 MCP Server 的工具列表。

测试场景:修改昨天的米饭​

在 ChatGPT 中输入:

昨天中午记录的米饭是 200 克,实际上只有 100 克。请帮我找到这条记录,并同步修改热量和营养成分。

理想情况下,ChatGPT 会先调用:

find_food_records

参数类似:

{
"record_date": "2026-10-19",
"meal_type": "lunch",
"food_name": "米饭"
}

注意,这里的日期只是课程示例。实际使用时,“昨天”应根据用户当前日期和时区解析。

如果查询结果只有一条,ChatGPT 就能够获得准确的记录 ID。

接下来,ChatGPT 根据原来的重量和营养数据重新估算,并调用:

update_food

例如:

{
"record_id": 1,
"weight_g": 100,
"calories": 116,
"protein_g": 2.6,
"carbs_g": 25.9,
"fat_g": 0.3
}

修改成功后,再根据工具结果回复用户。

整个过程中,我们没有手动填写 JSON。

ChatGPT 负责理解用户需求、查找目标记录和生成修改参数,MCP Server 负责执行数据库更新。

这就是 AI 工具调用在实际业务中的价值。

6.8 使用 ChatGPT 删除测试记录​

我们再测试删除功能。

首先,在 ChatGPT 中输入:

帮我记录一条测试饮食:今天下午吃了一根香蕉,热量 105 千卡,备注为“删除功能测试”。

保存成功后,再输入:

找到刚才那条备注为“删除功能测试”的香蕉记录,删除它。

这里有一个值得注意的地方。

我们当前的 find_food_records 只支持日期、餐次和食物名称三个条件,并不支持按照备注搜索。

因此,ChatGPT 可以先根据日期、餐次和食物名称查找,再检查返回结果中的 notes 字段。

如果找到了唯一匹配的记录,就可以调用 delete_food。

如果有多条相似记录,则应该先向用户确认。

删除完成后,再调用 find_food_records 查询。

如果那条记录已经不存在,就说明删除操作成功。

但要注意:查不到记录也可能是查询条件不正确,因此最可靠的方式是同时检查 delete_food 的执行结果。

6.9 为什么不能让 ChatGPT 直接执行 SQL?​

学到这里,可能有人会产生一个想法:

既然 ChatGPT 能够理解自然语言,为什么不直接提供一个工具:

@mcp.tool()
def execute_sql(sql: str):
...

让 ChatGPT 自己生成 SQL 呢?

例如:

删除昨天中午的米饭。

ChatGPT 直接生成:

DELETE FROM food_records
WHERE record_date = '2026-10-19'
AND meal_type = 'lunch'
AND food_name = '米饭';

看起来确实更加灵活。

但是,这种设计存在明显风险。

首先,模型可能生成错误 SQL。

其次,用户输入或外部内容可能诱导模型执行超出预期的操作。

例如,错误地执行:

DELETE FROM food_records;

就会删除整张表中的所有饮食记录。

如果提供的是不受限制的 SQL 执行接口,还可能修改数据库结构或访问其他敏感数据。

因此,我不建议在这个项目中向 ChatGPT 暴露任意 SQL 执行工具。

相比之下,我们现在的设计更加可控:

工具允许执行的操作
find_food_records受限制的 SELECT 查询
update_food根据 ID 修改允许的字段
delete_food根据 ID 删除单条记录

ChatGPT 只能通过这些明确设计的接口操作数据库。

工具的能力边界越清晰,越容易进行参数校验、权限控制和安全审计。

这也是开发 MCP Server 时需要特别重视的原则。

6.10 进一步完善:防止记录在修改前发生变化​

目前,我们采用的是:

先查询 → 获取 ID → 执行修改

这已经比根据食物名称直接修改安全得多。

但还存在一种特殊情况。

假设 ChatGPT 查询到:

id = 1
米饭 200 克

在 ChatGPT 执行修改之前,用户又通过其他客户端把这条记录修改成了 150 克。

此时,如果 ChatGPT 继续按照旧数据执行修改,就可能覆盖较新的结果。

这类问题通常称为并发更新冲突。

对于当前的单用户教学项目,出现概率不高,但在正式系统中值得考虑。

常见解决方法是乐观锁。

例如,给数据表增加:

updated_at

字段。

每次修改时,同时检查记录 ID 和原来的更新时间。

只有数据没有被其他操作修改过,才允许更新。

本章暂时不实现乐观锁,因为我们的重点是理解 MCP 工具如何完成增删改查。

不过,学员应该知道:记录 ID 可以准确定位数据,但不能单独解决并发更新问题。

6.11 本章工具清单​

到目前为止,Health Tracker 已经拥有以下工具:

工具功能
record_food新增饮食记录
get_food_records查询指定日期的饮食
get_daily_calories统计每日热量
record_exercise新增运动记录
record_weight新增体重记录
record_fasting新增断食记录
get_daily_summary查询每日综合健康数据
find_food_records根据条件查找饮食记录
update_food修改指定饮食记录
delete_food删除指定饮食记录

其中,饮食记录已经实现完整的 CRUD:

操作SQLMCP 工具
Create(新增)INSERTrecord_food
Read(读取)SELECTfind_food_records
Update(修改)UPDATEupdate_food
Delete(删除)DELETEdelete_food

这意味着,我们的 Health Tracker 已经从一个简单的饮食记录工具,逐步发展为具备完整数据管理能力的 MCP 应用。

6.12 本章总结​

这一章,我们解决了真实使用过程中经常遇到的问题:

如果 AI 保存的数据出现错误,应该如何准确地修改和删除?

我们没有直接让 ChatGPT 执行任意 SQL,而是设计了三个职责明确的 MCP 工具:

find_food_records 负责定位数据,update_food 负责修改指定记录,delete_food 负责删除指定记录。

同时,我们还学习了几个重要的数据库开发原则:

  • 使用记录 ID 精确定位数据。
  • SQL 数据值使用参数化查询。
  • 动态更新字段必须经过白名单限制。
  • 修改食物重量时,应同步检查营养数据。
  • 删除操作必须防止目标不明确。
  • 不要向 AI 暴露没有限制的数据库执行权限。

现在,我们已经能够直接在 ChatGPT 中完成饮食记录的新增、查询、修改和删除。

但还有一个实际问题没有解决。

目前 MCP Server 和 Tunnel Client 都运行在我们的 Mac 上。

只要关闭电脑,ChatGPT 就无法再调用 Health Tracker。

这显然不适合长期使用。

下一章预告:使用 Docker 将 MCP Server 部署到阿里云​

下一章,我们将把 Health Tracker 从本地开发环境迁移到云服务器。

具体包括:

  1. 为 Python MCP Server 编写 Dockerfile。
  2. 使用 Docker Compose 管理服务。
  3. 将 SQLite 数据库挂载到持久化目录。
  4. 在阿里云 ECS 上运行 MCP Server。
  5. 配置服务自动重启。
  6. 将 Tunnel Client 迁移到云服务器,或根据安全需求采用 HTTPS 连接。
  7. 验证关闭 Mac 后,ChatGPT 仍然能够正常使用 Health Tracker。

完成云端部署后,我们的 Health Tracker 就不再依赖本地电脑持续开机,而是成为一个能够长期运行的私人 MCP 服务。

课程推荐

想系统学习这门课程?

你已阅读完本文。需要更完整的知识体系,可以继续了解「Vibe Coding 实战课程」。

查看课程详情