ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

用AST解析Markdown,自动生成AI配图清单的工程实践

用AST解析Markdown,自动生成AI配图清单的工程实践 每次写完一篇长 Markdown我心里最抵触的不是写作而是配图。内容越往后期越凌乱图的位置、尺寸、风格是不是匹配段落有没有和上下文表述冲突单靠眼睛一段段扫非常容易漏。后来我把事情反过来做——先不定格配图只生成一份“配图清单”。这份清单告诉我某个章节适合放什么图、图要表达什么重点、按什么提示词去生成、大概要花多少钱。于是就有了这套流水线用 AST 解析 Markdown 文档的结构用接口适配器屏蔽不同出图渠道的差异再用受控并发控制真实请求的节奏最终输出一份方便人确认的配图清单。这套思路很适合三类人长期输出技术博客的工程师、需要给文档和教程批量配图的同学以及想为内容生产搭一套“机器先提方案人来拍板”工作流的人。它不要求你一次性生成完美图片真正解决的是“哪里需要图、图要画什么、怎么稳定拿到图”这三个问题。1. 为什么非要用 AST而不是正则扫描刚开始我很自然地想用正则比如匹配标题^##、匹配段落之间的空行再从里面抠文字。试了半小时我就放弃了因为 Markdown 语法从来不是“正则一下就能干净分块”的东西。文档里最常见的情况会让正则直接翻车。1.1 正则处理不了哪些“脏场景”代码块里的## 标题、链接文本里的方括号、表格单元格里的一整行描述、列表里的嵌套区块都会污染正则的结果。比如代码示例中如果包含“markdown”片段正则可能把其中伪代码误判为文档的一级标题。另一个问题是行号定位一组正则匹配到的字符串位置和真正的“某个标题下面有哪些正文段落”不一定一一对应特别是同一行有多段标记时。AST 解决的是这个基础问题的“结构化”。用remark/mdast解析 Markdown 时得到的不是文本片段而是一棵节点树。节点类型是稳定的heading、paragraph、code、list、image清清楚楚。每个节点自带位置信息能从position.start.line拿到它对应的原文行号。我需要判断“某段文字在哪个二级标题下面”只需沿着语法树向上找父级标题节点不用靠空行数量去猜。1.2 遍历 Markdown 语法树并整理候选插图片段我在这个项目里用的是unified生态。核心代码并不复杂import { remark } from remark import { visit } from unist-util-visit const tree await remark().parse(source) const sections [] let currentH2 null visit(tree, (node) { if (node.type heading node.depth 2) { currentH2 node.children .map((child) child.value || ) .join() } if ( node.type paragraph || node.type code ) { const text node.type code ? node.lang ? 代码示例${node.lang} : 代码示例 : node.children.map((c) c.value || ).join( ) if (shouldSuggestImage(node, text)) { sections.push({ section: currentH2 || 未分组, nodeType: node.type, startLine: node.position.start.line, endLine: node.position.end.line, text: truncatePreview(text, 120) }) } } })这里的判断规则不能太宽。早期我把所有超过 100 字的段落都当成候选结果一份博客生成了三十多张候选图预算直接失控。后来沉淀出的标准是二级标题下面首个正文段落有较高配图优先级适合做开篇示意代码块超过 20 行的一般需要一张运行结果或架构示意来承接超过三百字的文字段落才值得配图短段落通常是过渡句直接跳过原文档已经包含image节点的区域默认不再重复推荐。1.3 保留与原文的对应关系是关键很多自动生成图片工具的弊病在于图片生成完了却不知道它该放回哪个位置。因此 AST 解析阶段就必须保留精确的“原文引用锚点”。我会给每个候选节点记录startLine、endLine后续不管是生成清单还是自动插入图片都能靠行号重新锁定。即便输出是用markdown表格写成的也能保证每一行都能映射回原文这一点比“凭感觉建议”重要得多。2. 接口适配器把不同图源当成可插拔的插件配图清单不是玄学猜图而是真实调用图片生成能力。但市面可用的渠道非常多远程生成 API、部署在本地显卡的扩散模型、甚至一些团队内部只返回 SVG 示意图的无头服务。如果业务代码里直接写死某个供应商的 SDK 和请求格式后面替换图源会异常痛苦。2.1 统一定义图片生成请求接口我想把一个完整的“图想要什么”传给下游这需要比单个提示词更丰富的结构。所以我定义了这样一个请求对象interface ImageGenerateRequest { docId: string nodeId: string title: string // 所属标题 summary: string // 从原文抽出的文字摘要/重点 prompt: string // 给生成模型的具体描述 style: string // 图表/扁平插画/摄影感 reference?: string[] // 可选参考图 options?: Recordstring, unknown } interface ImageGenerateResult { requestId: string url: string bytes?: Buffer provider: string cost: number width: number height: number }请求里必须携带docId和nodeId这算是和原始 Markdown 节点的“血缘绑定”。只要后端拿到了唯一 ID即使以后对文档重新排序也能靠这两个字段追溯图源。2.2 实现远程图源适配器远程生成接口往往是 HTTP 调用。适配层做一件事把内部统一的ImageGenerateRequest转成目标供应商的参数再把对方的返回结果转成统一的ImageGenerateResult。class RemoteImageAdapter implements ImageProviderAdapter { constructor(private readonly apiKey: string) {} async generate(req: ImageGenerateRequest): PromiseImageGenerateResult { const response await this.httpClient.post( this.endpoint, { model: this.modelName, prompt: this.buildPrompt(req), n: 1, response_format: url }, { headers: { Authorization: Bearer ${this.apiKey} }, timeout: 60000 } ) return { requestId: ${req.nodeId}-${Date.now()}, url: response.data.data[0].url, provider: this.name, cost: this.calculateCost(response) } } }这层封装最大的收益是可以同时接多个供应商做灰度。客户遇到远程 API 昂贵时可以切到推理成本更可控的本地模型供应商故障时可以按权重把流量切给备胎业务层浑然不觉。2.3 本地生成和“截图型”适配器本地推理和调用远程服务不一样不能每次都同步等结果。我在实践里用一个本地 adapter 包装起一个基于扩散模型的 CLI 工具把所有请求先落到本地队列由后台进程消费再把结果文件写入images目录。对需要快速预览的场景更合适的是“模板渲染器”适配器——把请求转成一段 HTML交给无头浏览器截图输出整洁的架构图或带色块的数据卡片。为什么要折腾这么多适配器因为真正生产时你会见到各种奇奇怪怪的限制有的供应商对图片分辨率有强约束有的只能用 base64 返回大 JSON、浪费网络流量有的只支持异步回调。把差异都关在 adapter 里主流程反而变得很简单收集请求、交给并发控制器、拿到统一结果、写清单。这让成本核算、失败重试、日志记录都集中在一个地方。3. 受控并发别让请求一来就冲垮图源配图流水线一旦接上真实接口最大的后续问题不是“不会写请求”而是不会限速。如果一次跑两百个候选图直接并发二十个请求在多数图源限制下必然会得到 429。要是代码朴素地“一张张图顺序生成”一篇五千字的文章可能要等上一个多小时毫无体验。所以必须给请求加一层受控并发。3.1 用信号量控制同时在飞的数量一个简单方案是用信号量Semaphore来控制同时执行的请求数。考虑图源限制可能来自“每分钟调用次数”我引入了两个参数maxConcurrent控制同时在飞的请求数minIntervalMs控制两次请求之间的最小间隔。function createRateLimiter({ maxConcurrent 2, minIntervalMs 500 } {}) { let active 0 const queue [] return async function run(fn) { if (active maxConcurrent) { await new Promise((resolve) queue.push(resolve)) } active try { const lastCallAt globalLastCallTime ?? 0 const waitTime Math.max(0, minIntervalMs - (Date.now() - lastCallAt)) if (waitTime 0) { await sleep(waitTime) } const result await fn() globalLastCallTime Date.now() return result } finally { active-- if (queue.length 0) { const next queue.shift() next() } } } }这段代码只处理“同时请求数为 2 个每次至少隔 500 毫秒发起一次”。它不一定完全替代供应商级的配额计算但对大多数中小型生产足够用了。3.2 实践里的并发参数计算与动态调整假设某图源允许每分钟 20 次调用每次调用平均耗时 6 秒。理论上如果完全连续调用每分钟能发起的请求约为 10 次还没到峰值如果设置并发数为 2则两个请求交叠执行批请求处理周期大约 6 到 8 秒因此接近 2 并发时就可以跑满但不至于超限。这里的计算思路是目标 QPS 20 / 60 ≈ 0.333单请求平均耗时 T 6s建议并发数 目标 QPS × T ≈ 0.333 × 6 ≈ 2。如果网络延迟高、请求耗时达到 10 秒依然维持并发数 2则每分钟只能完成约 12 个请求离配额还远。这时就可以调大并发数到 3。我在代码里把这些参数做成环境变量避免每次改配置都要重新构建。IMAGE_CONCURRENCY3 IMAGE_MIN_INTERVAL_MS2000 IMAGE_HTTP_TIMEOUT_S1203.3 快速失败、重试与回退受控并发不等于无限等待。我的请求函数会统一包一层超时控制单张图片生成超过 120 秒就标记失败。对 429、网络抖动这类可重试错误实现简单的“三档退避”而不是立刻重试。async function requestWithRetry(adapter, request, retries 3) { let lastError for (let attempt 0; attempt retries; attempt) { try { return await adapter.generate(request) } catch (err) { lastError err const backoffMs 1000 * Math.min(2 ** attempt, 8) await sleep(backoffMs Math.random() * 500) } } throw lastError }这里的重试只在单次执行层面发生。真正的队列系统里一般不建议把失败任务无限重试到“永久等待”而是把失败原因写进清单里的“状态”列留给人工决定是重跑还是忽略。这个设计让整个流水线即使中途挂了也不至于把已生成的图片浪费掉。4. 配图清单怎么设计才真正好用很多人以为“配图清单”不过是一排提示词列表这事没那么简单。如果没有准确的元数据、结构化的字段和可标记状态这份清单只会是一堆模型生成的废案。我在试错过程中意识到了三点关键清单要能脱离生成器独立使用要能映射回原文要给人类留出明确的“决策空间”。4.1 清单字段设计让机器和人各取所需我最终输出的主清单是一份 Markdown 表格加一份 JSON。Markdown 表格方便直接预览JSON 则便于后续脚本消费。序号所属章节节点位置类型图片主题风格建议状态1从 Markdown 到配图清单L12-L18paragraph一条由文档流向图片的装配流水线示意扁平线性插画待确认2为什么用 AST 解析 MarkdownL24-L26codeAST 树结构的分支可视化简洁线框已生成3受控并发与限流L53-L61paragraph闸门与流量队列的卡通图偏灰调扁平风待确认第一列序号给人类做快速勾选第二三列用于定位原文第五六列是给生成模型的提示词核心。最关键的其实是最后一列“状态”。它允许人表示“这张图不太行换个思路重新生成”而不需要改动文档结构。4.2 把“图片提示词”从段落里提炼出来从 AST 节点拿到一段文字后不能直接把整段文本塞进生成模型。图片生成接口对提示词的接受能力有限直接塞整段文章会导致输出内容零散且没有重点。我选择的策略是先做“摘要提取”再交给适配器进行二次扩展。可以先用大语言模型对长段落做压缩提取这段文字的核心主题、关键对象和操作关系给出不超过 70 个词的图片描述最后返回一段用顿号连接的关键词列表再把压缩后的关键词与目标风格拼接成完整的提示词。比如原文段落讲到限流和调参描述词大概是一扇半开的数字闸门左侧拥堵着带名字的请求方块右侧通过两个绿色通道配以数字仪表盘扁平风格柔和背景。到这里提示词脱离原文章可能还有一定完整性但人一看就知道“这是给哪一段配的图”。如果这个方案不理想人工可以直接在清单中修改提示词后重新提交这正是清单化的好处。4.3 在清单阶段就做好预算预估图片生成不能只看质量还要算成本。适配器每返回一次结果我会及时计算并累加预估费用把它写进清单的总计。常见图源的计价是按张数和分辨率算也可能按调用次数阶梯计价。因此每个适配器里都实现一个estimateCost()方法比如简单的按单张价格乘以数量estimateCost(reqs: ImageGenerateRequest[]): CostEstimate { const unitPrice this.pricePerImage const totalCount reqs.length return { totalCount, unitPrice, totalPrice: totalCount * unitPrice, currency: USD } }当总预算超过设定的阈值时候可以选择降低风格复杂度、缩小图片尺寸或者跳过一部分低优先级候选节点。这个能力很重要因为真实使用中的失败往往不是技术不通而是财务上不可持续。做工具不把成本透明化上线第二天就会被叫停。4.4 从清单到自动插图中间留一条安全缓冲带有人会问既然清单已经那么完整了何不直接把图片插入 Markdown我目前刻意保留半步距离生成清单不等于插入原文更不等于覆盖原文件。这一步缓冲给了人工复核的机会避免出现以下情况AI 生成了自认为正确的图但把代码块错位放置或者同一个概念前后配了两张语义接近但风格相差明显的图破坏了文章整体性。人工确认后的清单再交给一个很小的插入脚本它会把图片路径和描述写回原 Markdown 对应行号下方。这个过程人必须能看到差异并在 Git 上 review。让机器做“准备”和“执行”让人做“判断”我的体验是最终交付质量稳定很多。5. 实操中踩过的坑与解决思路真的把链路跑通前后花了差不多两周时间。中途遇到的好几个问题单独看都不难串在一条流水线上时却差点把项目带偏。我把它们整理成排除表希望能帮你少走点弯路。现象根因排查与解决解析 Markdown 后经常出现空段落Markdown 里空行太多或者存在 HTML 注释块遍历 AST 时过滤掉空文本节点并忽略html节点与注释类型为ignore的区块配图清单中的行号和原文不对应没有处理 Windows 换行符很多编辑器把\r\n当换行解析前统一规范化文本为\n并在生成清单时重新做行号映射图片请求一直收到 429 限流提示只限制了最大并发却没限制单位时间请求数加入最小请求间隔参数把minIntervalMs从 0 改为 2000某些适配器拿不到图片二进制远程返回一个图片 URL直接解析 JSON 认为成功但请求图片地址时无法访问在适配器里增加二次 GET下载并校验字节流前 8 字节是否为合法图片头同一章节重复生成相同主题的图两个段落文本差异很小但提示词几乎一样用文档字符串的 hash 做缓存一旦命中缓存直接复用图片地址对长文档执行一次后内存增长明显一次性加载全文档并解析树大文件里大量候选节点同时驻留对超过 500 个节点的文档分段处理候选节点或只处理处于指定行号区间的区域5.1 解析阶段的坑不要想当然地处理所有属性Markdown 里的链接在很多 AST 实现里不是简单的一个字符串节点而是link节点内嵌了text子节点。如果自作聪明地只读取node.value就会漏掉链接文字。这要求你始终使用库里提供的递归取值或访问工具而不是实现对单个字段的取巧。另一个问题是插件会改变节点类型。如果你往解析流程里插入了像remark-directive这样的插件可能出现containerDirective之类的自定义节点。此时如果仍然只判断标准节点列表和说明块就会被漏掉。后来我增加了一个“白名单加识别自定义节点”的配置让特定命名空间下的节点也进入候选池。5.2 并发模块中最难定位的错误是“静默排队”刚上线时我发现一个怪象代码看起来没有错但列表里超过一半任务一直没有完成。后来才发现受控队列里存在优先级倒挂一个小任务长期卡在等待队列头部而后续大任务不断插队到顶部。这就是信号量实现里经常出现的公平性问题。解决的办法是给队列换成带时间戳的先进先出结构每个等待者超过 30 秒还没有被调度就打印日志并提前超时。并发不是无脑跑满而是要观察到关键节点“黑盒跑了但卡住”才是最浪费钱的问题。6. 这个流水线还能往哪些方向扩展配图清单只是第一步。把这套由“AST 解析 接口适配器 受控并发”组成的基础设施落地后我陆续接了几个变体玩法运行效果都还不错。6.1 把配图信息反过来更新 Markdown 的 alt 文本原先文档里已有的图片 alt 文本往往写得很随意比如“fig1”。借助 AST 可以定位到具体的image节点再把对应段落背景和图片描述生成更直观的 alt 文本。这一步不需要调用图片生成服务只需要文本模型但产出却是 SEO 与无障碍体验的双重提升。6.2 接图表生成器而不是专用大模型很多技术博客真正需要的不是一幅 AI 插画而是一个清晰的序列图或流程图。我在这个流水线中接入了一个“结构化图形适配器”从文章代码块的调度逻辑中解析出起点、终点、分支条件然后调起一个时序图渲染工具把文本定义转成图片。这类输出的可预测性远高于生成式模型适合步骤说明类文章。6.3 生成 Markdown 转幻灯片工作流的输入既然候选配图节点已经映射到了原文行号再往下就能生成幻灯片项目需要的分页结构一个章节标题对应一页正文部分自动切分。把配图清单和分页描述结合就能作为“从 Markdown 到幻灯片”工作流的前置输入。这时候最开始构建的 AST 解析和节点定位部分被二次复用价值更明显。这条流水线最终不会替代人去创作它只是把“整理素材”“统一样式”“批量生成”这类重复劳动从人身上剥离开。我自己实测下来一份 5000 字技术文章从标题梳理到拿到可用的配图清单用流水线大概在十几分钟内完成而人工做同样的事通常要半天还未必检查得比脚本仔细。不过我也保留了“必须由人来审核最终清单”的习惯——毕竟工具提供的只是上下文相关性的建议不是文章最终的审美判断。
返回列表