鸿蒙AI菜谱应用开发:如何让大模型稳定输出结构化JSON并渲染原生UI

1 阅读

从“今天吃什么”到AI菜谱应用

“今天吃什么?”——这大概是当代人每天最高频、也最令人头疼的问题之一。冰箱里躺着几样食材,却不知道能凑出什么菜;想做点新花样,又懒得翻菜谱App一页页找。

大语言模型的出现,让这个问题有了优雅的解法:把你有的食材告诉AI,让它帮你想菜谱。这听起来简单,但要做成一款体验好的应用,中间隔着一个关键的技术鸿沟——

在这里插入图片描述

大模型天生输出的是自然语言文本。如果我直接把它返回的一大段文字丢给用户,体验和在聊天框里问没什么区别:食材、步骤、用量混在一起,没有结构、无法交互、不能收藏。而一款真正的菜谱应用,需要的是结构化的数据:菜名、难度、耗时、用料清单(还要区分“你已有的”和“需要买的”)、分步做法、采购清单……

菜谱详情页

所以这篇文章的核心,不是“怎么调用大模型API”(那只是基础),而是:如何驯服大模型,让它稳定地吐出结构化JSON,再把这份JSON渲染成体验精致的鸿蒙原生界面。 这是AI应用从“能用”到“好用”的分水岭。

在这里插入图片描述

我用HarmonyOS(ArkTS/ArkUI)实现了这款应用,全程在鸿蒙PC(HUAWEI MateBook Pro)上开发运行。先看成品——这是应用的主界面,用户添加了“鸡蛋、番茄、土豆、豆腐”,AI立刻推荐了番茄炒蛋、土豆烧豆腐、番茄豆腐汤三道菜。

在这里插入图片描述

可以看到,AI的输出不是一段文字,而是三张结构清晰的菜谱卡片,每张都带着耗时、难度、以及“缺几样食材”的智能提示。这正是“结构化输出+原生渲染”的效果。下面我完整拆解它是怎么实现的。

架构设计:一个AI应用的分层

在写代码前先做架构。一款接入大模型的应用,最容易犯的错是把“网络请求、JSON解析、业务逻辑、UI渲染”全揉在一个页面里。这样的代码改一处牵一发,无法维护。

我把工程分为清晰的四层:

entry/src/main/ets/
├── entryability/     应用入口(生命周期、窗口、安全区)
├── common/           通用层(主题、AI配置、复用组件)
├── model/            数据与服务层(AI服务、数据模型、会话态)
└── pages/            UI层(四个功能页)

具体到文件:

├── common/
│   ├── Theme.ets           温暖美食橙主题
│   ├── AIConfig.ets        ★AI服务配置+结构化输出提示词
│   └── RecipeCard.ets      菜谱卡片组件(推荐/收藏复用)
├── model/
│   ├── Recipe.ets          菜谱数据模型+收藏/偏好持久化
│   ├── RecipeAI.ets        ★AI服务:请求+JSON解析(核心)
│   └── RecipeSession.ets   页面间的菜谱传递
└── pages/
    ├── Index.ets           四Tab主壳
    ├── CookTab.ets         做菜首页(食材输入+推荐)
    ├── DetailPage.ets      菜谱详情
    ├── FavTab.ets          收藏
    └── ProfileTab.ets      我的/设置

在这里插入图片描述

分层的核心思想是单一职责:UI层只管展示和交互,绝不直接碰网络;所有与大模型的交互收敛在RecipeAI;数据结构和持久化在Recipe;配置集中在AIConfig。这样当我要调整提示词、换模型、或改UI时,改动都被限制在一层里。

核心中的核心:让大模型输出结构化JSON

这是全文最重要的部分。我们的目标是:让大模型不要返回“番茄炒蛋做法:首先……”这样的散文,而是返回一个我们能直接解析的JSON对象。

用提示词定义数据契约

实现这一点的关键,是在系统提示词里把JSON的结构(schema)明确地“喂”给模型,并强硬要求它只输出JSON。我把这段提示词放在AIConfig里:

