鸿蒙 AI 应用开发实战:Core Vision Kit + 多模态大模型 + Intent Framework

本文以一款完整实现的鸿蒙应用「智慧收据管家」为载体,深入拆解应用智能化实践的四个关键维度:系统级 AI 能力接入(Core Vision Kit 端侧 OCR)、云端多模态大模型协同意图框架(Intent Framework 让应用能力被系统语音助手直接调度)、以及面向智能体的 MCP/Skill 协同架构设计。文章包含大量真实可运行的 ArkTS 代码、架构设计考量、一次真实的双引擎精度差异案例分析,以及全流程真机运行截图。

在这里插入图片描述

一、引言:应用智能化,不等于"接个聊天框"

过去一年,“给应用接入 AI"几乎成了移动开发领域最常见的需求。但如果我们冷静审视大多数"智能化改造”,会发现一个普遍现象:很多应用所谓的"智能化",仅仅是在界面里塞进了一个聊天输入框,背后连了某个大模型的对话接口。这当然是智能化的一种形态,但它有一个明显的局限——用户必须主动打开这个应用、找到这个聊天框、用自然语言描述需求,才能触发"智能"。这本质上只是把传统的表单交互,换成了对话式交互,交互的入口和路径并没有真正改变。

鸿蒙生态提出的"应用智能化"命题,指向的是一个更深层的方向:应用的能力应该能被系统级的智能化基础设施发现、理解、调度,而不仅仅是应用内部自己接了个模型。具体来说,这至少包含四个层次:

  1. 系统 AI 能力接入:应用调用鸿蒙系统提供的原生 AI 能力(如 Core Vision Kit 的文字识别),这类能力设备本地就能跑,不依赖网络,也不需要应用自己训练或托管模型;
  2. 云端大模型协同:当系统能力不足以覆盖某些复杂场景时,应用可以按需接入云端大模型(比如多模态模型),作为系统能力的补充,而不是唯一依赖;
  3. 意图框架(Intent Framework):应用把自己的核心能力"翻译"成系统能听懂的标准接口,注册给系统级智能助手,让用户可以用一句话直接触发应用功能,而不需要先打开应用;
  4. 面向智能体的协同架构(MCP/Skill):进一步思考,如果应用的能力要被外部智能体(不仅是系统语音助手,还包括各类 AI Agent、桌面工具)发现和调用,应该如何设计标准化的接口。

这篇文章要做的,就是把这四个层次,落到一个完整、可运行、可复现的真实项目里——「智慧收据管家」。

先看一眼这个应用在 DevEco Studio 中真实运行的样子:

智慧收据管家在 DevEco Studio 中运行,展示拍单识别页面

可以看到,应用运行在 Mate 80 Pro(HarmonyOS 6.1.0) 模拟器上,界面上有"端侧 OCR"与"AI 智能识别"两种识别方式可选,这正是本文要重点拆解的双引擎架构。下面,我们从"为什么要拍小票记账"这个具体场景切入,一层一层拆解它背后的智能化实践。

二、场景选择:为什么是"拍小票记账"

选择"小票 OCR 记账"作为智能化实践的载体,不是随意的决定。这个场景恰好能够同时触及"系统 AI 能力"与"云端大模型"这两条技术路线的边界地带,是一个绝佳的对比实验场:

其一,它有明确的、可离线完成的子任务——从图片里"读出文字",这是计算机视觉里非常成熟的能力,鸿蒙系统本身就内置了对应的 Kit,不需要任何网络请求就能完成,这对应"系统 AI 能力"这条线。

其二,它同时有一个"文字识别"解决不了的子任务——从一堆杂乱的文字里,判断"这到底是买了什么、该归到哪个消费类别"。这是一个语义理解问题,不是简单的文本提取。端侧 OCR 只能给你原始文字,没法帮你判断"金龙鱼食用调和油"应该归为"餐饮"还是"购物"。这个语义层面的短板,恰好是多模态大模型的强项,对应"云端大模型协同"这条线。

