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