鸿蒙HarmonyOS NEXT AI对话实战——从0到1用ArkTS接入蓝耘MaaS,给礼物App装上多轮选礼大脑
鸿蒙HarmonyOS NEXT AI对话实战——从0到1用ArkTS接入蓝耘MaaS,给礼物App装上多轮选礼大脑
HarmonyOS · HarmonyOS NEXT · ArkTS · ArkUI · 蓝耘MaaS · 人工智能 · 大模型 · 多轮对话 · Prompt工程 · AI应用开发
| 本文以“送礼不知道怎么选”的真实移动端场景为主线,从零搭建 HarmonyOS NEXT 原生 AI 选礼助手:用 ArkTS/ArkUI 完成聊天气泡、快捷提示词、自动滚动和模型切换;通过蓝耘 MaaS 统一 Chat Completions 接口接入大模型;用 System Prompt、最近对话窗口与字符预算实现连续上下文;再补齐 API Key 安全代理、超时、限流、空响应、重试、资源释放和长对话截断。文中给出可复用工程结构、核心代码、架构图、状态机、排错表和验收用例,既能跑通 Demo,也能按生产思路扩展为礼物推荐、客服、知识问答等对话型应用。 |

开场:送礼最难的不是“搜到商品”,而是把模糊需求聊清楚
“预算 500,给喜欢摄影的女朋友买生日礼物。”
如果这是一个传统推荐页,产品通常会让用户继续勾选年龄、预算、关系、兴趣、场合,再点击一次“生成”。但真实送礼决策很少一次完成:你会先看到几个方向,然后追问“拍立得太普通了”“她已经有相机了”“预算能不能压到 300”“能不能既有仪式感又不占地方”。
这类需求的关键不是单轮生成,而是让应用记得前面已经确认的约束,并在后续追问里继续收敛。也就是说,真正要做的是一个有上下文、有状态、有失败恢复能力的对话系统。
| 先给结论 本文不会把“请求接口成功”当作终点。我们会把 UI 状态、上下文窗口、Prompt 约束、模型热切换、API Key 安全、超时、限流、空响应、重试、资源释放和验收用例一起做完。这样得到的不是一次性 Demo,而是一套可以迁移到客服、知识问答、导购、健康助手等场景的通用对话骨架。 |
你最终会得到什么
| 能力 | 本文实现 | 解决的问题 |
| 原生聊天界面 | ArkUI 气泡、输入框、快捷提示词、自动滚动 | 让交互像聊天,而不是像填表 |
| 多轮上下文 | 固定规则 + 最近消息窗口 + 字符预算 | 让“便宜点”“换一个方向”有语境 |
| 统一模型调用 | 蓝耘 MaaS Chat Completions | 模型切换不改业务调用链 |
| 安全接入 | 客户端不保存 API Key,服务端薄网关转发 | 避免安装包反编译后泄露密钥 |
| 失败闭环 | 超时、401、429、5xx、空回复、重试 | 让异常可定位、可恢复 |
| 可验收 | 覆盖连续追问、切模型、断网、长对话等用例 | 避免只测“成功一次” |
本文目录
- 先把目标想清楚:这不是接一个 API,而是搭一条对话链路
- 版本、环境与可复现边界
- 为什么礼物推荐天然适合多轮对话
- 架构先行:开发可以直连,正式包必须保护 API Key
- 工程目录与网络权限
- 数据模型与请求状态
- Prompt 工程:先追问,再推荐,不让模型乱猜
- 多轮上下文:最近消息不是越多越好
- 封装 MaaS 服务层:把超时、错误和资源释放收口
- 对话 UI:消息气泡、快捷提示词、自动滚动
- 模型热切换:配置驱动,而不是把模型写死
- 首页快捷入口与跨页预填
- 错误恢复:401、429、5xx、空响应分别怎么处理
- 验收用例:连续追问、切模型、断网、长对话都要测
- 性能、成本与隐私:上线前最后三道关
- 进阶升级:流式输出、摘要记忆、结构化礼物卡片
- 完整链路复盘与总结
1. 先把目标想清楚:这不是接一个 API,而是搭一条对话链路
MaaS 平台把大模型调用统一成标准接口之后,网络请求本身反而是最简单的一环。真正决定体验的是前后两端:请求之前如何组织上下文,请求之后如何把结果变成稳定的交互状态。

