小师傅 AI 的架构设计

1. 背景与挑战 (Why)

在构建智能客服助手的过程中,我们面临三个核心技术痛点,直接影响用户体验与研发效率:

  • 实时性体验瓶颈:用户期望 AI 回复具有“流式打字机”效果,而非长时间等待后的全量返回。

  • 代码维护灾难:支持文本、卡片、多选、评价等 8+ 种复杂消息类型。传统的 if-elseswitch 逻辑导致解析层臃肿(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):业务新增格式时无需动核心逻辑,直接挂载解析器。

运行链路(讲给面试官的流水)
  1. parse(eventType, data) 被调用。

  2. _detectMessageType:

    • 先看 eventType(reason/message/suggestion/close/error…)。

    • 对 message 事件,依次跑 _messageDetectors 找出最匹配的 messageType;找不到就给默认 direct_text。

  3. 路由查找:用 eventType + messageType 在 _parsers 取解析函数;未命中返回空数据,异常走 _createFallbackData。

  4. 解析函数产出 SseEventData(含 text 或 card、状态、错误标记)。

  5. 下游 SseEventHandlerFactory 依据 SseEventData 写入对话模型,完全不用关心 JSON 细节。

扩展示例(两步走)
  1. 有新卡片 fooCard,特征是 content.message.content.type == ‘fooCard’。写一个 detector(可选,如果特征简单也可直接注册):
	 static String? _detectFooCard(Map<String, dynamic> data) {
	   return data['content']?['message']?['content']?['type'] == 'fooCard' ? 'fooCard' : null;
	 }

并把它放进 _messageDetectors。

  1. 注册解析器:
	 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 级别局部更新

针对流式更新带来的性能挑战,我实现了细粒度的状态管理方案

  1. 分层虚拟化:将对话拆解为最小单元(Fragment)。

  2. 局部重建:利用 ProviderSelectorConsumer 机制,确保只有当前正在更新的文本片段触发重绘,现有历史消息保持静默。

  3. 状态自治:卡片内部(如多选、评分)拥有独立状态机,避免父级业务逻辑干扰 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)$,即使是千级条目的复杂表单也能实现毫秒级跳转。