恰饭有谱:把 AI 助手接进饮食管理 App

恰饭有谱是我的个人项目。菜谱、用餐计划、食材库存和采购清单已经有各自的页面,加入 AI 助手之后,我关心的是另一件事:用户能不能用一句话找到这些数据,并从回答里继续完成下一步操作。下面按实际请求经过的路径,记录这部分的做法。

舒翱移动端 / 全栈 / Agent 应用

先保留业务边界,再增加对话入口

饮食管理很容易做出一个“看起来懂做饭”的聊天框。接一个模型,让它推荐几个菜,并不难。难的是用户问“明天安排了什么”“家里还有没有鸡蛋”时,回答必须对应自己的计划和库存。模型的常识不能替代这两张业务表。

因此我保留了原来的技术分工:iOS 使用 Swift、UIKit 和 SnapKit;网络请求使用 Alamofire,图片交给 Kingfisher。Java 21、Spring Boot 和 MyBatis 负责业务服务,MySQL 保存数据,Flyway 记录表结构变更。管理后台使用 Vue 3、TypeScript、Vite 和 Element Plus,Pinia 管理页面状态,ECharts 展示统计。

这里的重点是,增加聊天入口之后,原有业务规则仍由服务端执行。家庭成员能看什么、哪些菜谱处于发布状态,不能在模型提示词里重新解释一套。否则同一条数据,从列表页打开和从助手里查到,会出现两种权限结果。

本地和云端共用入口,保留各自的能力范围

聊天界面提供本地和云端两条路径。本地通过 llama.cpp 接入 Qwen 量化模型;云端由 Java 调用 DeepSeek。两条路径都要处理会话、证据、取消和展示,但不能因为 UI 相同,就把联网、性能和数据可用性说成相同。

本地模型由用户主动下载,校验完整性后再加载。模型不在安装包里,准备失败也不能用规则回答冒充模型输出。我把原生调用串行化,避免同一上下文同时推理和释放;内存警告、后台和空闲回收也要考虑正在执行的请求。

在旧设备上,本地方案最明显的成本是内存和首次计算。缩短提示、限制证据量、复用已计算的相同前缀,都值得做,但各自都有条件。前缀一旦被截断、取消或换了会话范围,就不能为了追求速度继续复用。一次固定短输入返回成功,只说明这个输入能跑通,不能推导出整段知识问答已经足够快。

所以我把首段等待、完整回答和峰值内存分开观察,也把应用自己的问候文案和真正的模型耗时分开记录。它们会给用户不同感受,技术上也不是同一个指标。

知识资料和实时业务数据分开取

“某种食材怎么保存”可以查知识资料,“家里还有多少”要读业务接口。我没有把这两类内容都塞进一个所谓的知识库。手机端用 NLTokenizer 整理词边界,SQLite FTS5 建索引,BM25 排序;资料保留语言、来源、版本及有效期,当前检索限制在编辑过的关键词。

这个方案适合随 App 分发的小份离线资料,缺点也很明确:关键词没覆盖到的问法可能找不到。云端当前复用同一批编辑知识,做关键词查询;它没有接向量库,也不能按“混合检索加重排”介绍。之后是否增加向量召回,应当由问题集上的错误决定。

资料命中还可能出错。有一次核查中,“花生”相关输入误带了库存指南的来源。修订后增加了更严格的命中和来源边界,再用真实问题复查。这类问题提醒我,来源列表不能装饰性地挂在回答底下:只要资料不支持当前问题,就不该因为“看起来相关”继续引用。

对于计划和库存,查询结果则带着当前业务范围。拿不到数据时保留失败状态;查到空结果时才表达没有记录。把两种情况区分开,用户才知道应该重试,还是确实需要先补一份计划。

云端工具调用,最终落到原来的服务

云端请求先由 Java 确定账号、已验证的家庭、语言和时区,再准备上下文,调用 DeepSeek 的流式接口。模型可以提出已登记的菜谱、计划或库存查询;程序验证工具和参数后,调用现有业务服务,把结果送回模型继续回答。

工具是闭合白名单,并且有调用上限。模型不能提交任意 URL,也不能在参数中换一个用户。没有命中、无权访问、暂时不可用和执行失败分别返回,避免生成阶段把所有异常都解释成“没有数据”。

目前这条聊天工具链主要完成只读查询。涉及安排计划、扣减库存等修改,应当进入对应草稿和确认流程,不能靠生成一句“已经完成”结束。我还单独用 Python 和 Pydantic 验证过工具合同、权限和证据规则,但这份核心没有接进 App,也没有运行 LangGraph 或 MCP。方案图里的候选组件,需要接通并验证之后才算产品能力。

流式聊天的麻烦,往往在文字之外

云端用 SSE 传递增量。网络每次给回的片段不一定是一条完整事件:一行可能断成两段,一次读取也可能带回多条。因此接收层先保存未完成内容,再按事件分隔解析;页面不直接拿网络片段当完整消息。

重连需要知道自己恢复的是哪一个任务,以及最后收到了哪条事件。如果直接重发原来的生成请求,用户看到的可能是第二份答案,也可能再次产生费用。当前做法为原任务和事件保留标识,在服务保留范围内回放,客户端忽略重复内容。旧服务端不支持恢复时,不自动重发可能计费的 POST。

这里也有边界:事件保存在单个服务实例的有界内存里,只适合短暂断线。服务重启、记录过期或换实例后,不能承诺恢复。它恢复的是传输,不是完整业务任务的持久检查点。这个区别在设计时说清楚,失败时才有明确的页面状态。

展示层按 Unicode 文字簇推进,避免把一个可见字符拆坏。用户往上翻时,暂缓新增展示内容,保留正在阅读的位置;点“回到最新”后再继续跟随。关闭页面可以暂停渲染,明确点停止则取消任务,并阻止迟到结果覆盖新的会话。录音识别也沿用这个思路:结果先进入可编辑草稿,取消后不再回填。

相比逐字效果,我更在意这些行为是否一致。一个用户刚刚按下的停止按钮,或者正在读的那段文字,不应该被后台不断到来的内容抢走。

用实际问题核对方案,而不是只检查代码

这部分已经做过真机模型加载、离线资料读取、云端真实工具调用和聊天交互的定向检查。我的判断口径是每份证据证明哪一段:固定模型输入能回答,不能替代领域问答;工具返回和基线相同,不能替代全部自然语言请求;测试替身通过,也不能记成供应商真实延迟。

我会继续保留几类回归问题:库存读取失败、跨家庭查询、含糊的日期、用户否认保存、无资料可引用、生成中取消、离开后返回以及网络中断。业务结果、引用是否对应、页面是否保留草稿,都要逐项看。性能数据则需要在相同设备、输入和版本下比较,单次好看的耗时没有代表性。

目前的工作还没有覆盖完整的知识发布治理、持久任务恢复和全部 Skill 流程。这篇记录的是已接通的技术路径及它的限制。对外源码也按范围处理:计划公开 iOS 客户端;Java 服务端、管理后台和独立 Python 工程保留私有。

做这个项目让我更确定一件事:对话只是入口。真正值得花时间的,是让回答能找到依据、让动作回到业务规则里,以及在出错时仍然知道这次请求走到了哪里。

延伸阅读

恰饭有谱 Logo对应项目恰饭有谱
返回全部文章

搜索项目和文章

输入关键词,查找项目或文章。

使用 Tab 选择结果,按 Esc 关闭。