图 1 生产级 AI 对话调用链路:客户端、上下文、安全网关与模型服务分层
把职责拆开之后,问题会清晰很多:HarmonyOS 只负责用户交互和本地会话状态;上下文构建器负责控制历史窗口;薄网关负责保管 API Key、校验模型和收口错误;蓝耘 MaaS 负责统一模型协议和模型路由。
这种拆分还有一个实际收益:以后从礼物 App 换成客服、课程助手或知识问答,UI 主题和 System Prompt 可以换,底层的请求状态机、上下文策略和安全网关基本不需要推倒重来。
2. 版本、环境与可复现边界
| 项目 | 本文口径 | 说明 |
| 客户端 | HarmonyOS NEXT / Stage 模型 / ArkTS + ArkUI | 使用当前 Network Kit 导入方式 |
| 网络能力 | @kit.NetworkKit 的 http 模块 | 请求结束后显式 destroy 释放资源 |
| 联网权限 | ohos.permission.INTERNET | 必须在 module.json5 声明 |
| 模型协议 | OpenAI 兼容 Chat Completions | 核心字段为 model、messages、max_tokens、temperature |
| MaaS 地址 | https://maas-api.lanyun.net/v1/chat/completions | 具体能力与模型 ID 以控制台为准 |
| 安全方式 | 正式包通过自有网关转发 | 不把 MaaS API Key 编译进客户端 |
| 更新时间 | 2026-09-04 | 版本敏感 API 请以当前官方文档为准 |
蓝耘 MaaS 当前公开页面强调统一 OpenAI 兼容接口、50+ 主流模型以及模型热切换能力。对客户端而言,最有价值的一点是:业务层可以围绕同一套 messages 协议设计,不必为每家模型重写一套 HTTP 代码。
| 边界说明 本文代码重点展示对话工程骨架。模型 ID、计费、上下文上限和可用模型会随平台更新变化,因此不要把某个模型名当作长期常量;应把它放进配置层,并以控制台当前展示为准。 |
3. 为什么礼物推荐天然适合多轮对话

