CookGraph 设计记录:面向开发板的中文菜谱知识图谱 Agent 部署
前言
最近把之前做的迷你烹饪问答机器人重新整理了一遍,并给它取了一个更像项目的名字:
1 | CookGraph |
这个名字里的 Cook 代表菜谱和烹饪,Graph 代表背后的知识图谱。
最开始做这个项目的时候,目标其实很简单:用户问一句“西红柿炒鸡蛋怎么做”,系统能够查到菜谱,然后把结果返回出来。
但真正做起来之后,问题很快就复杂了。用户不会永远按照知识图谱里的标准名称提问,可能会说:
1 | 西红柿炒鸡蛋怎么做 |
这些问题看起来都和菜谱有关,但查询方式并不相同。有的是正向菜谱查询,有的是反向推荐,有的是属性追问,还有的是需要用户补充信息的歧义问题。
如果把所有问题都交给一个小模型,让它自己决定调用什么工具、工具参数怎么写,实际效果并不稳定。模型可能会把自然语言直接塞到工具参数里,也可能明明已经查到了图谱结果,却继续联网搜索,最后还没有给出一个明确结论。
所以现在的 CookGraph,并不是简单的“LLM + 一个搜索工具”,而是把自然语言理解、实体归一化、查询计划、知识图谱执行和最终回答拆开。后续还希望把这套东西放到开发板上运行,因此从一开始就需要考虑模型大小、内存、存储、启动方式和离线数据准备。
这篇文章主要记录当前的设计,以及我准备如何把它部署到开发板上。
项目目标
CookGraph 当前的目标可以分成两部分。
第一部分是问答能力:
- 查询菜名的完整菜谱;
- 查询某道菜的食材、做法、火力和烹饪时间;
- 根据食材、技法、口味或菜系反向查菜;
- 处理“它需要哪些食材”“刚才那道菜火力怎么样”这类上下文追问;
- 本地图谱没有结果时,再考虑联网搜索。
第二部分是部署目标:
- 尽量减少开发板上的运行依赖;
- 不要求开发板一定有 GPU;
- 模型服务可以本地运行,也可以通过 OpenAI Compatible API 连接远端;
- 知识图谱和向量索引可以提前离线构建;
- 设备重启后能够自动恢复服务;
- 在网络不稳定时,至少保证本地图谱查询仍然可用。
这里有一个比较重要的取舍:开发板不一定要承担所有计算任务。
如果开发板只有几 GB 内存,强行在板子上运行 4B 或更大的模型,最后很可能变成“模型能启动,但整套服务没有余量”。因此 CookGraph 的模型层从设计上就保留了远端连接能力。
开发板负责 API、查询、持久化和前端服务,模型可以根据硬件条件选择:
1 | 开发板本地小模型 |
这样部署目标会更现实一些。
当前使用的数据
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 注册给模型,然后让模型自己填写参数。
这种方式在大模型上通常还可以,但在小模型上容易出现几类问题:
- 把整句用户问题当成菜名;
- 把“怎么做”这种意图词传给图谱查询;
- 用户说“番茄炒蛋”,图谱只有“西红柿炒鸡蛋”时无法归一化;
- 明明是食材反向查询,却按单道菜谱查询;
- 工具已经返回结果,模型没有继续总结,也没有给出结论;
- 工具参数不是合法 JSON,或者工具名被模型写成普通文本。
因此现在的做法是:模型负责理解用户意图,但真正的执行参数由后端结构化生成。
1 | 用户原文 |
模型不是直接控制整个执行过程,而是先输出一个受约束的意图结构。后端校验通过之后,再把它转换成图谱可以执行的查询计划。
当前查询链路
CookGraph 当前的实际链路大致如下:
1 | flowchart TD |
这里最重要的变化是:第一跳路由不再完全交给模型决定。
系统先判断这是不是菜谱问题,再判断查询类型。只有在本地图谱已经执行完成、需要补充公共网页信息时,才考虑调用网络搜索。
Query Understanding
query_understanding.py 是当前的意图识别入口。它不是简单返回一个字符串,而是输出一个结构化的 QueryFrame。
这个结构里会包含类似下面的信息:
1 | { |
实际字段会根据版本继续调整,但原则不变:
- 模型输出必须符合 JSON Schema;
- 后端负责类型、字段和枚举值校验;
- 模型输出不合规时,返回歧义状态或请求澄清;
- 不再使用一堆散落在适配器里的语义正则作为主流程。
这样做的好处是,后续想增加新的查询类型时,可以扩展 QueryFrame 和 QueryPlan,而不是继续在工具循环里增加特殊判断。
实体归一化
用户说的菜名和知识图谱里的菜名经常不是同一个字符串。
比如:
| 用户说法 | 图谱标准名 |
|---|---|
| 西红柿炒鸡蛋 | 番茄炒蛋 |
| 番茄炒蛋 | 番茄炒蛋 |
| 青椒炒肉 | 辣椒炒肉 |
| 小炒黄牛肉 | 小炒黄牛肉 |
实体归一化层会优先尝试精确匹配,再尝试别名、向量召回和模糊匹配,最终把结果交给查询计划。
这里没有把向量检索直接当作最终答案,而是把它当成“找到标准实体”的工具。真正的菜谱内容、食材关系和火力过程,仍然从知识图谱里精确读取。
因此完整的正向查询链路是:
1 | 自然语言菜名 |
反向推荐为什么单独处理
“我有辣椒和牛肉,可以做什么”与“辣椒炒肉怎么做”并不是同一种查询。
前者需要从食材节点出发,沿着关系找到可能的菜品;后者则需要先定位一道菜,再读取它的属性。
目前反向推荐主要依赖:
- 推荐别名表;
- 食材和菜名的向量召回;
- 图谱关系精确过滤;
- RRF 融合多路排序结果。
推荐向量索引通过脚本离线构建:
1 | python scripts/build_recommendation_aliases.py |
这样做的原因是,开发板上不应该在每次用户请求时重新遍历所有菜谱、重新生成全部 embedding。索引应该在开发机或服务器上提前生成,再和应用一起部署到板子上。
开发板运行时只负责:
1 | 用户输入 |
工具调用限制
工具调用没有设置成无限循环。
当前默认配置是:
1 | MAX_TOOL_TURNS=5 |
限制的原因很现实:如果小模型连续重复调用同一个工具,远端模型服务的 KV Cache 会不断增长,请求可能长时间占住推理引擎,最后表现成“网页一直在思考”或者服务没有响应。
达到工具调用上限后,系统不再继续追问模型,而是基于已经获得的工具结果生成阶段性总结。至少要把当前查到的内容返回给用户,而不是停留在一个错误状态。
这个限制对开发板也有帮助。开发板的 CPU、内存和网络都更有限,限制单次请求的最大工作量,可以避免一个异常请求拖垮整个服务。
面向开发板的部署设计
第一种方案:开发板只跑应用,模型在远端
这是我认为最稳妥的第一阶段方案。
1 | 开发板 |
开发板只运行业务逻辑,模型通过局域网或 Tailscale 访问远端服务。
优点:
- 对开发板内存要求低;
- 不需要在板子上安装 CUDA、PyTorch 或 vLLM;
- 模型可以随时替换;
- 适合先验证整套产品链路。
缺点也很明显:
- 依赖网络;
- 远端模型不可用时,Query Understanding 可能需要降级为澄清;
- 远端地址、认证和 SSH 隧道需要额外配置。
第二种方案:开发板运行小模型
如果开发板有足够内存,可以尝试部署 0.6B 或 1.7B 级别的量化模型,通过 llama.cpp 或其他轻量推理运行时提供本地 API。
这时建议把模型定位成“意图识别器”,不要让它承担全部回答工作:
1 | 本地小模型:输出 QueryFrame |
这样比让小模型自由发挥 ReAct 工具循环更省 token,也更容易控制错误范围。
如果本地模型无法稳定输出结构化 JSON,可以直接返回澄清问题,而不是退回到大量正则补丁。
第三种方案:应用和模型都在开发板
这是最终形态,但不建议作为第一阶段目标。
开发板需要同时承担:
- Python Web 服务;
- NetworkX 图谱加载;
- embedding 模型或向量索引;
- 本地 LLM 推理;
- SQLite 持久化;
- 前端静态文件服务。
如果硬件资源不足,最容易出现的问题不是某一个功能报错,而是整台板子开始频繁交换、请求超时、模型 KV Cache 不断增长,最后表现成系统不稳定。
所以更合理的推进顺序是:
1 | 远端模型 + 开发板应用 |
开发板上的文件布局
最终希望把运行时整理成下面这样:
1 | /opt/cookgraph/ |
其中需要区分三类文件:
代码文件
可以直接从 Git 仓库获取:
1 | git clone https://github.com/QDcvd/CookGraph.git |
配置文件
可以随项目发布,但不应该写入密钥、远端密码和内网地址。开发板上的 .env 需要单独创建。
运行数据
包括:
.pkl知识图谱;- embedding 模型;
- 向量索引;
- SQLite 数据库;
- 测试日志。
这些文件最好通过独立的部署包、局域网传输或设备初始化脚本提供,不要直接混进公开代码仓库。
预计的部署流程
开发板使用 Linux 系统时,优先使用 uv,而不是 conda。
部署流程大致如下:
1 | git clone https://github.com/QDcvd/CookGraph.git |
开发板上不一定适合每次重新下载 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 | 用户表达 |
这套结构对开发板部署是有利的,因为大部分核心查询逻辑并不依赖大模型。即使后续把模型换成更小的本地模型,或者暂时连接远端模型,图谱查询、实体归一化、结果格式化和会话持久化都可以继续复用。
真正需要谨慎的地方有三个:
- 不要把大模型推理负担全部压到开发板上;
- 不要在每次请求时重新构建 embedding 和索引;
- 不要把运行数据、密钥和本地环境文件直接打包进公开仓库。
总结
目前 CookGraph 的第一阶段目标,不是立刻做成一个所有东西都在板端运行的“全本地 AI”。更现实的目标是:
- 开发板上稳定运行 FastAPI、SQLite、知识图谱和前端;
- 模型通过配置选择远端或本地小模型;
- 推荐向量索引提前生成;
- 运行数据独立于源代码管理;
- 请求有工具调用上限和超时保护;
- 服务可以被 systemd 拉起并自动恢复。
这样做的好处是可以先把设备端的工程链路跑通,再逐步替换模型和存储实现。
我比较喜欢这种项目的一点,是它不会因为“模型能回答一句话”就结束。真正部署到开发板之后,还要面对内存、启动、网络、数据文件、日志、断电恢复和长期运行这些问题。
模型只是其中的一部分。剩下那些看起来不那么智能的工程细节,最后反而决定了这个 Agent 能不能真正放在那里工作。