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