鸿蒙原生应用开发实战:从零搭建家庭能源管理 App,深度集成蓝耘元生代 MaaS 实现 AI 流式对话

本文记录一个完整的 HarmonyOS 原生应用从架构设计到上线编译的全过程——以「家庭能源统计」为业务载体,深度集成蓝耘元生代 MaaS 平台,实现 AI 能源助手对话、多模型切换、Markdown 富文本渲染与品牌视觉融合。所有代码均通过 DevEco Studio 编译验证。

在这里插入图片描述

在这里插入图片描述

一、为什么要在鸿蒙上接蓝耘 MaaS

我手头有一个家庭能源管理的需求:记录每月水电气读数,生成趋势图,帮用户做节能决策。痛点是——用户看到一堆数字,不知道该怎么优化

传统做法是写死规则引擎:用电超 300 度提示「偏高」。但规则是死的,三口之家和五口之家的「正常用电」完全不同。于是想到接大模型,让 AI 结合上下文给个性化建议。问题来了——在鸿蒙端怎么接?

我选了蓝耘元生代 MaaS:https://maas.lanyun.net/#/model/modelSquare,理由:

考量维度 蓝耘 MaaS 的表现 为什么重要
协议兼容 OpenAI 兼容 Chat Completions 鸿蒙端用 @kit.NetworkKithttp 模块发 POST,零学习成本
模型覆盖 DeepSeek / Kimi / Qwen / GLM / MiniMax 等 50+ 模型 一个 API Key 调所有模型,切换只改一个字符串
流式支持 SSE 逐字返回 聊天框体感丝滑,用户不用干等整段弹出
成本透明 usage 字段含 reasoning_tokens / cached_tokens 移动端流量敏感,能精确追踪 Token 消耗
免费额度 注册即送体验 Token 开发调试阶段不花钱

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

蓝耘 MaaS 把「选模型、调网关、管计费」收成了统一入口,鸿蒙端只需要一套 HTTP 调用代码,就能在后端自由切换所有主流大模型。


二、项目整体架构

2.1 技术栈

平台    : HarmonyOS NEXT (API 12+)
语言    : ArkTS (严格模式)
UI      : ArkUI 声明式
架构    : Stage 模型(UIAbility + 多页面 Tabs)
网络    : @kit.NetworkKit → http.createHttp()
AI后端  : 蓝耘元生代 MaaS (https://maas-api.lanyun.net/v1)
主模型  : deepseek-v4-flash(深度思考模型)
调用方式: 非流式 chat()(模拟器)/ 流式 chatStream()(真机)
渲染    : RichText + Markdown→HTML(AI 回复富文本)
日志    : hilog 分层(网络层 0xA002 / UI 层 0xA001)

2.2 工程目录

entry/src/main/ets/
├── entryability/EntryAbility.ets  ← Stage 模型入口,沉浸式状态栏
├── common/
│   ├── Theme.ets                  ← 蓝耘品牌色系
│   └── LanYunAI.ets               ← 蓝耘 MaaS AI 服务层(核心)
└── pages/
    ├── Index.ets                  ← 主入口,底部五 Tab
    ├── HomeTab.ets                ← 首页:能耗概览 + 蓝耘品牌
    ├── Func1Tab.ets               ← 记录页:录入读数
    ├── Func2Tab.ets               ← 分析页:月度趋势柱状图
    ├── AITab.ets                  ← AI 助手页:多模型对话(核心)
    └── ProfileTab.ets             ← 我的页:蓝耘平台介绍

在这里插入图片描述

架构采用 「数据展示 + AI 增值」 双层:数据层是传统业务逻辑(ArkUI 声明式 UI),AI 层是独立的 AI 助手 Tab。AI 服务层封装为独立模块,业务页面通过 import 调用,解耦干净。AI 功能是增量,不是侵入——蓝耘服务挂了,基础能耗统计照常运行。


三、Stage 模型入口:沉浸式状态栏

// EntryAbility.ets
async onWindowStageCreate(windowStage: window.WindowStage): Promise<void> {
  windowStage.loadContent('pages/Index', (err) => {
    if (err.code) { return; }
    const win = windowStage.getMainWindowSync();
    win.setWindowLayoutFullScreen(true);                    // 1. 全屏沉浸式
    win.setWindowSystemBarProperties({
      statusBarContentColor: '#1C2333'                      // 2. 状态栏文字深色
    });
    // 3. 安全区域存入 AppStorage
    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));
  });
}