图 2 礼物推荐的本质:不断补充约束、排除选项、解释取舍并继续追问
礼物推荐至少同时受五类变量影响:收礼人关系、送礼场合、预算、兴趣偏好、禁忌或已有物品。用户第一次输入往往只会给出其中一部分。
因此,一个好的选礼助手不应该在信息不足时强行输出十几个“看起来合理”的商品,而应该先判断缺口。比如用户只说“给领导送礼”,助手至少还应追问预算、场合和偏好;如果用户说“预算 300,爸爸喜欢喝茶和钓鱼”,就已经足够进入候选生成。
多轮对话最重要的产品价值,是允许用户用自然语言不断修正约束。第一次说 500 元,第二轮说“太贵了,控制在 300 内”,第三轮再说“不要数码产品”,系统都应该继续沿用“送给谁、什么场合、兴趣是什么”等已确认信息。
4. 架构先行:开发可以直连,正式包必须保护 API Key
| 必须重视的安全问题 不要把蓝耘 MaaS API Key 写进 ArkTS 常量、资源文件或打包配置。移动端安装包最终在用户设备上,任何随包分发的长期密钥都存在被提取和滥用的风险。正式环境应让客户端只访问你自己的业务网关,由服务端通过环境变量保存 MaaS Key。 |
为了不把项目做成重型后端,这里只需要一个“薄网关”:验证请求、注入 System Prompt、限制可用模型和消息长度、转发到 MaaS、把上游错误转换成统一格式。它不需要数据库也能工作。
4.1 先用终端做一次最小连通性测试
在写鸿蒙代码之前,先确认账号、Key、模型 ID 和网络本身能工作。这样如果客户端失败,就不会把问题混在一起排查。
curl -X POST "https://maas-api.lanyun.net/v1/chat/completions" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $LANYUN_API_KEY" \
-d '{
"model": "deepseek-v3.2",
"messages": [
{"role": "system", "content": "你是礼物推荐助手。"},
{"role": "user", "content": "预算500,送给喜欢摄影的女朋友,请给3个方向。"}
],
"max_tokens": 1200,
"temperature": 0.7,
"stream": false
}'
如果模型 ID 在你的控制台中不同,替换 model 即可。这里的目标只有一个:先验证上游服务可用,再进入客户端开发。
4.2 一个足够小、但比直连安全得多的 Node.js 网关
下面示例使用 Node.js + Express。API Key 只从服务端环境变量读取;客户端永远看不到它。示例省略你自己的登录鉴权,正式业务应复用现有用户会话或签名机制。
// server.mjs
import express from 'express';
const app = express();
app.use(express.json({ limit: '64kb' }));
const MAAS_URL = 'https://maas-api.lanyun.net/v1/chat/completions';
const API_KEY = process.env.LANYUN_API_KEY;
const DEFAULT_MODEL = process.env.LANYUN_MODEL || 'deepseek-v3.2';
const ALLOWED_MODELS = new Set([
DEFAULT_MODEL,
// 继续加入你在模型广场中确认可用的模型 ID
]);
const SYSTEM_PROMPT = `
你是一个礼物推荐助手。
目标:根据收礼人关系、场合、预算、兴趣和禁忌,逐步缩小候选。
规则:
1. 信息明显不足时先追问,不要硬猜。
2. 每次推荐优先给 3 个不同方向,而不是堆很多相似商品。
3. 每个方向说明:适合原因、参考预算、可能踩坑、如何进一步个性化。
4. 价格只能作为参考区间,不声称实时库存或实时价格。
5. 用户修改预算、对象或禁忌后,后续回答必须采用新约束。
6. 不主动要求身份证号、精确住址等与送礼无关的敏感信息。
`;
app.post('/api/ai/chat', async (req, res) => {
const { model = DEFAULT_MODEL, messages = [] } = req.body ?? {};
if (!ALLOWED_MODELS.has(model)) {
return res.status(400).json({ code: 'MODEL_NOT_ALLOWED', message: '模型未开放' });
}
if (!Array.isArray(messages) || messages.length === 0) {
return res.status(400).json({ code: 'BAD_MESSAGES', message: '消息不能为空' });
}
const safeMessages = messages
.slice(-12)
.filter(m => ['user', 'assistant'].includes(m?.role))
.map(m => ({ role: m.role, content: String(m.content ?? '').slice(0, 4000) }));
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 85000);
try {
const upstream = await fetch(MAAS_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model,
messages: [{ role: 'system', content: SYSTEM_PROMPT }, ...safeMessages],
max_tokens: 1600,
temperature: 0.7,
stream: false,
}),
signal: controller.signal,
});
const payload = await upstream.json();
if (!upstream.ok) {
return res.status(upstream.status).json({
code: 'UPSTREAM_ERROR',
message: payload?.error?.message || '模型服务调用失败',
});
}
const msg = payload?.choices?.[0]?.message ?? {};
const text = msg.content || msg.reasoning_content || '';
if (!text) {
return res.status(502).json({ code: 'EMPTY_REPLY', message: '模型返回为空' });
}
return res.json({
text,
usage: payload?.usage ?? null,
model,
});
} catch (err) {
const timeout = err?.name === 'AbortError';
return res.status(timeout ? 504 : 502).json({
code: timeout ? 'TIMEOUT' : 'GATEWAY_ERROR',
message: timeout ? '模型响应超时' : '网关请求失败',
});
} finally {
clearTimeout(timer);
}
});
app.listen(3000, () => console.log('AI gateway listening on :3000'));
这层网关还有一个额外价值:以后你更换模型供应商、加入审计、限流、缓存或用户额度,不需要重新发版 App。移动端只依赖你自己的稳定业务协议。
5. 工程目录与网络权限
建议把 UI、上下文、模型配置和网络层拆开。不要把 Prompt、HTTP 请求、消息气泡和页面状态全部塞在一个 AITab.ets 里,否则后面一加重试、流式输出或持久化就会迅速失控。
entry/src/main/ets/
├── common/
│ ├── ai/
│ │ ├── GiftModels.ets // 模型配置
│ │ ├── ContextBuilder.ets // 多轮上下文窗口
│ │ └── GatewayClient.ets // 调用自有网关
│ └── model/
│ └── ChatModels.ets // 消息与响应类型
├── pages/
│ ├── HomeTab.ets // AI 快捷入口
│ └── AITab.ets // 对话页
└── entryability/
└── EntryAbility.ets
server/
└── server.mjs // 安全薄网关
5.1 module.json5 声明网络权限
{
"module": {
// ...
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
}
]
}
}
网络权限没有声明时,后续所有 HTTP 排查都会走错方向。先把权限、设备网络和网关可达性确认好,再看业务代码。
6. 数据模型与请求状态
消息数据最好分成“发给模型的消息”和“页面展示消息”两层。前者只有 role/content;后者还需要 id、loading、error 等 UI 字段。这样不会把 Loading 占位消息误发给模型。
// ChatModels.ets
export type ChatRole = 'user' | 'assistant';
export interface ChatMessage {
role: ChatRole;
content: string;
}
export interface UiMessage extends ChatMessage {
id: number;
loading: boolean;
error?: boolean;
}
export interface TokenUsage {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
}
export interface ChatResult {
text: string;
usage?: TokenUsage;
model?: string;
}
export enum RequestState {
IDLE = 'idle',
SENDING = 'sending',
SUCCESS = 'success',
ERROR = 'error'
}
显式状态的好处是:发送按钮是否可点、Loading 是否显示、失败是否允许重试,都有唯一依据。不要在多个 boolean 之间互相猜状态。
7. Prompt 工程:先追问,再推荐,不让模型乱猜
礼物推荐最常见的问题,不是模型“不够聪明”,而是 Prompt 只写了一句“你是选礼助手”。模型没有被告知什么时候该追问、输出几项、价格如何表述、用户修改约束后如何处理,于是回答很容易漂。
一个可用的 System Prompt 至少应该包含:角色、任务、信息缺口判断、输出结构、约束更新规则、事实边界和隐私边界。上面的网关示例已经把这些规则放在服务端统一管理。
| Prompt 约束 | 为什么需要 | 礼物场景示例 |
| 信息不足先追问 | 减少模型凭空补全 | 只说“送领导”时先问预算与场合 |
| 候选控制为 3 个方向 | 提高信息密度,避免清单泛滥 | 体验型 / 实用型 / 定制型 |
| 说明取舍 | 让用户知道为什么适合 | 占空间、运输、保修、重复购买风险 |
| 价格只给参考区间 | 避免把非实时信息说成实时事实 | “约 300~500 元,以实际渠道为准” |
| 新约束覆盖旧约束 | 保证多轮一致性 | 从 500 改到 300 后不得继续推荐 500 档 |
| 隐私最小化 | 避免无关敏感信息进入模型 | 不需要身份证号、精确住址 |
| 一个非常实用的判断 如果用户给出的信息不足以做出差异化推荐,先问一个最关键的问题,往往比直接生成十个“看起来都能送”的礼物更有价值。 |
8. 多轮上下文:最近消息不是越多越好