其三,记账这个动作有极强的"高频、低认知负担"诉求——用户希望能随口一说"记一下打车35元"就完成记账,而不想每次都打开 App、点几下按钮。这正好对应"意图框架"这条线。

其四,记账数据天然具备被"其他智能体查询/操作"的价值——比如一个通用的个人助理 Agent,可能想知道"这个月花了多少钱",这就引出了"MCP/Skill 协同架构"这条线的讨论。

于是这一个具体场景,天然串起了本文想讲的全部四个层次。下面我们逐一拆解。

在这里插入图片描述

三、第一层:系统 AI 能力接入——Core Vision Kit 端侧 OCR

3.1 为什么优先选系统能力,而不是一上来就接大模型

很多开发者做"图片识别"功能,第一反应是"接一个大模型的视觉理解接口"。但这个思路在工程上有两个明显代价:一是必须联网,离线场景完全不可用;二是每次识别都要把图片传到云端,对于小票、发票这类可能包含消费隐私的图片,这是一个隐私风险点。

鸿蒙系统提供的 Core Vision Kit 恰好补上了这个空白——它内置了通用文字识别(OCR)能力,运行在设备本地,识别过程不需要联网、图片不出设备。对"从图片提取文字"这种相对确定性的任务,系统能力完全够用,没有必要一开始就依赖大模型。这是本文想强调的第一个工程原则:能用确定性强、成本低、隐私性好的系统能力解决的问题,优先用系统能力;大模型应该用来补足系统能力覆盖不到的"模糊地带",而不是替代一切。
在这里插入图片描述

3.2 调用链路的完整实现

系统 OCR 的调用,本质上是"图片 → PixelMap → 文字识别 → 释放资源"这样一条链路。具体实现上,我把它封装在 VisionService 里:

import { textRecognition } from '@kit.CoreVisionKit';
import { image } from '@kit.ImageKit';

export interface OcrResult {
  ok: boolean;
  text: string;
  error: string;
}

export class VisionService {
  /** 把图片 uri 转为 PixelMap(RGBA_8888,OCR 要求) */
  static async uriToPixelMap(uri: string): Promise<image.PixelMap | null> {
    try {
      const source = image.createImageSource(uri);
      const pm = await source.createPixelMap({
        desiredPixelFormat: image.PixelMapFormat.RGBA_8888
      });
      source.release();
      return pm;
    } catch (e) {
      return null;
    }
  }

  /**
   * 对 PixelMap 做通用文字识别,返回完整文本。
   * 使用 Core Vision Kit 的 textRecognition.recognizeText 直接调用,
   * 无需手动创建/释放识别器实例,接口层面已经足够简洁。
   */
  static async recognize(pm: image.PixelMap): Promise<OcrResult> {
    try {
      const visionInfo: textRecognition.VisionInfo = { pixelMap: pm };
      const config: textRecognition.TextRecognitionConfiguration = {
        isDirectionDetectionSupported: false
      };
      const result = await textRecognition.recognizeText(visionInfo, config);
      const fullText: string = result.value;
      if (fullText.trim().length === 0) {
        return { ok: false, text: '', error: '未识别到文字,请重拍或手动输入' };
      }
      return { ok: true, text: fullText, error: '' };
    } catch (e) {
      return { ok: false, text: '', error: '文字识别失败,模拟器可能不支持 OCR,建议真机运行' };
    }
  }
}

这里有两个值得强调的工程细节:

其一,图片必须先转换成 PixelMap 格式(RGBA_8888)才能喂给识别接口,这是因为 Vision Kit 底层处理的是解码后的像素数据,而不是压缩后的图片文件;ImageSource.createPixelMap 这一步做的正是"解压缩+格式转换"。其二,source.release() 必须显式调用——图像资源如果不主动释放,在高频拍照识别场景下容易造成内存堆积,这是做图像处理类功能时容易被忽视的资源管理细节。

3.3 选图/拍照:优先用系统 Picker,不申请完整权限

