【共创季稿事节】HarmonyOS6.1让大模型帮你想「今天吃什么」:一款鸿蒙 AI 智能菜谱应用的结构化输出实战
【共创季稿事节】HarmonyOS6.1让大模型帮你想「今天吃什么」:一款鸿蒙 AI 智能菜谱应用的结构化输出实战
本文完整记录一款「AI 智能菜谱」鸿蒙应用在鸿蒙 PC 上从架构设计、大模型结构化输出、四大功能页实现到真机运行的全过程。重点剖析一个 AI 应用开发中的核心命题——如何让大模型稳定输出结构化 JSON,并把它渲染成精致的原生 UI。含大量可复现的 ArkTS 代码与真机运行截图。


一、从一个日常痛点说起
“今天吃什么?”——这大概是当代人每天最高频、也最令人头疼的问题之一。冰箱里躺着几样食材,却不知道能凑出什么菜;想做点新花样,又懒得翻菜谱 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 对象。
3.1 用提示词定义数据契约
实现这一点的关键,是在系统提示词里把 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。 菜谱推荐希望有一定创意和多样性(同样的食材,多点几次能有不同建议),所以温度设得比问答类应用略高。
3.2 组织请求
有了配置,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 就行。
3.3 发起网络请求
用鸿蒙 @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),遇到上面任何一种情况都会抛异常、整个功能崩溃。所以解析层必须做足容错。
4.1 先提取纯净的 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 []; // 解析失败返回空,由上层提示用户重试
}
}
这个"截取首尾大括号"的技巧很朴素但极其有效,能挡掉绝大多数模型不听话的情况。
4.2 逐字段安全提取
即使 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 很聪明地围绕这几样食材做了不同的组合,每道菜的描述也生动到位(“软嫩豆腐吸饱牛肉与胡萝卜的鲜甜汤汁”)。
五、功能页一:做菜首页
首页承担"输入食材 → 触发生成 → 展示推荐"的完整流程。
5.1 标签式食材输入
食材用标签(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 上,已添加的食材是橙色可删标签,下方是快捷添加区(土豆、青椒、牛肉……点一下即添加)。这种设计降低了输入成本,用户不用一个个手打。
5.2 触发生成与结果展示
点击生成按钮,调用 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 一旦有值就渲染成菜谱卡片列表。成功、失败(网络/解析错误)都有对应处理,不会让用户面对无响应的界面。
六、功能页二:菜谱卡片与详情页

6.1 复用的菜谱卡片组件
推荐列表和收藏列表都要展示菜谱卡片,所以我把它抽成独立组件 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('✓ 食材齐全') // 齐全,绿色提示
}
难度也用颜色区分(简单绿、中等黄、困难红),用户扫一眼就能判断这道菜适不适合现在做。
6.2 详情页:结构化数据的完整呈现
详情页是"结构化输出"价值的集中体现。因为数据是结构化的,我可以把它渲染成层次分明的几个区块:标题标签区、用料清单、采购清单、分步做法。
用料清单里,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 分钟、简单、#快手 #下饭 #素食 标签,用料清单区分了已有的鸡蛋番茄(✓)和需买的盐糖油葱(🛒),下方是"还需购买"的采购清单和四步做法。右上角还能一键收藏。这就是结构化输出相比纯文本的降维打击——数据可以被精确地摆放、着色、交互。
6.3 页面间的数据传递
从首页/收藏点击某道菜进入详情,我用一个轻量的 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),实现了流畅的浏览体验。
七、功能页三、四:收藏与个性化设置
7.1 收藏:本地持久化
喜欢的菜谱可以收藏,用 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);
}
}
收藏页展示所有收藏,支持左滑删除。收藏的是完整的菜谱结构,所以离线也能查看已收藏菜谱的完整做法——这是结构化存储的又一好处。
7.2 设置:让偏好影响 AI
"我的"页最有价值的是饮食偏好功能。用户勾选"清淡少油、无辣、素食、低卡"等标签,或自定义输入,这些偏好会被拼进发给 AI 的请求里:
const userMsg = `我现在有这些食材:${ingredients.join('、')}。` +
`用餐人数:${servings} 人。` +
(pref !== '' ? `我的偏好/忌口:${pref}。` : '') + // 偏好注入
`请推荐 2-3 道菜。`;
这样 AI 推荐就会考虑用户的忌口和口味——素食者不会被推荐红烧肉,怕辣的不会收到麻辣香锅。用一个简单的偏好字段,就让推荐从"通用"变成"个性化",这正是大模型灵活性的体现。设置页还提供模型服务配置(可运行时覆盖密钥和模型)。下图是设置页:

顶部橙色渐变的"美食家"卡片,下面是饮食偏好标签、智能菜谱服务配置(显示当前模型),整体延续了温暖的美食橙色调。
八、应用入口与鸿蒙 PC 适配
8.1 入口初始化
EntryAbility 在 onWindowStageCreate 里初始化存储、配置沉浸式全屏、计算安全区:
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));
});
}
8.2 鸿蒙 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 严格类型。 所有对象字面量必须有显式 interface,ChatRequest、Recipe、Ingredient 都得定义好,否则 arkts-no-untyped-obj-literals 编译不过。这比普通 TypeScript 严格得多。
坑三:@State 数组刷新。 增删食材、更新结果列表时,要 this.arr = this.arr.slice() 触发刷新。
坑四:AI 请求要给足超时。 生成多道完整菜谱比普通问答慢,readTimeout 给到 40 秒,否则容易超时失败。
坑五:网络与解析都要兜底。 把结果统一封装成 { ok, recipes, error },任何失败都返回友好中文提示,绝不让用户对着空白或崩溃的界面。
十一、可扩展方向与总结
这款应用已经是一个四页齐全、可真机运行的完整作品。继续深化的方向:
- 食材图像识别:拍一张冰箱照片,用多模态大模型识别出食材,自动填入——彻底免去手动输入。
- 营养分析:让模型额外返回每道菜的热量、蛋白质等,做健康管理。
- 一键生成采购清单:把多道菜的
shopping合并去重,生成一份完整购物清单。 - 流式输出:菜谱逐字生成,减少等待焦虑。
- 本地菜谱缓存:把生成过的菜谱缓存,相同food组合直接复用,省 token。
回顾整个项目,它虽然是个"想吃啥"的小工具,却完整演示了 AI 应用开发最核心的一课——如何把大模型不确定的自然语言输出,转化为确定的、结构化的、可交互的应用数据。这套"提示词定义 schema + 解析层容错 + 结构化渲染"的方法论,适用于几乎所有需要 AI 生成结构化内容的场景:不只是菜谱,还有行程规划、简历生成、数据报表、表单填充……
如果你也想在 HarmonyOS 上做一款 AI 应用,我最想强调的一句话是:大模型给你的是"可能性",而工程给你的是"确定性"。把提示词写扎实、把解析层做健壮,才能把 AI 的灵光一现,变成用户手里稳定好用的产品。
希望这篇实战对你有帮助。如果你也在鸿蒙上探索 AI 应用,欢迎交流。
更多推荐





所有评论(0)