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