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