export class AIConfig {
  static readonly endpoint: string =
    'https://xxxxxxxx/compatible-mode/v1/chat/completions';
  static readonly apiKey: string = 'sk-****************';
  static readonly model: string = 'qwen-plus';

/** 系统提示词:约束模型只输出结构化菜谱JSON */
  static readonly systemPrompt: string =
    '你是一位专业的家常菜大厨兼营养师。用户会告诉你现有的食材和偏好,' +
    '你需要基于这些食材推荐2到3道菜,尽量充分利用已有食材。' +
    '必须严格只输出JSON,不要输出任何多余文字、解释或markdown代码块标记。' +
    'JSON格式如下:' +
    '{"recipes":[{' +
    '"name":"菜名",' +
    '"desc":"一句话简介",' +
    '"difficulty":"简单|中等|困难",' +
    '"minutes":预计分钟数(整数),' +
    '"tags":["标签1","标签2"],' +
    '"ingredients":[{"name":"用料名","amount":"用量","have":true或false}],' +
    '"steps":["步骤1","步骤2"],' +
    '"shopping":["需要额外购买的食材1","食材2"]' +
    '}]}' +
    '其中have字段:用户已有的食材标true,需要额外买的标false;' +
    'shopping列出所有have为false的食材。';

static readonly temperature: number = 0.8;
  static readonly maxTokens: number = 1500;
  static readonly timeoutMs: number = 40000;
}

这段提示词有几个精心设计的点,值得逐条说明:

其一,给出完整的JSON骨架。 我没有含糊地说“请返回结构化数据”,而是把每一个字段、每一层嵌套都写清楚。大模型对这种“填空题”式的明确schema遵循度非常高——你越具体,它越听话。

其二,明令禁止多余输出。 “必须严格只输出JSON,不要输出任何多余文字、解释或markdown代码块标记”——这一句至关重要。大模型有个习惯,喜欢在JSON外面裹一层json…代码块,或者在前面加一句“好的,这是为你推荐的菜谱:”。这些都会破坏JSON解析,必须明确禁止。(当然,禁止了也不能全信,代码侧还要兜底,后面讲。)

其三,用字段承载业务语义。 have字段是这个应用的灵魂——它让模型在推荐时就判断“哪些食材用户有、哪些要买”,shopping则汇总要买的。这把“我有啥/还缺啥”这个业务逻辑,直接交给模型在生成阶段完成,而不是等数据回来后我再去比对。善用字段设计,把业务语义嵌入数据契约,是提示工程的高级技巧。

其四,temperature: 0.8 菜谱推荐希望有一定创意和多样性(同样的食材,多点几次能有不同建议),所以温度设得比问答类应用略高。

组织请求

有了配置,RecipeAI.generate负责把用户的食材、人数、偏好组织成请求。这里要注意ArkTS的强类型约束——所有对象字面量都必须有显式的interface,否则编译报arkts-no-untyped-obj-literals

interface ChatMessage {
  role: string;
  content: string;
}

interface ChatRequest {
  model: string;
  messages: ChatMessage[];
  temperature: number;
  max_tokens: number;
}

static async generate(ingredients: string[], servings: number, pref: string): Promise<RecipeResult> {
  // 把用户输入拼成一句自然语言
  const userMsg =
    `我现在有这些食材:${ingredients.join('、')}。` +
    `用餐人数:${servings}人。` +
    (pref !== '' ? `我的偏好/忌口:${pref}。` : '') +
    `请推荐2-3道菜。`;

const req: ChatRequest = {
    model: currentModel(),
    messages: [
      { role: 'system', content: AIConfig.systemPrompt },   // schema约束
      { role: 'user', content: userMsg }                     // 用户诉求
    ],
    temperature: AIConfig.temperature,
    max_tokens: AIConfig.maxTokens
  };
  // ... 发起请求
}

注意这里的分工:system消息负责“你要输出什么格式”,user消息负责“我要什么内容”。这种职责分离让提示词清晰、可复用——换个应用场景,只要改system里的schema就行。

