
1. 为什么“让 AI 硬写”这条路越来越走不通过去一年我接触了不少 Agent 项目从早期的纯 Prompt 编排到后来带工具调用的 ReAct 循环再到最近半年密集出现的“可视化生成”方案一个很明显的感受是让大模型直接输出最终产物无论是代码、流程图还是配置文件在 Demo 阶段很惊艳一旦进入真实业务就迅速崩盘。崩盘的原因不复杂。大模型本质是概率补全它擅长在模糊语境下给出“看起来合理”的答案但它不擅长保证结构一致性、不擅长处理长程依赖、更不擅长在几百个节点之间维持引用关系不出错。你让它直接写一段 50 行的 Python没问题你让它直接生成一个包含 30 个节点、每个节点有输入输出契约、还要保证连线不交叉的工作流它给你的东西大概率是“语法正确但逻辑断裂”的。这就是“Agent 可视化生成方案”这个方向最近被反复讨论的根本原因。核心思路其实一句话就能说清不要让模型直接写最终产物而是让模型写一种中间表示IR再由确定性的渲染引擎把 IR 转成可视化结果。这个思路在编译器领域早就成熟了——没人会让编译器直接吐机器码都是先构建 AST再做优化最后才 codegen。Agent 可视化生成走的是同一条路。这篇文章我想聊的不是某个具体产品而是这套方案背后的完整技术链路IR 怎么设计、DSL 和 SDK 各自扮演什么角色、可视化渲染层怎么和 Agent 解耦、并发和记忆问题怎么处理。适合正在做 Agent 开发、被“模型输出不稳定”折磨过的同学也适合想理解可视化生成底层逻辑的产品和架构同学。我会尽量把每一步的“为什么”讲透而不是只给结论。2. 核心思路拆解IR 才是这套方案的心脏2.1 从“模型直出”到“模型写 IR”的范式转移先把这个范式转移讲清楚。传统做法是用户意图 - LLM - 最终产物代码/图/配置可视化生成方案是用户意图 - LLM - IR结构化中间表示 - 渲染引擎 - 最终产物多出来的这一层 IR是整个方案的价值所在。它把“创造性”和“确定性”彻底分开了。LLM 只负责它最擅长的事——理解意图、做语义映射、填充内容而结构合法性、引用完整性、布局计算、渲染细节全部交给确定性代码处理。我举个实际例子。假设用户说“帮我生成一个用户注册流程包含手机验证和邮箱验证两条分支”。如果让模型直出它可能给你一段 Mermaid节点名一会儿是中文一会儿是英文分支条件写得含糊甚至箭头指向自己。但如果让模型输出 IR比如{ type: flow, nodes: [ {id: start, kind: entry, label: 开始注册}, {id: input, kind: form, label: 填写信息, fields: [phone, email]}, {id: branch, kind: condition, expr: verifyType}, {id: phone_v, kind: action, label: 手机验证}, {id: email_v, kind: action, label: 邮箱验证}, {id: done, kind: terminal, label: 注册完成} ], edges: [ {from: start, to: input}, {from: input, to: branch}, {from: branch, to: phone_v, when: phone}, {from: branch, to: email_v, when: email}, {from: phone_v, to: done}, {from: email_v, to: done} ] }模型只需要保证这个 JSON 的语义正确剩下的布局、连线、样式、交互全部由渲染引擎接管。节点位置算错了那是引擎的 bug不是模型的锅。连线交叉了引擎做一次分层布局就能解决。这种职责分离带来的稳定性提升是数量级的。2.2 IR、DSL、SDK 三者的分工与边界热词里同时出现了 IR、DSL、SDK很多人会混淆。我用一句话区分IRIntermediate Representation内存中的结构化数据通常是 JSON 或对象树是 Agent 和渲染引擎之间的契约。DSLDomain Specific Language面向人的文本表示比如一段 YAML 或自定义语法人可以手写、可以版本管理、可以 diff。SDK面向开发者的编程接口把 IR 的构建、校验、渲染封装成函数调用。三者的关系是DSL 是 IR 的文本序列化形式SDK 是操作 IR 的工具集。一个健康的可视化生成系统通常三者都具备但职责绝不重叠。我见过一些团队把这三者混在一起结果就是模型既要懂 DSL 语法又要懂 SDK 调用还要保证 IR 结构正确最后哪个都做不好。正确的做法是让模型只输出 IR或者输出 DSL再由解析器转 IRSDK 只负责程序化构建和渲染DSL 只作为人类可读的持久化格式。2.3 为什么这个方案能解决“硬写”的三大顽疾“硬写”方案有三个绕不过去的顽疾可视化生成方案恰好一一对应解决顽疾硬写方案的表现可视化生成方案的解法结构不一致节点命名混乱、引用断裂IR Schema 强校验非法结构直接拒绝长程依赖丢失30 个节点后开始胡编IR 分块生成每块独立校验后合并无法增量修改改一个节点要重生成全部IR 是数据可以局部 patch第三点尤其关键。真实业务里用户很少一次性把需求说完更多是“在刚才那个流程里加一个审批节点”。如果产物是模型直出的文本你只能重新生成之前调好的部分全丢。但如果产物是 IR你只需要在 nodes 数组里插入一个对象在 edges 里加两条连线渲染引擎自动重排。这种可增量、可回滚、可 diff 的特性是可视化生成方案能落地生产环境的根本原因。3. 核心细节解析IR 设计、DSL 选型与 SDK 封装3.1 IR Schema 怎么设计才不容易翻车IR 设计是整套方案里最容易埋坑的地方。我踩过的坑包括字段命名不统一导致模型猜错、可选字段太多导致渲染分支爆炸、嵌套层级过深导致模型生成时丢失上下文。我的经验是遵循三条原则第一扁平优先。节点和边分成两个顶层数组不要嵌套。嵌套结构对模型不友好它很容易在深层嵌套里漏掉闭合括号。扁平结构还有个好处是方便做增量 patch你改一个节点不影响其他节点。第二枚举收敛。节点的 kind 字段一定要用有限枚举比如entry / form / condition / action / terminal不要让模型自由发挥。枚举越收敛渲染引擎的分支越少出 bug 的概率越低。我见过一个项目让模型自由填 kind结果出现了 47 种不同的节点类型渲染层直接失控。第三必填字段最小化。除了 id、kind、label 这三个其他字段尽量可选并且给默认值。模型生成时字段越少出错概率越低。需要额外信息时用meta字段兜底不要污染主结构。一个经过实战检验的 IR Schema 大概长这样{ version: 1.0, type: flow | graph | tree, nodes: [ { id: string, required, unique, kind: enum, required, label: string, required, meta: object, optional } ], edges: [ { from: node id, required, to: node id, required, when: string, optional, meta: object, optional } ] }注意version 字段一定要有。IR 是会演进的没有版本号你后期做兼容会非常痛苦。我建议从第一天就把 version 写进 Schema并且渲染引擎对不认识的版本要明确报错而不是静默降级。3.2 DSL 选型YAML、JSON 还是自定义语法DSL 的选择直接决定了人类可读性和模型生成难度。三种主流选择各有取舍JSON的优点是模型最熟悉生成准确率最高解析器现成。缺点是冗长人读起来累手写容易漏逗号。适合作为 IR 的直接序列化格式不太适合作为人类编辑的 DSL。YAML的优点是紧凑、可读性好、支持注释适合人类手写和版本管理。缺点是缩进敏感模型生成时容易缩进错乱而且解析器对某些边界情况处理不一致。如果你的 DSL 主要给人看YAML 是好选择如果主要给模型生成要谨慎。自定义语法比如类似 Mermaid 的文本 DSL优点是极简、表达力强、适合特定领域。缺点是模型没见过生成准确率低需要大量 few-shot 示例。除非你的领域非常垂直否则不建议自研语法。我的实际选择是IR 用 JSONDSL 用 YAML两者之间做一层无损转换。模型只生成 JSON IR人类编辑时用 YAML DSL保存时转回 JSON。这样既保证了模型生成的稳定性又保证了人类编辑的体验。3.3 SDK 封装把复杂度挡在业务代码之外SDK 的价值在于把 IR 的构建、校验、渲染、导出封装成几个简单函数。没有 SDK 的话业务代码里会散落大量nodes.push(...)和edges.push(...)维护成本极高。一个够用的 SDK 至少要有这几个能力build(intent)从自然语言意图构建 IR内部调用 LLMvalidate(ir)校验 IR 合法性返回错误列表patch(ir, operation)增量修改 IRrender(ir, target)渲染到指定目标Canvas / SVG / 图片export(ir, format)导出为 DSL / 图片 / 代码import { AgentFlow } from agent-flow-sdk; const flow new AgentFlow({ apiKey: xxx }); // 从意图构建 const ir await flow.build(用户注册流程含手机和邮箱两条验证分支); // 校验 const errors flow.validate(ir); if (errors.length) { console.error(IR 非法, errors); } // 增量修改 flow.patch(ir, { op: addNode, node: { id: audit, kind: action, label: 人工审核 } }); // 渲染 flow.render(ir, document.getElementById(canvas));提示SDK 的 build 方法内部一定要做重试和修复。模型第一次生成的 IR 有 20% 概率不合法与其把错误抛给业务层不如在 SDK 内部做一次自动修复——把非法字段剔除、把悬空边删掉、把重复 id 重命名。修复后再校验通过才返回。4. 实操过程从零搭一个可视化生成链路4.1 环境准备与依赖选型我以 Node.js 技术栈为例走一遍完整链路。选 Node 是因为前端 SDK 生态成熟渲染层可以直接复用浏览器能力。核心依赖npm install model-context/sdk ajv dagre d3model-context/sdk模型调用封装这里用通用名实际按你用的模型服务替换ajvJSON Schema 校验用来做 IR 合法性检查dagre分层布局算法负责节点自动排布d3渲染和交互选 dagre 而不是自己写布局是因为分层布局layered layout在有向图场景下已经非常成熟自己写大概率不如它。dagre 会自动处理节点分层、连线避让、交叉最小化省掉大量调试时间。4.2 第一步定义 IR Schema 并接入校验先把 Schema 写成 JSON Schema 文件然后用 ajv 编译import Ajv from ajv; import schema from ./ir-schema.json; const ajv new Ajv({ allErrors: true }); const validateIR ajv.compile(schema); export function validate(ir) { const ok validateIR(ir); if (ok) return []; return validateIR.errors.map(e ${e.instancePath} ${e.message}); }Schema 里要重点约束的几处nodes[].id加pattern限制为^[a-z][a-z0-9_]*$避免模型生成中文 id 或带空格的 idedges[].from和edges[].to用$ref引用 nodes 的 id 定义但 JSON Schema 本身无法校验引用存在性需要在代码里补一层kind用enum严格限制引用存在性校验要单独写export function validateRefs(ir) { const ids new Set(ir.nodes.map(n n.id)); const errors []; for (const e of ir.edges) { if (!ids.has(e.from)) errors.push(边起点不存在: ${e.from}); if (!ids.has(e.to)) errors.push(边终点不存在: ${e.to}); } return errors; }注意Schema 校验和引用校验要分开做。Schema 管结构引用校验管语义。混在一起会让错误信息难以定位。我一开始图省事把两者塞进一个函数结果排查问题时根本分不清是结构错还是引用错。4.3 第二步让模型生成 IR 的 Prompt 工程Prompt 是这套方案里最需要反复打磨的部分。我的经验是Prompt 里必须包含四样东西完整的 IR Schema用 JSON Schema 原文贴进去2 到 3 个 few-shot 示例覆盖不同类型的图明确的输出约束比如“只输出 JSON不要任何解释文字”常见错误清单比如“不要生成悬空边、不要重复 id、不要用中文 id”一个实际在用的 Prompt 骨架你是一个流程图 IR 生成器。根据用户意图输出符合以下 Schema 的 JSON。 Schema: {...} 示例 1: 输入: 用户登录流程 输出: {...} 示例 2: 输入: 订单状态机 输出: {...} 约束: - 只输出 JSON不要 markdown 代码块不要解释 - 节点 id 必须是小写字母加下划线 - 每条边的 from 和 to 必须指向已存在的节点 id - 节点数量控制在 5 到 30 之间 用户意图: {{intent}}实测下来加了 few-shot 和错误清单之后一次通过率从 60% 左右提升到 85% 以上。剩下的 15% 靠 SDK 内部的自动修复兜底。4.4 第三步布局计算与渲染拿到合法 IR 之后布局和渲染是纯确定性工作。用 dagre 算位置import dagre from dagre; export function layout(ir) { const g new dagre.graphlib.Graph(); g.setGraph({ rankdir: TB, nodesep: 60, ranksep: 80 }); g.setDefaultEdgeLabel(() ({})); ir.nodes.forEach(n { g.setNode(n.id, { width: 160, height: 60 }); }); ir.edges.forEach(e { g.setEdge(e.from, e.to); }); dagre.layout(g); return ir.nodes.map(n { const pos g.node(n.id); return { ...n, x: pos.x, y: pos.y }; }); }rankdir: TB表示从上到下布局nodesep和ranksep控制节点间距和层级间距。这两个参数需要根据节点数量动态调整——节点多的时候间距要小一点否则图会撑得很大。渲染用 d3 把节点和边画到 SVG 上这部分是常规操作不展开。重点说一下交互每个节点都要支持点击编辑编辑后直接 patch IR 并重新布局。这是可视化生成相比纯文本生成的核心优势——用户可以在生成结果上直接改改完的 IR 可以存下来作为下次生成的参考。4.5 第四步增量修改与版本管理增量修改是生产环境必备能力。我的实现方式是把 IR 的每次变更记录成一个 operationconst operations [ { op: addNode, node: {...} }, { op: removeEdge, from: a, to: b }, { op: updateNode, id: c, patch: { label: 新标签 } } ];每个 operation 都是可逆的这样就能实现 undo/redo。同时把 operations 序列存下来就得到了完整的变更历史可以随时回放到任意版本。提示operations 一定要带时间戳和操作者标识。多人协作场景下没有这两个字段你根本没法做冲突合并。我吃过这个亏后来补上时间戳冲突排查效率提升明显。5. 常见问题与排查技巧实录5.1 模型生成的 IR 不合法怎么办这是最高频的问题。我的处理策略是三层防御第一层Prompt 约束。前面说的 few-shot 加错误清单把一次通过率拉到 85% 以上。第二层自动修复。SDK 内部对常见错误做自动修复错误类型自动修复策略悬空边删除该边重复 id给重复的加后缀_2非法 id 格式转小写、替换非法字符为下划线缺少必填字段用默认值填充kind 不在枚举内映射到最接近的枚举值第三层重试。修复后仍不合法把错误信息拼回 Prompt 让模型重新生成最多重试 2 次。三次都失败就报错给用户让用户手动调整意图描述。5.2 节点多了之后布局乱成一团节点超过 20 个之后布局容易失控。我的经验是节点超过 15 个时把ranksep从 80 调到 60nodesep从 60 调到 40节点超过 30 个时考虑做分组cluster把相关节点框在一起节点超过 50 个时不要一次性渲染做懒加载或分页dagre 支持 cluster但配置起来比较麻烦。如果分组需求强烈可以考虑换成 ELK 布局引擎它对大图的处理更好代价是包体积大一些。5.3 并发场景下 IR 冲突怎么处理Agent 扛并发是个热词可视化生成场景下的并发问题主要是多个请求同时修改同一份 IR。我的方案是给 IR 加一个revision字段每次修改递增。客户端提交修改时带上当前 revision服务端比对不一致就拒绝并返回最新版本。这是乐观锁的标准做法实现简单冲突概率低。function patchWithLock(ir, operation, clientRevision) { if (ir.revision ! clientRevision) { throw new Error(版本冲突请刷新后重试); } const next applyOperation(ir, operation); next.revision ir.revision 1; return next; }注意revision 冲突不要用悲观锁。可视化生成场景下用户编辑是低频操作悲观锁会带来不必要的等待。乐观锁加重试足够。5.4 常见问题速查表现象可能原因排查方向渲染出来是空白IR 为空或 nodes 数组为空检查模型输出看是否被截断连线指向错误edges 的 from/to 写反打印 edges 数组核对节点重叠布局参数没生效检查 dagre 是否在渲染前调用中文乱码字体未加载检查 SVG 的 font-family增量修改后布局跳变全量重排导致改用局部布局或固定已定位节点模型输出带 markdown 代码块Prompt 约束不够加“不要代码块”约束或后处理剥离6. 这套方案后续还能怎么扩展聊到这里核心链路已经完整了。最后分享几个我实际在用的扩展方向都是踩过坑之后验证可行的。第一个扩展是 IR 的跨工具复用。同一份 IR 可以渲染成流程图、可以导出成代码骨架、可以转成测试用例。我现在的做法是 IR 作为单一数据源不同目标写不同的 renderer。这样用户生成一次多处使用价值密度高很多。第二个扩展是 IR 作为 Agent 记忆的载体。热词里有 agent 记忆其实 IR 天然适合做结构化记忆。把历史生成的 IR 存起来下次生成时检索相似的 IR 作为 few-shot 示例生成质量会明显提升。我实测下来带 IR 检索的生成比不带的一次通过率高 10 个百分点左右。第三个扩展是 DSL 的双向同步。现在很多团队是 IR 和 DSL 单向转换其实可以做双向。用户在 DSL 里改了同步回 IR在可视化界面改了同步回 DSL。这样既照顾了喜欢写文本的开发者也照顾了喜欢拖拽的产品同学。实现难点在于冲突检测需要一套类似 OT 或 CRDT 的机制工程量不小但收益也大。我个人在实际操作中的体会是可视化生成方案的价值不在于“好看”而在于它把 Agent 的不确定性关进了一个可控的笼子里。IR 就是这个笼子。笼子设计得好模型再飘也飘不出边界笼子设计得差模型稍微一抖你就得全量重来。所以如果你准备上这套方案我建议把 70% 的精力花在 IR Schema 和校验修复上剩下的 30% 再去做渲染和交互。这个投入比例是我踩了无数坑之后总结出来的。