[{"data":1,"prerenderedAt":3697},["ShallowReactive",2],{"\u002F2026-06-21-ultracodeagent":3,"\u002F2026-06-21-ultracodeagent-rel":1309},{"id":4,"title":5,"body":6,"column":1292,"date":1293,"description":1294,"extension":1295,"hero_image":1296,"meta":1297,"navigation":134,"path":1298,"seo":1299,"series_id":1296,"severity":1296,"stem":1300,"summary":1301,"tags":1302,"__hash__":1308},"posts\u002F2026-06-21-Ultracode多Agent编排引擎重写.md","一次扇出，就能把机器打满",{"type":7,"value":8,"toc":1275},"minimark",[9,18,22,25,33,40,47,50,53,59,65,71,74,81,271,285,288,291,298,345,352,355,362,459,476,493,497,504,507,525,540,553,556,559,565,579,589,592,595,598,605,608,611,618,633,640,643,646,681,695,698,701,704,1183,1186,1190,1193,1199,1206,1209,1219,1222,1225,1228,1231,1238,1244,1250,1253,1268,1271],[10,11,12,13,17],"p",{},"单 Agent 处理复杂任务时，上下文上限和职责混乱很难避免。当任务规模扩大、需要多个专职代理时，简单的顺序派发不够用——需要一个真正的执行引擎，而不是一串 ",[14,15,16],"code",{},"await"," 调用。Ultracode 的重写把这个执行引擎从写死的轮次评委模式升级为通用、分阶段、可观察的编排系统。",[19,20,21],"h2",{"id":21},"为什么需要重写",[10,23,24],{},"原有 Ultracode 的核心逻辑是循环评委投票：单个 worker 代理做任务 → 固定的 9 个 judge 代理投票 → 收敛检测 → autoFix 落盘。这个模型有三个硬伤。",[10,26,27,28,32],{},"其一，",[29,30,31],"strong",{},"职责混乱","。所有代理都用同一套 prompt 模板，judge 和 worker 没有差别的实现，评委们也不知道自己要对抗什么。结果是许多时间花在重复评判已稳定的地方，而不是聚焦新引入的变化。",[10,34,35,36,39],{},"其二，",[29,37,38],{},"并发失控","。原架构是串行的：worker 做完 → 再起 9 个 judge。如果拆成了 10 个子任务，那就是 10×（1 + 9）= 100 个 agent 跑，没有并发上限的制约，大任务轻易触发网关限流。",[10,41,42,43,46],{},"其三，",[29,44,45],{},"观察盲区","。系统只在所有 judge 投票完后汇报结果。长任务跑到一半，用户看不见进展、无法判断是真的在做还是卡住了，也没办法中途调整策略。",[19,48,49],{"id":49},"三个核心决策",[10,51,52],{},"重写围绕三个硬需求展开。",[10,54,55,58],{},[29,56,57],{},"第一，并发必须有上限。"," 没有信号量的话，一个大的代理分派会立刻把网关打满 429，然后不得不在代码里加退避——但那是被动的、出了问题才补。正确做法是从一开始就设定并发的快慢桶：默认 10 并发（可调 5 ~ 15），队列存 100 个任务，当网关响应 429 时指数退避（100ms → 5s 重试，最多 3 次）。这样既防止了失控的并发喷发，也保证了任务不会丢失。",[10,60,61,64],{},[29,62,63],{},"第二，失败必须能隔离。"," 一个子任务失败不应该拦截整个编排流程，但要能标记为不可用、向下游传播。比如拆解阶段有个 worker 崩了，应该照样让其他 worker 继续跑，最后的综合阶段在做决策时看到「该 worker 失败」的标记，转入降级策略。这要求每个 agent 的执行结果都被独立记录：成功、失败、超时等状态都是数据，而不是异常。",[10,66,67,70],{},[29,68,69],{},"第三，执行过程必须可观察。"," 不是只返回最终结果，而是全程发事件：某阶段开始 → 该阶段有 N 个代理排队 → 某代理开始跑 → 它完成了（消耗多少 token、多少成本、耗时几秒）。中间可以实时看树形结构，长任务跑到一半时能判断是否要停、是否有机会加速。",[19,72,73],{"id":73},"从编排计划到分阶段执行",[10,75,76,77,80],{},"重写后的架构围绕一个数据结构：",[29,78,79],{},"编排计划","（Plan）。",[82,83,88],"pre",{"className":84,"code":85,"language":86,"meta":87,"style":87},"language-ts shiki shiki-themes github-light github-dark","interface Plan {\n  phases: Phase[];\n}\n\ninterface Phase {\n  title: string;\n  agents: AgentTask[];\n}\n\ninterface AgentTask {\n  id: string;\n  label: string;\n  role: 'worker' | 'judge' | 'synth';\n  prompt: string;\n  inputsFrom?: string[];\n}\n","ts","",[14,89,90,107,123,129,136,145,160,173,178,183,192,204,216,241,253,266],{"__ignoreMap":87},[91,92,95,99,103],"span",{"class":93,"line":94},"line",1,[91,96,98],{"class":97},"szBVR","interface",[91,100,102],{"class":101},"sScJk"," Plan",[91,104,106],{"class":105},"sVt8B"," {\n",[91,108,110,114,117,120],{"class":93,"line":109},2,[91,111,113],{"class":112},"s4XuR","  phases",[91,115,116],{"class":97},":",[91,118,119],{"class":101}," Phase",[91,121,122],{"class":105},"[];\n",[91,124,126],{"class":93,"line":125},3,[91,127,128],{"class":105},"}\n",[91,130,132],{"class":93,"line":131},4,[91,133,135],{"emptyLinePlaceholder":134},true,"\n",[91,137,139,141,143],{"class":93,"line":138},5,[91,140,98],{"class":97},[91,142,119],{"class":101},[91,144,106],{"class":105},[91,146,148,151,153,157],{"class":93,"line":147},6,[91,149,150],{"class":112},"  title",[91,152,116],{"class":97},[91,154,156],{"class":155},"sj4cs"," string",[91,158,159],{"class":105},";\n",[91,161,163,166,168,171],{"class":93,"line":162},7,[91,164,165],{"class":112},"  agents",[91,167,116],{"class":97},[91,169,170],{"class":101}," AgentTask",[91,172,122],{"class":105},[91,174,176],{"class":93,"line":175},8,[91,177,128],{"class":105},[91,179,181],{"class":93,"line":180},9,[91,182,135],{"emptyLinePlaceholder":134},[91,184,186,188,190],{"class":93,"line":185},10,[91,187,98],{"class":97},[91,189,170],{"class":101},[91,191,106],{"class":105},[91,193,195,198,200,202],{"class":93,"line":194},11,[91,196,197],{"class":112},"  id",[91,199,116],{"class":97},[91,201,156],{"class":155},[91,203,159],{"class":105},[91,205,207,210,212,214],{"class":93,"line":206},12,[91,208,209],{"class":112},"  label",[91,211,116],{"class":97},[91,213,156],{"class":155},[91,215,159],{"class":105},[91,217,219,222,224,228,231,234,236,239],{"class":93,"line":218},13,[91,220,221],{"class":112},"  role",[91,223,116],{"class":97},[91,225,227],{"class":226},"sZZnC"," 'worker'",[91,229,230],{"class":97}," |",[91,232,233],{"class":226}," 'judge'",[91,235,230],{"class":97},[91,237,238],{"class":226}," 'synth'",[91,240,159],{"class":105},[91,242,244,247,249,251],{"class":93,"line":243},14,[91,245,246],{"class":112},"  prompt",[91,248,116],{"class":97},[91,250,156],{"class":155},[91,252,159],{"class":105},[91,254,256,259,262,264],{"class":93,"line":255},15,[91,257,258],{"class":112},"  inputsFrom",[91,260,261],{"class":97},"?:",[91,263,156],{"class":155},[91,265,122],{"class":105},[91,267,269],{"class":93,"line":268},16,[91,270,128],{"class":105},[10,272,273,274,277,278,280,281,284],{},"计划定义了多个阶段，每个阶段内有若干代理并行执行。key point 是 ",[14,275,276],{},"inputsFrom","——如果某个 judge 要对抗式检查前面三个 worker 的产出，它的 ",[14,279,276],{}," 就引用那三个 worker 的 id。引擎执行时会把前序输出自动拼接到 prompt 前，规则很简单：",[29,282,283],{},"只能引用更早阶段的输出","，否则计划非法。",[10,286,287],{},"最终产出规定为最后一个阶段所有代理的输出拼接。通常最后一个阶段是单个 synth（综合器），所以最终答案就是它的输出。",[19,289,290],{"id":290},"执行引擎的三层保证",[10,292,293,294,297],{},"新的执行引擎 ",[14,295,296],{},"runPlan"," 接收这份计划和一个回调函数，逐阶段推进：",[299,300,301,311,325],"ol",{},[302,303,304,307,308,310],"li",{},[29,305,306],{},"校验阶段","：计划的 id 要唯一、",[14,309,276],{}," 只能指向更早阶段的存在 id、每个阶段至少有一个代理。非法计划直接返回失败，不浪费 API 调用。",[302,312,313,316,317,320,321,324],{},[29,314,315],{},"并发执行","：每个阶段启动后，引擎用一个信号量控制并发。假设有 5 个代理要在某阶段跑，并发限是 10，那么 5 个直接入队并行。前面某个代理完成后，队列里的下一个立刻启动，永远不超过 10 个。每个代理在启动时发 ",[14,318,319],{},"agent_start"," 事件，完成时发 ",[14,322,323],{},"agent_end"," 事件（包含成功与否、消耗、耗时）。",[302,326,327,330,331,334,335,334,338,334,341,344],{},[29,328,329],{},"护栏检查","：每启动新代理前检查三条红线——已用总成本超预算、已跑过的代理总数超上限（200）、墙钟超 4 小时。任一触发就停，返回已累计的结果和停止原因（",[14,332,333],{},"done"," \u002F ",[14,336,337],{},"budget",[14,339,340],{},"max_agents",[14,342,343],{},"timeout","）。",[10,346,347,348,351],{},"这样设计的好处是，即便一个代理失败，也不卡管线——该代理的 ",[14,349,350],{},"ok:false"," 被记录，下一代理照样启动。最后发现有人失败时，综合器可以选择降级方案（比如忽略这个 worker 的产出、或者用备选逻辑）。",[19,353,354],{"id":354},"失败隔离与结果合并",[10,356,357,358,361],{},"每个代理的执行结果是一个 ",[14,359,360],{},"AgentRunResult"," 对象：",[82,363,365],{"className":84,"code":364,"language":86,"meta":87,"style":87},"interface AgentRunResult {\n  ok: boolean;\n  text: string;\n  costUsd: number;\n  tokensIn: number;\n  tokensOut: number;\n  turns: number;\n  elapsedMs: number;\n}\n",[14,366,367,376,388,399,411,422,433,444,455],{"__ignoreMap":87},[91,368,369,371,374],{"class":93,"line":94},[91,370,98],{"class":97},[91,372,373],{"class":101}," AgentRunResult",[91,375,106],{"class":105},[91,377,378,381,383,386],{"class":93,"line":109},[91,379,380],{"class":112},"  ok",[91,382,116],{"class":97},[91,384,385],{"class":155}," boolean",[91,387,159],{"class":105},[91,389,390,393,395,397],{"class":93,"line":125},[91,391,392],{"class":112},"  text",[91,394,116],{"class":97},[91,396,156],{"class":155},[91,398,159],{"class":105},[91,400,401,404,406,409],{"class":93,"line":131},[91,402,403],{"class":112},"  costUsd",[91,405,116],{"class":97},[91,407,408],{"class":155}," number",[91,410,159],{"class":105},[91,412,413,416,418,420],{"class":93,"line":138},[91,414,415],{"class":112},"  tokensIn",[91,417,116],{"class":97},[91,419,408],{"class":155},[91,421,159],{"class":105},[91,423,424,427,429,431],{"class":93,"line":147},[91,425,426],{"class":112},"  tokensOut",[91,428,116],{"class":97},[91,430,408],{"class":155},[91,432,159],{"class":105},[91,434,435,438,440,442],{"class":93,"line":162},[91,436,437],{"class":112},"  turns",[91,439,116],{"class":97},[91,441,408],{"class":155},[91,443,159],{"class":105},[91,445,446,449,451,453],{"class":93,"line":175},[91,447,448],{"class":112},"  elapsedMs",[91,450,116],{"class":97},[91,452,408],{"class":155},[91,454,159],{"class":105},[91,456,457],{"class":93,"line":180},[91,458,128],{"class":105},[10,460,461,462,465,466,468,469,471,472,475],{},"引擎在 ",[14,463,464],{},"outputs"," 字典里按代理 id 存下所有输出文本，无论成功失败。如果某个 worker 失败了（比如网络超时、模型拒绝），它的 ",[14,467,350],{}," 和错误信息文本被记录；下游的 judge 如果设了 ",[14,470,276],{}," 引它，拿到的是「",[91,473,474],{},"失败","」的标记而不是代码。synth 在综合时看到这个标记，决定是否落地降级逻辑。这样避免了一个环节的失败导致后续环节无法启动的僵局。",[10,477,478,479,482,483,334,486,334,489,492],{},"计量在引擎层统一累加：每个代理完成后，它的 token 消耗、成本、耗时都加进全局统计。最终 ",[14,480,481],{},"PlanRunResult"," 返回的 ",[14,484,485],{},"totalCostUsd",[14,487,488],{},"totalTokensIn",[14,490,491],{},"totalTokensOut"," 是所有代理的总和，这个数字在实时树的统计行里显示。用户能看到这一轮 Ultracode 到底花了多少钱、消耗了多少 token。",[19,494,496],{"id":495},"ai-编排器从任务到计划","AI 编排器：从任务到计划",[10,498,499,500,503],{},"光有执行引擎不够，还需要一个组件把用户的任务自动转成合法的计划——这就是 ",[29,501,502],{},"AI 编排器","。",[10,505,506],{},"编排器是一个简单的 LLM 调用：告诉模型「我需要编排一个多代理方案解决这个任务」，模型回复一份 JSON Plan。它决定什么时候拆多阶段、什么时候加 judge、什么时候单 worker 就够了。核心要点：",[508,509,510,513,516],"ul",{},[302,511,512],{},"简单任务（比如\"翻译这段文本\"）→ 单 phase、单 worker，不过度工程化。",[302,514,515],{},"复杂任务 → 典型三阶段：Phase 1 拆解并行（多 worker）→ Phase 2 对抗验证（judge 们各自检查）→ Phase 3 综合（synth 合并）。",[302,517,518,519,521,522,524],{},"每个 judge 的 ",[14,520,276],{}," 指向前面的 worker，每个 synth 的 ",[14,523,276],{}," 指向全部前序输出。",[10,526,527,528,531,532,535,536,539],{},"Role 在执行时有默认映射。",[14,529,530],{},"worker"," 代理给全工具、maxTurns 设 16，让它有充分的时间思考和调用工具。",[14,533,534],{},"judge"," 代理不给文件工具（纯推理，避免在主目录空转浪费时间），maxTurns 设 4 就够（只需审查、不需实现）。",[14,537,538],{},"synth"," 不给任何工具（纯思考与综合），maxTurns 也是 4。这个映射可以在配置里调整，但默认值经过真机验证，不浪费 token 又保证完成度。",[10,541,542,543,545,546,549,550,552],{},"解析时有三道防线。第一，如果 LLM 完全乱来（乱文本、无 JSON、字段缺失），fallback 到「单 phase 单 worker」。第二，如果 Plan 结构合法但逻辑不符（id 重复、",[14,544,276],{}," 引同阶段的输出、总代理数超 200），",[14,547,548],{},"validatePlan"," 拦下来，转 fallback。第三，编排器代理本身崩了（返回 ",[14,551,350],{},"），直接 fallback。永远不抛异常、永远返回一个合法 Plan——这是对聊天服务的基本承诺。",[10,554,555],{},"Fallback 计划取用户任务的首行作为标签（约 24 字截断），生成一个单 phase 单 worker 的方案。即便编排器完全失败，系统也能降到「让单个 worker 代理尽力而为」的状态，至少能产出一个结果。这个降级极端简单，但对容错性至关重要。",[19,557,558],{"id":558},"单代理执行器的文本捕获修复",[10,560,561,562,503],{},"升级执行引擎时顺手修了一个隐藏的 bug：",[29,563,564],{},"空文本",[10,566,567,568,571,572,575,576,578],{},"之前的架构里，SDK 的消息循环会从 ",[14,569,570],{},"result"," 类消息里只读成本和轮数，但丢掉了最终文本字段。在聊天里没事，因为文本通过 ",[14,573,574],{},"onText"," 事件流式积累。但在 Ultracode 的低 maxTurns 子代理里（比如只跑 3 轮的 judge），最终答案常常只在 SDK 返回的 ",[14,577,570],{}," 对象的 text 字段里，一旦被丢就变成空字符串，后续评委拿不到内容。",[10,580,581,582,585,586,588],{},"修法是扩展 SDK message 的消费方式：新增一个 ",[14,583,584],{},"finalText"," 字段（从 result 消息的 text 取出），新执行器的文本获取规则是「优先用流式累加的 assistant 文本（非空），否则用 ",[14,587,584],{},"」。两层保险，确保没有空文本掉落。",[19,590,591],{"id":591},"实时执行树与折叠视觉",[10,593,594],{},"执行过程中，每个事件都被渲染成一棵树：顶部脉动蓝点 + 「Ultracode」+ 实时统计行（已用时间 · 代理数 · tokens · 成本）；下面是若干折叠的阶段分组，每个阶段显示代理数和进度圆点；展开时是一张表，每行一个代理（状态图标 + 名字 + tokens + 工具调用数 + 成本 + 耗时）。",[10,596,597],{},"运行中，running 的行背景高亮蓝色，queued 的行显「排队」，done 的行打 ✓，failed 的行打 ✗。进度圆点动态着色，一眼看本阶段的完成度。任务跑完后，树自动折成一行摘要（「Ultracode · 7 代理 · 142.6k tokens · $0.21」），点击展开看完整细节。",[10,599,600,601,604],{},"这一层的数据从执行引擎的事件流 → 聊天的 IPC → 前端组件。组件本地维护计时（用 ",[14,602,603],{},"setInterval"," 每秒更新已用时间），不依赖轮询服务端。组件卸载时清理 interval，避免泄漏。",[19,606,607],{"id":607},"并发与失败的权衡",[10,609,610],{},"这套系统在三个坐标轴上做了权衡。",[10,612,613,614,617],{},"其一是",[29,615,616],{},"吞吐 vs 限流","。并发默认 10，可调到 5（保守） ~ 15（激进）。10 这个数字经验得来：足够快（百代理任务不会太慢），又不会轻易撞 429。网关如果返回限流，退避队列自动延缓重试（100ms 起，指数增长到 5 秒，最多 3 轮），不需要外层干涉。这样既防止了脉冲式的并发喷发把网关打崩，也保证了排队的任务不会丢失。",[10,619,620,621,624,625,628,629,632],{},"其二是",[29,622,623],{},"自治 vs 安全","。主代理可以派任意数量的子代理、动态赋予工具权限，但 ",[14,626,627],{},"danger-guard","（防 ",[14,630,631],{},"rm -rf"," 等灾难命令）对所有子代理强制生效，不可绕过、不可被赋权赋进去。这是不可协商的红线。某个 judge 即便被指示「删掉这个目录」，系统也会直接 deny，日志记录尝试，让主代理知道。",[10,634,635,636,639],{},"其三是",[29,637,638],{},"完美 vs 可用","。原架构追求收敛一致的答案（所有 judge 投票通过），这导致许多任务无限收敛。新架构则是分阶段推进：每个阶段内，所有并行的代理跑完后再进入下一个阶段，不需要等待它们\"一致\"。如果最后发现有代理失败，synth 在综合时看到失败标记，主动降级（比如跳过那个输入、用其他输出补充）。终止条件是「超轮数（500）\u002F 超成本 \u002F 超墙钟（240 分钟）」，保证任务总能在有限时间内完成。这三条护栏是硬边界，任一触发立刻停，无论是否\"完美\"。",[19,641,642],{"id":642},"测试验证与真机硬门槛",[10,644,645],{},"新引擎在单元测试里用 mock executor 验证：",[508,647,648,657,663,669,675],{},[302,649,650,653,654,656],{},[29,651,652],{},"编排计划的校验","：id 唯一性、",[14,655,276],{}," 只能引更早阶段、无环依赖等；非法计划直接 reject。",[302,658,659,662],{},[29,660,661],{},"并发与信号量","：手动注入延迟的 executor，验证同时跑的代理数永不超过并发上限；队列机制正确。",[302,664,665,668],{},[29,666,667],{},"护栏触发","：构造会超成本、超代理数、超超时的场景，验证在对的时刻停止、返回对应的 stopReason。",[302,670,671,674],{},[29,672,673],{},"失败隔离","：某代理 ok:false，后续代理照样启动；最终输出字典里该代理的值是错误信息而不是空。",[302,676,677,680],{},[29,678,679],{},"事件流","：逐代理验证 agent_start 和 agent_end 事件的顺序和内容。",[10,682,683,684,690,691,694],{},"但光有单测不够。真机验收要求：",[29,685,686,687,689],{},"手写一份单 phase 单 worker 的最小计划，喂给 ",[14,688,296],{}," 走真实的 SDK 调用","。断言返回的 ",[14,692,693],{},"finalOutput"," 非空（确认空文本 bug 已修）、事件链完整、成本与 token 数据透出。这是硬门槛——不过此测试，不算完成。",[19,696,697],{"id":697},"一份具体的编排例子",[10,699,700],{},"假设任务是「用 TypeScript 各写 3 个独立纯函数（isPalindrome \u002F isPrime \u002F fibonacci），对抗式验证后综合为一份带测试的最终代码」。",[10,702,703],{},"编排器生成的计划看起来像：",[82,705,709],{"className":706,"code":707,"language":708,"meta":87,"style":87},"language-json shiki shiki-themes github-light github-dark","{\n  \"objective\": \"实现三个算法函数并通过对抗验证\",\n  \"phases\": [\n    {\n      \"title\": \"拆解并行实现\",\n      \"agents\": [\n        {\"id\": \"w1\", \"label\": \"实现 isPalindrome\", \"role\": \"worker\", \"prompt\": \"...\"},\n        {\"id\": \"w2\", \"label\": \"实现 isPrime\", \"role\": \"worker\", \"prompt\": \"...\"},\n        {\"id\": \"w3\", \"label\": \"实现 fibonacci\", \"role\": \"worker\", \"prompt\": \"...\"}\n      ]\n    },\n    {\n      \"title\": \"对抗验证\",\n      \"agents\": [\n        {\"id\": \"j1\", \"label\": \"检查 isPalindrome\", \"role\": \"judge\", \"inputsFrom\": [\"w1\"], \"prompt\": \"...\"},\n        {\"id\": \"j2\", \"label\": \"检查 isPrime\", \"role\": \"judge\", \"inputsFrom\": [\"w2\"], \"prompt\": \"...\"},\n        {\"id\": \"j3\", \"label\": \"检查 fibonacci\", \"role\": \"judge\", \"inputsFrom\": [\"w3\"], \"prompt\": \"...\"}\n      ]\n    },\n    {\n      \"title\": \"综合\",\n      \"agents\": [\n        {\"id\": \"s1\", \"label\": \"整合最终代码与测试\", \"role\": \"synth\", \"inputsFrom\": [\"w1\", \"w2\", \"w3\", \"j1\", \"j2\", \"j3\"], \"prompt\": \"...\"}\n      ]\n    }\n  ]\n}\n","json",[14,710,711,716,730,738,743,755,762,809,847,885,890,895,899,910,916,966,1012,1059,1064,1069,1074,1086,1093,1161,1166,1172,1178],{"__ignoreMap":87},[91,712,713],{"class":93,"line":94},[91,714,715],{"class":105},"{\n",[91,717,718,721,724,727],{"class":93,"line":109},[91,719,720],{"class":155},"  \"objective\"",[91,722,723],{"class":105},": ",[91,725,726],{"class":226},"\"实现三个算法函数并通过对抗验证\"",[91,728,729],{"class":105},",\n",[91,731,732,735],{"class":93,"line":125},[91,733,734],{"class":155},"  \"phases\"",[91,736,737],{"class":105},": [\n",[91,739,740],{"class":93,"line":131},[91,741,742],{"class":105},"    {\n",[91,744,745,748,750,753],{"class":93,"line":138},[91,746,747],{"class":155},"      \"title\"",[91,749,723],{"class":105},[91,751,752],{"class":226},"\"拆解并行实现\"",[91,754,729],{"class":105},[91,756,757,760],{"class":93,"line":147},[91,758,759],{"class":155},"      \"agents\"",[91,761,737],{"class":105},[91,763,764,767,770,772,775,778,781,783,786,788,791,793,796,798,801,803,806],{"class":93,"line":162},[91,765,766],{"class":105},"        {",[91,768,769],{"class":155},"\"id\"",[91,771,723],{"class":105},[91,773,774],{"class":226},"\"w1\"",[91,776,777],{"class":105},", ",[91,779,780],{"class":155},"\"label\"",[91,782,723],{"class":105},[91,784,785],{"class":226},"\"实现 isPalindrome\"",[91,787,777],{"class":105},[91,789,790],{"class":155},"\"role\"",[91,792,723],{"class":105},[91,794,795],{"class":226},"\"worker\"",[91,797,777],{"class":105},[91,799,800],{"class":155},"\"prompt\"",[91,802,723],{"class":105},[91,804,805],{"class":226},"\"...\"",[91,807,808],{"class":105},"},\n",[91,810,811,813,815,817,820,822,824,826,829,831,833,835,837,839,841,843,845],{"class":93,"line":175},[91,812,766],{"class":105},[91,814,769],{"class":155},[91,816,723],{"class":105},[91,818,819],{"class":226},"\"w2\"",[91,821,777],{"class":105},[91,823,780],{"class":155},[91,825,723],{"class":105},[91,827,828],{"class":226},"\"实现 isPrime\"",[91,830,777],{"class":105},[91,832,790],{"class":155},[91,834,723],{"class":105},[91,836,795],{"class":226},[91,838,777],{"class":105},[91,840,800],{"class":155},[91,842,723],{"class":105},[91,844,805],{"class":226},[91,846,808],{"class":105},[91,848,849,851,853,855,858,860,862,864,867,869,871,873,875,877,879,881,883],{"class":93,"line":180},[91,850,766],{"class":105},[91,852,769],{"class":155},[91,854,723],{"class":105},[91,856,857],{"class":226},"\"w3\"",[91,859,777],{"class":105},[91,861,780],{"class":155},[91,863,723],{"class":105},[91,865,866],{"class":226},"\"实现 fibonacci\"",[91,868,777],{"class":105},[91,870,790],{"class":155},[91,872,723],{"class":105},[91,874,795],{"class":226},[91,876,777],{"class":105},[91,878,800],{"class":155},[91,880,723],{"class":105},[91,882,805],{"class":226},[91,884,128],{"class":105},[91,886,887],{"class":93,"line":185},[91,888,889],{"class":105},"      ]\n",[91,891,892],{"class":93,"line":194},[91,893,894],{"class":105},"    },\n",[91,896,897],{"class":93,"line":206},[91,898,742],{"class":105},[91,900,901,903,905,908],{"class":93,"line":218},[91,902,747],{"class":155},[91,904,723],{"class":105},[91,906,907],{"class":226},"\"对抗验证\"",[91,909,729],{"class":105},[91,911,912,914],{"class":93,"line":243},[91,913,759],{"class":155},[91,915,737],{"class":105},[91,917,918,920,922,924,927,929,931,933,936,938,940,942,945,947,950,953,955,958,960,962,964],{"class":93,"line":255},[91,919,766],{"class":105},[91,921,769],{"class":155},[91,923,723],{"class":105},[91,925,926],{"class":226},"\"j1\"",[91,928,777],{"class":105},[91,930,780],{"class":155},[91,932,723],{"class":105},[91,934,935],{"class":226},"\"检查 isPalindrome\"",[91,937,777],{"class":105},[91,939,790],{"class":155},[91,941,723],{"class":105},[91,943,944],{"class":226},"\"judge\"",[91,946,777],{"class":105},[91,948,949],{"class":155},"\"inputsFrom\"",[91,951,952],{"class":105},": [",[91,954,774],{"class":226},[91,956,957],{"class":105},"], ",[91,959,800],{"class":155},[91,961,723],{"class":105},[91,963,805],{"class":226},[91,965,808],{"class":105},[91,967,968,970,972,974,977,979,981,983,986,988,990,992,994,996,998,1000,1002,1004,1006,1008,1010],{"class":93,"line":268},[91,969,766],{"class":105},[91,971,769],{"class":155},[91,973,723],{"class":105},[91,975,976],{"class":226},"\"j2\"",[91,978,777],{"class":105},[91,980,780],{"class":155},[91,982,723],{"class":105},[91,984,985],{"class":226},"\"检查 isPrime\"",[91,987,777],{"class":105},[91,989,790],{"class":155},[91,991,723],{"class":105},[91,993,944],{"class":226},[91,995,777],{"class":105},[91,997,949],{"class":155},[91,999,952],{"class":105},[91,1001,819],{"class":226},[91,1003,957],{"class":105},[91,1005,800],{"class":155},[91,1007,723],{"class":105},[91,1009,805],{"class":226},[91,1011,808],{"class":105},[91,1013,1015,1017,1019,1021,1024,1026,1028,1030,1033,1035,1037,1039,1041,1043,1045,1047,1049,1051,1053,1055,1057],{"class":93,"line":1014},17,[91,1016,766],{"class":105},[91,1018,769],{"class":155},[91,1020,723],{"class":105},[91,1022,1023],{"class":226},"\"j3\"",[91,1025,777],{"class":105},[91,1027,780],{"class":155},[91,1029,723],{"class":105},[91,1031,1032],{"class":226},"\"检查 fibonacci\"",[91,1034,777],{"class":105},[91,1036,790],{"class":155},[91,1038,723],{"class":105},[91,1040,944],{"class":226},[91,1042,777],{"class":105},[91,1044,949],{"class":155},[91,1046,952],{"class":105},[91,1048,857],{"class":226},[91,1050,957],{"class":105},[91,1052,800],{"class":155},[91,1054,723],{"class":105},[91,1056,805],{"class":226},[91,1058,128],{"class":105},[91,1060,1062],{"class":93,"line":1061},18,[91,1063,889],{"class":105},[91,1065,1067],{"class":93,"line":1066},19,[91,1068,894],{"class":105},[91,1070,1072],{"class":93,"line":1071},20,[91,1073,742],{"class":105},[91,1075,1077,1079,1081,1084],{"class":93,"line":1076},21,[91,1078,747],{"class":155},[91,1080,723],{"class":105},[91,1082,1083],{"class":226},"\"综合\"",[91,1085,729],{"class":105},[91,1087,1089,1091],{"class":93,"line":1088},22,[91,1090,759],{"class":155},[91,1092,737],{"class":105},[91,1094,1096,1098,1100,1102,1105,1107,1109,1111,1114,1116,1118,1120,1123,1125,1127,1129,1131,1133,1135,1137,1139,1141,1143,1145,1147,1149,1151,1153,1155,1157,1159],{"class":93,"line":1095},23,[91,1097,766],{"class":105},[91,1099,769],{"class":155},[91,1101,723],{"class":105},[91,1103,1104],{"class":226},"\"s1\"",[91,1106,777],{"class":105},[91,1108,780],{"class":155},[91,1110,723],{"class":105},[91,1112,1113],{"class":226},"\"整合最终代码与测试\"",[91,1115,777],{"class":105},[91,1117,790],{"class":155},[91,1119,723],{"class":105},[91,1121,1122],{"class":226},"\"synth\"",[91,1124,777],{"class":105},[91,1126,949],{"class":155},[91,1128,952],{"class":105},[91,1130,774],{"class":226},[91,1132,777],{"class":105},[91,1134,819],{"class":226},[91,1136,777],{"class":105},[91,1138,857],{"class":226},[91,1140,777],{"class":105},[91,1142,926],{"class":226},[91,1144,777],{"class":105},[91,1146,976],{"class":226},[91,1148,777],{"class":105},[91,1150,1023],{"class":226},[91,1152,957],{"class":105},[91,1154,800],{"class":155},[91,1156,723],{"class":105},[91,1158,805],{"class":226},[91,1160,128],{"class":105},[91,1162,1164],{"class":93,"line":1163},24,[91,1165,889],{"class":105},[91,1167,1169],{"class":93,"line":1168},25,[91,1170,1171],{"class":105},"    }\n",[91,1173,1175],{"class":93,"line":1174},26,[91,1176,1177],{"class":105},"  ]\n",[91,1179,1181],{"class":93,"line":1180},27,[91,1182,128],{"class":105},[10,1184,1185],{},"执行时：Phase 1 三个 worker 并行跑（互相独立）→ Phase 2 三个 judge 并行验证（各自消费一个 worker 的产出）→ Phase 3 synth 综合所有输入产出最终答案。如果某个 worker 失败了（比如网络超时），judge 仍继续验证其他 worker，synth 在综合时看到「w1 失败」的标记，决定采用降级方案。",[19,1187,1189],{"id":1188},"外部集成思考档位与聊天路由","外部集成：思考档位与聊天路由",[10,1191,1192],{},"在聊天层，Ultracode 和普通回复一样，是一条消息的「思考档位」。用户在 composer 选普通 \u002F 深度 \u002F Ultra（对应 normal \u002F extended \u002F ultracode），送出消息时聊天 endpoint 判断档位，如果是 Ultra 就走编排管线。",[10,1194,1195,1196,1198],{},"管线是：接收任务 → 编排器生成 Plan → 执行引擎 ",[14,1197,296],{}," 按阶段跑 → 事件流实时推到前端树 → 完成时最后的 synth 输出成为聊天的消息内容，落库作为一条 message。整个过程对聊天是透明的——用户看到的只是一条消息，但这条消息的产生过程被完整记录了（每个 phase、每个 agent、每个事件），支持稍后点击树节点回放具体某个代理的输出。",[10,1200,1201,1202,1205],{},"这个设计的好处是，Ultracode 不再是一个独立屏幕，而是聊天的一个模式。消息历史、搜索、导出等聊天功能自动支持 Ultracode 的任务——不需要单独维护一套 ",[14,1203,1204],{},"\u002Fultracode"," 屏的数据模型。",[19,1207,1208],{"id":1208},"从原架构到新架构的演变",[10,1210,1211,1212,1215,1216,503],{},"回看原有的评委-轮次模式和新的分阶段编排，区别在于",[29,1213,1214],{},"谁决策编排拓扑","和",[29,1217,1218],{},"何时收敛",[10,1220,1221],{},"原架构是「一个 worker + 9 个评委 + 收敛循环」的固定管线。优点是逻辑简单、有明确的投票机制；缺点是不敏捷——即便 worker 搞定了，还要等 9 个 judge 都投票，任何一个 judge 的异议都可能推进新一轮，导致无限循环。",[10,1223,1224],{},"新架构则是「LLM 编排器规划拓扑、通用引擎执行、阶段内并发、失败不拦管线」。编排器看到复杂任务时自主拆多 phase（比如 10 个并行 worker），而不是冗长的轮次。即便某个 worker 失败，judge 照样验证其他成功的 worker，synth 在综合时主动决定降级。这样既不浪费时间在已稳定的输出上重复评判，也保证了管线的吞吐。",[10,1226,1227],{},"从成本角度，新架构潜在地消耗更少——同样的任务规模，原架构是 1 worker + 9 judge × N 轮，新架构是 N worker + N judge（对抗验证）+ 1 synth，通常少好几倍。真机上见过单个任务从原架构的「200+ 代理跑了 6 轮」降到新架构的「30 代理一轮过」。",[19,1229,1230],{"id":1230},"仍未解决的边界情况",[10,1232,1233,1234,1237],{},"设计中也有已知的限制。其一，",[29,1235,1236],{},"子代理递归不支持","——即子代理再派下一层子代理。SDK 的 Task 工具在子代理的上下文不可用，这是 SDK 层的约束，引擎无法绕过。实践中这个限制影响不大，因为 Ultracode 的典型用途是「任务 → 多 worker 并行 → 对抗验证」的三层，很少需要递归。",[10,1239,35,1240,1243],{},[29,1241,1242],{},"动态编排调整","。计划一旦生成后就是固定的，运行中不能动态插入新 phase 或新 agent。如果某个阶段的结果出乎预料，主代理不能中途改变后续阶段的代理配置。这是为了保证计划的确定性和可回放性——一份计划应该在相同的输入下产出相同的执行树。未来如果真需要这个能力，可以在编排器层面实现「多轮编排」（第一轮生成初步计划、看结果后再微调）。",[10,1245,42,1246,1249],{},[29,1247,1248],{},"编排器本身的稳定性","。LLM 产出畸形 Plan 的风险永远存在，fallback 机制可以兜底但不能杜绝。未来可以考虑对编排器的输出做更激进的验证、甚至让编排器自己生成测试 case 来验证拓扑设计。但这超出了 SP 子项目的范围。",[19,1251,1252],{"id":1252},"后续迭代方向",[10,1254,1255,1256,1259,1260,1263,1264,1267],{},"这套引擎的设计目标是「通用」，所以留了扩展点。首先是",[29,1257,1258],{},"编排策略","——现在编排器是简单的「LLM 一次调用」，未来可以升级为「判断复杂度 → 按复杂度选用编排模板 → 微调参数」，甚至「用历史成功案例做 few-shot 引导」。其次是",[29,1261,1262],{},"收敛检测的进阶","——现在的判据是「连续 N 轮无新问题」，太粗糙。可以升级为「按问题类型（语法 vs 逻辑 vs 性能）分别统计、不同类型不同收敛阈值」。第三是",[29,1265,1266],{},"成本可见与预算控制","——现在成本相加没有细颗粒度控制（只有绝对上限），可以做分阶段的预算、或者基于历史任务的成本预测。",[10,1269,1270],{},"但这些都是后话。眼下重要的是让核心引擎的三个决策——并发上限、失败隔离、实时可观察——都落地并验证。",[1272,1273,1274],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}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 .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}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);}",{"title":87,"searchDepth":109,"depth":109,"links":1276},[1277,1278,1279,1280,1281,1282,1283,1284,1285,1286,1287,1288,1289,1290,1291],{"id":21,"depth":109,"text":21},{"id":49,"depth":109,"text":49},{"id":73,"depth":109,"text":73},{"id":290,"depth":109,"text":290},{"id":354,"depth":109,"text":354},{"id":495,"depth":109,"text":496},{"id":558,"depth":109,"text":558},{"id":591,"depth":109,"text":591},{"id":607,"depth":109,"text":607},{"id":642,"depth":109,"text":642},{"id":697,"depth":109,"text":697},{"id":1188,"depth":109,"text":1189},{"id":1208,"depth":109,"text":1208},{"id":1230,"depth":109,"text":1230},{"id":1252,"depth":109,"text":1252},"Agent 平台","2026-06-21","单 Agent 处理复杂任务时，上下文上限和职责混乱很难避免。当任务规模扩大、需要多个专职代理时，简单的顺序派发不够用——需要一个真正的执行引擎，而不是一串 await 调用。Ultracode 的重写把这个执行引擎从写死的轮次评委模式升级为通用、分阶段、可观察的编排系统。","md",null,{},"\u002F2026-06-21-ultracodeagent",{"title":5,"description":1294},"2026-06-21-Ultracode多Agent编排引擎重写","从顺序调用升级为分阶段扇出的通用执行引擎，用 AI 规划编排拓扑、并发控制防爆、失败隔离传播。",[1303,1304,1305,1306,1307],"多代理编排","Agent 引擎","任务并发","实时可视化","Claude","AV6bLD0-HzCYqijQdQ1dyCaZGHEeRLnBt7idYgeJaDk",[1310,2011,2651],{"id":1311,"title":1312,"body":1313,"column":1292,"date":1996,"description":1317,"extension":1295,"hero_image":1296,"meta":1997,"navigation":134,"path":1998,"seo":1999,"series_id":1296,"severity":1296,"stem":2000,"summary":2001,"tags":2002,"__hash__":2010},"posts\u002F2026-08-31-阈值不能推只能量.md","阈值不能推，只能量",{"type":7,"value":1314,"toc":1987},[1315,1318,1321,1325,1328,1334,1337,1340,1348,1351,1354,1359,1362,1372,1397,1403,1406,1410,1421,1424,1430,1433,1436,1439,1444,1447,1450,1505,1508,1511,1570,1573,1578,1582,1585,1591,1594,1597,1601,1604,1610,1613,1619,1622,1625,1632,1638,1641,1644,1650,1653,1696,1699,1703,1706,1709,1715,1718,1721,1725,1731,1799,1802,1897,1900,1903,1951,1954,1958,1961,1971,1974,1981,1984],[10,1316,1317],{},"知识库的检索参数有三组：分块阈值、召回阈值、精排阈值。这一天把三组都动了一遍，每一组都留下了实测数据。",[10,1319,1320],{},"起因是一个看不太出来的现象：知识库好像没被用上。",[19,1322,1324],{"id":1323},"一分块中位数-212-字","一、分块：中位数 212 字",[10,1326,1327],{},"先量现状。生产库里的分块统计：",[1329,1330,1331],"blockquote",{},[10,1332,1333],{},"13244 个分块中位数只有 212 字，而目标 2400 字，62% 的块不足 300 字。",[10,1335,1336],{},"目标块大小是 2400 字，实际交付的是 212。差了十倍。",[10,1338,1339],{},"分块器原来的逻辑是「一个标题一个块」。这在正常文档上没问题，但清单型、模板型文档里，几乎每一行列表项都会被标题识别逻辑认成标题：",[82,1341,1346],{"className":1342,"code":1344,"language":1345},[1343],"language-text","1. 原本想达成什么？\n","text",[14,1347,1344],{"__ignoreMap":87},[10,1349,1350],{},"这一行被当成标题，于是自己成了一个块。一篇文档被切成几十个几十字的碎片，注入给模型的全是碎片。",[10,1352,1353],{},"更严重的情况在连续列表项之间没有正文时。老实现让标题行只活在「面包屑」元数据里，不写进块正文。于是被误判成标题的那一行文字直接消失：",[1329,1355,1356],{},[10,1357,1358],{},"标题行只活在面包屑里，整行文字直接丢失（那类文档的四个核心问题在索引里根本不存在）。",[10,1360,1361],{},"两处改动：",[82,1363,1365],{"className":84,"code":1364,"language":86,"meta":87,"style":87},"\u002F\u002F 攒够了才在这里切：标题是「首选切点」，不是「强制切点」。\n",[14,1366,1367],{"__ignoreMap":87},[91,1368,1369],{"class":93,"line":94},[91,1370,1364],{"class":1371},"sJ8bj",[82,1373,1375],{"className":84,"code":1374,"language":86,"meta":87,"style":87},"\u002F\u002F 不变量：每一行输入都要落进某个块的正文，面包屑只是附加元数据。\n\u002F\u002F 老实现让标题行只活在面包屑里，于是被 detectHeading 误判成标题的列表项\n\u002F\u002F （「1. 原本想达成什么？」这类）整行文字就没了——连续几个列表项时只留得住最后一条。\n\u002F\u002F 与面包屑重复一次可以接受，丢字不行。\n",[14,1376,1377,1382,1387,1392],{"__ignoreMap":87},[91,1378,1379],{"class":93,"line":94},[91,1380,1381],{"class":1371},"\u002F\u002F 不变量：每一行输入都要落进某个块的正文，面包屑只是附加元数据。\n",[91,1383,1384],{"class":93,"line":109},[91,1385,1386],{"class":1371},"\u002F\u002F 老实现让标题行只活在面包屑里，于是被 detectHeading 误判成标题的列表项\n",[91,1388,1389],{"class":93,"line":125},[91,1390,1391],{"class":1371},"\u002F\u002F （「1. 原本想达成什么？」这类）整行文字就没了——连续几个列表项时只留得住最后一条。\n",[91,1393,1394],{"class":93,"line":131},[91,1395,1396],{"class":1371},"\u002F\u002F 与面包屑重复一次可以接受，丢字不行。\n",[10,1398,1399,1400,503],{},"标题从「强制切点」降为「首选切点」：缓冲区不足阈值时，标题并入当前块，不切。同时标题行本身写进正文，成为一条不变量——",[29,1401,1402],{},"每一行输入都要落进某个块的正文",[10,1404,1405],{},"实测效果：一篇文档从 3 块 52\u002F67\u002F100 字（四个核心问题全丢）变成 1 块 235 字，内容完整。",[19,1407,1409],{"id":1408},"二按比例推算推出了全场最差点","二、按比例推算，推出了全场最差点",[10,1411,1412,1413,1416,1417,1420],{},"第一版把阈值定成 ",[14,1414,1415],{},"maxChars × 0.6","。生产 ",[14,1418,1419],{},"maxChars"," 是 2400，算出来是 1440。",[10,1422,1423],{},"结果整篇文档并成一个块。上线后实测检索：",[82,1425,1428],{"className":1426,"code":1427,"language":1345},[1343],"5 篇文档 6 个查询，每篇取最佳命中分再平均\n  阈值 0（纯按小节切） 0.7046\n  阈值 250            0.6750\n  阈值 1440（线上）    0.4978\n",[14,1429,1427],{"__ignoreMap":87},[10,1431,1432],{},"1440 正好是最差的那个。",[10,1434,1435],{},"同一篇文档对「核心四问」这个查询，切成小节时得分 0.77，并成整块时只有 0.33——在全库 113 块里排到第 109 名。内容修好了，却再也检索不到。",[10,1437,1438],{},"根因在生产用的向量模型上：",[1329,1440,1441],{},[10,1442,1443],{},"生产 embedding 模型对「主题聚焦的小段」打分远高于「整篇文档」。",[10,1445,1446],{},"原来那个「一个标题一个块」的设计，主题纯度是对的。推翻它是错的判断。真正的缺陷只有一条——标题行被丢弃。",[10,1448,1449],{},"最终取值 250：",[82,1451,1453],{"className":84,"code":1452,"language":86,"meta":87,"style":87},"\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",[14,1454,1455,1460,1465,1470,1475,1480,1485,1490,1495,1500],{"__ignoreMap":87},[91,1456,1457],{"class":93,"line":94},[91,1458,1459],{"class":1371},"\u002F**\n",[91,1461,1462],{"class":93,"line":109},[91,1463,1464],{"class":1371}," * 缺省小节合并阈值。250 是在生产 embedding 模型上实测标定的，不是拍脑袋：\n",[91,1466,1467],{"class":93,"line":125},[91,1468,1469],{"class":1371}," * 5 篇文档 6 个查询，取每篇的最佳命中分做平均——\n",[91,1471,1472],{"class":93,"line":131},[91,1473,1474],{"class":1371}," *   阈值 0（纯按小节切）0.7046 \u002F 250 → 0.6750 \u002F 1440（= maxChars×0.6）→ 0.4978\n",[91,1476,1477],{"class":93,"line":138},[91,1478,1479],{"class":1371}," * 这个模型对「主题聚焦的小段」打分远高于「整篇文档」……\n",[91,1481,1482],{"class":93,"line":147},[91,1483,1484],{"class":1371}," * 所以阈值必须小，千万别再按 maxChars 的比例去推——那样在生产的 2400 上会算出 1440，\n",[91,1486,1487],{"class":93,"line":162},[91,1488,1489],{"class":1371}," * 正好是最差点。\n",[91,1491,1492],{"class":93,"line":175},[91,1493,1494],{"class":1371}," * 取 250 而不是 0：排序只差 4%，但块从几十字变成 250~330 字，\n",[91,1496,1497],{"class":93,"line":180},[91,1498,1499],{"class":1371}," * 同样召回 6 段能多喂两三倍的正文。\n",[91,1501,1502],{"class":93,"line":185},[91,1503,1504],{"class":1371}," *\u002F\n",[10,1506,1507],{},"取 250 而不是 0 的理由是这段话里第二重要的部分：排序只差 4%，但每个块从几十字变成两三百字，同样召回 6 段能多喂几倍的正文。",[10,1509,1510],{},"代码里还留了一条兜底：",[82,1512,1514],{"className":84,"code":1513,"language":86,"meta":87,"style":87},"\u002F\u002F 取 min：maxChars 很小的配置（测试里 300）不能让阈值反超块大小本身\nconst minChunkChars =\n  opts.minChunkChars ?? Math.min(DEFAULT_MIN_CHUNK_CHARS, Math.floor(maxChars * 0.6));\n",[14,1515,1516,1521,1532],{"__ignoreMap":87},[91,1517,1518],{"class":93,"line":94},[91,1519,1520],{"class":1371},"\u002F\u002F 取 min：maxChars 很小的配置（测试里 300）不能让阈值反超块大小本身\n",[91,1522,1523,1526,1529],{"class":93,"line":109},[91,1524,1525],{"class":97},"const",[91,1527,1528],{"class":155}," minChunkChars",[91,1530,1531],{"class":97}," =\n",[91,1533,1534,1537,1540,1543,1546,1549,1552,1555,1558,1561,1564,1567],{"class":93,"line":125},[91,1535,1536],{"class":105},"  opts.minChunkChars ",[91,1538,1539],{"class":97},"??",[91,1541,1542],{"class":105}," Math.",[91,1544,1545],{"class":101},"min",[91,1547,1548],{"class":105},"(",[91,1550,1551],{"class":155},"DEFAULT_MIN_CHUNK_CHARS",[91,1553,1554],{"class":105},", Math.",[91,1556,1557],{"class":101},"floor",[91,1559,1560],{"class":105},"(maxChars ",[91,1562,1563],{"class":97},"*",[91,1565,1566],{"class":155}," 0.6",[91,1568,1569],{"class":105},"));\n",[10,1571,1572],{},"以及一条守卫测试，专门防「有人再推一遍公式」：",[1329,1574,1575],{},[10,1576,1577],{},"加一条守卫测试钉死生产口径（阈值退回按比例推算即变红），防止将来有人再推一遍公式又回到 1440。",[19,1579,1581],{"id":1580},"三召回阈值会把整轮清零","三、召回阈值：会把整轮清零",[10,1583,1584],{},"分块改大之后，绝对余弦分整体下移。而召回阈值还是旧值 0.5：",[82,1586,1589],{"className":1587,"code":1588,"language":1345},[1343],"正确命中落在 0.43~0.63，旧值会把最高分 0.488 的查询整轮清零——\n检索到了正确文档却被门槛全部丢弃，用户看到的就是「知识库没被使用」。\n",[14,1590,1588],{"__ignoreMap":87},[10,1592,1593],{},"这条解释了我一开始看到的那个现象。检索其实命中了，只是分数没过门槛，于是整轮被丢掉，模型什么也没拿到。",[10,1595,1596],{},"改成 0.35。",[19,1598,1600],{"id":1599},"四精排从-912-到-1212","四、精排：从 9\u002F12 到 12\u002F12",[10,1602,1603],{},"同一天启用了 cross-encoder 精排。A\u002FB 实测：",[82,1605,1608],{"className":1606,"code":1607,"language":1345},[1343],"12 条查询 A\u002FB 实测：\n  纯向量  命中 9\u002F12，整轮零注入 1 次\n  开精排  命中 12\u002F12，整轮零注入 0 次，目标文档 10 条排第 1\n",[14,1609,1607],{"__ignoreMap":87},[10,1611,1612],{},"修好的都是「换了说法」的查询——向量检索的固有短板。举一个例子：",[82,1614,1617],{"className":1615,"code":1616,"language":1345},[1343],"「我想要复刻爆款视频…使用画布」注入 0 段 → 4 段\n（相关文档被向量埋在后面，精排提到第 2 名）\n",[14,1618,1616],{"__ignoreMap":87},[10,1620,1621],{},"精排启用时把两个阈值也一起改了。这是当天最曲折的一处。",[10,1623,1624],{},"第一版把精排阈值设成 0.15。发版后跑完整测试，出现随机漏召。",[10,1626,1627,1628,1631],{},"原因是精排的",[29,1629,1630],{},"绝对分不稳定","：",[82,1633,1636],{"className":1634,"code":1635,"language":1345},[1343],"同一查询同一候选集，「金字塔原理怎么做到结论先行」两次实测 0.251 与 0.135——\n排名都稳定第 1，只是分数漂了近一半。0.15 卡在中间，于是第二次整轮零注入。\n",[14,1637,1635],{"__ignoreMap":87},[10,1639,1640],{},"排名是稳定的，分数会漂。所以不能用绝对分当门槛去「把关质量」。",[10,1642,1643],{},"阈值扫描：",[82,1645,1648],{"className":1646,"code":1647,"language":1345},[1343],"0.02~0.10 均 12\u002F12 零丢弃，0.15\u002F0.20 → 11\u002F12 丢 1 次\n",[14,1649,1647],{"__ignoreMap":87},[10,1651,1652],{},"最终取 0.05，并把职责写清楚：",[82,1654,1656],{"className":84,"code":1655,"language":86,"meta":87,"style":87},"\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",[14,1657,1658,1662,1667,1672,1677,1682,1687,1692],{"__ignoreMap":87},[91,1659,1660],{"class":93,"line":94},[91,1661,1459],{"class":1371},[91,1663,1664],{"class":93,"line":109},[91,1665,1666],{"class":1371}," * 精排阈值。**它的职责只是扔掉垃圾，不是把关质量**——质量由排序保证：\n",[91,1668,1669],{"class":93,"line":125},[91,1670,1671],{"class":1371}," * 12 条查询里精排把正确目标全部放进了前 3 名（10 条第 1）。\n",[91,1673,1674],{"class":93,"line":131},[91,1675,1676],{"class":1371}," *\n",[91,1678,1679],{"class":93,"line":138},[91,1680,1681],{"class":1371}," * 取 0.05 而不是更高，是因为**精排的绝对分不稳定**……\n",[91,1683,1684],{"class":93,"line":147},[91,1685,1686],{"class":1371}," * 观测到的正确目标最低分 0.131，取 0.05 留约 60% 余量；\n",[91,1688,1689],{"class":93,"line":162},[91,1690,1691],{"class":1371}," * 垃圾档在 0.014~0.025，仍被干净滤掉。\n",[91,1693,1694],{"class":93,"line":175},[91,1695,1504],{"class":1371},[10,1697,1698],{},"观测到的垃圾档在 0.014~0.025，正确目标最低 0.131。0.05 落在两者之间，两边都有余量。",[19,1700,1702],{"id":1701},"五超时静默降级最贵","五、超时：静默降级最贵",[10,1704,1705],{},"同一批里还修了超时。",[10,1707,1708],{},"精排失败会自动降级回向量序，不阻断聊天。这个设计是对的——但有代价：",[82,1710,1713],{"className":1711,"code":1712,"language":1345},[1343],"法律知识库 30 块共 4.5 万字，冷调用实测 2263ms，只剩 737ms 余量。\n完整测试里有一条查询当场超时降级。\n超时是静默的，日志不报错，用户侧表现为「有时准有时不准」，极难归因。\n",[14,1714,1712],{"__ignoreMap":87},[10,1716,1717],{},"「有时准有时不准」这句话在这一天的上下文里已经出现第二次了——早上是参考图，这里是检索。",[10,1719,1720],{},"超时从 3000 毫秒提到 8000，给冷调用 3.5 倍余量。真卡死仍有降级兜底。",[19,1722,1724],{"id":1723},"六三个阈值放在一起","六、三个阈值放在一起",[10,1726,1727,1728],{},"三条修正的形式不同，结论是同一条：",[29,1729,1730],{},"阈值不能推导，只能测量。",[1732,1733,1734,1753],"table",{},[1735,1736,1737],"thead",{},[1738,1739,1740,1744,1747,1750],"tr",{},[1741,1742,1743],"th",{},"参数",[1741,1745,1746],{},"推导值",[1741,1748,1749],{},"实测值",[1741,1751,1752],{},"推导错在哪",[1754,1755,1756,1771,1785],"tbody",{},[1738,1757,1758,1762,1765,1768],{},[1759,1760,1761],"td",{},"分块合并阈值",[1759,1763,1764],{},"1440（按比例 0.6）",[1759,1766,1767],{},"250",[1759,1769,1770],{},"假设「块越大越好」，而该模型偏好主题聚焦的小段",[1738,1772,1773,1776,1779,1782],{},[1759,1774,1775],{},"召回阈值",[1759,1777,1778],{},"0.5（惯例值）",[1759,1780,1781],{},"0.35",[1759,1783,1784],{},"分块改动后分数分布整体下移，旧门槛把命中全丢",[1738,1786,1787,1790,1793,1796],{},[1759,1788,1789],{},"精排阈值",[1759,1791,1792],{},"0.15（留余量）",[1759,1794,1795],{},"0.05",[1759,1797,1798],{},"绝对分本身会漂近一半，拿漂移量当门槛必然随机漏召",[10,1800,1801],{},"三个值都有实测数字支撑，也都配了守卫测试。其中两条测试写的是「不许回到某个值」，而不是「必须等于某值」：",[82,1803,1805],{"className":84,"code":1804,"language":86,"meta":87,"style":87},"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",[14,1806,1807,1825,1849,1854,1858,1873,1893],{"__ignoreMap":87},[91,1808,1809,1812,1814,1817,1820,1823],{"class":93,"line":94},[91,1810,1811],{"class":101},"it",[91,1813,1548],{"class":105},[91,1815,1816],{"class":226},"\"向量阈值不得回到会整轮清零的 0.5\"",[91,1818,1819],{"class":105},", () ",[91,1821,1822],{"class":97},"=>",[91,1824,106],{"class":105},[91,1826,1827,1830,1832,1835,1838,1841,1843,1846],{"class":93,"line":109},[91,1828,1829],{"class":101},"  expect",[91,1831,1548],{"class":105},[91,1833,1834],{"class":155},"DEFAULT_KB_MIN_SCORE",[91,1836,1837],{"class":105},").",[91,1839,1840],{"class":101},"toBeLessThanOrEqual",[91,1842,1548],{"class":105},[91,1844,1845],{"class":155},"0.4",[91,1847,1848],{"class":105},");\n",[91,1850,1851],{"class":93,"line":125},[91,1852,1853],{"class":105},"});\n",[91,1855,1856],{"class":93,"line":131},[91,1857,135],{"emptyLinePlaceholder":134},[91,1859,1860,1862,1864,1867,1869,1871],{"class":93,"line":138},[91,1861,1811],{"class":101},[91,1863,1548],{"class":105},[91,1865,1866],{"class":226},"\"精排阈值不得高到会随分数漂移漏召——观测最低正确分 0.131\"",[91,1868,1819],{"class":105},[91,1870,1822],{"class":97},[91,1872,106],{"class":105},[91,1874,1875,1877,1879,1882,1884,1886,1888,1891],{"class":93,"line":147},[91,1876,1829],{"class":101},[91,1878,1548],{"class":105},[91,1880,1881],{"class":155},"DEFAULT_KB_RERANK_MIN_SCORE",[91,1883,1837],{"class":105},[91,1885,1840],{"class":101},[91,1887,1548],{"class":105},[91,1889,1890],{"class":155},"0.1",[91,1892,1848],{"class":105},[91,1894,1895],{"class":93,"line":162},[91,1896,1853],{"class":105},[10,1898,1899],{},"这样写是因为真正的风险不是「有人改了数值」，而是「有人又推了一遍公式」。测试防的是后者。",[10,1901,1902],{},"还有一条测试值得抄下来：",[82,1904,1906],{"className":84,"code":1905,"language":86,"meta":87,"style":87},"it(\"精排阈值必须低于向量阈值——两者不是同一个量纲\", () => {\n  \u002F\u002F 精排是 cross-encoder 分，向量是余弦分，拿同一个数去卡两边必然有一边错\n  expect(DEFAULT_KB_RERANK_MIN_SCORE).toBeLessThan(DEFAULT_KB_MIN_SCORE);\n});\n",[14,1907,1908,1923,1928,1947],{"__ignoreMap":87},[91,1909,1910,1912,1914,1917,1919,1921],{"class":93,"line":94},[91,1911,1811],{"class":101},[91,1913,1548],{"class":105},[91,1915,1916],{"class":226},"\"精排阈值必须低于向量阈值——两者不是同一个量纲\"",[91,1918,1819],{"class":105},[91,1920,1822],{"class":97},[91,1922,106],{"class":105},[91,1924,1925],{"class":93,"line":109},[91,1926,1927],{"class":1371},"  \u002F\u002F 精排是 cross-encoder 分，向量是余弦分，拿同一个数去卡两边必然有一边错\n",[91,1929,1930,1932,1934,1936,1938,1941,1943,1945],{"class":93,"line":125},[91,1931,1829],{"class":101},[91,1933,1548],{"class":105},[91,1935,1881],{"class":155},[91,1937,1837],{"class":105},[91,1939,1940],{"class":101},"toBeLessThan",[91,1942,1548],{"class":105},[91,1944,1834],{"class":155},[91,1946,1848],{"class":105},[91,1948,1949],{"class":93,"line":131},[91,1950,1853],{"class":105},[10,1952,1953],{},"同一个数量级、看起来可比，实际是两个不同的量纲。这种错觉在阈值调参里很常见，能用一条断言拦下来比写在注释里可靠。",[19,1955,1957],{"id":1956},"七评测集的缺口","七、评测集的缺口",[10,1959,1960],{},"这一轮所有数字来自临时扫的查询集：5 篇文档 6 个查询、12 条查询。",[10,1962,1963,1964,1967,1968,503],{},"仓库里确实有一个召回评测脚本（用户问题、期望命中的文档名、recall@K \u002F precision@K \u002F MRR 双模式），但它的标注集还是占位内容——两条用例，",[14,1965,1966],{},"kbIds"," 的值是字面量 ",[14,1969,1970],{},"\u003C替换为真实 kbId>",[10,1972,1973],{},"也就是说，这一轮的标定用的数据集没有入库。数字留在代码注释、配置和环境变量说明里，跑分的脚本和查询集没有留下。",[10,1975,1976,1977,1980],{},"这是这次工作里最该补上的一环。",[29,1978,1979],{},"阈值有实测依据，但依据本身不可复现。"," 下次有人想调整，只能重新扫一遍查询。",[10,1982,1983],{},"补的做法是明确的：把这一轮用的查询集和期望结果补进评测脚本的标注集，让它成为下一次调参的起点。参数变更时先跑评测再改值，改完之后把新的分数写进注释——注释里的数字应该来自一次可复现的运行，而不是一次性的手测。",[1272,1985,1986],{},"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":87,"searchDepth":109,"depth":109,"links":1988},[1989,1990,1991,1992,1993,1994,1995],{"id":1323,"depth":109,"text":1324},{"id":1408,"depth":109,"text":1409},{"id":1580,"depth":109,"text":1581},{"id":1599,"depth":109,"text":1600},{"id":1701,"depth":109,"text":1702},{"id":1723,"depth":109,"text":1724},{"id":1956,"depth":109,"text":1957},"2026-08-31",{},"\u002F2026-08-31",{"title":1312,"description":1317},"2026-08-31-阈值不能推只能量","知识库检索质量的三次修正：分块阈值按比例推算算出了全场最差点，精排阈值设高了会随分数漂移随机漏召，超时余量不够会静默降级。三次都靠实测数据定值。",[2003,2004,2005,2006,2007,2008,2009],"知识库","RAG","分块","检索","cross-encoder","阈值标定","评测","VbN-ARGFFeAmQMRZf2jG-4SfdtN22uzMLyCrc3dCuA8",{"id":2012,"title":2013,"body":2014,"column":1292,"date":2637,"description":2018,"extension":1295,"hero_image":1296,"meta":2638,"navigation":134,"path":2639,"seo":2640,"series_id":1296,"severity":1296,"stem":2641,"summary":2642,"tags":2643,"__hash__":2650},"posts\u002F2026-08-20-画布这一步深链运行历史与续跑.md","画布这一步：深链、运行历史与续跑",{"type":7,"value":2015,"toc":2627},[2016,2019,2022,2026,2029,2032,2038,2041,2055,2068,2071,2075,2078,2084,2087,2094,2097,2106,2109,2113,2116,2121,2151,2154,2159,2179,2186,2189,2193,2196,2285,2288,2291,2295,2298,2316,2325,2328,2331,2335,2338,2341,2361,2364,2369,2372,2404,2407,2499,2502,2508,2515,2519,2522,2525,2563,2570,2573,2579,2582,2587,2591,2594,2603,2609,2615,2621,2624],[10,2017,2018],{},"画布是这套平台里最复杂的界面。节点、连线、参数、批量框、运行状态，任何一件事都要在同一个视口里表达清楚。",[10,2020,2021],{},"这一天补齐八项操作。每一项都不难，难的是它们之间不能互相打架。",[19,2023,2025],{"id":2024},"一url-是唯一事实来源","一、URL 是唯一事实来源",[10,2027,2028],{},"原先画布是个单页状态机：打开就是列表，点进去切到编辑器，刷新回列表。",[10,2030,2031],{},"改成路由：",[82,2033,2036],{"className":2034,"code":2035,"language":1345},[1343],"\u002Fcanvas            列表\n\u002Fcanvas\u002F:id        编辑器\n",[14,2037,2035],{"__ignoreMap":87},[10,2039,2040],{},"四个行为一起对齐：",[508,2042,2043,2046,2049,2052],{},[302,2044,2045],{},"深链直达编辑器。",[302,2047,2048],{},"刷新不回列表。",[302,2050,2051],{},"后退回列表，而不是退出画布模块。",[302,2053,2054],{},"打开画布写入地址栏。",[10,2056,2057,2060,2061,334,2064,2067],{},[14,2058,2059],{},"popstate"," 监听已有的解析函数，",[14,2062,2063],{},"pushState",[14,2065,2066],{},"replaceState"," 写入。",[10,2069,2070],{},"这条改动看起来只是加了个路由，实际改变的是状态的归属：画布 ID 从组件内的 ref 变成了 URL 的一部分。后面几项都依赖它——运行历史要能链到具体的运行，批量框要能被分享，都要求「当前在看哪张画布」是一个可以从外部确定的量。",[19,2072,2074],{"id":2073},"二运行历史","二、运行历史",[10,2076,2077],{},"一个新面板，两个接口：",[82,2079,2082],{"className":2080,"code":2081,"language":1345},[1343],"GET \u002Fapi\u002Fcanvas-flow\u002Fruns?flowId=…      列表\nGET \u002Fapi\u002Fcanvas-flow\u002Fruns\u002F:id           快照\n",[14,2083,2081],{"__ignoreMap":87},[10,2085,2086],{},"列表默认取 10 条。点开某一条，用它的快照推导出画布上每个节点的状态，覆盖显示。",[10,2088,2089,2090,2093],{},"这个面板的数据来源和实时运行状态用的是同一套 ",[14,2091,2092],{},"nodeStates","——历史记录不是另一条平行显示，而是把画布切到那一次运行的视角。",[10,2095,2096],{},"登录态走单一来源：",[82,2098,2100],{"className":84,"code":2099,"language":86,"meta":87,"style":87},"\u002F\u002F 登录态只有 authToken() 一个来源（有守卫测试盯着，别直读 localStorage）\n",[14,2101,2102],{"__ignoreMap":87},[91,2103,2104],{"class":93,"line":94},[91,2105,2099],{"class":1371},[10,2107,2108],{},"这条注释是守卫测试抓出来的结果，不是提前的设计。",[19,2110,2112],{"id":2111},"三复制粘贴","三、复制粘贴",[10,2114,2115],{},"复制粘贴里有两个决定值得记。",[10,2117,2118],{},[29,2119,2120],{},"用应用内剪贴板，不碰系统剪贴板。",[82,2122,2124],{"className":84,"code":2123,"language":86,"meta":87,"style":87},"let clipboard: FlowClipboardPayload | null = null;\n",[14,2125,2126],{"__ignoreMap":87},[91,2127,2128,2131,2134,2136,2139,2141,2144,2147,2149],{"class":93,"line":94},[91,2129,2130],{"class":97},"let",[91,2132,2133],{"class":105}," clipboard",[91,2135,116],{"class":97},[91,2137,2138],{"class":101}," FlowClipboardPayload",[91,2140,230],{"class":97},[91,2142,2143],{"class":155}," null",[91,2145,2146],{"class":97}," =",[91,2148,2143],{"class":155},[91,2150,159],{"class":105},[10,2152,2153],{},"粘贴的内容是节点和边，格式带版本号。走系统剪贴板意味着把画布的 JSON 写进用户的剪贴板，用户去别处粘贴会看到一堆结构数据。应用内的模块级变量没有这个问题。",[10,2155,2156],{},[29,2157,2158],{},"没选节点时不拦截。",[82,2160,2162],{"className":84,"code":2161,"language":86,"meta":87,"style":87},"\u002F\u002F Cmd\u002FCtrl+C：复制选中的节点。\n\u002F\u002F 没选节点就不拦截——用户可能正在复制节点产物里的文字，抢了就是坏默认行为\n\u002F\u002F Cmd\u002FCtrl+V：粘贴。应用内剪贴板为空时同样放行系统默认行为\n",[14,2163,2164,2169,2174],{"__ignoreMap":87},[91,2165,2166],{"class":93,"line":94},[91,2167,2168],{"class":1371},"\u002F\u002F Cmd\u002FCtrl+C：复制选中的节点。\n",[91,2170,2171],{"class":93,"line":109},[91,2172,2173],{"class":1371},"\u002F\u002F 没选节点就不拦截——用户可能正在复制节点产物里的文字，抢了就是坏默认行为\n",[91,2175,2176],{"class":93,"line":125},[91,2177,2178],{"class":1371},"\u002F\u002F Cmd\u002FCtrl+V：粘贴。应用内剪贴板为空时同样放行系统默认行为\n",[10,2180,2181,2182,2185],{},"这两条合起来是一个原则：",[29,2183,2184],{},"快捷键只在它有明确意图时生效","。用户按 Cmd+C 时可能是想复制提示词里的文字，此时抢过来是损失。",[10,2187,2188],{},"粘贴到画布时重新生成 ID（节点 ID 最多重试 20 次防碰撞），偏移 24 像素，粘贴的结果进入撤销栈并成为新的选中项。",[19,2190,2192],{"id":2191},"四一键整理布局","四、一键整理布局",[10,2194,2195],{},"按依赖深度分层的纯函数，不引布局引擎：",[82,2197,2199],{"className":84,"code":2198,"language":86,"meta":87,"style":87},"\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",[14,2200,2201,2205,2210,2214,2219,2224,2229,2233,2247,2261],{"__ignoreMap":87},[91,2202,2203],{"class":93,"line":94},[91,2204,1459],{"class":1371},[91,2206,2207],{"class":93,"line":109},[91,2208,2209],{"class":1371}," * 一键整理布局：按依赖深度分层的纯函数。\n",[91,2211,2212],{"class":93,"line":125},[91,2213,1676],{"class":1371},[91,2215,2216],{"class":93,"line":131},[91,2217,2218],{"class":1371}," * 不引 dagre\u002Felk——画布的图是小规模 DAG（上限 100 节点），\n",[91,2220,2221],{"class":93,"line":138},[91,2222,2223],{"class":1371}," * 「上游在左、下游在右、同层竖排」这一条规则就够读顺一张乱图，\n",[91,2225,2226],{"class":93,"line":147},[91,2227,2228],{"class":1371}," * 引一个布局引擎为它的边缘能力买单不值。\n",[91,2230,2231],{"class":93,"line":162},[91,2232,1504],{"class":1371},[91,2234,2235,2237,2240,2242,2245],{"class":93,"line":175},[91,2236,1525],{"class":97},[91,2238,2239],{"class":155}," COLUMN_GAP",[91,2241,2146],{"class":97},[91,2243,2244],{"class":155}," 380",[91,2246,159],{"class":105},[91,2248,2249,2251,2254,2256,2259],{"class":93,"line":180},[91,2250,1525],{"class":97},[91,2252,2253],{"class":155}," ROW_GAP",[91,2255,2146],{"class":97},[91,2257,2258],{"class":155}," 240",[91,2260,159],{"class":105},[91,2262,2263,2265,2268,2270,2273,2276,2279,2282],{"class":93,"line":185},[91,2264,1525],{"class":97},[91,2266,2267],{"class":155}," ORIGIN",[91,2269,2146],{"class":97},[91,2271,2272],{"class":105}," { x: ",[91,2274,2275],{"class":155},"40",[91,2277,2278],{"class":105},", y: ",[91,2280,2281],{"class":155},"60",[91,2283,2284],{"class":105}," };\n",[10,2286,2287],{},"同列保持用户原有的上下相对顺序。这一条是这类功能能不能用的分界线——整理完之后用户还得能认出自己的图。",[10,2289,2290],{},"节点上限 100，单批最大 50 项，展开后的总任务上限 200。这几个数决定了上面那个判断成立：在这个规模内，一条规则够用。",[19,2292,2294],{"id":2293},"五便签不能做成节点","五、便签不能做成节点",[10,2296,2297],{},"画布上要有地方写注释。最自然的做法是加一个「便签节点」，但它的实现方式正好相反：",[82,2299,2301],{"className":84,"code":2300,"language":86,"meta":87,"style":87},"\u002F**\n * 画布便签：纯注释，不参与调度、连线与计费。\n *\u002F\n",[14,2302,2303,2307,2312],{"__ignoreMap":87},[91,2304,2305],{"class":93,"line":94},[91,2306,1459],{"class":1371},[91,2308,2309],{"class":93,"line":109},[91,2310,2311],{"class":1371}," * 画布便签：纯注释，不参与调度、连线与计费。\n",[91,2313,2314],{"class":93,"line":125},[91,2315,1504],{"class":1371},[82,2317,2319],{"className":84,"code":2318,"language":86,"meta":87,"style":87},"\u002F\u002F 不是 xyflow 节点——它不参与连线、调度与计费，做成节点要在注册表、校验器、执行器三处逐一开豁免，成本远高于一张自绘卡片。\n",[14,2320,2321],{"__ignoreMap":87},[91,2322,2323],{"class":93,"line":94},[91,2324,2318],{"class":1371},[10,2326,2327],{},"做成节点意味着它要在三处地方被显式排除。而排除逻辑每加一处，将来就多一处要维护的例外。做成一张画在流坐标系里的自绘卡片，这些例外一个都不需要。",[10,2329,2330],{},"契约里限制条数 100、单条 2000 字。",[19,2332,2334],{"id":2333},"六批量框","六、批量框",[10,2336,2337],{},"批量框是「框内的子图跑 N 遍」。它带来的第一件事是边界规则：",[10,2339,2340],{},"框内节点的输出不能接到框外，反过来可以。",[82,2342,2344],{"className":84,"code":2343,"language":86,"meta":87,"style":87},"\u002F\u002F 批量框边界规则：框内节点的输出不能接到框外（或另一个框）。\n\u002F\u002F 框内子图每份各跑一遍，往外接意味着下游要收 N 份——运行时不支持这种收束；\n\u002F\u002F 反方向（框外 → 框内）合法：同一上游共享给每份拷贝。\n",[14,2345,2346,2351,2356],{"__ignoreMap":87},[91,2347,2348],{"class":93,"line":94},[91,2349,2350],{"class":1371},"\u002F\u002F 批量框边界规则：框内节点的输出不能接到框外（或另一个框）。\n",[91,2352,2353],{"class":93,"line":109},[91,2354,2355],{"class":1371},"\u002F\u002F 框内子图每份各跑一遍，往外接意味着下游要收 N 份——运行时不支持这种收束；\n",[91,2357,2358],{"class":93,"line":125},[91,2359,2360],{"class":1371},"\u002F\u002F 反方向（框外 → 框内）合法：同一上游共享给每份拷贝。\n",[10,2362,2363],{},"拒绝时的文案要给下一步：",[1329,2365,2366],{},[10,2367,2368],{},"批量框内的节点不能连到框外：框内每份各跑一遍，产物会直接进素材库。要串联处理就把目标节点也拖进框里。",[10,2370,2371],{},"第二件事是删除节点的连带处理。节点被删掉之后，批量框里会留下幽灵成员，而运行创建时会拒绝整张图：",[82,2373,2375],{"className":84,"code":2374,"language":86,"meta":87,"style":87},"\u002F**\n * 从所有批量框成员里剔除已删除的节点；成员清空的框一并删除。\n *\n * 删除节点必须同步清理：残留的幽灵成员会让 run-create 直接拒绝整张画布\n * （「批量框引用了图中不存在的节点」），用户面对的是一张再也跑不起来的图。\n *\u002F\n",[14,2376,2377,2381,2386,2390,2395,2400],{"__ignoreMap":87},[91,2378,2379],{"class":93,"line":94},[91,2380,1459],{"class":1371},[91,2382,2383],{"class":93,"line":109},[91,2384,2385],{"class":1371}," * 从所有批量框成员里剔除已删除的节点；成员清空的框一并删除。\n",[91,2387,2388],{"class":93,"line":125},[91,2389,1676],{"class":1371},[91,2391,2392],{"class":93,"line":131},[91,2393,2394],{"class":1371}," * 删除节点必须同步清理：残留的幽灵成员会让 run-create 直接拒绝整张画布\n",[91,2396,2397],{"class":93,"line":138},[91,2398,2399],{"class":1371}," * （「批量框引用了图中不存在的节点」），用户面对的是一张再也跑不起来的图。\n",[91,2401,2402],{"class":93,"line":147},[91,2403,1504],{"class":1371},[10,2405,2406],{},"第三件事是撤销栈。批量框要进快照：",[82,2408,2410],{"className":84,"code":2409,"language":86,"meta":87,"style":87},"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",[14,2411,2412,2425,2443,2459,2474,2479,2495],{"__ignoreMap":87},[91,2413,2414,2417,2420,2423],{"class":93,"line":94},[91,2415,2416],{"class":97},"export",[91,2418,2419],{"class":97}," interface",[91,2421,2422],{"class":101}," GraphSnapshot",[91,2424,106],{"class":105},[91,2426,2427,2430,2433,2435,2438,2441],{"class":93,"line":109},[91,2428,2429],{"class":97},"  readonly",[91,2431,2432],{"class":112}," nodes",[91,2434,116],{"class":97},[91,2436,2437],{"class":97}," readonly",[91,2439,2440],{"class":101}," CanvasFlowNode",[91,2442,122],{"class":105},[91,2444,2445,2447,2450,2452,2454,2457],{"class":93,"line":125},[91,2446,2429],{"class":97},[91,2448,2449],{"class":112}," edges",[91,2451,116],{"class":97},[91,2453,2437],{"class":97},[91,2455,2456],{"class":101}," CanvasFlowEdge",[91,2458,122],{"class":105},[91,2460,2461,2463,2466,2468,2470,2472],{"class":93,"line":131},[91,2462,2429],{"class":97},[91,2464,2465],{"class":112}," selected",[91,2467,116],{"class":97},[91,2469,2437],{"class":97},[91,2471,156],{"class":155},[91,2473,122],{"class":105},[91,2475,2476],{"class":93,"line":138},[91,2477,2478],{"class":1371},"  \u002F** 批量框。撤销\u002F重做要连它一起回放，否则撤销删框后节点回来了框没了 *\u002F\n",[91,2480,2481,2483,2486,2488,2490,2493],{"class":93,"line":147},[91,2482,2429],{"class":97},[91,2484,2485],{"class":112}," batchGroups",[91,2487,116],{"class":97},[91,2489,2437],{"class":97},[91,2491,2492],{"class":101}," CanvasFlowBatchGroup",[91,2494,122],{"class":105},[91,2496,2497],{"class":93,"line":162},[91,2498,128],{"class":105},[10,2500,2501],{},"第四件事是状态聚合。批量框内一个节点对应多行执行状态，显示取哪个：",[82,2503,2506],{"className":2504,"code":2505,"language":1345},[1343],"failed > running > pending > cancelled > succeeded > idle\n",[14,2507,2505],{"__ignoreMap":87},[10,2509,2510,2511,2514],{},"原先只让 ",[14,2512,2513],{},"failed"," 优先。结果是第一份先成功、第二份还在跑的时候，节点就提前显示成「成功」。",[19,2516,2518],{"id":2517},"七续跑不重复扣费","七、续跑不重复扣费",[10,2520,2521],{},"这一项是这八项里唯一涉及钱的。",[10,2523,2524],{},"失败或被取消的运行，可以续跑。实现方式不是「重跑一遍」：",[82,2526,2528],{"className":84,"code":2527,"language":86,"meta":87,"style":87},"\u002F**\n * 单节点重试：给失败\u002F被取消的运行造一个「续跑」运行。成功节点的行原样回填\n * （产物、billingRef、时间戳都保留）——executor 的调度器见到 succeeded 行\n * 会直接当作上游已就绪，不会重新执行，也就不会重复扣费；其余节点\n * （failed \u002F cancelled \u002F pending）重置成全新的 pending 行，正常调度重跑。\n * 不修改原运行：重试是一条新的 CanvasFlowRun，历史记录保持完整。\n *\u002F\n",[14,2529,2530,2534,2539,2544,2549,2554,2559],{"__ignoreMap":87},[91,2531,2532],{"class":93,"line":94},[91,2533,1459],{"class":1371},[91,2535,2536],{"class":93,"line":109},[91,2537,2538],{"class":1371}," * 单节点重试：给失败\u002F被取消的运行造一个「续跑」运行。成功节点的行原样回填\n",[91,2540,2541],{"class":93,"line":125},[91,2542,2543],{"class":1371}," * （产物、billingRef、时间戳都保留）——executor 的调度器见到 succeeded 行\n",[91,2545,2546],{"class":93,"line":131},[91,2547,2548],{"class":1371}," * 会直接当作上游已就绪，不会重新执行，也就不会重复扣费；其余节点\n",[91,2550,2551],{"class":93,"line":138},[91,2552,2553],{"class":1371}," * （failed \u002F cancelled \u002F pending）重置成全新的 pending 行，正常调度重跑。\n",[91,2555,2556],{"class":93,"line":147},[91,2557,2558],{"class":1371}," * 不修改原运行：重试是一条新的 CanvasFlowRun，历史记录保持完整。\n",[91,2560,2561],{"class":93,"line":162},[91,2562,1504],{"class":1371},[10,2564,2565,2566,2569],{},"关键在于复用判定落在行状态上，而不是「这次运行是新是旧」。所以续跑不需要额外的豁免逻辑：被判成功的节点带着原来的 ",[14,2567,2568],{},"billingRef","，调度器看到它就不再执行。",[10,2571,2572],{},"预估只算子集，余额检查同理。接口层有幂等入口（按用户 + 请求 ID 查重）和归属校验。不可重试的两种情形给出明确文案：",[82,2574,2577],{"className":2575,"code":2576,"language":1345},[1343],"这次运行已全部成功，没有可重试的节点\n运行还没结束，等它终结后再重试\n",[14,2578,2576],{"__ignoreMap":87},[10,2580,2581],{},"界面上的按钮从「重试」改成「重试失败节点」，带一句说明：",[1329,2583,2584],{},[10,2585,2586],{},"成功节点的产物直接沿用，只有失败的节点会重新执行并计费。",[19,2588,2590],{"id":2589},"八八项之间的关系","八、八项之间的关系",[10,2592,2593],{},"单独看每一项，都是常规功能。放在一起时，出现了几条贯穿的取舍：",[10,2595,2596,2599,2600,2602],{},[29,2597,2598],{},"状态的归属要单一。"," 画布 ID 放 URL；运行状态用同一套 ",[14,2601,2092],{},"；撤销栈是唯一的变更入口。三处都收成一个来源之后，「刷新之后看到什么」才有确定答案。",[10,2604,2605,2608],{},[29,2606,2607],{},"例外要少。"," 便签不做成节点，就是为了避免在注册表、校验器、执行器三处开豁免。每开一处例外，就多一处将来会忘记的地方。",[10,2610,2611,2614],{},[29,2612,2613],{},"别抢用户的操作。"," 没选节点时不拦 Cmd+C；整理布局保留同列原有顺序；批量框拒绝时给下一步而不是只报错。",[10,2616,2617,2620],{},[29,2618,2619],{},"涉及钱的判定落在状态上。"," 续跑复用靠行状态，不靠运行的新旧；批量份数由框上显式配置，预估与执行读同一个函数。",[10,2622,2623],{},"最后一条是这一天唯一和故障档案有关的部分。同一天里，批量链路翻出一处行约定矛盾和一处从未命中的推断分支，两处的成因都是「两侧各写一份」。八项补齐之后，取值入口都比之前更集中——这不是巧合，是同一件事的两个方向。",[1272,2625,2626],{},"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":87,"searchDepth":109,"depth":109,"links":2628},[2629,2630,2631,2632,2633,2634,2635,2636],{"id":2024,"depth":109,"text":2025},{"id":2073,"depth":109,"text":2074},{"id":2111,"depth":109,"text":2112},{"id":2191,"depth":109,"text":2192},{"id":2293,"depth":109,"text":2294},{"id":2333,"depth":109,"text":2334},{"id":2517,"depth":109,"text":2518},{"id":2589,"depth":109,"text":2590},"2026-08-20",{},"\u002F2026-08-20",{"title":2013,"description":2018},"2026-08-20-画布这一步深链运行历史与续跑","一次补齐八项画布操作：URL 深链、小地图、运行历史、复制粘贴、批量框、一键整理布局、便签，以及失败运行的单节点续跑。",[2644,2645,2646,2647,2648,2649],"画布","工作流","URL状态","撤销重做","批量执行","单节点重试","eA-RfotRTWBXXppeiZFtcguPXjtpnVRWrkWgI5LKfY0",{"id":2652,"title":2653,"body":2654,"column":1292,"date":3684,"description":2658,"extension":1295,"hero_image":1296,"meta":3685,"navigation":134,"path":3686,"seo":3687,"series_id":1296,"severity":1296,"stem":3688,"summary":3689,"tags":3690,"__hash__":3696},"posts\u002F2026-08-08-同一张能力表抄了三份.md","同一张能力表，抄了三份",{"type":7,"value":2655,"toc":3676},[2656,2659,2662,2665,2669,2672,2821,2824,2830,2836,2839,2907,2910,2914,2917,2920,2942,2945,2948,2954,2969,2972,2976,2979,2982,2988,2991,3117,3120,3129,3132,3139,3347,3350,3354,3365,3368,3371,3431,3434,3495,3498,3531,3535,3538,3541,3547,3554,3557,3564,3571,3575,3578,3605,3611,3614,3664,3670,3673],[10,2657,2658],{},"用户反馈：设置里选完模型，分辨率档不对——Seedance-2.5 只有 480P 和 720P；另外 2.5 的上游支持到 29 秒，故事板那边应该跟着放开。",[10,2660,2661],{},"第一句说得对，2.5 确实只有 480p 和 720p。第二句也对，它确实支持 4~29 秒。",[10,2663,2664],{},"两条都对，说明我们发出去的档位和上游支持的对不上。查下去发现，同一份能力表被抄了三份。",[19,2666,2668],{"id":2667},"一三处各自漂移","一、三处各自漂移",[10,2670,2671],{},"后端有一张权威表，写清了每个模型支持哪些分辨率、哪些时长：",[82,2673,2675],{"className":84,"code":2674,"language":86,"meta":87,"style":87},"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",[14,2676,2677,2711,2739,2754,2769,2784,2799,2816],{"__ignoreMap":87},[91,2678,2679,2681,2684,2686,2689,2692,2695,2697,2700,2703,2706,2709],{"class":93,"line":94},[91,2680,1525],{"class":97},[91,2682,2683],{"class":155}," MODEL_RESOLUTIONS",[91,2685,116],{"class":97},[91,2687,2688],{"class":101}," Record",[91,2690,2691],{"class":105},"\u003C",[91,2693,2694],{"class":101},"VideoModel",[91,2696,777],{"class":105},[91,2698,2699],{"class":97},"readonly",[91,2701,2702],{"class":101}," VideoResolution",[91,2704,2705],{"class":105},"[]> ",[91,2707,2708],{"class":97},"=",[91,2710,106],{"class":105},[91,2712,2713,2716,2718,2721,2723,2726,2728,2731,2733,2736],{"class":93,"line":109},[91,2714,2715],{"class":226},"  \"seedance-2\"",[91,2717,952],{"class":105},[91,2719,2720],{"class":226},"\"480p\"",[91,2722,777],{"class":105},[91,2724,2725],{"class":226},"\"720p\"",[91,2727,777],{"class":105},[91,2729,2730],{"class":226},"\"1080p\"",[91,2732,777],{"class":105},[91,2734,2735],{"class":226},"\"4k\"",[91,2737,2738],{"class":105},"],\n",[91,2740,2741,2744,2746,2748,2750,2752],{"class":93,"line":125},[91,2742,2743],{"class":226},"  \"seedance-2-fast\"",[91,2745,952],{"class":105},[91,2747,2720],{"class":226},[91,2749,777],{"class":105},[91,2751,2725],{"class":226},[91,2753,2738],{"class":105},[91,2755,2756,2759,2761,2763,2765,2767],{"class":93,"line":131},[91,2757,2758],{"class":226},"  \"seedance-2-mini\"",[91,2760,952],{"class":105},[91,2762,2720],{"class":226},[91,2764,777],{"class":105},[91,2766,2725],{"class":226},[91,2768,2738],{"class":105},[91,2770,2771,2774,2776,2778,2780,2782],{"class":93,"line":138},[91,2772,2773],{"class":226},"  \"seedance-2.5\"",[91,2775,952],{"class":105},[91,2777,2720],{"class":226},[91,2779,777],{"class":105},[91,2781,2725],{"class":226},[91,2783,2738],{"class":105},[91,2785,2786,2789,2791,2793,2795,2797],{"class":93,"line":147},[91,2787,2788],{"class":226},"  \"kling-v3\"",[91,2790,952],{"class":105},[91,2792,2725],{"class":226},[91,2794,777],{"class":105},[91,2796,2730],{"class":226},[91,2798,2738],{"class":105},[91,2800,2801,2804,2806,2809,2811,2814],{"class":93,"line":162},[91,2802,2803],{"class":226},"  \"minimax-h3\"",[91,2805,952],{"class":105},[91,2807,2808],{"class":226},"\"2k\"",[91,2810,777],{"class":105},[91,2812,2813],{"class":226},"\"768p\"",[91,2815,2738],{"class":105},[91,2817,2818],{"class":93,"line":175},[91,2819,2820],{"class":105},"};\n",[10,2822,2823],{},"前端有一份手抄的副本。它早就漂了，而且漂在两个方向。",[10,2825,2826,2829],{},[29,2827,2828],{},"多出的档位。"," 设置页的取档逻辑是写死的规则：「H3 两档、其余一律四档」。于是 2.5、fast、mini 这三个只有 480p 和 720p 的模型，界面上给出 1080P 和 4K。用户选了，选中即报未配价，或者直接被上游拒。",[10,2831,2832,2835],{},[29,2833,2834],{},"少掉的档位。"," Kling 只支持 720p 和 1080p，界面给的是四档。反过来的情况也存在：如果某个模型的档位比默认四档更多，界面也显示不出来。",[10,2837,2838],{},"时长那一处漂得更彻底。前端和后端各写了一份 4~15 的夹取：",[82,2840,2842],{"className":84,"code":2841,"language":86,"meta":87,"style":87},"\u002F\u002F 前端\nreturn Math.max(4, Math.min(15, value));\n\n\u002F\u002F 后端\nreturn Math.max(4, Math.min(15, value));\n",[14,2843,2844,2849,2876,2880,2885],{"__ignoreMap":87},[91,2845,2846],{"class":93,"line":94},[91,2847,2848],{"class":1371},"\u002F\u002F 前端\n",[91,2850,2851,2854,2856,2859,2861,2864,2866,2868,2870,2873],{"class":93,"line":109},[91,2852,2853],{"class":97},"return",[91,2855,1542],{"class":105},[91,2857,2858],{"class":101},"max",[91,2860,1548],{"class":105},[91,2862,2863],{"class":155},"4",[91,2865,1554],{"class":105},[91,2867,1545],{"class":101},[91,2869,1548],{"class":105},[91,2871,2872],{"class":155},"15",[91,2874,2875],{"class":105},", value));\n",[91,2877,2878],{"class":93,"line":125},[91,2879,135],{"emptyLinePlaceholder":134},[91,2881,2882],{"class":93,"line":131},[91,2883,2884],{"class":1371},"\u002F\u002F 后端\n",[91,2886,2887,2889,2891,2893,2895,2897,2899,2901,2903,2905],{"class":93,"line":138},[91,2888,2853],{"class":97},[91,2890,1542],{"class":105},[91,2892,2858],{"class":101},[91,2894,1548],{"class":105},[91,2896,2863],{"class":155},[91,2898,1554],{"class":105},[91,2900,1545],{"class":101},[91,2902,1548],{"class":105},[91,2904,2872],{"class":155},[91,2906,2875],{"class":105},[10,2908,2909],{},"2.5 传 29 秒，被悄悄砍成 15。用户看不出为什么变短——没有任何提示，界面上显示的就是 15 秒。",[19,2911,2913],{"id":2912},"二被砍掉的秒数后面跟着钱","二、被砍掉的秒数后面跟着钱",[10,2915,2916],{},"时长那一处还不只是显示问题。",[10,2918,2919],{},"故事板按固定秒数切板。代码里是一个常量：",[82,2921,2923],{"className":84,"code":2922,"language":86,"meta":87,"style":87},"export const SHOT_SECONDS = 15;\n",[14,2924,2925],{"__ignoreMap":87},[91,2926,2927,2929,2932,2935,2937,2940],{"class":93,"line":94},[91,2928,2416],{"class":97},[91,2930,2931],{"class":97}," const",[91,2933,2934],{"class":155}," SHOT_SECONDS",[91,2936,2146],{"class":97},[91,2938,2939],{"class":155}," 15",[91,2941,159],{"class":105},[10,2943,2944],{},"按 15 秒一切。2.5 一板能放 29 秒，硬按 15 秒切，一集会被切成两倍数量的板。",[10,2946,2947],{},"板数翻倍就是出图与出片的费用翻倍。这不是理论推算——用户选 2.5 的动机就是长板数少切，结果切得和短时长模型一样多，还多花一倍钱。",[10,2949,2950,2953],{},[14,2951,2952],{},"comic-subshot.ts"," 里留着上限常量的注释，写明了它只是「模型未知时的保守默认」：",[82,2955,2957],{"className":84,"code":2956,"language":86,"meta":87,"style":87},"\u002F\u002F 上限只是「模型未知时的保守默认」——真正的上限逐模型不同（seedance-2.5 能到 29 秒），\n\u002F\u002F 调用方应把该模型的最大秒数作为 maxBoardSec 传进来。写死 15 的话，选了 2.5 也只切 15 秒一板。\n",[14,2958,2959,2964],{"__ignoreMap":87},[91,2960,2961],{"class":93,"line":94},[91,2962,2963],{"class":1371},"\u002F\u002F 上限只是「模型未知时的保守默认」——真正的上限逐模型不同（seedance-2.5 能到 29 秒），\n",[91,2965,2966],{"class":93,"line":109},[91,2967,2968],{"class":1371},"\u002F\u002F 调用方应把该模型的最大秒数作为 maxBoardSec 传进来。写死 15 的话，选了 2.5 也只切 15 秒一板。\n",[10,2970,2971],{},"调用方没传。",[19,2973,2975],{"id":2974},"三提示词长度四个数一个是实测出来的","三、提示词长度：四个数，一个是实测出来的",[10,2977,2978],{},"同一批里还有一个更贵的限制：提示词长度上限。",[10,2980,2981],{},"上游对提示词有硬上限，超过直接拒。各模型不一样，而这些值在很长一段时间里没有集中维护。表现是一条线上的完整失败：",[82,2983,2986],{"className":2984,"code":2985,"language":1345},[1343],"模型 seedance-2.5 的提示词不能超过 5000 个字符，当前为 18545 个字符\n",[14,2987,2985],{"__ignoreMap":87},[10,2989,2990],{},"接入本身没问题，是缺了长度约束。补完之后这张表长这样：",[82,2992,2994],{"className":84,"code":2993,"language":86,"meta":87,"style":87},"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",[14,2995,2996,3029,3040,3051,3061,3071,3082,3087,3097,3101],{"__ignoreMap":87},[91,2997,2998,3000,3003,3005,3008,3010,3013,3015,3017,3019,3022,3025,3027],{"class":93,"line":94},[91,2999,1525],{"class":97},[91,3001,3002],{"class":155}," MODEL_PROMPT_LIMITS",[91,3004,116],{"class":97},[91,3006,3007],{"class":101}," Partial",[91,3009,2691],{"class":105},[91,3011,3012],{"class":101},"Record",[91,3014,2691],{"class":105},[91,3016,2694],{"class":101},[91,3018,777],{"class":105},[91,3020,3021],{"class":155},"number",[91,3023,3024],{"class":105},">> ",[91,3026,2708],{"class":97},[91,3028,106],{"class":105},[91,3030,3031,3033,3035,3038],{"class":93,"line":109},[91,3032,2803],{"class":226},[91,3034,723],{"class":105},[91,3036,3037],{"class":155},"7000",[91,3039,729],{"class":105},[91,3041,3042,3044,3046,3049],{"class":93,"line":125},[91,3043,2715],{"class":226},[91,3045,723],{"class":105},[91,3047,3048],{"class":155},"2500",[91,3050,729],{"class":105},[91,3052,3053,3055,3057,3059],{"class":93,"line":131},[91,3054,2743],{"class":226},[91,3056,723],{"class":105},[91,3058,3048],{"class":155},[91,3060,729],{"class":105},[91,3062,3063,3065,3067,3069],{"class":93,"line":138},[91,3064,2758],{"class":226},[91,3066,723],{"class":105},[91,3068,3048],{"class":155},[91,3070,729],{"class":105},[91,3072,3073,3075,3077,3080],{"class":93,"line":147},[91,3074,2773],{"class":226},[91,3076,723],{"class":105},[91,3078,3079],{"class":155},"5000",[91,3081,729],{"class":105},[91,3083,3084],{"class":93,"line":162},[91,3085,3086],{"class":1371},"  \u002F\u002F 20260809 实测：发 2553 字被硬拒 `prompt: size must be between 0 and 2500`\n",[91,3088,3089,3091,3093,3095],{"class":93,"line":175},[91,3090,2788],{"class":226},[91,3092,723],{"class":105},[91,3094,3048],{"class":155},[91,3096,729],{"class":105},[91,3098,3099],{"class":93,"line":180},[91,3100,2820],{"class":105},[91,3102,3103,3105,3107,3110,3112,3115],{"class":93,"line":185},[91,3104,2416],{"class":97},[91,3106,2931],{"class":97},[91,3108,3109],{"class":155}," PROMPT_LIMIT_FALLBACK",[91,3111,2146],{"class":97},[91,3113,3114],{"class":155}," 50000",[91,3116,159],{"class":105},[10,3118,3119],{},"这几个数的来源不同，注释里写清了哪个是实测的：",[1329,3121,3122],{},[10,3123,3124,3125,3128],{},"seedance-2.5 的 5000 是",[29,3126,3127],{},"实测出来的，文档只字未提","：线上一条 18545 字的脚本被拒，报文写「提示词不能超过 5000 个字符」。同批实测另两条渠道收 16500 字照样 200，所以这是 2.5 独有的限制，不能推广到整个系列。加新渠道前先拿超长 prompt 打一次，别等线上炸。",[10,3130,3131],{},"这段话是这张表里唯一带出处的一条。其余几个数是按上游文档填的，文档没提的只能等线上撞。",[10,3133,3134,3135,3138],{},"长度约束补齐之后，还有一个配套的预算计算。原先只有 H3 有预算，其余模型返回 ",[14,3136,3137],{},"null","，等于完全不限：",[82,3140,3142],{"className":84,"code":3141,"language":86,"meta":87,"style":87},"\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",[14,3143,3144,3149,3165,3170,3183,3187,3233,3249,3297,3320,3343],{"__ignoreMap":87},[91,3145,3146],{"class":93,"line":94},[91,3147,3148],{"class":1371},"\u002F** 每秒成片对应的脚本篇幅。15 秒 → 7500 字，是人肉审稿与出片效果都合适的密度。 *\u002F\n",[91,3150,3151,3153,3155,3158,3160,3163],{"class":93,"line":109},[91,3152,2416],{"class":97},[91,3154,2931],{"class":97},[91,3156,3157],{"class":155}," CHARS_PER_SECOND",[91,3159,2146],{"class":97},[91,3161,3162],{"class":155}," 500",[91,3164,159],{"class":105},[91,3166,3167],{"class":93,"line":125},[91,3168,3169],{"class":1371},"\u002F** 留给用户自己追加修改的余量：脚本刚好顶满上限时，用户加一句就被上游拒了。 *\u002F\n",[91,3171,3172,3174,3177,3179,3181],{"class":93,"line":131},[91,3173,1525],{"class":97},[91,3175,3176],{"class":155}," PROMPT_BUDGET_MARGIN",[91,3178,2146],{"class":97},[91,3180,3162],{"class":155},[91,3182,159],{"class":105},[91,3184,3185],{"class":93,"line":138},[91,3186,135],{"emptyLinePlaceholder":134},[91,3188,3189,3191,3194,3197,3199,3202,3204,3206,3208,3211,3213,3216,3218,3220,3223,3225,3227,3229,3231],{"class":93,"line":147},[91,3190,2416],{"class":97},[91,3192,3193],{"class":97}," function",[91,3195,3196],{"class":101}," scriptCharBudget",[91,3198,1548],{"class":105},[91,3200,3201],{"class":112},"targetModel",[91,3203,116],{"class":97},[91,3205,156],{"class":155},[91,3207,230],{"class":97},[91,3209,3210],{"class":155}," undefined",[91,3212,777],{"class":105},[91,3214,3215],{"class":112},"durationSec",[91,3217,261],{"class":97},[91,3219,408],{"class":155},[91,3221,3222],{"class":105},")",[91,3224,116],{"class":97},[91,3226,408],{"class":155},[91,3228,230],{"class":97},[91,3230,2143],{"class":155},[91,3232,106],{"class":105},[91,3234,3235,3238,3241,3243,3246],{"class":93,"line":162},[91,3236,3237],{"class":97},"  const",[91,3239,3240],{"class":155}," hardLimit",[91,3242,2146],{"class":97},[91,3244,3245],{"class":101}," modelPromptHardLimit",[91,3247,3248],{"class":105},"(targetModel);\n",[91,3250,3251,3254,3257,3260,3263,3266,3269,3272,3275,3278,3280,3283,3286,3289,3292,3294],{"class":93,"line":175},[91,3252,3253],{"class":97},"  if",[91,3255,3256],{"class":105}," (",[91,3258,3259],{"class":97},"!",[91,3261,3262],{"class":105},"durationSec ",[91,3264,3265],{"class":97},"||",[91,3267,3268],{"class":97}," !",[91,3270,3271],{"class":105},"Number.",[91,3273,3274],{"class":101},"isFinite",[91,3276,3277],{"class":105},"(durationSec) ",[91,3279,3265],{"class":97},[91,3281,3282],{"class":105}," durationSec ",[91,3284,3285],{"class":97},"\u003C=",[91,3287,3288],{"class":155}," 0",[91,3290,3291],{"class":105},") ",[91,3293,2853],{"class":97},[91,3295,3296],{"class":105}," hardLimit;\n",[91,3298,3299,3301,3304,3306,3308,3311,3314,3316,3318],{"class":93,"line":180},[91,3300,3237],{"class":97},[91,3302,3303],{"class":155}," byDuration",[91,3305,2146],{"class":97},[91,3307,1542],{"class":105},[91,3309,3310],{"class":101},"round",[91,3312,3313],{"class":105},"(durationSec ",[91,3315,1563],{"class":97},[91,3317,3157],{"class":155},[91,3319,1848],{"class":105},[91,3321,3322,3325,3328,3331,3333,3335,3338,3340],{"class":93,"line":185},[91,3323,3324],{"class":97},"  return",[91,3326,3327],{"class":105}," hardLimit ",[91,3329,3330],{"class":97},"?",[91,3332,1542],{"class":105},[91,3334,1545],{"class":101},[91,3336,3337],{"class":105},"(byDuration, hardLimit) ",[91,3339,116],{"class":97},[91,3341,3342],{"class":105}," byDuration;\n",[91,3344,3345],{"class":93,"line":194},[91,3346,128],{"class":105},[10,3348,3349],{},"按 15 秒算出来是 7500 字——上限撤销之后，脚本直接写到 1.8 万字，那也是生成耗时 193 秒的原因。",[19,3351,3353],{"id":3352},"四抄一份是必要的那就测试它","四、抄一份是必要的，那就测试它",[10,3355,3356,3357,3360,3361,3364],{},"前端为什么不能直接读后端那张表：",[14,3358,3359],{},"apps\u002Fweb"," 不能 import ",[14,3362,3363],{},"apps\u002Fapi","。而节点面板必须知道「这个模型支持哪些分辨率、哪些时长」，才能只给出跑得通的选项。",[10,3366,3367],{},"抄一份是必要的。那就把「不许走样」变成断言。",[10,3369,3370],{},"新增的共享包文件头写明了它的性质和守卫：",[82,3372,3374],{"className":84,"code":3373,"language":86,"meta":87,"style":87},"\u002F**\n * 生图 \u002F 视频的**上游真实能力**表。\n *\n * 这是 apps\u002Fapi 里两张表的镜像……\n *\n * **为什么要抄一份**：前端（apps\u002Fweb）不能 import apps\u002Fapi，而节点面板必须知道\n * 「这个模型支持哪些分辨率、哪些时长」才能只给出跑得通的选项。\n * 抄一份就有走样的风险，所以 apps\u002Fapi 里有一条对比测试逐项核对两边——\n * 谁改了上游表而没同步这里，测试立刻红。\n *\n * 硬规矩：这里只允许出现上游真支持的取值。多给一个选项，用户就会选到一个必然失败的组合。\n *\u002F\n",[14,3375,3376,3380,3385,3389,3394,3398,3403,3408,3413,3418,3422,3427],{"__ignoreMap":87},[91,3377,3378],{"class":93,"line":94},[91,3379,1459],{"class":1371},[91,3381,3382],{"class":93,"line":109},[91,3383,3384],{"class":1371}," * 生图 \u002F 视频的**上游真实能力**表。\n",[91,3386,3387],{"class":93,"line":125},[91,3388,1676],{"class":1371},[91,3390,3391],{"class":93,"line":131},[91,3392,3393],{"class":1371}," * 这是 apps\u002Fapi 里两张表的镜像……\n",[91,3395,3396],{"class":93,"line":138},[91,3397,1676],{"class":1371},[91,3399,3400],{"class":93,"line":147},[91,3401,3402],{"class":1371}," * **为什么要抄一份**：前端（apps\u002Fweb）不能 import apps\u002Fapi，而节点面板必须知道\n",[91,3404,3405],{"class":93,"line":162},[91,3406,3407],{"class":1371}," * 「这个模型支持哪些分辨率、哪些时长」才能只给出跑得通的选项。\n",[91,3409,3410],{"class":93,"line":175},[91,3411,3412],{"class":1371}," * 抄一份就有走样的风险，所以 apps\u002Fapi 里有一条对比测试逐项核对两边——\n",[91,3414,3415],{"class":93,"line":180},[91,3416,3417],{"class":1371}," * 谁改了上游表而没同步这里，测试立刻红。\n",[91,3419,3420],{"class":93,"line":185},[91,3421,1676],{"class":1371},[91,3423,3424],{"class":93,"line":194},[91,3425,3426],{"class":1371}," * 硬规矩：这里只允许出现上游真支持的取值。多给一个选项，用户就会选到一个必然失败的组合。\n",[91,3428,3429],{"class":93,"line":206},[91,3430,1504],{"class":1371},[10,3432,3433],{},"同时把取值这件事收成单点：",[82,3435,3437],{"className":84,"code":3436,"language":86,"meta":87,"style":87},"\u002F**\n * 前端渲染面板、后端校验参数都走这一个函数——两边各写一份判断，\n * 迟早出现「界面给得出、服务端不认」的组合。\n *\u002F\nexport function resolveDynamicOptions(kind: \"videoResolution\" | \"videoDuration\", params): readonly { value, label }[]\n",[14,3438,3439,3443,3448,3453,3457],{"__ignoreMap":87},[91,3440,3441],{"class":93,"line":94},[91,3442,1459],{"class":1371},[91,3444,3445],{"class":93,"line":109},[91,3446,3447],{"class":1371}," * 前端渲染面板、后端校验参数都走这一个函数——两边各写一份判断，\n",[91,3449,3450],{"class":93,"line":125},[91,3451,3452],{"class":1371}," * 迟早出现「界面给得出、服务端不认」的组合。\n",[91,3454,3455],{"class":93,"line":131},[91,3456,1504],{"class":1371},[91,3458,3459,3461,3463,3466,3468,3471,3473,3476,3478,3481,3483,3486,3488,3490,3492],{"class":93,"line":138},[91,3460,2416],{"class":97},[91,3462,3193],{"class":97},[91,3464,3465],{"class":101}," resolveDynamicOptions",[91,3467,1548],{"class":105},[91,3469,3470],{"class":112},"kind",[91,3472,116],{"class":97},[91,3474,3475],{"class":226}," \"videoResolution\"",[91,3477,230],{"class":97},[91,3479,3480],{"class":226}," \"videoDuration\"",[91,3482,777],{"class":105},[91,3484,3485],{"class":112},"params",[91,3487,3222],{"class":105},[91,3489,116],{"class":97},[91,3491,2437],{"class":97},[91,3493,3494],{"class":105}," { value, label }[]\n",[10,3496,3497],{},"细粒度那一档也保留了两份清单，且是有意不同：",[82,3499,3501],{"className":84,"code":3500,"language":86,"meta":87,"style":87},"\u002F**\n * 注意它与下面的 MODEL_DURATION_OPTIONS 是两回事、且**故意不同**：\n * 后者是「按钮行」的精简清单（seedance 只列 7 档，避免一排按钮太长），\n * 而滑块是细粒度选择，应当覆盖服务端真正接受的全部档位——\n * 否则用户拖不到 7\u002F9\u002F11\u002F13\u002F14 秒，白白削掉能力。\n *\u002F\n",[14,3502,3503,3507,3512,3517,3522,3527],{"__ignoreMap":87},[91,3504,3505],{"class":93,"line":94},[91,3506,1459],{"class":1371},[91,3508,3509],{"class":93,"line":109},[91,3510,3511],{"class":1371}," * 注意它与下面的 MODEL_DURATION_OPTIONS 是两回事、且**故意不同**：\n",[91,3513,3514],{"class":93,"line":125},[91,3515,3516],{"class":1371}," * 后者是「按钮行」的精简清单（seedance 只列 7 档，避免一排按钮太长），\n",[91,3518,3519],{"class":93,"line":131},[91,3520,3521],{"class":1371}," * 而滑块是细粒度选择，应当覆盖服务端真正接受的全部档位——\n",[91,3523,3524],{"class":93,"line":138},[91,3525,3526],{"class":1371}," * 否则用户拖不到 7\u002F9\u002F11\u002F13\u002F14 秒，白白削掉能力。\n",[91,3528,3529],{"class":93,"line":147},[91,3530,1504],{"class":1371},[19,3532,3534],{"id":3533},"五假绿的七条用例","五、假绿的七条用例",[10,3536,3537],{},"修的过程中还撞到一个测试问题，值得单独记。",[10,3539,3540],{},"改动完成后跑测试，有一组用例报了这个警告：",[82,3542,3545],{"className":3543,"code":3544,"language":1345},[1343],"This might cause false positive tests\n",[14,3546,3544],{"__ignoreMap":87},[10,3548,3549,3550,3553],{},"追下去发现是真的假绿。供应商迁移之后，配置加载在某些条件下会抛错，而那个抛错发生在 ",[14,3551,3552],{},"try"," 之外，成了未处理的 rejection。用例本身并不感知异常，于是照样通过。",[10,3555,3556],{},"具体是 7 条。",[10,3558,3559,3560,3563],{},"修法是让配置加载把异常收敛成一个明确的错误类型（缺 key 重试没有意义，归为永久失败），并在两个相关测试文件的模块加载阶段注入测试用 key。注入位置也有讲究——放 ",[14,3561,3562],{},"beforeEach"," 会因为跨文件执行顺序失效。",[10,3565,3566,3567,3570],{},"这件事和主题有关：",[29,3568,3569],{},"同一份能力表抄三份会漂，同一份配置在三个地方加载也会。"," 假绿的七条用例，测的是「配置能加载」，而配置在测试环境里根本没被加载。",[19,3572,3574],{"id":3573},"六收口之后","六、收口之后",[10,3576,3577],{},"同一批里还顺手修了两处同源问题：",[508,3579,3580,3591],{},[302,3581,3582,3583,3586,3587,3590],{},"漫剧设置页那份模型列表是前端手写的副本，早已和后端漂开：列出「Seedance-2.0 Pro」和「可灵 V2」两个根本不存在的 ID，而真实的 ID 是 ",[14,3584,3585],{},"seedance-2-fast"," 和 ",[14,3588,3589],{},"kling-v3","。选中即被出片接口的 zod 拒掉。改成读取后端的模型清单接口。",[302,3592,3593,3594,334,3597,3600,3601,3604],{},"长篇项目的设置页没有把 ",[14,3595,3596],{},"videoModel",[14,3598,3599],{},"videoResolution"," 传给设置组件，",[14,3602,3603],{},"onSave"," 也没往上收。用户选完模型点保存，没有报错，刷新之后回退到原来的值。",[10,3606,3607,3608,503],{},"三处问题的形式不同，成因是同一个：",[29,3609,3610],{},"同一份事实存在多个副本，而且没有一处是权威",[10,3612,3613],{},"收口之后的结构是三份，每一份都有明确职责：",[1732,3615,3616,3629],{},[1735,3617,3618],{},[1738,3619,3620,3623,3626],{},[1741,3621,3622],{},"位置",[1741,3624,3625],{},"职责",[1741,3627,3628],{},"守卫",[1754,3630,3631,3642,3653],{},[1738,3632,3633,3636,3639],{},[1759,3634,3635],{},"后端能力表",[1759,3637,3638],{},"权威来源",[1759,3640,3641],{},"被镜像表逐项对比",[1738,3643,3644,3647,3650],{},[1759,3645,3646],{},"共享包镜像表",[1759,3648,3649],{},"跨端取值",[1759,3651,3652],{},"对比测试（改一边不改另一边即红）",[1738,3654,3655,3658,3661],{},[1759,3656,3657],{},"前端 UI 清单",[1759,3659,3660],{},"只影响展示形态",[1759,3662,3663],{},"值必须来自共享包，不许写字面量",[10,3665,3666,3667],{},"硬规矩只有一条，写在镜像表里：",[29,3668,3669],{},"只允许出现上游真支持的取值。多给一个选项，用户就会选到一个必然失败的组合。",[10,3671,3672],{},"反过来说，少给一个选项的代价同样实在——2.5 的 29 秒被砍到 15，用户看到的是「这个模型没比别的强」，看不到的是我们没把它的能力交出去。",[1272,3674,3675],{},"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":87,"searchDepth":109,"depth":109,"links":3677},[3678,3679,3680,3681,3682,3683],{"id":2667,"depth":109,"text":2668},{"id":2912,"depth":109,"text":2913},{"id":2974,"depth":109,"text":2975},{"id":3352,"depth":109,"text":3353},{"id":3533,"depth":109,"text":3534},{"id":3573,"depth":109,"text":3574},"2026-08-08",{},"\u002F2026-08-08",{"title":2653,"description":2658},"2026-08-08-同一张能力表抄了三份","上游模型的分辨率、时长、提示词长度散落在三处各自维护，各自漂移。一次用户反馈把三处一起翻出来：多出的档位、被砍掉的秒数、翻倍的板数。",[3691,3692,3693,3694,3695],"模型能力","配置漂移","契约","前端后端一致","提示词长度","xrC9lTC00ghKpLq5ZuDyYhLFe2fpcs3SgCXkd9QeigE",1789212573241]