Agent可视化生成:从LLM写HTML到结构化装配范式

发布时间:2026/10/27 8:56:57
Agent可视化生成:从LLM写HTML到结构化装配范式

1. 项目概述:为什么“Agent 可视化生成”正在取代硬编码式 AI 开发

最近三个月,我连续参与了四家不同行业客户的智能体(Agent)落地项目——从制造业设备预测性维护看板,到教育机构的个性化学习路径生成器,再到本地政务大厅的政策问答可视化助手。一个共同现象是:所有客户在第二轮需求迭代时,都主动提出同一个诉求:“能不能别让 AI 硬写 HTML 了?” 这句话背后,藏着过去两年 Agent 开发中最普遍、最隐蔽、也最消耗交付周期的痛点:AI 模型输出的 HTML/JS/SVG 内容不可控、不可调试、不可复用、不可审查。你让大模型生成一个带 Three.js 旋转地球仪的页面,它可能真的给你一段能跑起来的代码,但这段代码里混着内联样式、未声明的全局变量、硬编码的 canvas 尺寸、甚至还有拼错的requestAnimationFrame。更糟的是,当业务方说“把地球换成月球,加个环形轨道”,你得重新喂提示词、反复试错、手动修 DOM 结构——这根本不是工程,是玄学。

这就是“Agent 可视化生成方案”的真实战场。它不是指用拖拽工具画 UI,而是构建一套可编程、可验证、可版本化、可协作的可视化内容生成流水线。核心关键词——Agent、可视化生成、HTML、ThreeJS、SVG——不是并列关系,而是一个分层结构:Agent 是决策与编排中枢,HTML 是通用容器与语义骨架,SVG 是轻量级矢量交互层,ThreeJS 是高保真三维渲染层。三者不是非此即彼,而是按需组合:一个政策解读 Agent,用 SVG 生成动态流程图;一个工业数字孪生 Agent,用 ThreeJS 渲染设备模型;一个数据报告 Agent,用 HTML+CSS 构建响应式表格与图表容器。关键在于,生成过程必须脱离“文本拼接”范式,进入“结构化构件装配”范式。我见过太多团队卡在“如何让 LLM 输出符合 W3C 标准的 SVG 元素”,却忽略了更本质的问题:为什么非得让 LLM 直接写<circle cx="50" cy="50" r="20" fill="#3b82f6"/>?它本该只负责决策“这里需要一个蓝色圆点,半径20像素”,而坐标计算、单位转换、属性校验、DOM 插入时机,应由确定性的运行时逻辑接管。这才是“别再让 AI 硬写”的底层逻辑——把 AI 从“码农”解放为“产品经理”,把工程师从“提示词调参师”回归为“系统架构师”。

2. 核心设计思路:从“文本生成”到“结构化装配”的范式迁移

2.1 为什么传统 Prompt 工程在可视化生成上必然失效

很多人尝试用更精细的提示词约束 LLM 输出 HTML,比如要求“必须使用语义化标签”、“所有 CSS 必须内联”、“SVG 必须包含 viewBox 属性”。实测下来,这种思路在小规模、单次生成场景下尚可应付,一旦进入真实项目,立刻崩盘。原因有三:

第一,LLM 的 token 预测本质是概率采样,而非确定性编译。它无法保证每次生成都严格遵循你设定的 17 条格式规则。我做过一组对照实验:用同一段提示词让 GPT-4 Turbo 生成 100 个 SVG 流程图,其中 32% 缺少xmlns命名空间声明,27% 的<g>标签嵌套层级错误导致 transform 失效,19% 使用了不支持的 CSS 属性如filter: blur(2px)导致 Safari 兼容性问题。这些不是“模型能力不足”,而是其底层机制决定的——它在模仿人类写作,而非执行编译指令。

第二,可视化内容具有强状态依赖性。一个 ThreeJS 场景的初始化,必须严格遵循“创建 renderer → 创建 scene → 创建 camera → 添加 light → 加载模型 → 渲染循环”的顺序。而 LLM 生成的 JS 代码,常把renderer.render(scene, camera)放在scene.add(model)之前,或者漏掉camera.position.z = 5导致模型不可见。这不是语法错误,而是运行时状态链断裂,这种错误无法通过静态代码检查发现,只能在浏览器控制台报错后手动排查,效率极低。