发起网络请求

用鸿蒙@kit.NetworkKit的http模块发POST请求,采用OpenAI兼容协议:

const client = http.createHttp();
try {
  const resp = await client.request(AIConfig.endpoint, {
    method: http.RequestMethod.POST,
    header: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + currentKey()
    },
    extraData: JSON.stringify(req),
    connectTimeout: AIConfig.timeoutMs,
    readTimeout: AIConfig.timeoutMs
  });

if (resp.responseCode !== 200) {
    return { ok: false, recipes: [], error: `请求失败(${resp.responseCode})` };
  }
  const content = RecipeAI.extractContent(`${resp.result}`);
  const recipes = RecipeAI.parseRecipes(content);
  if (recipes.length === 0) {
    return { ok: false, recipes: [], error: '未能解析出菜谱,请换个食材再试' };
  }
  return { ok: true, recipes: recipes, error: '' };
} catch (e) {
  return { ok: false, recipes: [], error: '网络异常,请检查连接后重试' };
} finally {
  client.destroy();   // 释放资源,避免泄漏
}

菜谱生成通常比普通问答耗时更长(要生成多道菜的完整结构),所以我把超时设到40秒,max_tokens也给到1500。返回结果统一封装成RecipeResult { ok, recipes, error },让UI层能优雅地处理成功和失败两种情况。

下图是点击“生成菜谱”后的加载状态——按钮变为“正在为你想菜谱…”并禁用,防止重复提交。

驯服大模型:JSON解析的容错艺术

拿到模型返回的文本后,要把它解析成Recipe[]。这一步看似简单(JSON.parse一下嘛),实则是整个应用最容易翻车的地方

因为大模型再怎么被约束,也不是100%听话。实践中它可能:

  • 在JSON外面裹一层json…markdown代码块;
  • 在JSON前面加一句“好的,为你推荐:”;
  • 某个字段偶尔缺失或类型不对。

如果直接JSON.parse(rawText),遇到上面任何一种情况都会抛异常、整个功能崩溃。所以解析层必须做足容错

先提取纯净的JSON

我的策略是:不管模型返回什么,只截取第一个{到最后一个}之间的内容,把外层的markdown标记、寒暄语全部剥掉:

private static parseRecipes(content: string): Recipe[] {
  let text = content.trim();
  // 剥离```json```包裹、前后寒暄——只留JSON主体
  const start = text.indexOf('{');
  const end = text.lastIndexOf('}');
  if (start >= 0 && end > start) {
    text = text.substring(start, end + 1);
  }
  try {
    const obj = JSON.parse(text) as Record<string, Object>;
    const arr = obj['recipes'] as Object[];
    if (arr === undefined) { return []; }
    const result: Recipe[] = [];
    for (const item of arr) {
      result.push(RecipeAI.toRecipe(item as Record<string, Object>));
    }
    return result;
  } catch (e) {
    return [];   // 解析失败返回空,由上层提示用户重试
  }
}

这个“截取首尾大括号”的技巧很朴素但极其有效,能挡掉绝大多数模型不听话的情况。

逐字段安全提取

即使JSON能解析,也不能假设每个字段都完美存在。所以toRecipe里对每个字段都做了兜底:

private static toRecipe(r: Record<string, Object>): Recipe {
  const ings: Ingredient[] = [];
  const rawIngs = r['ingredients'] as Object[];
  if (rawIngs !== undefined) {
    for (const it of rawIngs) {
      const o = it as Record<string, Object>;
      ings.push({
        name: `${o['name'] ?? ''}`,
        amount: `${o['amount'] ?? ''}`,
        have: o['have'] === true      // 严格判断布尔
      });
    }
  }
  return {
    name: `${r['name'] ?? '未命名菜品'}`,       // 缺失给默认名
    desc: `${r['desc'] ?? ''}`,
    difficulty: `${r['difficulty'] ?? '简单'}`,  // 缺失给默认难度
    minutes: Number(r['minutes'] ?? 20),
    tags: RecipeAI.toStrArr(r['tags']),
    ingredients: ings,
    steps: RecipeAI.toStrArr(r['steps']),
    shopping: RecipeAI.toStrArr(r['shopping'])
  };
}