三个关键点:setWindowLayoutFullScreen(true) 让蓝耘品牌渐变头部铺满顶部;状态栏文字设为深色适配蓝耘深蓝头部;安全区域存入 AppStorage,每个页面通过 @StorageProp('safeTop') 读取,自动适配不同设备。

在这里插入图片描述


四、蓝耘品牌色系:Theme.ets

export class C {
  static readonly primary: string = '#1B4FCC';      // 蓝耘深蓝(主色)
  static readonly primaryDeep: string = '#0D2E7A';  // 蓝耘墨蓝
  static readonly accent: string = '#3B82F6';       // 蓝耘亮蓝
  static readonly accentCyan: string = '#06B6D4';   // 蓝耘青蓝
  static readonly primarySoft: string = '#DCE7FB';  // 蓝耘浅蓝

  // 蓝耘渐变组合(135° 对角线)
  static readonly gradLanYun: LinearGradient = {
    angle: 135, colors: [['#1B4FCC', 0.0], ['#3B82F6', 0.5], ['#06B6D4', 1.0]]
  };
  static readonly gradLanYunDeep: LinearGradient = {
    angle: 135, colors: [['#0D2E7A', 0.0], ['#1B4FCC', 1.0]]
  };
}

主色 #1B4FCC 用于所有主按钮、选中态、品牌标识。gradLanYun 三段渐变(深蓝→亮蓝→青蓝)用于首页支出卡、AI 发送按钮、用户头像。gradLanYunDeep 墨蓝渐变用于品牌横幅,更沉稳。

在这里插入图片描述

五、蓝耘 MaaS AI 服务层:LanYunAI.ets(核心)

5.1 连接配置

const BASE_URL = 'https://maas-api.lanyun.net/v1';
const API_KEY = 'sk-hfp6wgtzvwfw5wpoj36xcvxrmtokmbhrn7brbgll6bina7i6';
const DEFAULT_MODEL = 'deepseek-v4-flash';

BASE_URL 必须精确到 /v1DEFAULT_MODELdeepseek-v4-flash,适度推理(思考税约 50-60%),速度和质量平衡最好。

安全提醒:演示直接硬编码了 Key。生产环境务必用后端中转,不要把 Key 写进客户端代码。泄露后立即在蓝耘控制台删除重建。

5.2 非流式调用 chat()

export async function chat(messages: ChatMessage[], model: string = DEFAULT_MODEL,
  maxTokens: number = 2048, temperature: number = 0.4): Promise<string> {
  const client = http.createHttp();
  hilog.info(DOMAIN, TAG, 'chat start: model=%{public}s', model);

  // 超时保护:90 秒未返回则主动 reject,防止永久挂起
  const timeoutPromise = new Promise<string>((_, reject) => {
    setTimeout(() => reject(new Error('请求超时(90s)')), 90000);
  });

  const requestPromise = new Promise<string>(async (resolve, reject) => {
    try {
      const resp = await client.request(BASE_URL + '/chat/completions', {
        method: http.RequestMethod.POST,
        header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + API_KEY },
        extraData: JSON.stringify({ model, messages, max_tokens: maxTokens,
                                    temperature, stream: false }),
        connectTimeout: 30000, readTimeout: 60000,
      });
      hilog.info(DOMAIN, TAG, 'responseCode=%{public}d', resp.responseCode);

      if (resp.responseCode !== 200) {
        resolve('[请求失败] 状态码: ' + resp.responseCode);
        return;
      }
      const json: ChatResponse = JSON.parse(`${resp.result}`);
      const msg = json.choices?.[0]?.message;
      if (!msg) { resolve('[蓝耘 AI 返回为空]'); return; }

      // ⚠️ 关键:推理模型正文可能在 content,也可能只有 reasoning_content
      const content = msg.content ?? '';
      const reasoning = msg.reasoning_content ?? '';
      hilog.info(DOMAIN, TAG, 'content len=%{public}d, reasoning len=%{public}d',
        content.length, reasoning.length);
      resolve(content.length > 0 ? content : (reasoning.length > 0 ? reasoning : '[蓝耘 AI 返回为空]'));
    } catch (e) { reject(e); }
  });

  try {
    return await Promise.race([requestPromise, timeoutPromise]);
  } catch (e) {
    return '[请求异常] ' + (e as Error).message;
  } finally {
    client.destroy();
  }
}

