前言

最近把之前做的迷你烹饪问答机器人重新整理了一遍,并给它取了一个更像项目的名字:

1
CookGraph

这个名字里的 Cook 代表菜谱和烹饪,Graph 代表背后的知识图谱。

最开始做这个项目的时候,目标其实很简单:用户问一句“西红柿炒鸡蛋怎么做”,系统能够查到菜谱,然后把结果返回出来。

但真正做起来之后,问题很快就复杂了。用户不会永远按照知识图谱里的标准名称提问,可能会说:

1
2
3
4
5
6
西红柿炒鸡蛋怎么做
番茄炒蛋的做法
我有牛肉和辣椒,可以做什么
菜心有多少种做法
小炒黄牛肉要准备哪些食材
它需要多大火

这些问题看起来都和菜谱有关,但查询方式并不相同。有的是正向菜谱查询,有的是反向推荐,有的是属性追问,还有的是需要用户补充信息的歧义问题。

如果把所有问题都交给一个小模型,让它自己决定调用什么工具、工具参数怎么写,实际效果并不稳定。模型可能会把自然语言直接塞到工具参数里,也可能明明已经查到了图谱结果,却继续联网搜索,最后还没有给出一个明确结论。

所以现在的 CookGraph,并不是简单的“LLM + 一个搜索工具”,而是把自然语言理解、实体归一化、查询计划、知识图谱执行和最终回答拆开。后续还希望把这套东西放到开发板上运行,因此从一开始就需要考虑模型大小、内存、存储、启动方式和离线数据准备。

这篇文章主要记录当前的设计,以及我准备如何把它部署到开发板上。

项目目标

CookGraph 当前的目标可以分成两部分。

第一部分是问答能力:

  • 查询菜名的完整菜谱;
  • 查询某道菜的食材、做法、火力和烹饪时间;
  • 根据食材、技法、口味或菜系反向查菜;
  • 处理“它需要哪些食材”“刚才那道菜火力怎么样”这类上下文追问;
  • 本地图谱没有结果时,再考虑联网搜索。

第二部分是部署目标:

  • 尽量减少开发板上的运行依赖;
  • 不要求开发板一定有 GPU;
  • 模型服务可以本地运行,也可以通过 OpenAI Compatible API 连接远端;
  • 知识图谱和向量索引可以提前离线构建;
  • 设备重启后能够自动恢复服务;
  • 在网络不稳定时,至少保证本地图谱查询仍然可用。

这里有一个比较重要的取舍:开发板不一定要承担所有计算任务

如果开发板只有几 GB 内存,强行在板子上运行 4B 或更大的模型,最后很可能变成“模型能启动,但整套服务没有余量”。因此 CookGraph 的模型层从设计上就保留了远端连接能力。

开发板负责 API、查询、持久化和前端服务,模型可以根据硬件条件选择:

1
2
3
开发板本地小模型

远端 LM Studio / vLLM / OpenAI Compatible API

这样部署目标会更现实一些。

当前使用的数据

CookGraph 使用的是菜谱知识图谱,而不是单纯的 Markdown 文档搜索。

当前大图大约包含:

项目数量
节点75242
关系358690
菜品13214

图谱文件是:

1
config/2kg_chem+recipe_fire_12K.pkl

这个文件大约 51 MB。它已经从 GitHub 的公开仓库中移除,不再跟随代码发布。原因不只是文件偏大,也包括知识图谱属于运行数据,和源代码的生命周期并不完全一样。

实际部署时,应该把它作为独立的数据包放到:

1
CookGraph/config/2kg_chem+recipe_fire_12K.pkl

当前代码会用 pickle 加载图谱,再转成 networkx.DiGraph。查询层主要依赖几个索引:

  • dish_nodes:菜名到节点 ID 的索引;
  • all_nodes_by_label:实体类型到节点名的索引;
  • graph.edges():正向关系查询;
  • graph.in_edges():反向关系查询。

需要注意的是,51 MB 只是磁盘文件大小。反序列化成 Python 对象后,实际内存占用会更高。开发板部署时不能只看文件能不能放下,还要看 Python 进程、模型、SQLite、前端和系统本身是否还有足够余量。

为什么没有让模型直接调用图谱

早期的思路比较直接:把 recipe_query_tool 注册给模型,然后让模型自己填写参数。

