
1. 从一个踩坑现场说起Agent 和渲染到底谁先谁后去年年底我接了个活儿做一个能自动生成数据看板的 Agent 应用。需求听起来很直白用户用自然语言描述想看什么指标Agent 负责理解意图、调用数据接口、生成图表配置最后前端把图表渲染出来。我当时的直觉是——这不就是个典型的 LLM 加 Function Call 的活儿吗把工具定义好让模型去调前端拿到结构化数据渲染就完事了。结果第一版上线测试问题全冒出来了。用户说“帮我看看最近一周的销售趋势”Agent 确实调了数据接口也返回了图表配置但前端渲染出来的东西要么是空白要么是上一次的旧图要么干脆卡在半截加载状态。更离谱的是有时候 Agent 还在“思考”阶段前端已经把 loading 动画转完了用户以为没反应就反复点击最后一次性弹出七八个图表。那段时间我天天盯着日志看慢慢意识到一个之前被忽略的问题Agent 应用开发和传统前端渲染之间存在一条很深的认知鸿沟。做 Agent 的人习惯用“请求-响应”的思维看世界做渲染的人习惯用“状态-视图”的思维看世界这两套思维在同一个应用里碰撞不出问题才怪。这篇文章就是把我这大半年踩过的坑、想明白的道理、以及最后跑通的方案完整地梳理一遍。核心围绕Agent、渲染、LLM、SSE、Function Call这几个关键词展开聊清楚它们之间的关系该怎么处理。如果你正在做或者准备做 Agent 应用尤其是那种需要实时把 Agent 的中间过程展示给用户的场景这篇内容应该能帮你少走不少弯路。2. 为什么 Agent 和渲染的关系这么难处理2.1 两套时间观的冲突传统 Web 渲染的时间观是离散的、可预测的。用户点一个按钮发一个请求后端返回数据前端更新状态视图重新渲染。整个过程像一条流水线每个环节的耗时虽然不确定但顺序是确定的状态是有限的。Agent 的时间观完全不同。LLM 的推理过程是连续的、流式的、不可预测的。你发一个 prompt 过去模型可能先输出一段思考然后决定调用某个工具工具返回结果后它再继续思考可能又调用另一个工具最后才给出最终答案。这个过程可能持续几秒也可能持续几十秒中间会产生大量的中间状态。当这两套时间观撞在一起问题就来了前端到底应该在什么时候渲染什么是等 Agent 全部跑完再渲染最终结果还是把中间过程也实时渲染出来如果渲染中间过程那这些中间状态该怎么映射到视图上我一开始选的是“等全部跑完再渲染”简单粗暴。但用户体验很差——一个需要 20 秒才能跑完的 Agent 任务用户盯着空白屏幕 20 秒心理上早就放弃了。后来改成“实时渲染中间过程”复杂度直接上了一个数量级因为你要处理各种中间状态的渲染逻辑还要保证最终状态能正确覆盖中间状态。2.2 Function Call 带来的状态分裂Function Call 是 Agent 应用的核心机制但它也是渲染问题的重灾区。当 LLM 决定调用一个工具时整个应用的状态实际上被切成了好几块LLM 侧的状态模型正在生成什么、已经生成了什么、下一步打算调用什么工具工具侧的状态工具是否被调用、调用参数是什么、执行结果是什么、是否出错渲染侧的状态当前视图展示的是什么、哪些部分需要更新、更新时机是什么这三块状态如果不同步就会出现我前面说的那种情况——Agent 还在调工具前端已经把 loading 转完了或者工具已经返回结果了前端还在等 LLM 的下一步指令。更麻烦的是Function Call 的执行结果往往不是最终结果而是需要再喂回给 LLM 继续推理的中间结果。这意味着渲染层不能简单地把工具结果当成最终数据来渲染而要考虑“这个结果可能还会被 LLM 修改”这个事实。2.3 SSE 是桥梁但不是万能药SSEServer-Sent Events是目前 Agent 应用里最常用的流式传输方案。它允许服务端持续向客户端推送事件天然适合 LLM 的流式输出场景。我现在的项目里Agent 的每一个中间状态——LLM 的 token 输出、Function Call 的开始和结束、工具的执行结果——都是通过 SSE 推送到前端的。但 SSE 只解决了“传输”的问题没解决“渲染”的问题。前端收到一堆 SSE 事件之后怎么把这些事件映射成视图怎么处理事件之间的顺序和依赖怎么在事件流中断时做降级处理这些都得自己想办法。我见过不少项目SSE 通道搭得很漂亮事件定义也很清晰但前端拿到事件之后就是简单粗暴地setState结果就是视图频繁闪烁、状态错乱、性能拉胯。SSE 是桥梁但桥那头怎么组织交通是另一回事。3. 核心思路把 Agent 过程建模成可渲染的状态机3.1 为什么是状态机想明白 Agent 和渲染的关系之后我做的第一个决定是不要把 Agent 的输出当成数据流要把它当成状态机。数据流的思维是“我收到一个事件就更新一次视图”。这种思维在简单场景下没问题但在 Agent 场景下会失控因为 Agent 的事件太多了而且事件之间有关系。LLM 输出的一个 token、一次 Function Call 的开始、一次工具执行的结束这些事件不是孤立的它们共同构成了一个“Agent 正在做什么”的状态。状态机的思维是“我收到一个事件就推进一次状态然后根据新状态决定渲染什么”。这样渲染层就不需要关心事件的细节只需要关心当前处于哪个状态以及这个状态该渲染成什么样。我现在的项目里Agent 的执行过程被建模成这么几个状态状态含义渲染表现IDLE空闲等待用户输入显示输入框和欢迎语THINKINGLLM 正在推理显示思考中的动画和已输出的文本CALLING_TOOL正在调用工具显示工具名称和调用参数TOOL_RUNNING工具执行中显示工具执行进度TOOL_DONE工具执行完成显示工具结果摘要RESPONDINGLLM 正在生成最终回复流式显示回复内容DONE任务完成显示最终结果和操作按钮ERROR出错显示错误信息和重试按钮这个状态机不是拍脑袋定的是根据实际业务场景反复调整出来的。比如一开始我没有区分 CALLING_TOOL 和 TOOL_RUNNING后来发现这两个状态的渲染需求完全不同——前者要展示“模型决定调什么工具”后者要展示“工具执行到哪一步了”混在一起渲染会很别扭。3.2 状态推进的触发条件状态机定好了接下来要解决的是“什么事件触发什么状态推进”。这里的关键是事件和状态要一一对应不能有歧义。我现在的做法是服务端通过 SSE 推送的事件类型和状态推进严格对应// 事件类型定义 const EventType { LLM_TOKEN: llm_token, // LLM 输出一个 token LLM_THINKING: llm_thinking, // LLM 开始思考 TOOL_CALL_START: tool_call_start, // 开始调用工具 TOOL_CALL_END: tool_call_end, // 工具调用结束 TOOL_RESULT: tool_result, // 工具返回结果 FINAL_RESPONSE: final_response, // 最终回复 ERROR: error // 出错 }; // 状态推进逻辑 function reduceState(currentState, event) { switch (event.type) { case EventType.LLM_THINKING: return { ...currentState, status: THINKING }; case EventType.TOOL_CALL_START: return { ...currentState, status: CALLING_TOOL, toolName: event.toolName }; case EventType.TOOL_RESULT: return { ...currentState, status: TOOL_DONE, toolResult: event.result }; case EventType.FINAL_RESPONSE: return { ...currentState, status: RESPONDING, content: event.content }; // ... } }这样做的好处是渲染层只需要根据status字段决定渲染什么不需要关心事件的细节。事件和状态之间的映射关系是确定的不会出现“收到这个事件该不该更新视图”的纠结。3.3 中间状态的渲染策略状态机跑起来之后下一个问题是中间状态到底该怎么渲染我的经验是中间状态的渲染要遵循“渐进增强”的原则。也就是说每个中间状态都要有一个基础的渲染表现保证用户能看到“事情在推进”同时如果条件允许可以在这个基础上增加更丰富的渲染。比如 THINKING 状态基础渲染就是一个“思考中”的动画加一个转圈。但如果 LLM 已经开始输出 token 了就可以把已输出的文本流式展示出来让用户看到模型在想什么。再进一步如果模型输出的内容里有结构化的信息比如它打算调用什么工具可以提前把工具名称展示出来。这种渐进增强的策略好处是降级友好。如果 SSE 连接不稳定或者某个中间状态的事件丢失了基础的渲染表现仍然能让用户知道“Agent 还在工作”不会出现完全空白的情况。4. 实操从 SSE 事件到视图渲染的完整链路4.1 服务端把 Agent 的每一步都变成事件服务端的核心任务是把 Agent 的执行过程拆解成细粒度的事件通过 SSE 推送给前端。这里的关键是事件要足够细但也不能太细。太粗的事件比如只在 Agent 全部跑完后推一个结果会导致前端无法展示中间过程。太细的事件比如每个 token 都单独推一个事件会导致事件数量爆炸前端处理不过来。我现在的粒度是这样的# 服务端伪代码 async def run_agent(prompt, sse_queue): # 1. 推送思考开始事件 await sse_queue.put({ type: llm_thinking, timestamp: time.time() }) # 2. 流式推送 LLM 输出 async for token in llm.stream(prompt): await sse_queue.put({ type: llm_token, content: token, timestamp: time.time() }) # 3. 如果 LLM 决定调用工具 if tool_call : parse_tool_call(llm_output): await sse_queue.put({ type: tool_call_start, toolName: tool_call.name, params: tool_call.params, timestamp: time.time() }) # 4. 执行工具 result await execute_tool(tool_call) await sse_queue.put({ type: tool_result, toolName: tool_call.name, result: result, timestamp: time.time() }) # 5. 把工具结果喂回 LLM 继续推理 # ... 递归或循环 # 6. 推送最终回复 await sse_queue.put({ type: final_response, content: final_content, timestamp: time.time() })这里有个细节值得说每个事件都带 timestamp。这个 timestamp 在前端渲染时很有用可以用来做事件排序、计算耗时、检测超时。我踩过的坑是SSE 事件在网络传输中可能会乱序虽然理论上不会但实际环境中确实遇到过有了 timestamp 就能在前端做一次排序保证状态推进的顺序正确。4.2 前端事件接收与状态归约前端收到 SSE 事件之后不能直接更新视图要先经过一层“状态归约”。这层归约的作用是把事件流转换成状态机的状态。// 前端状态归约 class AgentStateReducer { constructor() { this.state { status: IDLE, thinkingContent: , toolCalls: [], finalContent: , error: null }; } reduce(event) { switch (event.type) { case llm_thinking: this.state.status THINKING; break; case llm_token: this.state.thinkingContent event.content; break; case tool_call_start: this.state.status CALLING_TOOL; this.state.toolCalls.push({ name: event.toolName, params: event.params, status: calling, startTime: event.timestamp }); break; case tool_result: const lastCall this.state.toolCalls[this.state.toolCalls.length - 1]; if (lastCall) { lastCall.status done; lastCall.result event.result; lastCall.endTime event.timestamp; } this.state.status TOOL_DONE; break; case final_response: this.state.status RESPONDING; this.state.finalContent event.content; break; case error: this.state.status ERROR; this.state.error event.message; break; } return this.state; } }这层归约的价值在于把事件流的复杂性挡在渲染层之外。渲染层拿到的永远是一个确定的状态对象不需要关心事件是怎么来的、顺序对不对、有没有丢失。如果某个事件丢失了状态机可能会卡在某个中间状态这时候可以通过超时机制来兜底。4.3 渲染层根据状态决定视图渲染层的逻辑就简单多了——根据当前状态决定渲染什么组件。function AgentView({ state }) { switch (state.status) { case IDLE: return WelcomeScreen /; case THINKING: return ( div classNamethinking-view ThinkingAnimation / StreamingText content{state.thinkingContent} / /div ); case CALLING_TOOL: case TOOL_RUNNING: case TOOL_DONE: return ( div classNametool-view ToolCallList calls{state.toolCalls} / /div ); case RESPONDING: return ( div classNameresponse-view StreamingText content{state.finalContent} / /div ); case ERROR: return ErrorView error{state.error} onRetry{handleRetry} /; default: return null; } }这种“状态驱动渲染”的模式最大的好处是可预测。不管 Agent 内部多复杂渲染层永远只处理有限的状态。每个状态的渲染逻辑是独立的可以单独测试、单独优化。4.4 流式文本渲染的性能优化流式文本渲染是 Agent 应用里最考验性能的部分。LLM 输出的 token 可能每秒几十个如果每个 token 都触发一次 React 重渲染页面会卡到没法用。我试过几种方案最后稳定下来的是批量更新加节流// 流式文本的批量更新 class StreamingTextBuffer { constructor(onUpdate, interval 50) { this.buffer ; this.onUpdate onUpdate; this.interval interval; this.timer null; } append(token) { this.buffer token; if (!this.timer) { this.timer setTimeout(() { this.onUpdate(this.buffer); this.timer null; }, this.interval); } } flush() { if (this.timer) { clearTimeout(this.timer); this.timer null; } this.onUpdate(this.buffer); } }这个方案的核心是把高频的 token 追加操作合并成低频的视图更新。50ms 的间隔意味着每秒最多更新 20 次视图人眼看起来仍然是流畅的但性能开销降低了一个数量级。注意节流间隔不能设太大否则用户会感觉到明显的“一跳一跳”的卡顿感。我实测下来 30ms 到 80ms 是比较舒服的区间具体值可以根据设备性能动态调整。5. 那些年我踩过的坑和对应的解法5.1 状态错乱事件顺序不对导致视图跳变问题现象前端偶尔会出现视图“跳变”——比如先显示了工具执行结果然后又跳回“思考中”状态。排查过程抓了 SSE 的原始日志发现事件本身是按顺序推送的但前端处理时出现了异步竞争。具体来说tool_result事件的处理函数里有一个异步操作比如格式化结果这个异步操作还没完成下一个llm_token事件就来了导致状态被覆盖。解法给状态归约加一个事件队列保证事件按顺序处理前一个事件的处理逻辑没跑完后一个事件就排队等着。class EventQueue { constructor(reducer) { this.reducer reducer; this.queue []; this.processing false; } push(event) { this.queue.push(event); this.process(); } async process() { if (this.processing) return; this.processing true; while (this.queue.length 0) { const event this.queue.shift(); await this.reducer.reduce(event); } this.processing false; } }5.2 内存泄漏SSE 连接没关导致页面越来越卡问题现象用户在一个页面停留时间长了之后页面越来越卡最后直接卡死。排查过程用 Chrome 的 Performance 面板录了一段发现内存占用持续上升而且有很多未清理的定时器和事件监听器。根因是 SSE 连接在组件卸载时没有正确关闭导致连接一直挂着事件一直在推送状态一直在更新。解法在组件的useEffect清理函数里显式关闭 SSE 连接并且清理所有相关的定时器和缓冲区。useEffect(() { const eventSource new EventSource(/api/agent/stream); const buffer new StreamingTextBuffer(updateView); eventSource.onmessage (e) { const event JSON.parse(e.data); buffer.append(event.content); }; return () { eventSource.close(); buffer.flush(); buffer.destroy(); }; }, []);5.3 降级处理SSE 断了怎么办问题现象网络不稳定的时候SSE 连接会断前端就卡在某个中间状态用户不知道发生了什么。解法加一套降级机制。SSE 断开后前端自动切换到轮询模式每隔几秒请求一次 Agent 的当前状态。同时在界面上给用户一个明确的提示告诉他们“连接不稳定正在重试”。class AgentConnection { constructor() { this.mode sse; // sse | polling this.retryCount 0; } connect() { if (this.mode sse) { this.connectSSE(); } else { this.startPolling(); } } connectSSE() { const es new EventSource(/api/agent/stream); es.onerror () { es.close(); this.retryCount; if (this.retryCount 3) { this.mode polling; this.startPolling(); } else { setTimeout(() this.connectSSE(), 1000 * this.retryCount); } }; } startPolling() { this.pollTimer setInterval(async () { const state await fetch(/api/agent/state).then(r r.json()); this.updateState(state); }, 3000); } }5.4 常见问题速查表问题可能原因排查方向解法视图空白SSE 事件没收到检查网络面板的 EventStream确认服务端推送正常检查前端监听逻辑状态跳变事件处理有异步竞争检查状态归约函数是否有 await加事件队列保证顺序处理页面卡顿高频更新导致重渲染Performance 面板看渲染频率加节流批量更新内存泄漏SSE 连接未关闭Memory 面板看内存曲线组件卸载时清理连接和定时器中间状态卡死某个事件丢失检查事件日志是否完整加超时机制超时后降级或重试最终结果被覆盖中间状态更新晚于最终状态检查事件时间戳用时间戳排序最终状态优先6. 关于 Agent 渲染关系的一些延伸思考6.1 Agent 的“可观测性”本质上是个渲染问题做 Agent 应用时间长了我越来越觉得Agent 的“可观测性”和“渲染”其实是同一个问题的两面。可观测性关注的是“我们能不能知道 Agent 在做什么”渲染关注的是“我们怎么把 Agent 在做的事情展示出来”。两者都需要把 Agent 的内部状态外化都需要处理状态的粒度和时序问题。所以我在设计 Agent 系统的时候会把“可观测性”和“渲染”放在一起考虑。服务端推送的事件既是给监控系统用的也是给前端渲染用的。这样设计的好处是事件的定义会更有约束——它必须足够结构化才能同时满足监控和渲染的需求。6.2 不同 Agent 架构对渲染的影响Agent 的架构设计会直接影响渲染的复杂度。我接触过的几种架构里单 Agent 线性执行的渲染最简单状态机就是一条直线前端只需要按顺序展示每个状态就行。多 Agent 协作的渲染就复杂多了因为多个 Agent 可能并行执行状态机变成了一个图前端要处理多个 Agent 的状态聚合和展示。带记忆的 Agent又不一样因为记忆的读写本身也是一个状态需要在渲染时体现出来比如“正在回忆相关历史”。我现在做架构选型的时候会把“渲染复杂度”作为一个考量因素。如果一个架构的渲染太复杂我会优先考虑简化架构而不是硬着头皮去实现复杂的渲染逻辑。6.3 渲染反过来影响 Agent 的设计这一点是我最近才意识到的渲染的需求会反过来影响 Agent 的设计。比如为了让用户能实时看到 Agent 的思考过程我在设计 Agent 的时候会刻意让 LLM 输出更多的中间推理步骤而不是直接给最终答案。再比如为了让工具调用的展示更友好我会在工具定义里加上“展示名称”和“展示描述”字段专门给渲染层用。这种“渲染驱动设计”的思路一开始我觉得有点本末倒置但后来发现它其实很有价值。因为 Agent 最终是给人用的人的体验决定了 Agent 的价值。如果渲染层展示不清楚Agent 再强大也没用。7. 一个可复用的 Agent 渲染架构模板把上面这些经验整合起来我现在用的 Agent 渲染架构大概长这样服务端负责三件事执行 Agent 逻辑、把执行过程拆成事件、通过 SSE 推送事件。事件的定义要遵循“一个事件对应一个状态变化”的原则不要有歧义。传输层用 SSE 做主力轮询做降级。SSE 事件要带类型、时间戳、以及足够的上下文信息让前端能独立处理每个事件。前端状态层用一个状态归约器把事件流转换成状态机。归约器要保证事件按顺序处理要有超时机制要有错误处理。前端渲染层根据状态机的状态决定渲染什么。每个状态的渲染逻辑独立支持渐进增强支持降级。监控层订阅同一套 SSE 事件做日志记录和异常告警。监控和渲染共用事件源保证两边看到的信息一致。这套架构不是一开始就设计出来的是踩了无数坑之后慢慢收敛出来的。它不一定适合所有场景但如果你也在做 Agent 应用尤其是那种需要实时展示 Agent 中间过程的场景这套思路应该能给你一些参考。最后分享一个我最近才想明白的点Agent 应用开发和渲染的关系本质上不是技术问题而是认知问题。做 Agent 的人要理解渲染的思维方式做渲染的人要理解 Agent 的运作机制两边在同一个认知框架下协作才能把东西做好。技术方案可以抄认知框架抄不来得自己一点点磨。