第三,业务逻辑与呈现逻辑被强行耦合。当 Agent 需要根据用户提问动态生成“设备故障率热力图”时,提示词既要描述数据聚合逻辑(“取近7天每小时故障次数,按产线分组”),又要描述可视化逻辑(“用 SVG 的<rect>绘制网格,颜色深浅映射数值”)。结果就是,一次数据口径变更(比如从“小时”改为“班次”),你得重写整个提示词,还要同步修改前端渲染逻辑——这违背了软件工程最基本的“关注点分离”原则。

2.2 结构化装配范式的三层架构设计

我们团队在交付某新能源车企的电池健康度分析 Agent 时,彻底放弃了“让 LLM 写完整 HTML”的思路,转而采用三层解耦架构:

  • 决策层(Agent Core):仅输出结构化 JSON 指令,不含任何 HTML/JS 字符串。例如:

    { "type": "threejs-scene", "config": { "camera": {"position": [0, 0, 15]}, "light": {"type": "ambient", "intensity": 0.8} }, "objects": [ { "id": "battery_model", "type": "gltf", "url": "/models/battery_v2.gltf", "transform": {"scale": [1.2, 1.2, 1.2]} }, { "id": "heat_map", "type": "custom-shader", "shader": "battery-temperature" } ] }

    这里 Agent 只负责回答“要什么”,不回答“怎么写”。

  • 装配层(Runtime Engine):一个轻量级 JavaScript 运行时,接收 JSON 指令,调用预置的、经过充分测试的组件库。它内置了 ThreeJS 初始化模板、SVG 渲染器、HTML 模板引擎。当收到上述指令,它会:

    1. 自动创建 WebGLRenderer 并设置抗锯齿;
    2. 实例化 GLTFLoader 并预加载模型;
    3. 为battery_model对象应用 scale 变换;
    4. 注入battery-temperature自定义着色器;
    5. 启动标准渲染循环。
  • 呈现层(Component Library):一组原子化、可复用、带类型定义的可视化组件。每个组件都是一个独立模块,例如SvgFlowChart.ts、ThreeJsOrbitControls.ts、HtmlDataTable.ts。它们接受标准化的 props(如data,config,onInteraction),内部封装所有 DOM 操作、Canvas 绘制、ThreeJS API 调用细节。工程师只需维护这组组件,无需关心 LLM 输出了什么。

这套架构的价值在于:Agent 的输出变成了可序列化、可验证、可 diff 的 JSON,而不是不可靠的字符串。你可以用 JSON Schema 校验其结构合法性,可以用 Jest 测试其字段含义,可以在 Git 中查看历史变更,可以像管理 API 接口一样管理可视化指令协议。这才是真正可工程化的 Agent 可视化方案。

2.3 为什么 HTML 是不可替代的底层容器

有人会问:既然有 SVG 和 ThreeJS,为什么还要强调 HTML?因为 HTML 提供了不可替代的语义层、可访问性层和布局层。一个纯 ThreeJS 场景无法被屏幕阅读器识别,一个纯 SVG 图表无法实现复杂的响应式网格布局。我们的实践是:HTML 是舞台,SVG 是布景,ThreeJS 是主角。

具体分工如下:

  • HTML 负责语义与结构:用<section aria-labelledby="chart-title">包裹可视化区域,用<h2 id="chart-title">电池温度分布</h2>提供标题,用<button aria-controls="battery-model">旋转视角</button>实现无障碍控制。这些是 LLM 永远无法可靠生成的,必须由工程师在模板中固化。
  • SVG 负责轻量交互与矢量图形:绘制流程图、拓扑图、仪表盘刻度、图标。它的优势在于缩放无损、DOM 可操作、事件绑定简单。我们规定所有 SVG 生成必须基于<svg xmlns="http://www.w3.org/2000/svg">根节点,并强制viewBox属性,确保跨设备一致性。
  • ThreeJS 负责高保真三维渲染:处理复杂模型、物理模拟、光影效果。我们禁用所有document.write()或内联<script>,所有 ThreeJS 代码必须通过 ES Module 动态导入,确保沙箱隔离与资源释放。

