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