四个关键设计:

  1. max_tokens 从 1024 提到 2048——推理模型会把 token 大半消耗在思维链上,1024 经常导致正文被截断甚至完全为空
  2. content 为空时回退到 reasoning_content——deepseek-v4-flash 这类推理模型,短回答场景下正文可能为空、只有思维链
  3. Promise.race 超时保护——模拟器网络栈偶发挂起,没有超时保护会永久卡在「思考中」
  4. 分层 hilog——网络层用独立 domain(0xA002)和 tag(LanYunAI),与 UI 层(tag AITab)隔离,出问题一眼定位是哪一层

resp.result 统一用 `${resp.result}` 转字符串——ArkTS 里它的类型可能是 string / ArrayBuffer / Object,直接 as string 在某些场景会拿到 [object Object]

5.3 流式调用 chatStream()——聊天框的灵魂

⚠️ 血泪教训:SSE 是长连接,服务端推完数据不会主动断开。await http.request() 等 SSE 响应会永久阻塞——request() 默认等完整响应体才 resolve,而 SSE 没有「完整」这个概念。必须用 requestInStream()

export async function chatStream(messages: ChatMessage[], callbacks: StreamCallbacks,
  model: string = DEFAULT_MODEL, maxTokens: number = 2048, temperature: number = 0.4): Promise<void> {
  const httpReq = http.createHttp();
  let fullText = '';
  let buffer = '';
  let done = false;

  const processLines = (text: string) => {
    buffer += text;                          // ⚠️ 跨块缓冲
    const lines = buffer.split('\n');
    buffer = lines.pop() ?? '';              // 最后一行可能不完整,留到下一块
    for (const line of lines) {
      const trimmed = line.trim();
      if (!trimmed.startsWith('data:')) { continue; }
      const dataStr = trimmed.slice(5).trim();
      if (dataStr === '[DONE]') { continue; }
      try {
        const obj: StreamResponse = JSON.parse(dataStr);
        const delta = obj.choices?.[0]?.delta;
        if (!delta) { continue; }
        // 思维链与正文都累加进 fullText,保证极端情况下有内容可展示
        if (delta.reasoning_content) {
          fullText += delta.reasoning_content;
          callbacks.onReasoning?.(delta.reasoning_content);
        }
        if (delta.content) {
          fullText += delta.content;
          callbacks.onContent?.(delta.content);
        }
      } catch (_) { /* 跳过无法解析的行 */ }
    }
  };

  const finish = () => {
    if (done) { return; }
    done = true;
    callbacks.onDone?.(fullText);
    httpReq.off('dataReceive');
    httpReq.off('dataEnd');
    httpReq.destroy();
  };

  // 逐块接收流式数据(只有 requestInStream 才会触发)
  httpReq.on('dataReceive', (data: ArrayBuffer) => {
    processLines(util.TextDecoder.create('utf-8').decodeToString(new Uint8Array(data)));
  });
  httpReq.on('dataEnd', () => { finish(); });

  try {
    // ⚠️ 关键:必须用 requestInStream,不能用 request
    const code = await httpReq.requestInStream(BASE_URL + '/chat/completions', {
      method: http.RequestMethod.POST,
      header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + API_KEY,
                'Accept': 'text/event-stream' },
      extraData: JSON.stringify({ model, messages, max_tokens: maxTokens,
                                  temperature, stream: true }),
      connectTimeout: 30000, readTimeout: 120000,
    });
    if (code !== 200) { callbacks.onError?.('状态码: ' + code); httpReq.destroy(); }
  } catch (e) {
    callbacks.onError?.((e as Error).message);
    httpReq.destroy();
  }
}

三个关键点:

