1. 从一个踩坑现场说起:Agent 和渲染到底谁在拖谁的后腿
去年冬天我在做一个基于 LLM 的智能体项目,前端用 React 画布做流程编排,后端跑 Agent 的推理循环。功能演示的时候一切正常,但一上真实场景就出问题:用户输入一个复杂任务,Agent 开始规划、调用工具、生成中间结果,前端画布上的节点一个接一个亮起来,看起来挺酷。可只要任务链路超过七八步,页面就开始卡,节点拖拽掉帧,SSE 连接时不时断掉,控制台报stream disconnected before completion: idle timeout waiting for SSE。当时我第一反应是前端性能不行,于是去优化 React 的渲染,用 memo、useCallback、虚拟化列表一顿操作,结果卡顿依旧。
后来把日志拉出来逐帧分析才发现,真正的问题不在渲染本身,而在于 Agent 的推理节奏和渲染的更新节奏完全对不上。Agent 那边是"想很久、吐一点、再想很久",渲染这边是"每来一点数据就全量重绘一次"。两边各干各的,中间没有节流、没有批处理、没有状态分层,于是 LLM 的 token 流一抖动,整个视图层就跟着抖。这件事让我意识到,Agent 应用开发和渲染之间的关系,不是简单的"前端展示后端数据",而是两种完全不同时间尺度的系统在互相耦合。
这篇内容我想把这段时间踩过的坑、想明白的道理、以及最后落地的方案完整讲一遍。核心围绕几个关键词展开:Agent、渲染、LLM、SSE、React。适合正在做 AI Agent 产品的前端和后端同学,也适合刚接触 LLM 应用、对"为什么我的 Agent 界面这么卡"感到困惑的开发者。不管你是用 React 画布做编排,还是用普通列表展示对话,只要涉及 LLM 的流式输出和视图更新,这里面的思路都能直接抄。
先说结论性的判断:Agent 是"慢思考、长链路、状态复杂"的系统,渲染是"快响应、高频次、状态敏感"的系统。把这两个系统硬接在一起,必然出问题。正确的做法是在中间加一层"节奏适配层",让 Agent 的推理节奏和渲染的刷新节奏解耦。下面我分几个部分把这件事讲透。
2. Agent 应用的本质:它和普通 CRUD 前端到底差在哪
2.1 Agent 是一个带记忆和循环的状态机
很多人第一次做 Agent 应用,会下意识把它当成"带 AI 的聊天框",于是用普通聊天应用的架构去套:用户发消息,后端调一次 LLM,返回结果,前端渲染。这个模型在单轮问答里没问题,但一旦涉及 Agent,就完全不够用了。
Agent 的核心特征是循环和工具调用。它拿到一个任务后,不是一次性给出答案,而是经历"思考—行动—观察—再思考"的多轮循环。每一轮里,LLM 可能输出一段推理文本,可能决定调用某个工具,工具执行完又产生新的观察结果,再喂回 LLM。这个过程可能持续几轮、十几轮,甚至几十轮。每一轮都会产生新的状态:当前在第几步、调用了什么工具、工具返回了什么、LLM 的中间推理是什么。
这意味着 Agent 应用的状态不是"一个结果",而是"一条不断增长的时间线"。前端要渲染的不是最终答案,而是这条时间线的实时演化。这跟普通 CRUD 前端"请求—响应—渲染"的模型有本质区别。普通前端的渲染是幂等的:同样的数据渲染出同样的界面。而 Agent 前端的渲染是增量的、时序的:每一步都依赖前一步的状态,渲染顺序本身就是信息的一部分。
我在项目里最开始就犯了这个错,把 Agent 的每一步都当成独立事件推给前端,前端每收到一个事件就重新渲染整个时间线。结果就是节点越多,每次重绘的成本越高,到后面直接卡死。正确的做法是把时间线当成一个整体状态来管理,新事件只是往这个状态里追加,渲染层只更新变化的部分。
2.2 LLM 的输出是流式的,而且是不均匀的
第二个关键差异是 LLM 的输出方式。LLM 生成 token 是一个一个吐出来的,通过 SSE 或者 WebSocket 推给前端。这个"流"有两个特点:不均匀和不可预测。
不均匀指的是 token 到达的间隔不固定。有时候一秒来十几个 token,有时候卡两三秒才来一个。这取决于模型推理速度、网络状况、服务端排队情况。不可预测指的是你没法提前知道这次输出会有多长、会在哪里停、会不会中途调用工具。
这两个特点直接决定了渲染策略。如果你用"每来一个 token 就 setState 一次"的朴素做法,在 token 密集的时候会触发大量重渲染,React 的调和过程会吃掉大量主线程时间,页面就卡了。而在 token 稀疏的时候,界面又长时间没反应,用户以为卡死了。
我实测过一个极端情况:一个 Agent 任务在某个工具调用上卡了 8 秒没有任何输出,前端的 SSE 连接因为 idle timeout 被断开,用户看到的是"任务进行中"但永远不动。这就是stream disconnected before completion: idle timeout waiting for SSE这个报错的典型来源。解决它需要在协议层加心跳,在渲染层加"思考中"的占位状态,而不是干等。
2.3 状态复杂度决定了渲染架构
把上面两点合起来看,Agent 应用的状态复杂度远高于普通前端。它至少包含这几层:
- 会话层:整个对话的上下文,包括历史消息、系统提示词。
- 任务层:当前 Agent 正在执行的任务,包括目标、计划、进度。
- 步骤层:每一步的推理、工具调用、观察结果。
- 流式层:当前正在生成的 token 流,还没定稿的内容。
这四层状态的生命周期完全不同。会话层可能持续几十分钟,任务层几分钟,步骤层几秒,流式层几十毫秒。如果把它们混在一个 state 里,任何一层的更新都会触发全量重渲染,性能必然崩。
我在重构的时候做的第一件事就是状态分层:把流式层单独抽出来,用 ref 或者独立的轻量 store 管理,避免它触发 React 的调和;把步骤层和任务层用 reducer 管理,保证更新的可预测性;会话层则放在更外层,很少变动。分层之后,token 流的高频更新只影响流式层那一小块 DOM,其他部分纹丝不动,帧率立刻回来了。
3. 渲染侧的核心矛盾:React 的更新模型和流式数据的冲突
3.1 React 的调和成本从哪来
要理解为什么 Agent 应用容易卡,得先搞清楚 React 渲染的成本结构。React 的更新分两个阶段:render 阶段和commit 阶段。render 阶段是计算虚拟 DOM 的差异,commit 阶段是把差异应用到真实 DOM。真正贵的是 render 阶段,尤其是组件树很深、组件数量很多的时候。
在 Agent 应用里,组件树往往很深。一个任务节点可能包含:标题、状态图标、推理文本、工具调用卡片、工具返回结果、耗时统计……每个节点又是列表里的一项。如果列表有几十个节点,每个节点又有十几个子组件,那组件实例数量轻松上千。这时候任何一次顶层 setState 都会触发整棵树的调和,哪怕只有一个小地方变了。
我做过一个粗略的测量:在一个 50 节点的画布上,一次全量重渲染大约消耗 30 到 50 毫秒。如果 token 流每秒触发 20 次更新,那就是每秒 600 到 1000 毫秒的渲染时间,主线程直接饱和,用户操作全部卡住。这就是为什么"每来一个 token 就更新"的做法必死。
3.2 流式更新为什么特别容易触发全量重渲染
流式数据的问题在于它天然是"高频小量"的。每个 token 只改变一小段文本,但它触发的是整个组件的 setState。如果这个组件恰好是列表的父组件,那整个列表都要重新调和。
更麻烦的是,很多开发者会把流式文本和结构化状态放在同一个 state 对象里。比如:
const [state, setState] = useState({ messages: [], currentStreaming: '', taskStatus: 'running', nodes: [], });每次 token 到达,都要setState(prev => ({...prev, currentStreaming: prev.currentStreaming + token}))。这个操作会创建一个全新的 state 对象,React 无法判断哪些部分没变,只能重新渲染所有依赖这个 state 的组件。列表、画布、状态栏全部重绘。
我踩过的坑就是在这里。当时画布上的节点位置、连线、状态都存在同一个 state 里,token 一来,整个画布重新计算布局,节点位置抖动,连线重绘,卡得没法看。后来把流式文本单独拆出去,画布状态和流式状态彻底分离,问题才解决。
3.3 渲染节奏应该由谁决定
这是我想重点讲的一个认知转变。很多人默认"数据来了就渲染",把渲染当成数据的被动响应。但在 Agent 场景下,这个默认是错的。
渲染节奏应该由用户体验决定,而不是由数据到达节奏决定。人眼能感知的流畅刷新大约是每秒 60 帧,也就是 16 毫秒一帧。但文本更新其实不需要 60 帧,每秒 10 到 20 次更新人眼就已经觉得很流畅了。也就是说,我们完全可以把 token 流攒一攒,每 50 到 100 毫秒批量更新一次,用户体验几乎无差别,但渲染成本降低一个数量级。
这就是批处理和节流的价值。我在项目里加了一个简单的缓冲:token 到达时先塞进一个队列,用一个定时器每 80 毫秒把队列里的内容合并成一次更新。实测下来,文本滚动依然顺滑,但 CPU 占用从 80% 降到 20% 以下。这个改动只有几十行代码,收益却极大。
提示:节流的间隔不是越小越好。太小起不到降低渲染频率的作用,太大用户会觉得文本一顿一顿的。我的经验值是 50 到 100 毫秒,具体可以根据设备性能动态调整。
4. 中间层设计:让 Agent 的节奏和渲染的节奏解耦
4.1 为什么需要一层"节奏适配层"
前面讲了 Agent 侧和渲染侧各自的特性,现在讲怎么把它们接起来。核心思路是:不要让 Agent 的输出直接驱动渲染,中间加一层做节奏转换和状态归并。
这一层我称之为"节奏适配层",它做三件事:接收 Agent 的原始事件流、按渲染友好的节奏归并、把归并后的状态推给视图层。Agent 那边爱怎么吐怎么吐,渲染这边按自己的节奏取,两边互不干扰。
这层可以放在前端,也可以放在后端。放在前端的好处是延迟低、实现简单;放在后端的好处是多个客户端可以共享同一份归并逻辑。我两个方案都试过,最后选了前端,因为 Agent 的中间状态对延迟敏感,放后端会多一次网络往返。
4.2 事件协议怎么设计才不会被流式拖垮
协议设计是这一层的核心。我踩过的坑是:一开始把 Agent 的每个 token 都当成一个独立事件推给前端,事件类型五花八门,前端要写一大堆 if-else 去处理。后来改成结构化事件 + 增量内容的混合模式。
具体来说,事件分两类:
- 状态事件:节点创建、节点状态变更、工具调用开始/结束、任务完成。这类事件频率低,但信息量大,需要触发结构性更新。
- 内容事件:token 流、日志流。这类事件频率高,但只是往已有结构里追加文本,不需要结构性更新。
两类事件走不同的通道,前端用不同的策略处理。状态事件立即处理,内容事件进缓冲区批量处理。这样既保证了结构变化的实时性,又避免了高频内容拖垮渲染。
协议里还要加心跳。SSE 连接如果长时间没有数据,中间的网络设备可能会主动断开,这就是 idle timeout 的来源。我的做法是服务端每 15 秒发一个心跳事件,前端收到后重置超时计时器。心跳事件不携带业务数据,只用来保活。
// 服务端心跳示例(Node.js) const heartbeat = setInterval(() => { res.write(`event: heartbeat\ndata: ${Date.now()}\n\n`); }, 15000); req.on('close', () => clearInterval(heartbeat));4.3 状态归并的具体策略
归并策略决定了渲染的最终效果。我的做法是按"节点"为单位归并:每个 Agent 步骤对应一个节点,节点有唯一 ID。内容事件到达时,根据事件里的节点 ID 找到对应节点,把文本追加进去。如果节点不存在,先创建。
归并的时机由定时器控制。定时器每 80 毫秒触发一次,把缓冲区里所有待处理的内容事件按节点分组,每个节点合并成一次更新。这样即使 80 毫秒内来了 50 个 token,也只触发一次渲染。
这里有个细节:归并后的更新要尽量保持不可变性。React 依赖引用相等来判断是否重渲染,如果每次归并都创建新对象,那所有节点都会被认为变了。我的做法是只更新真正有变化的节点,其他节点保持原引用。这样配合React.memo,只有变化的节点会重渲染。
// 归并逻辑示意 function flushBuffer(buffer, nodes) { const updates = new Map(); for (const event of buffer) { const prev = updates.get(event.nodeId) || ''; updates.set(event.nodeId, prev + event.content); } return nodes.map(node => { const delta = updates.get(node.id); if (!delta) return node; // 保持原引用 return { ...node, content: node.content + delta }; }); }这段代码的关键在if (!delta) return node。没有变化的节点直接返回原对象,React 的 memo 就能跳过它的重渲染。别小看这一行,在节点多的场景下它能省掉 90% 以上的调和成本。
5. 实操落地:从零搭一个不卡的 Agent 渲染架构
5.1 整体架构分层
讲完原理,进入实操。我把整个架构分成四层,从下到上依次是:
| 层级 | 职责 | 技术选型 |
|---|---|---|
| 传输层 | 维持 SSE 连接、心跳保活、断线重连 | EventSource / fetch stream |
| 归并层 | 缓冲事件、按节奏归并、状态分层 | 自定义 store + 定时器 |
| 状态层 | 管理会话、任务、步骤、流式四层状态 | useReducer + Context |
| 渲染层 | 按状态渲染视图、局部更新 | React + memo + 虚拟化 |
这个分层的好处是每层职责单一,出问题容易定位。传输层断了就查网络和心跳,归并层慢了就调定时器间隔,状态层乱了就查 reducer,渲染层卡了就查 memo 和虚拟化。
5.2 传输层:SSE 连接的稳定维护
SSE 是 Agent 应用最常用的传输方式,因为它天然支持服务端推送,比 WebSocket 轻量。但它有几个坑:浏览器对 SSE 的连接数有限制、长时间无数据会被断开、断线后不会自动重连(EventSource 会自动重连,但 fetch stream 不会)。
我用的是 fetch stream 而不是 EventSource,因为需要自定义请求头。代价是要自己处理重连。重连逻辑要处理几个情况:正常结束、网络错误、服务端主动关闭。每种情况的处理策略不同。
async function connectSSE(url, { onEvent, onError }) { let retries = 0; const maxRetries = 5; while (retries < maxRetries) { try { const res = await fetch(url, { headers: { Accept: 'text/event-stream' } }); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n\n'); buffer = lines.pop(); for (const line of lines) { if (line.startsWith('event: heartbeat')) continue; onEvent(parseEvent(line)); } } retries = 0; // 正常结束,重置重试计数 } catch (err) { retries++; onError(err); await new Promise(r => setTimeout(r, 1000 * retries)); } } }这段代码里有两个关键点。一是buffer的处理:SSE 的消息以空行分隔,但网络传输可能把一个消息拆成多个 chunk,所以要缓存不完整的部分。二是心跳事件的过滤:心跳只用来保活,不应该进入业务逻辑。
注意:重连时要从上次断开的位置继续,而不是从头开始。这需要在协议里带上事件序号,服务端支持断点续传。如果服务端不支持,至少要在重连后重新拉取当前任务状态,避免状态丢失。
5.3 归并层:缓冲与批量更新
归并层是性能的关键。我实现了一个简单的缓冲队列,配合 requestAnimationFrame 做批量更新。用 rAF 而不是 setInterval 的原因是 rAF 和浏览器的渲染节奏对齐,不会在页面不可见时浪费计算。
class EventBuffer { constructor(flushInterval = 80) { this.queue = []; this.timer = null; this.flushInterval = flushInterval; this.onFlush = null; } push(event) { this.queue.push(event); if (!this.timer) { this.timer = setTimeout(() => this.flush(), this.flushInterval); } } flush() { this.timer = null; if (this.queue.length === 0) return; const batch = this.queue; this.queue = []; this.onFlush?.(batch); } }这个实现有个细节:定时器只在第一次 push 时启动,后续 push 复用同一个定时器。这样保证每 80 毫秒最多 flush 一次,而不是每个事件都重置定时器(那样会导致高频事件永远不 flush)。
flush 的时候把整批事件交给上层处理。上层按节点分组、合并文本、生成新的状态。这里要注意,flush 本身要尽量快,不能在里面做重计算。如果合并逻辑复杂,可以再拆一层,用 worker 处理。
5.4 状态层:四层状态的分离管理
状态层我用 useReducer 管理结构化的三层(会话、任务、步骤),流式层单独用 ref 管理。为什么流式层不用 state?因为流式层更新频率太高,用 state 会触发大量重渲染。用 ref 存文本,配合一个低频的 state 做"版本号",需要渲染时手动触发。
function useAgentState() { const [state, dispatch] = useReducer(reducer, initialState); const streamingRef = useRef(new Map()); // nodeId -> text const [streamingVersion, setStreamingVersion] = useState(0); const appendStreaming = useCallback((nodeId, text) => { const prev = streamingRef.current.get(nodeId) || ''; streamingRef.current.set(nodeId, prev + text); }, []); const commitStreaming = useCallback(() => { setStreamingVersion(v => v + 1); }, []); return { state, dispatch, streamingRef, streamingVersion, appendStreaming, commitStreaming }; }这个模式的核心是"写入用 ref,提交用 state"。token 到达时只写 ref,不触发渲染;归并层的定时器到点时调一次 commitStreaming,触发一次渲染。这样渲染频率完全由归并层控制,和 token 到达频率解耦。
5.5 渲染层:memo、虚拟化和局部更新
渲染层的优化手段主要是三个:memo、虚拟化、局部更新。
memo 用来跳过没变化的节点。前面归并层保证了没变化的节点保持原引用,配合React.memo就能跳过重渲染。这里要注意 memo 的比较函数,默认的浅比较对大多数场景够用,但如果节点对象里有嵌套结构,可能需要自定义比较。
虚拟化用来处理长列表。Agent 任务可能有几十上百个步骤,全部渲染出来既慢又没必要。用虚拟化只渲染可视区域内的节点,滚动时动态替换。我用的是自己实现的简易虚拟化,因为现成的库对动态高度的支持不够好。
局部更新指的是把高频更新的部分隔离到独立组件。比如流式文本单独抽成一个组件,只有它订阅 streamingVersion,其他组件不受影响。这样一次 token 更新只重渲染那一个文本组件,成本极低。
const StreamingText = React.memo(function StreamingText({ nodeId, streamingRef, version }) { const text = streamingRef.current.get(nodeId) || ''; return <span className="streaming-text">{text}</span>; });这个组件只在 version 变化时重渲染,而且只读自己那一段文本。即使页面上有几十个这样的组件,一次更新也只影响正在流式输出的那一个。
6. 常见问题与排查技巧实录
6.1 SSE 断连问题速查
stream disconnected before completion: idle timeout waiting for SSE是我遇到最多的报错。它的成因和排查思路整理成表:
| 现象 | 可能原因 | 排查方法 | 解决 |
|---|---|---|---|
| 固定时间后断开 | 服务端或中间设备超时 | 看断开时间是否固定 | 加心跳,间隔小于超时时间 |
| 随机断开 | 网络抖动 | 看是否伴随网络错误 | 加重连,带断点续传 |
| 大响应时断开 | 缓冲区溢出 | 看响应大小 | 分块发送,控制单次大小 |
| 长时间无输出断开 | Agent 卡在工具调用 | 看 Agent 日志 | 加超时和占位状态 |
心跳间隔的设置有个经验:要比最短的超时时间小一半以上。如果中间设备超时是 60 秒,心跳就设 15 到 20 秒。我一般设 15 秒,留足余量。
6.2 渲染卡顿的定位方法
卡顿的定位不能靠猜,要用数据说话。我的方法是三步:
第一步,用 Performance 面板录一段,看主线程时间花在哪。如果大量时间在 React 的 render 阶段,就是组件太多或更新太频繁;如果在 commit 阶段,就是 DOM 操作太多;如果在 scripting 之外,可能是布局或绘制的问题。
第二步,用 React DevTools 的 Profiler 看哪些组件重渲染了、为什么重渲染。它会告诉你每次渲染的耗时和触发原因。我经常发现一些意料之外的组件在重渲染,比如某个 Context 的消费者,因为 Context value 每次都是新对象而全部重渲染。
第三步,在关键路径打点,统计归并层的 flush 频率和每次 flush 的耗时。如果 flush 频率远高于预期,说明节流没生效;如果单次 flush 耗时很长,说明归并逻辑太重。
6.3 几个反直觉的坑
第一个坑:useCallback 和 useMemo 不是越多越好。它们本身也有成本,对于简单组件,加 memo 反而更慢。我的原则是只对确实昂贵的组件加,而且要用 Profiler 验证效果。
第二个坑:Context 的 value 要稳定。很多人写<Context.Provider value={{state, dispatch}}>,每次渲染都创建新对象,导致所有消费者重渲染。正确做法是把 value 用 useMemo 包起来,或者拆成多个 Context。
第三个坑:虚拟化列表里的动态高度。Agent 节点的内容长度不一,高度动态变化。如果用固定高度的虚拟化,会出现内容被截断或空白。我的做法是先渲染一次测量高度,缓存起来,后续用缓存高度。测量阶段会有一次闪烁,可以用骨架屏遮一下。
第四个坑:SSE 的自动重连会重复消费。EventSource 断线后会自动重连,但服务端不知道客户端收到哪了,可能从头推。如果协议里没有事件 ID,就会重复。解决方法是服务端给每个事件编号,客户端重连时带上最后收到的编号。
6.4 性能优化的优先级
优化要有优先级,不能眉毛胡子一把抓。我的经验顺序是:
- 先解决协议问题。如果 SSE 频繁断连,再好的渲染优化也没用。先把连接稳定性搞定。
- 再做状态分层。把高频状态和低频状态分开,这是收益最大的一步。
- 然后加归并和节流。这一步能把渲染频率降一个数量级。
- 最后做组件级优化。memo、虚拟化、局部更新,这些是锦上添花。
很多人一上来就做第四步,结果收效甚微,因为前三步的问题没解决,组件级优化只是杯水车薪。
7. 一些延伸思考:Agent 和渲染的边界会越来越模糊
做这个项目的过程中,我有个越来越强的感受:Agent 和渲染的边界正在变得模糊。传统的架构里,Agent 是后端的事,渲染是前端的事,两边通过 API 通信。但在 LLM 应用里,这个边界开始动摇。
一方面,Agent 的推理过程本身就需要可视化。用户不只想看结果,还想看 Agent 是怎么想的、调用了什么工具、为什么这么决策。这就要求渲染层能实时反映 Agent 的内部状态,渲染不再是"展示结果",而是"参与推理过程"。
另一方面,渲染本身也开始有"智能"。比如根据内容自动调整布局、根据用户注意力动态加载、根据任务复杂度选择不同的展示方式。这些决策如果交给 LLM 来做,渲染就变成了 Agent 的一部分。
我试过让 LLM 来决定画布上节点的布局,效果意外地不错。它能把相关的节点聚在一起,把重要的节点放大,把次要的折叠起来。虽然响应慢一点,但布局质量比固定规则好很多。这让我觉得,未来的 Agent 应用里,渲染和推理可能不是两层,而是一个循环的两面。
当然,这条路还很长。当前阶段,把节奏适配层做好、把状态分层做对、把连接稳定性做扎实,就已经能解决 90% 的问题。剩下的 10%,留给未来去探索。
最后分享一个我一直在用的小技巧:在开发阶段,给归并层加一个"慢动作"开关,把 flush 间隔调到 500 毫秒甚至 1 秒。这样能肉眼看清状态是怎么一步步变化的,调试时序问题特别有用。上线前再调回正常值。这个开关帮我定位过好几次"看起来是渲染问题、实际是状态顺序问题"的 bug。