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