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