这种方式在大模型上通常还可以,但在小模型上容易出现几类问题:

  1. 把整句用户问题当成菜名;
  2. 把“怎么做”这种意图词传给图谱查询;
  3. 用户说“番茄炒蛋”,图谱只有“西红柿炒鸡蛋”时无法归一化;
  4. 明明是食材反向查询,却按单道菜谱查询;
  5. 工具已经返回结果,模型没有继续总结,也没有给出结论;
  6. 工具参数不是合法 JSON,或者工具名被模型写成普通文本。

因此现在的做法是:模型负责理解用户意图,但真正的执行参数由后端结构化生成。

1
2
3
4
5
6
用户原文
-> Query Understanding
-> QueryFrame
-> Entity Resolver
-> QueryPlan
-> recipe_query_tool(plan)

模型不是直接控制整个执行过程,而是先输出一个受约束的意图结构。后端校验通过之后,再把它转换成图谱可以执行的查询计划。

当前查询链路

CookGraph 当前的实际链路大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
flowchart TD
A[用户输入] --> B[FastAPI 接口]
B --> C[会话上下文恢复]
C --> D[Query Understanding]
D --> E[QueryFrame JSON Schema 校验]
E --> F[实体归一化]
F --> G[Query Router]
G -->|菜谱查询| H[recipe_query_tool plan]
G -->|联网请求| I[web_search_tool]
G -->|歧义| J[澄清用户]
H --> K[图谱精确查询]
H --> L[菜名/关系向量召回]
K --> M[统一 ToolResult]
L --> M
I --> M
M --> N[Grounded Answer]
N --> O[SSE 返回与 SQLite 持久化]

这里最重要的变化是:第一跳路由不再完全交给模型决定

系统先判断这是不是菜谱问题,再判断查询类型。只有在本地图谱已经执行完成、需要补充公共网页信息时,才考虑调用网络搜索。

Query Understanding

query_understanding.py 是当前的意图识别入口。它不是简单返回一个字符串,而是输出一个结构化的 QueryFrame

这个结构里会包含类似下面的信息:

1
2
3
4
5
6
7
8
{
"intent": "dish_recipe",
"dish_name": "番茄炒蛋",
"ingredients": [],
"attributes": ["ingredients", "steps"],
"need_web": false,
"confidence": 0.92
}

实际字段会根据版本继续调整,但原则不变:

  • 模型输出必须符合 JSON Schema;
  • 后端负责类型、字段和枚举值校验;
  • 模型输出不合规时,返回歧义状态或请求澄清;
  • 不再使用一堆散落在适配器里的语义正则作为主流程。

这样做的好处是,后续想增加新的查询类型时,可以扩展 QueryFrameQueryPlan,而不是继续在工具循环里增加特殊判断。

实体归一化

用户说的菜名和知识图谱里的菜名经常不是同一个字符串。

比如:

用户说法图谱标准名
西红柿炒鸡蛋番茄炒蛋
番茄炒蛋番茄炒蛋
青椒炒肉辣椒炒肉
小炒黄牛肉小炒黄牛肉

实体归一化层会优先尝试精确匹配,再尝试别名、向量召回和模糊匹配,最终把结果交给查询计划。

这里没有把向量检索直接当作最终答案,而是把它当成“找到标准实体”的工具。真正的菜谱内容、食材关系和火力过程,仍然从知识图谱里精确读取。

因此完整的正向查询链路是:

1
2
3
4
5
6
自然语言菜名
-> 别名/向量召回
-> 图谱标准菜名
-> 图谱精确查询
-> 结构化菜谱结果
-> 自然语言回答

反向推荐为什么单独处理

“我有辣椒和牛肉,可以做什么”与“辣椒炒肉怎么做”并不是同一种查询。

前者需要从食材节点出发,沿着关系找到可能的菜品;后者则需要先定位一道菜,再读取它的属性。

目前反向推荐主要依赖:

  • 推荐别名表;
  • 食材和菜名的向量召回;
  • 图谱关系精确过滤;
  • RRF 融合多路排序结果。

推荐向量索引通过脚本离线构建:

1
2
python scripts/build_recommendation_aliases.py
python scripts/build_recommendation_vector_index.py

这样做的原因是,开发板上不应该在每次用户请求时重新遍历所有菜谱、重新生成全部 embedding。索引应该在开发机或服务器上提前生成,再和应用一起部署到板子上。

开发板运行时只负责:

1
2
3
4
5
用户输入
-> 读取已有向量索引
-> 找候选菜名
-> 图谱关系过滤
-> 返回推荐结果

