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