从 AI 聊天组件源码复盘工程化架构:MVVM、解耦、Provider 与 SSE 流式响应
前言
最近在学习一个 HarmonyOS / ArkTS 里的 AI 聊天组件模块。刚开始看这类项目时,很容易被大量文件名和层级绕晕:页面入口、聊天组件、状态管理、Controller、Provider、HttpClient、SSE、卡片解析、会话管理等内容混在一起,看完一遍很快就忘。
后来我换了一种方式,不再死记每个文件细节,而是先从"用户发送一条消息"这个主流程出发,把整个模块按职责拆开理解。这样再回头看代码,就能知道每个文件大概站在哪一层、负责什么事情。
这篇文章主要记录这次源码学习过程中的架构理解,尽量只总结通用技术思想,不涉及具体公司业务、真实接口、内部库、服务地址和敏感配置。
一、先从整体链路看这个模块
一个 AI 聊天模块并不只是一个输入框加一个消息数组。完整一点的业务组件通常会包含:
text
页面入口
↓
聊天 UI 容器
↓
状态中心 ViewModel
↓
业务流程 Controller
↓
AI 平台 Provider
↓
网络请求 HttpClient
↓
SSE 流式响应
↓
状态回写
↓
UI 自动刷新
可以抽象成这条主线:
text
AgentChatPage
↓
AgentChatComp
↓
ChatViewModel
↓
ChatController
↓
AgentProvider / XxxProvider
↓
HttpClient
这条链路是理解整个模块的核心。只要能说清楚它,后面再去看具体文件就不会完全迷路。
二、每一层分别负责什么
我先用一句口诀概括:
text
Page 负责入口
Comp 负责 UI
ViewModel 负责状态
Controller 负责编排
Provider 负责平台适配
HttpClient 负责请求
Model 负责数据结构
Parser 负责解析
Card 负责展示
展开来看:
| 层级 | 主要职责 |
|---|---|
| Page | 页面入口、路由注册、Provider 创建、业务配置注入 |
| View / Comp | UI 组合、消息列表、输入框、抽屉、Loading、语音蒙层等 |
| ViewModel | 保存页面状态,给 UI 提供状态和方法入口 |
| Controller | 编排发送消息、停止生成、重试、会话切换等业务流程 |
| Provider | 适配不同 AI 平台的接口和协议 |
| HttpClient | 封装 GET、POST、上传、SSE、请求中断、日志等底层网络能力 |
| Model | 定义消息、会话、卡片、配置、返回结果等数据结构 |
| Parser | 把服务端返回的结构化数据解析成前端可用模型 |
| Card | 把解析后的业务卡片渲染成 UI |
这种分层的目的不是为了"看起来高级",而是为了避免所有逻辑都堆在一个页面文件里。
如果不拆层,一个页面里可能会同时出现:
text
UI 布局
输入框状态
消息数组
HTTP 请求
SSE 解析
AI 平台协议
错误处理
会话管理
卡片解析
埋点逻辑
业务跳转
短期可能能跑,但后期维护会非常痛苦。
三、Page:入口和配置装配层
页面入口层通常不负责真正的聊天业务。它更像一个"装配器"。
它主要做几件事:
text
1. 注册页面路由
2. 初始化 AI Provider 配置
3. 创建具体 Provider 实例
4. 构造 ChatConfig
5. 定义卡片 Builder / Loading Builder
6. 渲染核心聊天组件 AgentChatComp
伪代码大概是这样:
ts
@ComponentV2
export struct AgentChatPage {
@Local provider: AgentProvider | null = null
aboutToAppear(): void {
const config = new ProviderConfig()
config.userId = 'current_user_id'
this.provider = new SomeAIProvider(config)
}
build() {
AgentChatComp({
provider: this.provider,
chatConfig: this.buildChatConfig(),
cardsBuilder: this.cardsBuilder,
loadingBuilder: this.loadingBuilder
})
}
}
这里有一个很重要的点:
text
Page 创建 Provider,但不直接发送消息。
页面入口负责把能力传进去,真正的发送流程后面会交给 ChatController。
ChatConfig 的作用
通用聊天组件不能把所有业务行为写死,所以通常会通过配置对象注入业务差异。
比如:
text
欢迎语
推荐问题
动态推荐问题加载方法
链接点击回调
埋点回调
业务跳转回调
抽屉配置
Loading 样式
错误页样式
这样做的好处是:聊天组件保持通用,外部页面根据业务需要传入不同配置。
四、AgentChatComp:真正的聊天 UI 容器
AgentChatComp 可以理解为整个聊天页面的核心 UI 容器。
它主要做四类事情:
text
1. 接收外部传入的 provider、chatConfig、Builder 等参数
2. 创建 ChatViewModel
3. 初始化页面级 Controller
4. 组合聊天页面 UI
典型结构大概是:
text
Stack 根容器
├── 背景层
├── MessageList 消息列表
├── QuickQuestionsCard 推荐问题
├── FloatingButtons 浮动按钮
├── InputBar 输入框
├── VoiceMaskOverlay 语音蒙层
├── LoadingOverlay 加载/错误蒙层
└── ConversationDrawer 会话抽屉
它和 Page 的区别是:
text
AgentChatPage:页面入口,负责业务配置和 Provider 创建
AgentChatComp:聊天主体容器,负责创建 vm、启动 controller、组合 UI
可以这样记:
text
Page 负责把能力传进来
Comp 负责把聊天模块跑起来
五、MVVM:UI 和业务之间的状态桥梁
这个模块里最明显的架构思想是 MVVM。
MVVM 可以拆成:
text
View:页面和组件,负责展示和用户交互
ViewModel:状态中心,负责连接 UI 和业务
Model:数据结构,负责定义数据长什么样
对应到聊天模块中:
text
View:AgentChatComp、MessageList、InputBar、BotBubble、ConversationDrawer
ViewModel:ChatViewModel
Model:ChatItem、AgentCard、ConversationInfo、ChatConfig、AgentResult 等
需要注意的是:
text
ChatController 不是 Model。
Controller 是额外拆出来的业务流程层。Model 是数据结构,比如一条消息、一张卡片、一个会话、一个配置对象。
ChatViewModel 为什么是桥
ChatViewModel 是 UI 和业务之间的桥。
对 UI 来说:
text
UI 通过 vm 读取状态
UI 通过 vm 调用方法
例如:
text
InputBar 读取 / 修改 vm.userInput
MessageList 读取 vm.chatHistory
LoadingOverlay 读取 vm.loading / vm.loadFailed
ConversationDrawer 读取 vm.conversations
对 Controller 来说:
text
Controller 执行业务后,回写 vm 状态
例如:
text
更新 chatHistory
更新 loading
更新 conversationId
更新 quickPhrases
更新 showDrawer
完整链路可以记成:
text
用户操作 UI
↓
UI 调用 vm 方法
↓
vm 转发给 Controller
↓
Controller 执行业务
↓
Controller 更新 vm 状态
↓
UI 根据 vm 状态自动刷新
一句话总结:
text
UI 调 vm,Controller 改 vm,vm 变了 UI 刷新。
六、ChatController:发送消息流程的总指挥
ChatController 的核心职责是:编排一次聊天发送流程。
用户点击发送后,不是 UI 自己去拼参数、发请求、解析结果,而是走 Controller。
流程大概是:
text
用户点击发送
↓
InputBar 调用 vm.sendMessage()
↓
ViewModel 转发给 ChatController.sendMessage()
↓
ChatController 读取 vm.userInput
↓
校验输入和 loading 状态
↓
创建用户消息 ChatItem
↓
先把用户消息加入 vm.chatHistory
↓
清空 vm.userInput
↓
设置 vm.loading = true
↓
创建 AI 回复占位消息
↓
调用 provider.sendMessage()
↓
接收 onDelta / onMessage / onReplyComplete 回调
↓
持续更新 AI 占位消息内容
↓
如果有结构化数据,则解析成卡片
↓
回复完成后恢复 loading
这里有两个关键点。
1. 用户消息先入历史记录
用户点击发送后,用户消息会先加入 chatHistory,让页面立即显示。
不是等 AI 接口返回成功后,再把用户消息和 AI 回复一起放进去。
这样用户体验更自然:
text
我点了发送
↓
我的消息马上出现在聊天列表里
↓
AI 开始思考和回复
2. AI 回复先创建占位消息
AI 回复不是一开始就有完整内容,而是先创建一条空的 AI 消息,再通过流式回调不断更新。
text
创建 AI 占位消息
↓
收到第一个 delta,更新 content
↓
收到第二个 delta,继续更新 content
↓
最终形成完整回复
这和自己写 Demo 时用 setInterval 模拟打字机效果很像,只不过真实项目里数据来自服务端 SSE 流。
七、Provider:用抽象屏蔽不同 AI 平台
AI 聊天模块可能会接入不同平台,比如某个 AI 平台、内部大模型服务、Mock 服务等。
如果聊天组件直接依赖某个平台实现,就会被具体平台绑死。
不好的写法是:
ts
const provider = new SomeConcreteProvider()
provider.sendMessage()
这样以后换平台,就要改聊天组件。
更好的方式是定义一个抽象 Provider:
ts
export abstract class AgentProvider {
abstract getName(): string
abstract sendMessage(
message: string,
conversationId: string,
attachments: AgentAttachment[],
onDelta: (delta: string, fullText: string) => void,
onStatus?: (status: string) => void,
onMessage?: (content: string, msgId: string) => void,
onReplyComplete?: () => void
): Promise<AgentResult>
}
具体平台实现它:
ts
export class SomeAIProvider extends AgentProvider {
getName(): string {
return 'SomeAI'
}
async sendMessage(...): Promise<AgentResult> {
// 这里写具体平台的请求和协议解析
}
}
聊天组件只依赖抽象:
ts
@Param provider: AgentProvider | null = null
这样组件就不关心底层到底是哪一个 AI 平台。
只要外部传入的对象满足 AgentProvider 规范,它就能工作。
这就是解耦和面向接口编程。
可以这样记:
text
组件不关心具体实现类,只关心传入对象是否满足它依赖的接口或抽象类。
八、abstract:只定规则,不干具体活
在 Provider 抽象里会看到 abstract。
例如:
ts
abstract class AgentProvider {
abstract getName(): string
abstract sendMessage(...): Promise<AgentResult>
}
abstract 的意思是:抽象的,只定义规范,不提供完整实现。
抽象类不能直接 new:
ts
const provider = new AgentProvider() // 不允许
它的作用是规定子类必须实现哪些方法。
例如:
text
任何 AI Provider 都必须有 getName()
任何 AI Provider 都必须有 sendMessage()
具体怎么发请求,由子类自己实现。
一句话记忆:
text
abstract = 只定规则,不干具体活。
在架构上的意义是:
text
上层依赖抽象规则
下层提供具体实现
从而降低耦合
九、Provider 和 HttpClient 的区别
这里很容易混。
可以用一句话区分:
text
Provider 管协议,HttpClient 管网络。
Provider 管什么
Provider 是平台适配层,负责"某个平台怎么用"。
它通常会处理:
text
平台接口路径
请求 body 格式
会话创建
文件上传后的 file_id 使用
SSE event 类型含义
平台错误码
取消生成
会话列表
历史消息
推荐问题
比如:
text
哪个 event 是文本增量?
哪个 event 是完整消息?
哪个 event 表示错误?
返回的 JSON 怎么转成 AgentResult?
这些都属于 Provider。
HttpClient 管什么
HttpClient 是底层网络封装。
它通常负责:
text
GET
POST
PUT
upload
stream
abortStream
请求日志
敏感信息脱敏
超时处理
SSE 基础 event/data 解析
它不关心这个 event 是不是 AI 文本,也不关心业务卡片怎么展示。
例如:
text
HttpClient:我只把流里的 event 和 data 拆出来
Provider:我知道这些 event 和 data 在某个平台里代表什么
Controller:我决定怎么更新聊天状态
UI:我负责展示更新后的状态
十、SSE:AI 回复为什么能一点点显示
SSE 全称是 Server-Sent Events。
普通 HTTP 是:
text
客户端请求一次
↓
服务端处理完
↓
一次性返回完整结果
↓
请求结束
SSE 是:
text
客户端发起一次请求
↓
服务端不一次性返回完整答案
↓
而是在这条连接里不断推送 event/data
↓
客户端每收到一段就更新一次 UI
↓
直到服务端发送 completed / done 事件
↓
请求结束
一个简化的 SSE 内容可能长这样:
text
event: message.delta
data: {"content":"你好"}
event: message.delta
data: {"content":",我是 AI 助手"}
event: message.completed
data: {"finish_reason":"stop"}
在 UI 层,前端会先创建一条 AI 占位消息:
text
assistant: ''
然后每收到一个 delta,就更新这条消息:
text
assistant: '你好'
assistant: '你好,我是 AI 助手'
所以用户看到的效果就是 AI 在一点点打字。
在项目里的链路是:
text
ChatController 创建 AI 占位消息
↓
Provider 调用 HttpClient.stream()
↓
HttpClient 接收 SSE 数据流
↓
解析 event / data
↓
Provider 判断 event 类型
↓
触发 onDelta(delta, fullText)
↓
ChatController 更新 AI 占位消息 content
↓
ViewModel 状态变化
↓
MessageList / BotBubble 自动刷新
可以这样记:
text
SSE 负责后端一点点给
onDelta 负责前端一点点收
AI 占位消息负责页面一点点显示
十一、为什么需要 buffer 拼包
SSE 是流式数据,网络返回的数据块不一定刚好是一条完整事件。
服务端可能发送:
text
event: message.delta
data: {"content":"你好"}
但客户端实际收到时可能是:
text
第 1 块:event: mess
第 2 块:age.delta\ndata: {"cont
第 3 块:ent":"你好"}\n\n
所以不能收到一块就立刻当完整数据解析。
正确做法是:
text
1. 收到数据块
2. 转成字符串
3. 放进 sseBuffer
4. 按空行 \n\n 拆完整事件
5. 解析 event 和 data
6. 回调给 Provider
一句话:
text
网络数据块不等于完整 SSE 事件,所以要先拼包再解析。
十二、停止生成不是只改 loading
AI 聊天里常见"停止生成"。
它不是简单地:
ts
loading = false
更完整的流程是:
text
用户点击停止生成
↓
Controller 调用 stopGenerate()
↓
Provider 调用 cancelChat()
↓
HttpClient.abortStream() 中断本地 SSE
↓
Provider 通知服务端取消当前生成
↓
保留已经生成的部分文本
↓
恢复 loading 状态
还需要区分:
text
用户主动停止
网络异常中断
用户主动停止不应该提示"网络错误"。
所以通常会定义类似 StreamAbortedError 的错误类型,用于表示这是主动取消,不是真正的异常。
十三、Model:数据结构不是业务流程
前面容易混的一点是:Model 不是 Controller。
Model 是数据结构,比如:
text
ChatItem:一条聊天消息
ConversationInfo:一个会话信息
AgentCard:一个业务卡片
ChatConfig:聊天配置
AgentResult:AI 返回结果
AgentAttachment:附件信息
例如:
ts
export class ChatItem {
role: string = ''
content: string = ''
time: string = ''
cards: AgentCard[] = []
}
它只定义数据长什么样,不负责发送消息。
而 ChatController 是业务流程层,它负责一轮消息发送怎么跑。
可以这样记:
text
Model = 数据本身长什么样
Controller = 业务流程怎么跑
十四、Builder 和 Config:让组件更通用
聊天组件通常会支持业务卡片,比如路线卡片、商品卡片、订单卡片、推荐卡片等。
如果把这些具体业务 UI 全写死在聊天组件里,组件就很难复用。
更好的方式是通过 Builder 参数把具体卡片渲染交给外部:
ts
@BuilderParam cardsBuilder: (cards: AgentCard[]) => void
外部页面可以自己决定:
ts
@Builder
cardsBuilder(cards: AgentCard[]) {
RouteCardList({
cards: cards.filter(item => item.cardType === 'route')
})
PoiCardList({
cards: cards.filter(item => item.cardType === 'poi')
})
}
聊天组件只负责把卡片数据交出去:
text
我有 cards
我交给 cardsBuilder
至于怎么展示,由外部决定
这也是解耦。
同样,ChatConfig 可以把欢迎语、推荐问题、埋点、链接点击、业务跳转等差异配置化。
可以总结为:
text
Config 负责注入行为差异
Builder 负责注入 UI 差异
十五、HAR 共享包与解耦
在 HarmonyOS 项目中,这类聊天模块可以封装成 HAR 共享包。
HAR 可以理解成 HarmonyOS 里的共享代码包或组件库包。
它不是 exe,也不是可以直接双击运行的程序,更类似:
text
前端里的 npm package
Android 里的 AAR
Java 里的 JAR
HAR 通常用于封装:
text
公共组件
工具方法
业务模块
页面能力
请求封装
数据模型
资源文件
它解决的是工程结构层面的复用。
而解耦解决的是代码设计层面的依赖关系。
可以这样区分:
text
HAR 让代码从工程结构上独立出来
解耦让代码从职责关系上独立出来
两者可以一起使用。
例如:
text
把 AI 聊天模块做成 HAR
↓
主工程通过 import 使用它暴露的组件和能力
↓
模块内部再通过 ViewModel、Controller、Provider、HttpClient 分层解耦
十六、目前我对这套架构的理解
经过这几节源码学习,我对这个 AI 聊天模块的理解可以总结成:
text
它不是一个简单页面,而是一个完整的 AI 聊天能力模块。
Page 负责入口和业务配置。
AgentChatComp 负责搭建聊天 UI。
ChatViewModel 负责保存状态。
ChatController 负责编排发送消息流程。
AgentProvider 定义统一 AI 能力。
具体 Provider 负责适配某个 AI 平台协议。
HttpClient 负责底层请求和 SSE 流式响应。
Model 负责定义消息、会话、卡片等数据结构。
用户点击发送后,完整链路是:
text
InputBar 调 vm.sendMessage
↓
ViewModel 转给 ChatController
↓
Controller 先把用户消息写进 chatHistory
↓
Controller 创建 AI 占位消息
↓
Controller 调 Provider
↓
Provider 调 HttpClient.stream 发起 SSE
↓
服务端不断返回 delta
↓
Provider 解析事件并回调 Controller
↓
Controller 更新 AI 占位消息
↓
ViewModel 状态变化
↓
MessageList 自动刷新
最终形成完整对话。
十七、复习口诀
最后整理一版方便记忆的口诀:
text
Page 负责入口
Comp 负责 UI
ViewModel 负责状态
Controller 负责编排
Provider 负责平台适配
HttpClient 负责请求
Model 负责数据结构
Parser 负责解析
Card 负责展示
再短一点:
text
UI 调 vm,Controller 改 vm,Provider 接平台,HttpClient 发请求。
发送流程口诀:
text
先加用户消息
再加 AI 占位
Provider 发请求
SSE 回 delta
Controller 改消息
ViewModel 驱动 UI
分层边界口诀:
text
Provider 管协议
HttpClient 管网络
Model 管数据结构
Controller 管业务流程
十八、学习感受
这次源码学习最大的感受是:复杂项目不能只靠"看一遍"。看完很快忘是正常的,关键是要反复回忆主链路。
比起死记每个方法,更重要的是先回答几个问题:
text
入口在哪?
UI 在哪?
状态在哪?
业务流程在哪?
请求在哪?
AI 平台协议在哪?
服务端结果怎么回到 UI?
当这些问题能答出来后,再去看某个具体文件,才会知道它在整个架构中的位置。
目前对我来说,最重要的是先掌握这条主线:
text
AgentChatPage
↓
AgentChatComp
↓
ChatViewModel
↓
ChatController
↓
AgentProvider / XxxProvider
↓
HttpClient
↓
SSE
↓
ViewModel
↓
UI
这条线记住后,再继续往下分析卡片解析、消息转换、业务卡片渲染,就会顺很多。
总结
一个工程化的 AI 聊天模块,本质上是在解决几个问题:
text
UI 如何拆分?
状态如何管理?
业务流程如何编排?
不同 AI 平台如何适配?
流式响应如何处理?
数据如何转换成 UI?
模块如何复用?
这套架构的核心不是某个具体 API,而是分层思想:
text
通过 ViewModel 隔开 UI 和状态,
通过 Controller 承接复杂业务流程,
通过 Provider 屏蔽不同 AI 平台差异,
通过 HttpClient 统一网络请求,
通过 Config 和 Builder 保持组件可扩展,
通过 Model 和 Parser 让数据结构清晰。
以后再看类似项目时,可以先不急着钻细节,先找这几层:
text
入口层
UI 层
状态层
流程层
平台适配层
请求层
数据模型层
解析层
这样就不会一看到大量文件就迷路。