欢迎光临
我们一直在努力

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

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

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

在这里插入图片描述

在这里插入图片描述

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

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

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

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

考量维度蓝耘 MaaS 的表现为什么重要
协议兼容 OpenAI 兼容 Chat Completions 鸿蒙端用 @kit.NetworkKit 的 http 模块发 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 必须精确到 /v1。DEFAULT_MODEL 用 deepseek-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();
}
}

四个关键设计:

  • max_tokens 从 1024 提到 2048——推理模型会把 token 大半消耗在思维链上,1024 经常导致正文被截断甚至完全为空
  • content 为空时回退到 reasoning_content——deepseek-v4-flash 这类推理模型,短回答场景下正文可能为空、只有思维链
  • Promise.race 超时保护——模拟器网络栈偶发挂起,没有超时保护会永久卡在「思考中」
  • 分层 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_url 和 api_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。原代码有两个问题:

  • reasoning_content 只回调 onReasoning,而 AITab 根本没注册这个回调 → 思维链全部丢弃,fullText 永远为空
  • 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= 日志:完全没有)

    根因:@Component 的 async 方法中,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 面板分别过滤 LanYunAI 和 AITab:

    过滤结果结论
    两层日志都完整 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_content 和 content 分开返回,让产品层可以做「思考中」折叠区。数据管道已预留 onReasoning 回调,后续产品迭代可直接接入。

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

    15.4 成本透明:usage 字段

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


    十六、与其他平台的对比

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

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

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


    十七、总结

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

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

    赞(0)
    未经允许不得转载:171主机测评 » 鸿蒙原生应用开发实战:从零搭建家庭能源管理 App,深度集成蓝耘元生代 MaaS 实现 AI 流式对话
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址