作为一个每天要和LLM、向量库、Agent编排打交道的AI应用架构师我最怕的不是设计架构而是画架构图。方案评审要画、开发文档要画、新人培训要画图本身不难画难的是它永远跟不上代码变化。后来我把架构图自动生成这件事拆成了一个自动化转换工具流水线让架构图从手工活变成了工程产物——代码一改图自动更新。这套方案折腾了大半年中间踩了不少坑今天把它完整整理出来希望能让同岗位的你少走点弯路。1. 需求拆解与整体思路画图给谁看、要解决什么问题1.1 架构师画图的真实痛点先说痛点。AI应用架构和传统业务系统不太一样它除了常规的服务化拆分还有模型层、知识库、Agent编排、向量检索这些动态组件架构变更是家常便饭。今天用OpenAI的GPT-4o明天换成开源模型今天用ChromaDB明天想换成pgvectorAgent工具链更是隔三岔五加节点。这些变更如果靠手工同步到架构图基本是画图一小时、改图一上午的状态。手工画图最大的问题不是“画得慢”而是“图一定过期”。架构师通常在最开始精心画一张漂亮的架构图三周之后就再也不看了因为图和代码已经对不上。代码里明明白白有三个编排服务、五个模型网关图上还是两年前的单体结构。这种图放在文档里轻则误导新人重则让方案评审拿不到真实的技术决策依据。更麻烦的是不同读者需要的架构图根本不是同一张。给老板看的部署拓扑关注的是服务节点、中间件实例、资源链路给开发同学看的系统模块图关注的是模块依赖、数据流、接口边界给自己看的演进蓝图关注的是技术债、循环依赖、可拆分点。一张手工图很难同时满足三类诉求通常画了也是妥协的产物。所以这件事的本质不是“画图技巧”而是如何让架构信息始终与代码同步让不同视图可以随时按需生成。想通了这一点才走到自动化这条路。1.2 自动化转换工具的解题思路我的核心思路只有一句话架构图是代码的编译产物不是独立维护的文档。架构信息本来就散落在代码、配置文件、依赖声明和接口定义里我们要做的不是人工把它们摘抄成图而是写一个自动化转换工具把这些结构化信息抽取出来再按固定的渲染规则生成架构图。这个工具流水线长这样工程扫描器读取路由、模型、依赖、配置→ 统一中间描述JSON→ 多路渲染器Graphviz / Mermaid→ 产物SVG / PNG / Markdown你可以把它理解成一个编译过程源代码就是那些本来就存在的东西——FastAPI路由、Pydantic模型、Redis连接、向量库配置中间描述就是类似AST的产物一张存有节点、边、层级的JSON结构渲染器就是“代码生成器”把JSON转换成不同格式的图。这样做的好处是架构信息只需要在扫描阶段维护一次之后的布局、格式、展示逻辑都可以各自独立调整。我对比过商业建模工具比如企业中常用的可视化建模平台它们做得更重能在线协同、画图后反向生成代码。但它们并没有解决最核心的“图与代码一致”问题因为录入信息还是靠人。自动化转换工具的价值恰恰在这里源不是人脑是工程本身。这个差异决定了它能持续保鲜而且不依赖某个画图的人还在不在团队里。对比维度手工画图自动化转换工具生成时效性代码改完要重新画容易过期每次构建自动更新永远追代码准确性依赖人的记忆和主观取舍从代码事实抽取可追踪到具体文件行可回溯性没有来源图错了不知道错在哪每个节点、每条边都能反查代码位置工作量初始画一遍之后反复维护一次性搭建设计器之后零手工多视图支持每种视图画一遍同一份数据渲染成不同风格2. 核心细节解析与实操要点如何把工程变成结构化描述2.1 架构信息从哪里来设计这个工具第一个要回答的问题是架构信息到底藏在工程的哪些地方我根据AI应用的特点把来源分成四类。第一类接口路由。FastAPI、Flask这类框架的装饰器上路由、方法、请求参数、返回类型都是现成的。FastAPI的app.get(/chat, response_modelChatResponse)这一行就足以告诉扫描器“这里有一个对外暴露的HTTP接口它属于chat服务输入输出模型是什么”。比读设计文档可靠多了。第二类数据模型。Pydantic模型、SQLAlchemy模型、Django ORM类字段名、字段类型、嵌套关系、继承关系全部定义在类里。这些模型能还原出系统的数据结构也是大多数边界之间的流转载体。AI应用尤其看重这块因为向量库里的Collection、知识库里的Chunk结构、对话上下文的Message模型都直接影响架构关系。第三类外部依赖。代码里调用了什么外部服务虽然import语句并不能完全代表运行时依赖但结合调用点上下文就很有说服力。比如某个服务模块里出现了openai.OpenAI()那这条依赖关系就跑不掉某个数据层模块里出现了chromadb_client.get_collection()那它一定挂着一个向量库。第四类中间件和可观测配置。Redis、RabbitMQ、对象存储、日志采集、链路追踪这些通常不在代码调用链路里直接体现但会在配置文件、环境变量声明、docker-compose.yml或者部署清单里出现。扫描器需要把这些也纳进来否则架构图缺了“底座”看起来就像悬空的应用。这里有一个很重要的认知架构信息几乎全部已经存在于工程里了。我们要做的事情是“解读”而不是“重新输入”。一旦你理解了这一点就不会再觉得自动生成架构图很玄学它本质上就是写一个针对你自己工程规范的解析器。2.2 中间描述怎么设计所有扫描结果最后都汇到一张统一的JSON里这叫中间描述。它的结构我调了好几版最终稳定成下面这种形态{ project: ai-support-platform, generated_at: 2025-01-12T10:30:00Z, layers: [gateway, orchestration, model, data, observability], nodes: [ { id: module.chat.service, name: Chat Orchestrator, layer: orchestration, source: app/chat/service.py:15, meta: {type: fastapi-router, docs: /v1/chat} }, { id: model.llm.gateway, name: LLM Gateway, layer: model, source: infra/llm/gateway.py:8, meta: {type: http-client, targets: [gpt-4o, embedding-3]} } ], edges: [ { from: module.chat.service, to: model.llm.gateway, label: invoke, source: app/chat/service.py:42 }, { from: module.chat.service, to: data.vector.chroma, label: retrieval, source: app/chat/service.py:77 } ] }几个设计原则我想单独强调一下这是后来决定整个工具好不好用的关键。节点ID要稳定。我不用显示标题当ID而是用模块路径或服务名比如module.chat.service。因为标题可以改但模块相对稳定。如果ID经常变架构图的diff看起来就像“全变了”这对后续做变更检测非常不利。边必须带语义。只有“A连B”是不够的得说明是invoke还是retrieval还是event。否则这张图只能看耦合程度看不出数据流转方向对方案评审没价值。层级独立于节点存在。层的定义和节点解耦这样同一个节点在不同视图里可以归到不同层。比如网关在“部署拓扑视图”里属于gateway在“代码模块视图”里可能直接被忽略。至于为什么选JSON而不是YAML主要图省事JSON序列化零依赖、所有语言都原生支持、可以直接作为HTTP接口的响应体。你要是习惯YAML也无所谓中间描述格式主要是给自己用的顺手才重要。2.3 渲染器选型逻辑中间描述有了接下来是渲染器。我同时维护两套渲染器分别输出Graphviz和Mermaid用途完全不同。Graphviz用DOT语言描述图布局算法成熟节点一多排版也不容易乱适合生成复杂的系统全景图输出SVG之后还能做到交互式浏览。Mermaid则强在轻量语法可以直接嵌到Markdown文档和GitHub中适合接口文档、wiki、评审纪要里即时展示团队里不懂工具的同事也能看明白。PlantUML我也评估过如果你所在团队重流程建模它的时序图和用例图更顺手。但就“从代码自动生成架构图”这件事来说Graphviz和Mermaid的组合已经覆盖了我九成的场景全景图用Graphviz出PNG/SVG局部流程用Mermaid写进Markdown。3. 实操过程与核心环节实现从代码到架构图的全流程3.1 第一步扫描接口路由扫描接口路由我用的是Python标准库ast直接解析源码生成抽象语法树而不是用正则表达式硬抠。正则的脆弱之处在于没法处理装饰器里的复杂表达式也没法准确对应到函数定义的位置。AST方案可以精确拿到“哪个函数挂在哪个路由上”“响应模型是什么”“代码在第几行”。下面这段代码是我在实际项目里用的一个简化版本逻辑是遍历模块里所有FastAPI路由装饰器import ast import pathlib from typing import List ROUTE_DECORATORS {get, post, put, delete, patch} def find_route_from_decorator(decorator: ast.Call) - str: path for kw in decorator.keywords: if kw.arg path: path ast.literal_eval(kw.value) if not path and decorator.args: path ast.literal_eval(decorator.args[0]) return path or def walk_api_routes(filepath: pathlib.Path): source filepath.read_text(encodingutf-8) tree ast.parse(source) routes [] for node in ast.walk(tree): if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): continue for deco in node.decorator_list: if not (isinstance(deco, ast.Call) and isinstance(deco.func, ast.Attribute)): continue method deco.func.attr if method not in ROUTE_DECORATORS: continue route_path find_route_from_decorator(deco) routes.append({ path: route_path, method: method.upper(), handler: node.name, line: node.lineno, file: str(filepath), }) return routes if __name__ __main__: for fp in pathlib.Path(app).rglob(*.py): found walk_api_routes(fp) if found: for item in found: print(item)跑完之后你会得到一张接口清单但这还不够紧接着要把这些接口编组到对应的业务模块。我通常用路由前两段来归组比如/chat/就归到chat service/knowledge/就归到knowledge service。归组逻辑就是架构图上“服务节点”的切分粒度这步如果按目录来切会更稳因为代码目录结构比路由路径更接近模块边界。这里的坑在于动态路由的通配符。FastAPI支持/items/{item_id}这种路径参数花括号在Mermaid的节点标签里有特殊含义直接放进去会导致图渲染失败或语法错乱。我统一做了转义把{替换成[[渲染完再替换回来。还有一类是APIRouter的prefix比如router APIRouter(prefix/v1/chat)如果装饰器里的路径是相对路径扫描时必须把prefix拼上不然架构图上的接口路径是残缺的。3.2 第二步数据模型实体化AI应用里数据模型往往比传统业务更杂Pydantic模型定义请求响应、SQLAlchemy模型管理业务表、向量库的Collection结构甚至可能在代码里直接声明。我统一把它们扫描成“实体节点”并把实体之间、实体和接口之间的关系边建好。这一步的代码逻辑是逐个解析模型类拿到字段名和类型import ast import pathlib from typing import Dict, List def extract_model_fields(source: str) - List[Dict[str, str]]: tree ast.parse(source) models [] for node in tree.body: if isinstance(node, ast.ClassDef): for base in node.bases: if isinstance(base, ast.Name) and base.id in (BaseModel, Base): fields [] for stmt in node.body: if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name): field_name stmt.target.id field_type ast.unparse(stmt.annotation) if stmt.annotation else Any fields.append({name: field_name, type: field_type}) models.append({ name: node.name, fields: fields, line: node.lineno, }) return models拿到模型字段后还需要做两件事识别继承关系和关联关系。Pydantic模型之间常用继承比如ChatRequest(BaseModel)、ChatRequestWithHistory(ChatRequest)这种继承在架构图上体现为实体之间的extends边帮助看数据的复用结构。关联关系则靠字段类型来推断比如某个字段类型是KnowledgeItem那ChatContext和KnowledgeItem之间就能画一条引用边。接口和模型之间的边是更有价值的路由扫描时拿到了response_modelChatResponse那就在chat service节点和ChatResponse实体节点之间画一条produces边。这样架构图就不再只是“服务连服务”而是能反映出接口契约和数据结构之间的真实流转。这块千万别偷懒。如果架构图里没有数据实体只画服务的方块方案评审时你会被追问“这个接口改了什么结构影响哪个实体”然后你又得去翻代码。把实体变成一等公民图的信息量立刻不一样。3.3 第三步外部依赖扫描外部依赖扫描是四种来源里最容易过度设计的。很多人会直接把所有import都列成节点结果架构图变成一张巨大的“包依赖图”没意义。我的做法是维护一个白名单只关注Runtime层的关键外部服务。EXTERNAL_SERVICES { openai: (model, LLM Gateway), chromadb: (data, Vector DB), redis: (data, Cache), azure.storage: (data, Object Storage), pulsar: (messaging, Event Bus), prometheus_client: (observability, Metrics), sentry_sdk: (observability, Error Tracking), } import ast import pathlib def scan_external_dependencies(filepath: pathlib.Path): source filepath.read_text(encodingutf-8) tree ast.parse(source) edges [] for node in ast.walk(tree): if isinstance(node, ast.ImportFrom) and node.module: for alias in node.names: mod_name f{node.module}.{alias.name} if alias.name ! * else node.module for key, (layer, _) in EXTERNAL_SERVICES.items(): if key in mod_name: edges.append({ source: str(filepath), target: key, layer: layer, line: node.lineno, }) elif isinstance(node, ast.Import): for alias in node.names: for key, (layer, _) in EXTERNAL_SERVICES.items(): if key in alias.name: edges.append({ source: str(filepath), target: key, layer: layer, line: node.lineno, }) return edges但这个逻辑有个明显缺陷模块顶部import了一个SDK不代表当前文件真的发起了外部调用。只靠import推断会画出很多“假依赖”。我后来调整成双通道校验先收集import信息再在AST里找实际的调用点——比如看到openai.ChatCompletion.create(或redis.get(这种调用才最终确认这条边存在。redis这种客户端在构造函数里被注入的类里出现稍微难一点但也能通过扫描类方法里的调用表达式识别出来。这会影响到架构图的准确性挺值得花时间的。因为外部依赖是判断“这个服务跟哪些基础设施绑定”的关键依据评审时最常被挑战的就是这个位置。3.4 第四步生成架构图文件中间描述汇总好之后渲染这一步就是纯工程活了。Graphviz的DOT渲染我用的是官方的graphviz库Mermaid则直接输出文本。我把关键渲染代码贴出来import graphviz import json def load_arch_description(path: str) - dict: with open(path, r, encodingutf-8) as f: return json.load(f) def render_graphviz(arch: dict) - graphviz.Digraph: dot graphviz.Digraph( arch, commentarch[project], formatsvg, graph_attr{rankdir: LR, fontname: Noto Sans CJK SC, bgcolor: white}, node_attr{shape: box, style: rounded,filled, fontname: Noto Sans CJK SC}, edge_attr{fontname: Noto Sans CJK SC, fontsize: 9}, ) layer_colors { gateway: #fef9e7, orchestration: #eef2fb, model: #f5eef8, data: #eafaf1, observability: #fdf2e9, } # 按层级建子图 with dot.subgraph(namecluster_gateway) as c: c.attr(labelGateway, styledashed) for node in arch[nodes]: layer node[layer] fillcolor layer_colors.get(layer, #ffffff) dot.node(node[id], labelnode[name], fillcolorfillcolor, clustercluster_ layer) for edge in arch[edges]: dot.edge(edge[from], edge[to], labeledge[label]) return dot def render_mermaid(arch: dict) - str: lines [mermaid, graph LR] for layer in arch[layers]: lines.append(f subgraph {layer}) lines.append(f style {layer} fill:transparent) for node in arch[nodes]: safe_id node[id].replace({, [).replace(}, ]) lines.append(f {safe_id}[{node[name]}]) for layer in arch[layers]: lines.append( end) for edge in arch[edges]: lines.append(f {edge[from]} -- |{edge[label]}| {edge[to]}) lines.append() return \n.join(lines)输出物我建议同时落三种SVG给网页、PNG给文档/PPT、Mermaid文本给Markdown。Graphviz的SVG格式特别好用可以嵌入HTML里做成可缩放的交互图。PNG的分辨率要设置得高一些架构评审投屏之后细节还能看清。rankdirLR是我反复调整后确定的方向。AI应用架构的链路多半是“用户请求→编排→模型→数据”这种左到右的流水线LR布局能顺着阅读顺序展开比从上到下的TB布局更贴合直觉。子图cluster的用法也很关键不同层如果不用cluster包起来Graphviz会把同层的节点拆散到各处图会乱得没法看。3.5 第五步接入CI/CD自动更新架构图生成脚本搭好之后如果还是靠人手动跑那等于没做自动化用不了几天就回到手工维护的老路。真正让它持续有效的是接入CI/CD每次代码合并到主干分支后自动重跑。我用的流水线是GitHub Actions结合内部文档站发布核心步骤是name: generate-architecture-diagram on: push: branches: [main] jobs: generate: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-pythonv5 with: python-version: 3.11 - run: pip install graphviz pydantic ast - run: python scripts/generate_arch.py --out build/arch.json - run: python scripts/render_diagrams.py --input build/arch.json - name: Publish to wiki run: | # 复制生成的架构图到站点目录并触发发布这里有个细节很容易忽略如果生成结果每次commit都变GitHub Actions会把更新循环跑起来。架构图的布局算法可能存在微小抖动SVG内容的hash几乎不会稳定。我一开始没处理导致CI反复自动提交“架构图更新”非常痛苦。解决办法是分两步判断先对比arch.json中间描述的内容内容没变就不提交内容变了才提交图。中间描述是结构化数据对比稳定可靠图片文件只作为编译产物存在。这样架构图更新频率就和代码变更频率完全一致了。4. 常见问题与排查技巧实录4.1 架构图变成“蜘蛛网”节点太多怎么收敛第一个版本跑出来之后我非常激动地点开SVG然后整个人沉默了——图乱得像一盘意大利面几百个节点和边纠缠在一起根本没法看。问题出在我过于诚实地把所有的依赖关系都画了进去。架构图不是代码地图它需要“抽象”和“裁剪”。我总结出三个收敛手段。按层聚合。把“层”的粒度做成可配置可以把所有编排层的节点折叠成一个orchestration节点把数据层的所有实例折叠成一个数据枢纽。折叠和展开的开关留在JSON描述里需要看细节时再展开。设置热度阈值。统计每条边的调用次数或依赖次数只展示超过阈值的核心路径。那些只在一个角落有单一依赖的边直接忽略。这个阈值很灵通常设为“只展示至少被两个节点依赖的边”就能去掉一大半噪音。按读者裁剪。给老板看的部署拓扑图就不放代码模块细节只放服务、中间件和链路给开发看的模块图就不放资源规格只放接口和依赖。一份中间描述多套裁剪规则渲染器不变。架构图一旦能做收敛它才真正从“垃圾堆图”变成“架构视图”评审时大家才不会盯着密密麻麻的线条发呆。4.2 中文字体显示成方块Graphviz和Mermaid在不同系统上默认字体不一样中文字体缺失或配置不对时导出图里的汉字全部变成豆腐块。这个问题在Linux CI环境特别明显因为CI机器往往不装中文字体。处理方式是给Graphviz显式指定字体比如fontnameNoto Sans CJK SCCI环境里提前安装sudo apt-get install fonts-noto-cjk本机开发如果字体名不识别可以先fc-list | grep -i CJK查看系统可用的字体名然后把代码里的fontname改成对应值。Mermaid的问题主要在渲染端GitHub内置的Mermaid渲染器对中文其实没问题但有些旧版markdown渲染器的主题字体不支持中文。遇到这种情况要么升级渲染器要么在生成Mermaid文本时给主要节点加::: chinese-font的class定义。经验之谈中文字体问题看起来很小但一旦出现整个输出的可用性直接归零。4.3 图每次生成都“跳一下”布局不稳定用Graphviz的neato布局时每次运行生成的布局都会有细微差别节点位置不完全一致。如果架构图更新还用commit对比就会造成没实质内容变化却频繁提交。更麻烦的是评审会议上你展示上周的图和今天的图位置不一样会有人误以为架构又变了。我后面统一用dot布局引擎并且固定全局参数remincross和seed保证同一份输入产出同一份输出。顺便提一句如果要比较两次架构是否变化永远不要比较图片的像素hash一定要比较中间描述JSON的结构否则会陷入无休止的“图变了”误判。4.4 AST解析漏掉动态路由和装饰器别名AST方案很好但也不是万能的。它解析不到运行时动态生成的路由比如router.add_api_route(/chat, handler, methods[POST])这行代码在AST里只是一个普通函数调用我的扫描器最初完全无视它。还有的人习惯给装饰器起别名from fastapi import APIRouter后可能写成route.get(...)我的代码硬编码了router.get这些情况都会漏。解决办法我用了三个组合技AST配置补充。针对add_api_route这类写法单独写一个小解析分支。OpenAPI兜底。FastAPI框架天然自带OpenAPI schema开发环境起一个测试实例直接请求/openapi.json拿到运行时最准确的接口清单回填到扫描结果里。测试断言。架构生成器本身集成了断言逻辑如果扫描到的路由数量和openapi.json不一致测试直接失败提醒你补解析规则。4.5 问题速查表现象可能原因快速排查与修正架构图里的接口路径带{id}就渲染失败动态路由通配符与Mermaid/DOT语法冲突生成前把花括号转成[[或统一用id占位外部依赖多画出一堆不存在的边import了SDK但实际未调用在AST里检索调用点双通道校验CI反复提交“架构图更新”布局算法微小抖动或图片hash对比对比arch.json结构的语义差异图片只做产物图形节点中文变方块系统缺少CJK字体或fontname不对安装CJK字体显式指定fontname图中缺少部分接口动态路由、装饰器别名扫描不到增加AST分支或从openapi.json兜底全景图太乱节点和边没有按层聚合没有裁剪增加聚合开关、热度阈值、按视图裁剪Graphviz生成的图每次位置都变用了布局算法或缺少固定seed统一dot引擎固定remincross和seed5. 延伸架构图反哺架构治理5.1 把生成器变成架构守门员跑通自动生成之后我最大的意外收获是架构图开始反哺架构治理了。它不再只是一个“给人看的产物”而是变成了一套可执行的架构校验规则。原来代码评审时检查“这个服务是不是依赖反了”基本靠人肉现在生成器跑一遍就能直接报错。比如分层规则gateway层不能直接调data层编排层不能反向依赖模型网关的具体实现只能通过统一接口访问。扫描出的架构图如果出现反向依赖边生成的arch_violations.json就会记录违例如下CI直接转红。还可以做循环依赖检测。当A - B - C - A这种循环出现时架构图上会画出一个环肉眼不一定能快速发现但代码里用DFS拓扑检测很快。架构膨胀也能被量化某个服务的节点数量超过阈值说明它可能该拆分了某个模型层的入边数异常高说明模型网关已经沦为“上帝对象”风险正好被暴露在图上。这件事让架构图从“设计汇报工具”变成了“架构测试工具”和单测、lint同级。5.2 我还在继续做的两件事目前我在这套工具上还在做两个扩展方向。第一个是架构变更diff可视化。现在的架构图只是“最终态”我还想自动生成两次commit之间的架构变更diff图新增了哪些节点、哪些边被删除、哪些依赖方向反转了。这个结果直接贴在合并请求的评论里审查者扫一眼就知道这次改动对架构面的影响。第二个是图与接口文档联动。既然中间描述里有路由和模型的完整信息为什么不直接生成一份轻量级的API文档和图放在一起每个节点点击后都能跳到对应接口定义和模型字段。对AI应用来说这几乎等于自动生成了一份“架构API”联合文档新人上手成本会低很多。坦白讲这一路做下来最深的体会倒不是省了多少画图时间而是觉得架构师这个岗位真正该抓住的核心不是某张漂亮的图而是能把架构信息以结构化方式维护起来的能力。自动化转换工具把画图变成了工程任务架构图自动生成也就成了顺理成章的自然产物。如果你也准备搭这套东西我的建议就一个先别急着找什么“一键AI画图”的按钮老老实实先把工程里的结构化信息定义清楚这些源数据稳了工具才能画出真正有用的图。