调用 OCR 之前,得先拿到图片。这里有一个容易被忽视、但对应用隐私体验很重要的设计决策:优先使用系统提供的 Picker 组件(photoAccessHelper.PhotoViewPicker),而不是申请完整的相册读写权限。

static async pickFromGallery(): Promise<string> {
  const phPicker = new photoAccessHelper.PhotoViewPicker();
  const options = new photoAccessHelper.PhotoSelectOptions();
  options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
  options.maxSelectNumber = 1;
  const result = await phPicker.select(options);
  return result.photoUris.length > 0 ? result.photoUris[0] : '';
}

用户在系统提供的选择器界面里选中一张图,应用只拿到这一张图的临时授权 uri,看不到相册里其他任何照片。这种"最小权限"的设计思路,是应用智能化实践里同样重要的一环——智能化不能以牺牲用户隐私边界为代价,这一点在后文讨论多模态大模型时会再次强调。

3.4 结果解析:从原始文本到结构化字段

Core Vision Kit 返回的只是一大段原始文本,要从中提取"金额、商家、日期",需要额外的解析层。我用了一套基于关键词+正则的规则:

/**
 * 解析金额:优先找「合计/应付/实付/总计/金额」关键词所在行,再从中提取数字。
 * 兜底:取全文最后一个带 ¥/¥ 的数字,或最后出现的「数字.数字」。
 */
function parseAmount(lines: string[], raw: string): string {
  const keywords = ['合计', '应付', '实付', '总计', '实收', '小计', '总额', '金额'];
  for (const kw of keywords) {
    const hit = lines.find((l: string) => l.indexOf(kw) >= 0);
    if (hit !== undefined) {
      const num = extractNumber(hit);
      if (num !== '') { return num; }
    }
  }
  const yenMatch = raw.match(/(?:¥|¥)\s*(\d+(?:\.\d{1,2})?)/g);
  if (yenMatch !== null && yenMatch.length > 0) {
    const last = yenMatch[yenMatch.length - 1];
    const n = last.match(/(\d+(?:\.\d{1,2})?)/);
    if (n !== null) { return n[1]; }
  }
  return '';
}

这套"关键词定位 + 多级兜底"的策略,是处理真实世界票据文本的典型思路——因为不同商家、不同票据版式对"总金额"这个字段的措辞千差万别(合计/应付/实付/总计……),单一正则不可能覆盖所有情况,必须设计多层兜底逻辑。这也是本文想强调的第二个工程原则:系统 AI 能力给你的通常是"原始的、非结构化的"输出(这里是一整段文本),从原始输出到业务可用的结构化字段,中间这层解析逻辑往往需要开发者自己精心设计,不能指望系统能力一步到位。

四、第二层:云端大模型协同——用多模态模型补足语义理解的短板

4.1 端侧 OCR 的天花板在哪里

前面的正则解析策略虽然覆盖了大部分常见票据格式,但它有一个无法绕过的天花板:它只能"看文字",看不懂"语义"。举个具体例子——如果一张小票上只写着"金龙鱼食用调和油"和"鲁花花生油"这些商品名,端侧 OCR 加正则解析,永远没办法判断这笔消费应该归为"餐饮"还是"购物"分类,因为正则规则库里没有覆盖"食用油属于哪个消费类别"这种常识性判断。

这正是多模态大模型的用武之地——它不仅能识别图片中的文字,还能理解图片内容的语义,从而做出更接近人类判断的分类。于是我在应用里设计了第二条识别路径:AI 智能识别,接入阿里百炼的 qwen-vl-plus 多模态模型。

4.2 图片如何喂给云端多模态模型

多模态大模型的输入协议和纯文本模型不同——它需要在 content 字段里同时传入图片和文字指令。图片可以用 URL,也可以用 Base64 编码内嵌传输。考虑到我们处理的是用户本地的图片文件(不是已经托管在公网的图片),我选择了 Base64 内嵌的方式:

/** 把本地文件 uri 转为 base64(读文件 -> Uint8Array -> Base64Helper 编码) */
static uriToBase64(uri: string): string {
  const file = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
  try {
    const stat = fileIo.statSync(file.fd);
    const buf = new ArrayBuffer(stat.size);
    fileIo.readSync(file.fd, buf);
    const bytes = new Uint8Array(buf);
    const helper = new util.Base64Helper();
    return helper.encodeToStringSync(bytes);
  } finally {
    fileIo.closeSync(file);
  }
}

这里用到了鸿蒙 @kit.ArkTS 提供的 util.Base64Helper——它是一个纯粹的编解码工具类,encodeToStringSync 接受一个 Uint8Array,同步返回 Base64 字符串。整个文件读取过程用了同步 API(openSync/statSync/readSync),因为小票图片体积通常不大(几百 KB 级别),同步读取不会造成明显的主线程阻塞;finally 块里确保文件句柄一定会被关闭,避免文件描述符泄漏。

拿到 Base64 字符串后,构造成 OpenAI 兼容协议要求的 image_url 格式(Data URL):

const dataUrl = 'data:image/jpeg;base64,' + base64;
const content: ContentPart[] = [
  { type: 'image_url', image_url: { url: dataUrl } },
  { type: 'text', text: '请识别这张消费凭证图片。' }
];

这里的 ContentPart 是我自定义的 interface(严格遵循 ArkTS 的强类型约束),它对应了多模态对话协议里 content 字段可以是一个"混合内容数组"这一事实——一条消息里既可以有图片,也可以有文字。

4.3 提示词设计:让模型只输出可解析的结构化 JSON

和纯文本大模型的应用一样,多模态模型也需要精心设计提示词,约束它只输出结构化数据:

static readonly systemPrompt: string =
  '你是一位专业的财务记账助手,擅长识别各类小票、发票、支付截图。' +
  '用户会发给你一张消费凭证图片,你需要从图片中提取记账所需信息。' +
  '必须严格只输出 JSON,不要输出任何多余文字、解释或 markdown 代码块标记。' +
  'JSON 格式如下:' +
  '{"amount":"金额数字字符串,如 35.00","merchant":"商家/店铺名称",' +
  '"date":"日期,格式 YYYY-MM-DD,无法识别则留空",' +
  '"category":"从[餐饮,交通,购物,娱乐,居住,医疗,其他]中选择最匹配的一项",' +
  '"confidence":"高|中|低,你对这次识别结果的把握程度",' +
  '"reason":"一句话说明你是如何判断出分类的,20字以内"}';

这段提示词里有两个我认为很关键的设计:

其一,要求模型自己给出"置信度"和"判断理由"。 这不是锦上添花的装饰字段,而是一个重要的工程考量——大模型的输出永远存在不确定性,与其让用户盲目相信一个"黑盒结果",不如让模型主动暴露自己的把握程度,把最终判断权交还给用户。 这是"人机协同"而非"机器替代人"的一个具体设计体现。

其二,把分类限定在一个封闭的枚举集合里(“从[餐饮,交通,购物,娱乐,居住,医疗,其他]中选择”),而不是让模型自由发挥。这保证了输出的分类字段永远能被应用里预先定义好的 UI 分类标签正确渲染,不会出现"模型编出一个应用里没有的分类"这种边界情况。

4.4 解析容错:永远不要 100% 信任模型输出

即使提示词写得再明确,大模型偶尔还是会"不听话"——比如在 JSON 外面裹一层解释性文字,或者用 markdown 代码块包裹。解析层必须做容错:

private static parseResult(content: string): BailianResult | null {
  let text = content.trim();
  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>;
    return {
      ok: true,
      amount: `${obj['amount'] ?? ''}`,
      merchant: `${obj['merchant'] ?? ''}`,
      date: `${obj['date'] ?? ''}`,
      category: `${obj['category'] ?? '其他'}`,
      confidence: `${obj['confidence'] ?? '中'}`,
      reason: `${obj['reason'] ?? ''}`,
      error: ''
    };
  } catch (e) {
    return null;
  }
}