工具调用限制

工具调用没有设置成无限循环。

当前默认配置是:

1
2
3
MAX_TOOL_TURNS=5
MAX_TOTAL_TOOL_CALLS=5
MAX_CONSECUTIVE_TOOL_CALLS=3

限制的原因很现实:如果小模型连续重复调用同一个工具,远端模型服务的 KV Cache 会不断增长,请求可能长时间占住推理引擎,最后表现成“网页一直在思考”或者服务没有响应。

达到工具调用上限后,系统不再继续追问模型,而是基于已经获得的工具结果生成阶段性总结。至少要把当前查到的内容返回给用户,而不是停留在一个错误状态。

这个限制对开发板也有帮助。开发板的 CPU、内存和网络都更有限,限制单次请求的最大工作量,可以避免一个异常请求拖垮整个服务。

面向开发板的部署设计

第一种方案:开发板只跑应用,模型在远端

这是我认为最稳妥的第一阶段方案。

1
2
3
4
5
6
7
8
9
10
开发板
├── FastAPI
├── SQLite
├── NetworkX 菜谱图谱
├── 预生成的向量索引
└── Vue 前端

└── OpenAI Compatible API

└── 远端 LM Studio / vLLM

开发板只运行业务逻辑,模型通过局域网或 Tailscale 访问远端服务。

优点:

  • 对开发板内存要求低;
  • 不需要在板子上安装 CUDA、PyTorch 或 vLLM;
  • 模型可以随时替换;
  • 适合先验证整套产品链路。

缺点也很明显:

  • 依赖网络;
  • 远端模型不可用时,Query Understanding 可能需要降级为澄清;
  • 远端地址、认证和 SSH 隧道需要额外配置。

第二种方案:开发板运行小模型

如果开发板有足够内存,可以尝试部署 0.6B 或 1.7B 级别的量化模型,通过 llama.cpp 或其他轻量推理运行时提供本地 API。

这时建议把模型定位成“意图识别器”,不要让它承担全部回答工作:

1
2
3
本地小模型:输出 QueryFrame
后端程序:执行 QueryPlan 和知识图谱查询
图谱结果:生成最终菜谱回答

这样比让小模型自由发挥 ReAct 工具循环更省 token,也更容易控制错误范围。

如果本地模型无法稳定输出结构化 JSON,可以直接返回澄清问题,而不是退回到大量正则补丁。

第三种方案:应用和模型都在开发板

这是最终形态,但不建议作为第一阶段目标。

开发板需要同时承担:

  • Python Web 服务;
  • NetworkX 图谱加载;
  • embedding 模型或向量索引;
  • 本地 LLM 推理;
  • SQLite 持久化;
  • 前端静态文件服务。

如果硬件资源不足,最容易出现的问题不是某一个功能报错,而是整台板子开始频繁交换、请求超时、模型 KV Cache 不断增长,最后表现成系统不稳定。

所以更合理的推进顺序是:

1
2
3
远端模型 + 开发板应用
-> 开发板本地小模型
-> 应用、索引和模型全部本地化

开发板上的文件布局

最终希望把运行时整理成下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/opt/cookgraph/
├── .venv/
├── backend/
├── frontend/dist/
├── config/
│ ├── 2kg_chem+recipe_fire_12K.pkl
│ ├── recipe_aliases.json
│ ├── recommendation_aliases.json
│ ├── reverse_entity_aliases.json
│ └── recepi/
├── models/
│ └── gte-large-zh/ # 如果板端运行 embedding
├── backend/.cache/
│ └── recipe_recommendation_vector_index.npz
├── data/
│ └── cookgraph.sqlite3
├── .env
└── start.py

其中需要区分三类文件:

代码文件

可以直接从 Git 仓库获取:

1
git clone https://github.com/QDcvd/CookGraph.git

配置文件

可以随项目发布,但不应该写入密钥、远端密码和内网地址。开发板上的 .env 需要单独创建。

运行数据

包括:

  • .pkl 知识图谱;
  • embedding 模型;
  • 向量索引;
  • SQLite 数据库;
  • 测试日志。

这些文件最好通过独立的部署包、局域网传输或设备初始化脚本提供,不要直接混进公开代码仓库。

预计的部署流程

开发板使用 Linux 系统时,优先使用 uv,而不是 conda。

部署流程大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
git clone https://github.com/QDcvd/CookGraph.git
cd CookGraph

