ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Agent可视化生成方案:用IR中间表示解决大模型直出不稳定问题

Agent可视化生成方案:用IR中间表示解决大模型直出不稳定问题

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,很多人会混淆。我用一句话区分:

  • IR(Intermediate Representation):内存中的结构化数据,通常是 JSON 或对象树,是 Agent 和渲染引擎之间的契约。
  • DSL(Domain Specific Language):面向人的文本表示,比如一段 YAML 或自定义语法,人可以手写、可以版本管理、可以 diff。
  • SDK:面向开发者的编程接口,把 IR 的构建、校验、渲染封装成函数调用。

三者的关系是:DSL 是 IR 的文本序列化形式,SDK 是操作 IR 的工具集。一个健康的可视化生成系统,通常三者都具备,但职责绝不重叠。

我见过一些团队把这三者混在一起,结果就是模型既要懂 DSL 语法,又要懂 SDK 调用,还要保证 IR 结构正确,最后哪个都做不好。正确的做法是让模型只输出 IR(或者输出 DSL,再由解析器转 IR),SDK 只负责程序化构建和渲染,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 用 JSON,DSL 用 YAML,两者之间做一层无损转换。模型只生成 JSON IR,人类编辑时用 YAML DSL,保存时转回 JSON。这样既保证了模型生成的稳定性,又保证了人类编辑的体验。

3.3 SDK 封装:把复杂度挡在业务代码之外

SDK 的价值在于把 IR 的构建、校验、渲染、导出封装成几个简单函数。没有 SDK 的话,业务代码里会散落大量nodes.push(...)和edges.push(...),维护成本极高。

一个够用的 SDK 至少要有这几个能力:

  • build(intent):从自然语言意图构建 IR,内部调用 LLM
  • validate(ir):校验 IR 合法性,返回错误列表
  • patch(ir, operation):增量修改 IR
  • render(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 d3
  • @model-context/sdk:模型调用封装(这里用通用名,实际按你用的模型服务替换)
  • ajv:JSON 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 或带空格的 id
  • edges[].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 里必须包含四样东西:

  1. 完整的 IR Schema,用 JSON Schema 原文贴进去
  2. 2 到 3 个 few-shot 示例,覆盖不同类型的图
  3. 明确的输出约束,比如“只输出 JSON,不要任何解释文字”
  4. 常见错误清单,比如“不要生成悬空边、不要重复 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 的每次变更记录成一个 operation:

const 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 调到 60,nodesep从 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% 再去做渲染和交互。这个投入比例,是我踩了无数坑之后总结出来的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进