在这里插入图片描述

每个字段都用?? 默认值兜底,数组字段用统一的toStrArr转换。这样即使模型漏了某个字段,应用也只是显示一个默认值,而不会崩溃。“永远不要相信外部输入”这条工程铁律,在AI应用里要升级为“永远不要完全相信模型输出”。

正是这套“提示词约束+解析容错”的组合拳,让应用能稳定地把AI的输出变成可靠的结构化数据。下图展示了另一组食材(豆腐、面条、米饭、牛肉、胡萝卜)的推荐结果,可以看到即使食材组合完全不同,输出结构依然稳定:

AI 智能菜谱主界面

牛肉胡萝卜炖豆腐、牛肉胡萝卜炒面、牛肉胡萝卜米饭煲——AI很聪明地围绕这几样食材做了不同的组合,每道菜的描述也生动到位(“软嫩豆腐吸饱牛肉与胡萝卜的鲜甜汤汁”)。

生成菜谱加载状态

功能页一:做菜首页

不同食材的推荐结果

首页承担“输入食材→触发生成→展示推荐”的完整流程。

在这里插入图片描述

标签式食材输入

食材用标签(chip)的形式管理,可点击删除,还提供常见食材的快捷添加。核心是一个@State ingredients: string[],配合Flex自动换行布局:

@State ingredients: string[] = ['鸡蛋', '番茄'];   // 内置示例,开箱即用
@State input: string = '';

addIng(): void {
  const v = this.input.trim();
  if (v === '' || this.ingredients.includes(v)) { return; }  // 去重
  this.ingredients.push(v);
  this.ingredients = this.ingredients.slice();   // 触发UI刷新
  this.input = '';
}

这里this.ingredients = this.ingredients.slice()是ArkUI的关键技巧——对@State数组做浅拷贝赋值才能可靠触发重渲染,直接push有时不生效。

UI上,已添加的食材是橙色可删标签,下方是快捷添加区(土豆、青椒、牛肉……点一下即添加)。这种设计降低了输入成本,用户不用一个个手打。

触发生成与结果展示

点击生成按钮,调用RecipeAI.generate,根据返回结果更新UI:

async onGenerate(): Promise<void> {
  if (this.ingredients.length === 0) {
    promptAction.showToast({ message: '请先添加至少一种食材', duration: 1500 });
    return;
  }
  this.loading = true;
  this.results = [];
  const res = await RecipeAI.generate(this.ingredients, this.servings, this.pref);
  this.loading = false;
  if (!res.ok) {
    promptAction.showToast({ message: res.error, duration: 2000 });
    return;
  }
  this.results = res.recipes;
  RecipeSession.setResults(res.recipes);
}

loading状态驱动按钮文案与禁用,results一旦有值就渲染成菜谱卡片列表。成功、失败(网络/解析错误)都有对应处理,不会让用户面对无响应的界面。

功能页二:菜谱卡片与详情页

复用的菜谱卡片组件

推荐列表和收藏列表都要展示菜谱卡片,所以我把它抽成独立组件RecipeCard,通过@Prop接收数据、回调传递点击:

@Component
export struct RecipeCard {
  @Prop recipe: Recipe;
  onTap?: (r: Recipe) => void;
  // ... 展示菜名、简介、耗时、难度、缺料提示
}

卡片上有个体现细节的设计——根据shopping数组动态显示食材状态

if (this.recipe.shopping.length > 0) {
  Text('🛒 缺' + this.recipe.shopping.length + '样')  // 缺料,橙色提示
} else {
  Text('✓ 食材齐全')                                     // 齐全,绿色提示
}

难度也用颜色区分(简单绿、中等黄、困难红),用户扫一眼就能判断这道菜适不适合现在做。

详情页:结构化数据的完整呈现

详情页是“结构化输出”价值的集中体现。因为数据是结构化的,我可以把它渲染成层次分明的几个区块:标题标签区、用料清单、采购清单、分步做法