# 准备运行数据
mkdir -p config backend/.cache data
cp /path/to/2kg_chem+recipe_fire_12K.pkl config/

# 安装 Python 和前端依赖
bash deploy_uv.sh --skip-model

# 如果板端需要 embedding,再安装或复制 embedding 模型
python scripts/build_recommendation_aliases.py
python scripts/build_recommendation_vector_index.py

# 创建本地配置
cp .env.example .env

# 启动服务
.venv/bin/python start.py

开发板上不一定适合每次重新下载 gte-large-zh。如果存储、内存或下载速度有限,可以在开发机上先生成推荐别名和向量索引,再把生成结果复制到板子上。

后续要补上的部分

目前这还是一套可以运行的开发版本,距离“插上开发板就能长期运行”还差几项工作。

1. 重新确认开发板硬件预算

需要先确认具体开发板的:

  • CPU 架构,是 ARM64 还是 x86;
  • 内存大小;
  • 存储空间和介质寿命;
  • 是否有 GPU/NPU;
  • 系统是否支持 Python 3.11;
  • 是否能运行 NetworkX 和当前依赖。

不同硬件决定模型是本地跑还是远端跑,不能只根据“开发板”这三个字做最终方案。

2. 增加 systemd 服务

开发板不能依赖手动执行:

1
python start.py

后续应该增加:

  • cookgraph.service
  • 自动重启;
  • 启动前检查图谱文件;
  • 健康检查接口;
  • 日志轮转;
  • 前端和后端的独立进程管理。

3. 增加资源监控

至少需要记录:

  • 图谱加载耗时;
  • 进程 RSS 内存;
  • 请求耗时;
  • 工具调用次数;
  • 模型请求失败次数;
  • SQLite 数据库大小。

开发板上的性能问题很多时候不是第一条请求就暴露,而是运行几个小时后才出现。没有这些指标,很难判断是模型、图谱、网络还是 SQLite 在变慢。

4. 处理优雅退出

开发板可能会断电或被重启。后续需要确保:

  • SQLite 写入完成后再退出;
  • 不在启动时重复损坏会话数据;
  • 模型请求超时后能够释放任务;
  • 前端断开不会让后台请求无限挂起。

5. 将图谱加载改成更适合设备的格式

目前直接加载 pickle + networkx 对开发阶段很方便,但它不一定是开发板上的最终形态。

后续可以比较:

  • SQLite 节点表和关系表;
  • 压缩后的只读 JSON/MessagePack;
  • 更紧凑的邻接表结构;
  • 启动时只加载菜名索引,详细属性按需读取。

如果开发板内存很小,真正需要优化的可能不是模型,而是图谱对象的 Python 内存开销。

目前的判断

CookGraph 已经不再是最开始的“模型看到问题后调用一个工具”了。现在它更像一条确定性的数据处理链:

1
2
3
4
5
6
7
8
用户表达
-> 意图识别
-> 实体归一化
-> 查询计划
-> 向量召回候选
-> 图谱精确检索
-> 工具证据
-> 自然语言回答

这套结构对开发板部署是有利的,因为大部分核心查询逻辑并不依赖大模型。即使后续把模型换成更小的本地模型,或者暂时连接远端模型,图谱查询、实体归一化、结果格式化和会话持久化都可以继续复用。

真正需要谨慎的地方有三个:

  1. 不要把大模型推理负担全部压到开发板上;
  2. 不要在每次请求时重新构建 embedding 和索引;
  3. 不要把运行数据、密钥和本地环境文件直接打包进公开仓库。

总结

目前 CookGraph 的第一阶段目标,不是立刻做成一个所有东西都在板端运行的“全本地 AI”。更现实的目标是:

  • 开发板上稳定运行 FastAPI、SQLite、知识图谱和前端;
  • 模型通过配置选择远端或本地小模型;
  • 推荐向量索引提前生成;
  • 运行数据独立于源代码管理;
  • 请求有工具调用上限和超时保护;
  • 服务可以被 systemd 拉起并自动恢复。

这样做的好处是可以先把设备端的工程链路跑通,再逐步替换模型和存储实现。

我比较喜欢这种项目的一点,是它不会因为“模型能回答一句话”就结束。真正部署到开发板之后,还要面对内存、启动、网络、数据文件、日志、断电恢复和长期运行这些问题。

模型只是其中的一部分。剩下那些看起来不那么智能的工程细节,最后反而决定了这个 Agent 能不能真正放在那里工作。