这种分层不是理论空谈。在政务大厅项目中,当需要为视障用户提供政策流程图时,我们只需启用 HTML 的 ARIA 属性和 SVG 的<title>标签,无需重写整个渲染逻辑——因为语义层与呈现层早已解耦。

3. 核心技术实现:从 JSON 指令到可运行页面的完整链路

3.1 决策层:如何设计鲁棒的 Agent 输出 Schema

Agent 输出的 JSON 指令,是整条流水线的“契约”。它的设计直接决定了后续环节的健壮性。我们基于 12 个真实项目经验,总结出 Schema 设计的三大铁律:

铁律一:禁止自由文本字段,全部替换为受控枚举或引用 ID
错误示例:"color": "blue"—— LLM 可能输出"bluue"或"#0000ff",导致 CSS 解析失败。
正确做法:"color": "primary-blue",并在运行时映射表中定义"primary-blue": "#3b82f6"。所有颜色、字体、动画类型、布局模式,均采用预设 ID,杜绝字符串歧义。

铁律二:强制分离“配置”与“数据”,且数据必须为纯 JSON
错误示例:"data": "SELECT * FROM battery_logs WHERE time > NOW() - INTERVAL '7 days'"—— 这是 SQL,不是数据。
正确做法:Agent 只输出查询意图,数据由后端服务执行后,以标准 JSON 数组返回给 Runtime Engine。指令中只保留:"dataRef": "battery-temperature-last7days",由运行时通过 API 获取。

铁律三:所有坐标、尺寸、时间值,必须带单位或明确上下文
错误示例:"width": 800—— 800 什么?像素?百分比?rem?
正确做法:"width": {"value": 800, "unit": "px"}或"width": {"value": "100%", "unit": "percent"}。ThreeJS 的position字段则强制为{"x": 0, "y": 0, "z": 15},杜绝position: "0 0 15"这类模糊字符串。

基于此,我们定义了核心可视化指令 Schema(精简版):

{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { "type": { "enum": ["html-container", "svg-chart", "threejs-scene", "mixed-layout"] }, "id": {"type": "string"}, "config": {"$ref": "#/definitions/config"}, "content": {"$ref": "#/definitions/content"} }, "required": ["type", "id"], "definitions": { "config": { "type": "object", "properties": { "theme": {"enum": ["light", "dark", "auto"]}, "responsive": {"type": "boolean", "default": true}, "accessibility": {"type": "boolean", "default": true} } }, "content": { "oneOf": [ { "type": "array", "items": {"$ref": "#/definitions/html-element"} }, { "type": "object", "properties": { "svg": {"$ref": "#/definitions/svg-spec"}, "data": {"$ref": "#/definitions/data-ref"} } } ] } } }

这个 Schema 不仅用于校验,更驱动了整个开发流程:前端工程师据此开发组件,后端工程师据此设计 API,QA 工程师据此编写自动化测试用例。它让“可视化生成”从黑盒提示词,变成了白盒接口契约。

3.2 装配层:Runtime Engine 的核心实现逻辑

Runtime Engine 是连接决策层与呈现层的“翻译官”,其核心职责是:将抽象的 JSON 指令,安全、高效、可预测地转化为浏览器中的 DOM/Canvas/WebGL 实例。我们采用 TypeScript + Webpack 构建,体积控制在 42KB(gzip),确保首屏加载不阻塞。

核心模块分解如下:

模块一:指令解析器(Instruction Parser)
接收原始 JSON,执行三步校验:

  1. Schema 校验:使用ajv库验证是否符合预设 Schema,失败则抛出结构化错误(如ERROR_SCHEMA_MISMATCH: field 'config.theme' must be one of ['light','dark','auto']);
  2. 语义校验:检查逻辑矛盾,例如type: "threejs-scene"却未提供config.camera;
  3. 安全过滤:移除所有潜在危险字段,如onerror,javascript:,data:text/html等,防止 XSS。

提示:我们禁用所有eval()和Function()构造函数。所有动态代码(如自定义着色器)必须通过预编译的 WebAssembly 模块加载,确保零 runtime 代码执行。