要点 说明
requestInStream() 而非 request() SSE 长连接用 await request() 会永远等不到 resolve
跨块缓冲 buffer 一次 dataReceive 可能只拿到半行 JSON,直接 JSON.parse 会失败;用 buffer 拼接残留
util.TextDecoder ArrayBuffer → UTF-8 字符串,中文不会乱码

SSE 解析:蓝耘 MaaS 流式返回遵循 Server-Sent Events 协议,每行 data: {"choices":[{"delta":{"content":"xxx"}}]},最后 data: [DONE]delta.reasoning_content 是思维链,delta.content 是正文。

为什么 reasoning 和 content 分开回调? 推理模型(如 deepseek-v4-flash)会先「想」再「答」。思维链用户不一定要看见,但平台已计 reasoning_tokens。产品层:onReasoning → 「思考中」折叠区;onContent → 主气泡逐字显示;onDone → 存入对话历史。

但最终我选择了非流式——DevEco 模拟器对 requestInStream 支持不稳定(日志出现 NETSTACK http handover manager init fail)。真机可正常流式,模拟器调试阶段建议降级为 chat() 非流式,减少变量。生产包再切回流式。

在这里插入图片描述

在这里插入图片描述

5.4 回调接口

export interface StreamCallbacks {
  onContent?: (chunk: string) => void;     // 正文片段
  onReasoning?: (chunk: string) => void;   // 思维链片段
  onDone?: (fullText: string) => void;     // 完成
  onError?: (err: string) => void;         // 错误
}

四个回调可选,调用方按需实现。聊天页用全部四个做 UI 更新;后台静默调用只用 onDone

5.5 多模型列表

export const LAN_YUN_MODELS: string[] = [
  'deepseek-v4-flash',                    // 通用首选,适度推理
  '/maas/deepseek-ai/DeepSeek-V3.2',      // 零思考税,高频简单任务
  'kimi-k2.5',                            // 快速响应,零思考税
  'qwen3.6-flash',                        // 强推理(思考税极高,慎用)
  '/maas/zhipuai/GLM-5.2',                // 智谱系
  'minimax-m3',                           // 信息密度高
];

这正是蓝耘统一网关的核心价值——同一个 client,只改 model 字符串就能切换模型,base_urlapi_key 全部不变。

在这里插入图片描述

六、首页 HomeTab:能耗概览 + 蓝耘品牌

首页从上到下:蓝耘 Logo 头部 → 蓝耘品牌横幅(墨蓝渐变)→ 本月支出卡(蓝耘三段渐变)→ 消耗列表 → 蓝耘平台信息卡。

蓝耘平台信息卡展示五大核心特性:多模型统一网关、OpenAI 兼容协议、流式输出、智能路由、成本可控。卡片底部标注 API 端点 https://maas-api.lanyun.net/v1 和协议名称,让用户直观感知 App 背后跑的是蓝耘技术栈。

在这里插入图片描述

七、记录页 Func1Tab:每日读数录入

三个表类型切换按钮(电表/水表/气表),选中态用蓝耘主色 C.primary 填充 + 白字,未选中态白底灰字。大号数字输入框 + 保存按钮(蓝耘深蓝底)。品牌色驱动选中态,让蓝耘视觉渗透到每个交互细节。

在这里插入图片描述

八、分析页 Func2Tab:手写柱状图

年度累计卡用 gradLanYun 蓝耘三段渐变。柱状图纯 ArkUI 手写:数据归一化(柱高 = 实际值 × 120 / 最大值),ForEach 渲染 6 个月,alignItems(VerticalAlign.Bottom) 底部对齐,柱体颜色用蓝耘主色 #1B4FCC

在这里插入图片描述

九、AI 助手页 AITab:聊天核心

9.1 状态设计

@State bubbles: Bubble[] = [];           // 聊天气泡列表
@State sending: boolean = false;          // 防抖标志
@State currentModel: number = 0;          // 模型索引
private systemPrompt: string =
  '你是蓝耘 AI 能源助手,集成在家庭能源管理 App 中。' +
  '你可以帮用户分析家庭用电、用水、燃气消耗数据,提供节能建议。' +
  '回答要简洁实用,使用中文,适当使用 emoji。';