用料清单里,have字段的价值就体现出来了——已有的食材标绿色✓,需要买的标橙色🛒:

ForEach(this.recipe.ingredients, (ing: Ingredient) => {
  Row() {
    Text(ing.have ? '✓' : '🛒')
      .fontColor(ing.have ? C.accent : C.gold)   // 已有绿/待买橙
    Text(ing.name).layoutWeight(1)
    Text(ing.amount).fontColor(C.textSub)
  }
}, (ing: Ingredient) => ing.name)

制作步骤则用带序号圆标的列表,清晰易读。下图是“番茄炒蛋”的完整详情页:

可以看到,一份原本是AI文本的菜谱,被完整地结构化呈现:15分钟、简单、#快手#下饭#素食标签,用料清单区分了已有的鸡蛋番茄(✓)和需买的盐糖油葱(🛒),下方是“还需购买”的采购清单和四步做法。右上角还能一键收藏。这就是结构化输出相比纯文本的降维打击——数据可以被精确地摆放、着色、交互。

页面间的数据传递

从首页/收藏点击某道菜进入详情,我用一个轻量的RecipeSession单例来传递当前菜谱,避免复杂的路由传参:

export class RecipeSession {
  private static current: Recipe | null = null;
  static setCurrent(r: Recipe): void {
    RecipeSession.current = r;
    AppStorage.setOrCreate('currentRecipeName', r.name);  // 触发详情页@Watch刷新
  }
  static getCurrent(): Recipe | null { return RecipeSession.current; }
}

配合Tab切换(点击卡片切到“菜谱”Tab),实现了流畅的浏览体验。

功能页三、四:收藏与个性化设置

收藏:本地持久化

喜欢的菜谱可以收藏,用Preferences存到本地。RecipeStore封装了收藏的增删查:

static async addFav(r: Recipe): Promise<void> {
  const list = await RecipeStore.listFavorites();
  if (!list.some((x: Recipe) => x.name === r.name)) {   // 去重
    list.unshift(r);
    await RecipeStore.saveFav(list);
  }
}

收藏页展示所有收藏,支持左滑删除。收藏的是完整的菜谱结构,所以离线也能查看已收藏菜谱的完整做法——这是结构化存储的又一好处。

设置:让偏好影响AI

“我的”页最有价值的是饮食偏好功能。用户勾选“清淡少油、无辣、素食、低卡”等标签,或自定义输入,这些偏好会被拼进发给AI的请求里:

const userMsg = `我现在有这些食材:${ingredients.join('、')}。` +
  `用餐人数:${servings}人。` +
  (pref !== '' ? `我的偏好/忌口:${pref}。` : '') +   // 偏好注入
  `请推荐2-3道菜。`;

这样AI推荐就会考虑用户的忌口和口味——素食者不会被推荐红烧肉,怕辣的不会收到麻辣香锅。用一个简单的偏好字段,就让推荐从“通用”变成“个性化”,这正是大模型灵活性的体现。设置页还提供模型服务配置(可运行时覆盖密钥和模型)。下图是设置页:

顶部橙色渐变的“美食家”卡片,下面是饮食偏好标签、智能菜谱服务配置(显示当前模型),整体延续了温暖的美食橙色调。

我的与设置页

应用入口与鸿蒙PC适配

入口初始化

EntryAbilityonWindowStageCreate里初始化存储、配置沉浸式全屏、计算安全区:

async onWindowStageCreate(windowStage: window.WindowStage): Promise<void> {
  await RecipeStore.init(this.context);
  windowStage.loadContent('pages/Index', (err) => {
    if (err.code) { return; }
    const win = windowStage.getMainWindowSync();
    win.setWindowLayoutFullScreen(true);
    const top = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
    const bottom = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR);
    AppStorage.setOrCreate('safeTop', px2vp(top.topRect.height));
    AppStorage.setOrCreate('safeBottom', px2vp(bottom.bottomRect.height));
  });
}

鸿蒙PC上的表现

