三个月前接手这套系统时我内心的声音其实很矛盾。这是一套跑了大半年的 AI 对话产品模型侧已经迭代得很复杂可前端 UI 这层还停留在一个古老的自定义 JSON 协议上。所谓的AI UI 协议说穿了就是模型输出到页面之间那一层结构化约定每条消息该渲染成什么组件、事件怎么回传、流式增量怎么合并。旧代码能跑但加一次需求就要动十几个 if-else我实在忍不了决定用 Blazor 做一次彻底重构。三个月下来最大的体会是重构一个协议真正难的不是写渲染代码而是把隐式的坑一个个翻出来。这篇文章把我踩过的坑、排查思路、最终方案完整记下来给同样打算用 Blazor 重构这类协议层的人一个参考。1. 重构触发点旧协议不是丑是已经开始说谎1.1 协议演进到第 18 版后发生了什么我接手时的协议文档标记是 v18但代码里的 switch 分支已经是二十多个 case。文本、代码块、表格、图表、任务清单、工具调用卡片、步骤流、图片轮播、语音数据……这些消息类型各有各的渲染逻辑但协议的顶层结构十年来基本没变一个 JSON 数组每个元素带type字段前端根据type选择组件。听起来很合理对吧问题在于 v6 之后协议开始注水每个类型都有自己的extra字段用来塞私有数据有人塞markdown有人塞html还有人塞一个嵌套的payload。前端组件拿到extra之后还得再猜一层。到 v13 的时候团队为了兼容旧客户端又加了一个render_hint字段专门告诉前端这个类型新版本可以按新方式渲染但老版本请退回旧渲染。于是每个组件的渲染逻辑里同时存在三条路径正常渲染、兼容模式、降级模式。我随便打开一个组件文件光是判断条件就有七层嵌套。最恐怖的是代码里开始出现一些永远走不到的分支——协议文档说某字段已经废弃但旧数据还在生产环境跑谁也不敢删删了线上就有用户报空白。这种协议已经不只是丑的问题了它开始说谎字段名和真实含义对不上默认值在不同组件里含义不同同一个type在不同上下文中的渲染结果可能完全不同。任何新需求进来开发要先考古再打补丁最后测试还要背一堆这里虽然显示 X 但实际含义是 Y的隐性知识。我当时的判断很明确与其继续在这坨泥巴上修修补补不如重新设计一个协议层用一套干净的渲染引擎来承载它。1.2 为什么选 Blazor Server 作为渲染宿主团队背景决定了技术选型我们主力是 .NET 技术栈后端服务、模型调度服务全是 C#。如果前端引入 React 或 Vue意味着协议解析逻辑要再用 TypeScript 写一遍同一份协议维护两套代码跨语言联调的成本会抵消掉前端生态的优势。Blazor 的吸引力在于协议模型、校验逻辑、渲染逻辑可以用同一门语言写完工具类代码直接共享。我对比过三种方案。Blazor WebAssembly 的好处是发布后纯静态部署、交互响应快但首次加载要下载 .NET 运行时体量不小而且 WebAssembly 里调用浏览器 API 要通过 JS 互操作后面做剪贴板、文件上传这类功能会绕。Blazor Server 走 SignalR 长连接每次 UI 事件和渲染更新都要过网络延迟比 Wasm 高几毫秒但对内网或者对交互实时性要求不极端的场景完全够用。我们这套 AI 聊天产品大部分时候是人机对话服务器本来就要维持长连接接收模型流式输出SignalR 正好复用。我最终选了 Blazor Server核心原因是流式输出天然在服务端产生与其推给前端再解析不如服务端直接把渲染树增量推过去省一层网络传输。当时我还列了一个风险清单后来三个月踩的坑基本都在清单里流式高频更新时的性能、SignalR 连接稳定性、事件回传的上下文丢失、动态渲染 UI 树时的组件生命周期管理。只是纸上列风险和真正被坑是两码事后面每一个都让我付出了代价。1.3 三个月前立下的目标重构不是推翻一切。我当时的目标是协议层重新建模渲染管线换成 Blazor 组件树但对外 API 尽量保持兼容让老的调用方可以渐进迁移。具体拆成三件事第一定义一套新的UIBlock协议模型替代原来散装的type extra第二写一个协议解析层负责把 JSON 安全地变成强类型对象第三用 Blazor 组件做渲染层每个协议类型对应一个组件支持流式增量更新和事件回传。这三件事看着简单做起来每一步都踩出了新坑。2. System.Text.Json 的多态反序列化第一个星期就把我按在地上摩擦2.1 问题一个 JSON 单元五个渲染分支新协议模型我设计得很自然一个抽象类UIBlock下面派生出TextBlock、CodeBlock、TableBlock、ChartBlock、ToolCardBlock等具体类型。协议报文里用一个type字符串标明实际类型JSON 长这样{ type: code, id: msg_123_block_4, language: python, code: print(hello), metadata: { status: done } }我希望客户端拿到这个 JSON 之后一行JsonSerializer.DeserializeUIBlock(json)就得到正确的强类型对象。结果第一行代码就教做人了System.Text.Json默认不支持把 JSON 反序列化成抽象类或接口它会直接抛NotSupportedException。我只能写一个自定义JsonConverter来做类型分发。2.2 排查链路为什么 v18 的老协议反而好使我最初的反应很困惑原来的老代码也是JsonSerializer.DeserializeMessage它为什么没炸查了一圈才明白老协议把所有内容塞进一个Dictionarystring, object根本没有强类型建模反序列化当然不会失败但后续每次访问字段都要拆箱、转型、判空类型安全荡然无存。换句话说老协议不是没遇到这个坑而是它用放弃类型安全的方式绕开了这个坑。这给我一个重要提醒重构协议不能只盯着 JSON 层面的兼容模型层面的安全性与灵活性必须同时保证否则新协议会变成新的Dictionarystring, object。排查中还发现System.Text.Json对JsonConverter的解析有顺序要求。如果我在抽象类上标注[JsonConverter(typeof(UIBlockConverter))]那么整个转换过程都会由我的转换器接管包括子类型内的属性。因此转换器内部必须自己处理DeserializeTextBlock(json)而不能假设框架会帮忙做子类型多态。疏忽这个顺序会出现转换器里调转换器的递归死循环。后来我把转换器拆成两层外层只负责读type字段、分派类型内层每个类型用各自的JsonConverter或普通反序列化问题才消停。2.3 解法自研 JsonConverter 做类型分发最终我写了一个UIBlockConverter核心逻辑很简单先解析出type字段再用 switch 表达式映射到具体类型然后手动反序列化。为了避免每次 parse 两遍我用JsonDocument先读出根节点再基于根节点反序列化具体类型。public class UIBlockConverter : JsonConverterUIBlock { public override UIBlock? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { using var doc JsonDocument.ParseValue(ref reader); var root doc.RootElement; var type root.GetProperty(type).GetString(); return type switch { text root.DeserializeTextBlock(options), code root.DeserializeCodeBlock(options), table root.DeserializeTableBlock(options), chart root.DeserializeChartBlock(options), tool_card root.DeserializeToolCardBlock(options), _ new UnknownBlock { Raw root.GetRawText() } }; } public override void Write(Utf8JsonWriter writer, UIBlock value, JsonSerializerOptions options) { JsonSerializer.Serialize(writer, value, value.GetType(), options); } }这里有个关键取舍遇到未知类型时不要抛异常而是返回一个UnknownBlock保留原始 JSON 文本。这样前端至少能给出一个暂不支持渲染此内容的兜底界面而不是整条消息渲染失败。AI 模型的输出是动态的谁能保证线上模型不会突然吐出一个文档里没有的新类型协议设计必须把未知当成一等公民。2.4 边角问题null、大小写、字段缺失一个都不能放过写转换器时我一开始对null处理得很随意。AI 模型返回的 JSON 经常带一些奇怪的空白字段比如code: null或者整个metadata缺失。默认反序列化特性如果没设DefaultIgnoreCondition和PropertyNameCaseInsensitive很容易在运行时出现意料之外的空引用。我的做法是协议模型里所有可空字段一律用string?或object?并在模型层统一初始化成Empty确保 UI 渲染组件拿到的不是 null 而是空值。这样把null 检查收敛到解析层渲染层可以默认所有字段都有值。这些边角问题消耗了我第一周至少一半的时间但它们恰恰决定了协议层能不能稳定跑在真实流量的毒打之下。后来我把这套解析逻辑抽成了一个独立的ProtocolParser服务单元测试覆盖未知类型、字段缺失、大小写差异、嵌套块等场景总算让上半层稳定下来。3. 流式输出与组件刷新核心链路几乎被我自己写死3.1 现象输出越流畅界面越卡协议解析跑通之后我开始接入真实模型输出。AI 回复是流式的模型每生成一小段 token后端就会通过内部消息推送到渲染服务。我的第一版实现很简单粗暴每收到一个增量就重新反序列化整个消息块然后调用StateHasChanged()刷新整个页面。测试的时候短消息没什么感觉一旦模型开始输出大段长文页面明显卡顿输入框打字都开始掉帧。最夸张的一次一段 3000 字的回答页面滚动时 CPU 占用直接拉满。一开始我以为是 SignalR 消息频率太高导致的网络问题但是抓包看消息频率确实高每秒能有几十个增量包。问题的关键不是网络而是每次增量到达我都重建了整棵 UI 组件树所有子组件全部重新执行渲染生命周期。这个行为在 Blazor 里代价非常高组件参数比较、虚拟 DOM diff、样式重算全都要走一遍。模型输出越流畅增量包越多界面反而越卡形成了一个荒谬的反向优化。3.2 排查过程从状态冲突到渲染风暴我先怀疑是StateHasChanged被多个线程同时调用导致的状态冲突。Blazor Server 的 UI 线程是基于SynchronizationContext的如果我在后台 Task 里直接调用StateHasChanged确实可能抛出当前线程不在 SynchronizationContext 上的异常。检查了一圈代码里所有增量处理都回到了 UI 线程上下文所以这一层不是根因。接着我做了一次最小复现手动模拟每秒推送 50 个增量包每个包只更新一个文本节点的值。实测发现即使状态更新逻辑再简单只要每次触发全组件树重建性能就上不去。Blazor 的组件树在每次渲染时会对所有子组件执行SetParametersAsync即便参数引用没变也会走一遍 diff。我当时的组件树里一个消息块平均嵌套 4 层组件消息一多整个页面的渲染成本就是 O(消息数 × 嵌套深度)。这就是典型的渲染风暴。3.3 根因全量重建而不是只更新增量根本问题是我在协议层设了一个错误的粒度我把一条消息当成了渲染的最小单元AI 输出的时候每个 token 增量都试图重渲染这条消息的完整 UI。正确的粒度应该是一个 UIBlock 内部的可增量节点。比如一个TextBlock它内部可能是一个 Markdown 字符串渲染组件只需要在每次增量时追加文本片段没必要重建整个组件。我的出路不是放弃 Blazor 的组件模型而是改变数据流协议层不再为每个增量推送整棵树而是推送增量补丁组件内部维护一个ListDelta每次只把增量追加到对应节点再通过受控的刷新频率把改动批量反映到 UI。3.4 解决方案增量队列 节流刷新 局部渲染我设计了一个DeltaStreamer服务作用是把高频的模型增量包先放进一个队列以固定时间窗口合并后再批量应用。窗口我调到 80 毫秒比人眼能感知的最低延迟低不少同时能把每秒几十次的刷新降到十几次。private readonly ConcurrentQueueDelta _pendingDeltas new(); private CancellationTokenSource? _flushCts; public void Enqueue(Delta delta) { _pendingDeltas.Enqueue(delta); if (_flushCts ! null) return; _flushCts new CancellationTokenSource(); _ FlushAsync(_flushCts.Token); } private async Task FlushAsync(CancellationToken ct) { await Task.Delay(80, ct); _flushCts null; while (_pendingDeltas.TryDequeue(out var delta)) { await ApplyDeltaAsync(delta); } StateHasChanged(); }配套的组件层我做了三点约束。第一每个UIBlock组件只监听自己所属块的事件不监听全局消息事件第二文本块用RenderFragment只渲染自己那一段避免父子组件共享可变状态第三块内部如果只是追加文本调用StateHasChanged时只刷新当前组件不向上冒泡。这三点配合增量队列之后刚才那段 3000 字的长文从 CPU 拉满降到了稳定占用 30% 以内滚动也不再掉帧。这个坑给我的教训是Blazor 的组件化思维和传统的前端框架一样粒度决定了性能。协议层设计得再漂亮如果渲染层每次都拿整棵树去刷最终一定卡死在频繁小更新上。4. 事件回传闭环EventCallback、JS互操作与 Circuit 的三方拉扯4.1 需求场景协议不光要渲染还要能交互AI UI 协议不只是模型写给页面看的文本它还必须承载交互。比如一个工具调用卡片用户点击批准执行按钮前端要把事件回传给后端后端再调用工具并把结果送回对话流。再比如表格组件里有一个展开详情的链接点击后需要拉起一个抽屉。这些都要求协议块内部的组件能安全地把事件传递到 .NET 业务逻辑层。我最初天真地以为这不算事Blazor 组件本来就能用EventCallback绑定按钮点击事件直接onclickHandleAsync不就完了真正跑起来才遇到第一个坑协议块是通过RenderFragment或DynamicComponent动态创建的子组件里定义的EventCallback参数在反复重建时会被重置。具体表现是页面正常显示按钮也能点但点完之后回调方法有时候执行有时候完全不执行而且没有任何报错。4.2 排查链路为什么按钮点了没反应我花了两天才定位到原因。Blazor Server 的事件处理依赖 SignalR 连接也就是每个浏览器页面和服务器之前的电路Circuit。协议块组件每次重建时我都在父组件里重新创建了事件回调的委托而旧 Circuit 里订阅的事件处理器还握着旧委托引用。经过几轮流式更新之后组件树的委托引用和 SignalR 线路中的处理器不再一致于是点击事件被派发到了一个过期的组件实例上。排查过程中我做了三件事验证这个判断第一打开浏览器控制台看 SignalR 连接是否稳定结果连接是正常的第二在回调方法里加日志发现事件根本没进来第三临时把按钮事件绑定改成在最外层固定组件上用bind刷新后事件就正常了。由此确定问题出在动态组件的事件回调引用不稳定。4.3 修复与防再犯我的修复方案是把所有协议块的事件处理统一收敛到一个固定的InteractionBroker服务组件内部不直接持有EventCallback委托而是通过一个稳定的ActionProtocolEvent管道把事件发给 BrokerBroker 再根据事件类型分发到业务逻辑。这样组件树怎么重建事件管道都是同一个持有者不会出现旧委托问题。代码层面看起来是这样public class InteractionBroker { public event EventHandlerProtocolEventArgs? Raised; public void Raise(string blockId, string action, object? payload null) { Raised?.Invoke(this, new ProtocolEventArgs(blockId, action, payload)); } }组件的按钮点击就只调_broker.Raise(...)不直接跨越组件边界调用父级方法。另一个实践上的细节Blazor Server 里如果页面在后台放久了Circuit 可能因为超时被回收此时事件回传不会成功。我后来在前端加了一个断线重连状态同步逻辑每次重连成功后前端会重新拉取一次模型消息的完整快照保证 UI 状态不丢。这个环节不属于协议本身但不处理它协议的事件闭环永远是脆弱的。这个坑教会我一件事动态 UI 协议里事件通道和渲染通道必须分开设计。渲染通道可以自由创建和销毁组件事件通道则必须保持稳定生命周期否则用户交互永远会出现时灵时不灵的诡异问题。5. 协议演进向后兼容是重构里的隐形工作5.1 兼容性问题新协议上线第一天就出了白屏重构成了三个月真正让我意识到协议演进是核心工作的是一次线上事故。新协议上线后我加了全新的step_list类型同时把老协议里extra.markdown的字段重命名为content。结果一批老客户端请求进来直接白屏因为老客户端还在发旧字段名extra.markdown而我新的TextBlock模型只认content解析出来是空字符串组件渲染了一个空白块。这个事故让我清醒重构一个仍在线上运行的协议不能假设所有调用方可以同步升级。AI 产品的客户端多、升级周期不统一协议必须在一开始就内置多版本共存的能力。5.2 具体策略版本号、字段演进、宽容解析三件套我后来给协议做了三个补丁。第一顶层加protocol_version字段客户端上报自己支持的版本范围服务端根据版本范围决定下发哪种结构的消息。第二字段演进规则新增字段必须有默认值禁止删除字段字段语义变化时新增一个带_v2后缀的字段而不是改旧字段。第三解析层做宽容处理未知字段不忽略也不报错而是存进Extensions字典里组件需要时可以取用。举个例子TextBlock的设计变成public class TextBlock : UIBlock { public string? Content { get; set; } public string? LegacyMarkdown { get; set; } public string GetEffectiveContent() Content ?? LegacyMarkdown ?? string.Empty; }每次解析时如果Content为空而LegacyMarkdown有值组件就使用旧字段渲染同时后台打一条日志方便统计还有多少老客户端流量。等老流量降到阈值以下我再把兼容代码下线。5.3 迁移测试用生产流量做回放验证协议兼容单靠几个单元测试是不够的。我搭了一个流量回放管道把生产环境过去一周的真实消息 JSON 全部匿名化存成测试样本集。每次协议解析层发新版本先用样本集跑一遍回归重点检查旧数据能否在新解析器下无损加载、渲染层能否正常出图。这个动作帮我挡住了至少三次潜在的线上事故。有一次我调整了嵌套块的结构如果没有回放测试可能要到线上用户报历史聊天记录打不开才发现。这个阶段我深刻意识到协议重构最大的成本不是写代码而是维护历史债务。任何一次版本升级都要同时考虑存量数据和增量数据否则就是在给未来的自己埋雷。6. 三个月之后哪些决定让我后悔哪些我建议保留6.1 最后悔的三个决策回头看这三个月有几个决定是当初拍脑袋做的代价不小。第一个后悔的是重构初期没有先画出完整的协议生命周期图就直接写模型。我当时以为协议就是几个类、几个 JSON 字段的事结果做到流式增量时发现增量和全量之间的关系没定义清楚又回头改模型连带解析层、渲染层一起返工。如果最开始就画出创建块、流式增量、完成、事件回传、历史消息恢复这几条路径后面至少省两周。第二个后悔的是把StateHasChanged当成万能刷新手段。准确说不是不能用而是要明确知道它是整棵组件树级别的信号。在协议渲染这种高频更新场景里它造成的性能损耗比想象中严重得多。后来我用增量队列和局部刷新本质上是把渲染粒度从消息降到了块再从块降到了增量节点。第三个后悔的是事件回传链路没有在设计协议时提前考虑。我一开始把协议当成了纯展示层直到需要用户点击按钮才临时加事件管道导致组件间耦合了不少临时方案。如果重来我会在协议模型定义阶段就把blockId、action、payload这些交互字段设计进去而不是把它们当附加功能。6.2 如果再来一次会怎么选选型这一块我依然会坚持 Blazor Server但会做一些调整。网络环境复杂、离线场景多的产品不太适合 Server 模式因为 SignalR 断线会直接影响所有交互这一点对 on-premise 部署的产品尤其致命。如果产品需要纯前端离线能力我会直接考虑 Blazor WebAssembly并把协议解析层做成一个独立类库两种模式下复用。另外我会更早引入流量回放机制。第一次让模型产出一个新的chart类型时我真的很慌因为完全不知道线上到底存在多少种历史类型。做一个解析不到类型的消息统计仪表盘比任何代码审查都更能暴露协议的真实使用情况。这也是重构协议层最容易被忽视的隐性需求。6.3 给后来者的检查清单最后分享几条实务建议。协议模型里每个类型都必须有唯一 ID哪怕短期用不上后面做增量更新和事件回传一定会用到。流式输出场景下提前设计增量协议不要让解析层每次接收新 token 就全量重建。事件通道用一个稳定的 Broker 统一管理不要到处创建EventCallback委托。版本兼容不靠自觉靠回放测试和统计数据。重构一个 AI UI 协议表面上是前端组件重写本质上是在梳理一段持续演进的业务语言。我到现在还保留着一个习惯每次协议模型改动先写下老客户端会怎样、新客户端会怎样、中间态会不会出现三行注释然后才动代码。这个方法替我省了不知道多少次半夜查日志的麻烦。如果你也准备动这块从这三个问题开始比从代码开始要靠谱得多。