图 3 多轮上下文窗口:固定规则、摘要、最近消息和当前问题按优先级装载
最容易写出的版本,是每次都把 messages 全量发送。短对话时能用,但对话越长,prompt_tokens 越大,成本、延迟和上下文噪声一起上升。
更稳的策略是“双限制”:既限制最多保留多少条消息,也限制总字符预算。这样即使某一轮回复特别长,也不会把整个历史毫无边界地塞回去。
// ContextBuilder.ets
import { ChatMessage } from '../model/ChatModels';
export function buildContext(
all: ChatMessage[],
keepLast: number = 10,
maxChars: number = 12000
): ChatMessage[] {
const selected: ChatMessage[] = [];
let used = 0;
for (let i = all.length - 1; i >= 0; i--) {
const msg = all[i];
const text = msg.content.trim();
if (text.length === 0) {
continue;
}
// 当前消息优先,超出预算后停止继续向前装载
if (selected.length > 0 && used + text.length > maxChars) {
break;
}
selected.unshift({ role: msg.role, content: text });
used += text.length;
if (selected.length >= keepLast) {
break;
}
}
return selected;
}
这里故意不用“只取最近 10 条”作为唯一规则。因为 10 条消息可能每条只有几十字,也可能每条几千字。条数限制解决轮数失控,字符预算解决单轮超长,两者叠加更稳。
| 策略 | 优点 | 缺点 | 适用阶段 |
| 全量历史 | 实现最简单 | 成本与噪声持续上升 | 仅适合非常短的 Demo |
| 固定最近 N 条 | 简单、可预测 | 忽略单条长度差异 | 小型应用 |
| N 条 + 字符预算 | 成本边界更清晰 | 仍可能遗忘很早的信息 | 本文默认方案 |
| 摘要 + 最近窗口 | 长对话记忆更稳定 | 需要额外摘要逻辑 | 正式长期会话 |
9. 封装 MaaS 服务层:把超时、错误和资源释放收口
客户端不直接拼 MaaS 鉴权,而是调用自己的网关。这样 ArkTS 只需要关心一个稳定的响应结构:成功拿 text,失败拿 code/message。
// GatewayClient.ets
import { http } from '@kit.NetworkKit';
import { ChatMessage, ChatResult } from '../model/ChatModels';
const GATEWAY_URL = 'https://your-domain.example/api/ai/chat';
interface GatewayPayload {
text?: string;
model?: string;
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
};
code?: string;
message?: string;
}
export async function chat(
messages: ChatMessage[],
model: string
): Promise<ChatResult> {
const client = http.createHttp();
const timeoutPromise = new Promise<ChatResult>((_, reject) => {
setTimeout(() => reject(new Error('TIMEOUT')), 90000);
});
const requestPromise = new Promise<ChatResult>(async (resolve, reject) => {
try {
const options: http.HttpRequestOptions = {
method: http.RequestMethod.POST,
header: {
'Content-Type': 'application/json'
// 如业务已有用户登录,在这里带你自己的短期用户令牌。
},
extraData: JSON.stringify({
model,
messages
}),
expectDataType: http.HttpDataType.STRING,
connectTimeout: 20000,
readTimeout: 85000
};
const response = await client.request(GATEWAY_URL, options);
const body = JSON.parse(`${response.result}`) as GatewayPayload;
if (response.responseCode < 200 || response.responseCode >= 300) {
reject(new Error(body.code ?? `HTTP_${response.responseCode}`));
return;
}
const text = (body.text ?? '').trim();
if (text.length === 0) {
reject(new Error('EMPTY_REPLY'));
return;
}
resolve({
text,
usage: body.usage,
model: body.model
});
} catch (e) {
reject(e);
}
});
try {
return await Promise.race([requestPromise, timeoutPromise]);
} finally {
// 每个 HttpRequest 对象用完必须释放,避免长时间聊天产生资源堆积。
client.destroy();
}
}
这段封装重点不是“能 POST”,而是四个工程细节:网络层统一入口、明确超时、非 2xx 单独处理、finally 释放 HttpRequest。
另外,网关已经把上游可能出现的 content / reasoning_content 差异归一成 text,客户端不再绑定某一类模型的响应细节。以后换模型或做路由时,这个收益会非常明显。
10. 对话 UI:消息气泡、快捷提示词、自动滚动

