把 AI 塞进端侧,不靠“玄学”靠工程——HarmonyOS AI Engine 集成实战(框架 → 部署 → 本地推理)
本文分享了鸿蒙端侧AI开发的工程化实践,主要包含三部分内容: AI推理框架的三层结构:运行时引擎(设备选择/内存管理)、模型管理(加载/会话/推理)、业务封装(统一接口); 模型部署规范:定义格式、目录结构、版本校验,提供可复用的ModelManager类(支持预置/下载/校验/加载全流程); 本地推理实现:通过@ohos.ai模块调用核心API,完成从模型加载到推理执行的闭环,强调稳定性与可维护
我是兰瓶Coding,一枚刚踏入鸿蒙领域的转型小白,原是移动开发中级,如下是我学习笔记《零基础学鸿蒙》,若对你所有帮助,还请不吝啬的给个大大的赞~
前言
一句掏心窝的话:端侧 AI 不是“能跑就行”,而是“稳定、可维护、可迁移”。这篇我按你的大纲逐段落地:先讲 AI 推理框架的拼法,再把 模型如何打包/下发/加载 说清,最后用 本地推理接口 写一套“可复制的”最小闭环。文中示例以 ArkTS 为主,接口名围绕你给到的关键点 @ohos.ai 与 ModelManager 展开(不同 SDK/发行版包名可能略有差异,思路与结构一致)。
1|AI 推理框架:把“可用性”拆成三层
端侧 AI 的三层结构(也是我们工程化时的落点):
-
运行时/引擎(Runtime/Engine)
- 负责算子执行、设备选择(CPU/NPU/GPU)、内存管理、并发调度。
- HarmonyOS 下常见以
@ohos.ai模块暴露 API,底层可对接系统 AI 能力或本地推理库。
-
模型管理(ModelManager)
- 负责模型的版本/校验/持久化/热更新;
- 暴露“加载模型→创建会话→执行推理”的核心流程。
-
业务封装(UseCase/Service)
- 把“输入前处理 + 推理 + 后处理”组合成稳定接口,让 UI/业务不感知底层改造(例如从 CPU 切到 NPU)。
口号:把 AI 当“库”,把模型当“资源”,把业务当“流程”。接下来逐层给代码骨架。
2|模型部署:从资源到可调用的“资产”
2.1 选型与规范
- 模型格式:以目标引擎支持的格式为准(例如
.om/.mslite/.tflite等)。 - 输入输出约定:明确 张量名、维度、数据类型(
float32/uint8)、归一化规则。 - 多后端:准备 CPU 默认包,NPU/GPU 可选加速;通过引擎的 device preference 动态选择。
- 版本与校验:
modelId@version+sha256;线上热更必须做完整性校验与回滚。
2.2 目录与落地路径
/entry/src/main/resources/rawfile/ai/
├── classifier_v1.om # 预置模型(可选)
└── labels.txt # 类别文件(可选)
/entry/src/main/ets/ai/
├── model-manager.ts # ModelManager 封装
├── preproc.ts # 前处理(图像/音频…)
└── postproc.ts # 后处理(softmax/NMS…)
# 运行期下载后落到:
appFilesDir/ai/models/{modelId}/{version}/model.om
2.3 ModelManager:下载/校验/落盘/加载
下面是一个可直接复用的 ModelManager,负责模型资产全生命周期。
// ai/model-manager.ts
import fs from '@ohos.file.fs';
import path from '@ohos.file.path';
import crypto from '@ohos.security.cryptoFramework'; // 若无该模块,可在服务端校验或使用自带校验
// 引擎:以 @ohos.ai 为例。你的工程里可能是 @ohos.ai.nn / @ohos.ai.engine 等
import ai from '@ohos.ai';
type DevicePref = 'AUTO' | 'CPU' | 'NPU' | 'GPU';
export type ModelSpec = {
id: string; // 'classifier'
version: string; // '1.0.3'
sha256?: string; // 完整性校验
assetRawName?: string; // 预置 rawfile 名称(如 'ai/classifier_v1.om')
remoteUrl?: string; // 在线更新地址(可选)
input: { name: string; shape: number[]; dtype: 'float32'|'uint8' }[];
output: { name: string; shape: number[]; dtype: 'float32'|'uint8' }[];
};
export class ModelManager {
private ctx: any;
private baseDir: string;
private handle?: any; // 引擎模型句柄
private session?: any; // 推理会话
private usingSpec?: ModelSpec;
constructor(ctx: any) {
this.ctx = ctx;
this.baseDir = path.getFilesDir(ctx) + '/ai/models';
}
private modelPath(spec: ModelSpec) {
return `${this.baseDir}/${spec.id}/${spec.version}/model.om`;
}
async ensurePrepared(spec: ModelSpec): Promise<string> {
const fpath = this.modelPath(spec);
if (await this.exists(fpath)) {
if (!spec.sha256 || await this.verifySha256(fpath, spec.sha256)) return fpath;
// 校验失败则清理
await this.safeRemove(fpath);
}
// 优先使用预置 rawfile 回填
if (spec.assetRawName) {
await this.copyFromRaw(spec.assetRawName, fpath);
if (!spec.sha256 || await this.verifySha256(fpath, spec.sha256)) return fpath;
await this.safeRemove(fpath);
}
// 再尝试远端下载
if (spec.remoteUrl) {
await this.download(spec.remoteUrl, fpath);
if (!spec.sha256 || await this.verifySha256(fpath, spec.sha256)) return fpath;
await this.safeRemove(fpath);
}
throw new Error('MODEL_PREPARE_FAILED');
}
async load(spec: ModelSpec, device: DevicePref='AUTO') {
const fpath = await this.ensurePrepared(spec);
// 1) 加载模型
this.handle?.close?.();
this.handle = await ai.Model.loadFromFile(fpath); // 具体 API 以 SDK 为准
// 2) 会话/上下文
this.session?.close?.();
this.session = await ai.Model.createSession(this.handle, {
devicePreference: device, // AUTO / CPU / NPU / GPU
threads: 2, // CPU 线程数
// 更多后端属性:fp16/int8 优化、内存池等(视 SDK)
});
this.usingSpec = spec;
}
async infer(feeds: Record<string, ArrayBuffer | Float32Array | Uint8Array>) {
if (!this.session || !this.usingSpec) throw new Error('MODEL_NOT_LOADED');
// 构造输入
const inputs = this.usingSpec.input.map(i => ({
name: i.name,
buffer: feeds[i.name],
shape: i.shape,
dtype: i.dtype
}));
const outputs = this.usingSpec.output.map(o => ({ name: o.name }));
// 执行推理
const result = await this.session.run(inputs, outputs);
// result: { [name]: { buffer, shape, dtype } }
return result;
}
close() {
this.session?.close?.();
this.handle?.close?.();
this.session = undefined;
this.handle = undefined;
}
// ---------- 辅助 ----------
private async exists(p: string) {
try { await fs.access(p); return true; } catch { return false; }
}
private async mkdirs(dir: string) {
const parts = dir.split('/'); let cur = '';
for (const p of parts) { if (!p) continue; cur += '/' + p; try { await fs.mkdir(cur); } catch {} }
}
private async copyFromRaw(rawName: string, dst: string) {
const dstDir = dst.substring(0, dst.lastIndexOf('/')); await this.mkdirs(dstDir);
const srcFd = await fs.openRawFileDescriptor(this.ctx, rawName); // 某些模板需改为 resourceManager API
const dstFile = await fs.open(dst, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY);
try {
const buf = new Uint8Array(srcFd.length);
await fs.read(srcFd.fd, buf.buffer);
await fs.write(dstFile.fd, buf.buffer);
} finally {
await fs.close(srcFd.fd);
await fs.close(dstFile);
}
}
private async download(url: string, dst: string) {
const http = (await import('@ohos.net.http')).default;
const client = http.createHttp();
const res = await client.request(url, { method: 'GET', expectDataType: http.HttpDataType.BYTES, readTimeout: 15000 });
client.destroy();
const dstDir = dst.substring(0, dst.lastIndexOf('/')); await this.mkdirs(dstDir);
const f = await fs.open(dst, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY);
try { await fs.write(f.fd, res.result as ArrayBuffer); } finally { await fs.close(f); }
}
private async verifySha256(file: string, hex: string) {
try {
const st = await fs.stat(file);
const f = await fs.open(file, fs.OpenMode.READ_ONLY);
const buf = new Uint8Array(st.size);
await fs.read(f.fd, buf);
await fs.close(f);
// 若你工程没有 cryptoFramework,可在服务端校验;这里仅演示
const dig = await crypto.createDigest('SHA256');
await dig.update(buf.buffer);
const out = await dig.digest();
const cur = Array.from(new Uint8Array(out)).map(b => b.toString(16).padStart(2,'0')).join('');
return cur === hex.toLowerCase();
} catch { return false; }
}
private async safeRemove(p: string) { try { await fs.unlink(p); } catch {} }
}
重点:
ensurePrepared → load → infer三步明确,版本化 + 校验兜底,避免“灰度模型错位”带来的线上事故。
3|AI 推理框架:前处理/后处理 + 设备选择
这部分用图像分类做例子(输入 224×224×3 float32,输出 1×1000 float32 softmax)。
3.1 前处理(以 Bitmap/PixelMap → Float32Array)
// ai/preproc.ts
import image from '@ohos.multimedia.image';
export async function preprocessToFloat32(pm: image.PixelMap, size=224) {
// 1) 缩放到 size×size(若引擎自带 resize,可跳过)
const scaled = await pm.scale({ width: size, height: size }); // 具体 API 以版本为准
const info = await scaled.getImageInfo();
const pixels = new Uint8Array(size * size * 4);
await scaled.readPixelsToBuffer(pixels.buffer); // RGBA
// 2) 转 float32 + 归一化 [0,255]→[-1,1](按模型规范改)
const out = new Float32Array(size * size * 3);
for (let i=0, j=0; i<pixels.length; i+=4, j+=3) {
const r = pixels[i], g = pixels[i+1], b = pixels[i+2];
out[j] = (r/127.5) - 1.0;
out[j+1] = (g/127.5) - 1.0;
out[j+2] = (b/127.5) - 1.0;
}
return out; // NHWC=1×224×224×3 由 infer 时声明 shape
}
3.2 后处理(softmax + Top-K)
// ai/postproc.ts
export function softmax(logits: Float32Array) {
let max = -1e30; for (const v of logits) max = Math.max(max, v);
let sum = 0; const exps = new Float32Array(logits.length);
for (let i=0;i<logits.length;i++) { const e = Math.exp(logits[i]-max); exps[i]=e; sum+=e; }
for (let i=0;i<logits.length;i++) exps[i]/=sum;
return exps;
}
export function topK(prob: Float32Array, k=3) {
const idx = prob.map((v,i)=>[v,i] as [number,number]).sort((a,b)=>b[0]-a[0]).slice(0,k);
return idx.map(([p,i])=>({ index:i, prob:p }));
}
3.3 设备选择(CPU/NPU/GPU)与回落
- 首选 NPU/GPU:低功耗/低时延;
- 不可用时自动回落 CPU:保证功能稳定;
- 前后台策略:前台高性能,后台降频或暂停会话。
// 伪代码:基于引擎的 device capability 做选择(实际 API 以 SDK 为准)
import ai from '@ohos.ai';
export async function pickDevice(): Promise<'NPU'|'GPU'|'CPU'> {
const caps = await ai.getAvailableDevices(); // 返回 ['NPU','CPU'] 等
if (caps.includes('NPU')) return 'NPU';
if (caps.includes('GPU')) return 'GPU';
return 'CPU';
}
4|本地推理接口:把“AI 能力”抽象成稳定服务
4.1 统一服务(ImageClassifier 为例)
// ai/image-classifier.ts
import { ModelManager, ModelSpec } from './model-manager';
import { preprocessToFloat32 } from './preproc';
import { softmax, topK } from './postproc';
import resMgr from '@ohos.resourceManager';
const spec: ModelSpec = {
id: 'classifier',
version: '1.0.3',
assetRawName: 'ai/classifier_v1.om',
// sha256: 'xxxx…', // 正式环境建议开启
input: [{ name: 'input', shape: [1,224,224,3], dtype: 'float32' }],
output: [{ name: 'output', shape: [1,1000], dtype: 'float32' }]
};
export class ImageClassifier {
private mm: ModelManager;
private labels: string[] = [];
private ready = false;
constructor(private ctx: any) {
this.mm = new ModelManager(ctx);
}
async init() {
// 1) 加载标签
this.labels = await this.loadLabels('ai/labels.txt');
// 2) 选择设备并加载模型
const device = 'AUTO' as const; // 也可 pickDevice()
await this.mm.load(spec, device);
this.ready = true;
}
async classify(pixelMap: any) {
if (!this.ready) throw new Error('NOT_READY');
// 前处理
const f32 = await preprocessToFloat32(pixelMap, 224);
const feeds = { input: f32.buffer }; // 也可以直接传 Float32Array
// 推理
const outputs = await this.mm.infer(feeds);
// 取结果
const out = outputs['output'];
const logits = new Float32Array(out.buffer);
const prob = softmax(logits);
const tops = topK(prob, 3).map(x => ({ label: this.labels[x.index] || `#${x.index}`, prob: x.prob }));
return { tops, prob };
}
close() { this.mm.close(); }
private async loadLabels(rawName: string) {
const rm = this.ctx.resourceManager ?? (await resMgr.getResourceManager(this.ctx));
const file = await rm.getRawFileContent(rawName);
const txt = new TextDecoder().decode(file);
return txt.split(/\r?\n/).filter(Boolean);
}
}
4.2 页面侧使用(ArkUI)
// features/ai/ClassifyPage.ets
import image from '@ohos.multimedia.image';
import { ImageClassifier } from '../../ai/image-classifier';
@Component
export struct ClassifyPage {
private ic?: ImageClassifier;
@State private tips: string = '加载中…';
@State private result: Array<{label:string, prob:number}> = [];
async aboutToAppear() {
this.ic = new ImageClassifier(getContext(this));
try {
await this.ic.init();
this.tips = '模型就绪,选择一张图片吧';
} catch (e) {
this.tips = '模型初始化失败';
console.error(JSON.stringify(e));
}
}
async onPickAndRun() {
try {
const pm = await this.pickPhotoPixelMap();
const out = await this.ic!.classify(pm);
this.result = out.tops;
} catch (e) {
this.tips = '推理失败';
}
}
async pickPhotoPixelMap(): Promise<image.PixelMap> {
// 省略相册选择逻辑,得到 PixelMap 即可
// 也可用相机拍照;注意权限与 SystemCapability
throw new Error('implement me');
}
build() {
Column() {
Text(this.tips).margin(12)
Button('选择图片并识别').onClick(()=>this.onPickAndRun())
ForEach(this.result, (r)=> {
Row() {
Text(`${(r.prob*100).toFixed(1)}%`).width(80)
Text(r.label)
}.margin({ top: 8 })
})
}.padding(16)
}
}
5|性能与稳定性:从“能跑”到“跑得稳”
5.1 关键参数与优化
- 设备偏好:
devicePreference= AUTO/NPU/GPU/CPU,用户可切换“性能/省电”档位; - 多线程:CPU 后端可设置
threads(2~4); - 张量复用:复用输入/输出 buffer,减少频繁分配;
- 批量与流式:尽量合并请求(语音/视频流按帧批处理);
- 量化模型:能用 INT8/FP16 就别上 FP32;
- 冷启动:首帧前预热一次
run(dummy 输入),降低首推延迟; - 前后台:后台暂停/降频/关闭会话,回到前台再
load。
5.2 可观测性与回滚
- 埋点:
load 时长 / 首推时长 / 平均时长 / 错误码 / 设备后端占比; - 模型签名:在日志中打印
modelId@version@sha256[:short]; - 回滚:线上下发时保留上一个版本目录;加载失败自动回到上一个可用版本;
- 崩溃兜底:推理出现 native 异常时,立即
close()并切换 CPU。
6|权限与系统能力(SysCap)清单
-
网络(可选):
ohos.permission.INTERNET(若在线下发模型/远程配置); -
相机/相册(可选):拍照/选图需要对应权限;
-
系统能力:检测 AI 能力可用(伪代码)
import systemCapability from '@ohos.systemCapability'; const hasAI = systemCapability.hasSystemCapability('SystemCapability.AI.Engine'); // 以实际名称为准 -
资源读取:
rawfile访问走resourceManager;请勿直接硬编码私有路径到外部分享。
7|常见坑位与排查
- 模型维度不匹配:shape 写错或 NHWC/NCHW 混淆 → 在
infer()前打印输入 shape; - 像素格式混乱:
PixelMap读到 RGBA 却按 RGB 使用 → 前处理明确通道顺序; - 内存暴涨:频繁创建/销毁会话 → 复用
session,页面销毁时统一close(); - NPU 不可用:设备/ROM 不支持或权限受限 → 设备检测 + 自动回落 CPU;
- 热更新失败:sha256 未校验 / 断点续传不完整 → 下发后先校验再替换;
- 首推卡顿:模型懒加载 + 没有预热 →
init完成后做一次 dummy 推理; - 多模型并存:目录没分版本 → 一律
id/version/model.om,旧版本保留以便回滚。
8|把“AI 能力”做成团队资产:可配置 + 可替换
- 多模型注册:
ModelRegistry维护模型清单与默认设备偏好; - 策略切换:灰度开关(例如
useNewModel=true),结合远程配置; - 接口稳定:对 UI 只暴露
run(req): resp;替换模型/后端不影响上层; - 单元测试:前处理/后处理可脱离引擎做纯算法测试;
- 离线包:将默认模型随安装包下发,在线仅做“增量升级”。
9|完整最小 Demo(汇总关键调用顺序)
// 1) 页面 onAppear → 初始化分类器
const ic = new ImageClassifier(getContext(this));
await ic.init(); // 内含:ModelManager.ensurePrepared → load(AUTO)
// 2) 选图 → 前处理 → 推理
const pm = await pickPhotoPixelMap(); // 自行实现
const { tops } = await ic.classify(pm); // preprocess → session.run → softmax/topK
// 3) 展示结果 → 离开页面 close()
ic.close();
10|FAQ
Q1:必须使用 NPU/GPU 吗?
A:不是。CPU 也能跑,只是时延/功耗较高。建议:有就用、没有即回落,保证一致性优先。
Q2:模型要不要量化?
A:优先选 FP16/INT8(若视觉/语音容忍轻微精度损失)。端侧“时延/功耗/体积”收益明显。
Q3:多任务并发推理怎么做?
A:优先排队/池化(会话复用),必要时多会话并发但限制并行度(尤其 CPU)。
Q4:如何做在线下发?
A:远程配置拉取 modelId@version + sha256 + url → 下载到临时目录 → 校验通过再 rename 覆盖 → 生效后上报。
结语
端侧 AI 的“门槛”不在算法,而在工程化的确定性:模型怎么来、怎么验、怎么回滚;设备怎么选、失败怎么退、时延怎么稳。把这篇里的 ModelManager + 会话封装 + 前后处理 三件套塞进你的项目,你的端侧 AI 就不再是“演示级”,而是可上线、可运营的产品能力。
…
(未完待续)
更多推荐




所有评论(0)