9.2 发送流程

⚠️ 本项目最坑的一个 Bug@Component 里用 async send() + await chat()await 之后的代码不执行。现象是网络层日志完整打印到 chat done, result len=453,但 UI 层 await 之后的日志一行都没有,界面永远停在「思考中」。

ArkUI 组件方法中 await 的续延不保证在 UI 线程执行,@State 更新会静默失效。改用 .then()/.catch() 回调

private send(text: string) {          // ← 注意:不是 async
  if (text.trim().length === 0 || this.sending) { return; }

  // 1. 添加用户气泡
  this.bubbleId++;
  this.bubbles.push({ id: this.bubbleId, role: 'user', content: text.trim(), loading: false });
  this.bubbles = this.bubbles.slice();

  // 2. 添加 AI 气泡(loading)
  this.bubbleId++;
  const aiId = this.bubbleId;
  this.bubbles.push({ id: aiId, role: 'assistant', content: '', loading: true });
  this.bubbles = this.bubbles.slice();
  this.sending = true;
  this.scrollToBottom();

  // 3. 构建上下文(system + 最近 7 条),请求前快照
  const messages: ChatMessage[] = [{ role: 'system', content: this.systemPrompt }];
  const recent = this.bubbles.filter(b => !b.loading && b.content.length > 0).slice(-7);
  for (const b of recent) { messages.push({ role: b.role, content: b.content }); }

  const model = LAN_YUN_MODELS[this.currentModel];
  hilog.info(0xA001, 'AITab', 'send: model=%{public}s, msgs=%{public}d', model, messages.length);

  // 4. 调用蓝耘 MaaS —— 用 .then()/.catch() 而非 await
  chat(messages, model).then((reply: string) => {
    const idx = this.bubbles.findIndex(b => b.id === aiId);
    if (idx >= 0) {
      // ⚠️ 用 splice 替换元素,索引赋值 arr[i] = x 不触发 @State 观察
      this.bubbles.splice(idx, 1, {
        id: aiId, role: 'assistant',
        content: reply.length > 0 ? reply : '[蓝耘 AI 返回为空]',
        loading: false,
      });
      this.bubbles = [...this.bubbles];   // 强制新引用触发渲染
    }
    this.sending = false;
    this.bubbles = [...this.bubbles];
    this.scrollToBottom();
  }).catch((e: Error) => {
    const idx = this.bubbles.findIndex(b => b.id === aiId);
    if (idx >= 0) {
      this.bubbles.splice(idx, 1, {
        id: aiId, role: 'assistant',
        content: '请求失败: ' + e.message + '\n\n请检查网络连接后重试。',
        loading: false,
      });
      this.bubbles = [...this.bubbles];
    }
    this.sending = false;
  });
}

流程:添加用户气泡 → 添加 AI 气泡(loading 显示「蓝耘 AI 思考中…」)→ 构建 system + 最近 7 条上下文 → .then() 里拿到回复后更新气泡并清除 loading。

9.3 气泡 UI 与 Markdown 渲染

用户气泡右对齐,蓝耘深蓝底白字。AI 气泡左对齐,带蓝耘渐变「耘」字头像。loading 且 content 为空时显示 Loading 动画 + 「蓝耘 AI 思考中…」。

AI 回复必须用 RichText 而非 Text——大模型输出大量 Markdown(**加粗**1. 列表),用 Text 会把标记符号原样显示给用户,非常难看。写一个轻量 Markdown → HTML 转换:

/** Markdown → HTML(轻量转换,覆盖 AI 常用格式) */
private mdToHtml(md: string): string {
  let html = md;
  // 先转义 HTML 特殊字符
  html = html.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');          // **加粗**
  html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');                      // *斜体*
  // 有序列表 1. xxx
  html = html.replace(/^(\d+)\.\s+(.+)$/gm,
    '<div style="margin:4px 0;padding-left:4px"><strong>$1.</strong> $2</div>');
  // 无序列表 - xxx / • xxx
  html = html.replace(/^[-•]\s+(.+)$/gm, '<div style="margin:4px 0;padding-left:4px">• $1</div>');
  html = html.replace(/\n/g, '<br/>');                                    // 换行
  return `<body style="font-size:14px;color:${C.text};line-height:22px">${html}</body>`;
}
// 气泡渲染
RichText(this.mdToHtml(b.content)).width('100%')

