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