小师傅 AI 的架构设计
1. 背景与挑战 (Why)
在构建智能客服助手的过程中,我们面临三个核心技术痛点,直接影响用户体验与研发效率:
-
实时性体验瓶颈:用户期望 AI 回复具有“流式打字机”效果,而非长时间等待后的全量返回。
-
代码维护灾难:支持文本、卡片、多选、评价等 8+ 种复杂消息类型。传统的
if-else或switch逻辑导致解析层臃肿(300+ 行重复代码),扩展极其困难。 -
性能稳定性压力:在长对话场景下,频繁的流式数据更新易触发全局重绘(Rebuild),导致 UI 卡顿或 OOM(内存溢出)。
2. 核心架构设计思路 (How)
A. 通讯协议:SSE (Server-Sent Events) 流式交互
相比于 WebSocket,我选择了更轻量级的 SSE 方案:
-
选型逻辑:AI 对话本质是单向数据流(服务端推送),SSE 原生支持 HTTP,具备自动重连机制,且对企业防火墙更友好。
-
自研客户端:封装自定义
SSEClient替代第三方库,深度定制了重连策略(指数退避算法)与错误拦截机制,确保长连接的稳定性。
SSE协议内置了自动重连机制:当浏览器与服务器的连接意外断开时,浏览器(通过EventSource接口)会自动尝试重新连接,并携带最后接收的Event ID(如果有),以便服务器知道从哪里继续推送,保证数据流的连续性,而开发者可以通过设置
eventSource.retry属性来调整重连间隔。
SSE自动重连的原理
- 自动重试: SSE协议规范规定,连接失败后浏览器会尝试恢复连接。
- 事件ID(Event ID): 客户端会保存上次接收到的event ID,重连时会通过 HTTP 头部发送给服务器,告诉服务器从哪个事件ID之后的消息开始发送。
- 可配置重连间隔: 你可以使用 JavaScript 访问
EventSource对象的retry属性来设置浏览器重试连接的间隔(毫秒),例如eventSource.retry = 5000;表示每 5 秒重试一次。
SSE 本身就是单向的(服务端→客户端的长连接),Little Master 里用户“发送消息”走的是普通 HTTP 请求(POST),只是复用同一个 SSE 流来接收后续响应。整体效果看上去像双向,但技术上是:
- 上行:LittleMasterConnectionProvider.connect 调用 SseService.streamMessages,在建立 SSE 前先把请求体通过 POST 发送出去(LittleMasterHttpClient.subscribeToSSE 的 POST 分支),这是一次性请求。
- 下行:同一个请求返回的是 text/event-stream,后续所有结果通过 SSE 单向推送。
所以通道是单向的 SSE,下行;上行用普通 HTTP 请求,两者组合实现“类似双向”的交互。
B. 解耦核心:策略模式 + Parser Registry
为了消除臃肿的判断逻辑,我设计了一套分层解析注册系统。
-
传统方案:硬编码判断,逻辑耦合。
-
我的方案:通过注册器统一管理解析策略。
// 架构实现:统一注册,一行代码接入新类型
SseJsonParserRegistry.registerParser('message', 'voteCard', _parseVoteCard);
// 统一解析入口:通过 Registry 自动分发
final result = SseJsonParserRegistry.parse(eventType, jsonData);
Parser Registry 详解
要解决的问题
-
SSE 返回的 JSON 格式多且易变(reason 流、文本、建议卡、混合卡、评价/评分卡、项目列表、播报、固定格式卡等)。
-
过去用大 if/else(或多策略类)既难读又难扩展,新增一种格式要改一堆分支,容易出错。
-
目标:把“识别类型”与“具体解析”解耦,形成可注册、可演进、可复用的解析管线。
核心结构
-
路由表 _parsers[eventType][messageType]:双键定位解析函数,避免层层条件判断(common/core/sse_json_parser_registry.dart)。
-
自动探测器 _messageDetectors:一组按优先级排好的函数(suggestion、mixed_card、evaluation、score、itemListCard、plainTextCard、submitResult、固定格式、项目评分、播报…),逐个尝试,命中即返回 messageType。
-
统一输出模型 SseEventData:解析结果强制收敛为 text/card/id/status/isComplete/isError,后续处理器无需关心原始 JSON 细节。
-
注册入口 registerParser(eventType, messageType, parser):业务新增格式时无需动核心逻辑,直接挂载解析器。
运行链路(讲给面试官的流水)
-
parse(eventType, data) 被调用。
-
_detectMessageType:
-
先看 eventType(reason/message/suggestion/close/error…)。
-
对 message 事件,依次跑 _messageDetectors 找出最匹配的 messageType;找不到就给默认 direct_text。
-
-
路由查找:用 eventType + messageType 在 _parsers 取解析函数;未命中返回空数据,异常走 _createFallbackData。
-
解析函数产出 SseEventData(含 text 或 card、状态、错误标记)。
-
下游 SseEventHandlerFactory 依据 SseEventData 写入对话模型,完全不用关心 JSON 细节。
扩展示例(两步走)
- 有新卡片 fooCard,特征是 content.message.content.type == ‘fooCard’。写一个 detector(可选,如果特征简单也可直接注册):
static String? _detectFooCard(Map<String, dynamic> data) {
return data['content']?['message']?['content']?['type'] == 'fooCard' ? 'fooCard' : null;
}
并把它放进 _messageDetectors。
- 注册解析器:
SseJsonParserRegistry.registerParser('message', 'fooCard', (data) {
final content = data['content']['message']['content'];
return SseEventData(card: {
'type': 'fooCard',
'id': content['id'],
'content': content['payload'],
});
});
不动其他代码即可生效。
-
健壮性设计
-
深度安全取值 _safeGet,避免 null/类型错误直接抛异常。
-
状态门控:如 itemListCard 在 status == UPDATING 时直接空返回,避免 UI 显示占位。
-
兼容多套字段命名:评分卡 eval/score、播报卡 camelCase/snake_case 兼容。
-
错误与 fallback:错误事件专门解析 errorMessage/errorType;未知/异常时返回 fallback 文本,确保用户能看到可用提示而非崩溃。
-
-
为什么比传统 if/else 好
-
清晰路由:事件维度 + 消息维度双键定位,阅读和调试成本低。
-
开放封闭:新增类型靠注册而非改旧分支,降低回归风险。
-
复用/隔离:不同业务场景(请问/约工/播报/评分)共用同一框架,差异被 messageType 隔离,避免重复解析代码。
-
测试友好:每个解析器是纯函数,入参出参明确;可单测 detector 和 parser,不依赖 UI。
-
容错强:面对脏数据、占位流、错误事件有兜底,用户体验稳定。
-
-
讲解时的形象比喻
- 把它比作“海关分拣”:先看入境口(eventType),再看货物类型标签(messageType)。匹配到窗口后由专人(解析函数)拆包,最终都转成标准的托盘(SseEventData)送往仓库(Handler/UI)。新增货物类型只要再设一个窗口,不用改旧窗口规则。
C. 复杂状态治理:Fragment 级别局部更新
针对流式更新带来的性能挑战,我实现了细粒度的状态管理方案:
-
分层虚拟化:将对话拆解为最小单元(Fragment)。
-
局部重建:利用
Provider的Selector或Consumer机制,确保只有当前正在更新的文本片段触发重绘,现有历史消息保持静默。 -
状态自治:卡片内部(如多选、评分)拥有独立状态机,避免父级业务逻辑干扰 UI 渲染。
3. 深度解析:如何实现“一行代码注册”?
面试官常问:“你说一行代码注册,那 UI 解析逻辑去哪了?”
底层核心在于“职责分离”的多层映射架构:
| 层次 | 职责 | 变动频率 |
|---|---|---|
| Layer 1: 解析层 | JSON → 标准数据 (SseEventData)。负责字段提取与清洗。 | 随后端协议变动 |
| Layer 2: 模型层 | 标准数据 → 业务对象 (MessageModel)。负责类型安全约束。 | 稳定 |
| Layer 3: 渲染层 | 业务对象 → Widget。负责 UI 逻辑与交互。 | 随设计稿变动 |
结论:所谓的“一行代码”是指架构层面的挂载。通过这种设计,我将原本散落在各处的 if-else 转化为了结构化的业务实现,新功能的开发只涉及“增量代码”,而非“修改旧代码”。
4. 技术成果 (What)
-
研发效能:新增消息类型的集成工作量降低了 80%,解析层实现“零耦合”。
-
运行性能:在 100+ 条消息的长列表中,流式更新帧率稳定在 60FPS,重绘区域缩小了 95% 以上。
-
健壮性:崩溃率控制在 0.01%,通过 SSE 状态机自愈机制,弱网环境下的重连成功率提升了 40%。
💡 面试官高频 Q&A 模拟
Q1:为什么不用 WebSocket 而选择 SSE?
A:首先,AI 场景是典型的单向推送,SSE 协议更轻量、开销更小;其次,SSE 基于标准 HTTP,天然支持自动重连和 Event ID 断点续传,而 WebSocket 需要自研心跳包和重连逻辑。
Q2:Parser Registry 使用了什么设计模式?
A:它结合了策略模式(Strategy)与简单工厂模式(Factory)。策略模式负责解析算法的替换,工厂模式负责根据后端返回的 type 动态创建解析对象。
Q3:如何处理极其复杂的嵌套卡片(如多层表单)?
A:我引入了分层虚拟化索引。构建“目录-索引-项目”的三级映射关系,将搜索和定位的复杂度从 $O(n)$ 降至 $O(1)$,即使是千级条目的复杂表单也能实现毫秒级跳转。