注意RichText 没有 fontSize / fontColor / lineHeight 属性(编译报错 Property 'fontSize' does not exist on type 'RichTextAttribute')。样式只能通过 HTML 内的 style 内联控制。

9.4 ForEach 的 key 必须随内容变化

即使 @State 更新了,ForEach 仍可能复用旧组件不重新渲染:

// ❌ key 只有 id,内容变了但 key 不变 → ForEach 复用旧组件,UI 不刷新
}, (b: Bubble) => b.id.toString())

// ✅ key 加入 loading 和内容长度 → 状态变化时强制重建
}, (b: Bubble) => `${b.id}_${b.loading ? 1 : 0}_${b.content.length}`)

配合前面的 splice + [...this.bubbles],三重保险确保 UI 一定刷新。

9.5 快捷提问

底部输入栏上方有四个快捷提问按钮:「💡 怎么省电?」「📊 分析我的用电」「🌱 节能建议」「💰 电费计算」,点击直接发送。输入框 placeholder 为「问蓝耘 AI 任何能源问题…」,发送按钮用蓝耘渐变。

在这里插入图片描述
在这里插入图片描述

十、我的页 ProfileTab:蓝耘平台介绍中心

用户卡头像用蓝耘「耘」字 Logo(半透明白底),标注「AI 助手由蓝耘 MaaS 驱动」。页面中央有完整的蓝耘 MaaS 平台介绍卡:平台名称、描述、五大特性(带 ✓ 标记)、协议、API 端点、可用模型数。底部版本号标注「Version 2.0.0 · Powered by 蓝耘元生代 MaaS」。

在这里插入图片描述

十一、踩坑实录:ArkTS 严格模式编译错误

11.1 错误一:arkts-no-any-unknown

ERROR: Use explicit types instead of "any", "unknown"
At File: LanYunAI.ets:55:13

原因JSON.parse() 返回 any 类型,ArkTS 严格模式禁止隐式 any。

修复:为 JSON.parse 结果定义显式接口:

// 修复前
const json = JSON.parse(resp.result as string);
return json.choices?.[0]?.message?.content;

// 修复后
interface ChatResponse { choices: ChoiceItem[] }
interface ChoiceItem { message: MessageItem }
interface MessageItem { content: string }

const json: ChatResponse = JSON.parse(resp.result as string);
return json.choices?.[0]?.message?.content ?? '';

11.2 错误二:arkts-no-untyped-obj-literals

ERROR: Object literal must correspond to some explicitly declared class or interface
At File: LanYunAI.ets:155:29

原因LAN_YUN_INFO 对象字面量没有显式类型标注。

修复:定义接口并显式标注:

export interface LanYunInfo {
  name: string; platform: string; baseUrl: string;
  protocol: string; desc: string; features: string[];
}
export const LAN_YUN_INFO: LanYunInfo = { /* ... */ };

11.3 经验总结

ArkTS 严格模式与普通 TypeScript 最大区别:禁止 any/unknown,禁止未类型化的对象字面量。所有 JSON.parse 结果必须标注接口类型,所有导出常量必须显式声明类型。建议在项目初期就定义好所有数据接口,避免编译时集中报错。

在这里插入图片描述

十二、Debug 实录:AI 回复「一直思考中」的四层排查

这是本项目耗时最久的 Bug:AI 请求明明成功了,界面却永远停在「蓝耘 AI 思考中…」。现象迷惑性极强——蓝耘后台显示调用成功,curl 也没问题,但 App 就是不显示。

最终定位到 四个叠加的坑,逐层剥开才解决。

12.1 第一层:SSE 流式接口用错了 API

现象await http.request() 对 SSE 接口永久阻塞,代码卡在请求那一行,后面解析 data: 的代码根本没机会执行。

根因request() 默认等完整响应体才 resolve,而 SSE 是长连接,服务端推完数据不主动断开,靠 [DONE] 标记结束。

修复:改用 requestInStream() + on('dataReceive') 事件逐块接收(见 5.3)。