"截取首尾大括号"这个技巧能挡掉绝大多数模型输出的markdown包裹问题;每个字段都用 ?? 默认值 兜底,即使某个字段缺失也不会导致整体解析失败。

4.5 一次真实的双引擎精度差异案例——诚实记录一个 Bug

做技术分享,我认为比"展示完美结果"更有价值的,是诚实记录过程中遇到的真实问题。这里有一个值得深入分析的案例。

我用同一张真实小票(乐购生活超市,应付金额 166.77 元)分别测试了两条识别路径。这是小票原图:

测试用的真实小票,应付金额 166.77 元,支付方式微信支付

用"AI 智能识别"路径测试时,结果卡显示识别到的金额是 16677,而不是正确的 166.77

AI 智能识别结果,金额字段显示为 16677,商家与日期识别正确,置信度标记为「高」

这是一个非常有意思的真实案例,值得深入分析背后的原因。多模态大模型在"看图识字"这个环节,本质上是把图像中的视觉模式转换成文本 token,它对"小数点"这种细微的视觉符号,在某些渲染条件下(字体、分辨率、压缩率)识别置信度会低于普通数字字符——小票上的"166.77",小数点本身在票据的热敏打印字体下可能非常小、对比度低,模型有一定概率把它当成噪点忽略,只输出连续的数字序列"16677"。

更值得玩味的是,这次识别的置信度字段依然显示"高"——这说明模型自己对这次误判并不知情,它认为自己识别得很准确。这恰恰印证了前面提到的设计原则的必要性:"置信度"字段是模型的自我评估,不是绝对可靠的正确性保证,最终的把关必须依赖人工审核这个环节。应用里"识别结果均可编辑修正"这个设计,在这个真实案例里体现出了实实在在的价值——用户看到"16677"这个金额和小票不符,可以立刻手动改成"166.77"再确认入账,整个记账流程并不会因为这一次识别偏差而彻底失效。

这个案例也让我更加确信本文反复强调的一个观点:任何一次 AI 能力的接入,都不应该被设计成"自动化黑盒决策链路",而应该被设计成"AI 提供候选建议 + 人工确认"的协同模式,尤其是在记账这种涉及真实财务数据、错误成本不为零的场景里。

五、第三层:意图框架——让应用能力被系统语音助手直接调度

前面两节讨论的都是"应用内部如何调用 AI 能力",这一节要讨论一个更进一步的问题:应用的核心能力,如何被系统级的智能化基础设施感知和调用,而不需要用户先手动打开应用。这正是意图框架(Intent Framework)要解决的问题。

5.1 意图声明:把应用能力"翻译"成系统能听懂的接口

意图框架的核心思路是:应用开发者预先声明"我这个应用具备哪些标准化能力、每个能力需要什么参数",这份声明会被打包进应用配置,系统级的智能助手(如小艺)在解析用户语音指令时,就能查表匹配到对应的应用能力,并把解析出的参数传递过去执行。

在这个项目里,我声明了两个意图:AddRecord(记一笔)和 QueryMonthSpending(查询本月支出)。意图的执行逻辑统一收敛在一个执行器里:

import { InsightIntentExecutor } from '@kit.AbilityKit';
import type insightIntent from '@ohos.app.ability.insightIntent';

export default class ReceiptIntentExecutor extends InsightIntentExecutor {
  async onExecuteInUIAbilityForegroundMode(
    name: string,
    param: Record<string, Object>,
    pageLoader: object
  ): Promise<insightIntent.ExecuteResult> {
    let message = '';
    if (name === 'QueryMonthSpending') {
      message = await this.doQueryMonth();
    } else {
      message = await this.doAdd(param);
    }
    // 结果写入 AppStorage,供主页面 Toast 提示与跳转
    AppStorage.setOrCreate('lastIntentMsg', message);
    AppStorage.setOrCreate('intentTick', Date.now());
    return { code: 0, result: { message: message } } as insightIntent.ExecuteResult;
  }

