
做了这么多年前端我越来越觉得真正难的从来不是把某个组件写出来而是搞清楚那个组件在真实场景下为什么要这么设计。几个月前我们团队接到一个任务——做一个面向普通用户的 AI 问答产品。模型调用、提示词编排、知识库接入都还好说真正卡住我们的是那个看起来平平无奇的对话界面。我的办法很笨把某主流 AI 对话助手的界面当作一个黑盒按照逆向工程的思路去拆从交互行为反推它的设计逻辑和实现机制。不猜它用了什么框架只看它在什么时机、做了什么、为什么这么做。这篇文章就是那次逆向拆解的完整记录包括我推测出的实现方案和后来复刻过程中的取舍。想做 AI 对话产品、或者正在被智能界面交互细节折磨的朋友应该能从中找到一些直接能用的东西。1. 把黑盒打开之前先搞清楚要从黑盒里拿什么逆向的第一步不是动手而是定义对象。很多人一说逆向就想看 DOM 结构、抓网络请求对我来说那是最后一步因为拿到的堆栈信息如果没有行为上下文根本没有解释力。我建议把目标拆成三层行为层、界面层、状态层。行为层用户在什么条件下触发什么动作。比如光标移到回答区域会出现什么流式输出过程中按钮怎么变长按某段文字弹出什么菜单。界面层这些行为最终渲染成了什么结构。是卡片、是气泡、是工具栏还是嵌入文本里的芯片式元素。状态层界面背后的运行状态。是等待、流式中、已结束、出错还是被用户中断。把三层拆开以后我给自己列了一份观察清单就这么几项流式输出过程中文本是不是匀速出现的停顿有没有规律用户复制一段回答再粘贴到别处格式是否会变化上下文记忆是怎么体现的是文字提示、悬浮卡片还是会话条目里的元数据重新生成停止这些按钮在哪些时候可用、哪些时候置灰长对话长到什么程度界面会开始做摘要或折叠这份清单帮我挡掉了大量干扰信息因为如果不带着问题去观察很容易被界面细节带跑比如某个过渡动画好不好看某个阴影用什么参数——这些在逆向工程里都是低优先级的东西。真正值得研究的是那些你不做用户就会觉得别扭的交互机制。还有一个容易被忽略的步骤给自己限定时间。我给自己定的规矩是每轮只观察 20 分钟到点必须把发现写成文字哪怕只有三五行。因为界面细节太容易让人陷进去而逆向工程的价值在于结构不在于像素级复刻某个光晕。2. 流式输出的文字节奏感一半是网络协议一半是前端演技我最早注意到的现象是回答文字并不是绝对匀速出现的。有时候一段写得比较顺文字冒出来就很快有时候模型在思考转折或者回答里包含列表、代码块输出就会明显卡一下。最开始的判断是网络传输波动但后来观察了几十条长回答我确认这里面有前端有意设计的成分。2.1 从行为反推数据链路从网络链路看这类 AI 对话产品几乎都是流式返回用户发一条消息服务端把模型产出的文本切成一串带顺序的片段推过来。常见的承载方案有两种一种是基于 HTTP 的 SSE一种是 WebSocket。两者的差异在于简历链路和断线处理但对前端来说核心逻辑是一样的——你拿到的是一个持续的、不定长的文本流而不是一整个完整的响应体。有意思的是如果页面只是简单地把这些片段 append 到 DOM 里文字会以爆出来的方式显示一片一片地跳完全没节奏感。但我观察到的产品不是这样它有一套平滑的输出节流机制。我推测前端几乎必然存在一个渲染缓冲队列。2.2 缓冲队列与节奏算法我的推测是数据到达前端以后不会立刻进入 DOM而是先进入一个 pending 缓冲区。渲染层按固定的时间片从缓冲区取内容再追加到页面上的显示节点。这样即使网络一侧的数据是突发式的用户看到的文字依然保持稳定的输出节奏。这里有两个参数值得推敲一个是时间片的长度另一个是单次取出的字符数。从观察到的效果看普通文本的渲染周期大约在 16 到 30 毫秒之间每次取出的字符数在 3 到 8 个之间会配合一定量的随机偏移让节奏看起来更自然而不是一个匀速机器。如果完全匀速人眼很快会发现规律就会产生假的感觉。我后来在自己的项目里复刻了一套简化版。核心代码大致是这样的逻辑type StreamChunk { id: number; text: string; }; class TypewriterRenderer { private buffer: string ; private displayNode: HTMLElement; private timer: number | null null; private readonly stepInterval 20; // 每帧间隔 ms private readonly maxCharsPerStep 6; constructor(node: HTMLElement) { this.displayNode node; } append(chunk: StreamChunk) { this.buffer chunk.text; this.ensureTicking(); } private ensureTicking() { if (this.timer ! null) return; this.timer window.setInterval(() this.tick(), this.stepInterval); } private tick() { if (this.buffer.length 0) { window.clearInterval(this.timer!); this.timer null; return; } const stepSize this.randomStepSize(); const slice this.buffer.slice(0, stepSize); this.buffer this.buffer.slice(stepSize); this.displayNode.textContent slice; } private randomStepSize(): number { // 基础步长 3~6偶尔放大到 10~12 模拟思路顺畅的状态 if (Math.random() 0.85) { return 3 Math.floor(Math.random() * 4); } return 8 Math.floor(Math.random() * 5); } }这只是最基础的节奏模拟。真实产品里肯定还有更复杂的规则比如遇到代码块时加快渲染、遇到列表项时在换行处多停顿几十毫秒、以及根据文本的标点分布调整 commit 的粒度。但这些细节留给我们的复刻项目时并不着急——先把 80% 的效果做出来后面再慢慢磨。2.3 一个很容易被忽略的设计点光标的呼吸感除了文字节奏流式输出时期还有一个细节光标不是静止竖条而是按固定频率闪烁而且闪烁频率明显比普通输入框柔和。这个在观测时很容易被当成理所当然但想深一层这个光标的闪烁周期其实跟文字渲染节奏是同步的。文字推进和光标闪烁如果各走各的定时器观感会非常散。我的实现方式是把光标闪烁交给 CSS 动画animation-duration 刻意调成 1.2 秒到 1.6 秒之间的一个值和渲染帧的 20ms 没有整数倍关系。这样两个节奏叠加恰好产生一种不可预测但稳定的视觉感受从体验上更接近真实的人在打字。3. 上下文记忆的界面翻译从记住你说了什么到让你看见它记得第二块让我花了不少时间的是上下文记忆机制。从功能上这个产品能做到跨多轮对话保持主题连贯从界面上我发现它不仅是记住内容还把记忆的边界做成了可见的界面元素。3.1 会话元数据才是真正的记忆载体纯从技术上想上下文记忆的实现路径不外乎两种一种是把历史消息全部塞进提示词模型自己理解另一种是前端在会话变更时生成一份结构化的会话摘要把双方的关注点、结论、待办事项提炼出来存成元数据。观察到的产品的界面表现明显偏向第二种因为在长对话进行到某个节点时界面会出现一个卡片自动概括前面聊了什么。这个设计的好处很直接它在界面上给用户一个记忆快照让用户确认模型没跑偏。如果模型漏了什么用户可以直接点卡片里的修改来纠正这比让用户在对话里打你忘了我们刚才说…要自然得多。我后来在自己的项目里也复刻了这个模式不追求自动摘要的智能程度而是先做人工可编辑的会话摘要卡片效果竟然出奇地好。这种界面的数据结构其实不复杂可以想象成一张会话表字段类型说明session_idstring会话唯一标识summarystring供模型续接用的摘要文本user_intentstring[]用户核心诉求标签last_active_atnumber最后活跃时间用于排序pinned_tagsstring[]用户手动固定的关键词真正难的是摘要的更新策略是每轮对话都更新还是当 token 数超过阈值才触发我观测到的表现是它在回答变长、信息密度变大之后才出现摘要卡片所以更可能是按需触发 后端异步生成的模式前端拿到生成的摘要后把它渲染成一个可编辑卡片而不是实时逐字生成。3.2 记忆边界可视化带来的产品启发顺着这个思路我又追踪了一个细节新开会话时产品会在空白态展示最近几条主题以及每个主题下的一句话摘要。这说明记忆不只是为模型服务的它还在为用户的再回来服务。从界面语言上讲这就是把无形态的对话历史压缩成了有语义的条目。对我们自己的复刻项目来说这个机制带来的启发是不要把上下文记忆做成一个黑盒要让记忆本身成为可交互的界面对象。用户需要能够看到模型记住了什么、修改它记住了什么、甚至清空某一段记忆。很多 AI 产品聊到第二十轮就开始失忆用户会恼火但如果你提前把记忆摘要展示出来用户十有八九愿意手动帮模型补课因为他在界面上获得了掌控感。我实际做的时候还会在摘要卡片上行一个这个摘要来自第几轮的时间戳。别小看这一个字段它让卡片显得更真实也方便用户回溯到当初的那条消息。4. 智能渲染层AI 的回答界面不是一段文本是一张会变形的卡片纯文本聊天界面早就被淘汰了。现在这类产品能把回答渲染成富文本代码块带高亮、有折叠功能表格有边框并自动换行列表有层级缩进甚至有些回答里嵌了数学公式。这些能力看起来不难但嵌在流式渲染过程中就会变成一场灾难。因为流式收到的文本是半截的 Markdown。4.1 增量渲染的挑战你永远不知道这句代码块何时结束流式场景下模型可能先吐出一个javascript后面十几行都是代码最后才补一个收尾的。如果前端拿到第一行就立刻调用 Markdown 解析器去渲染解析器会把开头解释成一段未闭合的代码块然后把后面所有内容都吞进代码块样式里整屏样式错乱。我观测那款产品的解法大概是在流式过程中它并不会立刻渲染完整 Markdown而是用一个临时预览模式。它只做很轻的增量高亮比如代码块内的文字先用等宽字体和背景色做一个临时样式等功能块完整闭合后再做一次完整渲染。这个先粗略后精细的分步渲染机制在界面表现上的结果就是代码块出现时颜色并不是马上正确但用户感知不到明显重构的跳变。4.2 安全的 Markdown 渲染管线另一个被低估的问题是 XSS。模型生成的文本经过提示词约束后一般不会主动输出恶意脚本但用户粘贴进对话的内容不一定。更麻烦的是Markdown 本身支持 HTML 标签如果直接交给常见的 Markdown 渲染库用户完全可以在对话里塞一段img onerror...或者iframe。界面设计得再好看这层不守住就是裸奔。我复刻这套管线时采用了三层过滤的方案第一步在交给解析器之前先把危险标签和危险属性从原始文本中剥掉第二步由 Markdown 解析器生成 HTML 之后通过一套白名单属性过滤器再清洗一轮第三步渲染时不在页面里直接插入原始 HTML而是把清洗过的内容挂载进 shadow DOM 或使用可信的渲染容器。就我个人经验前两道过滤最常见的疏漏是class属性和>type ChatUIState | idle // 初始或结束态可输入 | thinking // 模型处理中等待首个 token | streaming // 流式输出中可停止 | regenerating // 重新生成中等价于 thinking 清理旧回答 | error; // 出错可重试或编辑输入状态迁移绕不开的约束是同一时刻只允许一个用户操作生效。在 thinking 阶段你不能让停止和重新生成同时可点否则两个请求会并发产生竞态。我看到的产品在这一点上处理得很干净它的按钮置灰逻辑不是靠某个独立变量而是直接由当前状态推导出来。也就是说每个按钮身上会绑定一个只在某些状态下可点击的规则。5.2 按钮可用性规则的黄金表下面这张表可以当成模板直接用操作idlethinkingstreamingregeneratingerror发送可点禁用禁用禁用可点停止隐藏可点可点可点隐藏重新生成可点禁用禁用禁用可点复制可点禁用禁用可点可点编辑可点禁用禁用禁用可点这里有个细节值得注意error 状态下停止按钮是隐藏的因为此时根本没有流式响应在跑提供停止按钮会造成误导。类似这种小规则只有把界面行为一条条拆成状态表时才能看见否则很容易做成每个按钮各自存一个 disabled 布尔值的脏代码。5.3 竞态问题状态机的第一杀手复刻时我最担心的是竞态条件。最典型的一个场景用户点击重新生成的瞬间网络请求发出然后立刻点了停止。理想情况下停止应该立即生效并取消请求。但如果你在代码里把停止和重新生成做成两个独立请求对象就很容易出现这种情况——停止的请求先返回了而重新生成的请求还在跑过一会儿旧回答又悄悄覆盖了新区域。解决这个问题没有太优雅的办法核心就一句话取消机制的粒度必须覆盖整个状态机而不只是某个请求。我在代码里会给每次生成分配一个单调递增的序号任何新操作都先让当前序号自增然后所有旧序号对应的数据回包在到达时直接被丢弃。看起来粗暴但在实际项目里这是最不容易出 bug 的方案。不止如此我还把按钮的点击处理包了一层运行时守卫代码核心是这样一个思路function createRequestGuard() { let latestToken 0; function next() { latestToken 1; return latestToken; } function isCurrent(token: number) { return token latestToken; } return { next, isCurrent }; }任何 fetch 回调、SSE 消息回调、定时器回调进来先检查isCurrent(token)不是最新的就直接 return。这比在开关里层层判断 flag 干净得多也让状态迁移的可读性提高了一个档次。后来排查 bug 时几乎只需要盯着状态转移表看而不是在回调里到处猜。6. 从像到好用复刻过程中那些没人告诉你的坑观察阶段结束后我们团队用了大约两周把核心界面复刻出来。功能做到八成的时候我原以为最难的部分已经过去了结果实际使用才发现逆向工程只能告诉你它做了什么仍然有一堆坑是它究竟如何做的细节没被展示出来的。6.1 渲染抖动和内容跳位第一个坑是流式渲染中的抖动问题。文字一边冒出来一边插入聊天记录用户会明显感觉到页面在跳尤其是当一个回答很长、页面自动滚动到底部时这种跳跃会被无限放大。那款产品在平滑滚动上做了很克制的事滚动条跟随是有粘滞感的也就是当用户主动上滑查看历史时滚动不会强行被新内容拉下去。我的实现方式是记录用户是否正在浏览历史区这个标志。只有用户停留在底部区域时才启用自动滚动一旦用户向上滚动超过阈值自动滚动立即暂停。这里面还有一个细节暂停不是立刻停止滚动而是做了一次惯性衰减避免画面瞬间卡住。这个衰减逻辑用 requestAnimationFrame 做不复杂但不知道要去做的话体验差距会特别明显。6.2 长页面的内存泄漏第二个坑是长对话导致的内存增长。一个重度用户连续聊五六个小时页面里的 DOM 节点和渲染对象越堆越多。普通聊天界面一般不会暴露这个问题但 AI 助手的长文回答结构复杂每一轮都包含代码块、高亮节点、卡片等堆上几百轮之后明显卡顿。拆解那款产品我发现它的做法很务实它不是不保存所有历史而是把超过一定轮数的早期消息降级渲染。早期的消息不再保留完整的富文本结构变成纯文本摘要样式并且把真正的原始数据卸载。用户想看细节可以点击展开重新加载。这在架构上其实是一种虚拟列表思想只不过粒度从消息变成了消息内部的结构块。6.3 无障碍细节反而是高级感的重要来源还有一个在逆向观察中很容易漏掉、但在我自己复刻后被用户反复表扬的细节键盘操作和读屏适配。普通用户很少注意到但Tab 键可以快速在按钮间跳转回答区域可以用方向键滚动代码块复制按钮有清晰的焦点状态这些细节会让一个 AI 聊天界面从能用升级成专业。我后来给复刻项目的每条流式消息都加了aria-livepolite声明——但只在流式开始时声明一次而不是在输出的每个片段上都触发。否则读屏用户会被持续打断那体验比没有辅助功能更差。这个取舍是被教育出来的无障碍不是越多越好而是要在关键时机给出关键提示。6.4 关于效果度量界面做得好不好不是看出来的最后说一个偏方法论的建议。复刻完成后我们犯过一个大问题团队成员天天盯着界面看觉得已经做得相当接近了。但找了一批真实用户来试用反馈却是总觉得哪里怪。怪这个词很难量化于是我们又回到观察法用录屏工具录了十几个真实用户的会话过程逐帧对比。结果发现出问题的几乎都是过渡动画的时值。比如用户播放完一段长回答后打字反馈的延迟、声音提示的峰值位置、按钮从出现到可点击的间隔——这些看起来不起眼的时值构成了用户对响应快不快的整体判断。我们花了三个晚上调这些时间参数没有改一行功能逻辑用户满意度却明显提升。这也是逆向工程带来的最大启发界面体验的差异往往不在功能的有无而在状态切换时序的毫秒级取舍。如果你现在也在做类似的对话式产品界面我的建议很简单先别急着写组件花半天时间拿别人的产品做一轮有目的的行为观察把按钮何时可点、文字何时出现、光标怎么闪这些细节按时间线列成一张表再动手实现。这张表会成为你界面代码的状态机蓝图比任何组件库都管用。