☰
鸿蒙原生应用开发实战:从零搭建家庭能源管理 App,用 TaoToken 统一 Key 接入蓝耘元生代 MaaS 流式对话
2026/9/28 4:34:58 网站建设 项目流程

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 ): Promise<string> { const client = http.createHttp(); hilog.info(DOMAIN, TAG, 'chat start: model=%{public}s', model); 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 + '/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 ): 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; } 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-unknown

JSON.parse()返回any,ArkTS 严格模式禁止。修复方式是为解析结果定义显式接口:

// 错误写法 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 len=453,但 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:网络层用0xA002+AiClient,UI 层用0xA001+AITab。在 DevEco Studio Log 面板分别过滤,一眼就能看出断点在哪一层。如果只在 UI 层打日志,看到「没有任何输出」会误判为网络请求没发出;如果只在网络层打日志,会误判为「数据回来了应该没问题」。两层都打,才能快速定位。

7. 继续往下走:模型切换与长期编码

这套封装搭好之后,切换模型只需要改LAN_YUN_MODELS[this.currentModel]这一个字符串,base_url和api_key全部不变。这就是 TaoToken 统一网关的核心价值——一套代码调多个模型,不用为每家厂商写适配层。

如果你打算把这个 AI 对话模块长期迭代下去,比如加入多轮上下文管理、Markdown 富文本渲染、思维链折叠区,建议用 Coding Plan 来管理调用配额和模型路由,避免每次调试都手动换 Key。接入过程中遇到鉴权或协议问题,可以对照接入文档逐项核对请求头和 body 字段。

最后提醒一句:演示代码里硬编码 Key 是为了让你快速跑通,正式发布前务必把 Key 挪到自己的后端,鸿蒙端只请求你的服务端,由服务端转发到 TaoToken。这样既安全,也方便你在服务端做限流和成本统计。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询