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