1. 这个项目到底要解决什么问题
项目代号叫 next-draw.io AI 架构图。当时起这个名字,没有绑定任何具体商业产品,就是 next(下一代)加 draw(绘图)加 io(输入输出)三个词的组合。真正做起来之后才发现,这名字歪打正着点中了架构图工具最核心的三个痛点:怎么把输入变成输出,怎么降低画图门槛,怎么让图能持续迭代维护。
这个项目的目标,是让 AI 根据一段需求描述,甚至一段代码,直接生成一份结构化、可继续编辑的系统架构图,而不是给一张只能看、不能改的静态图片。如果你和我一样,经历过“开会前两小时还在手动补箭头”“新同学入职读架构文档读到怀疑人生”“代码改了三轮,图还是半年前那版”这些场景,那你应该知道这类工具缺得有多离谱。
1.1 架构图为什么总是画不出来
先说最扎心的现实:不是大家不会画图,而是画图的成本收益严重失衡。
手动拖拽一个框、填一个名字,看起来只要十秒钟,但一张完整的系统架构图,光节点就有二三十个,连线几十条,还要分组、标注、调色、对齐。等你终于把框摆好、线连完,需求已经变了,于是你开始新一轮拖动、缩放、对齐。这种重复劳动极其消耗耐心,所以大部分团队的架构图要么画得很浅,要么根本不更新。
更深一层的问题是:架构图应该是“持久资产”,但实际上它往往成为“一次性产物”。真正的系统在演进,接口在变化,模块在拆分合并,而图不会自己跟着变。于是技术文档里的架构图,经常成为面试官最喜欢问的“哪张图才是对的”的笑话。
这个项目要解决的,就是让架构图可以像代码一样被生成、被版本管理、被自动化检查和更新。换句话说,把画图从“手工建模”变成“半自动编译”。
1.2 next-draw.io 的核心目标
我在设计目标时给自己列了四条硬性要求,少一条都会让项目失去价值:
首先,输入要低门槛。用户可以直接写一句人话,比如“用户端通过网关访问后端服务,后端服务依赖订单库和用户库”,也可以粘贴一段代码目录,甚至给一份接口清单。工具要做的是理解这些信息,而不是要求用户先学会某种复杂的建模语法。
其次,输出必须可编辑。AI 画完图,人还要改。所以生成结果不能是位图,不能是一张 PNG,而应该是带有节点、连线、分组、样式属性的文本格式,方便用户在已有基础上继续调整。
第三,结果必须能入库协作。架构图要放进 git 这类版本管理系统里,每次改动都能清晰对比,哪些模块新增了,哪些依赖被删掉了。图片格式做不了这件事,只有文本化、结构化格式能胜任。
第四,要支持迭代修复。AI 第一次生成的结果总是会有点偏差,要么漏了一个模块,要么把边连错了。工具需要有自检和重试机制,让用户说一句“再检查一下网关和鉴权服务的关系”,就能做局部修正,而不是整张推倒重来。
有了这四条,后面所有技术选型都围绕它们展开。你会发现,大部分设计取舍其实都是被这些目标倒逼出来的。
2. 整体设计思路:先选对路线,再谈实现
2.1 三条技术路线,我为什么选 XML 中间格式
做 AI 生成架构图,输出格式是第一个要拍板的决策。我认真对比过三条路线,各有各的坑。
第一条路线是让 AI 直接生成图片。这个最直观,模型用一张 PNG 或者 SVG 把图画出来。但问题非常明显:坐标、文字准确度不可控,节点边界经常糊成一团,而且生成结果完全不可编程、不可 diff,用户想改一个字都得重新生成。这就违背了“可编辑”和“可入库”两条核心目标。
第二条路线是生成文本描述类的 DSL 图表。这类语法适合简单流程图,写起来也很快,但对企业级系统架构图来说,表达能力偏弱。泳道、分组、跨层依赖、复杂样式,都要靠各种技巧去凑。而且 DSL 本身是一个大生态,将来想扩展自定义组件,成本会越来越高。
第三条路线是生成结构化的绘图 XML 中间格式。XML 听起来老气,但它有不可替代的好处:本质上描述的是一张数据表,节点是行,边是关系,样式是属性。解析容易,校验容易,扩展也容易。我把 AI 直接生成最终绘图格式作为远期目标,但在当下阶段,项目内部先定义一套精简的 XML 中间结构,让模型把“实体”“关系”“分组”“样式”语义化地表达出来,后续再加一层薄薄的转换器,把它映射成真正能导入第三方工具做可视化的文件。
三条路线的对比,我实测下来是这样:
| 输出形态 | 成本 | 可编辑性 | 入库存档 | 复杂图支持 |
|---|---|---|---|---|
| 位图/矢量图 | 低 | 差,基本重画 | 差,二进制无法 diff | 差,文字经常错 |
| 文本 DSL | 中 | 中,文本可改 | 中,diff 但语义弱 | 中,分组交叉很费劲 |
| XML 中间结构 | 高 | 强,节点属性全保留 | 强,结构化 diff 清晰 | 高,能带分组和自定义属性 |
如果你见过网页 HTML 和最终渲染结果之间的关系,就很容易理解我的选择。HTML 不是给你直接看的最终画面,它是一种中间结构,浏览器拿到之后做布局、样式、绘制。XML 架构图也是这样,AI 只负责把“网页语义”写好,布局和绘制交给后面的引擎,两者解耦之后,每一边都能做好自己擅长的事。
2.2 核心工作流拆解
整个项目流程被我拆成五个步骤,顺序是固定的,缺一步都会多米诺骨牌式崩塌。
第一步,输入处理。把用户输入的需求文本、代码结构、接口清单统一拼接成一条结构化 prompt。这一步看起来简单,但实际上大部分生成质量都取决于 prompt 里有没有把“什么是节点、什么是边、什么是分组”定义清楚。
第二步,结构生成。把 prompt 发给大模型,要求它返回 XML 中间结构。注意,不是让它返回示意图,而是返回一张有节点、有边、有分组的结构化数据。模型的角色被限定为“信息抽取器”,而不是“画家”。
第三步,解析清洗。大模型输出不可能次次完美,可能带 markdown 包裹,可能在 XML 里混入解释文字,可能节点 id 不唯一。这一步要用脚本把原始输出提取出来,解析成内存对象,并且对不合法内容做修正或丢弃。
第四步,布局计算。拿到节点和边的信息之后,我并不直接用模型建议的坐标,而是把实体关系交给一个独立的分层布局引擎,重新计算每个节点的位置。这一步能避免大量“节点叠在一起”“连线横穿所有模块”的惨案。
第五步,渲染回写。把布局之后的节点坐标、宽高、样式、分组信息,写回到与第三方工具兼容的绘图 XML 文件。用户在画图界面打开就能看到一张完整图,拖一拖就能继续调整。
这个流程相当于把 AI 的能力限制在“理解关系和生成结构”上,而把需要确定性的活交给算法。实践证明,这样分工的成功率远高于“一步到位”的做法。
2.3 模块划分与数据流
项目代码按职责划分成六个模块,各自只做一件事:
提示词管理模块负责拼 prompt,内部维护几个版本的模板,可以针对文本输入、代码输入、多轮修正输入分别调用不同的模板片段。解析模块负责把模型返回的字符串变成 Python 对象,里面有容错逻辑,比如自动补全缺失的结束标签、剔除多余的解释文本。
实体标准化模块负责统一命名和 id。模型有时候会把“User Service”和“用户服务”当成两个不同实体,这个模块负责做大小写归一、同义词合并,确保同一个真实系统只有一个节点。布局引擎模块接收标准化后的实体关系,输出坐标和尺寸。它的算法不需要多豪华,但必须稳定,不能因为输入顺序变化就让图整体乱跳。
渲染写回模块负责把内存模型映射成最终 XML 文本,包括节点坐标、样式颜色、分组层级。最后是自检回读模块,生成完毕之后重新解析一遍 XML,统计节点数、边数、孤立节点数量,低于阈值就自动重新生成一次。这一层守卫非常管用,它能拦截大量“看似生成了,实际结构是坏的”情况。
数据流是单向的:文本进,中间结构生成,清洗标准化,布局渲染,最后落盘。每一步的输出都是下一步的输入,所有中间格式都必须严格校验,这能避免错误在后端被放大。
3. 核心实现细节:从提示词到可编辑图
3.1 一个稳定的提示词模板
先说结论:提示词不要写太多花哨的废话,重点是把“信息格式”讲清楚。我踩过一个大坑,最初让模型“发挥创意”,结果十个结果十个结构都不带重样的。后来我把提示词收敛成固定模板,效果立刻稳定。
我现在用的是类似下面这种结构:
你是一名系统架构信息抽取器。 请把用户输入转换为可编辑架构图的中间描述。 中间描述格式要求: <diagram> <group id="g1" label="接入层"/> <node id="n1" label="用户端" group="g1" type="client"/> <node id="n2" label="网关服务" group="g1" type="service"/> <storage id="n3" label="用户数据库" type="database"/> <edge from="n1" to="n2" label="HTTPS"/> <edge from="n2" to="n3" label="JDBC"/> </diagram> 约束: 1. node、storage、group 的 id 必须全局唯一。 2. edge 只能引用已经存在的 id。 3. type 只能取 client、service、storage、message、external 五种。 4. 不要输出任何解释文字,只输出 XML。这个模板把“游戏规则”先定死,让模型只做填鸭式抽取。看起来没什么技术含量,但非常有效。type 限定为五种是有意为之,因为后端的颜色和形状就是按这五种类型去映射的,类型越少,系统越好维护。
关于输入部分,我会在模板后面动态追加用户原始描述,同时提醒模型“不要给用户描述里没提到的东西编造依赖关系”。这个提醒很关键,没有它,模型就会脑补出一堆现实中并不存在的中间件,图一多简直没法看。
3.2 解析和清洗:模型输出不是金标准
模型返回的文本再干净,也必须经过一道容错清洗。最常见的状况是它把 XML 放在 markdown 代码块里,有些还带 ```xml 标签,有些则直接裸输出。我的第一步是用正则把代码块里的内容抽出来。
import re def extract_xml(raw: str) -> str: # 先尝试匹配带 ``` 标记的代码块 pattern = r"```(?:xml|XML)?\s*(.*?)```" match = re.search(pattern, raw, re.S) if match: return match.group(1).strip() # 没有代码块时,尝试直接找 <diagram> 开头的内容 match = re.search(r"<diagram>[\s\S]*</diagram>", raw, re.S) if match: return match.group(0) return raw拿到 XML 字符串之后,再用标准库的 ElementTree 解析成内存树。解析这一步经常挂掉,原因也确实有意思,模型会输出类似<diagram>标签没闭合、属性值没加引号、边引用了不存在的 id。所以解析后必须立刻做一轮结构校验。
我通常在解析后打一份“结构摘要”,比如打印出总共有几个节点、几条边、哪些边引用了未知 id。这些信息能明确告诉我,这次生成的图是否具备继续处理的基础。如果发现未知 id,就直接把这条边丢弃,因为布局引擎没法处理悬空引用。
清洗还有一个容易被忽略的点:必须对节点 label 做 XML 转义。如果用户输入文本里包含<或&,直接拼进 XML 会导致解析失败。用 Python 的xml.sax.saxutils.escape把特殊字符转掉,哪怕生成结果里看不到区别,文件本身是稳定的。
3.3 布局方案:别依赖大模型给的坐标
最初我觉得大模型既然能把关系和层级抽出来,那么顺带给它几个绝对坐标也是顺手的事。实验做下来,我发现模型对坐标的直觉极差,经常输出“节点A在 x=80,节点B在 x=820,但它们还有同一层编号”这种自相矛盾的数据。而且即使坐标基本合理,只要用户加一个节点,整体布局就彻底乱掉。
所以我做了个重要决定:模型输出里关于坐标的信息全部忽略,只保留节点、边、分组、类型、label。真正的位置计算完全交给代码里的分层布局引擎。
实现的思路不复杂:
- 根据边关系,建立一个“依赖层级”。没有入边的节点归为第 0 层,下游节点按
层级 = max(所有上游节点层级) + 1计算。 - 每一层的节点按顺序排列,同一层内横向均匀分布。
- 纵向间距固定,比如 80px。
- 横向间距按 label 长度估算,每个节点宽度至少 120px,防止文字挤压。
- 连线的路由交给绘图工具自带的自动路由,布局引擎只需要保证节点不互相重叠。
写出来的核心函数大致是这样:
def assign_level(edges, node_ids): depth = {nid: 0 for nid in node_ids} indeg = {nid: 0 for nid in node_ids} graph = {nid: [] for nid in node_ids} for src, dst in edges: graph[src].append(dst) indeg[dst] += 1 queue = [nid for nid in node_ids if indeg[nid] == 0] for nid in queue: for child in graph[nid]: depth[child] = max(depth[child], depth[nid] + 1) indeg[child] -= 1 if indeg[child] == 0: queue.append(child) return depth这就是一次拓扑排序的变体。遇到环状依赖时,拓扑排序会留下未处理节点,我的处理方式是给这类节点统一分配一个“循环依赖层”,避免脚本死循环。对于架构图来说,环不应该出现在业务依赖里,如果出现了,图反而会帮你暴露问题。
布局算法稳定之后,用户体验就非常可预期。无论跑多少次,同样的输入永远不会产生结构漂移不同的图,这对后续做 diff 和版本对比非常重要。
3.4 样式注入与实体标准化
大模型生成节点样式属性时非常随性,今天给“用户端”填红色,明天给“用户端”填蓝色,后天可能直接不填。样式不稳定会让人对工具产生极强的不信任感。
我的方案是把样式属性从模型输出里剥离,完全交给代码决定。项目里维护一个简单的类型到样式的映射表:
TYPE_STYLE = { "client": "rounded=1;whiteSpace=wrap;fillColor=#E3F2FD;strokeColor=#1976D2;", "service": "rounded=0;whiteSpace=wrap;fillColor=#E8F5E9;strokeColor=#2E7D32;", "database": "shape=cylinder3;whiteSpace=wrap;fillColor=#FFF8E1;strokeColor=#F57F17;", "message": "shape=message;whiteSpace=wrap;fillColor=#F3E5F5;strokeColor=#7B1FA2;", "external": "shape=cloud;whiteSpace=wrap;fillColor=#ECEFF1;strokeColor=#455A64;", }这样用户看到同一类型节点永远是同一套视觉表达,图面色彩整洁,也便于团队在评审时快速过滤信息。
实体标准化模块则是解决“同一个东西被当成三个节点”的问题。常见情况包括英文字母大小写差异,比如users-db和UsersDB;中英文混写,比如“订单服务”和“Order Service”;还有缩写差异。我的处理策略:先统一成小写去掉特殊符号,再把等价词映射列表交给固定函数,遇到匹配就合并同一个 id。
这个模块不能做太激进,否则容易把两个本来独立的服务合并掉。所以我的原则是只对“完全一致但写法不同”做合并,对“看起来像但不一定相同”保留为独立节点,把选择权留给用户。用户可以在图上看到结果,发现不对劲再手动合并,比工具自作聪明要安全得多。
4. 实操过程:搭一个能跑通的最小版本
4.1 环境准备与文件结构
整套最小版本不需要前端页面,也没必要引入复杂框架。我强烈建议第一步先做成命令行工具,跑通核心链路之后再考虑 Web 界面和交互。
准备一个普通 Python 环境,版本 3.10 以上就行,不需要额外依赖重型库。大模型 API 调用我用标准库里的 urllib 实现,避免引入一堆依赖。文件结构安排如下:
next-draw-project/ ├─ prompts/ │ └─ diagram_prompt.txt ├─ src/ │ ├─ llm_client.py │ ├─ parser.py │ ├─ layout.py │ ├─ writer.py │ └─ cli.py └─ examples/ └─ demo_input.txt如果你的机器联网不方便,也可以用本地模型,只要保持输入输出格式不变,后端是谁其实不关键。架构上把 llm_client 做薄,就保留了随时切换后端的能力。
4.2 核心代码骨架
先看调用大模型的部分。假设你有一个兼容通用消息格式的接口,代码大概这样:
import json from urllib.request import Request, urlopen def call_llm(prompt: str, endpoint: str, token: str) -> str: payload = json.dumps({ "messages": [{"role": "user", "content": prompt}], "temperature": 0.2, "max_tokens": 4000, }).encode("utf-8") req = Request(endpoint, data=payload, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {token}", }) with urlopen(req, timeout=120) as resp: data = json.loads(resp.read().decode("utf-8")) return data["choices"][0]["message"]["content"]接着是 pompt 读取和主流程:
def build_prompt(user_input: str) -> str: template = open("prompts/diagram_prompt.txt", encoding="utf-8").read() return template + "\n\n用户输入:\n" + user_input def main(): user_input = open("examples/demo_input.txt", encoding="utf-8").read() prompt = build_prompt(user_input) raw = call_llm(prompt, ENDPOINT, TOKEN) xml_text = extract_xml(raw) model = parse_middle_xml(xml_text) model = normalize_entities(model) xmlish = write_editable_diagram(model) with open("output.xml", "w", encoding="utf-8") as f: f.write(xmlish) summary = validate_diagram(xmlish) print(summary)用这样一个简单的 90 分脚本,已经能走完“文本进,XML 出”的完整链路。我建议第一次跑的时候尽量使用短小、边界清晰的输入,比如“A 服务调用 B 服务,B 服务连接数据库”,先验证链路通顺,再逐步增加复杂度。
4.3 接入文档和版本库
命令行版本跑通之后,下一步一定是把它接进团队文档流程。
因为输出的是纯文本 XML,所以可以直接扔进 git。每次有人调整架构描述并重新生成, diff 里能清楚看到哪个节点变了、哪条边断了。这个体验是位图完全给不了的。在此基础上,我在自己的项目里加了一个小脚本,读取 XML 后输出一行摘要:
nodes=12, edges=15, groups=3, unlinked_nodes=0这个摘要可以作为 CI 检查的一部分,比如节点数不小于阈值、不存在孤立节点。一旦有人手工删改 XML 导致依赖断裂,检查立刻报警。架构图和代码库慢慢形成互相约束的关系,图不再是摆设。
可视化打开的问题,可以先由第三方工具的无损导入解决。中间结构 XML 经过一层映射之后写入绘图工具兼容格式,工具内部有自动布局能力,打开时再执行一次“整理布局”就能得到非常干净的图。我的经验是不要让项目试图覆盖所有画布交互,那个工程量不适合个人项目第一阶段去做。
5. 常见问题与排查手册
5.1 XML 解析失败
这是我在接入各种大模型后遇到概率最高的错误。典型表现是脚本报错syntax error: line 3, column 0,看一眼原始输出,发现模型把解释文字加在 XML 前面,或者少了个闭合标签。
处理优先级按下面这个表来:
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 一直解析失败 | 模型输出被 markdown 包裹 | 先用正则剥离代码块 |
| 标签缺闭合 | 上下文过长导致模型截断 | 降低 max_tokens 或拆分输入 |
| 属性没加引号 | 模型语法能力波动 | 手动用正则修正,让属性值统一被引号包裹 |
<被当标签解释 | 输入文本包含特殊字符 | 对 label 做 XML 转义 |
| 根节点多了一个 | 模板要求只输出一个 diagram | 截取第一个完整diagram块 |
碰到解析失败,不要急着改模板,先打印一份原始返回内容的前 500 个字符看看结构。很多时候问题出在输入文本带了很多代码片段,导致模型输出被截断,而不是提示词写得不够好。
5.2 节点重叠和布局偏移
所有第一次接出来的图,都会出现“节点全部叠在左上角”或者“边穿过五六个节点”这种状况。原因很直接:你用了模型返回的坐标,而模型根本不理解布局空间。
解决办法只有一个:不要续用模型坐标,改成重启布局引擎。我每次换输入都强制刷新坐标,甚至故意在 prompt 里不提供坐标信息,就是防止模型“顺手”填出几个坐标把我误导了。
如果布局引擎跑完依然重叠,检查分隔距离是否太小。我在实现里把横向间距设为 40px,纵向间距设为 80px,节点高度固定 40px,覆盖大多数情况。遇到图特别大,间距可以调成按节点数量比例缩放。
5.3 实体重复和关系丢失
“用户服务,USER-SERVICE,后端用户中心”这三个名字同时出现在生成的图里,是最让人崩溃的场景。我第一次碰到以为是模型抽风,后来才知道是没做标准化。
标准化模块上线之后,重复率下降得很明显。我试过最极端的情况,输入一段包含 500 行日志描述的资料,模型把同一个数据库重复建了四个节点。归一化处理配合边引用清理,最后保留一个节点,所有边都指向那一个 id。
关系丢失的问题更多出现在边长过长的时候。模型在生成最后几条边时,突然忘记了前面定义的节点 id,于是引用了一个不存在的 id。这种边会被校验模块直接丢弃,从结果上看就是“少了一条线”。我的处理方法是让提示词在结尾重复提醒“所有 id 必须是前面出现过的 id”,同时关闭自回归模型无关紧要的想象力,效果立竿见影。
5.4 生成慢和结果不稳定
调用大模型生成 20 个节点的图,一次可能要二十秒以上,用户体验很差。我的优化思路不是改模型参数,而是控制输出范围。
在提示词模板里加一条“节点数量不超过 25 个”,让模型在信息抽取阶段做取舍。超过规模的输入,先让模型做分层摘要,再分块生成局部图,最后再用布局引擎把几块图合并到一起。这个方法牺牲了一定的“全自动”,但稳定性和可读性都回来了。
不稳定问题还有一个隐藏推手:模型内置的随机性。虽然我用 temperature=0.2,但不同时间运行同一输入仍然可能得到细微差别。后来我在注意力机制之外,显式要求模型“按输入顺序输出节点”,把单体节点与它们的边绑定在相邻位置。实测下,结果几乎不再跳变。
6. 一些实测体会和后续扩展方向
6.1 我反复测试后的几个结论
做了这个项目之后,我对“AI 生成架构图”这件事有两个很明确的判断。
第一,大模型擅长提取关系,不擅长做空间设计。让它罗列系统有哪些模块、模块之间怎么调用,它能做得又快又好;但让它协调几十个节点的坐标,它瞬间退化成随机数生成器。因此架构上必须把“关系提取”和“空间布局”彻底分离,谁擅长什么就干什么。
第二,后处理永远比调 prompt 更可靠。你可以把提示词写到完美,但模型是非确定性的,下一次输出可能就任性;而解析、校验、标准化这些代码是确定性的,一旦写好就稳定。我的成功率从最开始三成不到,到现在基本九成以上,真正的转折点是写了一个严格的后处理校验链,而不是花了更多时间打磨 prompt。
多轮迭代也很值得做。第一版生成的结构即使有遗漏,第二步让模型“检测当前图和输入的一致性”,大概率能把缺掉的边补回来。该环节我建议最多做两轮,再多会给模型引入幻觉,让它生成不存在的依赖。
6.2 接下来可以做的新功能
当前版本只是个骨架,我下一步想优先做几个方向。
第一个方向是把代码仓库直接喂进去。解析源码里的类、函数、服务注册信息,自动生成服务依赖图,这比让用户写自然语言描述要精确得多。关键难点从文本抽取变成了语法解析,但底层工作流完全不用改。
第二个方向是局部编辑的对话式体验。现在生成完只能手动改 XML,不够友好。如果用户说“把订单服务的颜色改掉”“把缓存节点放到数据库旁边”,模型能在保留大部分结构不变的前提下做局部 patch,这个工具就真的很像团队里的架构师助理了。
第三个方向是架构演进对比。既然图是文本、能入库、能 diff,那就能自动对比前后两版架构图,标出新增模块、失效依赖、不稳定节点。这事对大型系统评审太有用了,我后续会优先投入。
最后再分享一个我在实际使用中发现的小技巧:永远在生成结果里保留一个“未知依赖”虚拟节点。当模型拿到一条没有来源的边时,不要直接丢弃,而是挂到一个虚拟节点上,提醒用户这里可能漏了系统。这样你不会在批量生成时因为丢边而悄悄损失语义,架构评审时还能“被迫”解释清楚。这个小改动,值得任何做 AI 绘图工具的同学抄走。