做Agent应用开发久了你会发现一个反直觉的事决定一个AI Agent能不能被用户接受往往不是模型推理有多强、工具调用有多准而是界面上的渲染体验。我见过不少团队把精力全部放在prompt、Agent框架、工具编排上一打开前端页面输出像流水账一样堆文字Markdown渲染错位ECharts图表闪个不停用户第一印象直接崩掉。这篇文章我想聊聊Agent应用开发和渲染之间这层容易被忽略的关系包括流式文本、Markdown、图表、3D场景的渲染以及怎么处理Agent场景下的并发和渲染性能问题。如果你是正在搭建Agent应用的产品、前端或全栈可能会有些参考价值。1. 为什么Agent应用越做越像渲染工程1.1 说清楚Agent是大脑Harness是身体渲染是表情很多人第一次接触Agent开发时习惯把Agent理解成一个“增强版API”输入问题返回答案。真正开始做产品后才发现Agent只是核心的推理循环负责感知、规划、行动而用户接触到的界面、交互、反馈全靠另一层东西承载。行业里有人把这层叫Harness也有人叫Agent运行时。总有人搜“harness和agent区别”我的理解很简单Agent是大脑Harness是身体而渲染是身体上的表情。为什么说渲染是表情因为Agent的所有思考结果必须通过某种方式“演”给用户看。一个会推理但不会表达的Agent就像一个人脑子里有千言万语却张嘴结巴用户体验会很差。反过来说一个只有普通规则引擎、但界面反馈做得很细腻的应用用户反而会觉得它“智能”。这意味着Agent应用开发不像传统后端服务它天然是一个前后端紧耦合的系统后端负责推理前端负责把推理过程变成可感知的体验。渲染不是壳上的装饰而是Harness表达能力的核心。我做过一个内部调研工具的Agent最初后端返回一段纯文本前端直接塞进pre里结果用户反馈“看不出它做了什么”。后来只是把工具调用、搜索过程、引用来源渲染成时间线卡片用户就觉得“这个Agent靠谱多了”。同一个模型同一个工具链唯一的变量是渲染方式。这件事让我彻底改变了设计顺序现在做Agent项目先定Harness和渲染协议再往下做Agent逻辑。1.2 从“答案正确”到“体验像人”渲染到底管什么Agent应用开发其实经历了几个阶段。最早的工具是CLI跑完命令打印文本大家觉得能用就行后来接Web后端返回JSON前端拼接字符串要求“结果对”再到现在流式输出、结构化展示、思考过程可见用户开始要求“体验像人”。这个变化背后其实是渲染层在承担越来越重的任务。你需要把Agent的过程渲染成一个时间轴模型正在读什么上下文、调用了哪个工具、拿到了什么结果、下一步打算做什么。每一步如果都有视觉反馈用户就会觉得这个Agent在“干活”而不是卡住了。相反如果中间过程完全黑箱只到最后弹出一个答案用户会怀疑是不是写死了或者觉得速度很慢。我常跟团队说一句话Agent的“智能感”是渲染出来的。不是骗人而是把真实过程用人类友好的方式表达出来。同样一个tool_call事件如果直接显示json用户会反感如果渲染成“正在读取本地文件… 找到3个匹配项正在汇总”体验完全不同。这需要前端不只是做模板还要理解Agent的事件流做协议转译。渲染的位置已经从“最后展示答案”提前到了“全程呈现智能”这是Agent应用和传统CRUD应用最本质的区别。1.3 被低估的渲染场景状态、记忆和工具调用除了最终答案Agent运行中产生的中间状态也需要渲染。最常见的三类状态、记忆、工具调用。状态指的是Agent当前处于什么阶段比如初始化、思考中、调用工具、等待输入、出错。很多人用几个布尔值拼状态结果组合一多就乱套。更好的做法是定义一个状态机前端拿一个状态值渲染对应的视图。工具调用则要渲染成卡片工具名称、入参摘要、耗时、出参预览都可以折叠展示。记忆是更进阶的一层Agent读写了哪些工作记忆哪些信息被记住了要不要允许用户一键清空这些都是渲染层可以做的。我记得有个项目里Agent会先查用户历史偏好再生成推荐。第一次实现时前端完全没展示“读取记忆”的过程用户觉得推荐“凭空出现”不够可信。后来加了一个“基于你上次的选择我优先考虑了这些方向”的渲染片段点击率明显提升。用户不是要真的看内部数据而是需要一个“被理解”的视觉信号。这类渲染场景看起来小却是Agent应用体验的胜负手。2. 拆解Agent应用里最常见的渲染任务2.1 流式文本打字机效果背后的增量渲染流式输出大概是Agent应用里最标配的功能。模型生成的token通过SSE或WebSocket一个个推过来前端要像打字机一样显示出来。实现起来有几个坑如果每次收到一个delta就把整段文本重新渲染浏览器会不断销毁重建DOM性能会迅速恶化如果用React/Vue的受控组件每次更新还会触发虚拟DOM diff大部分时候是浪费。正确的做法是维护一个稳定的DOM容器用document.createTextNode创建的文本节点然后不断appendData追加内容。这样浏览器只做增量更新不会反复解析HTML。代码大概是这样的const container document.getElementById(agent-output); const textNode document.createTextNode(); container.appendChild(textNode); ws.onmessage (e) { const { type, delta } JSON.parse(e.data); if (type text_delta) { textNode.appendData(delta); } };若想继续用前端框架也建议把流式文本的更新操作收敛到一个自定义Store里让组件只订阅最终结果而不是每次delta都重渲染。光这一步就能把长回复场景下的卡顿减少一大半。我见过有人用setState更新整个Markdown源码结果输入一长页面直接卡成PPT原因就是每次都重跑Markdown解析。流式场景的核心原则是文本进来只做“追加”格式化工作放到用户停顿或流结束后做。2.2 Markdown和代码块模型输出格式化的关键模型输出大部分是Markdown但原始的Markdown串不能直接丢给用户。要用markdown-it这类库解析成HTML。这里有几个细节值得注意。第一配置要开好。html: false一定要设置避免模型输出里的原始HTML被执行linkify: true可以把裸链接自动转成可点击链接typographer: true会统一标点符号观感更干净。第二代码高亮。模型经常输出代码块推荐把markdown-it的highlight回调指向highlight.js或Shiki否则一大段代码看起来就是灰色纯文本。import MarkdownIt from markdown-it; import hljs from highlight.js; const md new MarkdownIt({ html: false, linkify: true, typographer: true, highlight(str, lang) { if (lang hljs.getLanguage(lang)) { return pre classhljscode${hljs.highlight(str, { language: lang }).value}/code/pre; } return pre classhljscode${md.utils.escapeHtml(str)}/code/pre; } });第三Markdown渲染很吃主线程。尤其是模型输出几千字还要做代码高亮整个md.render(content)会卡住浏览器几百毫秒甚至一秒以上。更稳的做法是先用md.parse拿到tokens然后分成若干段逐帧渲染或者干脆做虚拟滚动只渲染可视区内的段落。对于“markdown-it渲染大量文字”这个痛点我推荐先把链接、图片、高亮这类重活异步化再考虑分词渲染。2.3 图表和3D当Agent开始画图和做空间Agent不只是聊文字还会输出图表。数据分析类Agent一般会用ECharts生成折线图、柱状图工业或空间智能类场景甚至会做3D网页渲染比如在浏览器里展示设备状态、建筑模型、点云数据。这些可视化一旦接入Agent流式输出问题就来了。最常见的是“ECharts闪烁怎么解决前端”这个问题。闪烁多半不是ECharts本身的问题而是使用方式不对。常见原因有三个一是DOM被v-if或v-show反复切换导致图表实例不断销毁重建二是每次数据更新没有复用旧实例而是重新init三是update时没有正确设置notMerge或lazyUpdate新旧series动画冲突看起来像闪了一下。我的标准写法是这样const chart echarts.getInstanceByDom(el); if (chart) { chart.dispose(); } const instance echarts.init(el); instance.setOption(option, { notMerge: true, lazyUpdate: true });如果你的图表数据是流式推进的建议额外加一个节流高频数据变化时最多每200ms调用一次setOption避免每一帧都触发内部动画。3D渲染同理不要每次Agent返回数据就重建整个场景应该维护一个场景实例只更新对象的属性或坐标。对大部分场景来说Three.js的Object3D更新远比重新加载场景轻量。2.4 Agent运行状态的实时展示不是可选项是刚需很多Agent应用现在会展示“思考中…”“正在调用工具…”这些状态渲染看起来简单但做不好会非常影响信任度。我建议自己维护一个状态机视图而不是用一堆loading变量。后端事件协议可以设计成统一的{ event: tool_call_start, data: { tool: search_docs, args: { query: agent渲染 }, ts: 1710000000000 } }前端拿到事件后根据event类型分发到对应组件text_delta走流式文本tool_call_start走工具卡片status_change走顶部状态栏。这样每个组件只关心一类事件渲染逻辑清晰也不容易出现状态串台。工具调用卡片还能再往深做入参可以折叠出参只显示摘要耗时超过N秒就自动展开“查看详情”。这些不是花哨功能而是把Agent“黑箱运行”变成“白箱过程”的关键。用户在等待时能看到进度就不会焦虑也会更信任结果。现在很多商用Agent产品都在做“thought过程展示”本质就是状态渲染的产品化。3. 实操一套能扛住并发的Agent前端渲染方案3.1 数据通道SSE还是WebSocketAgent应用的数据通道我一般优先推荐SSEServer-Sent Events。SSE基于HTTP自动重连服务端实现简单特别适合LLM这种单向流式输出。WebSocket虽然支持双向通信但在Agent场景里大多数时候只需要服务器往客户端推流用户端最多发个“停止”。如果工具需要人工确认才需要双向通道。用SSE时注意一点很多网关或反向代理默认会缓冲响应导致流式输出变成一次性到达。前端等半天看不到字。需要在后端设置响应头X-Accel-Buffering: no或者把代理的proxy_buffering off打开。另外SSE的消息格式要遵循规范多个事件用\n\n分开每条事件里可以带id方便断线续传。前端用EventSource接断线后会自动重连但如果要传鉴权头可能需要用fetch ReadableStream模拟或者带上token参数。WebSocket适合需要高频确认的Agent流程比如“Agent建议执行操作等待用户批准”。这种情况下SSE的单向模型会让确认逻辑变绕。我的建议是默认SSE需要双向再切WS不要把复杂留给自己。3.2 渲染管线的四层结构一套能扛并发的Agent前端方案我习惯分成四层协议层、缓冲层、视图层、监控层。协议层负责定义后端事件格式所有消息必须是结构化事件而不是随便发一段文本。这是最重要的一层因为渲染大部分问题是协议设计不当导致的。缓冲层负责合并高频事件。例如文本delta可能每秒来几十个如果每次都触发React更新性能一定崩。这里可以做一个批量调度把同一次“渲染帧”内的事件合并然后在requestAnimationFrame里统一更新视图。视图层就是一个个事件对应的组件只消费自己关心的事件做到组件隔离。监控层则是用PerformanceObserver记录长任务和渲染耗时帮你在上线后定位问题。缓冲层的代码思路可以这样let pendingEvents []; let scheduled false; function pushEvent(e) { pendingEvents.push(e); if (!scheduled) { scheduled true; requestAnimationFrame(() { applyEvents(pendingEvents); pendingEvents []; scheduled false; }); } }这个简单的“微任务动画帧”合并能显著减少因高频更新导致的布局抖动。后端并发再高前端也不至于被事件风暴冲垮。很多人问“AI agent怎么扛并发”其实前端能做到的就是合并更新、控制渲染频率、避免每个token都触发一次完整DOM操作。3.3 大量文字的Markdown渲染优化针对“markdown-it渲染大量文字”这个具体问题我把优化方案拆成三步。第一步是解析和渲染分离。md.parse(content)会生成tokens但不会立即输出HTMLmd.renderer.render(tokens)才生成HTML。你可以把parse放在空闲时间或者干脆只在流结束后再做一次完整渲染。流进行中先显示纯文本作为草稿用户不会觉得慢还能提前阅读。第二步是高亮异步化。highlight.js在代码块很大时是性能杀手。可以把高亮丢到Web Worker里Worker算完再把高亮HTML塞回来。前端只负责接收结果。另一个方案是使用Shiki的wasm版本虽然加载更重但高亮性能更稳。对于超长文档不要一次性渲染全部段落可以用虚拟滚动只渲染可视区内的内容滚动时动态替换。第三步是做安全过滤。模型输出可能被用户注入内容所以html: false必须开外部链接要加target_blank relnoopener noreferrer必要时再跑一遍sanitize。安全不是小事尤其Agent能访问个人信息、执行工具渲染层是最后一道防线。3.4 ECharts闪烁问题排查与修复前面提过ECharts闪烁的几个常见原因这里把排查过程说细一点。闪烁一旦出现不要急着改配置先打开开发者工具看DOM。第一步检查图表容器是否被反复创建/销毁。如果组件里用了v-ifshowChart而Agent每次返回数据都把showChart从false切到true那么ECharts实例会重建动画会重播看起来就是闪。改成用v-show或者实例保持不变。第二步检查setOption的合并参数。ECharts默认是merge模式如果新旧数据里的series名称变了旧图元会残留然后被新动画覆盖出现闪烁。如果数据是全新一批用notMerge: true更干净。如果是增量更新保持series结构一致只改data值。第三步统一管理实例。用echarts.getInstanceByDom取出已有实例不要重复init。组件卸载时一定要dispose否则会造成内存泄漏图表一多页面越来越卡。resize监听记得debounce不然窗口拖动时图表疯狂重算也会给你一种“闪”的错觉。下面是一个可复用的基础封装骨架function renderChart(el, option) { let chart echarts.getInstanceByDom(el); if (!chart) { chart echarts.init(el); } chart.setOption(option, { notMerge: true, lazyUpdate: true }); return chart; }4. 渲染引擎和Agent运行时的深层关系4.1 渲染引擎原理从Impeller到React Concurrent如果你在移动端做Agent应用大概率会接触到Flutter。Flutter 3.10之后默认启用Impeller渲染引擎它的核心思路是预编译Shader、不做逐帧JIT着色器编译从而消除应用启动和滚动时的掉帧。对Agent应用来说意义在于Agent运行过程中通常会有大量列表、卡片、动画状态切换如果渲染引擎在首帧卡一下用户会觉得“这个Agent反应慢”。Web端对应的概念是React的Concurrent特性。React 18之后渲染可以切片高优先级更新可以打断低优先级渲染。Agent应用里流式文本更新的优先级可以调低用户点击“停止”按钮的优先级必须调高否则Agent生成内容时界面会完全无法响应。这已经不是简单的组件优化而是渲染调度策略问题。理解渲染引擎原理不是说每个开发者都要去读源码而是知道性能瓶颈在哪里。移动端卡顿先怀疑着色器和图片解码Web端卡顿先怀疑JS长任务和布局抖动。Agent应用把推理和渲染放在一条链路上任何一个环节掉帧用户都会把它归因于“AI不行”。4.2 前端长任务如何反过来拖垮Agent推理很多人以为前端渲染慢只影响体验不影响Agent本身。这是个误区。Agent应用的通信链路通常是后端推理 - 推送token - 前端接收 - 渲染。如果前端主线程被Markdown高亮或大列表渲染占满WebSocket或SSE的消息回调就无法及时执行导致数据积压前端看起来就是“Agent停住了”。浏览器性能工具里Long Task就是指超过50ms的任务。一旦出现长任务用户输入、滚动、消息处理都会延迟。我在一个Agent项目里踩过坑模型流式输出代码块前端每次收到完整代码后就立刻高亮结果渲染一次要300ms浏览器卡到鼠标都动不了。后来把高亮改成空闲时执行再加上代码块懒高亮整个交互才算顺过来。解决思路是给渲染层做“预算管理”。每次渲染前估算工作量超过阈值就分片处理使用requestIdleCallback处理低优先级任务高亮、图片解码尽量放到Worker长列表用虚拟滚动。记住Agent应用的前端不能抱着“反正后端算得快”的心态渲染耗时会直接透支用户体验甚至阻塞通信。4.3 并发视角Agent后端再快渲染层也会成为瓶颈聊到“AI agent怎么扛并发”大家通常关注后端并行、模型推理、工具调度。但前端是单线程的哪怕后端同时跑10个Agent会话所有结果最终都要汇到同一个UI线程。如果不做合并和缓冲并发越高前端越容易崩溃。我试过同一个页面同时展示多个Agent会话的流式输出。后端并发毫无压力但前端收到的事件一多频繁setState导致页面掉到20帧。最终方案是给每个会话维护独立的事件队列所有队列统一在动画帧里合并更新。这跟后端限流的思路类似控制进入视图层的流量而不是让每个事件都立刻驱动DOM。另外渲染层还应该做乐观更新。Agent调用工具需要2秒前端可以先用骨架屏或占位卡片展示“正在搜索”等结果到了再替换成真实内容。这样用户感知到的延迟会大幅下降也不会觉得Agent没反应。记住并发的瓶颈不一定在服务端前端的事件风暴同样是系统瓶颈。5. 常见问题速查表与独家避坑心得5.1 常见问题速查表下面整理了我做Agent应用时比较常踩的渲染问题按症状、原因、解法三列给出方便你直接对照排查。症状可能原因快速解法ECharts图表闪烁实例重复创建、setOption合并冲突复用实例按场景设置notMergedispose旧实例markdown-it渲染大量文字卡顿一次性渲染全部内容高亮阻塞主线程parse/render分离高亮放Worker虚拟滚动流式输出时UI冻结每次token都触发setState使用文本节点appendData或批量调度更新多个Agent会话互相串状态全局变量控制状态组件未隔离用状态机统一事件协议每个会话独立Store代码块高亮导致下拉卡顿高亮耗时过长且全部渲染懒高亮只高亮可见代码块页面出现XSS风险模型输出未过滤html:true关闭html渲染开启sanitize和链接relAgent工具调用过程看不到只渲染最终答案把tool_call等事件渲染成卡片/时间线流式输出一次到齐代理缓冲了SSE响应关闭代理缓冲设置X-Accel-Buffering:no3D场景频繁重建每次数据返回都重新load维护Object3D实例只更新属性或矩阵移动端Agent应用掉帧Impeller未开启或Shader编译卡顿升级Flutter版本检查Impeller状态监控首帧5.2 三个必须养的编码习惯第一渲染层绝对信任用户输入是不行的。模型输出里可能夹带链接、图片、HTML标签甚至XSS脚本。Markdown渲染必须关掉html链接加relnoopener noreferrer必要时用DOMPurify再做一遍清洗。这不是小题大做Agent应用一旦能访问用户数据渲染安全就是数据安全的一部分。第二Agent状态不要用一堆布尔值。isLoading、isToolCalling、isDone排列组合起来会变成灾难。用一个status枚举再加事件驱动前端只需要根据状态和事件渲染。实践下来状态机能让并发场景下的UI保持稳定排查问题也更快。第三监控渲染性能要提前接入。使用PerformanceObserver监测Long Task把超过50ms的渲染任务记录上报。Agent应用上线后如果只是看后端指标会漏掉大量前端瓶颈。我习惯在开发环境保留一个“渲染预算”面板明确标出每次Markdown渲染、图表更新消耗了多少时间团队心里都有数。5.3 我对“Agent和渲染关系”的一系列判断顺着这个话题多说几句。我发现凡是成功的Agent应用很少把渲染当作可有可无的壳。它们普遍在“呈现过程”上花了大量功夫比如显示思考过程、工具调用时间线、记忆状态、下一步计划。这意味着Agent应用正在变成一种“交互式媒体”而不仅仅是工具。渲染的本质是把Agent的决策过程翻译成用户能理解的语言。接下来的Agent产品会越来越复杂可能涉及3D场景、空间计算、实时音视频这些都需要渲染层提前设计好架构。不要再把“前端套壳”当成贬义词Agent的Harness和渲染决定了AI能力能不能“落地成人话”。再强的模型渲染成糊成一团的文字价值也会大打折扣。最后说点实在的。我踩过最大的坑是把Agent应用当作纯后端项目来设计把渲染当成“最后再说”的事情。结果每次联调都在补前端性能债。现在我的原则是协议先行渲染同步设计性能预算从一开始就算进去。Agent框架能帮你连接模型和工具但连接用户和Agent的永远是渲染。希望这篇思考能帮你少走点弯路。