1. 从一次真实的性能翻车说起去年下半年我接手了一个内部工具项目核心功能是让一个 Agent 自动抓取网页内容、整理成结构化数据然后在前端实时渲染出来给运营同学看。听起来不复杂对吧结果上线第一天就翻车了Agent 每抓一条数据前端就卡死一次页面直接白屏。运营同学在群里疯狂艾特我说“你这玩意儿还不如我手动复制粘贴快”。那次事故让我彻底意识到一个问题Agent 应用开发和渲染之间的关系远比大多数人想象的要深。很多人做 Agent 项目时脑子里只有“模型调用”“工具编排”“记忆管理”这些后端逻辑前端渲染随便糊一个列表就完事了。但实际跑起来你会发现Agent 的输出是流式的、异步的、不确定长度的、可能包含富文本和结构化数据的这跟传统“请求-响应”模式下的渲染逻辑完全是两码事。这篇文章我想把这块东西彻底讲透。不管你是刚入门 Agent 开发的新手还是已经做过几个项目但总在渲染环节踩坑的老手我都会从架构设计、核心原理、实操细节到问题排查把 Agent 与渲染之间的那层窗户纸捅破。涉及的关键词包括 Agent 开发、视图渲染、流式输出、Markdown 渲染、图表闪烁、并发处理等我会尽量用大白话加实际代码片段的方式来讲保证你能直接抄作业。2. Agent 应用与渲染的本质关系拆解2.1 为什么 Agent 的渲染比普通应用难普通 Web 应用的渲染逻辑其实很线性用户点按钮前端发请求后端查数据库返回 JSON前端拿到数据后一次性渲染。整个过程是同步的、确定性的、数据量可控的。你甚至可以在请求发出前就预估到返回的数据结构长什么样。Agent 应用完全不是这个逻辑。一个典型的 Agent 执行流程是这样的用户输入一个意图Agent 开始思考决定调用哪个工具工具执行可能耗时几秒到几十秒执行过程中会不断产生中间状态“正在搜索”“正在读取文件”“正在总结”最终输出可能是一段 Markdown、一个表格、一张图表甚至是一段需要二次渲染的 HTML。更麻烦的是Agent 的输出长度是不确定的可能几十个字也可能几千个字还可能中途出错需要重试。这就导致渲染层面临三个核心挑战第一状态是流式的你不能等 Agent 全部执行完再渲染那样用户体验极差第二内容是异构的同一段输出里可能混杂纯文本、代码块、表格、图片链接第三时序是不确定的Agent 可能先输出一半然后卡住过几秒又继续输出渲染层必须能处理这种“断断续续”的情况。我见过太多项目在这三个挑战上翻车。有的项目为了图省事等 Agent 完全执行完再一次性返回结果用户等十几秒看不到任何反馈直接关页面走人。有的项目用简单的字符串拼接来渲染流式内容遇到 Markdown 语法跨 chunk 断裂时直接渲染出乱码。这些都是没有理解 Agent 与渲染本质关系导致的。2.2 渲染在 Agent 架构中的位置从架构层面看渲染层在 Agent 应用中扮演的是最后一公里的角色。Agent 核心负责“思考”和“执行”渲染层负责“表达”和“交互”。这两者之间的边界如果划不清楚就会出现两种极端要么渲染层承担了太多业务逻辑变得臃肿不堪要么渲染层太薄所有格式化工作都推给后端导致传输数据量巨大。我的经验是渲染层应该承担三件事流式内容的增量解析、异构内容的类型分发、交互状态的即时反馈。而 Agent 核心应该保证输出的结构化和可追溯。举个例子Agent 输出一段包含代码块和表格的 Markdown 时渲染层需要能识别出代码块的开始和结束标记在流式传输过程中正确处理“代码块还没传输完”的中间状态而不是傻乎乎地把半截代码块当普通文本渲染。这里有个很关键的认知Agent 的输出协议设计直接决定了渲染层的复杂度。如果你在 Agent 侧就把输出定义成“事件流”的形式每个事件带有明确的类型标记文本增量、工具调用开始、工具调用结束、错误、完成那渲染层只需要按类型分发处理即可逻辑会清晰很多。反之如果你让 Agent 直接吐一大段 Markdown 字符串渲染层就得自己做语法解析和状态管理复杂度会指数级上升。2.3 常见架构模式的对比与选型目前市面上 Agent 应用的渲染架构大致有三种模式我分别说说它们的优缺点和适用场景。第一种是“全量等待”模式。Agent 执行完毕后一次性返回完整结果前端拿到后整体渲染。这种模式实现最简单渲染层几乎不需要做特殊处理但用户体验最差。适合那种执行时间短几秒内、结果不需要实时反馈的场景比如简单的问答机器人。但只要是涉及工具调用、多步推理的 Agent这种模式基本不可用。第二种是“流式增量”模式。Agent 每产生一个输出片段就立即推送给前端前端做增量渲染。这是目前最主流的方案用户体验最好但实现复杂度最高。核心难点在于增量解析Markdown 语法是上下文相关的一个**可能开启加粗也可能只是普通字符你必须维护解析状态。我后面会详细讲怎么处理。第三种是“分阶段渲染”模式。把 Agent 执行过程拆成几个明确的阶段思考中、工具调用中、生成结果中每个阶段用不同的 UI 组件渲染阶段内部再走流式。这种模式在复杂 Agent 应用中很常见因为它能给用户更清晰的进度感知。比如工具调用阶段显示一个“正在搜索”的动画结果生成阶段再切换到流式文本渲染。架构模式实现复杂度用户体验适用场景全量等待低差简单问答、短任务流式增量高好通用 Agent 应用分阶段渲染中高很好多步推理、工具调用频繁选型的时候不要盲目追求最复杂的方案。我见过一个内部工具Agent 执行时间稳定在 2 秒以内结果团队非要上流式渲染最后为了处理各种边界情况多花了两周时间收益却微乎其微。架构选型的核心原则是匹配实际场景而不是堆技术。3. 流式渲染的核心技术细节3.1 流式输出的数据协议设计流式渲染的地基是数据协议。如果协议设计得不好后面渲染层怎么写都别扭。我推荐用SSEServer-Sent Events或者WebSocket来传输具体选哪个看你的场景SSE 更简单单向推送够用WebSocket 双向通信适合需要前端反向控制的场景。协议的核心是事件类型定义。我一般会定义这么几类事件{type: text_delta, content: 这是一段} {type: tool_start, tool: web_search, input: ...} {type: tool_end, tool: web_search, output: ...} {type: error, message: ...} {type: done, usage: {...}}每个事件都带type字段渲染层根据type分发到不同的处理函数。text_delta走文本增量渲染tool_start和tool_end走工具状态渲染error走错误提示渲染。这样渲染层的逻辑就非常清晰不需要去猜“这段内容到底是什么”。注意text_delta的content必须是最小增量单元不要攒一大段再发。我见过有的实现为了减少网络请求每 500 毫秒才发一次结果用户看到的是“一顿一顿”的输出效果体验很差。正确的做法是每产生一个 token 或一小段文本就立即推送。3.2 Markdown 增量解析的难点与解法Markdown 增量解析是流式渲染里最麻烦的部分。麻烦在哪Markdown 的语法是上下文相关的。比如**这两个字符单独看没有意义只有成对出现才表示加粗。在流式传输中你可能会先收到一个**这时候你无法确定它是加粗的开始还是普通字符必须等后续内容才能判断。更麻烦的是代码块。代码块用三个反引号开启和关闭在流式传输中你可能会收到“两个反引号加一个字母”这种中间状态如果直接渲染就会出错。还有表格表格的列对齐依赖表头行的分隔符流式传输时表头可能还没传完你就无法确定表格结构。我的解法是维护一个解析状态机。状态机记录当前处于什么语法上下文中普通文本、代码块内、表格内、列表内等每收到一个增量就更新状态并决定如何渲染。具体实现上我推荐用成熟的增量 Markdown 解析库比如markdown-it配合自定义的流式插件或者streaming-markdown这类专门为流式场景设计的库。import MarkdownIt from markdown-it; import streamPlugin from markdown-it-stream; const md new MarkdownIt().use(streamPlugin); // 每次收到增量时调用 function onTextDelta(delta) { const html md.renderStream(delta); // 将 html 追加到渲染容器 container.insertAdjacentHTML(beforeend, html); }如果不想引入额外依赖也可以自己写一个简化版的状态机。核心思路是遇到就进入代码块状态遇到成对的**就切换加粗状态遇到|开头的行就进入表格状态。但自己写的话边界情况会很多建议还是用成熟库。实操心得增量解析时一定要处理“不完整语法”的情况。比如收到**加粗但还没收到结尾的**这时候应该先按普通文本渲染等收到结尾标记后再重新渲染这一段。我试过用“先渲染后修正”的策略虽然会有短暂的样式闪烁但比卡住不渲染要好得多。3.3 异构内容的类型分发策略Agent 的输出往往不是纯文本可能混杂代码块、表格、图表、图片、甚至可交互的组件。渲染层需要一套类型分发机制根据内容类型选择对应的渲染器。我的做法是在 Agent 侧就给输出打上类型标记。比如 Agent 决定输出一个图表时不是直接输出图表的 JSON 配置而是输出一个带有类型标记的事件{type: chart, chartType: bar, data: {...}}渲染层收到chart类型的事件后调用图表渲染器比如 ECharts来渲染。这样渲染层不需要去解析内容猜测类型逻辑清晰很多。对于 Markdown 内部的异构内容比如代码块和表格增量解析库会输出对应的 HTML 结构渲染层只需要在 HTML 插入后做一些增强处理。比如代码块插入后调用语法高亮库表格插入后添加排序和筛选功能。这里有个坑要注意ECharts 在流式渲染场景下容易闪烁。原因是每次收到增量都重新setOption图表会重新绘制。解法是用setOption的notMerge参数控制合并策略或者用防抖函数控制重绘频率。我一般会等图表数据完全传输完毕后再渲染中间过程用一个占位符代替。4. 实操过程与核心环节实现4.1 从零搭建一个流式渲染的 Agent 前端假设我们要做一个 Agent 应用功能是用户输入问题Agent 调用搜索工具后流式输出答案。我来一步步拆解实现过程。第一步建立 SSE 连接。前端用EventSource或fetch的流式读取来接收事件。我推荐用fetch因为EventSource不支持自定义请求头而且只能发 GET 请求。async function startAgent(query) { const response await fetch(/api/agent, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ query }) }); const reader response.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); buffer lines.pop(); // 保留不完整的最后一行 for (const line of lines) { if (line.startsWith(data: )) { const event JSON.parse(line.slice(6)); handleEvent(event); } } } }第二步事件分发处理。根据事件类型调用不同的处理函数。function handleEvent(event) { switch (event.type) { case text_delta: appendText(event.content); break; case tool_start: showToolStatus(event.tool, running); break; case tool_end: showToolStatus(event.tool, done); break; case error: showError(event.message); break; case done: finalize(); break; } }第三步增量文本渲染。用增量 Markdown 解析库处理text_delta。let mdBuffer ; function appendText(delta) { mdBuffer delta; const html md.renderStream(delta); outputContainer.insertAdjacentHTML(beforeend, html); scrollToBottom(); }第四步工具状态渲染。工具调用时显示一个状态卡片调用完成后更新状态。function showToolStatus(tool, status) { let card document.querySelector([data-tool${tool}]); if (!card) { card document.createElement(div); card.dataset.tool tool; card.className tool-card; outputContainer.appendChild(card); } card.textContent status running ? 正在调用 ${tool}... : ${tool} 调用完成; }这套代码看起来简单但实际跑起来会遇到很多细节问题。比如buffer的处理如果服务端发送的数据被 TCP 分片了你可能收到半行 JSON直接JSON.parse会报错。所以必须用buffer保留不完整的行等下一批数据到了再拼接。4.2 后端 Agent 的流式输出实现后端这边核心是把 Agent 的执行过程转成事件流。以 Python 为例如果用 FastAPI可以这样实现from fastapi import FastAPI from fastapi.responses import StreamingResponse import json app FastAPI() async def agent_stream(query: str): # 模拟 Agent 执行 yield fdata: {json.dumps({type: text_delta, content: 正在思考...})}\n\n # 工具调用 yield fdata: {json.dumps({type: tool_start, tool: web_search})}\n\n result await web_search(query) yield fdata: {json.dumps({type: tool_end, tool: web_search, output: result})}\n\n # 流式生成答案 async for chunk in llm_stream(query, result): yield fdata: {json.dumps({type: text_delta, content: chunk})}\n\n yield fdata: {json.dumps({type: done})}\n\n app.post(/api/agent) async def agent_endpoint(query: str): return StreamingResponse(agent_stream(query), media_typetext/event-stream)关键点是StreamingResponse和text/event-stream的媒体类型。每个事件必须以data:开头以\n\n结尾这是 SSE 的格式要求。注意事项后端流式输出时一定要处理客户端断开连接的情况。如果用户关闭了页面后端还在傻乎乎地生成内容会浪费资源。可以在生成器里检查await request.is_disconnected()如果断开就提前终止。4.3 并发场景下的渲染性能优化Agent 应用经常需要处理并发请求比如多个用户同时使用或者一个用户同时发起多个 Agent 任务。并发场景下渲染性能会急剧下降因为每个流式连接都在不断触发 DOM 更新。我踩过的一个坑是每个text_delta都直接操作 DOM导致高频更新时浏览器主线程被占满页面卡顿。解法是用requestAnimationFrame或requestIdleCallback来批量更新 DOM。let pendingUpdates []; let rafScheduled false; function scheduleUpdate(html) { pendingUpdates.push(html); if (!rafScheduled) { rafScheduled true; requestAnimationFrame(flushUpdates); } } function flushUpdates() { const html pendingUpdates.join(); outputContainer.insertAdjacentHTML(beforeend, html); pendingUpdates []; rafScheduled false; }这样把多次 DOM 更新合并到一帧内执行性能提升非常明显。实测下来在高频流式输出场景下页面帧率能从 20fps 提升到 55fps 以上。另一个优化点是虚拟滚动。如果 Agent 输出内容非常长比如几千行全部渲染到 DOM 里会导致内存暴涨。可以用虚拟滚动只渲染可视区域的内容。不过虚拟滚动和流式增量渲染结合时会有一些复杂性需要维护一个“已渲染内容”的缓冲区滚动时动态替换。5. 常见问题与排查技巧实录5.1 渲染闪烁与卡顿的排查思路渲染闪烁是 Agent 应用最常见的问题之一。表现是内容在渲染过程中不断跳动或者图表反复重绘。排查思路我整理成了一个速查表问题现象可能原因排查方法解决方案文本闪烁增量解析时重复渲染检查是否每次增量都重新渲染整段只渲染新增部分图表闪烁频繁 setOption打印 setOption 调用频率防抖 notMerge页面卡顿DOM 更新过于频繁Performance 面板录制requestAnimationFrame 批量更新滚动跳动自动滚动逻辑冲突检查 scrollToBottom 调用时机用户手动滚动时暂停自动滚动布局抖动图片/图表未预留空间检查是否有固定宽高预留占位空间我重点说说图表闪烁。ECharts 在流式场景下闪烁的根本原因是每次setOption都会触发完整的重绘流程。解法有两个一是等数据完全传输完毕再渲染中间用骨架屏占位二是用setOption的notMerge: false参数让 ECharts 做增量合并而不是完全替换。实测下来第二种方案在数据量不大时效果很好但数据量大时还是会有性能问题建议结合使用。5.2 内容截断与乱码的修复方法内容截断通常发生在流式传输的边界处理上。比如服务端发送的 JSON 被 TCP 分片前端收到半截 JSON 直接解析就会报错。解法是维护一个缓冲区只处理完整的行。let buffer ; function onData(chunk) { buffer chunk; const lines buffer.split(\n); buffer lines.pop(); // 最后一行可能不完整保留 for (const line of lines) { if (line.trim()) { try { const event JSON.parse(line.replace(/^data: /, )); handleEvent(event); } catch (e) { console.error(解析失败:, line, e); } } } }乱码问题通常出现在多字节字符比如中文被分片的情况下。TextDecoder的{ stream: true }参数就是解决这个问题的它会把不完整的多字节字符缓存起来等后续字节到了再一起解码。实操心得调试流式渲染时我习惯在服务端加一个“慢速模式”每个事件之间延迟 500 毫秒发送。这样能清楚地看到每个增量是如何渲染的方便定位问题。生产环境再关掉这个延迟。5.3 Agent 执行出错时的渲染降级策略Agent 执行过程中出错是常态比如工具调用超时、模型返回格式错误、网络中断等。渲染层必须能优雅地处理这些错误而不是直接白屏。我的策略是分级降级。第一级如果错误发生在工具调用阶段渲染层显示一个错误提示卡片但保留已经渲染的内容让用户知道哪一步出了问题。第二级如果错误发生在文本生成阶段把已生成的部分内容保留在末尾追加一个“生成中断”的提示。第三级如果整个连接断开显示一个重试按钮点击后重新发起请求。function handleError(error) { const errorCard document.createElement(div); errorCard.className error-card; errorCard.innerHTML span生成中断${error.message}/span button onclickretry()重试/button ; outputContainer.appendChild(errorCard); }这里有个细节重试时不要清空已渲染的内容而是从断点继续。这需要 Agent 侧支持“断点续传”实现起来比较复杂。如果不想做断点续传至少要把已渲染的内容保留下来让用户能复制走。6. 一些踩坑之后的个人体会做 Agent 应用这一年多我在渲染这块踩的坑比后端逻辑还多。最大的体会是不要试图用传统前端思维来做 Agent 渲染。传统前端是“数据确定、结构确定、时序确定”的三确定模型而 Agent 渲染是“数据流式、结构异构、时序不确定”的三不确定模型。你必须换一套思路把渲染层当成一个状态机来设计而不是一个模板引擎。另一个体会是协议先行。在写任何渲染代码之前先把 Agent 输出的事件协议定义清楚。协议定好了前后端可以并行开发渲染层的逻辑也会清晰很多。我见过太多项目因为协议没定好前后端反复扯皮最后渲染层写了一堆兼容代码维护起来极其痛苦。最后说一个容易被忽视的点渲染层的可观测性。Agent 应用出问题时你很难复现因为每次执行路径可能都不一样。所以在渲染层加日志非常重要记录每个事件的到达时间、处理耗时、渲染结果。这样出问题时能快速定位是 Agent 侧的问题还是渲染侧的问题。我一般会在开发环境把每个事件都打印到控制台生产环境则上报到监控系统设置异常告警。这套东西说起来复杂但真正跑通之后你会发现 Agent 应用的渲染其实有很强的规律性。核心就是三件事流式增量解析、异构类型分发、错误优雅降级。把这三件事做好剩下的就是细节打磨了。