12.2 第二层:推理模型的 token 被思维链吃光

现象:思维链正常返回,但正文 content 为空 → 气泡永远 loading

根因deepseek-v4-flash 是深度思考模型,先输出 reasoning_content 再输出 content。原代码有两个问题:

  1. reasoning_content 只回调 onReasoning,而 AITab 根本没注册这个回调 → 思维链全部丢弃,fullText 永远为空
  2. max_tokens 默认 1024 → 思维链吃掉大半 token,正文被挤没,甚至 finish_reason: "length" 时正文完全为空

实测证据(max_tokens=50):50 个 token 全被思维链占用,content 字段全程是 null

修复:思维链也累加进 fullText 作为兜底;max_tokens 提到 2048。

12.3 第三层:ArkUI 组件里 await 后续延不执行 ⭐ 最坑

现象:网络层日志完整打印到 chat done, result len=453,但 UI 层 await 之后的日志一行都没有@State 更新静默失效。

LanYunAI  chat start: model=deepseek-v4-flash
LanYunAI  request sending...
LanYunAI  responseCode=200
LanYunAI  chat done, result len=453     ← 网络层成功
LanYunAI  client destroyed
(AITab 的 reply len= 日志:完全没有)

根因@Componentasync 方法中,await 的续延不保证在 UI 线程执行,导致 @State 更新失效。

修复:去掉 async/await,改用 .then()/.catch()(见 9.2)。

这个坑的隐蔽之处在于:编译不报错、运行不崩溃、网络层一切正常,只是 UI 不更新。如果只在一处打日志,永远定位不到。

12.4 第四层:ForEach 复用旧组件,状态更新了也不重渲染

现象.then()this.bubbles 已经更新(sending 也重置了,否则第二次发送会被拦截),但界面仍显示「思考中」。

根因:两个叠加问题:

  • ForEach 的 key 只用了 b.id,内容变了但 key 不变 → 复用旧组件
  • 索引赋值 this.bubbles[idx] = {...} 不触发 @State 观察

修复:三重保险(见 9.4)

this.bubbles.splice(idx, 1, { ... });    // ① splice 替换,触发 @State
this.bubbles = [...this.bubbles];        // ② 新数组引用,强制刷新
// ③ ForEach key 加入 loading 和内容长度
}, (b: Bubble) => `${b.id}_${b.loading ? 1 : 0}_${b.content.length}`)

12.5 方法论:分层打日志是定位这类问题的唯一手段

这个 Bug 之所以能定位,全靠网络层和 UI 层用不同的 hilog tag/domain

// 网络层 LanYunAI.ets
const TAG = 'LanYunAI';
const DOMAIN = 0xA002;

// UI 层 AITab.ets
hilog.info(0xA001, 'AITab', ...);

在 DevEco Studio Log 面板分别过滤 LanYunAIAITab

过滤结果 结论
两层日志都完整 UI 渲染问题(ForEach / key)
只有 LanYunAI 有日志 await 续延没执行(12.3)
LanYunAI 停在 request sending... 网络层阻塞(12.1)
content len=0 但有 reasoning len 思维链吃光 token(12.2)

教训:如果只在 UI 层打日志,看到「没有任何输出」会误判为网络请求没发出;如果只在网络层打日志,会误判为「数据回来了应该没问题」。两层都打,才能一眼看出断点在哪一层。


十三、网络权限配置

蓝耘 MaaS 调用需要网络权限,在 module.json5 中声明:

{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET",
        "reason": "$string:reason_internet",
        "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
      }
    ]
  }
}

漏配这个权限,http.createHttp().request() 会直接失败,报 undefined 或权限错误。usedScene.when 设为 inuse 表示仅在使用时申请,符合最小权限原则。


十四、蓝耘 MaaS 实测验证

编译通过后,我用 curl 快速验证了蓝耘 API 连通性:

curl -s --max-time 30 https://maas-api.lanyun.net/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer sk-hfp6wgtzvwfw5wpoj36xcvxrmtokmbhrn7brbgll6bina7i6" \
  -d '{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"只回复四个字:蓝耘成功"}],"stream":false,"max_tokens":64}'

返回:

{
  "choices": [{
    "message": {
      "role": "assistant",
      "content": "蓝耘成功",
      "reasoning_content": "我们只需要回复四个字:蓝耘成功。"
    }
  }],
  "usage": {
    "prompt_tokens": 91,
    "completion_tokens": 15,
    "total_tokens": 106
  }
}

API 连通正常,返回「蓝耘成功」。注意 reasoning_content 字段——模型先想了「我们只需要回复四个字」,再输出正文。这就是前面说的思维链,usage 里也会计入 reasoning_tokens

在这里插入图片描述

十五、蓝耘 MaaS 在本项目中的核心价值

回顾整个开发过程,蓝耘元生代 MaaS 在这个鸿蒙项目里承担了四个核心角色:

15.1 统一网关:一套代码调六模型

App 里的 AI 助手支持 6 个模型切换,但整个代码库里只有一套 HTTP 调用逻辑。切换模型只是改 LAN_YUN_MODELS[this.currentModel] 这一个字符串。如果没有统一网关,要对接 6 家不同厂商的 SDK,代码量和维护成本会翻好几倍。

15.2 流式输出:聊天体验的基石

蓝耘 MaaS 的 SSE 流式支持,让 AI 助手的聊天体验从「等 5 秒弹出整段」变成「1 秒后开始逐字显示」。这在移动端尤其重要——用户注意力短,干等超过 3 秒就会觉得卡。

工程建议:真机用 chatStream() 流式;DevEco 模拟器对 requestInStream 支持不稳定(日志会报 NETSTACK http handover manager init fail),调试阶段降级为 chat() 非流式,把网络变量降到最少,确定业务逻辑无误后再切回流式。

15.3 思维链分离:产品可观测性

reasoning_contentcontent 分开返回,让产品层可以做「思考中」折叠区。数据管道已预留 onReasoning 回调,后续产品迭代可直接接入。

但要注意深度思考模型的坑:思维链会消耗大量 token,如果 max_tokens 给小了,正文会被挤没甚至完全为空。本项目 chat() 里做了兜底处理——正文为空时回退展示思维链,保证用户至少能看到内容。

15.4 成本透明:usage 字段

蓝耘 MaaS 的 usage 字段包含 prompt_tokenscompletion_tokensreasoning_tokenscached_tokens,让每次调用的成本可追溯。移动端应用对成本敏感,这些数据可以用来优化 prompt 长度、选择性价比更高的模型。


十六、与其他平台的对比

在选型阶段,我也对比了其他几个方案:

维度 蓝耘 MaaS 直接对接 OpenAI 自建推理服务
接入成本 base_url 即可 需要翻墙/代理 买卡+部署+运维
模型选择 50+ 模型一个 Key 仅 OpenAI 系列 仅自己部署的
国内访问 直连,延迟低 需要代理,不稳定 取决于机房位置
成本 按 Token 计费,有免费额度 按 Token 计费,无免费 固定成本(显卡)
运维 零运维 零运维 高运维

对于鸿蒙端开发者,蓝耘 MaaS 的优势在于国内直连 + OpenAI 兼容 + 多模型统一网关。鸿蒙的 @kit.NetworkKit 不需要额外配置代理,直接 http.createHttp().request() 就能调通,这在开发调试阶段省了大量时间。


十七、总结

这个家庭能源管理 App 从零到编译通过,完整经历了:

  1. Stage 模型搭建:EntryAbility 沉浸式状态栏 + 安全区域适配
  2. 品牌视觉统一:Theme.ets 蓝耘色系,所有页面统一引用
  3. 业务页面开发:首页概览、记录录入、分析图表,纯 ArkUI 声明式
  4. AI 服务层封装:LanYunAI.ets 封装蓝耘 MaaS 非流式/流式调用
  5. AI 助手页面:多模型切换、多轮上下文、Markdown 富文本渲染
  6. 编译排错:ArkTS 严格模式的 any/unknown 和未类型化对象字面量
  7. 运行时 Debug:SSE 长连接阻塞、思维链吃 token、await 续延失效、ForEach 不重渲染
  8. 实测验证:curl 验证 API 连通,DevEco Studio 编译通过

在这里插入图片描述

Logo

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

更多推荐