图 4 对话页交互结构:聊天气泡、模型选择、快捷提示词、输入区与失败恢复入口
10.1 页面状态
@State private messages: UiMessage[] = [];
@State private inputText: string = '';
@State private requestState: RequestState = RequestState.IDLE;
@State private currentModelIndex: number = 0;
private messageId: number = 0;
private scroller: Scroller = new Scroller();
10.2 气泡要把“内容状态”和“请求状态”区分开
@Builder
MessageBubble(m: UiMessage) {
Row() {
if (m.role === 'assistant') {
Column() {
if (m.loading) {
Row({ space: 8 }) {
LoadingProgress().width(18).height(18)
Text('正在整理礼物方案…').fontSize(13)
}
.padding(12)
.backgroundColor('#EEF5FF')
.borderRadius(16)
} else {
Text(m.content)
.fontSize(14)
.lineHeight(23)
.fontColor(m.error ? '#A33A3A' : '#24384B')
.padding(12)
.backgroundColor(m.error ? '#FFF0F0' : '#EEF5FF')
.borderRadius(16)
}
}.constraintSize({ maxWidth: '82%' })
Blank().layoutWeight(1)
} else {
Blank().layoutWeight(1)
Text(m.content)
.fontSize(14)
.lineHeight(23)
.fontColor('#FFFFFF')
.padding(12)
.backgroundColor('#F28A3A')
.borderRadius(16)
.constraintSize({ maxWidth: '82%' })
}
}
.width('100%')
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
}
移动端最容易忽略的是宽度上限。模型一旦输出长段落,气泡如果没有 maxWidth,很快就会撑满整行,阅读体验会明显下降。
10.3 发送流程:先落用户消息,再放 Loading 占位
private async doSend(raw: string): Promise<void> {
const text = raw.trim();
if (text.length === 0 || this.requestState === RequestState.SENDING) {
return;
}
this.requestState = RequestState.SENDING;
this.inputText = '';
const userMsg: UiMessage = {
id: ++this.messageId,
role: 'user',
content: text,
loading: false
};
this.messages.push(userMsg);
const aiId = ++this.messageId;
this.messages.push({
id: aiId,
role: 'assistant',
content: '',
loading: true
});
this.messages = [...this.messages];
this.scrollToBottom();
const history: ChatMessage[] = this.messages
.filter(m => !m.loading && !m.error && m.content.length > 0)
.map(m => ({ role: m.role, content: m.content }));
try {
const context = buildContext(history, 10, 12000);
const model = GIFT_MODELS[this.currentModelIndex].id;
const result = await chat(context, model);
this.replaceMessage(aiId, {
id: aiId,
role: 'assistant',
content: result.text,
loading: false
});
this.requestState = RequestState.SUCCESS;
} catch (e) {
this.replaceMessage(aiId, {
id: aiId,
role: 'assistant',
content: this.friendlyError(e as Error),
loading: false,
error: true
});
this.requestState = RequestState.ERROR;
}
this.scrollToBottom();
}
private replaceMessage(id: number, next: UiMessage): void {
const index = this.messages.findIndex(m => m.id === id);
if (index >= 0) {
this.messages.splice(index, 1, next);
this.messages = [...this.messages];
}
}
private scrollToBottom(): void {
setTimeout(() => {
if (this.messages.length > 0) {
this.scroller.scrollToIndex(
this.messages.length - 1,
true,
ScrollAlign.END
);
}
}, 0);
}
这个顺序很重要:先把用户消息落到 UI,再立即插入 AI Loading,占位消息拥有稳定 id;请求结束后只替换这一条。这样不会因为并发更新把前面的气泡改错。
同时用 requestState === SENDING 阻止重复点击,可以避免用户连续点两次发送导致同一句话并发请求。
11. 模型热切换:配置驱动,而不是把模型写死
MaaS 的价值之一是用统一协议调用不同模型。但“热切换”不应该变成页面里散落的 if/else。把模型 ID、显示名和用途说明收进一个配置数组即可。
// GiftModels.ets
export interface GiftModel {
id: string;
name: string;
tip: string;
}
export const GIFT_MODELS: GiftModel[] = [
{
id: 'deepseek-v3.2',
name: '均衡模型',
tip: '复杂选礼、需要解释取舍时优先'
},
{
id: 'deepseek-v3',
name: '快速模型',
tip: '短问答、贺卡文案、快速补充建议'
}
// 继续加入你在模型广场确认可用的 Qwen / Kimi / GLM / MiniMax 等模型 ID
];
UI 上最好展示“均衡模型 / 快速模型”这类用户能理解的用途,而不是直接扔一串内部模型 ID。真正的 ID 可以作为次级信息或调试信息。
切模型时不要清空当前 messages。用户切换模型通常是在当前上下文中尝试另一种回答风格;如果切换就丢失历史,体验会像突然失忆。
12. 首页快捷入口与跨页预填
对话产品最怕“空白输入框”。首页给几个高频入口,可以显著降低第一次开口的成本。
| 快捷场景 | 预填内容 | 为什么有效 |
| 送女友 | 预算、生日、兴趣偏好 | 把最常见约束直接带入 |
| 送父母 | 预算、生活习惯、实用偏好 | 避免只推荐“保健品”这类泛答案 |
| 朋友结婚 | 预算、关系亲密度、是否送红包 | 适合做组合方案 |
| 写贺卡 | 关系、场合、语气 | 快速生成配套文案 |
| 预算规划 | 多人、多场合、总预算 | 适合让模型做约束分配 |
// HomeTab.ets
function goAIWithPrompt(prompt: string): void {
AppStorage.setOrCreate('giftAiPresetPrompt', prompt);
AppStorage.setOrCreate('currentTab', 1);
}
// AITab.ets
@StorageLink('giftAiPresetPrompt')
private presetPrompt: string = '';
aboutToAppear(): void {
if (this.presetPrompt.trim().length > 0) {
this.inputText = this.presetPrompt;
this.presetPrompt = '';
}
}
这里的关键不是 AppStorage 本身,而是把“发现入口”和“深度对话”串成一条路径:用户在首页点场景,进入对话页时输入框已经准备好,只需要补充个性化信息或直接发送。
13. 错误恢复:401、429、5xx、空响应分别怎么处理

