HarmonyOS应用《民族图鉴》开发第38篇:AIService——AI对话服务与上下文管理

一、AI 对话系统的完整架构
1.1 AI 功能的定位
民族图鉴里的 AI 问答助手,是一个知识增强型的对话功能——用户可以问关于 56 个民族的各种问题,AI 给出解答。
和通用 AI(如 ChatGPT)不同,这个 AI 是垂直领域的:
- 只回答民族文化相关的问题
- 回答基于权威资料,准确性优先
- 不闲聊、不编故事
- 回答风格偏科普、教育
这种定位决定了 AI 服务的设计方向。
1.2 AI 对话系统的四层架构
一个完整的 AI 对话系统,通常由四层组成:
┌─────────────────────────────────────────────────────┐
│ 对话管理层 │
│ 状态管理、上下文管理、对话流程控制、意图识别 │
├─────────────────────────────────────────────────────┤
│ 知识检索层 │
│ 知识库、向量检索、文档检索、知识图谱 │
├─────────────────────────────────────────────────────┤
│ 模型层 │
│ LLM 调用、Prompt 构建、流式输出、函数调用 │
├─────────────────────────────────────────────────────┤
│ 输出处理层 │
│ Markdown 渲染、代码高亮、格式化、内容审核 │
└─────────────────────────────────────────────────────┘
1. 对话管理层
对话管理层是系统的"大脑",负责:
- 对话状态管理:当前对话处于什么状态(正常、等待澄清、结束…)
- 上下文管理:维护对话历史,管理 token 预算
- 意图识别:判断用户想问什么(节日?服饰?饮食?)
- 对话流程控制:多轮对话的流程编排
2. 知识检索层
对于垂直领域的 AI 问答,通用大模型的知识可能不够准确、不够新。需要检索增强生成(RAG,Retrieval-Augmented Generation):
- 先从知识库中检索相关内容
- 把检索到的内容和用户问题一起发给 LLM
- LLM 基于检索到的知识生成回答
这样可以大大提高回答的准确性,避免"胡说八道"。
3. 模型层
模型层负责和 LLM 交互:
- Prompt 构建:把 system prompt、上下文、检索到的知识、用户问题组装成完整的 prompt
- API 调用:调用大模型 API
- 流式输出:SSE/WebSocket 流式接收
- 函数调用:如果 LLM 支持 function calling,可以调用外部工具
4. 输出处理层
LLM 返回的原始内容,需要处理后再展示给用户:
- Markdown 渲染:标题、列表、加粗、代码块
- 代码高亮:如果有代码片段
- 内容审核:检查输出是否合规
- 格式化:日期、数字、引用格式
1.3 本项目的简化架构
对于民族图鉴这个项目,我们不需要做完整的四层架构那样复杂。MVP 版本可以简化为:
┌─────────────────────────────────────────┐
│ AI 对话页(UI层) │
│ 消息列表、输入框、加载状态 │
├─────────────────────────────────────────┤
│ AIService(服务层) │
│ sendMessage / 上下文管理 / Mock │
├─────────────────────────────────────────┤
│ Mock 数据层 / 未来的真实 API │
│ generateMockReply / LLM API │
└─────────────────────────────────────────┘
为什么不一步到位做 RAG?
- MVP 原则:先跑通核心流程,再逐步增强
- 成本考虑:向量数据库、知识库搭建都需要成本
- Mock 优先:没有真实后端的情况下,先把 UI 和交互做好
- 可扩展:架构上预留了扩展空间,以后加 RAG 不用大改
架构设计的关键不是"一步到位做最复杂的",而是"简单但可扩展"。
二、核心数据模型
2.1 消息模型
// AIModels.ets
export interface ChatMessage {
id: string; // 消息唯一ID
role: 'user' | 'assistant'; // 角色:用户 / AI助手
content: string; // 消息内容
timestamp: number; // 时间戳
}
字段设计说明:
| 字段 | 作用 | 为什么需要 |
|---|---|---|
| id | 消息唯一标识 | 列表渲染用 key、定位消息、删除消息 |
| role | 谁发的 | 界面上区分用户气泡和 AI 气泡 |
| content | 消息内容 | 核心数据 |
| timestamp | 发送时间 | 排序、显示时间、计算响应时长 |
2.2 为什么 role 只有 user 和 assistant
很多 AI 对话 API 有三种角色:system、user、assistant。
| 角色 | 作用 |
|---|---|
| system | 系统提示词,设定 AI 的人设、能力边界 |
| user | 用户说的话 |
| assistant | AI 的回复 |
本项目的 ChatMessage 里只有 user 和 assistant,为什么?
因为 system prompt 是配置层面的,不是对话历史的一部分。用户看不到 system prompt,也不应该看到。它在调用 AI API 时拼进去就行,不用存在对话历史里。
💡 System Prompt 的重要性:
System Prompt 决定了 AI 的"人设"和"边界"。对于民族图鉴应用,system prompt 大概是这样的:
你是一个中国民族文化知识助手。请准确、客观地回答用户关于中国56个民族的问题。 范围包括:历史、文化、习俗、节日、服饰、饮食、语言、宗教、分布地区等。 如果问题与民族文化无关,请礼貌地说明你只回答民族文化相关的问题。 回答要通俗易懂,适合普通用户阅读。
三、AIService 实现
3.1 单例与历史记录
export class AIService {
private static instance: AIService;
private conversationHistory: ChatMessage[] = [];
private constructor() {}
static getInstance(): AIService {
if (!AIService.instance) {
AIService.instance = new AIService();
}
return AIService.instance;
}
}
AI 服务也是单例。原因:
- 对话历史是全局的,离开页面再进来不能丢
- 多个地方可能都要用到 AI 能力
- 统一管理上下文,避免混乱
3.2 发送消息
async sendMessage(userMessage: string): Promise<ChatMessage> {
// 1. 创建用户消息,加入历史
const userMsg: ChatMessage = {
id: this.generateId(),
role: 'user',
content: userMessage,
timestamp: Date.now()
};
this.conversationHistory.push(userMsg);
// 2. 模拟 AI 回复(实际项目中是调用 API)
const delay = Math.floor(Math.random() * 400) + 800;
return new Promise((resolve) => {
setTimeout(() => {
const replyContent = generateMockReply(userMessage);
const assistantMsg: ChatMessage = {
id: this.generateId(),
role: 'assistant',
content: replyContent,
timestamp: Date.now()
};
this.conversationHistory.push(assistantMsg);
resolve(assistantMsg);
}, delay);
});
}
发送消息的流程:
- 先存用户消息:用户发的话先加到历史里,UI 立刻显示
- 调用 AI 接口:把用户消息发给 AI,等回复
- 存 AI 回复:AI 回复了再加到历史里,UI 更新
3.3 历史记录管理
getHistory(): ChatMessage[] {
return [...this.conversationHistory]; // 返回副本,防止外部修改
}
clearHistory(): void {
this.conversationHistory = [];
}
为什么返回副本?
和 MusicService 的 playlist 一样——返回数组引用的话,外部可以直接修改内部状态,绕过服务的封装。返回副本([...arr])保证外部只能读、不能改。
3.4 生成消息 ID
private generateId(): string {
return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
格式:msg_时间戳_随机串,保证唯一性。
💡 ID 生成策略选择:
方案 优点 缺点 时间戳 + 随机 简单、够用 极端并发下可能重复(概率极低) 自增数字 简单、短 多端同步会冲突 UUID 全局唯一 太长、可读性差 雪花ID 有序、唯一 实现复杂 对于客户端本地消息,时间戳+随机完全够用了。
四、上下文管理:AI 怎么"记住"对话
4.1 为什么需要上下文
AI 模型本身是无状态的——它不记得上一轮说了什么。每次请求都是独立的。
那我们平时用 ChatGPT 为什么感觉它"记得"之前的对话?
因为每次发新消息时,客户端都会把之前的对话历史一起发给 AI:
[
{"role": "system", "content": "你是民族文化助手..."},
{"role": "user", "content": "傣族有什么节日?"},
{"role": "assistant", "content": "傣族的主要节日有泼水节..."},
{"role": "user", "content": "那什么时候过?"} ← 最新的问题
]
AI 看到完整的上下文,就知道"那"指的是泼水节,"什么时候"问的是时间。
4.2 上下文窗口限制
但是,不是历史越多越好——AI 有 Token 限制(上下文窗口):
- GPT-3.5:4K / 16K tokens
- GPT-4:8K / 32K / 128K tokens
- 不同模型限制不同
超过限制会怎么样?
- API 直接报错,不给回复
- 或者截断前面的内容,AI 失忆
Token 是什么?
- Token 是 AI 处理文本的基本单位
- 中文大约 1 个字 = 1~2 个 token
- 英文大约 1 个单词 = 1~2 个 token
- 1000 tokens ≈ 700 个英文单词 ≈ 500 个汉字
4.3 上下文管理策略
对话历史越来越长,超过 token 限制怎么办?常见的策略:
| 策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 截断最早的 | 只保留最近 N 轮对话 | 简单 | 早期信息丢了 |
| 摘要压缩 | 把旧的对话总结成摘要,保留要点 | 省 token | 实现复杂,摘要可能失真 |
| 向量检索 | 从历史中找最相关的几条,拼进上下文 | 最精准 | 需要向量数据库,实现复杂 |
| 滑动窗口 | 只保留最近 K 条 + system prompt | 简单有效 | 还是会丢早期信息 |
对于本项目(民族知识问答):
对话轮次一般不多(用户问几个问题就走了),而且民族知识是事实性的,不太依赖长期记忆。所以滑动窗口策略就够了——只保留最近 10~20 轮对话。
// 伪代码:滑动窗口策略
const MAX_HISTORY_TURNS = 10;
buildPrompt(userMessage: string): ChatMessage[] {
const messages: ChatMessage[] = [];
// system prompt 永远放第一条
messages.push({ role: 'system', content: SYSTEM_PROMPT });
// 只取最近 N 轮
const recentHistory = this.conversationHistory.slice(-MAX_HISTORY_TURNS * 2);
messages.push(...recentHistory);
// 最新的用户消息(其实已经在 history 里了,这里只是示意)
return messages;
}
4.4 Token 计数
发请求之前,最好算一下总 token 数,留一些余量:
// 伪代码
const totalTokens = countTokens(messages);
if (totalTokens > MAX_INPUT_TOKENS) {
// 截断一些历史,或者报错
}
开源的 tokenizer 库可以帮你计算 token 数。不过客户端一般不做这么细,由后端 API 处理更合适。
五、Prompt 设计与优化
Prompt(提示词)是 AI 对话的灵魂——同样的模型,好的 prompt 和差的 prompt,回答质量天差地别。
5.1 System Prompt 的设计
System Prompt 是给 AI 的"角色设定",它决定了 AI 的人设、能力边界、回答风格。
民族图鉴的 System Prompt 应该包含什么?
你是一个专业的中国民族文化知识助手,名为"民族图鉴"。
【角色定位】
- 你是一位民族文化专家,精通中国56个民族的历史、文化、习俗
- 你的回答准确、客观、通俗易懂,适合普通大众阅读
- 你只回答与中国民族文化相关的问题
【知识范围】
可以回答的内容包括:
- 56个民族的基本信息(人口、分布、语言、文字等)
- 民族历史、起源、迁徙
- 传统节日、习俗、礼仪
- 民族服饰、饮食、建筑
- 宗教信仰、神话传说
- 民族艺术、音乐、舞蹈
- 其他与民族文化相关的知识
【回答规范】
1. 回答要准确,基于权威资料,不编造信息
2. 语言要通俗易懂,避免过于学术化的表达
3. 结构要清晰,适当使用列表、分段
4. 不知道的问题,如实说明,不要编造
5. 对于有争议的内容,说明不同观点
【边界设定】
- 如果问题与民族文化无关,礼貌地说明你只回答民族文化相关问题
- 不讨论政治、宗教敏感话题
- 不提供任何违法违规内容
- 不对其他民族进行贬低或不当评价
回答格式:使用 Markdown 格式,适当使用标题、列表、加粗等增强可读性。
好的 System Prompt 的特点:
| 要素 | 作用 | 示例 |
|---|---|---|
| 角色设定 | 给 AI 一个明确的身份 | “你是民族文化专家” |
| 知识范围 | 明确能回答什么、不能回答什么 | “只回答民族文化相关问题” |
| 回答规范 | 定义回答的风格和格式 | “通俗易懂、结构清晰” |
| 边界设定 | 划定安全边界 | “不讨论政治敏感话题” |
| 输出格式 | 指定输出格式 | “使用 Markdown 格式” |
5.2 Prompt 工程的核心技巧
1. 角色设定(Persona)
给 AI 一个明确的角色,回答会更专业:
❌ 不好:"介绍一下傣族"
✅ 好:"你是一位民族文化专家,请用通俗易懂的语言介绍一下傣族"
2. 分步思考(Chain of Thought)
复杂问题让 AI 一步步思考,结果更准确:
❌ 不好:"哪个民族人口最多?"
✅ 好:"请先列出中国人口排名前五的民族,然后指出哪个最多"
3. 少样本学习(Few-shot)
给几个示例,AI 更容易理解你想要的格式:
请按照以下格式介绍民族:
【示例】
民族:蒙古族
人口:约629万
主要分布:内蒙古自治区
特色节日:那达慕大会
现在请介绍傣族:
4. 明确输出格式
指定输出格式(JSON、Markdown、列表等),方便后续处理:
请用 JSON 格式返回,包含以下字段:
- name: 民族名称
- population: 人口数
- festivals: 主要节日(数组)
5.3 本项目的 Prompt 策略
对于民族图鉴这个垂直领域应用,我们的 Prompt 设计原则:
- 强约束:严格限定回答范围,避免 AI 跑偏
- 准确性优先:宁可不回答,也不要瞎回答
- 结构化输出:统一回答格式,提升阅读体验
- 友好引导:用户问了无关问题,礼貌引导回正题
Prompt 的迭代优化:
Prompt 不是一次写好就不变的,需要持续迭代:
v1.0 初版 → 测试 → 发现问题 → 调整 → v1.1 → 再测试...
常见的优化方向:
- 回答太啰嗦?→ 加上"简洁回答"的要求
- 经常答非所问?→ 更明确地限定范围
- 格式不统一?→ 给出更具体的格式要求
- 容易被诱导聊别的?→ 加强边界设定
六、知识库检索:RAG 实现
通用大模型虽然知识渊博,但对于垂直领域的专业知识,往往存在三个问题:
- 准确性不足:可能编造信息(幻觉)
- 知识过时:训练数据截止到某个时间
- 深度不够:表面知识还行,深入的就不行了
这时候就需要 RAG(Retrieval-Augmented Generation,检索增强生成)。
6.1 什么是 RAG
RAG 的核心思想:先检索,再生成。
用户提问
↓
检索知识库 → 找到最相关的 N 篇文档
↓
把问题 + 检索到的知识一起发给 LLM
↓
LLM 基于检索到的知识生成回答
RAG 的优势:
- 回答更准确:基于真实资料,减少幻觉
- 知识可更新:更新知识库就行,不用重新训练模型
- 可溯源:回答基于哪些资料,可以列出来
- 成本更低:比微调模型便宜多了
6.2 知识库的构建
民族图鉴的知识库应该包含什么?
知识库/
├── 民族基本信息/ # 56个民族的基本介绍
│ ├── 汉族.md
│ ├── 蒙古族.md
│ └── ...
├── 民族节日/ # 各民族的传统节日
│ ├── 春节.md
│ ├── 泼水节.md
│ └── ...
├── 民族服饰/ # 服饰文化
├── 民族饮食/ # 饮食文化
├── 民族建筑/ # 建筑特色
└── 历史传说/ # 神话传说、历史故事
知识库的来源:
- 权威百科资料
- 学术论文、专著
- 政府公开资料
- 博物馆、文化机构资料
6.3 向量检索的原理
怎么从海量文档中快速找到最相关的内容?答案是向量检索。
基本原理:
1. 把知识库的每一段文本,通过 Embedding 模型转换成向量(一组数字)
2. 用户提问时,也把问题转换成向量
3. 在向量空间中,找和问题向量最相似的 N 个文档向量
4. 把这 N 个文档的内容作为上下文,和问题一起发给 LLM
相似度怎么算?
常用的是余弦相似度——两个向量的夹角越小,越相似。
问题向量: [0.1, 0.5, 0.3, ...]
文档A向量:[0.2, 0.4, 0.3, ...] ← 相似度 0.95(最相关)
文档B向量:[0.8, 0.1, 0.2, ...] ← 相似度 0.3(不相关)
Embedding 模型:
- 开源:BGE、M3E、text2vec
- 商用:OpenAI text-embedding-ada-002、Cohere
6.4 本项目的 RAG 规划
目前本项目是 Mock 模式,还没有真正的 RAG。但架构上预留了扩展空间。
未来的 RAG 实现架构:
┌─────────────────────────────────────────┐
│ AIService(服务层) │
│ sendMessage │
└───────┬─────────────────────┬───────────┘
│ │
▼ ▼
┌───────────────┐ ┌───────────────────┐
│ 检索服务 │ │ LLM API 服务 │
│ 向量检索 │ │ 流式输出 │
│ 关键词检索 │ │ 函数调用 │
└───────┬───────┘ └─────────┬─────────┘
│ │
▼ ▼
┌───────────────┐ ┌───────────────────┐
│ 向量数据库 │ │ 大模型 API │
│ Milvus/Pinecone │ │ DeepSeek/GPT │
└───────────────┘ └───────────────────┘
分阶段实施路径:
| 阶段 | 内容 | 价值 |
|---|---|---|
| V1 | Mock 关键词回复 | 跑通流程,验证需求 |
| V2 | 接入真实 LLM API | 体验真实 AI 对话 |
| V3 | 接入知识库 + 向量检索 | 回答更准确,减少幻觉 |
| V4 | 优化检索质量、多轮对话 | 提升体验,增加功能 |
6.5 民族知识问答的特殊挑战
民族文化领域的 QA,有一些特殊的挑战:
- 专有名词多:很多民族专有名词(如"那达慕"“雪顿节”),通用模型可能不熟悉
- 译名不统一:同一个民族名称可能有不同的译法
- 信息准确性要求高:民族问题敏感,不能出错
- 文化差异大:不同民族的文化、习俗差异很大
这些都需要通过高质量的知识库和精心设计的 Prompt 来解决。
七、流式输出(SSE)的实现
现在的 AI 对话产品,基本都是流式输出——一边生成一边显示,用户体验好很多。
7.1 为什么需要流式输出
如果不用流式输出,用户体验是这样的:
用户:"傣族有什么节日?"
(等了 5 秒,什么都没发生...)
(突然一大段文字蹦出来)
用了流式输出,体验是这样的:
用户:"傣族有什么节日?"
(立刻开始显示)
傣族的主要节日有...
(一个字一个字地蹦出来)
...
(大概 3 秒显示完)
流式输出的优势:
- 感知速度快:不用等全部生成完,立刻有内容
- 体验更自然:像真人打字一样
- 可以打断:用户看到一半不想看了可以停下
7.2 SSE 技术原理
流式输出通常用 SSE(Server-Sent Events) 技术。
SSE 是什么?
- 基于 HTTP 的长连接
- 服务器可以主动向客户端推送数据
- 单向通信:只能服务器推给客户端
- 比 WebSocket 更轻量
WebSocket vs SSE:
| 特性 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 单向(服务器→客户端) | 双向 |
| 协议 | HTTP | 自定义协议(WS) |
| 实现复杂度 | 低 | 较高 |
| 自动重连 | 支持 | 需要自己实现 |
| 适用场景 | 纯推送(AI 输出、通知) | 双向通信(聊天、游戏) |
对于 AI 对话输出,SSE 完全够用,而且更简单。
7.3 流式输出的服务层设计
流式输出下,sendMessage 的接口要改一改,不能只返回一个 Promise 了。
方案一:回调函数
interface StreamOptions {
onStart?: () => void; // 开始输出
onToken?: (token: string) => void; // 每收到一个 token
onComplete?: (fullText: string) => void; // 输出完成
onError?: (error: Error) => void; // 出错
}
sendMessageStream(
userMessage: string,
options: StreamOptions
): void {
// 1. 保存用户消息
// 2. 建立 SSE 连接
// 3. 收到数据就调用 onToken
// 4. 结束调用 onComplete
}
方案二:异步迭代器(Async Iterator)
async *sendMessageStream(userMessage: string): AsyncGenerator<string> {
// 建立连接
// 每收到一个 token 就 yield 一下
while (有新数据) {
const token = await 取下一个token();
yield token;
}
}
// 使用
for await (const token of aiService.sendMessageStream('你好')) {
console.log(token);
}
异步迭代器更现代,但兼容性要考虑。
7.4 UI 层的打字机效果
流式输出在 UI 上的表现就是"打字机效果"——文字一个一个蹦出来。
实现思路:
@Component
struct ChatMessage {
@State content: string = '';
private fullContent: string = '';
private isStreaming: boolean = false;
build() {
Text(this.content)
.fontSize(16)
.width('70%')
}
async startStreaming(stream: AsyncGenerator<string>) {
this.isStreaming = true;
this.content = '';
for await (const token of stream) {
this.content += token;
}
this.isStreaming = false;
}
}
优化体验的小技巧:
- 光标闪烁:正在输出时,末尾加一个闪烁的光标
- 自动滚动:新内容来了,列表自动滚到底部
- 速度控制:如果 token 来太快,可以稍作延迟,更像真人打字
- 分段渲染:Markdown 可以边收边渲染,不用等全部收完
7.5 本项目的 Mock 流式输出
虽然我们现在是 Mock 模式,但也可以模拟流式输出的效果:
async *sendMockStream(message: string): AsyncGenerator<string> {
const reply = generateMockReply(message);
const chars = reply.split('');
// 模拟逐字输出
for (const char of chars) {
await new Promise(resolve => setTimeout(resolve, 30)); // 每个字 30ms
yield char;
}
}
这样 UI 层的代码和真实流式输出是一样的,以后接真实 API 不用改 UI。
八、AI 回答的格式化:Markdown 渲染
AI 的回答通常是 Markdown 格式的,包含标题、列表、加粗、代码块等。我们需要在界面上正确渲染。
8.1 为什么用 Markdown
Markdown 是 AI 输出的事实标准:
- 结构化:有标题、列表、段落,可读性好
- 通用:几乎所有 LLM 都支持 Markdown 输出
- 可扩展:可以支持代码、表格、链接等
AI 回答的典型 Markdown 结构:
## 傣族的主要节日
傣族有很多富有特色的传统节日,其中最著名的是**泼水节**。
### 主要节日
1. **泼水节**:傣族的新年,一般在公历4月中旬
2. **关门节**:傣历九月十五日,为期三个月
3. **开门节**:傣历十二月十五日
### 泼水节的习俗
泼水节期间,人们会:
- 互相泼水祝福
- 赛龙舟
- 放高升
- 赶摆
> 泼水节一般持续3-7天,是傣族最隆重的节日。
渲染成 HTML 后,层次分明,阅读体验很好。
8.2 Markdown 渲染的技术方案
在鸿蒙 ArkTS 中渲染 Markdown,有几种方案:
| 方案 | 说明 | 优点 | 缺点 |
|---|---|---|---|
| Web 组件 | 用 Web 组件加载 HTML | 功能最全,支持所有 Markdown 语法 | 性能稍差,交互复杂 |
| 自定义组件 | 自己解析 Markdown,用原生组件渲染 | 性能好,体验统一 | 开发工作量大 |
| 第三方库 | 使用现成的 Markdown 渲染库 | 省事 | 依赖库的质量和维护 |
| 简化渲染 | 只支持常用语法(标题、列表、加粗) | 简单可控 | 功能有限 |
本项目的选择:
对于民族图鉴这个应用,AI 回答主要是科普性质的文本,不需要太复杂的格式。我们采用简化渲染的方案——自己实现一个轻量的 Markdown 解析器,只支持常用的几种语法:
- 标题(# ## ###)
- 粗体(text)
- 列表(- 1.)
- 换行和段落
- 引用(>)
这样既满足需求,又不会引入复杂的依赖。
8.3 简单 Markdown 解析器实现
思路:把 Markdown 文本解析成一个结构化的节点数组,然后根据节点类型用不同的组件渲染。
interface MarkdownNode {
type: 'heading' | 'paragraph' | 'list' | 'listItem' | 'quote' | 'text';
level?: number; // 标题级别 1-6
content?: string;
children?: MarkdownNode[];
bold?: boolean;
}
function parseMarkdown(text: string): MarkdownNode[] {
const lines = text.split('\n');
const nodes: MarkdownNode[] = [];
let currentList: MarkdownNode | null = null;
for (const line of lines) {
// 标题
if (line.startsWith('#')) {
const level = line.match(/^#+/)?.[0].length || 1;
const content = line.replace(/^#+\s*/, '');
nodes.push({ type: 'heading', level, content });
currentList = null;
continue;
}
// 列表项
if (/^[-*]\s/.test(line) || /^\d+\.\s/.test(line)) {
if (!currentList || currentList.type !== 'list') {
currentList = { type: 'list', children: [] };
nodes.push(currentList);
}
const content = line.replace(/^[-*]\s/, '').replace(/^\d+\.\s/, '');
currentList.children!.push({ type: 'listItem', content });
continue;
}
// 引用
if (line.startsWith('> ')) {
nodes.push({ type: 'quote', content: line.replace(/^>\s/, '') });
currentList = null;
continue;
}
// 空行
if (line.trim() === '') {
currentList = null;
continue;
}
// 普通段落
nodes.push({ type: 'paragraph', content: line });
currentList = null;
}
return nodes;
}
然后在 UI 层根据节点类型渲染不同的组件:
@Component
struct MarkdownRenderer {
@State nodes: MarkdownNode[] = [];
build() {
Column({ space: 8 }) {
ForEach(this.nodes, (node: MarkdownNode, index: number) => {
if (node.type === 'heading') {
Text(node.content || '')
.fontSize(node.level === 1 ? 20 : node.level === 2 ? 18 : 16)
.fontWeight(FontWeight.Bold)
.margin({ top: index === 0 ? 0 : 12 })
} else if (node.type === 'paragraph') {
Text(node.content || '')
.fontSize(16)
.lineHeight(24)
} else if (node.type === 'list') {
Column({ space: 4 }) {
ForEach(node.children!, (item: MarkdownNode) => {
Text(`• ${item.content}`)
.fontSize(16)
.lineHeight(22)
})
}
.margin({ left: 8 })
} else if (node.type === 'quote') {
Text(node.content || '')
.fontSize(15)
.fontColor('#666')
.padding({ left: 12 })
.border({ left: { width: 3, color: '#ddd' } })
}
})
}
.width('100%')
}
}
8.4 代码高亮
虽然民族图鉴的 AI 回答大概率不会有代码块,但作为一个完整的方案,还是提一下。
代码高亮的方案:
- highlight.js:最流行的代码高亮库,支持很多语言
- Prism.js:轻量级的代码高亮库
- highlight.ts:TypeScript 实现的版本
如果需要的话,可以把代码高亮库打包后用 Web 组件渲染,或者自己实现简单的关键字着色。
九、AI 服务的降级与错误处理
AI 服务依赖网络和第三方 API,稳定性是个大问题。做好降级和错误处理,用户体验才不会崩。
9.1 可能出问题的地方
AI 对话链路上,每个环节都可能出问题:
用户输入 → 内容审核 → 检索知识库 → 调用 LLM API → 接收响应 → 渲染输出
↑ ↑ ↑ ↑ ↑ ↑
❌ ❌ ❌ ❌ ❌ ❌
输入为空 审核不通过 检索失败 API 报错 连接断开 渲染失败
常见错误类型:
| 错误类型 | 原因 | 频率 | 用户感知 |
|---|---|---|---|
| 网络错误 | 没网、DNS 解析失败 | 中 | 明显 |
| API 限流 | 调用太频繁,被 rate limit | 中 | 明显 |
| 超时 | AI 生成太慢,超过超时时间 | 低 | 明显 |
| 内容审核 | 输入或输出违规 | 低 | 明显 |
| 服务不可用 | 服务器挂了、维护中 | 很低 | 明显 |
| 输出格式异常 | 返回格式不对,解析失败 | 很低 | 不明显 |
9.2 降级策略
出问题了怎么办?不能直接给用户甩一个错误。要有降级方案。
降级策略矩阵:
| 错误类型 | 降级方案 | 用户提示 |
|---|---|---|
| 网络错误 | 提示检查网络,提供重试按钮 | “网络连接失败,请检查网络后重试” |
| API 限流 | 显示倒计时,自动重试 | “请求过于频繁,X 秒后重试…” |
| 超时 | 重试一次,还是不行就提示 | “响应超时,请稍后再试” |
| 内容审核 | 礼貌说明,引导换个问题 | “这个问题我无法回答,试试问我民族文化相关的问题吧” |
| 服务不可用 | 提示稍后再试 | “服务暂时不可用,请稍后再试” |
| 输出格式异常 | 用纯文本显示,不渲染格式 | (静默降级,用户可能察觉不到) |
9.3 重试机制
网络类的错误,可以重试。但重试要有策略,不能瞎重试。
重试三原则:
-
只重试可恢复的错误
- ✅ 可以重试:网络错误、超时、限流、5xx
- ❌ 不要重试:内容审核、参数错误、4xx(除了 429)
-
指数退避(Exponential Backoff)
- 第一次等 1 秒
- 第二次等 2 秒
- 第三次等 4 秒
- …
- 避免把服务器打挂了
-
最大重试次数
- 最多重试 2-3 次
- 不行就提示用户,不能无限等下去
带重试的请求实现:
async sendWithRetry(
message: string,
maxRetries: number = 2
): Promise<ChatMessage> {
let lastError: Error | null = null;
for (let i = 0; i <= maxRetries; i++) {
try {
return await this.sendMessage(message);
} catch (error: any) {
lastError = error;
// 判断是否应该重试
if (!this.isRetriableError(error)) {
break;
}
// 最后一次了,不再等
if (i === maxRetries) {
break;
}
// 指数退避等待
const delay = Math.pow(2, i) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError;
}
private isRetriableError(error: any): boolean {
const retriableCodes = [429, 500, 502, 503, 504];
return retriableCodes.includes(error?.statusCode);
}
9.4 熔断机制
如果错误率太高,说明服务可能挂了,这时候应该"熔断"——暂时停止请求,直接返回降级内容,避免继续浪费资源。
熔断的三种状态:
正常(Closed)
│
│ 错误率超过阈值
▼
熔断(Open)◄──────────┐
│ │
│ 等待一段时间 │ 探测成功
▼ │
半开(Half-Open)──────┘
│
│ 又失败了
▼
继续熔断
- Closed(关闭):正常请求,统计错误率
- Open(打开):直接拒绝请求,快速失败
- Half-Open(半开):放少量请求试试,好了就恢复,不好继续熔断
对于客户端来说,熔断可以做简单一点:
- 连续失败 N 次后,暂时禁用发送
- 显示"服务暂时不可用,请稍后再试"
- 过一段时间自动恢复
9.5 本项目的降级实践
因为本项目目前是 Mock 模式,所以没有真实的网络错误。但我们在架构上考虑了降级:
- Mock 就是最大的降级:没有真实 API 的时候,Mock 数据保证功能可用
- 友好的错误提示:即使出错,也给用户清晰的提示
- 加载状态:请求中显示加载动画,不让用户以为卡住了
以后接入真实 API 时,这些降级策略都会用上。
十、Mock 回复:generateMockReply
本项目用的是 Mock 回复,没有真的调用 AI API。但 Mock 回复也有讲究——不能太假。
10.1 一个"像样"的 Mock 回复应该怎样
// AIMockData.ets
export function generateMockReply(userMessage: string): string {
const msg = userMessage.toLowerCase();
if (msg.includes('节日') || msg.includes('festival')) {
return '中国56个民族各有特色节日,例如:\n\n' +
'• 汉族:春节、元宵节、清明节、端午节、中秋节\n' +
'• 傣族:泼水节\n' +
'• 蒙古族:那达慕大会\n' +
'• 藏族:藏历新年、雪顿节\n' +
'• 彝族:火把节\n' +
'• 回族:开斋节、古尔邦节\n\n' +
'想了解哪个民族的节日,可以问我具体的哦~';
}
if (msg.includes('服饰') || msg.includes('服装')) {
return '民族服饰是民族文化的重要组成部分:\n\n' +
'• 蒙古族:蒙古袍、靴子、帽子\n' +
'• 藏族:藏袍、邦典(围裙)\n' +
'• 苗族:银饰、百褶裙\n' +
'• 维吾尔族:艾德莱斯绸、小花帽\n' +
'• 傣族:筒裙、短上衣\n\n' +
'每一种服饰都承载着深厚的文化内涵。';
}
return '这是一个很有意思的问题!\n\n' +
'中国56个民族都有独特的文化和历史。你可以问我具体的问题,比如:\n\n' +
'• "傣族有什么传统节日?"\n' +
'• "蒙古族住在哪里?"\n' +
'• "藏族的饮食特色是什么?"\n\n' +
'我会尽力为你解答~';
}
好的 Mock 回复的特点:
- 有针对性:不是千篇一律的"收到",而是根据问题内容给不同的回复
- 格式正确:有换行、有列表,和真实 AI 回复的格式像
- 内容像样:内容本身是正确的、有信息量的,不是胡说八道
- 有引导性:引导用户问更多问题,增加交互
10.2 为什么 Mock 也要做得像
很多人觉得 Mock 嘛,随便写个"哈哈哈"就行,反正以后要接真实 API 的。
但其实 Mock 做得好有很多好处:
- 产品演示:给老板/客户演示,Mock 回复像样才好意思拿出手
- UI 调试:测试长文本、列表、换行这些边界情况,Mock 数据要足够真实
- 开发体验:自己测的时候也不想看到一片乱码吧
- 测试自动化:UI 自动化测试也需要稳定的、可预测的回复
十一、历史记录持久化与存储策略
现在对话历史只存在内存里,App 被杀了就没了。如果想保留历史,需要持久化存储。
11.1 持久化方案选择
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| Preferences | 少量数据、简单结构 | 简单、快 | 不适合大量数据 |
| 关系型数据库(RDB) | 大量数据、需要查询 | 可查询、容量大 | 实现复杂 |
| 文件存储 | 归档数据 | 容量大 | 查询不方便 |
对于对话历史:
- 历史不多(< 100 条)→ Preferences 够用
- 历史多、需要搜索 → RDB 更合适
- 要备份/导出 → 文件存储
11.2 Preferences 持久化实现
简单起见,我们先用 Preferences 存:
// 保存历史
async saveHistory(): Promise<void> {
try {
await StorageService.getInstance().saveObject(
StorageConstants.KEY_CHAT_HISTORY,
this.conversationHistory
);
} catch (error) {
console.error('保存聊天历史失败', error);
}
}
// 加载历史
async loadHistory(): Promise<void> {
try {
const history = await StorageService.getInstance().getObject<ChatMessage[]>(
StorageConstants.KEY_CHAT_HISTORY,
[]
);
this.conversationHistory = history || [];
} catch (error) {
console.error('加载聊天历史失败', error);
this.conversationHistory = [];
}
}
什么时候保存?
- 每条 AI 回复后保存一次
- 页面退出时保存一次
- App 切后台时保存一次
11.3 历史记录的清理策略
对话历史不能无限增长,需要有清理策略:
- 数量上限:最多保留 N 条(如 100 条),超出自动删最早的
- 时间上限:只保留最近 30 天的历史
- 手动清理:用户可以手动清空历史
- 存储空间限制:快存满了提示用户清理
private static readonly MAX_HISTORY_COUNT = 100;
private trimHistory(): void {
if (this.conversationHistory.length > AIService.MAX_HISTORY_COUNT * 2) {
// 只保留最近 N 轮对话(每轮 2 条:用户+AI)
const keepCount = AIService.MAX_HISTORY_COUNT * 2;
this.conversationHistory = this.conversationHistory.slice(-keepCount);
}
}
十二、隐私与安全
AI 对话涉及用户输入,隐私和安全不能忽视。
12.1 内容安全
用户输入可能包含违规内容,AI 回复也可能生成不当内容。需要:
- 输入审核:用户发的消息先过一遍内容安全检查
- 输出审核:AI 的回复也要检查,过滤违规内容
- 敏感词过滤:涉及政治、色情、暴力等内容直接拦截
对于民族文化类应用,特别要注意:
- 不发表不当民族言论
- 不涉及宗教极端内容
- 不传播虚假历史信息
12.2 隐私保护
- 不要发敏感信息:用户输入里的手机号、身份证号等,最好过滤掉再发给 AI
- 数据脱敏:如果要存对话历史,敏感信息要脱敏
- 告知用户:明确告诉用户对话数据会不会被收集、会不会用于训练
- 本地优先:能本地处理的就不要发给服务器
12.3 费用控制
AI API 是按 token 收费的,调用多了成本不低:
- 限流:每个用户每分钟/每天限制调用次数
- 输入长度限制:用户消息不能太长
- 上下文截断:历史不要带太多,省 token
- 监控告警:费用异常时告警
限流的简单实现:
private static readonly MAX_REQUESTS_PER_MINUTE = 10;
private requestTimestamps: number[] = [];
private checkRateLimit(): boolean {
const now = Date.now();
const oneMinuteAgo = now - 60 * 1000;
// 移除一分钟前的记录
this.requestTimestamps = this.requestTimestamps.filter(t => t > oneMinuteAgo);
if (this.requestTimestamps.length >= AIService.MAX_REQUESTS_PER_MINUTE) {
return false;
}
this.requestTimestamps.push(now);
return true;
}
十三、架构总结
13.1 完整分层架构
┌─────────────────────────────────────────────────────┐
│ AI 对话页(UI 层) │
│ 消息列表 / 输入框 / 加载状态 / Markdown 渲染 │
└──────────────────────┬──────────────────────────────┘
│ 调用
┌──────────────────────▼──────────────────────────────┐
│ AIService(服务层) │
│ sendMessage / 上下文管理 / 历史记录 │
│ 降级策略 / 重试机制 / 限流 │
└─────────┬───────────────────────────────┬───────────┘
│ │
▼ ▼
┌───────────────────┐ ┌─────────────────────────┐
│ 知识检索层 │ │ 模型层 │
│ 向量检索 │ │ LLM API 调用 │
│ 关键词检索 │ │ 流式输出 │
└───────────────────┘ └─────────────────────────┘
│ │
▼ ▼
┌───────────────────┐ ┌─────────────────────────┐
│ 向量数据库 │ │ 大模型 API │
│ Milvus/Pinecone │ │ DeepSeek / GPT │
└───────────────────┘ └─────────────────────────┘
13.2 设计模式总结
| 模式 | 体现 |
|---|---|
| 单例模式 | 全局一个 AI 服务实例 |
| 外观模式 | 封装复杂的 AI API 调用 |
| 策略模式 | Mock / 真实 API 可切换 |
| 观察者模式 | 流式输出的回调机制 |
| 降级模式 | 出错时的降级策略 |
| 熔断模式 | 连续失败时停止请求 |
13.3 从 Mock 到生产的演进路径
| 阶段 | 内容 | 目标 |
|---|---|---|
| V1 Mock | 关键词匹配 + 模板回复 | 跑通流程,验证需求 |
| V2 基础版 | 接入真实 LLM API | 体验真实 AI 对话 |
| V3 增强版 | 接入知识库 + RAG | 回答更准确 |
| V4 完整版 | 流式输出 + 多轮对话 + 语音 | 完整体验 |
十四、小结
| 技术点 | 核心要点 | 难度 |
|---|---|---|
| 系统架构 | 四层架构:对话管理 / 知识检索 / 模型 / 输出处理 | ⭐⭐⭐⭐ |
| 数据模型 | ChatMessage 消息结构(id/role/content/timestamp) | ⭐⭐ |
| 单例服务 | 全局统一管理对话历史 | ⭐⭐ |
| 上下文管理 | system prompt / 滑动窗口 / token 限制 | ⭐⭐⭐⭐ |
| Prompt 工程 | 角色设定 / 少样本 / 格式指定 / 迭代优化 | ⭐⭐⭐⭐ |
| RAG 检索 | 向量检索 / 知识库 / Embedding | ⭐⭐⭐⭐⭐ |
| 流式输出 | SSE / 异步迭代器 / 打字机效果 | ⭐⭐⭐⭐ |
| Markdown 渲染 | 解析器 / 自定义组件 / 代码高亮 | ⭐⭐⭐ |
| 降级容错 | 重试 / 熔断 / 降级策略 | ⭐⭐⭐⭐ |
| 隐私安全 | 内容审核 / 数据脱敏 / 费用控制 | ⭐⭐⭐ |
AIService 虽然现在的实现很简单(就是 Mock 回复),但它代表了一类更复杂的服务——网络请求、状态管理、上下文管理、流式输出、错误处理、知识库检索… 真实生产环境的 AI 服务要考虑的事情还很多。
但只要架构设计得好,就可以从简单的 Mock 开始,逐步迭代增强,而不用一开始就做最复杂的方案。
下一篇我们来看最后一个服务:ApiService,看看如何统一管理所有的后端 API 调用。
更多推荐



所有评论(0)