1. 鸿蒙家庭能源 App 的 AI 对话模块到底难在哪家庭能源管理 App 的核心诉求很朴素把每月水电气的读数记下来生成趋势告诉用户哪里能省。但真做起来你会发现纯数字展示对普通用户几乎没有说服力——看到「本月用电 312 度」和「上月 287 度」大多数人只会「哦」一声然后关掉页面。真正有价值的是让 AI 结合家庭人数、季节、历史曲线给出一句人话建议比如「你家三口人本月用电比同户型均值高 18%主要增量在夜间 22 点后建议检查热水器定时」。这就是我在鸿蒙原生应用里做 AI 对话模块的起点。技术栈是 HarmonyOS NEXT ArkTS 严格模式 ArkUI 声明式网络层用kit.NetworkKit的http模块。问题在于鸿蒙端没有现成的 OpenAI SDK所有请求都得自己用http.createHttp()手搓而大模型厂商的接口协议、鉴权方式、流式格式又各不相同。如果每接一家就写一套适配代码维护成本会迅速失控。我的解法是用 TaoToken 做统一 Key 和 API 通道后端对接蓝耘元生代 MaaS。这样鸿蒙端只需要维护一套 HTTP 调用逻辑切换模型只改一个字符串。下面把 config.toml、settings.json 骨架、请求封装片段、流式验证和排错动作完整拆开讲你可以直接照着搭。2. TaoToken 前置统一 Key 与 MaaS 通道准备TaoToken 在这里扮演的是「统一入口」角色你在它这里拿到一个 Key就能通过 OpenAI 兼容协议访问蓝耘元生代 MaaS 上的多个模型。对鸿蒙端来说好处是请求格式统一——都是POST /v1/chat/completions都是Authorization: Bearer key流式都是 SSE。先到 TaoToken 控制台创建 API Key入口在 API Keys 管理页。创建后复制sk-开头的字符串后面配置里会用到。接口基址用https://taotoken.net/api注意这个地址不带任何查询参数直接作为base_url的前缀。注意Key 只应存在于服务端或本地调试环境。鸿蒙客户端代码里硬编码 Key 仅用于演示正式发布必须走你自己的后端中转否则反编译就能拿到。模型侧我选的是蓝耘元生代 MaaS 上的deepseek-v4-flash理由是它在推理质量和响应速度之间平衡得比较好适合能源建议这种需要一点分析但不需要超长推理的场景。如果你要跑更重的分析可以在 TaoToken 的模型对话页先试效果确认后再写进代码。3. 可复制配置config.toml 与 settings.json 骨架鸿蒙工程本身不强制用 toml但我在项目根目录放了一份config.toml作为「人读配置」再用脚本同步到settings.json供构建期读取。这样做的原因是ArkTS 里直接读环境变量不方便而把配置集中在一处能避免 Key 散落在多个 ets 文件里。config.toml骨架如下# config.toml —— 项目级配置构建前同步到 settings.json [app] name HomeEnergy version 2.0.0 bundle com.example.homeenergy [ai] provider taotoken base_url https://taotoken.net/api api_key sk-你的TaoToken密钥 default_model deepseek-v4-flash max_tokens 2048 temperature 0.4 connect_timeout_ms 30000 read_timeout_ms 120000 [ai.models] list [ deepseek-v4-flash, kimi-k2.5, qwen3.6-flash, minimax-m3 ] [log] network_domain 0xA002 ui_domain 0xA001对应的settings.json骨架放在entry/src/main/resources/rawfile/下运行时读取{ ai: { baseUrl: https://taotoken.net/api, apiKey: sk-你的TaoToken密钥, defaultModel: deepseek-v4-flash, maxTokens: 2048, temperature: 0.4, connectTimeout: 30000, readTimeout: 120000, models: [ deepseek-v4-flash, kimi-k2.5, qwen3.6-flash, minimax-m3 ] } }两个文件字段一一对应config.toml给人看和改settings.json给代码读。同步脚本可以用 Node 写一个十几行的转换也可以手动维护——字段不多手动同步反而更可控。4. 请求封装ArkTS 里手搓 OpenAI 兼容调用鸿蒙端没有官方 OpenAI SDK所以封装分两层一层是类型定义一层是请求函数。类型定义必须显式因为 ArkTS 严格模式禁止any和unknown。// common/AiTypes.ets export interface ChatMessage { role: string; content: string; } export interface ChatResponse { choices: ChoiceItem[]; usage?: UsageInfo; } export interface ChoiceItem { message: MessageItem; finish_reason?: string; } export interface MessageItem { role: string; content: string; reasoning_content?: string; } export interface UsageInfo { prompt_tokens: number; completion_tokens: number; total_tokens: number; reasoning_tokens?: number; } export interface StreamDelta { content?: string; reasoning_content?: string; } export interface StreamChoice { delta: StreamDelta; } export interface StreamResponse { choices: StreamChoice[]; } export interface StreamCallbacks { onContent?: (chunk: string) void; onReasoning?: (chunk: string) void; onDone?: (fullText: string) void; onError?: (err: string) void; }非流式请求封装带超时保护和 reasoning 兜底// common/AiClient.ets import { http } from kit.NetworkKit; import { hilog } from kit.PerformanceAnalysisKit; import { ChatMessage, ChatResponse } from ./AiTypes; const TAG AiClient; const DOMAIN 0xA002; const BASE_URL https://taotoken.net/api; const API_KEY sk-你的TaoToken密钥; const DEFAULT_MODEL deepseek-v4-flash; export async function chat( messages: ChatMessage[], model: string DEFAULT_MODEL, maxTokens: number 2048, temperature: number 0.4 ): Promisestring { const client http.createHttp(); hilog.info(DOMAIN, TAG, chat start: model%{public}s, model); const timeoutPromise new Promisestring((_, reject) { setTimeout(() reject(new Error(请求超时(90s))), 90000); }); const requestPromise new Promisestring(async (resolve, reject) { try { const resp await client.request(BASE_URL /v1/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; } 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给到 2048 而不是 1024因为推理模型会把大量 token 花在思维链上给少了正文会被截断甚至完全为空。content为空时回退到reasoning_content保证用户至少能看到内容。Promise.race做超时保护避免模拟器网络抖动时永久卡在「思考中」。resp.result统一用模板字符串转字符串因为它的类型可能是 string、ArrayBuffer 或 Object直接as string在某些场景会拿到[object Object]。流式请求封装这是聊天框体验的关键// common/AiStream.ets import { http } from kit.NetworkKit; import { util } from kit.ArkTS; import { hilog } from kit.PerformanceAnalysisKit; import { ChatMessage, StreamResponse, StreamCallbacks } from ./AiTypes; const TAG AiStream; const DOMAIN 0xA002; const BASE_URL https://taotoken.net/api; const API_KEY sk-你的TaoToken密钥; const DEFAULT_MODEL deepseek-v4-flash; export async function chatStream( messages: ChatMessage[], callbacks: StreamCallbacks, model: string DEFAULT_MODEL, maxTokens: number 2048, temperature: number 0.4 ): Promisevoid { 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; } 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(); }; httpReq.on(dataReceive, (data: ArrayBuffer) { processLines(util.TextDecoder.create(utf-8).decodeToString(new Uint8Array(data))); }); httpReq.on(dataEnd, () { finish(); }); try { const code await httpReq.requestInStream(BASE_URL /v1/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(); } }流式封装里最容易踩的坑是SSE 是长连接服务端推完数据不会主动断开用await http.request()等 SSE 响应会永久阻塞因为request()默认等完整响应体才 resolve。必须用requestInStream()配合on(dataReceive)事件逐块接收。另外dataReceive一次可能只拿到半行 JSON直接JSON.parse会失败所以要用buffer拼接残留按\n切分后再解析。5. 验证请求从 curl 到鸿蒙端流式确认写完封装别急着跑 UI先用 curl 确认 TaoToken 通道和蓝耘元生代 MaaS 是通的curl -s --max-time 30 https://taotoken.net/api/v1/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer sk-你的TaoToken密钥 \ -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 } }看到content有值就说明通道没问题。注意reasoning_content字段——模型先「想」了再「答」这就是思维链usage里也会计入reasoning_tokens。流式验证用 curl 加-N关闭缓冲curl -N --max-time 60 https://taotoken.net/api/v1/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer sk-你的TaoToken密钥 \ -d { model: deepseek-v4-flash, messages: [{role: user, content: 用三句话介绍家庭节能}], stream: true, max_tokens: 256 }你会看到一行行data: {choices:[{delta:{content:...}}]}陆续输出最后以data: [DONE]结束。这就是鸿蒙端chatStream要解析的格式。鸿蒙端验证时在AITab.ets里注册回调并打日志chatStream(messages, { onContent: (chunk: string) { hilog.info(0xA001, AITab, content chunk len%{public}d, chunk.length); }, onReasoning: (chunk: string) { hilog.info(0xA001, AITab, reasoning chunk len%{public}d, chunk.length); }, onDone: (full: string) { hilog.info(0xA001, AITab, stream done, total len%{public}d, full.length); }, onError: (err: string) { hilog.error(0xA001, AITab, stream error: %{public}s, err); } }, deepseek-v4-flash);在 DevEco Studio 的 Log 面板过滤AITab如果能看到连续的content chunk日志且stream done有总长度说明流式链路通了。6. 本篇常见错排查6.1 编译报错 arkts-no-any-unknownJSON.parse()返回anyArkTS 严格模式禁止。修复方式是为解析结果定义显式接口// 错误写法 const json JSON.parse(resp.result as string); return json.choices?.[0]?.message?.content; // 正确写法 const json: ChatResponse JSON.parse(resp.result as string); return json.choices?.[0]?.message?.content ?? ;所有JSON.parse的结果都必须标注接口类型所有导出的常量对象也必须显式声明类型否则会报arkts-no-untyped-obj-literals。6.2 流式请求永久卡住现象是代码停在await http.request()那一行后面的解析逻辑根本没机会执行。根因是request()默认等完整响应体才 resolve而 SSE 是长连接没有「完整」这个概念。修复就是改用requestInStream()加on(dataReceive)事件接收见第 4 节封装。6.3 正文为空但思维链正常现象是reasoning_content有内容content为空气泡永远 loading。根因有两个一是reasoning_content只回调了onReasoning而 UI 层没注册这个回调思维链全丢弃二是max_tokens默认 1024 太小思维链吃掉大半 token正文被挤没。修复是把思维链也累加进fullText作为兜底同时把max_tokens提到 2048。6.4 ArkUI 组件里 await 后续不执行现象是网络层日志完整打到chat done, result len453但 UI 层await之后的日志一行都没有State更新静默失效。根因是Component的async方法中await的续延不保证在 UI 线程执行。修复是去掉async/await改用.then()/.catch()回调chat(messages, model).then((reply: string) { const idx this.bubbles.findIndex(b b.id aiId); if (idx 0) { this.bubbles.splice(idx, 1, { id: aiId, role: assistant, content: reply.length 0 ? reply : [AI 返回为空], loading: false }); this.bubbles [...this.bubbles]; } this.sending false; }).catch((e: Error) { // 错误处理 });6.5 ForEach 复用旧组件不刷新即使State更新了ForEach仍可能复用旧组件不重新渲染。两个修复点一是 key 不能只用b.id要加入loading和内容长度让状态变化时强制重建二是用splice替换元素而不是索引赋值再配合[...this.bubbles]强制新引用// key 加入状态信息 }, (b: Bubble) ${b.id}_${b.loading ? 1 : 0}_${b.content.length})6.6 网络权限缺失module.json5里必须声明ohos.permission.INTERNET否则http.createHttp().request()直接失败{ module: { requestPermissions: [ { name: ohos.permission.INTERNET, reason: $string:reason_internet, usedScene: { abilities: [EntryAbility], when: inuse } } ] } }6.7 分层打日志是定位这类问题的唯一手段网络层和 UI 层用不同的 hilog tag/domain网络层用0xA002AiClientUI 层用0xA001AITab。在 DevEco Studio Log 面板分别过滤一眼就能看出断点在哪一层。如果只在 UI 层打日志看到「没有任何输出」会误判为网络请求没发出如果只在网络层打日志会误判为「数据回来了应该没问题」。两层都打才能快速定位。7. 继续往下走模型切换与长期编码这套封装搭好之后切换模型只需要改LAN_YUN_MODELS[this.currentModel]这一个字符串base_url和api_key全部不变。这就是 TaoToken 统一网关的核心价值——一套代码调多个模型不用为每家厂商写适配层。如果你打算把这个 AI 对话模块长期迭代下去比如加入多轮上下文管理、Markdown 富文本渲染、思维链折叠区建议用 Coding Plan 来管理调用配额和模型路由避免每次调试都手动换 Key。接入过程中遇到鉴权或协议问题可以对照接入文档逐项核对请求头和 body 字段。最后提醒一句演示代码里硬编码 Key 是为了让你快速跑通正式发布前务必把 Key 挪到自己的后端鸿蒙端只请求你的服务端由服务端转发到 TaoToken。这样既安全也方便你在服务端做限流和成本统计。