  private async doAdd(p: Record<string, Object>): Promise<string> {
    const amount = toNumber(p['amount']);
    if (amount <= 0) { return '未识别到金额,记账失败,请说明具体金额'; }
    const rec: ReceiptRecord = {
      id: genId('r'),
      amount: amount,
      merchant: toStr(p['merchant']) !== '' ? toStr(p['merchant']) : '语音记账',
      category: toStr(p['category']) !== '' ? toStr(p['category']) : '其他',
      date: Date.now(),
      source: 'voice',
      rawText: toStr(p['note']),
      createTime: Date.now()
    };
    await ReceiptStore.add(rec);
    return `已为你记一笔:${rec.category} ${amount}`;
  }
}

这段代码里有一个值得关注的架构设计:意图执行器和应用主界面运行在不同的生命周期/进程上下文里,两者之间不能直接调用方法通信,需要借助 AppStorage 这个全局状态容器做跨上下文的信息传递——执行器完成记账后,把提示消息和一个时间戳写入 AppStorage,主界面组件通过监听这些全局状态的变化,间接感知到"刚刚有一次意图执行发生",进而弹出 Toast 提示、跳转到对应页面。这是意图框架场景下一个通用的通信范式。

5.2 应用内模拟:验证意图解析逻辑,不依赖真实语音链路

真实的"对小艺说话触发意图"这条链路,依赖系统级的语音识别、自然语言理解、以及应用在 AppGallery Connect 后台的意图能力审批,这些都超出了单个开发者能在本地完全打通的范围。但这不妨碍我们在应用内部先验证"意图参数解析"这部分核心逻辑是否正确——我做了一个"模拟语音触发"的演示入口:

意图协同页面,展示意图声明与模拟语音触发演示区

用户输入一句自然语言(如"记一下打车35元"),应用本地解析出结构化参数并直接完成记账,账单列表里能看到这条记录的完整来源标注:

账单列表展示一条语音记账记录:交通类别 ¥35.00,来源标注为语音,原始文字为「记一下打车35元」

解析逻辑本身是一套本地的关键词匹配规则,不依赖网络或大模型(因为在语音场景下,端上的解析延迟极其敏感,本地规则解析几乎是零延迟):

export function parseVoice(text: string): VoiceParsed {
  let amount = '';
  const am = text.match(/(\d+(?:\.\d{1,2})?)\s*(元|块|块钱)?/);
  if (am !== null) { amount = am[1]; }

  let category = '';
  const catMap: string[][] = [
    ['打车', '地铁', '公交', '高铁', '停车', '加油', '滴滴', '出租车', '交通'],
    ['午饭', '晚饭', '早饭', '吃饭', '餐', '外卖', '奶茶', '咖啡', '餐饮'],
    // ...更多分类关键词映射
  ];
  const catNames = ['交通', '餐饮', '购物', '娱乐', '居住', '医疗'];
  for (let i = 0; i < catMap.length; i++) {
    for (const kw of catMap[i]) {
      if (text.indexOf(kw) >= 0) { category = catNames[i]; break; }
    }
    if (category !== '') { break; }
  }
  // ... 去除数字与关键词后剩余文本作为商家名
  return { amount, merchant, category, note: '' };
}

这个设计带来一个重要的架构启示:“应用内模拟测试入口” 是意图框架类功能开发中一个非常实用的工程手段——它把"意图参数解析逻辑是否正确"和"系统语音链路是否打通"这两个原本耦合在一起、难以独立调试的问题解耦开了,开发者可以先在自己可控的范围内把核心逻辑验证到位,再去处理平台侧的审批和联调,大大降低了联调成本。

六、第四层:面向智能体的协同架构——MCP/Skill 设计前瞻

前面三层讨论的都是"应用如何被系统级智能助手调用",这一节要讨论一个更开放的问题:如果未来有更广泛的外部智能体(不只是系统语音助手,还包括各类通用 AI Agent、桌面自动化工具)想要调用这个应用的能力,应该怎么设计接口?

