[{"data":1,"prerenderedAt":2902},["ShallowReactive",2],{"\u002F2026-06-03-574124g":3,"\u002F2026-06-03-574124g-rel":488},{"id":4,"title":5,"body":6,"column":471,"date":472,"description":12,"extension":473,"hero_image":474,"meta":475,"navigation":476,"path":477,"seo":478,"series_id":474,"severity":474,"stem":479,"summary":480,"tags":481,"__hash__":487},"posts\u002F2026-06-03-574用户124G全量迁移.md","搬 574 个用户之前，先拿两个人试",{"type":7,"value":8,"toc":456},"minimark",[9,13,16,20,23,26,29,32,37,40,43,86,89,92,95,122,125,128,183,186,194,198,201,204,207,210,213,216,220,223,226,239,246,249,253,256,259,266,269,273,276,279,286,289,293,296,299,302,351,358,362,365,368,371,374,378,381,384,387,390,393,396,399,402,405,412,419,422,428,431,434,437,440,443,446,449,452],[10,11,12],"p",{},"6 月初完成了一次规模迁移：把老 Docker Swarm 平台的全部用户搬到新 Kubernetes 集群。574 个用户、124G 数据量、573 个中转站密钥需要转换。数据完整保留，管理组件在新系统全新部署。",[10,14,15],{},"这不是\"先设计再执行\"的故事。而是在两个覆盖不同数据形态的真实用户身上走通全流程，每踩到一个坑就固化成 runbook 里的一条，再展开到全量。最终沉淀下来七个必须遵守的关键点。",[17,18,19],"h2",{"id":19},"为什么选择循序渐进而非一步到位",[10,21,22],{},"迁移之前我做了一个完整的架构设计：怎么打包数据、用什么协议传输、新系统怎么导入。理论上没有漏洞。",[10,24,25],{},"但在动手准备脚本时，我决定先拿两个真实用户验证。不是因为不放心设计，而是因为数据迁移这类操作，细节决定成败。方案在脑子里再完美，一旦接触现实数据就会暴露出盲点。",[10,27,28],{},"我选了一个 volume 型用户和一个 bind 型用户。两种挂载方式代表了数据存储的完全不同逻辑。如果两个都能通，全量迁移的风险就能显著降低。",[17,30,31],{"id":31},"七个从测试用户踩出来的关键点",[33,34,36],"h3",{"id":35},"_1-数据双源bind-和-volume-走不同的导出路径","1. 数据双源：bind 和 volume 走不同的导出路径",[10,38,39],{},"容器数据有两种存储方式。bind 挂载直接把主机目录映射进容器，volume 是由容器引擎管理的抽象存储。两者在容器里看起来一样，但取数据的方式完全不同。",[10,41,42],{},"bind 型用户的数据在主机上就是普通目录。我最初想直接用 tar 打包，脚本很简单：",[44,45,50],"pre",{"className":46,"code":47,"language":48,"meta":49,"style":49},"language-bash shiki shiki-themes github-light github-dark","tar czf user.tgz \u003Cuser-data-dir>\u002F\n","bash","",[51,52,53],"code",{"__ignoreMap":49},[54,55,58,62,66,69,73,76,80,83],"span",{"class":56,"line":57},"line",1,[54,59,61],{"class":60},"sScJk","tar",[54,63,65],{"class":64},"sZZnC"," czf",[54,67,68],{"class":64}," user.tgz",[54,70,72],{"class":71},"szBVR"," \u003C",[54,74,75],{"class":64},"user-data-di",[54,77,79],{"class":78},"sVt8B","r",[54,81,82],{"class":71},">",[54,84,85],{"class":64},"\u002F\n",[10,87,88],{},"结果一个 50GB 的目录，打出来的包里只有 13 个文件。其他数千个文件都消失了。",[10,90,91],{},"排查过程：先检查源目录确实有完整的文件。再看 tar 命令的权限——发现文件的所有者是 UID 10001（容器内的非 root 用户）。当 tar 以普通用户身份运行时，对某些文件没有读权限。容器内的应用可以读，是因为它就是 UID 10001 的进程。",[10,93,94],{},"解决方案就一个：用 sudo 运行 tar。",[44,96,98],{"className":46,"code":97,"language":48,"meta":49,"style":49},"sudo tar czf user.tgz \u003Cuser-data-dir>\u002F\n",[51,99,100],{"__ignoreMap":49},[54,101,102,105,108,110,112,114,116,118,120],{"class":56,"line":57},[54,103,104],{"class":60},"sudo",[54,106,107],{"class":64}," tar",[54,109,65],{"class":64},[54,111,68],{"class":64},[54,113,72],{"class":71},[54,115,75],{"class":64},[54,117,79],{"class":78},[54,119,82],{"class":71},[54,121,85],{"class":64},[10,123,124],{},"这次拿到了完整的数据。但 sudo 意味着脚本需要配置 sudoers，或者在迁移期间给执行用户 sudo 权限。这是一个权限模型的变化。",[10,126,127],{},"volume 型用户的情况不同。volume 数据不在主机上直接可见，而是由容器引擎管理。要取出数据必须进到容器内部。我的做法是启动一个 alpine 容器，挂载目标 volume，然后在容器内运行 tar 打包。",[44,129,131],{"className":46,"code":130,"language":48,"meta":49,"style":49},"docker run --rm -v \u003Cvolume-name>:\u002Fdata alpine tar czf - \u002Fdata | tee user.tgz\n",[51,132,133],{"__ignoreMap":49},[54,134,135,138,141,145,148,150,153,156,158,161,164,166,168,171,174,177,180],{"class":56,"line":57},[54,136,137],{"class":60},"docker",[54,139,140],{"class":64}," run",[54,142,144],{"class":143},"sj4cs"," --rm",[54,146,147],{"class":143}," -v",[54,149,72],{"class":71},[54,151,152],{"class":64},"volume-nam",[54,154,155],{"class":78},"e",[54,157,82],{"class":71},[54,159,160],{"class":64},":\u002Fdata",[54,162,163],{"class":64}," alpine",[54,165,107],{"class":64},[54,167,65],{"class":64},[54,169,170],{"class":64}," -",[54,172,173],{"class":64}," \u002Fdata",[54,175,176],{"class":71}," |",[54,178,179],{"class":60}," tee",[54,181,182],{"class":64}," user.tgz\n",[10,184,185],{},"容器内的进程对 volume 有完整的读权限，不存在属主问题。打出来的包是完整的。",[10,187,188,189,193],{},"这两条路的存在意味着迁移脚本必须",[190,191,192],"strong",{},"先判断用户数据的挂载类型","。我在数据库里加了一个字段记录每个用户是 bind 还是 volume，导出脚本根据这个字段选择对应的打包方式。",[33,195,197],{"id":196},"_2-设备表如果不迁桌面端会完全失效","2. 设备表如果不迁，桌面端会完全失效",[10,199,200],{},"中间有一个 table 我差点漏掉。老系统数据库里有一张 ConnectorDevice 表，记录的是每个用户绑定的设备信息。一共 658 个设备记录跨 551 个用户。",[10,202,203],{},"初版迁移计划里没有这张表。理由是\"设备信息不是核心用户数据，新系统支持重新绑定\"。",[10,205,206],{},"验证第一个 volume 型用户时一切正常——账号能登，数据在那。第二个 bind 型用户登上去以后，试着从桌面端的工具栏打开一个功能，结果无响应。",[10,208,209],{},"日志显示工具调用失败。错误消息指向设备 token 查询。追进去才发现，工具栏里的每个按钮都通过设备标识符来路由调用，设备表里没有这个用户，路由直接返回 404。",[10,211,212],{},"这不是\"用户可以重新绑定\"就解决的问题。用户从桌面端发起的操作链路已经依赖于设备标识，设备表是必需的。漏掉它等于功能瘫痪。",[10,214,215],{},"所以 ConnectorDevice 表成了迁移的强制条件。新系统的数据库导入流程里，要同时导入这张表，并保持 userId 的一致性。",[33,217,219],{"id":218},"_3-中转站账号不变但密文必须转换","3. 中转站账号不变，但密文必须转换",[10,221,222],{},"两个平台都接入同一个 sub2api 中转站。中转站管理着用户的各种第三方工具授权。在老系统里，这些授权用老平台的 SEALED_BOX 密钥加密存储。搬到新系统，需要用新平台的 SEALED_BOX 密钥重新加密。",[10,224,225],{},"为什么要转换？SEALED_BOX 是一种公钥加密方案，每个平台都有自己的密钥对。老平台的私钥无法解密新平台加的密文，所以迁移过程必须：",[227,228,229,233,236],"ol",{},[230,231,232],"li",{},"用老平台的私钥解密",[230,234,235],{},"取出明文",[230,237,238],{},"用新平台的公钥重新加密",[10,240,241,242,245],{},"我写了一个转换脚本，用 libsodium 的 ",[51,243,244],{},"crypto_box_seal"," 接口来验证整个往返过程。解出来的 keyLen=67、pwLen=32，用新平台的私钥解密验证通过。",[10,247,248],{},"这个转换必须在导出端完成。新系统导入时拿到的是已经用新密钥加密的密文，导入脚本直接写进数据库，中转站调用也会成功。",[33,250,252],{"id":251},"_4-userid-保持不变clusterid-需要改","4. userId 保持不变，clusterId 需要改",[10,254,255],{},"用户的全局标识是 userId。在老系统里它是用户的数据库主键，在新系统也是。所有的关联记录——设备表、配额表、中转站账号——都通过 userId 串联。",[10,257,258],{},"userId 必须 1:1 迁移，不能改。",[10,260,261,262,265],{},"clusterId 是集群标识。老系统里 clusterId 反映的是容器运行在哪个 Swarm 集群。新系统采用 Kubernetes，集群标识体系不同。我统一把新系统的 clusterId 改成 ",[51,263,264],{},"cce-1","。这个值对应新 K8s 集群的内部标识。",[10,267,268],{},"新系统的容器启动脚本会读这个字段，根据它去连接对应的控制平面。改错 clusterId 等于把容器指向了错误的集群。",[33,270,272],{"id":271},"_5-用私有桶中转数据不走公开-cdn","5. 用私有桶中转数据，不走公开 CDN",[10,274,275],{},"打包好的数据文件动辄几百 MB 到几 GB。老系统的网络和新系统的网络不在同一个 VPC 里，直接 scp 传输会占用宝贵的跨域带宽。",[10,277,278],{},"我用云对象存储的私有桶作为中间仓库。老系统打好包以后上传到私有桶，新构建机再从私有桶下载。两端都是到公有云厂商的接入点，利用云厂商内部的高速专线。",[10,280,281,282,285],{},"关键是这个桶必须是",[190,283,284],{},"私有的，不挂公开 CDN","。用户数据严禁走任何公开网络。每次上传和下载都用签名的 URL 来授权，传完数据立刻删除对象，不留痕迹。",[10,287,288],{},"我验证过 md5：一个 13.7MB 的包从老机上传、新机下载，字节级一致。解包出来的 4778 个文件和 openclaw.json 的校验和都对。",[33,290,292],{"id":291},"_6-sfs-挂载会掉检查后自动重新-mount","6. SFS 挂载会掉，检查后自动重新 mount",[10,294,295],{},"新系统把共享存储（SFS）挂到一台 build 机上。这台机器负责接收下载的数据、解包、导入数据库。",[10,297,298],{},"问题是每次 build 机重启，SFS 的挂载点就掉了。虽然自动挂载配置写在 fstab 里，但在容器平台的场景下不总是可靠。如果导入脚本在挂载掉的时刻运行，直接写会失败。",[10,300,301],{},"解决方案是每个导入脚本的开头加一个检查逻辑：",[44,303,305],{"className":46,"code":304,"language":48,"meta":49,"style":49},"if ! mountpoint \u002Fmnt\u002Fsfs > \u002Fdev\u002Fnull 2>&1; then\n  mount -a\nfi\n",[51,306,307,336,345],{"__ignoreMap":49},[54,308,309,312,315,318,321,324,327,330,333],{"class":56,"line":57},[54,310,311],{"class":71},"if",[54,313,314],{"class":71}," !",[54,316,317],{"class":60}," mountpoint",[54,319,320],{"class":64}," \u002Fmnt\u002Fsfs",[54,322,323],{"class":71}," >",[54,325,326],{"class":64}," \u002Fdev\u002Fnull",[54,328,329],{"class":71}," 2>&1",[54,331,332],{"class":78},"; ",[54,334,335],{"class":71},"then\n",[54,337,339,342],{"class":56,"line":338},2,[54,340,341],{"class":60},"  mount",[54,343,344],{"class":143}," -a\n",[54,346,348],{"class":56,"line":347},3,[54,349,350],{"class":71},"fi\n",[10,352,353,354,357],{},"脚本先看一遍挂载点是否存活，不活就执行 ",[51,355,356],{},"mount -a"," 重新挂载。这样即使 build 机在迁移过程中重启了，脚本也能自动恢复。",[33,359,361],{"id":360},"_7-迁移完成后桌面端-connector-必须重启","7. 迁移完成后桌面端 Connector 必须重启",[10,363,364],{},"新系统的控制平面地址和老系统不同。桌面端的 Connector 进程启动时会连接到指定的控制平面，握手成功后建立长连接。",[10,366,367],{},"如果 Connector 进程还连着老系统，即使用户账号已经迁到新系统，Connector 也无法获取到新系统的指令。反过来说，如果 Connector 没有重启，它就不知道用户迁到了新系统。",[10,369,370],{},"这就是为什么迁移完成后必须通知用户重启桌面端 Connector。重启时 Connector 会重新寻址、重新握手、连接到新的控制平面。之后一切恢复正常。",[10,372,373],{},"如果用户没有重启，会卡在\"正在尝试恢复连接\"的状态。新的工具指令下不来，老的控制平面也有超时断开了，这个过程会很难受。",[17,375,377],{"id":376},"从两个用户到-574-个用户","从两个用户到 574 个用户",[10,379,380],{},"两个测试用户的迁移用了半天。第一个用户（volume 型，约 329MB，7728 个文件）：导出、上传、下载、解包、导入、验证，全程顺利。中间没有意外，密钥转换、设备表、clusterId 都对上了。",[10,382,383],{},"第二个用户（bind 型，约 75MB，4778 个文件）：打包时踩了那个属主的坑——sudo 加上以后就通了。从这里开始意识到，bind 和 volume 的处理逻辑必须分开。",[10,385,386],{},"两个用户都通过以后，我把脚本参数化，改成了单用户批处理的形式。老系统的导出脚本接收 userId，自动判断挂载类型、选择打包方式、执行密钥转换。新系统的导入脚本接收 userId，自动从私有桶拉数据、解包、导入数据库。",[10,388,389],{},"全量执行时用批量循环调用这两个脚本。老系统可以较高并发上传（打包是 CPU 密集但量小），新系统的 build 机 CPU 是瓶颈，所以每批控制在 5-10 个用户。失败的用户记录下来，单独重试。脚本设计成幂等的，重跑同一个用户不会产生重复导入或覆盖。",[10,391,392],{},"整个过程没有\"先完整设计再验证\"的阶段。而是在两个真实用户身上把七个关键点全部踩了一遍，每个坑的解法都通过验证，然后把脚本一般化。这样到全量执行时，风险已经可控。",[10,394,395],{},"574 个用户的迁移在一个停机窗口内完成。中间有两个用户因为网络超时失败，重跑以后成功。最后逐个抽样验证，账号能登、数据完整、设备表存在、sub2api 调用成功。",[10,397,398],{},"密钥转换的幂等性证明了它的必要性：如果中间有用户的导入被中断，下一次重跑时新的加密过程会用最新的密钥重算，结果还是对的。这避免了\"某个用户的密文版本是混杂的\"这种中间态。",[17,400,401],{"id":401},"迁移执行中的可见性与回滚设计",[10,403,404],{},"全量迁移前，我准备了详细的进度跟踪和失败处理机制。每个用户的迁移过程记录到日志里：已导出、已上传、已下载、已解包、已导入。如果某个环节失败，日志会精确指出是哪一步、为什么失败。",[10,406,407,408,411],{},"失败不等于灾难，因为整个过程设计得是",[190,409,410],{},"幂等的","。重跑同一个用户的导出和导入脚本不会产生重复的数据库行。如果导入中途被打断，下一次导入会看到已有的用户记录，跳过创建步骤，只更新增量数据（比如新一轮的密钥转换）。",[10,413,414,415,418],{},"老系统的数据在整个迁移期间处于",[190,416,417],{},"只读状态","。没有删除任何源数据，只是读取、转换、上传。一旦新系统稳定运行，老系统作为完整的回滚备份而继续保活。这是保险的做法——如果新系统在某个时刻崩溃或数据破损，我可以立刻切回老系统，损失只是中间几个小时的新增数据。",[10,420,421],{},"两个测试用户通过后，迁移在一个停机窗口内展开。574 个用户分批处理，老系统的上传和新系统的导入并行进行。中间有两个用户因为网络超时导致下载失败，重新跑了一遍就成功了。这证实了脚本的幂等性和容错能力。",[10,423,424,425,427],{},"迁移完成后，我逐个抽样验证。登录账号，检查数据目录是否完整，跑 md5 校验和（与打包时的 hash 对比），用新系统的接口调用 sub2api 看密钥是否正确转换。设备表的记录数和新系统现存的设备数应该吻合。clusterId 在所有用户上是一致的 ",[51,426,264],{},"。",[10,429,430],{},"这些检查都通过以后，才通知用户切换到新系统，并提醒重启桌面端 Connector。",[17,432,433],{"id":433},"为什么数据迁移总是出其不意",[10,435,436],{},"事后看，七个关键点里有五个是在测试阶段才浮出来的。最初的设计文档对其中三个（bind 属主问题、设备表漏掉、SFS 挂载掉线）完全没有预见。",[10,438,439],{},"这不是设计不够仔细，而是这类问题的特性：它们涉及多个系统的交界面。bind 挂载的属主问题不会出现在任何单一组件的文档里，而是 Linux 文件系统权限、Docker 挂载、tar 命令的组合特性。设备表漏掉是因为新系统的功能链路和老系统不同，在纸面上看不出来。SFS 的挂载掉线需要真实的硬件重启来复现。",[10,441,442],{},"如果先花两周完整设计，再花两周编码实现，再花一周全量执行，那么遇到这些问题时已经是 go-live 前几个小时，后果会很严重。",[10,444,445],{},"改成\"两个用户验证 → 踩坑固化 → 全量执行\"的流程，则是在可控的范围内把风险提前释放。两个用户的数据量不大（总共不到 500MB），失败了重来也快。从踩坑到修复脚本，整个周期不超过一天。新系统的导入逻辑也是在这个过程中逐步完善的——先是最基础的\"把数据解开、写进数据库\"，然后加上\"密钥转换\"，再加上\"设备表导入\"，最后加上\"幂等检查和错误恢复\"。",[10,447,448],{},"每一层都是在真实数据验证下加上去的，不是基于假设。",[10,450,451],{},"新系统稳定运行两周以后，我才把老 Swarm 系统下线。期间没有发现任何数据不一致或功能失效。574 个用户和它们的 124G 数据完整迁移到了新平台。",[453,454,455],"style",{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}",{"title":49,"searchDepth":338,"depth":338,"links":457},[458,459,468,469,470],{"id":19,"depth":338,"text":19},{"id":31,"depth":338,"text":31,"children":460},[461,462,463,464,465,466,467],{"id":35,"depth":347,"text":36},{"id":196,"depth":347,"text":197},{"id":218,"depth":347,"text":219},{"id":251,"depth":347,"text":252},{"id":271,"depth":347,"text":272},{"id":291,"depth":347,"text":292},{"id":360,"depth":347,"text":361},{"id":376,"depth":338,"text":377},{"id":401,"depth":338,"text":401},{"id":433,"depth":338,"text":433},"Agent 平台","2026-06-03","md",null,{},true,"\u002F2026-06-03-574124g",{"title":5,"description":12},"2026-06-03-574用户124G全量迁移","老 Swarm 平台向新 K8s 集群迁移 574 用户及其 124G 用户数据，以测试用户踩出的七个关键点固化成执行 runbook。",[482,483,484,485,486],"Kubernetes","Docker Swarm","数据迁移","生产实战","运维","dlB_dynUu1DA8l32l1REN8u3JzSU2dAQIPWDJ6MkAjY",[489,1201,1849],{"id":490,"title":491,"body":492,"column":471,"date":1186,"description":496,"extension":473,"hero_image":474,"meta":1187,"navigation":476,"path":1188,"seo":1189,"series_id":474,"severity":474,"stem":1190,"summary":1191,"tags":1192,"__hash__":1200},"posts\u002F2026-08-31-阈值不能推只能量.md","阈值不能推，只能量",{"type":7,"value":493,"toc":1177},[494,497,500,504,507,513,516,519,527,530,533,538,541,553,579,585,588,592,603,606,612,615,618,621,626,629,632,693,696,699,758,761,766,770,773,779,782,785,789,792,798,801,807,810,813,820,826,829,832,838,841,884,887,891,894,897,903,906,909,913,919,987,990,1087,1090,1093,1141,1144,1148,1151,1161,1164,1171,1174],[10,495,496],{},"知识库的检索参数有三组：分块阈值、召回阈值、精排阈值。这一天把三组都动了一遍，每一组都留下了实测数据。",[10,498,499],{},"起因是一个看不太出来的现象：知识库好像没被用上。",[17,501,503],{"id":502},"一分块中位数-212-字","一、分块：中位数 212 字",[10,505,506],{},"先量现状。生产库里的分块统计：",[508,509,510],"blockquote",{},[10,511,512],{},"13244 个分块中位数只有 212 字，而目标 2400 字，62% 的块不足 300 字。",[10,514,515],{},"目标块大小是 2400 字，实际交付的是 212。差了十倍。",[10,517,518],{},"分块器原来的逻辑是「一个标题一个块」。这在正常文档上没问题，但清单型、模板型文档里，几乎每一行列表项都会被标题识别逻辑认成标题：",[44,520,525],{"className":521,"code":523,"language":524},[522],"language-text","1. 原本想达成什么？\n","text",[51,526,523],{"__ignoreMap":49},[10,528,529],{},"这一行被当成标题，于是自己成了一个块。一篇文档被切成几十个几十字的碎片，注入给模型的全是碎片。",[10,531,532],{},"更严重的情况在连续列表项之间没有正文时。老实现让标题行只活在「面包屑」元数据里，不写进块正文。于是被误判成标题的那一行文字直接消失：",[508,534,535],{},[10,536,537],{},"标题行只活在面包屑里，整行文字直接丢失（那类文档的四个核心问题在索引里根本不存在）。",[10,539,540],{},"两处改动：",[44,542,546],{"className":543,"code":544,"language":545,"meta":49,"style":49},"language-ts shiki shiki-themes github-light github-dark","\u002F\u002F 攒够了才在这里切：标题是「首选切点」，不是「强制切点」。\n","ts",[51,547,548],{"__ignoreMap":49},[54,549,550],{"class":56,"line":57},[54,551,544],{"class":552},"sJ8bj",[44,554,556],{"className":543,"code":555,"language":545,"meta":49,"style":49},"\u002F\u002F 不变量：每一行输入都要落进某个块的正文，面包屑只是附加元数据。\n\u002F\u002F 老实现让标题行只活在面包屑里，于是被 detectHeading 误判成标题的列表项\n\u002F\u002F （「1. 原本想达成什么？」这类）整行文字就没了——连续几个列表项时只留得住最后一条。\n\u002F\u002F 与面包屑重复一次可以接受，丢字不行。\n",[51,557,558,563,568,573],{"__ignoreMap":49},[54,559,560],{"class":56,"line":57},[54,561,562],{"class":552},"\u002F\u002F 不变量：每一行输入都要落进某个块的正文，面包屑只是附加元数据。\n",[54,564,565],{"class":56,"line":338},[54,566,567],{"class":552},"\u002F\u002F 老实现让标题行只活在面包屑里，于是被 detectHeading 误判成标题的列表项\n",[54,569,570],{"class":56,"line":347},[54,571,572],{"class":552},"\u002F\u002F （「1. 原本想达成什么？」这类）整行文字就没了——连续几个列表项时只留得住最后一条。\n",[54,574,576],{"class":56,"line":575},4,[54,577,578],{"class":552},"\u002F\u002F 与面包屑重复一次可以接受，丢字不行。\n",[10,580,581,582,427],{},"标题从「强制切点」降为「首选切点」：缓冲区不足阈值时，标题并入当前块，不切。同时标题行本身写进正文，成为一条不变量——",[190,583,584],{},"每一行输入都要落进某个块的正文",[10,586,587],{},"实测效果：一篇文档从 3 块 52\u002F67\u002F100 字（四个核心问题全丢）变成 1 块 235 字，内容完整。",[17,589,591],{"id":590},"二按比例推算推出了全场最差点","二、按比例推算，推出了全场最差点",[10,593,594,595,598,599,602],{},"第一版把阈值定成 ",[51,596,597],{},"maxChars × 0.6","。生产 ",[51,600,601],{},"maxChars"," 是 2400，算出来是 1440。",[10,604,605],{},"结果整篇文档并成一个块。上线后实测检索：",[44,607,610],{"className":608,"code":609,"language":524},[522],"5 篇文档 6 个查询，每篇取最佳命中分再平均\n  阈值 0（纯按小节切） 0.7046\n  阈值 250            0.6750\n  阈值 1440（线上）    0.4978\n",[51,611,609],{"__ignoreMap":49},[10,613,614],{},"1440 正好是最差的那个。",[10,616,617],{},"同一篇文档对「核心四问」这个查询，切成小节时得分 0.77，并成整块时只有 0.33——在全库 113 块里排到第 109 名。内容修好了，却再也检索不到。",[10,619,620],{},"根因在生产用的向量模型上：",[508,622,623],{},[10,624,625],{},"生产 embedding 模型对「主题聚焦的小段」打分远高于「整篇文档」。",[10,627,628],{},"原来那个「一个标题一个块」的设计，主题纯度是对的。推翻它是错的判断。真正的缺陷只有一条——标题行被丢弃。",[10,630,631],{},"最终取值 250：",[44,633,635],{"className":543,"code":634,"language":545,"meta":49,"style":49},"\u002F**\n * 缺省小节合并阈值。250 是在生产 embedding 模型上实测标定的，不是拍脑袋：\n * 5 篇文档 6 个查询，取每篇的最佳命中分做平均——\n *   阈值 0（纯按小节切）0.7046 \u002F 250 → 0.6750 \u002F 1440（= maxChars×0.6）→ 0.4978\n * 这个模型对「主题聚焦的小段」打分远高于「整篇文档」……\n * 所以阈值必须小，千万别再按 maxChars 的比例去推——那样在生产的 2400 上会算出 1440，\n * 正好是最差点。\n * 取 250 而不是 0：排序只差 4%，但块从几十字变成 250~330 字，\n * 同样召回 6 段能多喂两三倍的正文。\n *\u002F\n",[51,636,637,642,647,652,657,663,669,675,681,687],{"__ignoreMap":49},[54,638,639],{"class":56,"line":57},[54,640,641],{"class":552},"\u002F**\n",[54,643,644],{"class":56,"line":338},[54,645,646],{"class":552}," * 缺省小节合并阈值。250 是在生产 embedding 模型上实测标定的，不是拍脑袋：\n",[54,648,649],{"class":56,"line":347},[54,650,651],{"class":552}," * 5 篇文档 6 个查询，取每篇的最佳命中分做平均——\n",[54,653,654],{"class":56,"line":575},[54,655,656],{"class":552}," *   阈值 0（纯按小节切）0.7046 \u002F 250 → 0.6750 \u002F 1440（= maxChars×0.6）→ 0.4978\n",[54,658,660],{"class":56,"line":659},5,[54,661,662],{"class":552}," * 这个模型对「主题聚焦的小段」打分远高于「整篇文档」……\n",[54,664,666],{"class":56,"line":665},6,[54,667,668],{"class":552}," * 所以阈值必须小，千万别再按 maxChars 的比例去推——那样在生产的 2400 上会算出 1440，\n",[54,670,672],{"class":56,"line":671},7,[54,673,674],{"class":552}," * 正好是最差点。\n",[54,676,678],{"class":56,"line":677},8,[54,679,680],{"class":552}," * 取 250 而不是 0：排序只差 4%，但块从几十字变成 250~330 字，\n",[54,682,684],{"class":56,"line":683},9,[54,685,686],{"class":552}," * 同样召回 6 段能多喂两三倍的正文。\n",[54,688,690],{"class":56,"line":689},10,[54,691,692],{"class":552}," *\u002F\n",[10,694,695],{},"取 250 而不是 0 的理由是这段话里第二重要的部分：排序只差 4%，但每个块从几十字变成两三百字，同样召回 6 段能多喂几倍的正文。",[10,697,698],{},"代码里还留了一条兜底：",[44,700,702],{"className":543,"code":701,"language":545,"meta":49,"style":49},"\u002F\u002F 取 min：maxChars 很小的配置（测试里 300）不能让阈值反超块大小本身\nconst minChunkChars =\n  opts.minChunkChars ?? Math.min(DEFAULT_MIN_CHUNK_CHARS, Math.floor(maxChars * 0.6));\n",[51,703,704,709,720],{"__ignoreMap":49},[54,705,706],{"class":56,"line":57},[54,707,708],{"class":552},"\u002F\u002F 取 min：maxChars 很小的配置（测试里 300）不能让阈值反超块大小本身\n",[54,710,711,714,717],{"class":56,"line":338},[54,712,713],{"class":71},"const",[54,715,716],{"class":143}," minChunkChars",[54,718,719],{"class":71}," =\n",[54,721,722,725,728,731,734,737,740,743,746,749,752,755],{"class":56,"line":347},[54,723,724],{"class":78},"  opts.minChunkChars ",[54,726,727],{"class":71},"??",[54,729,730],{"class":78}," Math.",[54,732,733],{"class":60},"min",[54,735,736],{"class":78},"(",[54,738,739],{"class":143},"DEFAULT_MIN_CHUNK_CHARS",[54,741,742],{"class":78},", Math.",[54,744,745],{"class":60},"floor",[54,747,748],{"class":78},"(maxChars ",[54,750,751],{"class":71},"*",[54,753,754],{"class":143}," 0.6",[54,756,757],{"class":78},"));\n",[10,759,760],{},"以及一条守卫测试，专门防「有人再推一遍公式」：",[508,762,763],{},[10,764,765],{},"加一条守卫测试钉死生产口径（阈值退回按比例推算即变红），防止将来有人再推一遍公式又回到 1440。",[17,767,769],{"id":768},"三召回阈值会把整轮清零","三、召回阈值：会把整轮清零",[10,771,772],{},"分块改大之后，绝对余弦分整体下移。而召回阈值还是旧值 0.5：",[44,774,777],{"className":775,"code":776,"language":524},[522],"正确命中落在 0.43~0.63，旧值会把最高分 0.488 的查询整轮清零——\n检索到了正确文档却被门槛全部丢弃，用户看到的就是「知识库没被使用」。\n",[51,778,776],{"__ignoreMap":49},[10,780,781],{},"这条解释了我一开始看到的那个现象。检索其实命中了，只是分数没过门槛，于是整轮被丢掉，模型什么也没拿到。",[10,783,784],{},"改成 0.35。",[17,786,788],{"id":787},"四精排从-912-到-1212","四、精排：从 9\u002F12 到 12\u002F12",[10,790,791],{},"同一天启用了 cross-encoder 精排。A\u002FB 实测：",[44,793,796],{"className":794,"code":795,"language":524},[522],"12 条查询 A\u002FB 实测：\n  纯向量  命中 9\u002F12，整轮零注入 1 次\n  开精排  命中 12\u002F12，整轮零注入 0 次，目标文档 10 条排第 1\n",[51,797,795],{"__ignoreMap":49},[10,799,800],{},"修好的都是「换了说法」的查询——向量检索的固有短板。举一个例子：",[44,802,805],{"className":803,"code":804,"language":524},[522],"「我想要复刻爆款视频…使用画布」注入 0 段 → 4 段\n（相关文档被向量埋在后面，精排提到第 2 名）\n",[51,806,804],{"__ignoreMap":49},[10,808,809],{},"精排启用时把两个阈值也一起改了。这是当天最曲折的一处。",[10,811,812],{},"第一版把精排阈值设成 0.15。发版后跑完整测试，出现随机漏召。",[10,814,815,816,819],{},"原因是精排的",[190,817,818],{},"绝对分不稳定","：",[44,821,824],{"className":822,"code":823,"language":524},[522],"同一查询同一候选集，「金字塔原理怎么做到结论先行」两次实测 0.251 与 0.135——\n排名都稳定第 1，只是分数漂了近一半。0.15 卡在中间，于是第二次整轮零注入。\n",[51,825,823],{"__ignoreMap":49},[10,827,828],{},"排名是稳定的，分数会漂。所以不能用绝对分当门槛去「把关质量」。",[10,830,831],{},"阈值扫描：",[44,833,836],{"className":834,"code":835,"language":524},[522],"0.02~0.10 均 12\u002F12 零丢弃，0.15\u002F0.20 → 11\u002F12 丢 1 次\n",[51,837,835],{"__ignoreMap":49},[10,839,840],{},"最终取 0.05，并把职责写清楚：",[44,842,844],{"className":543,"code":843,"language":545,"meta":49,"style":49},"\u002F**\n * 精排阈值。**它的职责只是扔掉垃圾，不是把关质量**——质量由排序保证：\n * 12 条查询里精排把正确目标全部放进了前 3 名（10 条第 1）。\n *\n * 取 0.05 而不是更高，是因为**精排的绝对分不稳定**……\n * 观测到的正确目标最低分 0.131，取 0.05 留约 60% 余量；\n * 垃圾档在 0.014~0.025，仍被干净滤掉。\n *\u002F\n",[51,845,846,850,855,860,865,870,875,880],{"__ignoreMap":49},[54,847,848],{"class":56,"line":57},[54,849,641],{"class":552},[54,851,852],{"class":56,"line":338},[54,853,854],{"class":552}," * 精排阈值。**它的职责只是扔掉垃圾，不是把关质量**——质量由排序保证：\n",[54,856,857],{"class":56,"line":347},[54,858,859],{"class":552}," * 12 条查询里精排把正确目标全部放进了前 3 名（10 条第 1）。\n",[54,861,862],{"class":56,"line":575},[54,863,864],{"class":552}," *\n",[54,866,867],{"class":56,"line":659},[54,868,869],{"class":552}," * 取 0.05 而不是更高，是因为**精排的绝对分不稳定**……\n",[54,871,872],{"class":56,"line":665},[54,873,874],{"class":552}," * 观测到的正确目标最低分 0.131，取 0.05 留约 60% 余量；\n",[54,876,877],{"class":56,"line":671},[54,878,879],{"class":552}," * 垃圾档在 0.014~0.025，仍被干净滤掉。\n",[54,881,882],{"class":56,"line":677},[54,883,692],{"class":552},[10,885,886],{},"观测到的垃圾档在 0.014~0.025，正确目标最低 0.131。0.05 落在两者之间，两边都有余量。",[17,888,890],{"id":889},"五超时静默降级最贵","五、超时：静默降级最贵",[10,892,893],{},"同一批里还修了超时。",[10,895,896],{},"精排失败会自动降级回向量序，不阻断聊天。这个设计是对的——但有代价：",[44,898,901],{"className":899,"code":900,"language":524},[522],"法律知识库 30 块共 4.5 万字，冷调用实测 2263ms，只剩 737ms 余量。\n完整测试里有一条查询当场超时降级。\n超时是静默的，日志不报错，用户侧表现为「有时准有时不准」，极难归因。\n",[51,902,900],{"__ignoreMap":49},[10,904,905],{},"「有时准有时不准」这句话在这一天的上下文里已经出现第二次了——早上是参考图，这里是检索。",[10,907,908],{},"超时从 3000 毫秒提到 8000，给冷调用 3.5 倍余量。真卡死仍有降级兜底。",[17,910,912],{"id":911},"六三个阈值放在一起","六、三个阈值放在一起",[10,914,915,916],{},"三条修正的形式不同，结论是同一条：",[190,917,918],{},"阈值不能推导，只能测量。",[920,921,922,941],"table",{},[923,924,925],"thead",{},[926,927,928,932,935,938],"tr",{},[929,930,931],"th",{},"参数",[929,933,934],{},"推导值",[929,936,937],{},"实测值",[929,939,940],{},"推导错在哪",[942,943,944,959,973],"tbody",{},[926,945,946,950,953,956],{},[947,948,949],"td",{},"分块合并阈值",[947,951,952],{},"1440（按比例 0.6）",[947,954,955],{},"250",[947,957,958],{},"假设「块越大越好」，而该模型偏好主题聚焦的小段",[926,960,961,964,967,970],{},[947,962,963],{},"召回阈值",[947,965,966],{},"0.5（惯例值）",[947,968,969],{},"0.35",[947,971,972],{},"分块改动后分数分布整体下移，旧门槛把命中全丢",[926,974,975,978,981,984],{},[947,976,977],{},"精排阈值",[947,979,980],{},"0.15（留余量）",[947,982,983],{},"0.05",[947,985,986],{},"绝对分本身会漂近一半，拿漂移量当门槛必然随机漏召",[10,988,989],{},"三个值都有实测数字支撑，也都配了守卫测试。其中两条测试写的是「不许回到某个值」，而不是「必须等于某值」：",[44,991,993],{"className":543,"code":992,"language":545,"meta":49,"style":49},"it(\"向量阈值不得回到会整轮清零的 0.5\", () => {\n  expect(DEFAULT_KB_MIN_SCORE).toBeLessThanOrEqual(0.4);\n});\n\nit(\"精排阈值不得高到会随分数漂移漏召——观测最低正确分 0.131\", () => {\n  expect(DEFAULT_KB_RERANK_MIN_SCORE).toBeLessThanOrEqual(0.1);\n});\n",[51,994,995,1014,1038,1043,1048,1063,1083],{"__ignoreMap":49},[54,996,997,1000,1002,1005,1008,1011],{"class":56,"line":57},[54,998,999],{"class":60},"it",[54,1001,736],{"class":78},[54,1003,1004],{"class":64},"\"向量阈值不得回到会整轮清零的 0.5\"",[54,1006,1007],{"class":78},", () ",[54,1009,1010],{"class":71},"=>",[54,1012,1013],{"class":78}," {\n",[54,1015,1016,1019,1021,1024,1027,1030,1032,1035],{"class":56,"line":338},[54,1017,1018],{"class":60},"  expect",[54,1020,736],{"class":78},[54,1022,1023],{"class":143},"DEFAULT_KB_MIN_SCORE",[54,1025,1026],{"class":78},").",[54,1028,1029],{"class":60},"toBeLessThanOrEqual",[54,1031,736],{"class":78},[54,1033,1034],{"class":143},"0.4",[54,1036,1037],{"class":78},");\n",[54,1039,1040],{"class":56,"line":347},[54,1041,1042],{"class":78},"});\n",[54,1044,1045],{"class":56,"line":575},[54,1046,1047],{"emptyLinePlaceholder":476},"\n",[54,1049,1050,1052,1054,1057,1059,1061],{"class":56,"line":659},[54,1051,999],{"class":60},[54,1053,736],{"class":78},[54,1055,1056],{"class":64},"\"精排阈值不得高到会随分数漂移漏召——观测最低正确分 0.131\"",[54,1058,1007],{"class":78},[54,1060,1010],{"class":71},[54,1062,1013],{"class":78},[54,1064,1065,1067,1069,1072,1074,1076,1078,1081],{"class":56,"line":665},[54,1066,1018],{"class":60},[54,1068,736],{"class":78},[54,1070,1071],{"class":143},"DEFAULT_KB_RERANK_MIN_SCORE",[54,1073,1026],{"class":78},[54,1075,1029],{"class":60},[54,1077,736],{"class":78},[54,1079,1080],{"class":143},"0.1",[54,1082,1037],{"class":78},[54,1084,1085],{"class":56,"line":671},[54,1086,1042],{"class":78},[10,1088,1089],{},"这样写是因为真正的风险不是「有人改了数值」，而是「有人又推了一遍公式」。测试防的是后者。",[10,1091,1092],{},"还有一条测试值得抄下来：",[44,1094,1096],{"className":543,"code":1095,"language":545,"meta":49,"style":49},"it(\"精排阈值必须低于向量阈值——两者不是同一个量纲\", () => {\n  \u002F\u002F 精排是 cross-encoder 分，向量是余弦分，拿同一个数去卡两边必然有一边错\n  expect(DEFAULT_KB_RERANK_MIN_SCORE).toBeLessThan(DEFAULT_KB_MIN_SCORE);\n});\n",[51,1097,1098,1113,1118,1137],{"__ignoreMap":49},[54,1099,1100,1102,1104,1107,1109,1111],{"class":56,"line":57},[54,1101,999],{"class":60},[54,1103,736],{"class":78},[54,1105,1106],{"class":64},"\"精排阈值必须低于向量阈值——两者不是同一个量纲\"",[54,1108,1007],{"class":78},[54,1110,1010],{"class":71},[54,1112,1013],{"class":78},[54,1114,1115],{"class":56,"line":338},[54,1116,1117],{"class":552},"  \u002F\u002F 精排是 cross-encoder 分，向量是余弦分，拿同一个数去卡两边必然有一边错\n",[54,1119,1120,1122,1124,1126,1128,1131,1133,1135],{"class":56,"line":347},[54,1121,1018],{"class":60},[54,1123,736],{"class":78},[54,1125,1071],{"class":143},[54,1127,1026],{"class":78},[54,1129,1130],{"class":60},"toBeLessThan",[54,1132,736],{"class":78},[54,1134,1023],{"class":143},[54,1136,1037],{"class":78},[54,1138,1139],{"class":56,"line":575},[54,1140,1042],{"class":78},[10,1142,1143],{},"同一个数量级、看起来可比，实际是两个不同的量纲。这种错觉在阈值调参里很常见，能用一条断言拦下来比写在注释里可靠。",[17,1145,1147],{"id":1146},"七评测集的缺口","七、评测集的缺口",[10,1149,1150],{},"这一轮所有数字来自临时扫的查询集：5 篇文档 6 个查询、12 条查询。",[10,1152,1153,1154,1157,1158,427],{},"仓库里确实有一个召回评测脚本（用户问题、期望命中的文档名、recall@K \u002F precision@K \u002F MRR 双模式），但它的标注集还是占位内容——两条用例，",[51,1155,1156],{},"kbIds"," 的值是字面量 ",[51,1159,1160],{},"\u003C替换为真实 kbId>",[10,1162,1163],{},"也就是说，这一轮的标定用的数据集没有入库。数字留在代码注释、配置和环境变量说明里，跑分的脚本和查询集没有留下。",[10,1165,1166,1167,1170],{},"这是这次工作里最该补上的一环。",[190,1168,1169],{},"阈值有实测依据，但依据本身不可复现。"," 下次有人想调整，只能重新扫一遍查询。",[10,1172,1173],{},"补的做法是明确的：把这一轮用的查询集和期望结果补进评测脚本的标注集，让它成为下一次调参的起点。参数变更时先跑评测再改值，改完之后把新的分数写进注释——注释里的数字应该来自一次可复现的运行，而不是一次性的手测。",[453,1175,1176],{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":49,"searchDepth":338,"depth":338,"links":1178},[1179,1180,1181,1182,1183,1184,1185],{"id":502,"depth":338,"text":503},{"id":590,"depth":338,"text":591},{"id":768,"depth":338,"text":769},{"id":787,"depth":338,"text":788},{"id":889,"depth":338,"text":890},{"id":911,"depth":338,"text":912},{"id":1146,"depth":338,"text":1147},"2026-08-31",{},"\u002F2026-08-31",{"title":491,"description":496},"2026-08-31-阈值不能推只能量","知识库检索质量的三次修正：分块阈值按比例推算算出了全场最差点，精排阈值设高了会随分数漂移随机漏召，超时余量不够会静默降级。三次都靠实测数据定值。",[1193,1194,1195,1196,1197,1198,1199],"知识库","RAG","分块","检索","cross-encoder","阈值标定","评测","VbN-ARGFFeAmQMRZf2jG-4SfdtN22uzMLyCrc3dCuA8",{"id":1202,"title":1203,"body":1204,"column":471,"date":1835,"description":1208,"extension":473,"hero_image":474,"meta":1836,"navigation":476,"path":1837,"seo":1838,"series_id":474,"severity":474,"stem":1839,"summary":1840,"tags":1841,"__hash__":1848},"posts\u002F2026-08-20-画布这一步深链运行历史与续跑.md","画布这一步：深链、运行历史与续跑",{"type":7,"value":1205,"toc":1825},[1206,1209,1212,1216,1219,1222,1228,1231,1246,1260,1263,1267,1270,1276,1279,1286,1289,1298,1301,1305,1308,1313,1345,1348,1353,1373,1380,1383,1387,1390,1479,1482,1485,1489,1492,1510,1519,1522,1525,1529,1532,1535,1555,1558,1563,1566,1598,1601,1697,1700,1706,1713,1717,1720,1723,1761,1768,1771,1777,1780,1785,1789,1792,1801,1807,1813,1819,1822],[10,1207,1208],{},"画布是这套平台里最复杂的界面。节点、连线、参数、批量框、运行状态，任何一件事都要在同一个视口里表达清楚。",[10,1210,1211],{},"这一天补齐八项操作。每一项都不难，难的是它们之间不能互相打架。",[17,1213,1215],{"id":1214},"一url-是唯一事实来源","一、URL 是唯一事实来源",[10,1217,1218],{},"原先画布是个单页状态机：打开就是列表，点进去切到编辑器，刷新回列表。",[10,1220,1221],{},"改成路由：",[44,1223,1226],{"className":1224,"code":1225,"language":524},[522],"\u002Fcanvas            列表\n\u002Fcanvas\u002F:id        编辑器\n",[51,1227,1225],{"__ignoreMap":49},[10,1229,1230],{},"四个行为一起对齐：",[1232,1233,1234,1237,1240,1243],"ul",{},[230,1235,1236],{},"深链直达编辑器。",[230,1238,1239],{},"刷新不回列表。",[230,1241,1242],{},"后退回列表，而不是退出画布模块。",[230,1244,1245],{},"打开画布写入地址栏。",[10,1247,1248,1251,1252,1255,1256,1259],{},[51,1249,1250],{},"popstate"," 监听已有的解析函数，",[51,1253,1254],{},"pushState"," \u002F ",[51,1257,1258],{},"replaceState"," 写入。",[10,1261,1262],{},"这条改动看起来只是加了个路由，实际改变的是状态的归属：画布 ID 从组件内的 ref 变成了 URL 的一部分。后面几项都依赖它——运行历史要能链到具体的运行，批量框要能被分享，都要求「当前在看哪张画布」是一个可以从外部确定的量。",[17,1264,1266],{"id":1265},"二运行历史","二、运行历史",[10,1268,1269],{},"一个新面板，两个接口：",[44,1271,1274],{"className":1272,"code":1273,"language":524},[522],"GET \u002Fapi\u002Fcanvas-flow\u002Fruns?flowId=…      列表\nGET \u002Fapi\u002Fcanvas-flow\u002Fruns\u002F:id           快照\n",[51,1275,1273],{"__ignoreMap":49},[10,1277,1278],{},"列表默认取 10 条。点开某一条，用它的快照推导出画布上每个节点的状态，覆盖显示。",[10,1280,1281,1282,1285],{},"这个面板的数据来源和实时运行状态用的是同一套 ",[51,1283,1284],{},"nodeStates","——历史记录不是另一条平行显示，而是把画布切到那一次运行的视角。",[10,1287,1288],{},"登录态走单一来源：",[44,1290,1292],{"className":543,"code":1291,"language":545,"meta":49,"style":49},"\u002F\u002F 登录态只有 authToken() 一个来源（有守卫测试盯着，别直读 localStorage）\n",[51,1293,1294],{"__ignoreMap":49},[54,1295,1296],{"class":56,"line":57},[54,1297,1291],{"class":552},[10,1299,1300],{},"这条注释是守卫测试抓出来的结果，不是提前的设计。",[17,1302,1304],{"id":1303},"三复制粘贴","三、复制粘贴",[10,1306,1307],{},"复制粘贴里有两个决定值得记。",[10,1309,1310],{},[190,1311,1312],{},"用应用内剪贴板，不碰系统剪贴板。",[44,1314,1316],{"className":543,"code":1315,"language":545,"meta":49,"style":49},"let clipboard: FlowClipboardPayload | null = null;\n",[51,1317,1318],{"__ignoreMap":49},[54,1319,1320,1323,1326,1329,1332,1334,1337,1340,1342],{"class":56,"line":57},[54,1321,1322],{"class":71},"let",[54,1324,1325],{"class":78}," clipboard",[54,1327,1328],{"class":71},":",[54,1330,1331],{"class":60}," FlowClipboardPayload",[54,1333,176],{"class":71},[54,1335,1336],{"class":143}," null",[54,1338,1339],{"class":71}," =",[54,1341,1336],{"class":143},[54,1343,1344],{"class":78},";\n",[10,1346,1347],{},"粘贴的内容是节点和边，格式带版本号。走系统剪贴板意味着把画布的 JSON 写进用户的剪贴板，用户去别处粘贴会看到一堆结构数据。应用内的模块级变量没有这个问题。",[10,1349,1350],{},[190,1351,1352],{},"没选节点时不拦截。",[44,1354,1356],{"className":543,"code":1355,"language":545,"meta":49,"style":49},"\u002F\u002F Cmd\u002FCtrl+C：复制选中的节点。\n\u002F\u002F 没选节点就不拦截——用户可能正在复制节点产物里的文字，抢了就是坏默认行为\n\u002F\u002F Cmd\u002FCtrl+V：粘贴。应用内剪贴板为空时同样放行系统默认行为\n",[51,1357,1358,1363,1368],{"__ignoreMap":49},[54,1359,1360],{"class":56,"line":57},[54,1361,1362],{"class":552},"\u002F\u002F Cmd\u002FCtrl+C：复制选中的节点。\n",[54,1364,1365],{"class":56,"line":338},[54,1366,1367],{"class":552},"\u002F\u002F 没选节点就不拦截——用户可能正在复制节点产物里的文字，抢了就是坏默认行为\n",[54,1369,1370],{"class":56,"line":347},[54,1371,1372],{"class":552},"\u002F\u002F Cmd\u002FCtrl+V：粘贴。应用内剪贴板为空时同样放行系统默认行为\n",[10,1374,1375,1376,1379],{},"这两条合起来是一个原则：",[190,1377,1378],{},"快捷键只在它有明确意图时生效","。用户按 Cmd+C 时可能是想复制提示词里的文字，此时抢过来是损失。",[10,1381,1382],{},"粘贴到画布时重新生成 ID（节点 ID 最多重试 20 次防碰撞），偏移 24 像素，粘贴的结果进入撤销栈并成为新的选中项。",[17,1384,1386],{"id":1385},"四一键整理布局","四、一键整理布局",[10,1388,1389],{},"按依赖深度分层的纯函数，不引布局引擎：",[44,1391,1393],{"className":543,"code":1392,"language":545,"meta":49,"style":49},"\u002F**\n * 一键整理布局：按依赖深度分层的纯函数。\n *\n * 不引 dagre\u002Felk——画布的图是小规模 DAG（上限 100 节点），\n * 「上游在左、下游在右、同层竖排」这一条规则就够读顺一张乱图，\n * 引一个布局引擎为它的边缘能力买单不值。\n *\u002F\nconst COLUMN_GAP = 380;\nconst ROW_GAP = 240;\nconst ORIGIN = { x: 40, y: 60 };\n",[51,1394,1395,1399,1404,1408,1413,1418,1423,1427,1441,1455],{"__ignoreMap":49},[54,1396,1397],{"class":56,"line":57},[54,1398,641],{"class":552},[54,1400,1401],{"class":56,"line":338},[54,1402,1403],{"class":552}," * 一键整理布局：按依赖深度分层的纯函数。\n",[54,1405,1406],{"class":56,"line":347},[54,1407,864],{"class":552},[54,1409,1410],{"class":56,"line":575},[54,1411,1412],{"class":552}," * 不引 dagre\u002Felk——画布的图是小规模 DAG（上限 100 节点），\n",[54,1414,1415],{"class":56,"line":659},[54,1416,1417],{"class":552}," * 「上游在左、下游在右、同层竖排」这一条规则就够读顺一张乱图，\n",[54,1419,1420],{"class":56,"line":665},[54,1421,1422],{"class":552}," * 引一个布局引擎为它的边缘能力买单不值。\n",[54,1424,1425],{"class":56,"line":671},[54,1426,692],{"class":552},[54,1428,1429,1431,1434,1436,1439],{"class":56,"line":677},[54,1430,713],{"class":71},[54,1432,1433],{"class":143}," COLUMN_GAP",[54,1435,1339],{"class":71},[54,1437,1438],{"class":143}," 380",[54,1440,1344],{"class":78},[54,1442,1443,1445,1448,1450,1453],{"class":56,"line":683},[54,1444,713],{"class":71},[54,1446,1447],{"class":143}," ROW_GAP",[54,1449,1339],{"class":71},[54,1451,1452],{"class":143}," 240",[54,1454,1344],{"class":78},[54,1456,1457,1459,1462,1464,1467,1470,1473,1476],{"class":56,"line":689},[54,1458,713],{"class":71},[54,1460,1461],{"class":143}," ORIGIN",[54,1463,1339],{"class":71},[54,1465,1466],{"class":78}," { x: ",[54,1468,1469],{"class":143},"40",[54,1471,1472],{"class":78},", y: ",[54,1474,1475],{"class":143},"60",[54,1477,1478],{"class":78}," };\n",[10,1480,1481],{},"同列保持用户原有的上下相对顺序。这一条是这类功能能不能用的分界线——整理完之后用户还得能认出自己的图。",[10,1483,1484],{},"节点上限 100，单批最大 50 项，展开后的总任务上限 200。这几个数决定了上面那个判断成立：在这个规模内，一条规则够用。",[17,1486,1488],{"id":1487},"五便签不能做成节点","五、便签不能做成节点",[10,1490,1491],{},"画布上要有地方写注释。最自然的做法是加一个「便签节点」，但它的实现方式正好相反：",[44,1493,1495],{"className":543,"code":1494,"language":545,"meta":49,"style":49},"\u002F**\n * 画布便签：纯注释，不参与调度、连线与计费。\n *\u002F\n",[51,1496,1497,1501,1506],{"__ignoreMap":49},[54,1498,1499],{"class":56,"line":57},[54,1500,641],{"class":552},[54,1502,1503],{"class":56,"line":338},[54,1504,1505],{"class":552}," * 画布便签：纯注释，不参与调度、连线与计费。\n",[54,1507,1508],{"class":56,"line":347},[54,1509,692],{"class":552},[44,1511,1513],{"className":543,"code":1512,"language":545,"meta":49,"style":49},"\u002F\u002F 不是 xyflow 节点——它不参与连线、调度与计费，做成节点要在注册表、校验器、执行器三处逐一开豁免，成本远高于一张自绘卡片。\n",[51,1514,1515],{"__ignoreMap":49},[54,1516,1517],{"class":56,"line":57},[54,1518,1512],{"class":552},[10,1520,1521],{},"做成节点意味着它要在三处地方被显式排除。而排除逻辑每加一处，将来就多一处要维护的例外。做成一张画在流坐标系里的自绘卡片，这些例外一个都不需要。",[10,1523,1524],{},"契约里限制条数 100、单条 2000 字。",[17,1526,1528],{"id":1527},"六批量框","六、批量框",[10,1530,1531],{},"批量框是「框内的子图跑 N 遍」。它带来的第一件事是边界规则：",[10,1533,1534],{},"框内节点的输出不能接到框外，反过来可以。",[44,1536,1538],{"className":543,"code":1537,"language":545,"meta":49,"style":49},"\u002F\u002F 批量框边界规则：框内节点的输出不能接到框外（或另一个框）。\n\u002F\u002F 框内子图每份各跑一遍，往外接意味着下游要收 N 份——运行时不支持这种收束；\n\u002F\u002F 反方向（框外 → 框内）合法：同一上游共享给每份拷贝。\n",[51,1539,1540,1545,1550],{"__ignoreMap":49},[54,1541,1542],{"class":56,"line":57},[54,1543,1544],{"class":552},"\u002F\u002F 批量框边界规则：框内节点的输出不能接到框外（或另一个框）。\n",[54,1546,1547],{"class":56,"line":338},[54,1548,1549],{"class":552},"\u002F\u002F 框内子图每份各跑一遍，往外接意味着下游要收 N 份——运行时不支持这种收束；\n",[54,1551,1552],{"class":56,"line":347},[54,1553,1554],{"class":552},"\u002F\u002F 反方向（框外 → 框内）合法：同一上游共享给每份拷贝。\n",[10,1556,1557],{},"拒绝时的文案要给下一步：",[508,1559,1560],{},[10,1561,1562],{},"批量框内的节点不能连到框外：框内每份各跑一遍，产物会直接进素材库。要串联处理就把目标节点也拖进框里。",[10,1564,1565],{},"第二件事是删除节点的连带处理。节点被删掉之后，批量框里会留下幽灵成员，而运行创建时会拒绝整张图：",[44,1567,1569],{"className":543,"code":1568,"language":545,"meta":49,"style":49},"\u002F**\n * 从所有批量框成员里剔除已删除的节点；成员清空的框一并删除。\n *\n * 删除节点必须同步清理：残留的幽灵成员会让 run-create 直接拒绝整张画布\n * （「批量框引用了图中不存在的节点」），用户面对的是一张再也跑不起来的图。\n *\u002F\n",[51,1570,1571,1575,1580,1584,1589,1594],{"__ignoreMap":49},[54,1572,1573],{"class":56,"line":57},[54,1574,641],{"class":552},[54,1576,1577],{"class":56,"line":338},[54,1578,1579],{"class":552}," * 从所有批量框成员里剔除已删除的节点；成员清空的框一并删除。\n",[54,1581,1582],{"class":56,"line":347},[54,1583,864],{"class":552},[54,1585,1586],{"class":56,"line":575},[54,1587,1588],{"class":552}," * 删除节点必须同步清理：残留的幽灵成员会让 run-create 直接拒绝整张画布\n",[54,1590,1591],{"class":56,"line":659},[54,1592,1593],{"class":552}," * （「批量框引用了图中不存在的节点」），用户面对的是一张再也跑不起来的图。\n",[54,1595,1596],{"class":56,"line":665},[54,1597,692],{"class":552},[10,1599,1600],{},"第三件事是撤销栈。批量框要进快照：",[44,1602,1604],{"className":543,"code":1603,"language":545,"meta":49,"style":49},"export interface GraphSnapshot {\n  readonly nodes: readonly CanvasFlowNode[];\n  readonly edges: readonly CanvasFlowEdge[];\n  readonly selected: readonly string[];\n  \u002F** 批量框。撤销\u002F重做要连它一起回放，否则撤销删框后节点回来了框没了 *\u002F\n  readonly batchGroups: readonly CanvasFlowBatchGroup[];\n}\n",[51,1605,1606,1619,1639,1655,1671,1676,1692],{"__ignoreMap":49},[54,1607,1608,1611,1614,1617],{"class":56,"line":57},[54,1609,1610],{"class":71},"export",[54,1612,1613],{"class":71}," interface",[54,1615,1616],{"class":60}," GraphSnapshot",[54,1618,1013],{"class":78},[54,1620,1621,1624,1628,1630,1633,1636],{"class":56,"line":338},[54,1622,1623],{"class":71},"  readonly",[54,1625,1627],{"class":1626},"s4XuR"," nodes",[54,1629,1328],{"class":71},[54,1631,1632],{"class":71}," readonly",[54,1634,1635],{"class":60}," CanvasFlowNode",[54,1637,1638],{"class":78},"[];\n",[54,1640,1641,1643,1646,1648,1650,1653],{"class":56,"line":347},[54,1642,1623],{"class":71},[54,1644,1645],{"class":1626}," edges",[54,1647,1328],{"class":71},[54,1649,1632],{"class":71},[54,1651,1652],{"class":60}," CanvasFlowEdge",[54,1654,1638],{"class":78},[54,1656,1657,1659,1662,1664,1666,1669],{"class":56,"line":575},[54,1658,1623],{"class":71},[54,1660,1661],{"class":1626}," selected",[54,1663,1328],{"class":71},[54,1665,1632],{"class":71},[54,1667,1668],{"class":143}," string",[54,1670,1638],{"class":78},[54,1672,1673],{"class":56,"line":659},[54,1674,1675],{"class":552},"  \u002F** 批量框。撤销\u002F重做要连它一起回放，否则撤销删框后节点回来了框没了 *\u002F\n",[54,1677,1678,1680,1683,1685,1687,1690],{"class":56,"line":665},[54,1679,1623],{"class":71},[54,1681,1682],{"class":1626}," batchGroups",[54,1684,1328],{"class":71},[54,1686,1632],{"class":71},[54,1688,1689],{"class":60}," CanvasFlowBatchGroup",[54,1691,1638],{"class":78},[54,1693,1694],{"class":56,"line":671},[54,1695,1696],{"class":78},"}\n",[10,1698,1699],{},"第四件事是状态聚合。批量框内一个节点对应多行执行状态，显示取哪个：",[44,1701,1704],{"className":1702,"code":1703,"language":524},[522],"failed > running > pending > cancelled > succeeded > idle\n",[51,1705,1703],{"__ignoreMap":49},[10,1707,1708,1709,1712],{},"原先只让 ",[51,1710,1711],{},"failed"," 优先。结果是第一份先成功、第二份还在跑的时候，节点就提前显示成「成功」。",[17,1714,1716],{"id":1715},"七续跑不重复扣费","七、续跑不重复扣费",[10,1718,1719],{},"这一项是这八项里唯一涉及钱的。",[10,1721,1722],{},"失败或被取消的运行，可以续跑。实现方式不是「重跑一遍」：",[44,1724,1726],{"className":543,"code":1725,"language":545,"meta":49,"style":49},"\u002F**\n * 单节点重试：给失败\u002F被取消的运行造一个「续跑」运行。成功节点的行原样回填\n * （产物、billingRef、时间戳都保留）——executor 的调度器见到 succeeded 行\n * 会直接当作上游已就绪，不会重新执行，也就不会重复扣费；其余节点\n * （failed \u002F cancelled \u002F pending）重置成全新的 pending 行，正常调度重跑。\n * 不修改原运行：重试是一条新的 CanvasFlowRun，历史记录保持完整。\n *\u002F\n",[51,1727,1728,1732,1737,1742,1747,1752,1757],{"__ignoreMap":49},[54,1729,1730],{"class":56,"line":57},[54,1731,641],{"class":552},[54,1733,1734],{"class":56,"line":338},[54,1735,1736],{"class":552}," * 单节点重试：给失败\u002F被取消的运行造一个「续跑」运行。成功节点的行原样回填\n",[54,1738,1739],{"class":56,"line":347},[54,1740,1741],{"class":552}," * （产物、billingRef、时间戳都保留）——executor 的调度器见到 succeeded 行\n",[54,1743,1744],{"class":56,"line":575},[54,1745,1746],{"class":552}," * 会直接当作上游已就绪，不会重新执行，也就不会重复扣费；其余节点\n",[54,1748,1749],{"class":56,"line":659},[54,1750,1751],{"class":552}," * （failed \u002F cancelled \u002F pending）重置成全新的 pending 行，正常调度重跑。\n",[54,1753,1754],{"class":56,"line":665},[54,1755,1756],{"class":552}," * 不修改原运行：重试是一条新的 CanvasFlowRun，历史记录保持完整。\n",[54,1758,1759],{"class":56,"line":671},[54,1760,692],{"class":552},[10,1762,1763,1764,1767],{},"关键在于复用判定落在行状态上，而不是「这次运行是新是旧」。所以续跑不需要额外的豁免逻辑：被判成功的节点带着原来的 ",[51,1765,1766],{},"billingRef","，调度器看到它就不再执行。",[10,1769,1770],{},"预估只算子集，余额检查同理。接口层有幂等入口（按用户 + 请求 ID 查重）和归属校验。不可重试的两种情形给出明确文案：",[44,1772,1775],{"className":1773,"code":1774,"language":524},[522],"这次运行已全部成功，没有可重试的节点\n运行还没结束，等它终结后再重试\n",[51,1776,1774],{"__ignoreMap":49},[10,1778,1779],{},"界面上的按钮从「重试」改成「重试失败节点」，带一句说明：",[508,1781,1782],{},[10,1783,1784],{},"成功节点的产物直接沿用，只有失败的节点会重新执行并计费。",[17,1786,1788],{"id":1787},"八八项之间的关系","八、八项之间的关系",[10,1790,1791],{},"单独看每一项，都是常规功能。放在一起时，出现了几条贯穿的取舍：",[10,1793,1794,1797,1798,1800],{},[190,1795,1796],{},"状态的归属要单一。"," 画布 ID 放 URL；运行状态用同一套 ",[51,1799,1284],{},"；撤销栈是唯一的变更入口。三处都收成一个来源之后，「刷新之后看到什么」才有确定答案。",[10,1802,1803,1806],{},[190,1804,1805],{},"例外要少。"," 便签不做成节点，就是为了避免在注册表、校验器、执行器三处开豁免。每开一处例外，就多一处将来会忘记的地方。",[10,1808,1809,1812],{},[190,1810,1811],{},"别抢用户的操作。"," 没选节点时不拦 Cmd+C；整理布局保留同列原有顺序；批量框拒绝时给下一步而不是只报错。",[10,1814,1815,1818],{},[190,1816,1817],{},"涉及钱的判定落在状态上。"," 续跑复用靠行状态，不靠运行的新旧；批量份数由框上显式配置，预估与执行读同一个函数。",[10,1820,1821],{},"最后一条是这一天唯一和故障档案有关的部分。同一天里，批量链路翻出一处行约定矛盾和一处从未命中的推断分支，两处的成因都是「两侧各写一份」。八项补齐之后，取值入口都比之前更集中——这不是巧合，是同一件事的两个方向。",[453,1823,1824],{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":49,"searchDepth":338,"depth":338,"links":1826},[1827,1828,1829,1830,1831,1832,1833,1834],{"id":1214,"depth":338,"text":1215},{"id":1265,"depth":338,"text":1266},{"id":1303,"depth":338,"text":1304},{"id":1385,"depth":338,"text":1386},{"id":1487,"depth":338,"text":1488},{"id":1527,"depth":338,"text":1528},{"id":1715,"depth":338,"text":1716},{"id":1787,"depth":338,"text":1788},"2026-08-20",{},"\u002F2026-08-20",{"title":1203,"description":1208},"2026-08-20-画布这一步深链运行历史与续跑","一次补齐八项画布操作：URL 深链、小地图、运行历史、复制粘贴、批量框、一键整理布局、便签，以及失败运行的单节点续跑。",[1842,1843,1844,1845,1846,1847],"画布","工作流","URL状态","撤销重做","批量执行","单节点重试","eA-RfotRTWBXXppeiZFtcguPXjtpnVRWrkWgI5LKfY0",{"id":1850,"title":1851,"body":1852,"column":471,"date":2889,"description":1856,"extension":473,"hero_image":474,"meta":2890,"navigation":476,"path":2891,"seo":2892,"series_id":474,"severity":474,"stem":2893,"summary":2894,"tags":2895,"__hash__":2901},"posts\u002F2026-08-08-同一张能力表抄了三份.md","同一张能力表，抄了三份",{"type":7,"value":1853,"toc":2881},[1854,1857,1860,1863,1867,1870,2021,2024,2030,2036,2039,2107,2110,2114,2117,2120,2142,2145,2148,2154,2169,2172,2176,2179,2182,2188,2191,2319,2322,2331,2334,2341,2551,2554,2558,2569,2572,2575,2636,2639,2700,2703,2736,2740,2743,2746,2752,2759,2762,2769,2776,2780,2783,2810,2816,2819,2869,2875,2878],[10,1855,1856],{},"用户反馈：设置里选完模型，分辨率档不对——Seedance-2.5 只有 480P 和 720P；另外 2.5 的上游支持到 29 秒，故事板那边应该跟着放开。",[10,1858,1859],{},"第一句说得对，2.5 确实只有 480p 和 720p。第二句也对，它确实支持 4~29 秒。",[10,1861,1862],{},"两条都对，说明我们发出去的档位和上游支持的对不上。查下去发现，同一份能力表被抄了三份。",[17,1864,1866],{"id":1865},"一三处各自漂移","一、三处各自漂移",[10,1868,1869],{},"后端有一张权威表，写清了每个模型支持哪些分辨率、哪些时长：",[44,1871,1873],{"className":543,"code":1872,"language":545,"meta":49,"style":49},"const MODEL_RESOLUTIONS: Record\u003CVideoModel, readonly VideoResolution[]> = {\n  \"seedance-2\": [\"480p\", \"720p\", \"1080p\", \"4k\"],\n  \"seedance-2-fast\": [\"480p\", \"720p\"],\n  \"seedance-2-mini\": [\"480p\", \"720p\"],\n  \"seedance-2.5\": [\"480p\", \"720p\"],\n  \"kling-v3\": [\"720p\", \"1080p\"],\n  \"minimax-h3\": [\"2k\", \"768p\"],\n};\n",[51,1874,1875,1910,1939,1954,1969,1984,1999,2016],{"__ignoreMap":49},[54,1876,1877,1879,1882,1884,1887,1890,1893,1896,1899,1902,1905,1908],{"class":56,"line":57},[54,1878,713],{"class":71},[54,1880,1881],{"class":143}," MODEL_RESOLUTIONS",[54,1883,1328],{"class":71},[54,1885,1886],{"class":60}," Record",[54,1888,1889],{"class":78},"\u003C",[54,1891,1892],{"class":60},"VideoModel",[54,1894,1895],{"class":78},", ",[54,1897,1898],{"class":71},"readonly",[54,1900,1901],{"class":60}," VideoResolution",[54,1903,1904],{"class":78},"[]> ",[54,1906,1907],{"class":71},"=",[54,1909,1013],{"class":78},[54,1911,1912,1915,1918,1921,1923,1926,1928,1931,1933,1936],{"class":56,"line":338},[54,1913,1914],{"class":64},"  \"seedance-2\"",[54,1916,1917],{"class":78},": [",[54,1919,1920],{"class":64},"\"480p\"",[54,1922,1895],{"class":78},[54,1924,1925],{"class":64},"\"720p\"",[54,1927,1895],{"class":78},[54,1929,1930],{"class":64},"\"1080p\"",[54,1932,1895],{"class":78},[54,1934,1935],{"class":64},"\"4k\"",[54,1937,1938],{"class":78},"],\n",[54,1940,1941,1944,1946,1948,1950,1952],{"class":56,"line":347},[54,1942,1943],{"class":64},"  \"seedance-2-fast\"",[54,1945,1917],{"class":78},[54,1947,1920],{"class":64},[54,1949,1895],{"class":78},[54,1951,1925],{"class":64},[54,1953,1938],{"class":78},[54,1955,1956,1959,1961,1963,1965,1967],{"class":56,"line":575},[54,1957,1958],{"class":64},"  \"seedance-2-mini\"",[54,1960,1917],{"class":78},[54,1962,1920],{"class":64},[54,1964,1895],{"class":78},[54,1966,1925],{"class":64},[54,1968,1938],{"class":78},[54,1970,1971,1974,1976,1978,1980,1982],{"class":56,"line":659},[54,1972,1973],{"class":64},"  \"seedance-2.5\"",[54,1975,1917],{"class":78},[54,1977,1920],{"class":64},[54,1979,1895],{"class":78},[54,1981,1925],{"class":64},[54,1983,1938],{"class":78},[54,1985,1986,1989,1991,1993,1995,1997],{"class":56,"line":665},[54,1987,1988],{"class":64},"  \"kling-v3\"",[54,1990,1917],{"class":78},[54,1992,1925],{"class":64},[54,1994,1895],{"class":78},[54,1996,1930],{"class":64},[54,1998,1938],{"class":78},[54,2000,2001,2004,2006,2009,2011,2014],{"class":56,"line":671},[54,2002,2003],{"class":64},"  \"minimax-h3\"",[54,2005,1917],{"class":78},[54,2007,2008],{"class":64},"\"2k\"",[54,2010,1895],{"class":78},[54,2012,2013],{"class":64},"\"768p\"",[54,2015,1938],{"class":78},[54,2017,2018],{"class":56,"line":677},[54,2019,2020],{"class":78},"};\n",[10,2022,2023],{},"前端有一份手抄的副本。它早就漂了，而且漂在两个方向。",[10,2025,2026,2029],{},[190,2027,2028],{},"多出的档位。"," 设置页的取档逻辑是写死的规则：「H3 两档、其余一律四档」。于是 2.5、fast、mini 这三个只有 480p 和 720p 的模型，界面上给出 1080P 和 4K。用户选了，选中即报未配价，或者直接被上游拒。",[10,2031,2032,2035],{},[190,2033,2034],{},"少掉的档位。"," Kling 只支持 720p 和 1080p，界面给的是四档。反过来的情况也存在：如果某个模型的档位比默认四档更多，界面也显示不出来。",[10,2037,2038],{},"时长那一处漂得更彻底。前端和后端各写了一份 4~15 的夹取：",[44,2040,2042],{"className":543,"code":2041,"language":545,"meta":49,"style":49},"\u002F\u002F 前端\nreturn Math.max(4, Math.min(15, value));\n\n\u002F\u002F 后端\nreturn Math.max(4, Math.min(15, value));\n",[51,2043,2044,2049,2076,2080,2085],{"__ignoreMap":49},[54,2045,2046],{"class":56,"line":57},[54,2047,2048],{"class":552},"\u002F\u002F 前端\n",[54,2050,2051,2054,2056,2059,2061,2064,2066,2068,2070,2073],{"class":56,"line":338},[54,2052,2053],{"class":71},"return",[54,2055,730],{"class":78},[54,2057,2058],{"class":60},"max",[54,2060,736],{"class":78},[54,2062,2063],{"class":143},"4",[54,2065,742],{"class":78},[54,2067,733],{"class":60},[54,2069,736],{"class":78},[54,2071,2072],{"class":143},"15",[54,2074,2075],{"class":78},", value));\n",[54,2077,2078],{"class":56,"line":347},[54,2079,1047],{"emptyLinePlaceholder":476},[54,2081,2082],{"class":56,"line":575},[54,2083,2084],{"class":552},"\u002F\u002F 后端\n",[54,2086,2087,2089,2091,2093,2095,2097,2099,2101,2103,2105],{"class":56,"line":659},[54,2088,2053],{"class":71},[54,2090,730],{"class":78},[54,2092,2058],{"class":60},[54,2094,736],{"class":78},[54,2096,2063],{"class":143},[54,2098,742],{"class":78},[54,2100,733],{"class":60},[54,2102,736],{"class":78},[54,2104,2072],{"class":143},[54,2106,2075],{"class":78},[10,2108,2109],{},"2.5 传 29 秒，被悄悄砍成 15。用户看不出为什么变短——没有任何提示，界面上显示的就是 15 秒。",[17,2111,2113],{"id":2112},"二被砍掉的秒数后面跟着钱","二、被砍掉的秒数后面跟着钱",[10,2115,2116],{},"时长那一处还不只是显示问题。",[10,2118,2119],{},"故事板按固定秒数切板。代码里是一个常量：",[44,2121,2123],{"className":543,"code":2122,"language":545,"meta":49,"style":49},"export const SHOT_SECONDS = 15;\n",[51,2124,2125],{"__ignoreMap":49},[54,2126,2127,2129,2132,2135,2137,2140],{"class":56,"line":57},[54,2128,1610],{"class":71},[54,2130,2131],{"class":71}," const",[54,2133,2134],{"class":143}," SHOT_SECONDS",[54,2136,1339],{"class":71},[54,2138,2139],{"class":143}," 15",[54,2141,1344],{"class":78},[10,2143,2144],{},"按 15 秒一切。2.5 一板能放 29 秒，硬按 15 秒切，一集会被切成两倍数量的板。",[10,2146,2147],{},"板数翻倍就是出图与出片的费用翻倍。这不是理论推算——用户选 2.5 的动机就是长板数少切，结果切得和短时长模型一样多，还多花一倍钱。",[10,2149,2150,2153],{},[51,2151,2152],{},"comic-subshot.ts"," 里留着上限常量的注释，写明了它只是「模型未知时的保守默认」：",[44,2155,2157],{"className":543,"code":2156,"language":545,"meta":49,"style":49},"\u002F\u002F 上限只是「模型未知时的保守默认」——真正的上限逐模型不同（seedance-2.5 能到 29 秒），\n\u002F\u002F 调用方应把该模型的最大秒数作为 maxBoardSec 传进来。写死 15 的话，选了 2.5 也只切 15 秒一板。\n",[51,2158,2159,2164],{"__ignoreMap":49},[54,2160,2161],{"class":56,"line":57},[54,2162,2163],{"class":552},"\u002F\u002F 上限只是「模型未知时的保守默认」——真正的上限逐模型不同（seedance-2.5 能到 29 秒），\n",[54,2165,2166],{"class":56,"line":338},[54,2167,2168],{"class":552},"\u002F\u002F 调用方应把该模型的最大秒数作为 maxBoardSec 传进来。写死 15 的话，选了 2.5 也只切 15 秒一板。\n",[10,2170,2171],{},"调用方没传。",[17,2173,2175],{"id":2174},"三提示词长度四个数一个是实测出来的","三、提示词长度：四个数，一个是实测出来的",[10,2177,2178],{},"同一批里还有一个更贵的限制：提示词长度上限。",[10,2180,2181],{},"上游对提示词有硬上限，超过直接拒。各模型不一样，而这些值在很长一段时间里没有集中维护。表现是一条线上的完整失败：",[44,2183,2186],{"className":2184,"code":2185,"language":524},[522],"模型 seedance-2.5 的提示词不能超过 5000 个字符，当前为 18545 个字符\n",[51,2187,2185],{"__ignoreMap":49},[10,2189,2190],{},"接入本身没问题，是缺了长度约束。补完之后这张表长这样：",[44,2192,2194],{"className":543,"code":2193,"language":545,"meta":49,"style":49},"const MODEL_PROMPT_LIMITS: Partial\u003CRecord\u003CVideoModel, number>> = {\n  \"minimax-h3\": 7000,\n  \"seedance-2\": 2500,\n  \"seedance-2-fast\": 2500,\n  \"seedance-2-mini\": 2500,\n  \"seedance-2.5\": 5000,\n  \u002F\u002F 20260809 实测：发 2553 字被硬拒 `prompt: size must be between 0 and 2500`\n  \"kling-v3\": 2500,\n};\nexport const PROMPT_LIMIT_FALLBACK = 50000;\n",[51,2195,2196,2229,2242,2253,2263,2273,2284,2289,2299,2303],{"__ignoreMap":49},[54,2197,2198,2200,2203,2205,2208,2210,2213,2215,2217,2219,2222,2225,2227],{"class":56,"line":57},[54,2199,713],{"class":71},[54,2201,2202],{"class":143}," MODEL_PROMPT_LIMITS",[54,2204,1328],{"class":71},[54,2206,2207],{"class":60}," Partial",[54,2209,1889],{"class":78},[54,2211,2212],{"class":60},"Record",[54,2214,1889],{"class":78},[54,2216,1892],{"class":60},[54,2218,1895],{"class":78},[54,2220,2221],{"class":143},"number",[54,2223,2224],{"class":78},">> ",[54,2226,1907],{"class":71},[54,2228,1013],{"class":78},[54,2230,2231,2233,2236,2239],{"class":56,"line":338},[54,2232,2003],{"class":64},[54,2234,2235],{"class":78},": ",[54,2237,2238],{"class":143},"7000",[54,2240,2241],{"class":78},",\n",[54,2243,2244,2246,2248,2251],{"class":56,"line":347},[54,2245,1914],{"class":64},[54,2247,2235],{"class":78},[54,2249,2250],{"class":143},"2500",[54,2252,2241],{"class":78},[54,2254,2255,2257,2259,2261],{"class":56,"line":575},[54,2256,1943],{"class":64},[54,2258,2235],{"class":78},[54,2260,2250],{"class":143},[54,2262,2241],{"class":78},[54,2264,2265,2267,2269,2271],{"class":56,"line":659},[54,2266,1958],{"class":64},[54,2268,2235],{"class":78},[54,2270,2250],{"class":143},[54,2272,2241],{"class":78},[54,2274,2275,2277,2279,2282],{"class":56,"line":665},[54,2276,1973],{"class":64},[54,2278,2235],{"class":78},[54,2280,2281],{"class":143},"5000",[54,2283,2241],{"class":78},[54,2285,2286],{"class":56,"line":671},[54,2287,2288],{"class":552},"  \u002F\u002F 20260809 实测：发 2553 字被硬拒 `prompt: size must be between 0 and 2500`\n",[54,2290,2291,2293,2295,2297],{"class":56,"line":677},[54,2292,1988],{"class":64},[54,2294,2235],{"class":78},[54,2296,2250],{"class":143},[54,2298,2241],{"class":78},[54,2300,2301],{"class":56,"line":683},[54,2302,2020],{"class":78},[54,2304,2305,2307,2309,2312,2314,2317],{"class":56,"line":689},[54,2306,1610],{"class":71},[54,2308,2131],{"class":71},[54,2310,2311],{"class":143}," PROMPT_LIMIT_FALLBACK",[54,2313,1339],{"class":71},[54,2315,2316],{"class":143}," 50000",[54,2318,1344],{"class":78},[10,2320,2321],{},"这几个数的来源不同，注释里写清了哪个是实测的：",[508,2323,2324],{},[10,2325,2326,2327,2330],{},"seedance-2.5 的 5000 是",[190,2328,2329],{},"实测出来的，文档只字未提","：线上一条 18545 字的脚本被拒，报文写「提示词不能超过 5000 个字符」。同批实测另两条渠道收 16500 字照样 200，所以这是 2.5 独有的限制，不能推广到整个系列。加新渠道前先拿超长 prompt 打一次，别等线上炸。",[10,2332,2333],{},"这段话是这张表里唯一带出处的一条。其余几个数是按上游文档填的，文档没提的只能等线上撞。",[10,2335,2336,2337,2340],{},"长度约束补齐之后，还有一个配套的预算计算。原先只有 H3 有预算，其余模型返回 ",[51,2338,2339],{},"null","，等于完全不限：",[44,2342,2344],{"className":543,"code":2343,"language":545,"meta":49,"style":49},"\u002F** 每秒成片对应的脚本篇幅。15 秒 → 7500 字，是人肉审稿与出片效果都合适的密度。 *\u002F\nexport const CHARS_PER_SECOND = 500;\n\u002F** 留给用户自己追加修改的余量：脚本刚好顶满上限时，用户加一句就被上游拒了。 *\u002F\nconst PROMPT_BUDGET_MARGIN = 500;\n\nexport function scriptCharBudget(targetModel: string | undefined, durationSec?: number): number | null {\n  const hardLimit = modelPromptHardLimit(targetModel);\n  if (!durationSec || !Number.isFinite(durationSec) || durationSec \u003C= 0) return hardLimit;\n  const byDuration = Math.round(durationSec * CHARS_PER_SECOND);\n  return hardLimit ? Math.min(byDuration, hardLimit) : byDuration;\n}\n",[51,2345,2346,2351,2367,2372,2385,2389,2437,2453,2500,2523,2546],{"__ignoreMap":49},[54,2347,2348],{"class":56,"line":57},[54,2349,2350],{"class":552},"\u002F** 每秒成片对应的脚本篇幅。15 秒 → 7500 字，是人肉审稿与出片效果都合适的密度。 *\u002F\n",[54,2352,2353,2355,2357,2360,2362,2365],{"class":56,"line":338},[54,2354,1610],{"class":71},[54,2356,2131],{"class":71},[54,2358,2359],{"class":143}," CHARS_PER_SECOND",[54,2361,1339],{"class":71},[54,2363,2364],{"class":143}," 500",[54,2366,1344],{"class":78},[54,2368,2369],{"class":56,"line":347},[54,2370,2371],{"class":552},"\u002F** 留给用户自己追加修改的余量：脚本刚好顶满上限时，用户加一句就被上游拒了。 *\u002F\n",[54,2373,2374,2376,2379,2381,2383],{"class":56,"line":575},[54,2375,713],{"class":71},[54,2377,2378],{"class":143}," PROMPT_BUDGET_MARGIN",[54,2380,1339],{"class":71},[54,2382,2364],{"class":143},[54,2384,1344],{"class":78},[54,2386,2387],{"class":56,"line":659},[54,2388,1047],{"emptyLinePlaceholder":476},[54,2390,2391,2393,2396,2399,2401,2404,2406,2408,2410,2413,2415,2418,2421,2424,2427,2429,2431,2433,2435],{"class":56,"line":665},[54,2392,1610],{"class":71},[54,2394,2395],{"class":71}," function",[54,2397,2398],{"class":60}," scriptCharBudget",[54,2400,736],{"class":78},[54,2402,2403],{"class":1626},"targetModel",[54,2405,1328],{"class":71},[54,2407,1668],{"class":143},[54,2409,176],{"class":71},[54,2411,2412],{"class":143}," undefined",[54,2414,1895],{"class":78},[54,2416,2417],{"class":1626},"durationSec",[54,2419,2420],{"class":71},"?:",[54,2422,2423],{"class":143}," number",[54,2425,2426],{"class":78},")",[54,2428,1328],{"class":71},[54,2430,2423],{"class":143},[54,2432,176],{"class":71},[54,2434,1336],{"class":143},[54,2436,1013],{"class":78},[54,2438,2439,2442,2445,2447,2450],{"class":56,"line":671},[54,2440,2441],{"class":71},"  const",[54,2443,2444],{"class":143}," hardLimit",[54,2446,1339],{"class":71},[54,2448,2449],{"class":60}," modelPromptHardLimit",[54,2451,2452],{"class":78},"(targetModel);\n",[54,2454,2455,2458,2461,2464,2467,2470,2472,2475,2478,2481,2483,2486,2489,2492,2495,2497],{"class":56,"line":677},[54,2456,2457],{"class":71},"  if",[54,2459,2460],{"class":78}," (",[54,2462,2463],{"class":71},"!",[54,2465,2466],{"class":78},"durationSec ",[54,2468,2469],{"class":71},"||",[54,2471,314],{"class":71},[54,2473,2474],{"class":78},"Number.",[54,2476,2477],{"class":60},"isFinite",[54,2479,2480],{"class":78},"(durationSec) ",[54,2482,2469],{"class":71},[54,2484,2485],{"class":78}," durationSec ",[54,2487,2488],{"class":71},"\u003C=",[54,2490,2491],{"class":143}," 0",[54,2493,2494],{"class":78},") ",[54,2496,2053],{"class":71},[54,2498,2499],{"class":78}," hardLimit;\n",[54,2501,2502,2504,2507,2509,2511,2514,2517,2519,2521],{"class":56,"line":683},[54,2503,2441],{"class":71},[54,2505,2506],{"class":143}," byDuration",[54,2508,1339],{"class":71},[54,2510,730],{"class":78},[54,2512,2513],{"class":60},"round",[54,2515,2516],{"class":78},"(durationSec ",[54,2518,751],{"class":71},[54,2520,2359],{"class":143},[54,2522,1037],{"class":78},[54,2524,2525,2528,2531,2534,2536,2538,2541,2543],{"class":56,"line":689},[54,2526,2527],{"class":71},"  return",[54,2529,2530],{"class":78}," hardLimit ",[54,2532,2533],{"class":71},"?",[54,2535,730],{"class":78},[54,2537,733],{"class":60},[54,2539,2540],{"class":78},"(byDuration, hardLimit) ",[54,2542,1328],{"class":71},[54,2544,2545],{"class":78}," byDuration;\n",[54,2547,2549],{"class":56,"line":2548},11,[54,2550,1696],{"class":78},[10,2552,2553],{},"按 15 秒算出来是 7500 字——上限撤销之后，脚本直接写到 1.8 万字，那也是生成耗时 193 秒的原因。",[17,2555,2557],{"id":2556},"四抄一份是必要的那就测试它","四、抄一份是必要的，那就测试它",[10,2559,2560,2561,2564,2565,2568],{},"前端为什么不能直接读后端那张表：",[51,2562,2563],{},"apps\u002Fweb"," 不能 import ",[51,2566,2567],{},"apps\u002Fapi","。而节点面板必须知道「这个模型支持哪些分辨率、哪些时长」，才能只给出跑得通的选项。",[10,2570,2571],{},"抄一份是必要的。那就把「不许走样」变成断言。",[10,2573,2574],{},"新增的共享包文件头写明了它的性质和守卫：",[44,2576,2578],{"className":543,"code":2577,"language":545,"meta":49,"style":49},"\u002F**\n * 生图 \u002F 视频的**上游真实能力**表。\n *\n * 这是 apps\u002Fapi 里两张表的镜像……\n *\n * **为什么要抄一份**：前端（apps\u002Fweb）不能 import apps\u002Fapi，而节点面板必须知道\n * 「这个模型支持哪些分辨率、哪些时长」才能只给出跑得通的选项。\n * 抄一份就有走样的风险，所以 apps\u002Fapi 里有一条对比测试逐项核对两边——\n * 谁改了上游表而没同步这里，测试立刻红。\n *\n * 硬规矩：这里只允许出现上游真支持的取值。多给一个选项，用户就会选到一个必然失败的组合。\n *\u002F\n",[51,2579,2580,2584,2589,2593,2598,2602,2607,2612,2617,2622,2626,2631],{"__ignoreMap":49},[54,2581,2582],{"class":56,"line":57},[54,2583,641],{"class":552},[54,2585,2586],{"class":56,"line":338},[54,2587,2588],{"class":552}," * 生图 \u002F 视频的**上游真实能力**表。\n",[54,2590,2591],{"class":56,"line":347},[54,2592,864],{"class":552},[54,2594,2595],{"class":56,"line":575},[54,2596,2597],{"class":552}," * 这是 apps\u002Fapi 里两张表的镜像……\n",[54,2599,2600],{"class":56,"line":659},[54,2601,864],{"class":552},[54,2603,2604],{"class":56,"line":665},[54,2605,2606],{"class":552}," * **为什么要抄一份**：前端（apps\u002Fweb）不能 import apps\u002Fapi，而节点面板必须知道\n",[54,2608,2609],{"class":56,"line":671},[54,2610,2611],{"class":552}," * 「这个模型支持哪些分辨率、哪些时长」才能只给出跑得通的选项。\n",[54,2613,2614],{"class":56,"line":677},[54,2615,2616],{"class":552}," * 抄一份就有走样的风险，所以 apps\u002Fapi 里有一条对比测试逐项核对两边——\n",[54,2618,2619],{"class":56,"line":683},[54,2620,2621],{"class":552}," * 谁改了上游表而没同步这里，测试立刻红。\n",[54,2623,2624],{"class":56,"line":689},[54,2625,864],{"class":552},[54,2627,2628],{"class":56,"line":2548},[54,2629,2630],{"class":552}," * 硬规矩：这里只允许出现上游真支持的取值。多给一个选项，用户就会选到一个必然失败的组合。\n",[54,2632,2634],{"class":56,"line":2633},12,[54,2635,692],{"class":552},[10,2637,2638],{},"同时把取值这件事收成单点：",[44,2640,2642],{"className":543,"code":2641,"language":545,"meta":49,"style":49},"\u002F**\n * 前端渲染面板、后端校验参数都走这一个函数——两边各写一份判断，\n * 迟早出现「界面给得出、服务端不认」的组合。\n *\u002F\nexport function resolveDynamicOptions(kind: \"videoResolution\" | \"videoDuration\", params): readonly { value, label }[]\n",[51,2643,2644,2648,2653,2658,2662],{"__ignoreMap":49},[54,2645,2646],{"class":56,"line":57},[54,2647,641],{"class":552},[54,2649,2650],{"class":56,"line":338},[54,2651,2652],{"class":552}," * 前端渲染面板、后端校验参数都走这一个函数——两边各写一份判断，\n",[54,2654,2655],{"class":56,"line":347},[54,2656,2657],{"class":552}," * 迟早出现「界面给得出、服务端不认」的组合。\n",[54,2659,2660],{"class":56,"line":575},[54,2661,692],{"class":552},[54,2663,2664,2666,2668,2671,2673,2676,2678,2681,2683,2686,2688,2691,2693,2695,2697],{"class":56,"line":659},[54,2665,1610],{"class":71},[54,2667,2395],{"class":71},[54,2669,2670],{"class":60}," resolveDynamicOptions",[54,2672,736],{"class":78},[54,2674,2675],{"class":1626},"kind",[54,2677,1328],{"class":71},[54,2679,2680],{"class":64}," \"videoResolution\"",[54,2682,176],{"class":71},[54,2684,2685],{"class":64}," \"videoDuration\"",[54,2687,1895],{"class":78},[54,2689,2690],{"class":1626},"params",[54,2692,2426],{"class":78},[54,2694,1328],{"class":71},[54,2696,1632],{"class":71},[54,2698,2699],{"class":78}," { value, label }[]\n",[10,2701,2702],{},"细粒度那一档也保留了两份清单，且是有意不同：",[44,2704,2706],{"className":543,"code":2705,"language":545,"meta":49,"style":49},"\u002F**\n * 注意它与下面的 MODEL_DURATION_OPTIONS 是两回事、且**故意不同**：\n * 后者是「按钮行」的精简清单（seedance 只列 7 档，避免一排按钮太长），\n * 而滑块是细粒度选择，应当覆盖服务端真正接受的全部档位——\n * 否则用户拖不到 7\u002F9\u002F11\u002F13\u002F14 秒，白白削掉能力。\n *\u002F\n",[51,2707,2708,2712,2717,2722,2727,2732],{"__ignoreMap":49},[54,2709,2710],{"class":56,"line":57},[54,2711,641],{"class":552},[54,2713,2714],{"class":56,"line":338},[54,2715,2716],{"class":552}," * 注意它与下面的 MODEL_DURATION_OPTIONS 是两回事、且**故意不同**：\n",[54,2718,2719],{"class":56,"line":347},[54,2720,2721],{"class":552}," * 后者是「按钮行」的精简清单（seedance 只列 7 档，避免一排按钮太长），\n",[54,2723,2724],{"class":56,"line":575},[54,2725,2726],{"class":552}," * 而滑块是细粒度选择，应当覆盖服务端真正接受的全部档位——\n",[54,2728,2729],{"class":56,"line":659},[54,2730,2731],{"class":552}," * 否则用户拖不到 7\u002F9\u002F11\u002F13\u002F14 秒，白白削掉能力。\n",[54,2733,2734],{"class":56,"line":665},[54,2735,692],{"class":552},[17,2737,2739],{"id":2738},"五假绿的七条用例","五、假绿的七条用例",[10,2741,2742],{},"修的过程中还撞到一个测试问题，值得单独记。",[10,2744,2745],{},"改动完成后跑测试，有一组用例报了这个警告：",[44,2747,2750],{"className":2748,"code":2749,"language":524},[522],"This might cause false positive tests\n",[51,2751,2749],{"__ignoreMap":49},[10,2753,2754,2755,2758],{},"追下去发现是真的假绿。供应商迁移之后，配置加载在某些条件下会抛错，而那个抛错发生在 ",[51,2756,2757],{},"try"," 之外，成了未处理的 rejection。用例本身并不感知异常，于是照样通过。",[10,2760,2761],{},"具体是 7 条。",[10,2763,2764,2765,2768],{},"修法是让配置加载把异常收敛成一个明确的错误类型（缺 key 重试没有意义，归为永久失败），并在两个相关测试文件的模块加载阶段注入测试用 key。注入位置也有讲究——放 ",[51,2766,2767],{},"beforeEach"," 会因为跨文件执行顺序失效。",[10,2770,2771,2772,2775],{},"这件事和主题有关：",[190,2773,2774],{},"同一份能力表抄三份会漂，同一份配置在三个地方加载也会。"," 假绿的七条用例，测的是「配置能加载」，而配置在测试环境里根本没被加载。",[17,2777,2779],{"id":2778},"六收口之后","六、收口之后",[10,2781,2782],{},"同一批里还顺手修了两处同源问题：",[1232,2784,2785,2796],{},[230,2786,2787,2788,2791,2792,2795],{},"漫剧设置页那份模型列表是前端手写的副本，早已和后端漂开：列出「Seedance-2.0 Pro」和「可灵 V2」两个根本不存在的 ID，而真实的 ID 是 ",[51,2789,2790],{},"seedance-2-fast"," 和 ",[51,2793,2794],{},"kling-v3","。选中即被出片接口的 zod 拒掉。改成读取后端的模型清单接口。",[230,2797,2798,2799,1255,2802,2805,2806,2809],{},"长篇项目的设置页没有把 ",[51,2800,2801],{},"videoModel",[51,2803,2804],{},"videoResolution"," 传给设置组件，",[51,2807,2808],{},"onSave"," 也没往上收。用户选完模型点保存，没有报错，刷新之后回退到原来的值。",[10,2811,2812,2813,427],{},"三处问题的形式不同，成因是同一个：",[190,2814,2815],{},"同一份事实存在多个副本，而且没有一处是权威",[10,2817,2818],{},"收口之后的结构是三份，每一份都有明确职责：",[920,2820,2821,2834],{},[923,2822,2823],{},[926,2824,2825,2828,2831],{},[929,2826,2827],{},"位置",[929,2829,2830],{},"职责",[929,2832,2833],{},"守卫",[942,2835,2836,2847,2858],{},[926,2837,2838,2841,2844],{},[947,2839,2840],{},"后端能力表",[947,2842,2843],{},"权威来源",[947,2845,2846],{},"被镜像表逐项对比",[926,2848,2849,2852,2855],{},[947,2850,2851],{},"共享包镜像表",[947,2853,2854],{},"跨端取值",[947,2856,2857],{},"对比测试（改一边不改另一边即红）",[926,2859,2860,2863,2866],{},[947,2861,2862],{},"前端 UI 清单",[947,2864,2865],{},"只影响展示形态",[947,2867,2868],{},"值必须来自共享包，不许写字面量",[10,2870,2871,2872],{},"硬规矩只有一条，写在镜像表里：",[190,2873,2874],{},"只允许出现上游真支持的取值。多给一个选项，用户就会选到一个必然失败的组合。",[10,2876,2877],{},"反过来说，少给一个选项的代价同样实在——2.5 的 29 秒被砍到 15，用户看到的是「这个模型没比别的强」，看不到的是我们没把它的能力交出去。",[453,2879,2880],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":49,"searchDepth":338,"depth":338,"links":2882},[2883,2884,2885,2886,2887,2888],{"id":1865,"depth":338,"text":1866},{"id":2112,"depth":338,"text":2113},{"id":2174,"depth":338,"text":2175},{"id":2556,"depth":338,"text":2557},{"id":2738,"depth":338,"text":2739},{"id":2778,"depth":338,"text":2779},"2026-08-08",{},"\u002F2026-08-08",{"title":1851,"description":1856},"2026-08-08-同一张能力表抄了三份","上游模型的分辨率、时长、提示词长度散落在三处各自维护，各自漂移。一次用户反馈把三处一起翻出来：多出的档位、被砍掉的秒数、翻倍的板数。",[2896,2897,2898,2899,2900],"模型能力","配置漂移","契约","前端后端一致","提示词长度","xrC9lTC00ghKpLq5ZuDyYhLFe2fpcs3SgCXkd9QeigE",1789212573252]