本文所有截图都是在华为MateBook Pro(鸿蒙PC)上运行的。应用以窗口形式跑在电脑桌面,同一套ArkTS代码无需修改就能适配PC大屏。

能做到这一点,靠的是几个原则:module.json5里声明"deviceTypes": ["phone", "tablet", "2in1"]把PC形态纳入;UI全程用弹性布局(layoutWeight、百分比、Flex换行),不写死尺寸;安全区逻辑统一处理手机刘海与PC窗口边框。

对菜谱这类应用来说,PC大屏其实体验更好:食材输入用物理键盘更顺手,菜谱详情的多区块内容在大窗口里一览无余,做菜时把电脑架在厨房台面上看菜谱也很实用。

四Tab主壳

最后用Tabs把四个页面组织起来。这里有个巧妙设计——“菜谱”Tab就是详情页,从做菜或收藏点击某道菜时,通过回调切到该Tab展示:

Tabs({ barPosition: BarPosition.End, index: this.current }) {
  TabContent() { CookTab({ onOpenDetail: () => { this.current = 1; } }) }
    .tabBar(this.bar('做菜', '🍳', 0))
  TabContent() { DetailPage({ onBack: () => { this.current = 0; } }) }
    .tabBar(this.bar('菜谱', '📖', 1))
  TabContent() { FavTab({ onOpenDetail: () => { this.current = 1; } }) }
    .tabBar(this.bar('收藏', '♥', 2))
  TabContent() { ProfileTab() }.tabBar(this.bar('我的', '👤', 3))
}
.scrollable(false)   // 禁用滑动切换,避免与页面内滚动冲突

踩坑与经验小结

开发过程中的几个关键经验,供参考:

坑一:大模型不老实输出JSON。 这是AI结构化应用最大的坑。解决靠“提示词强约束+解析层截取首尾大括号+逐字段兜底”三重防线,缺一不可。只靠提示词,模型偶尔还是会加markdown包裹;只靠解析,提示词不给力时JSON结构会乱。

坑二:ArkTS严格类型。 所有对象字面量必须有显式interfaceChatRequestRecipeIngredient都得定义好,否则arkts-no-untyped-obj-literals编译不过。这比普通TypeScript严格得多。

坑三:@State数组刷新。 增删食材、更新结果列表时,要this.arr = this.arr.slice()触发刷新。

坑四:AI请求要给足超时。 生成多道完整菜谱比普通问答慢,readTimeout给到40秒,否则容易超时失败。

坑五:网络与解析都要兜底。 把结果统一封装成{ ok, recipes, error },任何失败都返回友好中文提示,绝不让用户对着空白或崩溃的界面。

可扩展方向与总结

这款应用已经是一个四页齐全、可真机运行的完整作品。继续深化的方向:

  1. 食材图像识别:拍一张冰箱照片,用多模态大模型识别出食材,自动填入——彻底免去手动输入。
  2. 营养分析:让模型额外返回每道菜的热量、蛋白质等,做健康管理。
  3. 一键生成采购清单:把多道菜的shopping合并去重,生成一份完整购物清单。
  4. 流式输出:菜谱逐字生成,减少等待焦虑。
  5. 本地菜谱缓存:把生成过的菜谱缓存,相同food组合直接复用,省token。

回顾整个项目,它虽然是个“想吃啥”的小工具,却完整演示了AI应用开发最核心的一课——如何把大模型不确定的自然语言输出,转化为确定的、结构化的、可交互的应用数据。这套“提示词定义schema+解析层容错+结构化渲染”的方法论,适用于几乎所有需要AI生成结构化内容的场景:不只是菜谱,还有行程规划、简历生成、数据报表、表单填充……

如果你也想在HarmonyOS上做一款AI应用,我最想强调的一句话是:大模型给你的是“可能性”,而工程给你的是“确定性”。把提示词写扎实、把解析层做健壮,才能把AI的灵光一现,变成用户手里稳定好用的产品。

希望这篇实战对你有帮助。如果你也在鸿蒙上探索AI应用,欢迎交流。