这就是当下 AI 生态里越来越受关注的 MCP(Model Context Protocol)与 Skill 协同思路——本质上是把应用能力抽象成一套标准化、自描述的工具接口,让任何遵循这套协议的智能体都能发现并调用。

在应用的"意图"页面里,我用文档化的方式,清晰列出了如果要把这个应用的能力开放给外部智能体,应该暴露哪些接口:

MCP/Skill 协同架构预留设计,展示新增记账与查询本月支出两个标准化接口的入参出参设计

具体的接口设计如下:

// 能力一:新增一笔记账
interface AddRecordInput {
  amount: number;
  merchant?: string;
  category?: string;
  note?: string;
}
interface AddRecordOutput {
  ok: boolean;
  id: string;
  message: string;
}

// 能力二:查询本月支出
interface QueryMonthInput {
  month?: string;   // 'YYYY-MM',默认本月
}
interface QueryMonthOutput {
  total: number;
  count: number;
  byCategory: CatStat[];
}

这里我想强调一个务实的工程判断:这次实践里,我没有强行在移动端跑通一个真实的 MCP Server。原因很直接——MCP 的典型部署形态是一个常驻的网络服务(HTTP/WebSocket 端点),而移动应用是一个生命周期不确定、随时可能被系统回收的前台/后台进程,让一个手机 App 长期扮演"网络服务提供方"的角色,从工程可行性和电量/资源消耗的角度都不是最佳实践。

更合理的落地路径,是把这套接口设计作为"架构契约"沉淀下来,实际部署时通过服务端(比如一个常驻云端服务,同步持有应用的数据)或者鸿蒙系统能力(如 ServiceExtensionAbility,在特定场景下可以提供跨应用调用的服务端点)来实现。 这也是我认为技术分享里应该坦诚的一点:不是所有"面向未来"的架构设计,都需要不计代价地在当前环境里"强行跑通一个演示",清晰、严谨的接口设计文档本身就是一种有价值的工程产出,尤其是当它诚实地标注了"这是设计层面的预留,不是已经落地的功能"时。

同时,接口设计里也刻意包含了鉴权层面的考量说明——“使用 OAuth/设备级令牌,避免敏感财务数据被未授权访问”。这提醒我们:当应用能力被开放给更广泛的外部调用方时,安全边界的设计和接口本身的设计同等重要,尤其是像记账这类涉及真实财务隐私的场景。

七、统计与可视化:让智能化的价值"看得见"

智能化实践如果只停留在"识别准了"这个单点能力,价值感是有限的。真正让用户感知到"这个应用变聪明了"的,往往是把智能化收集来的结构化数据,转化为直观的洞察。

「我的」页面把本月的记账数据,用两种方式可视化呈现——近 7 天支出趋势的柱状图,以及本月分类支出的色块条:

我的页面,展示近7天支出趋势柱状图、本月分类支出占比、以及 AI 智能识别服务的当前模型配置(qwen-vl-plus)

趋势图用纯 ArkUI 组件实现,没有引入任何第三方图表库:

Row({ space: 8 }) {
  ForEach(this.daily, (d: DailyStat) => {
    Column({ space: 4 }) {
      Column()
        .width('100%')
        .height(Math.max(4, d.total / this.maxDaily * 80))   // 按最大值归一化柱高
        .backgroundColor(d.total > 0 ? C.primary : C.stroke)
        .borderRadius(4)
      Text(d.day).fontSize(9).fontColor(C.textDim)
    }.layoutWeight(1).justifyContent(FlexAlign.End).height(100)
  }, (d: DailyStat) => d.day)
}.width('100%').alignItems(VerticalAlign.Bottom)

Columnheight 属性配合归一化计算,就能实现一个简洁的柱状图——这提醒我们,并非所有的数据可视化需求都需要引入重型图表库,ArkUI 的基础布局组件配合简单的数学换算,已经能覆盖相当一部分常见的统计展示场景,这对控制应用体积、减少依赖复杂度是有实际意义的。

八、架构回顾:四层能力如何有机组合

