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