鸿蒙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、空回复、重试

让异常可定位、可恢复

可验收

覆盖连续追问、切模型、断网、长对话等用例

避免只测“成功一次”

本文目录

  1. 先把目标想清楚:这不是接一个 API,而是搭一条对话链路
  2. 版本、环境与可复现边界
  3. 为什么礼物推荐天然适合多轮对话
  4. 架构先行:开发可以直连,正式包必须保护 API Key
  5. 工程目录与网络权限
  6. 数据模型与请求状态
  7. Prompt 工程:先追问,再推荐,不让模型乱猜
  8. 多轮上下文:最近消息不是越多越好
  9. 封装 MaaS 服务层:把超时、错误和资源释放收口
  10. 对话 UI:消息气泡、快捷提示词、自动滚动
  11. 模型热切换:配置驱动,而不是把模型写死
  12. 首页快捷入口与跨页预填
  13. 错误恢复:401、429、5xx、空响应分别怎么处理
  14. 验收用例:连续追问、切模型、断网、长对话都要测
  15. 性能、成本与隐私:上线前最后三道关
  16. 进阶升级:流式输出、摘要记忆、结构化礼物卡片
  17. 完整链路复盘与总结

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. 完整链路复盘与总结

到这里,一次“送礼推荐”完整经历的是:

  1. 用户从首页快捷场景或对话页输入需求。
  2. UI 立即落地用户消息,进入 SENDING 状态,并插入 AI Loading 占位。
  3. ContextBuilder 从本地消息中剔除 loading / error,按最近轮次和字符预算构建上下文。
  4. ArkTS 通过 Network Kit 调用自有网关,客户端不携带 MaaS 长期 API Key。
  5. 网关注入统一 System Prompt,做模型白名单和消息长度校验,再转发到蓝耘 MaaS。
  6. MaaS 按 model 字段调用对应模型,并返回统一 Chat Completions 响应。
  7. 网关把不同模型的正文统一成 text,同时保留 usage 供调试与成本观察。
  8. 客户端用稳定 id 替换 Loading 气泡,进入 SUCCESS;如果失败则进入 ERROR 并保留原会话。
  9. 用户可以继续追问、修改预算、排除选项或切换模型,历史上下文继续生效。

真正让这个礼物 App 从“能调用大模型”变成“有对话大脑”的,不是某一段 API 代码,而是四个层次同时成立:

层次

核心能力

最终价值

交互层

聊天气泡、快捷入口、Loading、重试、自动滚动

用户愿意持续聊

上下文层

规则、最近窗口、字符预算、摘要

模型记得关键约束

服务层

统一接口、模型配置、错误归一、usage

模型可替换,业务不重写

安全与运维层

Key 隔离、白名单、超时、限流、日志脱敏

从 Demo 走向可上线

最后一句

表单擅长处理“我已经知道自己要什么”;多轮对话擅长处理“我还说不清自己要什么”。礼物推荐恰好属于后者。把上下文、状态和安全边界搭好之后,MaaS 不再只是一个 API,而会真正变成 App 的决策能力。

参考资料

  1. 蓝耘 MaaS: https://maas.lanyun.net/
  2. 蓝耘科技大模型服务平台: https://www.lanyun.net/index.html
  3. HarmonyOS ArkTS 文档: https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts
  4. HarmonyOS Network Kit HTTP API: https://developer.huawei.com/consumer/en/doc/harmonyos-references-V13/js-apis-http-V13
Logo

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

更多推荐