回顾整个应用的架构,四个层次的能力并不是孤立堆砌的,而是形成了一条清晰的能力递进链路:

用户拍照/选图
    │
    ├── 端侧 OCR(Core Vision Kit)── 离线、隐私、只出文本 ──┐
    │                                                          ├──→ 结构化记账数据(人工确认后入库)
    └── 云端多模态识别(阿里百炼)── 联网、懂语义、给分类 ──┘
                                                                       │
                                                          ┌────────────┴────────────┐
                                                          │                          │
                                              意图框架(语音直达触发)      MCP/Skill 协同架构预留
                                              系统语音助手可直接调用          面向外部智能体的能力开放

这条链路体现的核心思想是:智能化不是单一技术点的堆砌,而是"系统能力打底、大模型补齐语义短板、意图框架打通调用入口、开放协议预留生态位"这四层能力的有机组合。每一层都有自己明确的职责边界,也都有自己的局限——系统 OCR 不懂语义、大模型不够确定可靠、意图框架依赖平台审批、MCP 协同需要额外的服务端基础设施。认清每一层的能力边界,而不是指望某一层"包打天下",是做好应用智能化实践的关键心态。

九、工程实践中的关键坑与经验总结

这个项目开发过程中,也踩到并解决了几个值得记录的坑:

坑一:多模态请求体的类型设计。 OpenAI 兼容协议里,content 字段既可以是纯字符串,也可以是一个包含图文混合内容的数组,这在 ArkTS 严格类型系统下需要用联合类型显式表达(content: string | ContentPart[]),不能简单地用 any 或裸对象绕过。

坑二:Base64 编码的同步 API 选择。 对于小票这类不大的图片,用同步文件读取 API(openSync/readSync)比异步 API 更简洁,也不会造成明显的性能问题;但如果处理的是大图片或视频文件,则必须换成异步 API 分片处理,避免阻塞主线程。

坑三:跨执行上下文的通信。 意图执行器和主界面运行在不同的生命周期上下文里,必须通过 AppStorage 这类全局状态容器做信息传递,直接的方法调用在这种架构下是不可行的。

坑四:AI 输出的自我评估不能等同于事实正确性。 前文详细分析的"16677 vs 166.77"案例说明,即使模型给出了"高置信度"的自我评估,也依然可能出现明显错误,产品设计上必须保留人工确认环节。

十、总结:应用智能化的本质是"能力的可发现性与可组合性"

回顾整个实践过程,我认为"鸿蒙应用智能化"这个命题,最终指向的不是"用没用大模型"这个表层问题,而是一个更本质的工程命题:应用的能力,能不能被更广泛的智能化基础设施(系统语音助手、其他智能体、甚至未来的应用)发现、理解和组合调用?

系统 AI 能力(Core Vision Kit)解决的是"确定性任务的低成本、高隐私实现";云端大模型(阿里百炼多模态)解决的是"语义理解这类系统能力覆盖不到的模糊地带";意图框架解决的是"应用能力如何被系统级智能助手发现和调用";MCP/Skill 协同架构解决的是"应用能力如何面向更开放的智能体生态"。这四层能力叠加起来,才构成了一个真正意义上"智能化"的应用,而不只是"内嵌了一个聊天框"的应用。

如果你也在做鸿蒙应用的智能化改造,我想留给你的核心建议是:先分清楚你的问题属于"确定性任务"还是"模糊语义任务",前者优先用系统能力解决,后者才考虑引入大模型;无论用了什么样的 AI 能力,永远为用户保留"确认与修正"的最后一道关卡;最后,认真思考你的应用能力有没有可能被更广泛的智能化生态发现和调用,即使暂时没有条件全部实现,把这套接口设计想清楚、写下来,本身就是有价值的工程资产。
在这里插入图片描述

希望这次实战记录,能给同样在鸿蒙生态里做应用智能化实践的开发者一些具体的参考。如果你对本文的架构设计或实现细节有任何疑问,欢迎交流讨论。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