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