很多人一听到“让 AI 画架构图”,第一反应是“不就是把需求丢给对话式大模型,让它生成一段 Mermaid 嘛”。我一开始也这么想,直到自己动手做了一个叫 next-ai-draw-io 的小项目——它的目标不只是让 AI 吐出图片,而是真正替你把架构图画成可编辑、可继续改、能直接进已有文档体系的工程文件。这篇文章就把我做的这个项目的完整思路、核心实现、踩坑记录都摊开讲,项目本身不大,但里面关于格式选型、布局算法、提示词设计这些细节,沉淀下来还是有不少值得聊的东西。先说结论:架构图这件事真正的痛点,从来不是“画不出来”,而是“画出来之后没法改、没法复用、没法纳入协作流程”。这也是我最终放弃纯 Mermaid 路线、转而锚定 draw.io XML 格式的核心原因。Mermaid 适合快速表达逻辑,但它是文本渲染,样式、对齐、微调都很痛苦;而 draw.io(也就是 diagrams.net)的 XML 格式,本质上是带坐标、带样式、带层级关系的图形描述语言,AI 只要生成规范的 XML,用户拿到的就是一个真正意义上的 .drawio 文件——双击打开,所有节点都能拖、能改、能套用团队模板。1. 整体设计与思路拆解1.1 先想清楚:AI 替人画架构图,到底解决的是什么问题做这个项目之前,我先整理了一下自己平时画架构图的流程。传统做法大概是这样的:先在白板上把系统拆成模块,理清调用关系,然后打开绘图工具,一个框一个框地拖,一条线一条线地连,中途还要反复调整对齐、配色、分组,遇到架构评审要改方案,又得整片整片重画。真正花费时间的,其实不是“构思”,而是“落笔”和“返工”。市面上已有的 AI 绘图方案,大多只解决到“生成图片”这一步,生成的图要么是位图无法编辑,要么是 Mermaid 源码但渲染出的布局经常不如人意。我想要的,是一个能理解“订单系统依赖库存系统,通过消息队列异步解耦”这种描述,然后输出一个结构清晰、样貌规整、还能二次编辑的架构图文件。所以 next-ai-draw-io 的产品定义就一句话:把自然语言描述转为符合 draw.io 格式规范的结构化 XML 文件。这个选择还有个附加好处:draw.io 的 XML 本质上是公开的 mxGraphModel 格式,它不锁定在某个私有工具里。文件可以用 VS Code 打开、可以放进 Git 做版本管理、可以用脚本批量校验、可以自动导入到很多在线协作白板里。一套格式,打通了绘图、评审、存档、复用整个链路。1.2 为什么不是 Mermaid,也不是纯 PNG这可能是这个项目最重要的一个决策。我做了一个小对比:Mermaid 的优点是人眼可读、生成容易、版本管理友好,但它本质是“用文本描述图”,渲染引擎才是真正决定布局的主体。问题恰恰出在这个“主体不受控”上:AI 能生成代码,但没法控制它被渲染成什么样,遇到复杂一点的架构图,Mermaid 的自动布局能把箭头绕成蜘蛛网。PNG 这类位图就更不用说了,看着是一张图,改起来等于重画。架构图这种东西,在设计阶段几乎每天都要变——加一个模块、调一条链路、合并两个服务——位图方案在协作上是灾难。draw.io 的 XML 就不一样了。它是精确到每个节点的 x、y、width、height 和图元样式的描述语言。AI 只要生成结构正确的 XML,出来的图就是可控的:节点位置是我算好的,连线走的是我定好的路线,样式风格统一。用户后续想调整,也只是改属性,而不是推翻重画。1.3 项目整体架构:从自然语言到坐标图元整个 next-ai-draw-io 的处理链路可以拆成这么几层:第一层是输入理解层。接收用户输入的自然语言描述,用大模型抽取里面的“实体”和“关系”。所谓实体,就是一个个架构组件,比如“API 网关”“订单服务”“MySQL”;所谓关系,就是组件之间的连接,包括连线两端、关系类型(同步调用、异步消息、数据读写)。第二层是结构建模层。把抽取出来的实体和关系组织成一张有向图。这里有比较多的细节要处理:层级怎么分(上游、下游还是平级)、归属怎么定(哪些节点属于同一个逻辑分组)、孤立节点怎么摆。第三层是坐标计算层。这也是我自己写代码而不是完全丢给大模型的关键部分。大模型擅长理解语义,但在“把 18 个节点排得整整齐齐”这件事上并不可靠。坐标计算用了传统的分层布局思路,按拓扑序分层、层内居中、交叉最小化,输出每个节点的精确位置。第四层是格式生成层。把布局结果用 draw.io XML 模板序列化,生成 stylesheet、mxCell、mxGeometry 这些图元定义。这一步看似模板化,其实最容易出错,后面我会专门讲。2. 核心细节解析与实操要点2.1 mxGraphModel 格式:读懂 draw.io 的“图纸语言”其实 draw.io XML 没有想象中难,它的核心是<mxfile>包裹一个<diagram>,内部是<mxGraphModel>。所有图形元素都是<mxCell>,包括节点、连线还有分组容器。一个最简节点大概是这种感觉:<mxCell id="node-1" value="订单服务" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"> <mxGeometry x="120" y="80" width="120" height="60" as="geometry"/> </mxCell>一条连线则是:<mxCell id="edge-1" value="" style="edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="node-1" target="node-2"> <mxGeometry relative="1" as="geometry"/> </mxCell>看到没,这里的每一笔都是精确的:节点的坐标、宽高、样式写在 geometry 里;连线通过 source 和 target 引用节点的 id,不直接写坐标,由 draw.io 自己按正交线段去渲染。有个细节特别容易忽略:容器节点和普通节点在 parent 属性上不同。普通节点的 parent 是"1"(代表画布根节点),但如果要把几个服务画在一个分组框里,那么这个分组框本身是一个容器节点,而里面的所有子节点 parent 都要设为容器节点的 id,同时还要给子节点的 mxGeometry 加一个 relative="1" 属性。这个层级关系一旦搞错,draw.io 打开文件会直接把节点渲染到画布外面去。我给这个项目定了一个比较稳的编码约定:所有 id 用语义化命名(比如 svc-order、db-mysql-1),不用数字 id。原因很简单,AI 生成的时候能记住语义 id,不容易出现引用错乱,而且生成的 XML 文本人可以审查、比较、再编辑。2.2 布局算法:让 AI 生成的不是“一坨节点”试想一下:如果直接把大模型输出的节点堆到画布上,坐标全是随机值,出来的图会非常灾难——节点互相重叠、连线穿来穿去。所以布局这一步不能依赖大模型自由发挥,需要用确定性的算法来兜底。我用的方案是分层有向图布局,思路不复杂。第一步,根据实体之间的依赖关系建图,依赖方向统一理解为“被依赖方在下游”;第二步,用拓扑排序确定每个节点的层级深度,比如 API 网关在第 0 层,订单服务在第 1 层,数据库在第 2 层;第三步,同一层的节点从左往右排列,按层内序号分配 x 坐标;第四步,层与层之间按最大层宽度计算 y 坐标偏移。计算逻辑大致是:def compute_layer_layout(levels, node_width=120, node_height=60, h_gap=40, v_gap=80): layout = {} max_width = max(len(level) for level in levels.values()) total_width = max_width * node_width + (max_width - 1) * h_gap y = 0 for depth in sorted(levels.keys()): nodes = levels[depth] level_width = len(nodes) * node_width + (len(nodes) - 1) * h_gap start_x = (total_width - level_width) / 2 x = start_x for node in nodes: layout[node] = (x, y, node_width, node_height) x += node_width + h_gap y += node_height + v_gap return layout这里的 max_width 取了所有层里最宽的那一层,用来确定整个画布的视觉中心;每一层用自己的 level_width 计算起始 x,这样能保证每层都是居中的。效果就是最终图整体居中、层间留白均匀,看起来比大多数自动布局都顺眼。连线部分用了 draw.io 的orthogonalEdgeStyle,也就是正交折线风格。这个风格好就好在:它不要求 AI 提前给出连线的路由点,draw.io 自己会基于源节点和目标节点的相对位置自动绕线。这大大降低了 AI 生成“好看连线”的难度。2.3 样式规范:不搞花哨,但要有统一的视觉语言架构图最大的问题是风格不统一。AI 如果自由发挥样式,每个节点的填充色、圆角、边框粗细都不一样,会很乱。我在样式这块直接做了白名单约束。节点样式字段我固定成几个维度:形状(矩形、圆角矩形、椭圆、菱形)、填充色、边框色、字体色、是否加粗。然后按组件类型做了分类规范。比如:应用服务:圆角矩形,浅蓝填充,深蓝边框数据库/存储:圆柱体形状,浅绿填充,深绿边框中间件(消息队列、缓存):圆角矩形,浅黄填充,深黄边框外部系统:普通矩形,浅灰填充,深灰边框这个分类的好处一是美观,二是让架构图有“语义色彩”——扫一眼颜色就知道哪个是服务、哪个是存储。代码里做了一张类型到样式的映射表,生成 XML 时按类型去查。容器分组的边框我用了虚线,这样既区分了逻辑边界,又不会喧宾夺主。字体的选择上,统一用 Helvetica,14 号,不支持中文渲染的问题?draw.io 会自己做字体回退,这个不用担心。2.4 提示词设计:如何让大模型稳定输出结构化数据next-ai-draw-io 的第一个版本,是直接让大模型输出完整 XML。后来发现两个问题,一是 XML 特别容易在中途被截断(token 超限),二是大模型在计算坐标时经常“发挥不稳定”,一会儿靠左一会儿靠右。于是我把流程改了:先让大模型只输出 JSON,也就是抽取出来的实体和关系,坐标交给代码算。提示词的核心结构大概是这样的:从以下系统描述中抽取架构组件,输出 JSON,不要解释,不要包含其他内容。 输出格式要求: { "nodes": [ {"id": "唯一标识", "label": "展示名称", "type": "service|database|middleware|external|group"} ], "edges": [ {"source": "源节点id", "target": "目标节点id", "label": "关系描述"} ] }这里有两个小技巧比较关键。一是“不要解释,不要包含其他内容”这句约束,能有效防止大模型输出前后缀文字,省掉解析时剥离噪音的麻烦;二是 type 字段不要开放自由字符串,用枚举值,这样后面样式映射才能准确。对于分组信息,我会在 JSON 里允许一个可选的 container 字段。如果一个节点有 container,那它的 parent 就是那个容器 id。这样能处理“核心链路”和“支撑服务”这样的逻辑分区。3. 实操过程与核心环节实现3.1 环境准备与依赖清单我用的是 Python 3.10 + FastAPI,做一个本地 HTTP 服务。其实如果是自己用,纯 CLI 也够,但做成服务后面可以接 Web 前端、接团队机器人,通用性更好。核心依赖其实只有两个:大模型的 SDK 用来做语义抽取,xml.etree 做 XML 序列化。布局算法完全是自己写的,没引第三方图形库,因为 for 循环就算得过来。如果做前端的同学,还可以直接在前端用drawio的嵌入模式预览生成的.drawio文件,实现“输入描述 → 看到架构图”的完整闭环。3.2 核心代码:JSON 解析与 XML 序列化这里提供一段可以直接落地跑的简化版核心代码。我在项目里用xml.etree.ElementTree来构建 XML,然后用indent()做格式化,保证输出文件可读。import xml.etree.ElementTree as ET from xml.dom import minidom def build_drawio_xml(nodes, edges, layouts, groups): mxfile = ET.Element("mxfile", {"host": "app.diagrams.net"}) diagram = ET.SubElement(mxfile, "diagram", {"id": "page-1", "name": "架构图"}) graph = ET.SubElement(diagram, "mxGraphModel", {"dx": "1200", "dy": "800", "grid": "1"}) root = ET.SubElement(graph, "root") ET.SubElement(root, "mxCell", {"id": "0"}) ET.SubElement(root, "mxCell", {"id": "1", "parent": "0"}) for gid, gname in groups.items(): gx, gy, gw, gh = groups_layout[gid] ET.SubElement(root, "mxCell", { "id": gid, "value": gname, "vertex": "1", "parent": "1", "style": "rounded=0;dashed=1;verticalAlign=top;whiteSpace=wrap;html=1;" }).append(ET.Element("mxGeometry", { "x": str(gx), "y": str(gy), "width": str(gw), "height": str(gh), "as": "geometry" })) for n in nodes: x, y, w, h = layouts[n["id"]] parent = n.get("container", "1") cell = ET.SubElement(root, "mxCell", { "id": n["id"], "value": n["label"], "vertex": "1", "parent": parent, "style": type_style_map[n["type"]] }) geo = ET.Element("mxGeometry", { "x": str(x), "y": str(y), "width": str(w), "height": str(h), "as": "geometry" }) if parent != "1": geo.set("relative", "1") cell.append(geo) edges = sorted(edges, key=lambda e: e["source"]) for e in edges: ET.SubElement(root, "mxCell", { "id": f"edge-{e['source']}-{e['target']}", "value": e.get("label", ""), "edge": "1", "parent": "1", "source": e["source"], "target": e["target"], "style": "edgeStyle=orthogonalEdgeStyle;rounded=0;" }).append(ET.Element("mxGeometry", {"relative": "1", "as": "geometry"})) rough = ET.tostring(mxfile, encoding="unicode") parsed = minidom.parseString(rough) return parsed.toprettyxml(indent=" ")代码里有两个我反复验证过的细节。第一个是parent != "1"时给子元素加relative="1"的操作,不加这个属性,draw.io 会忽略容器内节点的相对坐标,导致节点跑位。第二个是给边 id 加了固定前缀edge-,避免和节点 id 冲突,同时用“源-目标”的组合保证同一对节点之间不会生成两条重复边时 id 冲突。如果你想让架构图更美观,可以在这个基础上加子布局:比如允许一个容器节点内部独立跑一次布局算法,再把结果相对于容器左上角偏移。这个我实现了,效果不错,但首版完全可以不做。3.3 大模型解析与异常兜底大模型输出 JSON 不是 100% 可靠的。我遇到过几种情况:输出了 Markdown 代码块包裹的 JSON;JSON 里混入了注释;字段名被翻译成了中文;id 里有空格导致 XML 格式问题。这些都需要在接入层处理。我的做法是在解析前先做一个预处理函数:import json, re def extract_and_parse_json(raw_text): text = raw_text.strip() text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text) start = text.find("{") end = text.rfind("}") if start == -1 or end == -1: raise ValueError("no json found") text = text[start:end + 1] return json.loads(text)这段代码的意图很简单:先剥掉大模型可能套上的代码块外壳,再用首尾大括号切出 JSON 主体,避免了解析整个字符串时被前后缀文字干扰。实际跑下来,一个这样的兜底函数能省掉 80% 的格式异常问题。JSON 解析成功后,还不能直接进布局。我会做一轮严格校验:每个节点必须有 id、label、type;每个边必须有 source、target,且 source 和 target 必须存在于节点集合里;type 必须是白名单枚举值。任何一条不满足,直接报错返回提示,不静默容错。因为架构图这东西错了就是错了,宁可让用户改描述重试,也不要生成一个引用了不存在节点的坏文件。3.4 让架构图从“单页”到“多页”做第二版的时候我发现,真实架构图往往不是一张图能放下的。一个系统可能有整体架构图、部署架构图、数据流图三个视图。硬塞在一页里,图会非常拥挤。所以后来给 JSON 格式加了一个可选的pages字段:一次解析可以产出多个页面,每个页面有独立的名字、节点列表、边列表。XML 侧就是多生成几个<diagram>节点的事,draw.io 打开后会显示底部多页签,非常方便。这里有个体验细节:不同页面里的重复节点(比如同一个数据库在“部署视图”和“数据流视图”都出现),我给它分配不同的 id 后缀,避免跨页引用混乱。宁可让两个页面的节点是两份独立定义,也不要搞全局引用,因为 draw.io 对跨页引用的支持并不友好。4. 常见问题与排查技巧实录4.1 XML 被截断或结构不完整这是第一版最容易 hit 的问题。大模型输出长 XML 时,快到 token 上限就会截断,导致文件后半部分缺失,draw.io 直接打不开。排查方法很简单:文件生成后用 XML 解析器过一遍,解析不了就说明结构坏了。我写了一个校验函数,在返回文件前自动跑一遍。修复思路有两个方向:彻底解决:走新流程,大模型只输出 JSON,XML 由代码拼装,XML 长度完全可控,不会再截断;兜底方案:如果 XML 解析失败,提示用户“请简化描述后重试”,并输出当前已解析出的部分信息,帮助定位是哪一部分描述有歧义。注意:如果你调试时发现 draw.io 能打开文件但有些节点缺失,基本可以确定是 XML 被截断,优先检查文件尾部有没有完整的</mxfile>闭合标签。4.2 连线拓扑混乱我遇到过一个很经典的症状:生成的图节点排得整齐,但连线乱成一团,A 节点连到 B 的线在图上绕了一个大圈。原因几乎都是节点和连线的坐标/引用不匹配。排查顺序按这三步走:检查边的 source 和 target 是否存在且唯一;检查是否出现了循环依赖。布局算法最怕环,一旦有环,拓扑排序会崩,或者某条边直接反向。解决办法是布局前先做环检测,有环时按“先到先得”原则忽略某一条边,并在返回结果中标记;检查连线是否都使用了orthogonalEdgeStyle。如果没有,draw.io 默认直线连接,节点一多,直线穿过其他节点就是必然。4.3 分组容器内的节点飞出边界这个问题在分组嵌套的时候特别常见:容器的几何信息写在父节点上,但子节点没有相对坐标的概念,结果就是 draw.io 计算子节点位置时用相对值还是绝对值搞错了。我的统一处理规则是:容器子节点的几何坐标一律存相对值,也就是相对容器左上角的偏移,同时加relative="1";容器的宽高,则根据子节点的绝对坐标算出来再微调加 padding。def compute_container_layout(children, padding=30): min_x = min(pos[0] for pos in children.values()) min_y = min(pos[1] for pos in children.values()) max_x = max(pos[0] + pos[2] for pos in children.values()) max_y = max(pos[1] + pos[3] for pos in children.values()) return min_x - padding, min_y - padding, (max_x - min_x) + 2 * padding, (max_y - min_y) + 2 * padding原理很简单,先把所有子节点排好,再用子节点的边界反推容器的边界。这样容器永远能包住内容,不会出现节点在框外面的尴尬情况。4.4 中文字符渲染乱码draw.io 对中文支持本身没问题,容易栽坑的是 XML 声明的编码。如果你用 Python 的ET.tostring输出 unicode 字符串,再写入文件时没指定encoding="utf-8",Windows 默认编码写的文件就可能是 GBK,draw.io 打开直接乱码。解法就一行:with open("arch.drawio", "w", encoding="utf-8") as f: f.write(xml_content)另外 XML 声明里最好明确写<?xml version="1.0" encoding="UTF-8"?>,双保险。4.5 常见问题速查表现象可能原因排查方法draw.io 打不开文件XML 截断、闭合标签缺失用 XML 解析器校验文件是否完整文件打开了但节点重叠坐标计算未执行或布局异常检查拓扑排序是否有环、层级是否正确节点都在画布左上角堆着父节点 id 引用错误或 relative 属性缺失检查容器节点 parent 和 geometry relative节点显示但连线不显示边的 source/target 指向不存在节点校验节点集合,检查 id 是否有空格中文乱码文件编码不是 UTF-8统一以 UTF-8 编码写入文件样式全部丢失style 属性为空或被覆盖检查样式映射表,确认 type 命中白名单5. 经验总结与扩展思路这个项目做到最后,我自己最大的收获倒不是“AI 能画图”这个结果,而是发现了一条更适合工程化的路径:让大模型做语义理解,让代码做结构计算,两者各干自己擅长的事。这个思路不仅适合画架构图,也适合其他很多 AI 辅助生成场景。实际跑下来的体会是,AI 在“理解‘订单系统依赖库存系统并发送领域事件’这句描述,并提取出三个实体和两条关系”这种任务上,表现非常稳定;而在“18 个节点怎么排不重叠”这种纯计算任务上,指望 AI 反而不靠谱。架构图的最终质量,由布局算法兜底,而不是靠提示词碰运气。如果你也想在团队内部引入类似能力,可以从一个很小的场景入手:先只支持单页、三五十个节点以内的架构图,把布局、校验、格式这块打磨稳,再逐步加分组、多页、子布局、样式定制。这种项目天然适合做成一个内部工具,嵌入到现有文档流程里去。最后分享一个小技巧:生成完.drawio文件后,记得再让代码自动跑一次“导入验证”。具体做法是用 draw.io 的命令行工具做一次静默转换,比如转成 SVG,如果转换成功,说明文件结构基本没问题;如果转换失败,说明文件有问题,可以直接拦截,不用等用户手动打开才发现。这个小验证我在项目里是作为 CI 的一环跑的,省了很多后续沟通成本。这个方向进一步做下去,可以支持更多画布类型,也可以把描述层做成对话式——用户先让 AI 生成初版架构图,再基于图上的节点继续追问“如果把缓存加在订单服务和数据库之间,图会变成什么样”,也就是实现架构图的交互式迭代。这个我下个版本已经在做了。
很多人一听到“让 AI 画架构图”,第一反应是“不就是把需求丢给对话式大模型,让它生成一段 Mermaid 嘛”。我一开始也这么想,直到自己动手做了一个叫 next-ai-draw-io 的小项目——它的目标不只是让 AI 吐出图片,而是真正替你把架构图画成可编辑、可继续改、能直接进已有文档体系的工程文件。这篇文章就把我做的这个项目的完整思路、核心实现、踩坑记录都摊开讲,项目本身不大,但里面关于格式选型、布局算法、提示词设计这些细节,沉淀下来还是有不少值得聊的东西。先说结论:架构图这件事真正的痛点,从来不是“画不出来”,而是“画出来之后没法改、没法复用、没法纳入协作流程”。这也是我最终放弃纯 Mermaid 路线、转而锚定 draw.io XML 格式的核心原因。Mermaid 适合快速表达逻辑,但它是文本渲染,样式、对齐、微调都很痛苦;而 draw.io(也就是 diagrams.net)的 XML 格式,本质上是带坐标、带样式、带层级关系的图形描述语言,AI 只要生成规范的 XML,用户拿到的就是一个真正意义上的 .drawio 文件——双击打开,所有节点都能拖、能改、能套用团队模板。1. 整体设计与思路拆解1.1 先想清楚:AI 替人画架构图,到底解决的是什么问题做这个项目之前,我先整理了一下自己平时画架构图的流程。传统做法大概是这样的:先在白板上把系统拆成模块,理清调用关系,然后打开绘图工具,一个框一个框地拖,一条线一条线地连,中途还要反复调整对齐、配色、分组,遇到架构评审要改方案,又得整片整片重画。真正花费时间的,其实不是“构思”,而是“落笔”和“返工”。市面上已有的 AI 绘图方案,大多只解决到“生成图片”这一步,生成的图要么是位图无法编辑,要么是 Mermaid 源码但渲染出的布局经常不如人意。我想要的,是一个能理解“订单系统依赖库存系统,通过消息队列异步解耦”这种描述,然后输出一个结构清晰、样貌规整、还能二次编辑的架构图文件。所以 next-ai-draw-io 的产品定义就一句话:把自然语言描述转为符合 draw.io 格式规范的结构化 XML 文件。这个选择还有个附加好处:draw.io 的 XML 本质上是公开的 mxGraphModel 格式,它不锁定在某个私有工具里。文件可以用 VS Code 打开、可以放进 Git 做版本管理、可以用脚本批量校验、可以自动导入到很多在线协作白板里。一套格式,打通了绘图、评审、存档、复用整个链路。1.2 为什么不是 Mermaid,也不是纯 PNG这可能是这个项目最重要的一个决策。我做了一个小对比:Mermaid 的优点是人眼可读、生成容易、版本管理友好,但它本质是“用文本描述图”,渲染引擎才是真正决定布局的主体。问题恰恰出在这个“主体不受控”上:AI 能生成代码,但没法控制它被渲染成什么样,遇到复杂一点的架构图,Mermaid 的自动布局能把箭头绕成蜘蛛网。PNG 这类位图就更不用说了,看着是一张图,改起来等于重画。架构图这种东西,在设计阶段几乎每天都要变——加一个模块、调一条链路、合并两个服务——位图方案在协作上是灾难。draw.io 的 XML 就不一样了。它是精确到每个节点的 x、y、width、height 和图元样式的描述语言。AI 只要生成结构正确的 XML,出来的图就是可控的:节点位置是我算好的,连线走的是我定好的路线,样式风格统一。用户后续想调整,也只是改属性,而不是推翻重画。1.3 项目整体架构:从自然语言到坐标图元整个 next-ai-draw-io 的处理链路可以拆成这么几层:第一层是输入理解层。接收用户输入的自然语言描述,用大模型抽取里面的“实体”和“关系”。所谓实体,就是一个个架构组件,比如“API 网关”“订单服务”“MySQL”;所谓关系,就是组件之间的连接,包括连线两端、关系类型(同步调用、异步消息、数据读写)。第二层是结构建模层。把抽取出来的实体和关系组织成一张有向图。这里有比较多的细节要处理:层级怎么分(上游、下游还是平级)、归属怎么定(哪些节点属于同一个逻辑分组)、孤立节点怎么摆。第三层是坐标计算层。这也是我自己写代码而不是完全丢给大模型的关键部分。大模型擅长理解语义,但在“把 18 个节点排得整整齐齐”这件事上并不可靠。坐标计算用了传统的分层布局思路,按拓扑序分层、层内居中、交叉最小化,输出每个节点的精确位置。第四层是格式生成层。把布局结果用 draw.io XML 模板序列化,生成 stylesheet、mxCell、mxGeometry 这些图元定义。这一步看似模板化,其实最容易出错,后面我会专门讲。2. 核心细节解析与实操要点2.1 mxGraphModel 格式:读懂 draw.io 的“图纸语言”其实 draw.io XML 没有想象中难,它的核心是<mxfile>包裹一个<diagram>,内部是<mxGraphModel>。所有图形元素都是<mxCell>,包括节点、连线还有分组容器。一个最简节点大概是这种感觉:<mxCell id="node-1" value="订单服务" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"> <mxGeometry x="120" y="80" width="120" height="60" as="geometry"/> </mxCell>一条连线则是:<mxCell id="edge-1" value="" style="edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="node-1" target="node-2"> <mxGeometry relative="1" as="geometry"/> </mxCell>看到没,这里的每一笔都是精确的:节点的坐标、宽高、样式写在 geometry 里;连线通过 source 和 target 引用节点的 id,不直接写坐标,由 draw.io 自己按正交线段去渲染。有个细节特别容易忽略:容器节点和普通节点在 parent 属性上不同。普通节点的 parent 是"1"(代表画布根节点),但如果要把几个服务画在一个分组框里,那么这个分组框本身是一个容器节点,而里面的所有子节点 parent 都要设为容器节点的 id,同时还要给子节点的 mxGeometry 加一个 relative="1" 属性。这个层级关系一旦搞错,draw.io 打开文件会直接把节点渲染到画布外面去。我给这个项目定了一个比较稳的编码约定:所有 id 用语义化命名(比如 svc-order、db-mysql-1),不用数字 id。原因很简单,AI 生成的时候能记住语义 id,不容易出现引用错乱,而且生成的 XML 文本人可以审查、比较、再编辑。2.2 布局算法:让 AI 生成的不是“一坨节点”试想一下:如果直接把大模型输出的节点堆到画布上,坐标全是随机值,出来的图会非常灾难——节点互相重叠、连线穿来穿去。所以布局这一步不能依赖大模型自由发挥,需要用确定性的算法来兜底。我用的方案是分层有向图布局,思路不复杂。第一步,根据实体之间的依赖关系建图,依赖方向统一理解为“被依赖方在下游”;第二步,用拓扑排序确定每个节点的层级深度,比如 API 网关在第 0 层,订单服务在第 1 层,数据库在第 2 层;第三步,同一层的节点从左往右排列,按层内序号分配 x 坐标;第四步,层与层之间按最大层宽度计算 y 坐标偏移。计算逻辑大致是:def compute_layer_layout(levels, node_width=120, node_height=60, h_gap=40, v_gap=80): layout = {} max_width = max(len(level) for level in levels.values()) total_width = max_width * node_width + (max_width - 1) * h_gap y = 0 for depth in sorted(levels.keys()): nodes = levels[depth] level_width = len(nodes) * node_width + (len(nodes) - 1) * h_gap start_x = (total_width - level_width) / 2 x = start_x for node in nodes: layout[node] = (x, y, node_width, node_height) x += node_width + h_gap y += node_height + v_gap return layout这里的 max_width 取了所有层里最宽的那一层,用来确定整个画布的视觉中心;每一层用自己的 level_width 计算起始 x,这样能保证每层都是居中的。效果就是最终图整体居中、层间留白均匀,看起来比大多数自动布局都顺眼。连线部分用了 draw.io 的orthogonalEdgeStyle,也就是正交折线风格。这个风格好就好在:它不要求 AI 提前给出连线的路由点,draw.io 自己会基于源节点和目标节点的相对位置自动绕线。这大大降低了 AI 生成“好看连线”的难度。2.3 样式规范:不搞花哨,但要有统一的视觉语言架构图最大的问题是风格不统一。AI 如果自由发挥样式,每个节点的填充色、圆角、边框粗细都不一样,会很乱。我在样式这块直接做了白名单约束。节点样式字段我固定成几个维度:形状(矩形、圆角矩形、椭圆、菱形)、填充色、边框色、字体色、是否加粗。然后按组件类型做了分类规范。比如:应用服务:圆角矩形,浅蓝填充,深蓝边框数据库/存储:圆柱体形状,浅绿填充,深绿边框中间件(消息队列、缓存):圆角矩形,浅黄填充,深黄边框外部系统:普通矩形,浅灰填充,深灰边框这个分类的好处一是美观,二是让架构图有“语义色彩”——扫一眼颜色就知道哪个是服务、哪个是存储。代码里做了一张类型到样式的映射表,生成 XML 时按类型去查。容器分组的边框我用了虚线,这样既区分了逻辑边界,又不会喧宾夺主。字体的选择上,统一用 Helvetica,14 号,不支持中文渲染的问题?draw.io 会自己做字体回退,这个不用担心。2.4 提示词设计:如何让大模型稳定输出结构化数据next-ai-draw-io 的第一个版本,是直接让大模型输出完整 XML。后来发现两个问题,一是 XML 特别容易在中途被截断(token 超限),二是大模型在计算坐标时经常“发挥不稳定”,一会儿靠左一会儿靠右。于是我把流程改了:先让大模型只输出 JSON,也就是抽取出来的实体和关系,坐标交给代码算。提示词的核心结构大概是这样的:从以下系统描述中抽取架构组件,输出 JSON,不要解释,不要包含其他内容。 输出格式要求: { "nodes": [ {"id": "唯一标识", "label": "展示名称", "type": "service|database|middleware|external|group"} ], "edges": [ {"source": "源节点id", "target": "目标节点id", "label": "关系描述"} ] }这里有两个小技巧比较关键。一是“不要解释,不要包含其他内容”这句约束,能有效防止大模型输出前后缀文字,省掉解析时剥离噪音的麻烦;二是 type 字段不要开放自由字符串,用枚举值,这样后面样式映射才能准确。对于分组信息,我会在 JSON 里允许一个可选的 container 字段。如果一个节点有 container,那它的 parent 就是那个容器 id。这样能处理“核心链路”和“支撑服务”这样的逻辑分区。3. 实操过程与核心环节实现3.1 环境准备与依赖清单我用的是 Python 3.10 + FastAPI,做一个本地 HTTP 服务。其实如果是自己用,纯 CLI 也够,但做成服务后面可以接 Web 前端、接团队机器人,通用性更好。核心依赖其实只有两个:大模型的 SDK 用来做语义抽取,xml.etree 做 XML 序列化。布局算法完全是自己写的,没引第三方图形库,因为 for 循环就算得过来。如果做前端的同学,还可以直接在前端用drawio的嵌入模式预览生成的.drawio文件,实现“输入描述 → 看到架构图”的完整闭环。3.2 核心代码:JSON 解析与 XML 序列化这里提供一段可以直接落地跑的简化版核心代码。我在项目里用xml.etree.ElementTree来构建 XML,然后用indent()做格式化,保证输出文件可读。import xml.etree.ElementTree as ET from xml.dom import minidom def build_drawio_xml(nodes, edges, layouts, groups): mxfile = ET.Element("mxfile", {"host": "app.diagrams.net"}) diagram = ET.SubElement(mxfile, "diagram", {"id": "page-1", "name": "架构图"}) graph = ET.SubElement(diagram, "mxGraphModel", {"dx": "1200", "dy": "800", "grid": "1"}) root = ET.SubElement(graph, "root") ET.SubElement(root, "mxCell", {"id": "0"}) ET.SubElement(root, "mxCell", {"id": "1", "parent": "0"}) for gid, gname in groups.items(): gx, gy, gw, gh = groups_layout[gid] ET.SubElement(root, "mxCell", { "id": gid, "value": gname, "vertex": "1", "parent": "1", "style": "rounded=0;dashed=1;verticalAlign=top;whiteSpace=wrap;html=1;" }).append(ET.Element("mxGeometry", { "x": str(gx), "y": str(gy), "width": str(gw), "height": str(gh), "as": "geometry" })) for n in nodes: x, y, w, h = layouts[n["id"]] parent = n.get("container", "1") cell = ET.SubElement(root, "mxCell", { "id": n["id"], "value": n["label"], "vertex": "1", "parent": parent, "style": type_style_map[n["type"]] }) geo = ET.Element("mxGeometry", { "x": str(x), "y": str(y), "width": str(w), "height": str(h), "as": "geometry" }) if parent != "1": geo.set("relative", "1") cell.append(geo) edges = sorted(edges, key=lambda e: e["source"]) for e in edges: ET.SubElement(root, "mxCell", { "id": f"edge-{e['source']}-{e['target']}", "value": e.get("label", ""), "edge": "1", "parent": "1", "source": e["source"], "target": e["target"], "style": "edgeStyle=orthogonalEdgeStyle;rounded=0;" }).append(ET.Element("mxGeometry", {"relative": "1", "as": "geometry"})) rough = ET.tostring(mxfile, encoding="unicode") parsed = minidom.parseString(rough) return parsed.toprettyxml(indent=" ")代码里有两个我反复验证过的细节。第一个是parent != "1"时给子元素加relative="1"的操作,不加这个属性,draw.io 会忽略容器内节点的相对坐标,导致节点跑位。第二个是给边 id 加了固定前缀edge-,避免和节点 id 冲突,同时用“源-目标”的组合保证同一对节点之间不会生成两条重复边时 id 冲突。如果你想让架构图更美观,可以在这个基础上加子布局:比如允许一个容器节点内部独立跑一次布局算法,再把结果相对于容器左上角偏移。这个我实现了,效果不错,但首版完全可以不做。3.3 大模型解析与异常兜底大模型输出 JSON 不是 100% 可靠的。我遇到过几种情况:输出了 Markdown 代码块包裹的 JSON;JSON 里混入了注释;字段名被翻译成了中文;id 里有空格导致 XML 格式问题。这些都需要在接入层处理。我的做法是在解析前先做一个预处理函数:import json, re def extract_and_parse_json(raw_text): text = raw_text.strip() text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text) start = text.find("{") end = text.rfind("}") if start == -1 or end == -1: raise ValueError("no json found") text = text[start:end + 1] return json.loads(text)这段代码的意图很简单:先剥掉大模型可能套上的代码块外壳,再用首尾大括号切出 JSON 主体,避免了解析整个字符串时被前后缀文字干扰。实际跑下来,一个这样的兜底函数能省掉 80% 的格式异常问题。JSON 解析成功后,还不能直接进布局。我会做一轮严格校验:每个节点必须有 id、label、type;每个边必须有 source、target,且 source 和 target 必须存在于节点集合里;type 必须是白名单枚举值。任何一条不满足,直接报错返回提示,不静默容错。因为架构图这东西错了就是错了,宁可让用户改描述重试,也不要生成一个引用了不存在节点的坏文件。3.4 让架构图从“单页”到“多页”做第二版的时候我发现,真实架构图往往不是一张图能放下的。一个系统可能有整体架构图、部署架构图、数据流图三个视图。硬塞在一页里,图会非常拥挤。所以后来给 JSON 格式加了一个可选的pages字段:一次解析可以产出多个页面,每个页面有独立的名字、节点列表、边列表。XML 侧就是多生成几个<diagram>节点的事,draw.io 打开后会显示底部多页签,非常方便。这里有个体验细节:不同页面里的重复节点(比如同一个数据库在“部署视图”和“数据流视图”都出现),我给它分配不同的 id 后缀,避免跨页引用混乱。宁可让两个页面的节点是两份独立定义,也不要搞全局引用,因为 draw.io 对跨页引用的支持并不友好。4. 常见问题与排查技巧实录4.1 XML 被截断或结构不完整这是第一版最容易 hit 的问题。大模型输出长 XML 时,快到 token 上限就会截断,导致文件后半部分缺失,draw.io 直接打不开。排查方法很简单:文件生成后用 XML 解析器过一遍,解析不了就说明结构坏了。我写了一个校验函数,在返回文件前自动跑一遍。修复思路有两个方向:彻底解决:走新流程,大模型只输出 JSON,XML 由代码拼装,XML 长度完全可控,不会再截断;兜底方案:如果 XML 解析失败,提示用户“请简化描述后重试”,并输出当前已解析出的部分信息,帮助定位是哪一部分描述有歧义。注意:如果你调试时发现 draw.io 能打开文件但有些节点缺失,基本可以确定是 XML 被截断,优先检查文件尾部有没有完整的</mxfile>闭合标签。4.2 连线拓扑混乱我遇到过一个很经典的症状:生成的图节点排得整齐,但连线乱成一团,A 节点连到 B 的线在图上绕了一个大圈。原因几乎都是节点和连线的坐标/引用不匹配。排查顺序按这三步走:检查边的 source 和 target 是否存在且唯一;检查是否出现了循环依赖。布局算法最怕环,一旦有环,拓扑排序会崩,或者某条边直接反向。解决办法是布局前先做环检测,有环时按“先到先得”原则忽略某一条边,并在返回结果中标记;检查连线是否都使用了orthogonalEdgeStyle。如果没有,draw.io 默认直线连接,节点一多,直线穿过其他节点就是必然。4.3 分组容器内的节点飞出边界这个问题在分组嵌套的时候特别常见:容器的几何信息写在父节点上,但子节点没有相对坐标的概念,结果就是 draw.io 计算子节点位置时用相对值还是绝对值搞错了。我的统一处理规则是:容器子节点的几何坐标一律存相对值,也就是相对容器左上角的偏移,同时加relative="1";容器的宽高,则根据子节点的绝对坐标算出来再微调加 padding。def compute_container_layout(children, padding=30): min_x = min(pos[0] for pos in children.values()) min_y = min(pos[1] for pos in children.values()) max_x = max(pos[0] + pos[2] for pos in children.values()) max_y = max(pos[1] + pos[3] for pos in children.values()) return min_x - padding, min_y - padding, (max_x - min_x) + 2 * padding, (max_y - min_y) + 2 * padding原理很简单,先把所有子节点排好,再用子节点的边界反推容器的边界。这样容器永远能包住内容,不会出现节点在框外面的尴尬情况。4.4 中文字符渲染乱码draw.io 对中文支持本身没问题,容易栽坑的是 XML 声明的编码。如果你用 Python 的ET.tostring输出 unicode 字符串,再写入文件时没指定encoding="utf-8",Windows 默认编码写的文件就可能是 GBK,draw.io 打开直接乱码。解法就一行:with open("arch.drawio", "w", encoding="utf-8") as f: f.write(xml_content)另外 XML 声明里最好明确写<?xml version="1.0" encoding="UTF-8"?>,双保险。4.5 常见问题速查表现象可能原因排查方法draw.io 打不开文件XML 截断、闭合标签缺失用 XML 解析器校验文件是否完整文件打开了但节点重叠坐标计算未执行或布局异常检查拓扑排序是否有环、层级是否正确节点都在画布左上角堆着父节点 id 引用错误或 relative 属性缺失检查容器节点 parent 和 geometry relative节点显示但连线不显示边的 source/target 指向不存在节点校验节点集合,检查 id 是否有空格中文乱码文件编码不是 UTF-8统一以 UTF-8 编码写入文件样式全部丢失style 属性为空或被覆盖检查样式映射表,确认 type 命中白名单5. 经验总结与扩展思路这个项目做到最后,我自己最大的收获倒不是“AI 能画图”这个结果,而是发现了一条更适合工程化的路径:让大模型做语义理解,让代码做结构计算,两者各干自己擅长的事。这个思路不仅适合画架构图,也适合其他很多 AI 辅助生成场景。实际跑下来的体会是,AI 在“理解‘订单系统依赖库存系统并发送领域事件’这句描述,并提取出三个实体和两条关系”这种任务上,表现非常稳定;而在“18 个节点怎么排不重叠”这种纯计算任务上,指望 AI 反而不靠谱。架构图的最终质量,由布局算法兜底,而不是靠提示词碰运气。如果你也想在团队内部引入类似能力,可以从一个很小的场景入手:先只支持单页、三五十个节点以内的架构图,把布局、校验、格式这块打磨稳,再逐步加分组、多页、子布局、样式定制。这种项目天然适合做成一个内部工具,嵌入到现有文档流程里去。最后分享一个小技巧:生成完.drawio文件后,记得再让代码自动跑一次“导入验证”。具体做法是用 draw.io 的命令行工具做一次静默转换,比如转成 SVG,如果转换成功,说明文件结构基本没问题;如果转换失败,说明文件有问题,可以直接拦截,不用等用户手动打开才发现。这个小验证我在项目里是作为 CI 的一环跑的,省了很多后续沟通成本。这个方向进一步做下去,可以支持更多画布类型,也可以把描述层做成对话式——用户先让 AI 生成初版架构图,再基于图上的节点继续追问“如果把缓存加在订单服务和数据库之间,图会变成什么样”,也就是实现架构图的交互式迭代。这个我下个版本已经在做了。