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