模块二:组件注册中心(Component Registry)
一个 Map 结构,键为指令type,值为对应组件工厂函数:

const componentRegistry = new Map<string, ComponentFactory>(); componentRegistry.set('svg-chart', () => new SvgChartComponent()); componentRegistry.set('threejs-scene', () => new ThreeJsSceneComponent()); // ...其他组件

当解析器识别到type: "svg-chart",即调用对应工厂函数创建实例,并传入校验后的config和content。

模块三:生命周期管理器(Lifecycle Manager)
每个可视化组件都有标准生命周期:init()→render()→update(props)→destroy()。管理器确保:

  • init()在 DOM 插入后立即调用,完成 WebGL 上下文创建、SVG 命名空间声明等一次性初始化;
  • render()在浏览器空闲时段(requestIdleCallback)执行,避免阻塞主线程;
  • update()支持增量更新,例如 SVG 图表仅重绘变化的数据点,而非全量重绘;
  • destroy()清理所有事件监听器、取消网络请求、释放 WebGL 纹理内存。

模块四:资源加载协调器(Resource Loader)
专门处理异步资源(GLTF 模型、SVG 图标、字体文件)。它实现:

  • 并发控制:默认限制同时加载 3 个资源,防止单页请求风暴;
  • 缓存策略:对/models/*.gltf类 URL 启用内存缓存,相同 URL 多次请求返回同一 Promise;
  • 降级机制:若 GLTF 加载失败,自动 fallback 到预烘焙的 PNG 序列帧。

这套引擎已在生产环境稳定运行 8 个月,平均指令处理耗时 17ms(Chrome DevTools Performance 面板实测),内存泄漏率为 0。

3.3 展示层:SVG 与 ThreeJS 组件的工程化封装实践

3.3.1 SVG 组件:从“画图”到“编程”的质变

我们不再用 LLM 生成<path d="M10 10 L20 20 Z">,而是构建了一套基于 D3.js 的声明式 SVG 组件库。以SvgFlowChart为例,其核心思想是:将流程图抽象为“节点”与“边”的拓扑关系,而非像素坐标。

组件接收的 props 示例:

interface FlowChartProps { nodes: Array<{ id: string; label: string; type: 'start' | 'process' | 'decision' | 'end'; position?: { x: number; y: number }; // 可选,自动布局时忽略 }>; edges: Array<{ source: string; // 节点 id target: string; // 节点 id label?: string; }>; layout: 'top-down' | 'left-right' | 'radial'; // 布局算法 }

内部实现逻辑:

  1. 拓扑分析:使用dagre-d3库自动计算最优节点位置,避免手动指定cx/cy;
  2. 路径生成:根据layout类型,调用预设的贝塞尔曲线生成器,生成平滑的<path>指令;
  3. 样式注入:所有颜色、字体、阴影,均通过 CSS Custom Properties 控制,如--node-fill: var(--primary-color);,确保主题切换一键生效;
  4. 交互增强:为每个<g class="node">绑定click事件,触发onNodeClick(id)回调,供 Agent 监听用户操作。

这种封装带来的好处是:当业务方说“把流程图改成从左到右”,你只需改一行layout="left-right",无需重写任何 SVG 字符串。我们已封装 17 种常用 SVG 组件,覆盖 92% 的政务、教育、制造类可视化需求。

3.3.2 ThreeJS 组件:告别“Hello World”,拥抱生产就绪

ThreeJS 组件的封装难点在于:如何平衡灵活性与易用性。我们拒绝提供addMesh()这类底层 API,而是聚焦于高频场景。

以ThreeJsEquipmentModel组件为例,它专为工业设备数字孪生设计,props 定义如下:

interface EquipmentModelProps { modelUrl: string; // GLTF URL textures?: Record<string, string>; // 贴图映射,如 {"metalness": "/textures/metal.png"} animations?: Array<{ name: string; loop: boolean }>; interactiveParts?: Array<{ id: string; highlightColor: string }>; onPartClick?: (partId: string) => void; }

关键工程实践:

  • 模型预处理标准化:所有 GLTF 模型必须通过gltf-pipeline工具优化,压缩纹理、合并网格、剔除未使用材质。我们提供 CI 脚本,在 PR 提交时自动校验模型大小(< 5MB)与格式合规性。
  • 动画状态机:内置状态机管理播放/暂停/循环,避免mixer.update(delta)手动调用错误。当animations中定义loop: true,组件自动在render()中调用mixer.update(clock.getDelta())。
  • 部件高亮系统:利用 ThreeJS 的Raycaster,在鼠标悬停时精准拾取子网格(mesh),并动态替换其材质为 emissive 材质,实现“点击高亮设备阀门”的效果。
  • 性能兜底:当设备模型超过 50 万面片时,自动启用LOD(Level of Detail)系统,远距离显示简化版模型,保障 60fps。

这套组件已在某半导体工厂部署,实时渲染 12 台光刻机的 3D 模型,平均帧率 58fps(MacBook Pro M1),内存占用稳定在 1.2GB。

4. 实操全流程:从零搭建一个政策解读 Agent 可视化生成系统

4.1 环境准备与依赖安装

我们选择 Vue 3 + TypeScript 作为前端框架,因其 Composition API 天然契合“指令驱动”的设计理念。后端使用 FastAPI,轻量且类型安全。

前端环境初始化(推荐 Node 18+):

# 创建项目 npm create vue@latest agent-viz-demo -- --typescript --router --pinia --vitest cd agent-viz-demo # 安装核心依赖 npm install three @types/three @pixi/core @pixi/app @pixi/graphics npm install d3 dagre-d3 @types/d3 @types/dagre-d3 npm install ajv ajv-formats # 用于 JSON Schema 校验 npm install @webgpu/types # 为未来 WebGPU 迁移做准备 # 安装开发依赖 npm install -D vite-plugin-svgr # 将 SVG 文件作为 React 组件导入 npm install -D @rollup/plugin-node-resolve # 解决 ThreeJS 的 CJS 模块问题

关键配置项说明:

  • vite.config.ts中需添加define: { global: 'globalThis' },解决 ThreeJS 的global全局变量引用问题;
  • tsconfig.json的compilerOptions必须包含"moduleResolution": "bundler"和"allowSyntheticDefaultImports": true,否则import * as THREE from 'three'会报错;
  • package.json的type字段设为"module",确保 ESM 正确解析。

注意:不要使用create-react-app或Next.js的默认配置。它们对 WebGL 上下文的生命周期管理不友好,容易导致内存泄漏。Vite 的 HMR(热模块替换)在 ThreeJS 场景中表现更稳定,我们实测过 200 次热更新后内存增长 < 5MB。

4.2 构建 Runtime Engine 核心类

创建src/lib/runtime/Engine.ts,这是整个系统的中枢:

import { validate } from 'ajv'; import { InstructionSchema } from './schema'; import { ComponentFactory, ComponentRegistry } from './components'; export class RuntimeEngine { private parser: InstructionParser; private registry: ComponentRegistry; constructor() { this.parser = new InstructionParser(InstructionSchema); this.registry = new ComponentRegistry(); } // 注册组件,供外部调用 registerComponent(type: string, factory: ComponentFactory) { this.registry.register(type, factory); } // 主入口:接收指令,返回可挂载的组件实例 async mount(instruction: unknown, container: HTMLElement): Promise<void> { try { // 1. 解析与校验 const validated = this.parser.parse(instruction); // 2. 查找对应组件工厂 const factory = this.registry.get(validated.type); if (!factory) { throw new Error(`No component registered for type: ${validated.type}`); } // 3. 创建组件实例 const component = factory(); // 4. 初始化并挂载 await component.init(validated.config, validated.content); component.mount(container); // 5. 返回销毁函数,供外部清理 return () => component.destroy(); } catch (error) { console.error('RuntimeEngine mount failed:', error); throw error; } } } // 使用示例 const engine = new RuntimeEngine(); engine.registerComponent('svg-chart', () => new SvgChartComponent()); engine.registerComponent('threejs-scene', () => new ThreeJsSceneComponent()); // 在 Vue 组件中调用 const destroy = await engine.mount(instructionJson, document.getElementById('viz-container')!); // 页面卸载时调用 destroy()

这个RuntimeEngine类的设计哲学是:暴露最小必要接口,隐藏所有实现细节。使用者只需关心“给什么指令”和“挂到哪”,无需了解 ThreeJS 的 renderer 如何创建、SVG 的 namespace 如何声明。

4.3 开发首个可视化组件:SvgPolicyFlowChart

创建src/lib/components/SvgPolicyFlowChart.ts,这是一个完整的、可复用的 SVG 组件:

import * as d3 from 'd3'; import * as dagreD3 from 'dagre-d3'; export class SvgPolicyFlowChart { private svg: SVGSVGElement | null = null; private g: SVGGElement | null = null; private nodes: Array<{ id: string; label: string; type: string }> = []; private edges: Array<{ source: string; target: string }> = []; init(config: any, content: any): Promise<void> { // 1. 解析输入数据 this.nodes = content.nodes || []; this.edges = content.edges || []; // 2. 创建 SVG 容器 this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); this.svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); this.svg.setAttribute('viewBox', '0 0 1000 600'); // 默认视口 this.svg.setAttribute('class', 'policy-flow-chart'); // 3. 创建分组容器 this.g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); this.g.setAttribute('transform', 'translate(50, 50)'); this.svg.appendChild(this.g); return Promise.resolve(); } render(): void { if (!this.svg || !this.g) return; // 清空旧内容 while (this.g.firstChild) { this.g.removeChild(this.g.firstChild); } // 4. 使用 dagre-d3 自动布局 const graph = new dagreD3.graphlib.Graph(); graph.setGraph({ rankdir: 'TB' }); graph.setDefaultEdgeLabel(() => ({})); this.nodes.forEach(node => { graph.setNode(node.id, { label: node.label, width: 120, height: 60, style: this.getNodeStyle(node.type) }); }); this.edges.forEach(edge => { graph.setEdge(edge.source, edge.target); }); dagreD3.layout().run(graph, this.g); // 5. 渲染节点(使用预设 SVG 模板) this.nodes.forEach(node => { const nodeGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); nodeGroup.setAttribute('class', `node-${node.type}`); // 绘制矩形背景 const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rect.setAttribute('width', '120'); rect.setAttribute('height', '60'); rect.setAttribute('rx', '8'); rect.setAttribute('ry', '8'); rect.setAttribute('fill', this.getNodeFillColor(node.type)); nodeGroup.appendChild(rect); // 绘制文字 const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); text.setAttribute('x', '60'); text.setAttribute('y', '35'); text.setAttribute('text-anchor', 'middle'); text.setAttribute('dominant-baseline', 'middle'); text.setAttribute('font-size', '14'); text.textContent = node.label; nodeGroup.appendChild(text); // 添加到主容器 this.g.appendChild(nodeGroup); }); // 6. 渲染连线(使用 dagre-d3 生成的 path) const edges = this.g.querySelectorAll('path'); edges.forEach(path => { path.setAttribute('stroke', '#6b7280'); path.setAttribute('stroke-width', '2'); path.setAttribute('fill', 'none'); }); } private getNodeStyle(type: string): string { switch (type) { case 'start': return 'fill:#10b981;stroke:#059669;'; case 'process': return 'fill:#3b82f6;stroke:#2563eb;'; case 'decision': return 'fill:#8b5cf6;stroke:#7c3aed;'; case 'end': return 'fill:#ef4444;stroke:#dc2626;'; default: return 'fill:#9ca3af;stroke:#6b7280;'; } } private getNodeFillColor(type: string): string { return this.getNodeStyle(type).split(';').find(s => s.startsWith('fill:'))?.split(':')[1] || '#9ca3af'; } mount(container: HTMLElement): void { if (this.svg) { container.appendChild(this.svg); this.render(); // 首次渲染 } } update(props: any): void { // 更新数据并重新渲染 this.nodes = props.nodes || this.nodes; this.edges = props.edges || this.edges; this.render(); } destroy(): void { if (this.svg && this.svg.parentNode) { this.svg.parentNode.removeChild(this.svg); } } }

这个组件的关键亮点:

  • 完全不依赖外部 CSS:所有样式通过setAttribute内联,确保在任意宿主环境中样式隔离;
  • 自动布局:调用dagre-d3计算节点位置,开发者无需手动计算cx/cy;
  • 类型驱动样式:start/process/decision/end四种节点类型,对应不同颜色与边框,业务语义清晰;
  • 可扩展性强:update()方法支持动态更新,为后续接入 WebSocket 实时数据流预留接口。

4.4 集成 Agent:对接 LLM 的输出管道

Agent 侧(以 OpenAI API 为例)的提示词设计至关重要。我们摒弃了“生成 HTML”的指令,改为“生成结构化 JSON”:

系统提示词(System Prompt):

你是一个专业的政策解读智能体。你的任务是根据用户提问,生成一个结构化的可视化指令 JSON,用于展示政策流程图。请严格遵守以下规则: 1. 输出必须是合法 JSON,无任何额外文本、注释或 Markdown 格式; 2. `type` 字段必须为 "svg-chart"; 3. `nodes` 数组中,每个节点的 `type` 必须是以下之一:["start", "process", "decision", "end"]; 4. `edges` 数组中,`source` 和 `target` 必须是 `nodes` 中存在的 `id`; 5. 所有字符串字段(如 `label`)必须使用中文,不得包含 HTML 标签; 6. 如果无法确定流程,请输出空数组,而非猜测。

用户提示词(User Prompt):

用户提问:“申请公租房需要哪些步骤?” 请生成对应的政策流程图指令。

预期输出(经校验后):

{ "type": "svg-chart", "id": "public-housing-application", "config": {"theme": "light"}, "content": { "nodes": [ {"id": "start", "label": "开始申请", "type": "start"}, {"id": "submit-docs", "label": "提交身份与收入证明", "type": "process"}, {"id": "verify-income", "label": "收入审核", "type": "decision"}, {"id": "pass", "label": "审核通过", "type": "process"}, {"id": "fail", "label": "审核不通过", "type": "process"}, {"id": "end", "label": "完成申请", "type": "end"} ], "edges": [ {"source": "start", "target": "submit-docs"}, {"source": "submit-docs", "target": "verify-income"}, {"source": "verify-income", "target": "pass"}, {"source": "verify-income", "target": "fail"}, {"source": "pass", "target": "end"}, {"source": "fail", "target": "end"} ] } }

前端调用链路:

// 1. 调用 Agent API const response = await fetch('/api/agent/policy', { method: 'POST', body: JSON.stringify({ question: '申请公租房需要哪些步骤?' }) }); const instruction = await response.json(); // 2. 交给 Runtime Engine 渲染 const destroy = await engine.mount(instruction, document.getElementById('viz')!); // 3. 页面卸载时清理 onUnmounted(() => { destroy?.(); });

整个流程中,Agent 只负责“思考”,Runtime Engine 负责“执行”,前端工程师负责“定义边界”。三方各司其职,这才是可持续的工程实践。

5. 常见问题与实战排错指南

5.1 “SVG 渲染空白,控制台无报错”——九成是命名空间问题

这是新手踩坑率最高的问题。当你用document.createElement('svg')创建元素,它默认是 HTML 命名空间,而非 SVG 命名空间。浏览器会将其当作普通 HTML 标签,忽略所有 SVG 特有属性(如viewBox,xmlns)。

错误代码:

const svg = document.createElement('svg'); // ❌ 错误!创建的是 HTML 元素 svg.setAttribute('viewBox', '0 0 100 100');

正确代码:

const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); // ✅ 正确!创建 SVG 元素 svg.setAttribute('viewBox', '0 0 100 100');

排错技巧:

  • 在 Chrome DevTools 的 Elements 面板中,检查<svg>标签是否显示为<svg xmlns="http://www.w3.org/2000/svg">。如果没有xmlns属性,说明命名空间错误;
  • 使用console.dir(svg.namespaceURI),正确值应为"http://www.w3.org/2000/svg",错误值为null;
  • 所有 SVG 子元素(<g>,<rect>,<path>)也必须用createElementNS创建。

我们在组件库中已将此封装为工具函数createSvgElement(tagName: string),内部自动调用createElementNS,避免重复犯错。

5.2 “ThreeJS 场景黑屏,但控制台没报错”——检查渲染循环与相机位置

ThreeJS 黑屏的常见原因中,70%