图 5 请求状态机:空闲、发送中、成功、失败与重试形成闭环
聊天应用一定会遇到失败。真正影响体验的不是“有没有失败”,而是失败发生后用户是否知道发生了什么,以及能不能继续。
| 现象 / 状态 | 常见原因 | 客户端表现 | 处理方式 |
| 401 / 403 | 服务端 Key 无效、权限或账户问题 | 显示“服务配置异常” | 不要把上游原始鉴权信息直接暴露给用户;检查网关环境变量 |
| 429 | 速率或额度限制 | 提示“请求过于频繁” | 禁用立即连点,稍后重试;服务端可做指数退避或排队 |
| 5xx | 上游模型或网关暂时异常 | 保留用户原消息 | 提供原地重试,不要求用户重新输入 |
| 超时 | 模型耗时、网络抖动 | Loading 结束并明确超时 | 90 秒兜底,允许重试或切换较快模型 |
| 空响应 | 模型返回结构异常或内容为空 | 显示可恢复错误气泡 | 网关统一 content / reasoning_content,再做非空校验 |
| JSON 解析失败 | 网关异常页、代理改写 | 统一“响应格式异常” | 记录 requestId 与状态码,避免把 HTML 错误页当 JSON |
| 重复点击 | 发送按钮未锁定 | 出现重复用户消息 | SENDING 状态期间禁止再次发送 |
| 长对话变慢 | 历史上下文持续膨胀 | 回复越来越慢 | 最近 N 条 + 字符预算;长期会话加摘要 |
13.1 友好错误映射
private friendlyError(error: Error): string {
const code = error.message;
if (code === 'TIMEOUT') {
return '这次思考时间有点长,已停止等待。你可以重试,或切换到更快的模型。';
}
if (code === 'EMPTY_REPLY') {
return '模型这次没有返回有效内容,保留当前对话后可直接重试。';
}
if (code.includes('429')) {
return '请求有点密集,请稍后再试。';
}
if (code.includes('401') || code.includes('403')) {
return 'AI 服务暂时不可用,请稍后再试。';
}
return '网络或模型服务出现异常,本轮消息已保留,可以直接重试。';
}
错误文案要面向用户,不要把内部 Key、完整上游堆栈、代理地址或服务端实现细节直接展示在气泡里。详细信息应该进入服务端日志,并通过 requestId 定位。
14. 验收用例:连续追问、切模型、断网、长对话都要测
AI 应用最容易出现一种错觉:第一次问答成功,就以为功能完成了。真正需要验证的是状态连续性和失败恢复。
| 用例 | 输入 / 操作 | 预期结果 |
| 首次信息不足 | “给朋友送礼,帮我推荐” | 先追问预算、场合或偏好,而不是直接堆清单 |
| 首次信息充分 | “预算500,女朋友生日,喜欢摄影和手账” | 给少量差异化方向,并说明理由与参考预算 |
| 预算收紧 | 第二轮:“太贵了,控制在300以内” | 保留对象与兴趣,只更新预算 |
| 排除已拥有物品 | 第三轮:“她已经有相机了” | 后续不再把相机本体作为主要推荐 |
| 改变场合 | “其实是周年纪念,不是生日” | 新场合覆盖旧场合,推荐逻辑随之变化 |
| 模型切换 | 对话中切换模型后继续问 | 保留同一份历史,不清空上下文 |
| 重复点击 | 发送时快速连点两次 | 只产生一条用户消息和一次请求 |
| 断网 / 超时 | 请求过程中断网或模拟超时 | Loading 正常结束,出现可重试错误,不丢用户输入 |
| 上游限流 | 网关返回 429 | 友好提示并允许稍后重试 |
| 长对话 | 持续 20+ 轮并穿插长回复 | buildContext 自动截断,历史不无限增长 |
| 空响应 | 网关返回 EMPTY_REPLY | 显示可恢复错误,不插入空白气泡 |
| 重新进入页面 | 从首页进入 AI 页并带快捷场景 | 预填正确,且不会重复消费同一预填内容 |
| 验收标准 任何一次失败都不应该迫使用户重新描述前面已经说过的需求。只要用户消息已经进入本地会话,失败后就应该能够原地重试。 |
15. 性能、成本与隐私:上线前最后三道关
15.1 性能:避免把 UI 主线程变成等待室
- 发送后立即展示用户气泡与 Loading,不要等模型返回后一次性更新。
- 同一时刻只允许一条发送中的主请求,避免重复点击制造并发。
- 请求结束后再自动滚动到底部,避免列表尚未布局完成就滚动。
- 每个 http.createHttp() 对应请求结束后 destroy,避免长时间聊天造成资源堆积。
- 长回复如果后续切换到流式输出,要用增量更新节流,避免每个 token 都触发昂贵重绘。
15.2 成本:控制上下文,比盯单次输出更重要
大模型对话的输入成本会随着历史增长。礼物场景里,大部分有效信息集中在最近几轮,因此先做最近 N 条 + 字符预算,通常就能解决最明显的膨胀问题。
- 简单贺卡、短文案可以路由到更快、更便宜的模型;复杂比较再用更强模型。
- max_tokens 不要盲目设很大。礼物推荐通常更需要结构紧凑,而不是长篇大论。
- 如果平台返回 usage,建议在调试环境记录 prompt_tokens、completion_tokens 和 total_tokens,定位异常长上下文。
- 缓存要谨慎:个性化礼物推荐包含用户上下文,不适合无差别共享缓存。
15.3 隐私:送礼信息也可能包含个人数据
送礼看似低风险,但用户可能在聊天里输入真实姓名、住址、生日、职业、疾病、家庭关系等信息。产品侧要坚持“完成推荐所需的最少信息”原则。
| 信息 | 是否通常必要 | 处理原则 |
| 关系与场合 | 必要 | 可直接用于推荐 |
| 预算区间 | 必要 | 尽量用区间,不要求精确资产信息 |
| 兴趣偏好 | 必要 | 用于候选筛选 |
| 真实姓名 | 通常不必要 | 用“女朋友 / 爸爸 / 同事”即可 |
| 精确住址 | 不必要 | 不要为了推荐主动索取 |
| 身份证 / 账户信息 | 不必要 | 明确不收集、不发送给模型 |
| 健康或敏感属性 | 仅用户主动且确有必要时 | 避免推断;减少日志留存和二次传播 |
16. 进阶升级:流式输出、摘要记忆、结构化礼物卡片
基础版本稳定以后,再做下面三类升级最划算。它们都建立在前面的分层之上,不需要推翻重写。
16.1 SSE 流式输出:先提升“感知速度”
非流式请求的优点是最容易跑通、错误边界清楚;缺点是长回答期间用户只能看 Loading。下一步可以让网关开启 stream,并把上游 SSE 增量转给客户端。客户端需要增加“正在生成”的消息状态,并对增量刷新做节流。
| 不要一开始就上流式 如果非流式版本连超时、重试、空响应和资源释放都没做稳,直接加入 SSE 只会把问题从一个请求扩大成连接管理、分片解析、断线恢复和增量渲染四个问题。先跑稳,再升级。 |
16.2 摘要记忆:让 50 轮之后仍然记得关键约束
当对话达到一定长度,可以把较早的轮次压缩成摘要,例如“对象:女朋友;预算最终调整为 300;喜欢摄影和手账;已有相机;不想送摆件”。之后只把摘要 + 最近窗口发给模型。
摘要要记录“事实”和“当前约束”,不要记录冗长措辞;并且用户的新信息必须能够覆盖旧摘要。
16.3 结构化礼物卡片:从文字答案走向可交互 UI
当推荐结果需要收藏、对比、跳转详情时,可以让网关把模型输出约束为结构化 JSON,再由 ArkUI 渲染成卡片。建议字段包括 title、reason、budgetRange、risk、personalizeTip。
{
"recommendations": [
{
"title": "定制摄影体验",
"reason": "兼顾摄影兴趣与纪念意义",
"budgetRange": "300-500",
"risk": "需要提前预约,注意时间安排",
"personalizeTip": "加入两人的照片或纪念日期"
}
]
}
结构化输出的优势不是“看起来更工程化”,而是 UI 可以可靠地收藏、排序、比较和二次编辑,而不必从一大段自然语言里做脆弱的正则解析。
17. 完整链路复盘与总结
到这里,一次“送礼推荐”完整经历的是:
- 用户从首页快捷场景或对话页输入需求。
- UI 立即落地用户消息,进入 SENDING 状态,并插入 AI Loading 占位。
- ContextBuilder 从本地消息中剔除 loading / error,按最近轮次和字符预算构建上下文。
- ArkTS 通过 Network Kit 调用自有网关,客户端不携带 MaaS 长期 API Key。
- 网关注入统一 System Prompt,做模型白名单和消息长度校验,再转发到蓝耘 MaaS。
- MaaS 按 model 字段调用对应模型,并返回统一 Chat Completions 响应。
- 网关把不同模型的正文统一成 text,同时保留 usage 供调试与成本观察。
- 客户端用稳定 id 替换 Loading 气泡,进入 SUCCESS;如果失败则进入 ERROR 并保留原会话。
- 用户可以继续追问、修改预算、排除选项或切换模型,历史上下文继续生效。
真正让这个礼物 App 从“能调用大模型”变成“有对话大脑”的,不是某一段 API 代码,而是四个层次同时成立:
| 层次 | 核心能力 | 最终价值 |
| 交互层 | 聊天气泡、快捷入口、Loading、重试、自动滚动 | 用户愿意持续聊 |
| 上下文层 | 规则、最近窗口、字符预算、摘要 | 模型记得关键约束 |
| 服务层 | 统一接口、模型配置、错误归一、usage | 模型可替换,业务不重写 |
| 安全与运维层 | Key 隔离、白名单、超时、限流、日志脱敏 | 从 Demo 走向可上线 |
| 最后一句 表单擅长处理“我已经知道自己要什么”;多轮对话擅长处理“我还说不清自己要什么”。礼物推荐恰好属于后者。把上下文、状态和安全边界搭好之后,MaaS 不再只是一个 API,而会真正变成 App 的决策能力。 |
参考资料
- 蓝耘 MaaS: https://maas.lanyun.net/
- 蓝耘科技大模型服务平台: https://www.lanyun.net/index.html
- HarmonyOS ArkTS 文档: https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts
- HarmonyOS Network Kit HTTP API: https://developer.huawei.com/consumer/en/doc/harmonyos-references-V13/js-apis-http-V13
更多推荐



所有评论(0)