做质量和故障复盘的人对这一幕应该都不陌生:会议室订好了,Miro 画板开好了,六个人围着一块屏幕,主持人先在白板上画一条横线,再斜着拉出六根大骨,写上"人、机、料、法、环、测",然后大家开始沉默。等真正讨论到第三层原因的时候,四十分钟已经过去了,而画板上的内容还停留在骨架阶段。MiroFish 这个项目就是冲着这个断点去的——它把一个已经结构化的根因清单,自动铺成一张排布整齐、间距均匀、带连线的鱼骨图,直接落在 Miro 画板上。人一进会议室,看到的是一张已经长出枝干的图,剩下的时间全部用来往上面补细节和吵架,而不是画线。
说白了,MiroFish 干的是"结构到图形"的翻译工作。输入是一份人写起来不费劲的清单(谁、哪个环节、什么现象、可能是哪类原因),输出是 Miro 上可以直接拖、可以直接改的原生图元。它不算什么大模型应用,也不做什么智能推理,它解决的是一件特别土但特别耗时间的事:把脑子里想清楚的结构,手工搬到白板上。用过协作白板做复盘的人都知道,手工摆放三十个文本框并拉好连线,是一个纯粹的体力活,而且一旦要调整分类,整个布局就崩了,得重来。
这个项目适合三类人参考:一是经常主持复盘会、评审会的团队负责人和敏捷教练,可以直接拿去用,把会议前二十分钟省下来;二是做 Miro 插件、白板类工具的开发者,里面关于 Web SDK 与 REST API 的边界划分、OAuth 授权链路、图元批量写入和限流处理的部分,是可以直接抄的;三是对图形自动布局感兴趣的人,鱼骨图虽然简单,但它是一个很好的"约束求解"入门案例,比力导向图好上手得多。
1. 项目整体设计与思路拆解
1.1 先把场景钉死:MiroFish 到底解决哪一段
动手写代码之前,我把这件事拆成了三段:收集、布局、呈现。收集阶段是人输入结构,布局阶段是算坐标,呈现阶段是往画板上写。当时的第一反应是用大模型直接把一段会议录音转成鱼骨图,听起来很酷,但我很快否掉了这个方向。原因是复盘会最值钱的部分恰恰是"人一起讨论出结构"的过程,机器猜出来的结构没人认,反而会变成新的争论点。所以 MiroFish 只做后两段,收集那段老老实实交给人,用一份 YAML 或者一个简单的表单来填。
这个取舍带来了一个很实际的好处:整个系统的确定性极高。同样的输入永远得到同样的输出坐标,不会出现"今天生成的图好看、明天生成的图挤成一团"的情况。布局是纯函数,输入是树形结构,输出是图元列表,中间没有随机数、没有网络请求、没有模型幻觉。调试的时候你可以直接把输出打印出来,一眼就能看出问题出在哪一层。
第二个取舍是不做双向同步。最早我确实想过监听画板的改变事件,让人在 Miro 上手动挪动图元之后能回写到 YAML。做了两天就放弃了:用户在画板上的操作是高度非结构化的——挪一点位置、改一下字体、拆一个框、加一段便签,这些操作很难反向映射回原来的树。强行做同步,最后的结果是系统每隔几秒就把人刚调好的布局冲掉一次,比不做还糟。现在的做法是单向生成,生成完就撒手,后面怎么改都是人的自由。
1.2 为什么选择鱼骨图,而不是思维导图
有人会问,同样是结构化展示,为什么不直接生成思维导图?Miro 上画思维导图更简单,一个中心节点往外发散就行。这里的关键区别在于鱼骨图带有一个隐含的因果方向。思维导图表达的是"从属关系",而鱼骨图的每一根骨都是指向结果的,大骨是小骨的原因,小骨是大骨的原因,整张图从左到右汇集到最右侧的"结果头"。这个方向性对复盘场景是刚需:它逼着参与者在写每一条的时候想清楚"这一条是原因还是现象"。
另外从布局角度讲,鱼骨图其实比思维导图更规整。思维导图的节点数量和层级一变化,整张图的形状就完全不一样,很难做统一的美化。鱼骨图的骨架是固定的:一条主骨、几根斜骨、斜骨上挂几排水平的小骨。骨架固定意味着坐标可以提前算好,剩下的事情就是往格子里填内容。这一点对自动生成极其友好——我们不是在算一个通用布局问题,而是在填一个已知形状的模板。
具体到 Miro 平台,还有一层考虑。Miro 的画板坐标系是无限大的,图元之间没有自动避让,你放哪儿就是哪儿,重叠了也不会有人提醒你。所以"算好坐标再写"比"边写边调整"重要得多。如果布局引擎在本地就把所有冲突解决掉,写进画板的就是一份已经排好的结果,用户看到的画板是干净的。
1.3 三层架构与各自的分工边界
MiroFish 的整体结构分三层,边界划得很清楚:
- 描述层:一份 YAML 文件,描述结果头、分类骨、每根骨头下的子原因。这一层是人唯一需要接触的东西,格式要尽量宽容。
- 布局层:一个纯 Python 模块,把描述层的树转成一组带坐标的图元对象(矩形、文本、连线)。这一层没有任何外部依赖,可以单独跑单元测试。
- 写入层:一个 Miro 应用,负责拿到画板上下文,把布局层产出的图元批量写到画板上。这一层处理鉴权、限流、错误重试。
分层的价值在调试的时候体现得特别明显。画板上的图歪了,先看布局层输出的坐标表;坐标是对的但画板上没东西,那问题在后面两层;画板上东西多了或者少了,多半是写入层的批量分片出了岔子。每一层都有独立的验证手段,不用每次都从头跑一遍。
注意:布局层一定要做成纯函数。我一开始贪方便,在布局函数里直接调了 Miro API 去读画板尺寸,结果单元测试根本没法写,每次调试都要连真实的画板。后来改成把画板尺寸当参数传进来,测试数据直接写死,调试效率提升了不止一个档次。
2. 核心技术点逐层拆解
2.1 Web SDK 和 REST API 的边界怎么划
Miro 提供了两套写入能力,用错了会很别扭。简单说:Web SDK 跑在画板里,REST API 跑在你的服务器上。
Web SDK 是一段跑在 Miro 画板 iframe 里的 JavaScript,它通过 postMessage 和宿主页面通信。它的优势是"有上下文"——miro.board.getInfo()直接告诉你当前是哪个画板、用户是谁、视口在哪,不需要任何额外鉴权参数。它还有一个 REST API 做不到的能力:控制视口。图生成完了调一次miro.board.viewport.zoomTo(items),画板会自动缩放到刚好装下所有图元,用户体验上差别很大。
REST API 则适合服务端批处理。它有一个批量创建图元的接口,一次请求可以塞进去多个图元,网络往返次数能压下来。如果一次要写两百个图元,用 Web SDK 一个个创建就是两百次 postMessage 往返,用 REST 批量接口可能十几次请求就完事了。
MiroFish 的选择是混用:静态骨架走 REST 批量接口,收尾操作走 Web SDK。服务端先把所有矩形、文本、连线批量写完,最后在画板里通过 SDK 调一次视口自适应,并弹一个提示告诉用户生成完成。这样既有批量写入的效率,又有 SDK 的交互体验。
2.2 授权链路:两套令牌别搞混
这是最容易卡住新手的地方。Miro 应用有两种用途不同的令牌,刚开始我混着用,报了一下午的 401。
第一种是OAuth 授权码流程拿到的访问令牌,代表"某个用户授权了这个应用"。它的典型用法是:用户点一下安装,跳转到授权页,同意之后回调拿 code,换 access_token 和 refresh_token。这个令牌适合服务端长时间跑任务,比如定时把某个系统的数据同步到画板。
第二种是画板级令牌(board token),它是用应用密钥对当前会话信息签出来的 JWT,有效期很短。Web SDK 的初始化就需要这个。它的作用范围被限制在当前打开的这一块画板上,权限最小。
实操上我建议这样分工:Web SDK 侧走画板级令牌,服务端批处理走 OAuth 令牌,两套密钥分开管理,别共用。令牌的存储也要注意,access_token 和 refresh_token 放在服务端的环境变量或者密钥管理服务里,绝对不要写进前端代码。前端只需要拿到一个短期的画板令牌就够了。
权限声明也要克制。Miro 应用在注册时需要声明 scopes,比如读画板、写画板。我当时顺手把团队级的权限也勾上了,结果审核的时候被问了一堆"你为什么需要这个权限"的问题。后来全部砍掉,只留boards:read和boards:write,审核顺畅得多。原则是最小权限,用不到的一律不勾。
2.3 鱼骨图布局算法:把坐标一个个算出来
这是整个项目最核心也最有意思的部分。先把几何关系说清楚:
设定一条水平主骨,从左侧起点向右延伸到结果头,长度为 L。主骨上均匀分布 n 根分类骨,第 i 根分类骨的基点在主骨上的位置是:
x_i = x0 + L * (i + 1) / (n + 1)为什么用(i+1)/(n+1)而不是i/(n-1)?因为前者让首尾两根骨头跟起点、终点之间各留出一段等长的空隙,整张图看起来是居中的;后者会让第一根骨头正好压在起点上,视觉上很挤。这个细节很土,但改完之后图确实好看了一个档次。
分类骨从基点斜向上或斜向下伸出,交替排列,避免同一侧挤成一堆。倾角取 60 度,这是一个经验值:
- 角度太小(接近水平),骨头和主骨区分不开,看着像一条线。
- 角度太大(接近垂直),骨头占的纵向空间太多,整张图会被拉得很高。
- 60 度在视觉上最接近经典教材里的画法,横向占位是
B * cos60° = 0.5B,纵向占位是B * sin60° ≈ 0.866B,比例舒服。
假设骨长 B = 420(Miro 坐标单位大致对应 100% 缩放下的像素),那么一根向上的分类骨端点坐标是:
终点 x = x_i + B * cos(60°) = x_i + 210 终点 y = y_spine - B * sin(60°) = y_spine - 364子原因挂在分类骨上,每条子原因是一条水平线,起点落在分类骨上,向右延伸。第 j 条子原因在骨头上的位置按比例 t 取:
t = t_start + j * Δ / B 点 x = x_i + t * B * cos(60°) 点 y = y_spine - t * B * sin(60°) 线长 s = 180这里的 Δ 是相邻两条子原因沿骨头的间隔,它的取值不是随便定的,要从文本不重叠倒推。水平文本的行高按 h = 48 算(Miro 默认字号大约在这个量级),两条相邻子原因在垂直方向上的间距是:
垂直间距 = Δ * sin(60°) = 0.866 * Δ要不重叠,垂直间距必须大于行高:
0.866 * Δ ≥ 48 → Δ ≥ 55.4取 Δ = 60,留一点余量。反过来说,如果某一根骨下面有 k 条子原因,骨长至少要满足:
可用段长 = B - B * t_start k 条子原因需要的长度 = (k - 1) * Δ 所以 B ≥ t_start * B + (k - 1) * Δ取 t_start = 0.15,让第一根子原因离主骨稍微远一点,避免贴着主骨看不清。整理一下就得到自适应骨长公式:
B = max(B_min, (k - 1) * Δ / (1 - t_start)) = max(320, (k - 1) * 60 / 0.85)k = 5 时,B = max(320, 282) = 320。k = 8 时,B = max(320, 494) = 494。也就是说骨头会随着内容变多自动变长,不会出现子原因挤成一坨的情况。而整张图的高度,则由最长的两根上、下骨决定,主骨位置需要相应下移:
上方最高点 = y_spine - B_up_max * sin(60°) 主骨 y 坐标 = 画板基准 y + B_up_max * 0.866 + 边距这一步很容易忘。如果主骨还是按固定位置画,子原因多的一侧就会冲出画板边界,或者被结果头的文本框盖住。
2.4 输入结构设计:让人写得下的格式
描述层我用的是 YAML,理由很简单:它支持缩进表达层级,写起来比 JSON 少一半的括号和引号,非程序员也能改。一份典型输入长这样:
effect: "线上订单支付成功率下降" meta: angle: 60 branch_gap: 60 sub_line_len: 180 categories: - name: "客户端" bones: - "部分机型 SDK 初始化超时" - "版本灰度期间旧版逻辑未回退" - "本地缓存写入失败" - name: "服务端" bones: - "支付网关连接池打满" - "下游回调超时重试放大" - name: "依赖方" bones: - "渠道侧维护窗口重叠"有几个设计决定值得说一下。effect单独拎出来当一个字段,而不是混在分类里,因为它是整张图唯一的结果头,位置和样式都跟其他节点不一样,需要单独渲染。meta里放布局参数,这样可以针对不同长度的内容微调,而不用改代码。子原因用纯字符串数组,不搞嵌套对象——试过让它支持"原因 + 证据 + 责任人"的多字段结构,结果填的人嫌麻烦,一半的字段留空,还不如就一行字。真要记录责任人,让他写在画板便签上更自然。
实操心得:YAML 的缩进对新手极不友好,一个 tab 和四个空格的混用就能让解析直接报错,而且报错信息往往指向错误的行号。我的处理方式是在加载后立刻做一次 schema 校验,把"第几根骨、缺少什么字段"这种人类看得懂的错误抛出来,而不是把底层的解析异常直接甩给用户。
3. 从零跑通 MiroFish:实操步骤
3.1 环境准备与项目初始化
本地需要准备的东西不多,我列一下我的实际环境:
python3 --version # 3.11.x node --version # 20.xPython 端只用到两个库,pyyaml负责解析输入,requests负责调 REST API。布局部分我刻意不引第三方几何库,因为鱼骨图的数学太简单了,引库反而是负担,而且多一个依赖就多一个版本冲突的可能。
mkdir mirofish && cd mirofish python3 -m venv .venv source .venv/bin/activate pip install pyyaml requests前端部分要建一个 Miro 应用工程。Miro 官方提供脚手架,但我的习惯是自己建,因为脚手架会带一堆用不上的示例代码,反而干扰。最小结构是这样:
mirofish/ ├── layout/ │ ├── engine.py 布局核心 │ └── models.py 图元数据类 ├── writer/ │ ├── api_client.py REST 写入 │ └── auth.py 令牌管理 ├── webapp/ │ ├── index.html 画板内 iframe 页面 │ └── app.js Web SDK 逻辑 ├── examples/ │ └── payment.yaml 示例输入 └── config.yaml注意:把
layout目录和writer目录严格分开。我最初混在一个包里,结果只要 import 布局模块就会连带把requests和令牌配置一起加载进来,写单元测试的时候环境变量缺失直接报错。拆开之后,布局模块零外部依赖,跑测试飞快。
3.2 应用注册与权限声明
在 Miro 的开发者后台新建一个应用,需要填的几项:
| 配置项 | 建议值 | 说明 |
|---|---|---|
| 应用类型 | 画板内应用 | 决定入口在画板工具栏 |
| 回调地址 | 你服务端的/oauth/callback | 必须是公网可访问的地址 |
| 权限范围 | boards:read, boards:write | 不要多勾 |
| Webhook | 暂时不开 | 单向生成用不到 |
注册完会拿到三样东西:client_id、client_secret、以及一个用于签名画板令牌的密钥。这三样都要放进服务端的环境变量,用.env管理,并且把.env写进.gitignore。我见过太多次密钥被推上公开仓库的事故,加一行忽略规则的成本几乎为零。
权限这块再强调一次:Miro 的权限是分层的,画板级、团队级、组织级各有对应的 scope。你要做的事情如果只是往某一块画板写图元,boards:write就够了。团队级的 scope 意味着你的应用可能被用来遍历整个团队的所有画板,审核会严格得多。同理,webhook 不开就不需要相应的订阅权限,能省一步是一步。
3.3 布局引擎的具体实现
布局引擎的核心就是前面推导的那几个公式。我把它写成纯函数,输入是解析好的结构加一组布局参数,输出是图元对象列表:
import math def build_layout(spec, board_w=3200, board_h=2000): angle = math.radians(spec.meta.get("angle", 60)) cos_a, sin_a = math.cos(angle), math.sin(angle) gap = spec.meta.get("branch_gap", 60) t_start = 0.15 line_len = spec.meta.get("sub_line_len", 180) cats = spec.categories n = len(cats) # 第一步:先算出每根骨需要的长度,取最大值决定整体高度 branch_len = [] for c in cats: k = max(len(c.bones), 1) need = (k - 1) * gap / (1 - t_start) if k > 1 else 0 branch_len.append(max(320, need)) up_max = max([branch_len[i] for i in range(n) if i % 2 == 0] or [320]) dn_max = max([branch_len[i] for i in range(n) if i % 2 == 1] or [320]) margin_x, margin_y = 200, 120 spine_y = margin_y + up_max * sin_a + 80 spine_x0 = margin_x spine_len = board_w - 2 * margin_x - 320 # 右侧给结果头留位置 items = [] items.append({ "type": "spine", "x1": spine_x0, "y1": spine_y, "x2": spine_x0 + spine_len, "y2": spine_y, }) for i, c in enumerate(cats): base_x = spine_x0 + spine_len * (i + 1) / (n + 1) direction = -1 if i % 2 == 0 else 1 # -1 向上,1 向下 b_len = branch_len[i] end_x = base_x + b_len * cos_a end_y = spine_y + direction * b_len * sin_a items.append({ "type": "branch", "x1": base_x, "y1": spine_y, "x2": end_x, "y2": end_y, "label": c.name, }) for j, bone in enumerate(c.bones): t = t_start + j * gap / b_len px = base_x + t * b_len * cos_a py = spine_y + direction * t * b_len * sin_a items.append({ "type": "sub", "x1": px, "y1": py, "x2": px + line_len, "y2": py, "text": bone, }) return { "items": items, "spine_y": spine_y, "head": {"x": spine_x0 + spine_len + 60, "y": spine_y}, "bounds": {"w": board_w, "h": spine_y + dn_max * sin_a + margin_y}, }几个容易写错的地方。一是t的取值范围,如果gap / b_len算出来让t超过 1,说明骨长估计不足,子原因会跑到骨头外面去。稳妥的做法是在循环里加一句t = min(t, 0.92),把最后一条压回骨头范围内。二是方向交替用的是i % 2,这意味着分类的顺序会直接影响哪根骨头朝上、哪根朝下。如果你希望某一类固定朝上,可以在 YAML 里加一个显式的side字段覆盖掉自动交替。
三是布局完成后一定要算一遍边界。上面返回值里的bounds就是干这个的,它能告诉你整张图实际占了多大地方。后面写进画板、调视口自适应,都靠这个值。没有它的话,你只能靠肉眼看画板是不是被切掉了。
3.4 把图元写进 Miro 画板
写入分两步。第一步用 REST 批量接口创建矩形和文本,第二步用 Web SDK 补连线并调整视口。
REST 侧的关键是分片。批量接口单次能提交的图元数量是有限的,我的做法是固定按 20 个一批切分:
import requests, time BATCH = 20 def push_items(board_id, token, items): url = f"https://api.miro.com/v2/boards/{board_id}/items" headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json", } created = [] for i in range(0, len(items), BATCH): chunk = items[i:i + BATCH] payload = {"data": [to_miro_item(x) for x in chunk]} for attempt in range(3): r = requests.post(url, json=payload, headers=headers, timeout=15) if r.status_code in (200, 201): created.extend(r.json().get("data", [])) break if r.status_code == 429: wait = int(r.headers.get("Retry-After", 2)) time.sleep(wait * (attempt + 1)) continue raise RuntimeError(f"写入失败 {r.status_code}: {r.text[:200]}") else: raise RuntimeError("连续三次失败,中止") time.sleep(0.15) # 主动降速,别把限流撞满 return created这里的重试逻辑有几个细节值得说。只对 429 和 5xx 重试,4xx 里的参数错误重试一百次也没用,直接抛出来让人看。退避要乘上尝试次数,第一次等 2 秒,第二次等 4 秒,而不是每次都等固定值,否则容易把服务端刚恢复的窗口又打满。成功之后主动 sleep 一小会儿,这个看起来傻,但实测比不限速硬冲要稳得多,尤其是图元数量上百的时候。
图元映射这一层要做转换。Miro 的矩形图元用shape类型,文本用text类型,连线用connector。鱼骨图的骨头本身既要有线又要有标签,我的做法是用矩形做一个非常扁的形状来代替线,高度设成 3 像素,这样它会跟着画板一起缩放,样式也能统一控制。真正需要连线的只有主骨和结果头之间那一段。
Web SDK 侧的收尾代码很短:
async function finish(boardItems, bounds) { await miro.board.viewport.zoomTo(boardItems); await miro.board.notifications.showInfo( `已生成 ${boardItems.length} 个图元,可直接编辑` ); }zoomTo传图元数组,画板会自动算出包围盒并缩放到合适比例。这个体验比手动setViewport好太多,因为包围盒是它自己算的,你不用操心。
提示:如果你生成的图元数量超过一百个,
zoomTo之后画板可能会比较拥挤。这时候可以按分类分组,生成完之后只zoomTo第一根分类骨所在的区域,再给用户一个"查看全图"的按钮。人看图的注意力是有限的,一次给他一根骨头,比一次给他整张图更好用。
3.5 联调与验收:怎么判断生成对了
验收不是看"画板上有没有东西",而是看几个具体指标。我固定检查这几项:
| 检查项 | 判定标准 | 常见异常 |
|---|---|---|
| 图元总数 | 等于2 * 骨数 + 2 * 子原因数 + 3 | 数量偏少说明批量分片丢包 |
| 子原因垂直间距 | 40 ~ 60 之间 | 过小说明 gap 参数被改小 |
| 最外侧骨头是否出界 | 距离画板可视边界 ≥ 100 | 骨长估计不足 |
| 连线完整性 | 每根分类骨都有连线指向主骨 | 后处理阶段跳过了 |
| 文本是否被裁切 | 文本框宽度 ≥ 实际文字宽度 | 字号估小了 |
文本裁切这一项是踩过坑之后加的。Miro 的文本图元有个宽度属性,如果设得比实际文字窄,它会自动换行,换行之后行高变大,就跟上一条子原因撞在一起了。中文字符的宽度估算比英文麻烦,我的做法是按"中文字符算 1 个单位,英文和数字算 0.55 个单位"来估,再乘上字号,最后加 20% 的余量。估算肯定不准,但宁可框子大一点,也不要让它换行。
4. 踩坑实录与排查手册
4.1 授权与权限类问题
401 报错最常见的原因是把两套令牌用串了。服务端调 REST API 用的是 OAuth 拿到的访问令牌,画板内 Web SDK 用的是画板级令牌,两者不能互换。判断方法很直接:如果 401 出现在服务端日志里,检查你传进去的是不是 OAuth 令牌;如果 401 出现在浏览器控制台,检查画板令牌是不是过期了。画板令牌的有效期通常很短,过期了需要重新签发。
403 报错基本就是权限没声明。有时候代码完全正确,但应用注册的时候少勾了一个 scope,写入就会被拒。改完权限声明后记得重新走一遍安装授权流程,因为令牌里携带的是授权时刻的权限快照,旧令牌不会自动获得新权限。这一点很容易忽略,改完配置直接跑,还是 403,然后开始怀疑代码,实际上只是令牌没刷新。
回调地址不匹配也是一个高频问题。Miro 会严格比对回调地址,协议、域名、端口、路径任何一处不一致都会拒绝。开发阶段如果用本地地址调试,记得把地址和端口固定下来,不要今天 3000 明天 8080。
4.2 图元写入与连线类问题
图元写进去了,但连线连不上,这是新手最容易遇到的。Miro 的连线需要引用两端的图元 ID,而这个 ID 是创建图元之后服务端返回的。所以流程必然是"先建图元、拿到 ID、再建连线",不能一次性把连线和图元混在一个请求里。我一开始就是图省事混着提交,结果连线全部悬空,只能全部删掉重来。
图元位置偏移也遇到过。原因是 Miro 的坐标系原点和我们想象的左上角不完全一致,而且矩形图元创建时传的x, y默认是中心点,不是左上角。这两个概念搞混了,整张图会往右下偏半个矩形的距离。解决办法很土:先写一个图元到画板上,目测它落在哪,然后用这个偏移量校正整个布局。比翻半天文档快。
文本换行导致布局崩坏前面提过。补充一个排查技巧:如果发现某一条子原因的位置特别奇怪,先看它的文本长度是不是明显超过了其他条目。中文没有天然的分词边界,长句很容易触发换行,换行之后整个堆叠关系就乱了。
4.3 大数据量与限流问题
图元数量上去之后,两类问题会同时出现:请求被限流和画板变卡。
限流的表现是 429,前面给了重试逻辑。这里补充一个经验:主动降速比被动重试划算得多。我实测过两种策略,一种是每批之间不加延迟直接冲,撞到 429 再退避;另一种是每批之间固定等一小会儿。前者总耗时反而更长,因为退避的等待时间往往比你主动停顿的时间久。而且被动重试会让日志里全是 429,真正的问题反而被淹没。
画板变卡是另一个维度的限制。Miro 画板在几百个图元的量级上还是流畅的,上千之后缩放和拖动就会明显掉帧。MiroFish 的应对策略是控制单次生成的规模:分类骨最多八根,每根骨下面的子原因最多十条,超出的部分截断并给一个提示。八十个左右的子原因已经足够覆盖绝大多数复盘场景了,硬塞更多只会让图变得没人看。
4.4 常见问题速查表
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 画板上什么都没有 | 权限 scope 缺失 | 补齐 boards:write 并重新授权 |
| 服务端 401 | 令牌类型用错或已过期 | 区分 OAuth 令牌与画板令牌 |
| 连线悬空 | 图元与连线在同一批提交 | 拆成先建图元、再建连线 |
| 整张图偏右下 | 矩形坐标用了左上角语义 | 按中心点重新计算 |
| 子原因挤成一坨 | gap 参数被调小 | 恢复 60 并检查骨长自适应 |
| 部分图元丢失 | 批量分片边界处理错误 | 检查切片步长与总数是否一致 |
| 请求持续 429 | 没有主动降速 | 每批之间加固定延迟 |
| 骨头冲出画板 | 未计算整图边界 | 用 bounds 结果反推主骨位置 |
| 文字被裁断 | 文本框宽度估小 | 宽度估算加 20% 余量 |
5. 会议现场怎么用,以及后面还能长成什么样
5.1 实战中的几个使用心得
这套东西上线之后我用了小半年,攒了几条文档里不会写的心得。
别在会前就把子原因填满。我一开始的思路是把所有已知原因都写进 YAML,会前生成一张完整的图。结果会议变成了"审阅机器的结论",大家只会说"这个不对""那个要改",讨论的深度反而不如从前。后来改成只填分类骨和大方向,子原因留空,现场边讨论边往上加。这才是鱼骨图本来的用法——它是讨论的载体,不是结论的展示板。
分类骨的命名最好现场统一。最常见的六分类是"人、机、料、法、环、测",但软件场景下这套不太合适,用"客户端、服务端、依赖方、数据、流程、变更"更贴。关键是让所有人对分类的含义有共识,否则会出现同一条原因被两个人分别写在两个分类下的情况。我通常在生成图之前先花两分钟跟大家确认分类,这两分钟能省掉后面二十分钟的重复讨论。
生成的图别急着美化。有人会花时间调字色、加图标、换主题,这些对复盘结论的产出没有任何帮助。MiroFish 生成的默认样式我刻意做得比较素,就是不想让人把注意力放在外观上。
保留每次生成的快照。复盘常常跨多次会议,把每次的 YAML 存一份,下次接着改。Miro 的画板历史可以回退,但回退到某个时间点不如直接拿一份 YAML 重新生成来得干净。
5.2 三个值得往下做的方向
第一个是本地预览。现在每次调整都要真的写到 Miro 上才能看到效果,来回一轮要十几秒。用 SVG 在本地把同一份坐标渲染出来,改参数就能即时看到布局变化,调试效率会高很多。布局层已经是纯函数,加一个渲染器是顺理成章的事。
第二个是分类的自动归并。不是用模型猜原因,而是在人已经写好子原因之后,用简单的文本相似度把重复的条目提示出来。复盘现场经常出现两个人写了同一条原因的情况,人工去重很费神。这个功能不需要多聪明,能用编辑距离把"连接池打满"和"连接池耗尽"标出来就够了。
第三个是把布局引擎抽出来做通用化。现在它只会画鱼骨图,但里面那套"约束推导间距、边界反推基准位置"的思路,用在树形图、层级图上是通用的。把几何部分抽象成一个独立的库,输入是节点和层级约束,输出是坐标,后面想支持别的图形就只用写新的模板,不用重写布局逻辑。
我个人在这套东西上的体会是,自动化最该介入的地方永远是"结构已经清楚、只是搬起来费劲"的环节,而不是"还没想清楚、指望工具帮你想"的环节。MiroFish 之所以好用,恰恰是因为它对前者做得足够扎实,对后者一点都不碰。至于后面会不会加上更多花哨的功能,我的态度是先看用的人有没有真的提需求,没人提就说明现在这样正好。