[{"data":1,"prerenderedAt":42560},["ShallowReactive",2],{"proj-claude-codex":3},[4,215,369,1293,1981,2741,3425,4322,4950,5819,6445,6830,7178,8149,8703,9729,10632,10934,11252,11724,12127,12543,12687,12819,13117,13568,14253,15074,15359,15671,15987,16467,16802,17057,18300,18579,19469,19955,20304,20985,21649,22342,22489,22772,23218,23422,24094,24648,25197,26413,27053,27634,28287,28582,29015,29338,30386,31214,31603,31950,32248,33074,33631,33841,34391,34838,35110,35473,35739,38886,38997,39413,39635,40398,40518,40889,41185,41517,41884,42135,42303,42423],{"id":5,"title":6,"body":7,"column":196,"date":197,"description":198,"extension":199,"hero_image":200,"meta":201,"navigation":202,"path":203,"seo":204,"series_id":200,"severity":200,"stem":205,"summary":206,"tags":207,"__hash__":214},"posts\u002F2026-09-10-Ai项目群的工程化分水岭.md","AI 项目做多以后，真正难的是把边界接起来",{"type":8,"value":9,"toc":182},"minimark",[10,19,22,25,30,35,45,48,51,67,70,74,93,96,100,109,112,116,119,122,144,147,151,154,157,160,163,166,169,173,176,179],[11,12,13,14,18],"p",{},"这段时间回看 ",[15,16,17],"code",{},"\u002FUsers\u002Fxingye\u002FAi"," 下的项目，最明显的变化不是项目数量增加，而是问题的重心变了。",[11,20,21],{},"早期问题集中在“功能能不能跑起来”：语音推理、网页生成、桌面端启动、小程序页面、视频服务。进入平台化阶段以后，问题变成了另一组：任务是否可追踪，失败是否能恢复，费用是否能对账，版本是否能回滚，用户是否知道发生了什么。",[11,23,24],{},"这是一条从功能开发走向系统工程的分水岭。",[26,27,29],"h2",{"id":28},"一项目群已经形成几条清晰的产品线","一、项目群已经形成几条清晰的产品线",[31,32,34],"h3",{"id":33},"agent-与多租户平台","Agent 与多租户平台",[11,36,37,40,41,44],{},[15,38,39],{},"yun-claw"," 与 ",[15,42,43],{},"yun-claude"," 的记录显示，平台主线已经覆盖用户容器、模型接入、画布工作流、代理渠道、后台管理和运营工具。近期提交涉及画布自绘 Select、资产大图预览、代理激活码申请，以及渠道注册页的差异化展示。",[11,46,47],{},"这些功能看起来分属不同页面，底层却共享一套问题：用户身份、资源权限、异步任务和状态反馈必须保持一致。只改页面，不补状态契约，功能就会在跨页面操作时暴露断点。",[31,49,50],{"id":50},"内容生产流水线",[11,52,53,56,57,56,60,40,63,66],{},[15,54,55],{},"ai-write","、",[15,58,59],{},"instantlink-site",[15,61,62],{},"ai-auto-edit",[15,64,65],{},"ai-digital-human-workflow"," 分别覆盖写作、漫剧、自动剪辑和数字人视频。提交记录里反复出现几个关键词：任务网关、切片下载、共享卷、FFmpeg、提示词优化、失败提示和构建复现。",[11,68,69],{},"这说明内容生产已经不是单次调用模型，而是一条有中间产物的流水线。文本、图片、视频、音频和项目资产都有生命周期，任何一步的状态丢失都会让用户无法判断下一步该做什么。",[31,71,73],{"id":72},"交付网关与桌面端","交付、网关与桌面端",[11,75,76,56,79,56,82,56,85,88,89,92],{},[15,77,78],{},"newCodex",[15,80,81],{},"new-openclaw",[15,83,84],{},"openclaw",[15,86,87],{},"服务器管理"," 和 ",[15,90,91],{},"SSHTS"," 的记录，集中在更新、恢复、远程诊断、单实例生命周期和离线环境上。这里的核心指标不是“新版本发布了没有”，而是安装失败能否定位、更新中断能否恢复、客户机器能否留下可审计证据。",[11,94,95],{},"这也是为什么桌面端工程会逐渐靠近运维工程：安装器、后台服务、日志、配置和回滚必须一起设计。",[31,97,99],{"id":98},"网关计费与数据观察","网关、计费与数据观察",[11,101,102,40,105,108],{},[15,103,104],{},"NewAPI",[15,106,107],{},"new-api"," 的近期记录包括按模型统计 Token 消耗、扣费量、时间粒度聚合、昨日活跃用户和订单搜索。网关不只是转发请求，它还承担了模型选择、消耗记录、后台观察和问题追踪。",[11,110,111],{},"一旦计费数据进入用户体验，接口成功与业务成功就不再是同一个概念。请求返回 200，只能说明一次网络调用完成；扣费、产物落盘和任务终态是否一致，需要单独核对。",[26,113,115],{"id":114},"二git-记录里最有价值的变化是边界被写出来了","二、Git 记录里最有价值的变化，是“边界被写出来了”",[11,117,118],{},"多个项目的文档都在补同一类内容：部署手册、验收清单、测试报告、故障复盘、模型环境快照和用户协议。",[11,120,121],{},"这类文档的价值不在于把代码再讲一遍，而在于把系统边界写清楚：",[123,124,125,129,132,135,138,141],"ul",{},[126,127,128],"li",{},"输入是什么，哪些字段必须存在；",[126,130,131],{},"状态有哪些，什么条件才能进入下一状态；",[126,133,134],{},"外部服务超时后，如何判断请求是否已经执行；",[126,136,137],{},"产物写入失败时，是否允许重试；",[126,139,140],{},"更新中断后，哪个版本可以安全恢复；",[126,142,143],{},"用户看到的成功，是否和后台记录的成功一致。",[11,145,146],{},"当这些问题没有答案时，系统只能靠人工经验维持。一旦项目数量增加，经验就会变成不可复制的隐性依赖。",[26,148,150],{"id":149},"三下一阶段的重点不是再加一个入口","三、下一阶段的重点不是再加一个入口",[11,152,153],{},"从现有项目的提交和文档可以看到，下一阶段更值得投入的是四个基础能力。",[11,155,156],{},"第一，统一任务状态。不同项目都在处理异步任务，但状态命名、失败原因和重试策略不应各自发明。",[11,158,159],{},"第二，统一产物账本。一个任务产生了哪些文件、消耗了哪些资源、是否对用户可见，需要能从提交到终态完整追踪。",[11,161,162],{},"第三，统一故障出口。401、超时、部分成功、未知执行和版本回滚，应该有明确的用户反馈与后台证据。",[11,164,165],{},"第四，统一发布验收。构建通过不代表线上可用，应该把健康检查、代理路径、真实端口、关键日志和浏览器操作放到同一份验收清单里。",[11,167,168],{},"项目多以后，工程能力不再体现在“能写多少功能”，而体现在“能否让不同功能遵守同一套边界”。",[26,170,172],{"id":171},"四我会保留的判断","四、我会保留的判断",[11,174,175],{},"AI 项目最容易被低估的部分，不是模型调用，而是模型调用之后的系统。",[11,177,178],{},"生成只是起点。身份、任务、费用、存储、交付、恢复和反馈，决定了一个功能能不能进入真实使用。",[11,180,181],{},"当 Git 提交开始频繁出现“补状态”“补恢复”“补验收”“补审计”时，这不是工程变慢了，而是产品从演示阶段进入了可运营阶段。",{"title":183,"searchDepth":184,"depth":184,"links":185},"",2,[186,193,194,195],{"id":28,"depth":184,"text":29,"children":187},[188,190,191,192],{"id":33,"depth":189,"text":34},3,{"id":50,"depth":189,"text":50},{"id":72,"depth":189,"text":73},{"id":98,"depth":189,"text":99},{"id":114,"depth":184,"text":115},{"id":149,"depth":184,"text":150},{"id":171,"depth":184,"text":172},"工程手记","2026-09-10","这段时间回看 \u002FUsers\u002Fxingye\u002FAi 下的项目，最明显的变化不是项目数量增加，而是问题的重心变了。","md",null,{},true,"\u002F2026-09-10-ai",{"title":6,"description":198},"2026-09-10-Ai项目群的工程化分水岭","从多个 AI 项目的 Git 记录和设计文档看，工程难点已经从“能不能生成”转向任务、计费、交付、恢复与用户反馈能否形成闭环。",[208,209,210,211,212,213],"AI 平台","Agent","内容生产","网关","桌面端","工程化","DzOpOX92EsanPp8pLNTusbzBmKH3WOTf3qcaZAFmhM8",{"id":216,"title":217,"body":218,"column":355,"date":197,"description":222,"extension":199,"hero_image":200,"meta":356,"navigation":202,"path":357,"seo":358,"series_id":200,"severity":200,"stem":359,"summary":360,"tags":361,"__hash__":368},"posts\u002F2026-09-10-登录失效不能只显示空白页.md","登录失效不能只显示空白页",{"type":8,"value":219,"toc":348},[220,223,226,229,233,236,239,242,255,259,266,281,284,287,291,294,297,300,303,307,310,321,324,328,331,334,345],[11,221,222],{},"用户看到的是空白、卡住或一直加载，系统里却可能已经有一个明确的信号：接口返回了 401。",[11,224,225],{},"最近一次修复针对的就是这种错位。登录令牌过期后，前端仍只检查本地存储里有没有令牌字符串，不检查令牌是否还有效。页面因此保持“已登录”的外观，但余额停在同步中，会话列表没有内容，用户也收不到提示。",[11,227,228],{},"这不是一个单独页面的 Bug，而是登录状态判断与接口真实状态脱节。",[26,230,232],{"id":231},"现象页面还在业务已经失效","现象：页面还在，业务已经失效",[11,234,235],{},"问题出现时，用户仍然可以看到应用外壳。真正需要数据的请求陆续返回 401，页面组件各自处理失败结果，有的停在 loading，有的显示空列表，有的没有任何反馈。",[11,237,238],{},"这种状态比直接跳转登录页更难排查，因为页面没有崩溃，浏览器也没有明显报错。用户的描述通常是“突然不能用了”。",[11,240,241],{},"根因有两个：",[243,244,245,252],"ol",{},[126,246,247,248,251],{},"本地 ",[15,249,250],{},"yc_token"," 只作为“字符串是否存在”的标记；",[126,253,254],{},"应用内的请求分散在几十个文件，没有统一的 401 出口。",[26,256,258],{"id":257},"修复在请求边界识别失效","修复：在请求边界识别失效",[11,260,261,262,265],{},"这次修改没有要求逐个页面补判断，而是在应用入口包一层 ",[15,263,264],{},"window.fetch","，把判定条件收紧到三点：",[123,267,268,275,278],{},[126,269,270,271,274],{},"请求是同源 ",[15,272,273],{},"\u002Fapi\u002F"," 路径；",[126,276,277],{},"请求不在登录、注册和票据登录等合法返回 401 的白名单里；",[126,279,280],{},"本地确实存在登录令牌。",[11,282,283],{},"满足条件后，才把 401 解释为登录失效，并触发统一提示。外部签名 URL 不会被误判，流式响应也不读取 body，只观察 HTTP status，避免影响聊天和视频编导流程。",[11,285,286],{},"这个边界很重要。全局拦截器如果只看状态码，容易把登录接口本身的“凭据错误”当成“会话过期”；如果不区分同源请求，也可能误伤对象存储或其他外部服务。",[26,288,290],{"id":289},"交互先保住用户正在做的事","交互：先保住用户正在做的事",[11,292,293],{},"修复没有直接清空登录态，而是分两层提示。",[11,295,296],{},"第一层是弹窗，告诉用户会话已失效，并提供重新登录入口。用户可以选择稍后处理，先复制还没有保存的内容。",[11,298,299],{},"第二层是顶部常驻提示条。用户关掉弹窗后，页面仍保留可见的恢复入口，避免回到“没有提示的死状态”。",[11,301,302],{},"多标签页也是一个边界：如果另一个标签页已经完成续期，当前页面重新检查后应接管新的有效令牌，而不是把它删掉。否则用户在一个标签页登录，另一个标签页却把新令牌清除，问题会从单页失效变成跨标签页互相干扰。",[26,304,306],{"id":305},"测试验证状态转换而不是只测组件渲染","测试：验证状态转换，而不是只测组件渲染",[11,308,309],{},"这次变更补了三类测试：",[123,311,312,315,318],{},[126,313,314],{},"失效令牌触发弹窗和常驻提示；",[126,316,317],{},"登录、注册等白名单接口的 401 不触发全局失效；",[126,319,320],{},"多标签页已有新令牌时，当前页面能复用有效状态。",[11,322,323],{},"测试重点不是“按钮是否出现”，而是确认请求状态、登录状态和用户反馈之间的转换。只有把这三者放在一起验证，才能避免页面看似正常、用户却无法继续工作的情况。",[26,325,327],{"id":326},"结论鉴权错误也是产品状态","结论：鉴权错误也是产品状态",[11,329,330],{},"401 不是只留给开发者看的网络状态。对用户来说，它意味着“当前工作无法继续”，必须有明确的恢复路径。",[11,332,333],{},"前端鉴权设计至少要回答三个问题：",[243,335,336,339,342],{},[126,337,338],{},"哪些 401 表示凭据错误，哪些表示会话过期；",[126,340,341],{},"失效时用户正在编辑的内容如何保留；",[126,343,344],{},"多标签页和重试发生时，哪个令牌拥有更高的有效性。",[11,346,347],{},"把这三个问题写进代码和测试，登录失效就不再是一张空白页，而是一个可理解、可恢复的产品状态。",{"title":183,"searchDepth":184,"depth":184,"links":349},[350,351,352,353,354],{"id":231,"depth":184,"text":232},{"id":257,"depth":184,"text":258},{"id":289,"depth":184,"text":290},{"id":305,"depth":184,"text":306},{"id":326,"depth":184,"text":327},"故障档案",{},"\u002F2026-09-10",{"title":217,"description":222},"2026-09-10-登录失效不能只显示空白页","一次登录令牌过期问题的修复：从分散的接口错误，收口到同源 API 的统一失效提示，同时保留未保存内容和多标签页续期场景。",[362,363,364,365,366,367],"前端","鉴权",401,"fetch","用户体验","测试","mDBl0HrrKMlN2mL_SZGdBwS6YpvL96QS8WWVfThP2c4",{"id":370,"title":371,"body":372,"column":355,"date":1278,"description":376,"extension":199,"hero_image":200,"meta":1279,"navigation":202,"path":1280,"seo":1281,"series_id":1282,"severity":200,"stem":1283,"summary":1284,"tags":1285,"__hash__":1292},"posts\u002F2026-08-31-FA-024-同一批活儿扣了两遍钱.md","同一批活儿，扣了两遍钱",{"type":8,"value":373,"toc":1270},[374,377,388,391,395,398,484,492,499,502,505,564,571,574,668,675,679,682,689,767,782,789,792,795,815,818,822,825,828,831,834,849,856,860,863,866,927,938,941,1015,1018,1084,1087,1094,1097,1125,1128,1132,1135,1138,1176,1187,1190,1201,1204,1210,1214,1217,1232,1235,1238,1245,1259,1266],[11,375,376],{},"用户点「批量生视频」，10 张已经出好的分镜图被整套重跑，再扣一遍钱。",[11,378,379,380,383,384,387],{},"生产数据核实过：两次运行的 ",[15,381,382],{},"image.generate"," 都是 ",[15,385,386],{},"charged","。不是显示问题，是真的扣了两次。",[11,389,390],{},"修的过程里翻出两处独立成因，以及一条让它们同时隐身的原因。",[26,392,394],{"id":393},"一祖先闭包不该当目标传","一、祖先闭包不该当目标传",[11,396,397],{},"画布上有一个「运行整组」的入口。前端传目标节点给后端：",[399,400,404],"pre",{"className":401,"code":402,"language":403,"meta":183,"style":183},"language-tsx shiki shiki-themes github-light github-dark","onRunGroup={\n  onRunTargets && runState && !['estimating', 'submitted', 'running'].includes(runState)\n    ? (groupId) => {\n        const targetNodes = ancestorClosure(group.nodeIds, flow.edges);\n        onRunTargets(targetNodes);\n      }\n    : undefined\n}\n","tsx",[15,405,406,422,445,450,456,462,468,478],{"__ignoreMap":183},[407,408,411,415,419],"span",{"class":409,"line":410},"line",1,[407,412,414],{"class":413},"sVt8B","onRunGroup",[407,416,418],{"class":417},"szBVR","=",[407,420,421],{"class":413},"{\n",[407,423,424,427,431,434,437,439,442],{"class":409,"line":184},[407,425,426],{"class":413},"  onRunTargets && runState && ![",[407,428,430],{"class":429},"sZZnC","'estimating'",[407,432,433],{"class":413},", ",[407,435,436],{"class":429},"'submitted'",[407,438,433],{"class":413},[407,440,441],{"class":429},"'running'",[407,443,444],{"class":413},"].includes(runState)\n",[407,446,447],{"class":409,"line":189},[407,448,449],{"class":413},"    ? (groupId) => {\n",[407,451,453],{"class":409,"line":452},4,[407,454,455],{"class":413},"        const targetNodes = ancestorClosure(group.nodeIds, flow.edges);\n",[407,457,459],{"class":409,"line":458},5,[407,460,461],{"class":413},"        onRunTargets(targetNodes);\n",[407,463,465],{"class":409,"line":464},6,[407,466,467],{"class":413},"      }\n",[407,469,471,474],{"class":409,"line":470},7,[407,472,473],{"class":413},"    : ",[407,475,477],{"class":476},"sj4cs","undefined\n",[407,479,481],{"class":409,"line":480},8,[407,482,483],{"class":413},"}\n",[11,485,486,487,491],{},"它把组内节点连同",[488,489,490],"strong",{},"祖先闭包","一起传了过去。",[11,493,494,495,498],{},"后端的复用逻辑是这样的：",[15,496,497],{},"reuseCandidates = runSet − targetSet","。已经在目标集合里的节点不会被复用，要重新执行。祖先一旦也算目标，复用候选就成了空集——本来该被复用的上游，全部重跑。",[11,500,501],{},"而那 10 张分镜图正是祖先。",[11,503,504],{},"修法是把这一步交回给后端：",[399,506,508],{"className":401,"code":507,"language":403,"meta":183,"style":183},"? () => {\n    \u002F\u002F 只传组内节点当目标，**绝不能把祖先闭包当目标传**：\n    \u002F\u002F 后端 reuseCandidates = runSet − targetSet，祖先若也算目标，\n    \u002F\u002F 复用候选就成了空集，已出图的上游会被整套重跑并再次扣费\n    \u002F\u002F （线上真实事故：点「批量生视频」把 10 张分镜图重扣了一遍）。\n    \u002F\u002F 祖先闭包由后端 pruneFlowForTargets 自己算。\n    onRunTargets(group.nodeIds);\n  }\n",[15,509,510,524,530,535,540,545,550,559],{"__ignoreMap":183},[407,511,512,515,518,521],{"class":409,"line":410},[407,513,514],{"class":417},"?",[407,516,517],{"class":413}," () ",[407,519,520],{"class":417},"=>",[407,522,523],{"class":413}," {\n",[407,525,526],{"class":409,"line":184},[407,527,529],{"class":528},"sJ8bj","    \u002F\u002F 只传组内节点当目标，**绝不能把祖先闭包当目标传**：\n",[407,531,532],{"class":409,"line":189},[407,533,534],{"class":528},"    \u002F\u002F 后端 reuseCandidates = runSet − targetSet，祖先若也算目标，\n",[407,536,537],{"class":409,"line":452},[407,538,539],{"class":528},"    \u002F\u002F 复用候选就成了空集，已出图的上游会被整套重跑并再次扣费\n",[407,541,542],{"class":409,"line":458},[407,543,544],{"class":528},"    \u002F\u002F （线上真实事故：点「批量生视频」把 10 张分镜图重扣了一遍）。\n",[407,546,547],{"class":409,"line":464},[407,548,549],{"class":528},"    \u002F\u002F 祖先闭包由后端 pruneFlowForTargets 自己算。\n",[407,551,552,556],{"class":409,"line":470},[407,553,555],{"class":554},"sScJk","    onRunTargets",[407,557,558],{"class":413},"(group.nodeIds);\n",[407,560,561],{"class":409,"line":480},[407,562,563],{"class":413},"  }\n",[11,565,566,567,570],{},"祖先闭包本来就由后端的 ",[15,568,569],{},"pruneFlowForTargets"," 算。前端多算一遍，算的不只是重复劳动，而是一个语义不同的集合：后端要的是「用户想跑的」，前端给的是「跑这个需要的」。",[11,572,573],{},"补的守卫测试很直白——组件层的回调只能接收一个组 ID：",[399,575,577],{"className":401,"code":576,"language":403,"meta":183,"style":183},"it(\"运行整组时只把组内节点当目标，不能传祖先闭包（线上重复扣费事故）\", () => {\n  fireEvent.click(screen.getByRole(\"button\", { name: \"整组执行\" }));\n  expect(onRunGroup).toHaveBeenCalledWith(mockGroup.id);\n  expect(onRunGroup.mock.calls[0]).toHaveLength(1);\n});\n",[15,578,579,597,625,639,663],{"__ignoreMap":183},[407,580,581,584,587,590,593,595],{"class":409,"line":410},[407,582,583],{"class":554},"it",[407,585,586],{"class":413},"(",[407,588,589],{"class":429},"\"运行整组时只把组内节点当目标，不能传祖先闭包（线上重复扣费事故）\"",[407,591,592],{"class":413},", () ",[407,594,520],{"class":417},[407,596,523],{"class":413},[407,598,599,602,605,608,611,613,616,619,622],{"class":409,"line":184},[407,600,601],{"class":413},"  fireEvent.",[407,603,604],{"class":554},"click",[407,606,607],{"class":413},"(screen.",[407,609,610],{"class":554},"getByRole",[407,612,586],{"class":413},[407,614,615],{"class":429},"\"button\"",[407,617,618],{"class":413},", { name: ",[407,620,621],{"class":429},"\"整组执行\"",[407,623,624],{"class":413}," }));\n",[407,626,627,630,633,636],{"class":409,"line":189},[407,628,629],{"class":554},"  expect",[407,631,632],{"class":413},"(onRunGroup).",[407,634,635],{"class":554},"toHaveBeenCalledWith",[407,637,638],{"class":413},"(mockGroup.id);\n",[407,640,641,643,646,649,652,655,657,660],{"class":409,"line":452},[407,642,629],{"class":554},[407,644,645],{"class":413},"(onRunGroup.mock.calls[",[407,647,648],{"class":476},"0",[407,650,651],{"class":413},"]).",[407,653,654],{"class":554},"toHaveLength",[407,656,586],{"class":413},[407,658,659],{"class":476},"1",[407,661,662],{"class":413},");\n",[407,664,665],{"class":409,"line":458},[407,666,667],{"class":413},"});\n",[11,669,670,671,674],{},"同一处 ",[15,672,673],{},"ancestorClosure"," 在运行面板里保留着，那里是用它显示「运行 N 个（复用 M 个）」的，不参与提交。",[26,676,678],{"id":677},"二落库的行对不上","二、落库的行对不上",[11,680,681],{},"第二处成因更早，也更深。",[11,683,684,685,688],{},"画布运行会把每个节点展开成若干行写入 ",[15,686,687],{},"CanvasFlowNodeRun","。批量框要求一个节点跑 N 份，所以展开后的行需要一个复合标识：",[399,690,694],{"className":691,"code":692,"language":693,"meta":183,"style":183},"language-ts shiki shiki-themes github-light github-dark","const nodeRuns = expandedNodes.map((node) => ({\n  id: generateNodeRunId(),\n  runId,\n  nodeId: node.nodeId,   \u002F\u002F 展开后的复合 id\n  batchIndex: node.batchIndex,\n  \u002F\u002F ...\n}))\n","ts",[15,695,696,728,739,744,752,757,762],{"__ignoreMap":183},[407,697,698,701,704,707,710,713,716,720,723,725],{"class":409,"line":410},[407,699,700],{"class":417},"const",[407,702,703],{"class":476}," nodeRuns",[407,705,706],{"class":417}," =",[407,708,709],{"class":413}," expandedNodes.",[407,711,712],{"class":554},"map",[407,714,715],{"class":413},"((",[407,717,719],{"class":718},"s4XuR","node",[407,721,722],{"class":413},") ",[407,724,520],{"class":417},[407,726,727],{"class":413}," ({\n",[407,729,730,733,736],{"class":409,"line":184},[407,731,732],{"class":413},"  id: ",[407,734,735],{"class":554},"generateNodeRunId",[407,737,738],{"class":413},"(),\n",[407,740,741],{"class":409,"line":189},[407,742,743],{"class":413},"  runId,\n",[407,745,746,749],{"class":409,"line":452},[407,747,748],{"class":413},"  nodeId: node.nodeId,   ",[407,750,751],{"class":528},"\u002F\u002F 展开后的复合 id\n",[407,753,754],{"class":409,"line":458},[407,755,756],{"class":413},"  batchIndex: node.batchIndex,\n",[407,758,759],{"class":409,"line":464},[407,760,761],{"class":528},"  \u002F\u002F ...\n",[407,763,764],{"class":409,"line":470},[407,765,766],{"class":413},"}))\n",[11,768,769,770,773,774,777,778,781],{},"问题在这张表的唯一约束是 ",[15,771,772],{},"@@unique([runId, nodeId, batchIndex])","，而 ",[15,775,776],{},"nodeId"," 落的是",[488,779,780],{},"展开后的复合 id","。",[11,783,784,785,788],{},"于是 executor 那一侧全线对不上。它按 ",[15,786,787],{},"nodeId + batchIndex"," 反查行、统计每份的项数、构建调度状态——落库的键和它查的键不是一套。",[11,790,791],{},"调度器找不到已派发的记录，于是重复派发同一个批量单元。周期的量级是 100 毫秒。",[11,793,794],{},"修法是把复合 id 留在内存里，落库只落原始 ID：",[399,796,798],{"className":691,"code":797,"language":693,"meta":183,"style":183},"\u002F\u002F 行必须以「原始 nodeId + batchIndex」落库（对齐 @@unique([runId, nodeId, batchIndex])）。\n\u002F\u002F 展开后的复合 id 只活在调度器内存里：落了复合 id，executor 的\n\u002F\u002F itemCountsFromRows\u002FbuildSchedulerState 就全都对不上行。\n",[15,799,800,805,810],{"__ignoreMap":183},[407,801,802],{"class":409,"line":410},[407,803,804],{"class":528},"\u002F\u002F 行必须以「原始 nodeId + batchIndex」落库（对齐 @@unique([runId, nodeId, batchIndex])）。\n",[407,806,807],{"class":409,"line":184},[407,808,809],{"class":528},"\u002F\u002F 展开后的复合 id 只活在调度器内存里：落了复合 id，executor 的\n",[407,811,812],{"class":409,"line":189},[407,813,814],{"class":528},"\u002F\u002F itemCountsFromRows\u002FbuildSchedulerState 就全都对不上行。\n",[11,816,817],{},"配套改了 executor 三处状态写库的定位方式，并在 ready 循环里加了一层 in-flight 防重派兜底，防止同类问题再犯。",[26,819,821],{"id":820},"三为什么单测全绿","三、为什么单测全绿",[11,823,824],{},"这一处的成因能藏住，是因为测试的写法。",[11,826,827],{},"批量链路的每个环节都有自己的单测：创建函数有自己的用例，executor 也有。两组用例各自手写 fixture——创建函数的用例断言它写出了正确的行，executor 的用例喂给它一组正确的行，断言它正确调度。两组都绿。",[11,829,830],{},"错的正是「创建函数写出的行」和「executor 期望的行」之间的那个接口。",[11,832,833],{},"修的时候补了一个贯通测试环境，理由写在文件头：",[399,835,837],{"className":691,"code":836,"language":693,"meta":183,"style":183},"存在的意义是让「写读贯通」测试成为可能——行由真实的创建函数产生、由真实的 executor 消费，\n中间不允许手写 fixture（批量行约定矛盾就是靠各自手写 fixture 的单测互相全绿才漏网的）。\n",[15,838,839,844],{"__ignoreMap":183},[407,840,841],{"class":409,"line":410},[407,842,843],{"class":413},"存在的意义是让「写读贯通」测试成为可能——行由真实的创建函数产生、由真实的 executor 消费，\n",[407,845,846],{"class":409,"line":184},[407,847,848],{"class":413},"中间不允许手写 fixture（批量行约定矛盾就是靠各自手写 fixture 的单测互相全绿才漏网的）。\n",[11,850,851,852,855],{},"新的契约测试从创建一路跑到执行，中间不插桩。断言的是端到端的账：",[15,853,854],{},"itemCount=3"," 时，成员 3 行、每份恰好执行一次。",[26,857,859],{"id":858},"四项数从哪来","四、项数从哪来",[11,861,862],{},"修完上面两处，还有一个数对不上：预估和实扣。",[11,864,865],{},"批量框跑几份，原先是从上游推断的：",[399,867,869],{"className":691,"code":868,"language":693,"meta":183,"style":183},"if (upstreamNode.nodeDefId === 'material.input') {\n  const count = (upstreamNode.params as any)?.count ?? 1;\n  \u002F\u002F ...\n}\n",[15,870,871,888,919,923],{"__ignoreMap":183},[407,872,873,876,879,882,885],{"class":409,"line":410},[407,874,875],{"class":417},"if",[407,877,878],{"class":413}," (upstreamNode.nodeDefId ",[407,880,881],{"class":417},"===",[407,883,884],{"class":429}," 'material.input'",[407,886,887],{"class":413},") {\n",[407,889,890,893,896,898,901,904,907,910,913,916],{"class":409,"line":184},[407,891,892],{"class":417},"  const",[407,894,895],{"class":476}," count",[407,897,706],{"class":417},[407,899,900],{"class":413}," (upstreamNode.params ",[407,902,903],{"class":417},"as",[407,905,906],{"class":476}," any",[407,908,909],{"class":413},")?.count ",[407,911,912],{"class":417},"??",[407,914,915],{"class":476}," 1",[407,917,918],{"class":413},";\n",[407,920,921],{"class":409,"line":189},[407,922,761],{"class":528},[407,924,925],{"class":409,"line":452},[407,926,483],{"class":413},[11,928,929,930,933,934,937],{},"注册表里从来没有 ",[15,931,932],{},"material.input"," 这个节点——真名是 ",[15,935,936],{},"asset.input","。这个分支从未命中过，项数永远回落 1。",[11,939,940],{},"改成框上显式配置：",[399,942,944],{"className":691,"code":943,"language":693,"meta":183,"style":183},"\u002F**\n * 框内子图跑几份（1~50，缺省 1）。配在框上、由用户手动设置——\n * 「按上游 list 项数自动展开」的推断从未走通过（框架节点没注册），\n * 手动份数是当前唯一的项数来源。\n *\u002F\nitemCount: z.number().int().min(1).max(50).optional(),\n",[15,945,946,951,956,961,966,971],{"__ignoreMap":183},[407,947,948],{"class":409,"line":410},[407,949,950],{"class":528},"\u002F**\n",[407,952,953],{"class":409,"line":184},[407,954,955],{"class":528}," * 框内子图跑几份（1~50，缺省 1）。配在框上、由用户手动设置——\n",[407,957,958],{"class":409,"line":189},[407,959,960],{"class":528}," * 「按上游 list 项数自动展开」的推断从未走通过（框架节点没注册），\n",[407,962,963],{"class":409,"line":452},[407,964,965],{"class":528}," * 手动份数是当前唯一的项数来源。\n",[407,967,968],{"class":409,"line":458},[407,969,970],{"class":528}," *\u002F\n",[407,972,973,976,979,982,985,988,990,993,995,997,1000,1003,1005,1008,1010,1013],{"class":409,"line":464},[407,974,975],{"class":554},"itemCount",[407,977,978],{"class":413},": z.",[407,980,981],{"class":554},"number",[407,983,984],{"class":413},"().",[407,986,987],{"class":554},"int",[407,989,984],{"class":413},[407,991,992],{"class":554},"min",[407,994,586],{"class":413},[407,996,659],{"class":476},[407,998,999],{"class":413},").",[407,1001,1002],{"class":554},"max",[407,1004,586],{"class":413},[407,1006,1007],{"class":476},"50",[407,1009,999],{"class":413},[407,1011,1012],{"class":554},"optional",[407,1014,738],{"class":413},[11,1016,1017],{},"同时把预估也切到同一个来源：",[399,1019,1021],{"className":691,"code":1020,"language":693,"meta":183,"style":183},"\u002F**\n * 批量倍数：与执行侧同源——run-create 落行的份数就是框上的 itemCount，\n * 预估必须用同一个数，否则「预计 1 份、实扣 3 份」。\n *\u002F\nexport function batchMultipliersFromFlow(flow: CanvasFlow): Record\u003Cstring, number> {\n",[15,1022,1023,1027,1032,1037,1041],{"__ignoreMap":183},[407,1024,1025],{"class":409,"line":410},[407,1026,950],{"class":528},[407,1028,1029],{"class":409,"line":184},[407,1030,1031],{"class":528}," * 批量倍数：与执行侧同源——run-create 落行的份数就是框上的 itemCount，\n",[407,1033,1034],{"class":409,"line":189},[407,1035,1036],{"class":528}," * 预估必须用同一个数，否则「预计 1 份、实扣 3 份」。\n",[407,1038,1039],{"class":409,"line":452},[407,1040,970],{"class":528},[407,1042,1043,1046,1049,1052,1054,1057,1060,1063,1066,1068,1071,1074,1077,1079,1081],{"class":409,"line":458},[407,1044,1045],{"class":417},"export",[407,1047,1048],{"class":417}," function",[407,1050,1051],{"class":554}," batchMultipliersFromFlow",[407,1053,586],{"class":413},[407,1055,1056],{"class":718},"flow",[407,1058,1059],{"class":417},":",[407,1061,1062],{"class":554}," CanvasFlow",[407,1064,1065],{"class":413},")",[407,1067,1059],{"class":417},[407,1069,1070],{"class":554}," Record",[407,1072,1073],{"class":413},"\u003C",[407,1075,1076],{"class":476},"string",[407,1078,433],{"class":413},[407,1080,981],{"class":476},[407,1082,1083],{"class":413},"> {\n",[11,1085,1086],{},"创建、预估、重试三个路由统一用这个函数。",[11,1088,1089,1090,1093],{},"同一轮里还修了预估的一个老问题：视频节点的预估用的是后台的一口价资源键 ",[15,1091,1092],{},"canvas_video_generate","，而执行侧用的是「模型 + 分辨率 + 是否带上游视频」算出的键，按秒计价。两套键，两套价——预估和实扣自然对不上。预估改用执行同款键。",[11,1095,1096],{},"以及一处显眼的占位：",[399,1098,1100],{"className":691,"code":1099,"language":693,"meta":183,"style":183},"\u002F\u002F 改前\nconst totalEstimatedCost = 0; \u002F\u002F TODO: Pass in actual estimate\n",[15,1101,1102,1107],{"__ignoreMap":183},[407,1103,1104],{"class":409,"line":410},[407,1105,1106],{"class":528},"\u002F\u002F 改前\n",[407,1108,1109,1111,1114,1116,1119,1122],{"class":409,"line":184},[407,1110,700],{"class":417},[407,1112,1113],{"class":476}," totalEstimatedCost",[407,1115,706],{"class":417},[407,1117,1118],{"class":476}," 0",[407,1120,1121],{"class":413},"; ",[407,1123,1124],{"class":528},"\u002F\u002F TODO: Pass in actual estimate\n",[11,1126,1127],{},"运行记录里的预估成本一直是 0。",[26,1129,1131],{"id":1130},"五续跑为什么不重复扣费","五、续跑为什么不重复扣费",[11,1133,1134],{},"同一轮加了单节点重试。既然重复扣费是这个月的主线，这条功能的实现方式值得记下来。",[11,1136,1137],{},"失败运行的续跑不是「重新跑一遍」：",[399,1139,1141],{"className":691,"code":1140,"language":693,"meta":183,"style":183},"\u002F**\n * 单节点重试：给失败\u002F被取消的运行造一个「续跑」运行。成功节点的行原样回填\n * （产物、billingRef、时间戳都保留）——executor 的调度器见到 succeeded 行\n * 会直接当作上游已就绪，不会重新执行，也就不会重复扣费；其余节点\n * （failed \u002F cancelled \u002F pending）重置成全新的 pending 行，正常调度重跑。\n * 不修改原运行：重试是一条新的 CanvasFlowRun，历史记录保持完整。\n *\u002F\n",[15,1142,1143,1147,1152,1157,1162,1167,1172],{"__ignoreMap":183},[407,1144,1145],{"class":409,"line":410},[407,1146,950],{"class":528},[407,1148,1149],{"class":409,"line":184},[407,1150,1151],{"class":528}," * 单节点重试：给失败\u002F被取消的运行造一个「续跑」运行。成功节点的行原样回填\n",[407,1153,1154],{"class":409,"line":189},[407,1155,1156],{"class":528}," * （产物、billingRef、时间戳都保留）——executor 的调度器见到 succeeded 行\n",[407,1158,1159],{"class":409,"line":452},[407,1160,1161],{"class":528}," * 会直接当作上游已就绪，不会重新执行，也就不会重复扣费；其余节点\n",[407,1163,1164],{"class":409,"line":458},[407,1165,1166],{"class":528}," * （failed \u002F cancelled \u002F pending）重置成全新的 pending 行，正常调度重跑。\n",[407,1168,1169],{"class":409,"line":464},[407,1170,1171],{"class":528}," * 不修改原运行：重试是一条新的 CanvasFlowRun，历史记录保持完整。\n",[407,1173,1174],{"class":409,"line":470},[407,1175,970],{"class":528},[11,1177,1178,1179,1182,1183,1186],{},"关键在于复用判定落在",[488,1180,1181],{},"行状态","上，而不是「这次运行是新是旧」。所以续跑不需要额外的豁免逻辑：被判成功的节点带上原来的 ",[15,1184,1185],{},"billingRef","，调度器看到它就不再执行。",[11,1188,1189],{},"预估也跟着只算子集，余额检查同理。",[11,1191,1192,1193,1196,1197,1200],{},"同一次改动里还补了一个幂等入口——按 ",[15,1194,1195],{},"userId + clientRequestId"," 查重，重复提交返回已有的运行 ID。归属校验用带 ",[15,1198,1199],{},"userId"," 的查询。",[11,1202,1203],{},"界面文案也改了，从「重试」改成「重试失败节点」，带一句说明：",[1205,1206,1207],"blockquote",{},[11,1208,1209],{},"成功节点的产物直接沿用，只有失败的节点会重新执行并计费。",[26,1211,1213],{"id":1212},"六重复扣费这道题","六、重复扣费这道题",[11,1215,1216],{},"两处成因的形式完全不同：",[123,1218,1219,1226],{},[126,1220,1221,1222,1225],{},"一处是",[488,1223,1224],{},"前端算了一个它不该算的集合","。祖先闭包的计算本身没错，错在把它当成了「目标」。",[126,1227,1221,1228,1231],{},[488,1229,1230],{},"落库的键和查库的键不是一套","。两边各自都有定义，各自都有测试，只是没人对着比过一次。",[11,1233,1234],{},"两条链路的共同点是：扣费发生的时刻离「用户点了什么」很远。用户在界面上点的是一个按钮，扣费发生在调度器认为某个节点未被执行的那一刻。中间隔着目标集合的计算、行的展开、行的落库、调度状态的重建。",[11,1236,1237],{},"链路上每一段都「合理」，叠起来就是收两次钱。",[11,1239,1240,1241,1244],{},"防守这类问题的办法不是加校验，是",[488,1242,1243],{},"把口径收成一份","：",[123,1246,1247,1250,1253,1256],{},[126,1248,1249],{},"目标集合由后端算，前端只传用户选了什么。",[126,1251,1252],{},"批量份数由框上配置，预估与执行读同一个函数。",[126,1254,1255],{},"复用与否由行状态决定，续跑沿用这套判定，不加例外。",[126,1257,1258],{},"贯通测试不允许手写 fixture——两侧各自造数据，就只能测出两侧各自的正确性。",[11,1260,1261,1262,1265],{},"最后那条是这次最实在的收获。一组测试全绿但系统是错的，通常不是因为用例写得不好，而是因为",[488,1263,1264],{},"用例的输入各自构造","。第三方造的数据一定和另一方一致，因为它们都来自同一个人的同一份理解；真实的接口不一致，恰恰是因为两边由不同的人、在不同的时间实现。",[1267,1268,1269],"style",{},"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 .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}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 .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":183,"searchDepth":184,"depth":184,"links":1271},[1272,1273,1274,1275,1276,1277],{"id":393,"depth":184,"text":394},{"id":677,"depth":184,"text":678},{"id":820,"depth":184,"text":821},{"id":858,"depth":184,"text":859},{"id":1130,"depth":184,"text":1131},{"id":1212,"depth":184,"text":1213},"2026-08-31",{},"\u002F2026-08-31-fa-024",{"title":371,"description":376},"FA-024","2026-08-31-FA-024-同一批活儿扣了两遍钱","点「批量生视频」把 10 张已经出好的分镜图整套重跑并再次扣费。两处独立成因：前端把祖先闭包当目标传给后端，后端落库的行 ID 与唯一约束不一致导致重复派发。",[1286,1287,1288,1289,1290,1291],"画布","批量执行","幂等","重复扣费","契约测试","计费","ajZtLrM5ge42AeND6nbuv6CH5k69juZtT88VhFe_hNc",{"id":1294,"title":1295,"body":1296,"column":1966,"date":1278,"description":1300,"extension":199,"hero_image":200,"meta":1967,"navigation":202,"path":1968,"seo":1969,"series_id":200,"severity":200,"stem":1970,"summary":1971,"tags":1972,"__hash__":1980},"posts\u002F2026-08-31-阈值不能推只能量.md","阈值不能推，只能量",{"type":8,"value":1297,"toc":1957},[1298,1301,1304,1308,1311,1316,1319,1322,1330,1333,1336,1341,1344,1353,1378,1384,1387,1391,1402,1405,1411,1414,1417,1420,1425,1428,1431,1486,1489,1492,1547,1550,1555,1559,1562,1568,1571,1574,1578,1581,1587,1590,1596,1599,1602,1608,1614,1617,1620,1626,1629,1672,1675,1679,1682,1685,1691,1694,1697,1701,1707,1775,1778,1867,1870,1873,1921,1924,1928,1931,1941,1944,1951,1954],[11,1299,1300],{},"知识库的检索参数有三组：分块阈值、召回阈值、精排阈值。这一天把三组都动了一遍，每一组都留下了实测数据。",[11,1302,1303],{},"起因是一个看不太出来的现象：知识库好像没被用上。",[26,1305,1307],{"id":1306},"一分块中位数-212-字","一、分块：中位数 212 字",[11,1309,1310],{},"先量现状。生产库里的分块统计：",[1205,1312,1313],{},[11,1314,1315],{},"13244 个分块中位数只有 212 字，而目标 2400 字，62% 的块不足 300 字。",[11,1317,1318],{},"目标块大小是 2400 字，实际交付的是 212。差了十倍。",[11,1320,1321],{},"分块器原来的逻辑是「一个标题一个块」。这在正常文档上没问题，但清单型、模板型文档里，几乎每一行列表项都会被标题识别逻辑认成标题：",[399,1323,1328],{"className":1324,"code":1326,"language":1327},[1325],"language-text","1. 原本想达成什么？\n","text",[15,1329,1326],{"__ignoreMap":183},[11,1331,1332],{},"这一行被当成标题，于是自己成了一个块。一篇文档被切成几十个几十字的碎片，注入给模型的全是碎片。",[11,1334,1335],{},"更严重的情况在连续列表项之间没有正文时。老实现让标题行只活在「面包屑」元数据里，不写进块正文。于是被误判成标题的那一行文字直接消失：",[1205,1337,1338],{},[11,1339,1340],{},"标题行只活在面包屑里，整行文字直接丢失（那类文档的四个核心问题在索引里根本不存在）。",[11,1342,1343],{},"两处改动：",[399,1345,1347],{"className":691,"code":1346,"language":693,"meta":183,"style":183},"\u002F\u002F 攒够了才在这里切：标题是「首选切点」，不是「强制切点」。\n",[15,1348,1349],{"__ignoreMap":183},[407,1350,1351],{"class":409,"line":410},[407,1352,1346],{"class":528},[399,1354,1356],{"className":691,"code":1355,"language":693,"meta":183,"style":183},"\u002F\u002F 不变量：每一行输入都要落进某个块的正文，面包屑只是附加元数据。\n\u002F\u002F 老实现让标题行只活在面包屑里，于是被 detectHeading 误判成标题的列表项\n\u002F\u002F （「1. 原本想达成什么？」这类）整行文字就没了——连续几个列表项时只留得住最后一条。\n\u002F\u002F 与面包屑重复一次可以接受，丢字不行。\n",[15,1357,1358,1363,1368,1373],{"__ignoreMap":183},[407,1359,1360],{"class":409,"line":410},[407,1361,1362],{"class":528},"\u002F\u002F 不变量：每一行输入都要落进某个块的正文，面包屑只是附加元数据。\n",[407,1364,1365],{"class":409,"line":184},[407,1366,1367],{"class":528},"\u002F\u002F 老实现让标题行只活在面包屑里，于是被 detectHeading 误判成标题的列表项\n",[407,1369,1370],{"class":409,"line":189},[407,1371,1372],{"class":528},"\u002F\u002F （「1. 原本想达成什么？」这类）整行文字就没了——连续几个列表项时只留得住最后一条。\n",[407,1374,1375],{"class":409,"line":452},[407,1376,1377],{"class":528},"\u002F\u002F 与面包屑重复一次可以接受，丢字不行。\n",[11,1379,1380,1381,781],{},"标题从「强制切点」降为「首选切点」：缓冲区不足阈值时，标题并入当前块，不切。同时标题行本身写进正文，成为一条不变量——",[488,1382,1383],{},"每一行输入都要落进某个块的正文",[11,1385,1386],{},"实测效果：一篇文档从 3 块 52\u002F67\u002F100 字（四个核心问题全丢）变成 1 块 235 字，内容完整。",[26,1388,1390],{"id":1389},"二按比例推算推出了全场最差点","二、按比例推算，推出了全场最差点",[11,1392,1393,1394,1397,1398,1401],{},"第一版把阈值定成 ",[15,1395,1396],{},"maxChars × 0.6","。生产 ",[15,1399,1400],{},"maxChars"," 是 2400，算出来是 1440。",[11,1403,1404],{},"结果整篇文档并成一个块。上线后实测检索：",[399,1406,1409],{"className":1407,"code":1408,"language":1327},[1325],"5 篇文档 6 个查询，每篇取最佳命中分再平均\n  阈值 0（纯按小节切） 0.7046\n  阈值 250            0.6750\n  阈值 1440（线上）    0.4978\n",[15,1410,1408],{"__ignoreMap":183},[11,1412,1413],{},"1440 正好是最差的那个。",[11,1415,1416],{},"同一篇文档对「核心四问」这个查询，切成小节时得分 0.77，并成整块时只有 0.33——在全库 113 块里排到第 109 名。内容修好了，却再也检索不到。",[11,1418,1419],{},"根因在生产用的向量模型上：",[1205,1421,1422],{},[11,1423,1424],{},"生产 embedding 模型对「主题聚焦的小段」打分远高于「整篇文档」。",[11,1426,1427],{},"原来那个「一个标题一个块」的设计，主题纯度是对的。推翻它是错的判断。真正的缺陷只有一条——标题行被丢弃。",[11,1429,1430],{},"最终取值 250：",[399,1432,1434],{"className":691,"code":1433,"language":693,"meta":183,"style":183},"\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",[15,1435,1436,1440,1445,1450,1455,1460,1465,1470,1475,1481],{"__ignoreMap":183},[407,1437,1438],{"class":409,"line":410},[407,1439,950],{"class":528},[407,1441,1442],{"class":409,"line":184},[407,1443,1444],{"class":528}," * 缺省小节合并阈值。250 是在生产 embedding 模型上实测标定的，不是拍脑袋：\n",[407,1446,1447],{"class":409,"line":189},[407,1448,1449],{"class":528}," * 5 篇文档 6 个查询，取每篇的最佳命中分做平均——\n",[407,1451,1452],{"class":409,"line":452},[407,1453,1454],{"class":528}," *   阈值 0（纯按小节切）0.7046 \u002F 250 → 0.6750 \u002F 1440（= maxChars×0.6）→ 0.4978\n",[407,1456,1457],{"class":409,"line":458},[407,1458,1459],{"class":528}," * 这个模型对「主题聚焦的小段」打分远高于「整篇文档」……\n",[407,1461,1462],{"class":409,"line":464},[407,1463,1464],{"class":528}," * 所以阈值必须小，千万别再按 maxChars 的比例去推——那样在生产的 2400 上会算出 1440，\n",[407,1466,1467],{"class":409,"line":470},[407,1468,1469],{"class":528}," * 正好是最差点。\n",[407,1471,1472],{"class":409,"line":480},[407,1473,1474],{"class":528}," * 取 250 而不是 0：排序只差 4%，但块从几十字变成 250~330 字，\n",[407,1476,1478],{"class":409,"line":1477},9,[407,1479,1480],{"class":528}," * 同样召回 6 段能多喂两三倍的正文。\n",[407,1482,1484],{"class":409,"line":1483},10,[407,1485,970],{"class":528},[11,1487,1488],{},"取 250 而不是 0 的理由是这段话里第二重要的部分：排序只差 4%，但每个块从几十字变成两三百字，同样召回 6 段能多喂几倍的正文。",[11,1490,1491],{},"代码里还留了一条兜底：",[399,1493,1495],{"className":691,"code":1494,"language":693,"meta":183,"style":183},"\u002F\u002F 取 min：maxChars 很小的配置（测试里 300）不能让阈值反超块大小本身\nconst minChunkChars =\n  opts.minChunkChars ?? Math.min(DEFAULT_MIN_CHUNK_CHARS, Math.floor(maxChars * 0.6));\n",[15,1496,1497,1502,1512],{"__ignoreMap":183},[407,1498,1499],{"class":409,"line":410},[407,1500,1501],{"class":528},"\u002F\u002F 取 min：maxChars 很小的配置（测试里 300）不能让阈值反超块大小本身\n",[407,1503,1504,1506,1509],{"class":409,"line":184},[407,1505,700],{"class":417},[407,1507,1508],{"class":476}," minChunkChars",[407,1510,1511],{"class":417}," =\n",[407,1513,1514,1517,1519,1522,1524,1526,1529,1532,1535,1538,1541,1544],{"class":409,"line":189},[407,1515,1516],{"class":413},"  opts.minChunkChars ",[407,1518,912],{"class":417},[407,1520,1521],{"class":413}," Math.",[407,1523,992],{"class":554},[407,1525,586],{"class":413},[407,1527,1528],{"class":476},"DEFAULT_MIN_CHUNK_CHARS",[407,1530,1531],{"class":413},", Math.",[407,1533,1534],{"class":554},"floor",[407,1536,1537],{"class":413},"(maxChars ",[407,1539,1540],{"class":417},"*",[407,1542,1543],{"class":476}," 0.6",[407,1545,1546],{"class":413},"));\n",[11,1548,1549],{},"以及一条守卫测试，专门防「有人再推一遍公式」：",[1205,1551,1552],{},[11,1553,1554],{},"加一条守卫测试钉死生产口径（阈值退回按比例推算即变红），防止将来有人再推一遍公式又回到 1440。",[26,1556,1558],{"id":1557},"三召回阈值会把整轮清零","三、召回阈值：会把整轮清零",[11,1560,1561],{},"分块改大之后，绝对余弦分整体下移。而召回阈值还是旧值 0.5：",[399,1563,1566],{"className":1564,"code":1565,"language":1327},[1325],"正确命中落在 0.43~0.63，旧值会把最高分 0.488 的查询整轮清零——\n检索到了正确文档却被门槛全部丢弃，用户看到的就是「知识库没被使用」。\n",[15,1567,1565],{"__ignoreMap":183},[11,1569,1570],{},"这条解释了我一开始看到的那个现象。检索其实命中了，只是分数没过门槛，于是整轮被丢掉，模型什么也没拿到。",[11,1572,1573],{},"改成 0.35。",[26,1575,1577],{"id":1576},"四精排从-912-到-1212","四、精排：从 9\u002F12 到 12\u002F12",[11,1579,1580],{},"同一天启用了 cross-encoder 精排。A\u002FB 实测：",[399,1582,1585],{"className":1583,"code":1584,"language":1327},[1325],"12 条查询 A\u002FB 实测：\n  纯向量  命中 9\u002F12，整轮零注入 1 次\n  开精排  命中 12\u002F12，整轮零注入 0 次，目标文档 10 条排第 1\n",[15,1586,1584],{"__ignoreMap":183},[11,1588,1589],{},"修好的都是「换了说法」的查询——向量检索的固有短板。举一个例子：",[399,1591,1594],{"className":1592,"code":1593,"language":1327},[1325],"「我想要复刻爆款视频…使用画布」注入 0 段 → 4 段\n（相关文档被向量埋在后面，精排提到第 2 名）\n",[15,1595,1593],{"__ignoreMap":183},[11,1597,1598],{},"精排启用时把两个阈值也一起改了。这是当天最曲折的一处。",[11,1600,1601],{},"第一版把精排阈值设成 0.15。发版后跑完整测试，出现随机漏召。",[11,1603,1604,1605,1244],{},"原因是精排的",[488,1606,1607],{},"绝对分不稳定",[399,1609,1612],{"className":1610,"code":1611,"language":1327},[1325],"同一查询同一候选集，「金字塔原理怎么做到结论先行」两次实测 0.251 与 0.135——\n排名都稳定第 1，只是分数漂了近一半。0.15 卡在中间，于是第二次整轮零注入。\n",[15,1613,1611],{"__ignoreMap":183},[11,1615,1616],{},"排名是稳定的，分数会漂。所以不能用绝对分当门槛去「把关质量」。",[11,1618,1619],{},"阈值扫描：",[399,1621,1624],{"className":1622,"code":1623,"language":1327},[1325],"0.02~0.10 均 12\u002F12 零丢弃，0.15\u002F0.20 → 11\u002F12 丢 1 次\n",[15,1625,1623],{"__ignoreMap":183},[11,1627,1628],{},"最终取 0.05，并把职责写清楚：",[399,1630,1632],{"className":691,"code":1631,"language":693,"meta":183,"style":183},"\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",[15,1633,1634,1638,1643,1648,1653,1658,1663,1668],{"__ignoreMap":183},[407,1635,1636],{"class":409,"line":410},[407,1637,950],{"class":528},[407,1639,1640],{"class":409,"line":184},[407,1641,1642],{"class":528}," * 精排阈值。**它的职责只是扔掉垃圾，不是把关质量**——质量由排序保证：\n",[407,1644,1645],{"class":409,"line":189},[407,1646,1647],{"class":528}," * 12 条查询里精排把正确目标全部放进了前 3 名（10 条第 1）。\n",[407,1649,1650],{"class":409,"line":452},[407,1651,1652],{"class":528}," *\n",[407,1654,1655],{"class":409,"line":458},[407,1656,1657],{"class":528}," * 取 0.05 而不是更高，是因为**精排的绝对分不稳定**……\n",[407,1659,1660],{"class":409,"line":464},[407,1661,1662],{"class":528}," * 观测到的正确目标最低分 0.131，取 0.05 留约 60% 余量；\n",[407,1664,1665],{"class":409,"line":470},[407,1666,1667],{"class":528}," * 垃圾档在 0.014~0.025，仍被干净滤掉。\n",[407,1669,1670],{"class":409,"line":480},[407,1671,970],{"class":528},[11,1673,1674],{},"观测到的垃圾档在 0.014~0.025，正确目标最低 0.131。0.05 落在两者之间，两边都有余量。",[26,1676,1678],{"id":1677},"五超时静默降级最贵","五、超时：静默降级最贵",[11,1680,1681],{},"同一批里还修了超时。",[11,1683,1684],{},"精排失败会自动降级回向量序，不阻断聊天。这个设计是对的——但有代价：",[399,1686,1689],{"className":1687,"code":1688,"language":1327},[1325],"法律知识库 30 块共 4.5 万字，冷调用实测 2263ms，只剩 737ms 余量。\n完整测试里有一条查询当场超时降级。\n超时是静默的，日志不报错，用户侧表现为「有时准有时不准」，极难归因。\n",[15,1690,1688],{"__ignoreMap":183},[11,1692,1693],{},"「有时准有时不准」这句话在这一天的上下文里已经出现第二次了——早上是参考图，这里是检索。",[11,1695,1696],{},"超时从 3000 毫秒提到 8000，给冷调用 3.5 倍余量。真卡死仍有降级兜底。",[26,1698,1700],{"id":1699},"六三个阈值放在一起","六、三个阈值放在一起",[11,1702,1703,1704],{},"三条修正的形式不同，结论是同一条：",[488,1705,1706],{},"阈值不能推导，只能测量。",[1708,1709,1710,1729],"table",{},[1711,1712,1713],"thead",{},[1714,1715,1716,1720,1723,1726],"tr",{},[1717,1718,1719],"th",{},"参数",[1717,1721,1722],{},"推导值",[1717,1724,1725],{},"实测值",[1717,1727,1728],{},"推导错在哪",[1730,1731,1732,1747,1761],"tbody",{},[1714,1733,1734,1738,1741,1744],{},[1735,1736,1737],"td",{},"分块合并阈值",[1735,1739,1740],{},"1440（按比例 0.6）",[1735,1742,1743],{},"250",[1735,1745,1746],{},"假设「块越大越好」，而该模型偏好主题聚焦的小段",[1714,1748,1749,1752,1755,1758],{},[1735,1750,1751],{},"召回阈值",[1735,1753,1754],{},"0.5（惯例值）",[1735,1756,1757],{},"0.35",[1735,1759,1760],{},"分块改动后分数分布整体下移，旧门槛把命中全丢",[1714,1762,1763,1766,1769,1772],{},[1735,1764,1765],{},"精排阈值",[1735,1767,1768],{},"0.15（留余量）",[1735,1770,1771],{},"0.05",[1735,1773,1774],{},"绝对分本身会漂近一半，拿漂移量当门槛必然随机漏召",[11,1776,1777],{},"三个值都有实测数字支撑，也都配了守卫测试。其中两条测试写的是「不许回到某个值」，而不是「必须等于某值」：",[399,1779,1781],{"className":691,"code":1780,"language":693,"meta":183,"style":183},"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",[15,1782,1783,1798,1819,1823,1828,1843,1863],{"__ignoreMap":183},[407,1784,1785,1787,1789,1792,1794,1796],{"class":409,"line":410},[407,1786,583],{"class":554},[407,1788,586],{"class":413},[407,1790,1791],{"class":429},"\"向量阈值不得回到会整轮清零的 0.5\"",[407,1793,592],{"class":413},[407,1795,520],{"class":417},[407,1797,523],{"class":413},[407,1799,1800,1802,1804,1807,1809,1812,1814,1817],{"class":409,"line":184},[407,1801,629],{"class":554},[407,1803,586],{"class":413},[407,1805,1806],{"class":476},"DEFAULT_KB_MIN_SCORE",[407,1808,999],{"class":413},[407,1810,1811],{"class":554},"toBeLessThanOrEqual",[407,1813,586],{"class":413},[407,1815,1816],{"class":476},"0.4",[407,1818,662],{"class":413},[407,1820,1821],{"class":409,"line":189},[407,1822,667],{"class":413},[407,1824,1825],{"class":409,"line":452},[407,1826,1827],{"emptyLinePlaceholder":202},"\n",[407,1829,1830,1832,1834,1837,1839,1841],{"class":409,"line":458},[407,1831,583],{"class":554},[407,1833,586],{"class":413},[407,1835,1836],{"class":429},"\"精排阈值不得高到会随分数漂移漏召——观测最低正确分 0.131\"",[407,1838,592],{"class":413},[407,1840,520],{"class":417},[407,1842,523],{"class":413},[407,1844,1845,1847,1849,1852,1854,1856,1858,1861],{"class":409,"line":464},[407,1846,629],{"class":554},[407,1848,586],{"class":413},[407,1850,1851],{"class":476},"DEFAULT_KB_RERANK_MIN_SCORE",[407,1853,999],{"class":413},[407,1855,1811],{"class":554},[407,1857,586],{"class":413},[407,1859,1860],{"class":476},"0.1",[407,1862,662],{"class":413},[407,1864,1865],{"class":409,"line":470},[407,1866,667],{"class":413},[11,1868,1869],{},"这样写是因为真正的风险不是「有人改了数值」，而是「有人又推了一遍公式」。测试防的是后者。",[11,1871,1872],{},"还有一条测试值得抄下来：",[399,1874,1876],{"className":691,"code":1875,"language":693,"meta":183,"style":183},"it(\"精排阈值必须低于向量阈值——两者不是同一个量纲\", () => {\n  \u002F\u002F 精排是 cross-encoder 分，向量是余弦分，拿同一个数去卡两边必然有一边错\n  expect(DEFAULT_KB_RERANK_MIN_SCORE).toBeLessThan(DEFAULT_KB_MIN_SCORE);\n});\n",[15,1877,1878,1893,1898,1917],{"__ignoreMap":183},[407,1879,1880,1882,1884,1887,1889,1891],{"class":409,"line":410},[407,1881,583],{"class":554},[407,1883,586],{"class":413},[407,1885,1886],{"class":429},"\"精排阈值必须低于向量阈值——两者不是同一个量纲\"",[407,1888,592],{"class":413},[407,1890,520],{"class":417},[407,1892,523],{"class":413},[407,1894,1895],{"class":409,"line":184},[407,1896,1897],{"class":528},"  \u002F\u002F 精排是 cross-encoder 分，向量是余弦分，拿同一个数去卡两边必然有一边错\n",[407,1899,1900,1902,1904,1906,1908,1911,1913,1915],{"class":409,"line":189},[407,1901,629],{"class":554},[407,1903,586],{"class":413},[407,1905,1851],{"class":476},[407,1907,999],{"class":413},[407,1909,1910],{"class":554},"toBeLessThan",[407,1912,586],{"class":413},[407,1914,1806],{"class":476},[407,1916,662],{"class":413},[407,1918,1919],{"class":409,"line":452},[407,1920,667],{"class":413},[11,1922,1923],{},"同一个数量级、看起来可比，实际是两个不同的量纲。这种错觉在阈值调参里很常见，能用一条断言拦下来比写在注释里可靠。",[26,1925,1927],{"id":1926},"七评测集的缺口","七、评测集的缺口",[11,1929,1930],{},"这一轮所有数字来自临时扫的查询集：5 篇文档 6 个查询、12 条查询。",[11,1932,1933,1934,1937,1938,781],{},"仓库里确实有一个召回评测脚本（用户问题、期望命中的文档名、recall@K \u002F precision@K \u002F MRR 双模式），但它的标注集还是占位内容——两条用例，",[15,1935,1936],{},"kbIds"," 的值是字面量 ",[15,1939,1940],{},"\u003C替换为真实 kbId>",[11,1942,1943],{},"也就是说，这一轮的标定用的数据集没有入库。数字留在代码注释、配置和环境变量说明里，跑分的脚本和查询集没有留下。",[11,1945,1946,1947,1950],{},"这是这次工作里最该补上的一环。",[488,1948,1949],{},"阈值有实测依据，但依据本身不可复现。"," 下次有人想调整，只能重新扫一遍查询。",[11,1952,1953],{},"补的做法是明确的：把这一轮用的查询集和期望结果补进评测脚本的标注集，让它成为下一次调参的起点。参数变更时先跑评测再改值，改完之后把新的分数写进注释——注释里的数字应该来自一次可复现的运行，而不是一次性的手测。",[1267,1955,1956],{},"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":183,"searchDepth":184,"depth":184,"links":1958},[1959,1960,1961,1962,1963,1964,1965],{"id":1306,"depth":184,"text":1307},{"id":1389,"depth":184,"text":1390},{"id":1557,"depth":184,"text":1558},{"id":1576,"depth":184,"text":1577},{"id":1677,"depth":184,"text":1678},{"id":1699,"depth":184,"text":1700},{"id":1926,"depth":184,"text":1927},"Agent 平台",{},"\u002F2026-08-31",{"title":1295,"description":1300},"2026-08-31-阈值不能推只能量","知识库检索质量的三次修正：分块阈值按比例推算算出了全场最差点，精排阈值设高了会随分数漂移随机漏召，超时余量不够会静默降级。三次都靠实测数据定值。",[1973,1974,1975,1976,1977,1978,1979],"知识库","RAG","分块","检索","cross-encoder","阈值标定","评测","VbN-ARGFFeAmQMRZf2jG-4SfdtN22uzMLyCrc3dCuA8",{"id":1982,"title":1983,"body":1984,"column":2727,"date":2728,"description":1988,"extension":199,"hero_image":200,"meta":2729,"navigation":202,"path":2730,"seo":2731,"series_id":200,"severity":200,"stem":2732,"summary":2733,"tags":2734,"__hash__":2740},"posts\u002F2026-08-30-canvas-ref-提示词里的图片引用从哪来.md","提示词里的 @图片1 是从哪来的",{"type":8,"value":1985,"toc":2718},[1986,1989,1996,1999,2003,2010,2016,2033,2038,2045,2048,2052,2055,2058,2183,2189,2192,2212,2222,2225,2231,2240,2244,2247,2254,2257,2266,2269,2273,2279,2285,2288,2350,2353,2360,2363,2368,2374,2377,2380,2412,2417,2437,2442,2451,2456,2462,2465,2502,2505,2508,2531,2536,2578,2581,2586,2592,2624,2627,2631,2634,2640,2647,2705,2708,2715],[11,1987,1988],{},"脚本节点做的事，是把一段梗概或剧本变成结构化的镜头表，再按镜头批量出分镜图、批量生视频。",[11,1990,1991,1992,1995],{},"用户反馈：资产没成组，也没连脚本节点；分镜节点之间没有连线；",[15,1993,1994],{},"@"," 功能不工作。",[11,1997,1998],{},"三句话指向同一件事——脚本节点在画布上像一个外来户。",[26,2000,2002],{"id":2001},"一自造了一套画布不认识的格式","一、自造了一套画布不认识的格式",[11,2004,2005,2006,2009],{},"根因在这里：脚本节点用 ",[15,2007,2008],{},"{{Image 1}}"," 这种写法引用参考图。",[11,2011,2012,2013,1244],{},"这是它自己发明的格式。画布的原生引用是 ",[15,2014,2015],{},"@图片1",[123,2017,2018,2024,2030],{},[126,2019,2020,2021,2023],{},"前端会把 ",[15,2022,2015],{}," 渲染成 chip 模块，用户看得出那是一个整体。",[126,2025,2026,2027,2029],{},"chip 与连线顺序一一对应——",[15,2028,2015],{}," 就是接进来的第一张图。",[126,2031,2032],{},"节点卡片上会根据编号显示出对应素材的缩略图。",[11,2034,2035,2037],{},[15,2036,2008],{}," 哪一个都不占。它在提示词框里只是一串字面量，模型看到也是一串字面量。",[11,2039,2040,2041,2044],{},"而连线这一步也被绕开了：脚本节点原先用 ",[15,2042,2043],{},"refMaterialIds"," 参数把参考图传给下游节点，不走连线。参数传递能跑通生成，但画布上看不到连线，用户无从知道哪张图进了哪个节点。",[11,2046,2047],{},"于是「资产没成组、分镜节点没连线、@ 不工作」这三件事一起出现——它们本来就是一件事的三个侧面。",[26,2049,2051],{"id":2050},"二编号只有一份真源","二、编号只有一份真源",[11,2053,2054],{},"改动的核心是把引用交还给画布原生体系。顺序很关键。",[11,2056,2057],{},"第一步是确定「这个镜头会用哪几张参考图」：",[399,2059,2061],{"className":691,"code":2060,"language":693,"meta":183,"style":183},"\u002F**\n * 该镜会被「连线接入」的参考图资产，顺序即连线顺序、即 @图片N 的编号顺序。\n *\n * **这是编号的唯一真源**：spawn 分镜节点时按它连线，拼提示词时按它编号。\n * 两处若各自计算，编号与实际连线就会错位——@图片2 指到另一张图上，\n * 而画面「看起来只是不太对」，极难发现。\n *\n * 只收已生成出图的资产：没有 materialId 的连不了线，占了编号会让后面全体错位。\n *\u002F\nexport function shotReferenceImages(shot, assets) {\n  return shotReferencedAssets(shot, assets).filter((asset) => asset.status === \"done\" && !!asset.materialId);\n}\n",[15,2062,2063,2067,2078,2082,2087,2098,2103,2107,2112,2116,2137,2178],{"__ignoreMap":183},[407,2064,2065],{"class":409,"line":410},[407,2066,950],{"class":528},[407,2068,2069,2072,2075],{"class":409,"line":184},[407,2070,2071],{"class":528}," * 该镜会被「连线接入」的参考图资产，顺序即连线顺序、即 ",[407,2073,2074],{"class":417},"@图片N",[407,2076,2077],{"class":528}," 的编号顺序。\n",[407,2079,2080],{"class":409,"line":189},[407,2081,1652],{"class":528},[407,2083,2084],{"class":409,"line":452},[407,2085,2086],{"class":528}," * **这是编号的唯一真源**：spawn 分镜节点时按它连线，拼提示词时按它编号。\n",[407,2088,2089,2092,2095],{"class":409,"line":458},[407,2090,2091],{"class":528}," * 两处若各自计算，编号与实际连线就会错位——",[407,2093,2094],{"class":417},"@图片2",[407,2096,2097],{"class":528}," 指到另一张图上，\n",[407,2099,2100],{"class":409,"line":464},[407,2101,2102],{"class":528}," * 而画面「看起来只是不太对」，极难发现。\n",[407,2104,2105],{"class":409,"line":470},[407,2106,1652],{"class":528},[407,2108,2109],{"class":409,"line":480},[407,2110,2111],{"class":528}," * 只收已生成出图的资产：没有 materialId 的连不了线，占了编号会让后面全体错位。\n",[407,2113,2114],{"class":409,"line":1477},[407,2115,970],{"class":528},[407,2117,2118,2120,2122,2125,2127,2130,2132,2135],{"class":409,"line":1483},[407,2119,1045],{"class":417},[407,2121,1048],{"class":417},[407,2123,2124],{"class":554}," shotReferenceImages",[407,2126,586],{"class":413},[407,2128,2129],{"class":718},"shot",[407,2131,433],{"class":413},[407,2133,2134],{"class":718},"assets",[407,2136,887],{"class":413},[407,2138,2140,2143,2146,2149,2152,2154,2157,2159,2161,2164,2166,2169,2172,2175],{"class":409,"line":2139},11,[407,2141,2142],{"class":417},"  return",[407,2144,2145],{"class":554}," shotReferencedAssets",[407,2147,2148],{"class":413},"(shot, assets).",[407,2150,2151],{"class":554},"filter",[407,2153,715],{"class":413},[407,2155,2156],{"class":718},"asset",[407,2158,722],{"class":413},[407,2160,520],{"class":417},[407,2162,2163],{"class":413}," asset.status ",[407,2165,881],{"class":417},[407,2167,2168],{"class":429}," \"done\"",[407,2170,2171],{"class":417}," &&",[407,2173,2174],{"class":417}," !!",[407,2176,2177],{"class":413},"asset.materialId);\n",[407,2179,2181],{"class":409,"line":2180},12,[407,2182,483],{"class":413},[11,2184,2185,2186,2188],{},"最后一句是这个函数里最容易忽略的约束。生成中的资产还连不了线，如果它先占了一个编号，后面所有素材的编号会整体前移一位，提示词里的 ",[15,2187,2094],{}," 就指到了另一张图上。",[11,2190,2191],{},"第二步是连线。spawn 分镜图节点时，参考图靠连线接入，顺序与编号一致：",[399,2193,2195],{"className":691,"code":2194,"language":693,"meta":183,"style":183},"\u002F\u002F 参考图靠**连线**接入（画布原生机制），顺序必须与提示词里的 @图片N 一致：\n\u002F\u002F 两边都以 shotReferenceImages 为真源，先连的就是 @图片1。\n\u002F\u002F 用 refMaterialIds 参数传参会绕开 @ 引用体系，chip 不渲染、编号也对不上（线上踩过）。\n",[15,2196,2197,2202,2207],{"__ignoreMap":183},[407,2198,2199],{"class":409,"line":410},[407,2200,2201],{"class":528},"\u002F\u002F 参考图靠**连线**接入（画布原生机制），顺序必须与提示词里的 @图片N 一致：\n",[407,2203,2204],{"class":409,"line":184},[407,2205,2206],{"class":528},"\u002F\u002F 两边都以 shotReferenceImages 为真源，先连的就是 @图片1。\n",[407,2208,2209],{"class":409,"line":189},[407,2210,2211],{"class":528},"\u002F\u002F 用 refMaterialIds 参数传参会绕开 @ 引用体系，chip 不渲染、编号也对不上（线上踩过）。\n",[11,2213,2214,2215,2217,2218,2221],{},"第三步是资产落节点。资产出图之后自动落 ",[15,2216,936],{}," 节点、编组、连到脚本节点上，按 ",[15,2219,2220],{},"scriptAssetId"," 查重保证幂等。",[11,2223,2224],{},"第四步是让模型别自己发明引用。智能合成的提示词里，正文只写资产的名字：",[399,2226,2229],{"className":2227,"code":2228,"language":1327},[1325],"资产图锚定：\n角色 林芽 的参考图是 @图片1\n",[15,2230,2228],{"__ignoreMap":183},[11,2232,2233,2234,40,2236,2239],{},"生成提示词时明确禁止模型自造 ",[15,2235,1994],{},[15,2237,2238],{},"{{Image}}"," 记号——模型写出来的编号不受控制，写错一个，整段引用就错位。",[26,2241,2243],{"id":2242},"三还有一个反向的坑","三、还有一个反向的坑",[11,2245,2246],{},"同一轮里发现一个反方向的问题：脚本节点连到下游时，它的文本产出会被当成提示词的一部分。",[11,2248,2249,2250,2253],{},"脚本节点的输出是",[488,2251,2252],{},"整张镜头表","。接进生图节点，等于把整篇镜头表灌进生图提示词。",[11,2255,2256],{},"所以后端的上游输入收集里显式排除了它：",[399,2258,2260],{"className":691,"code":2259,"language":693,"meta":183,"style":183},"\u002F\u002F script.storyboard 的文本产出是整张镜头表，接进取参考图会把整篇灌进生图提示词\n",[15,2261,2262],{"__ignoreMap":183},[407,2263,2264],{"class":409,"line":410},[407,2265,2259],{"class":528},[11,2267,2268],{},"脚本节点连过来只做溯源，不作为文本输入。",[26,2270,2272],{"id":2271},"四节点要有名字","四、节点要有名字",[11,2274,2275,2276,2278],{},"引用体系要能用，前提是用户能认出「",[15,2277,2015],{}," 到底是画布上哪一个」。",[11,2280,2281,2282,2284],{},"原先节点标题只有类型名。一排「图片」摆在那儿，",[15,2283,1994],{}," 菜单里也是一排「图片」。",[11,2286,2287],{},"改成按类型各自递增编号：",[399,2289,2291],{"className":691,"code":2290,"language":693,"meta":183,"style":183},"\u002F**\n * 新节点的默认标题：「图片节点 1」「视频节点 2」这样按类型各自递增。\n *\n * 为什么要编号：@ 菜单里一排「图片」根本分不出是画布上哪一个，\n * 有了编号才认得出。编号一次性写进 params.nodeTitle 后就**不再变**——\n * 跟着当前节点数实时算的话，删掉中间一个会让后面的全部改名，\n * 用户提示词里已经写下的 @图片节点3 就指向了别的东西。\n *\n * 取「同类已用过的最大编号 + 1」而不是「同类个数 + 1」：后者在删掉中间节点后\n * 会复用旧编号，画布上于是出现两个「图片节点 2」。\n *\u002F\n",[15,2292,2293,2297,2302,2306,2311,2316,2321,2332,2336,2341,2346],{"__ignoreMap":183},[407,2294,2295],{"class":409,"line":410},[407,2296,950],{"class":528},[407,2298,2299],{"class":409,"line":184},[407,2300,2301],{"class":528}," * 新节点的默认标题：「图片节点 1」「视频节点 2」这样按类型各自递增。\n",[407,2303,2304],{"class":409,"line":189},[407,2305,1652],{"class":528},[407,2307,2308],{"class":409,"line":452},[407,2309,2310],{"class":528}," * 为什么要编号：@ 菜单里一排「图片」根本分不出是画布上哪一个，\n",[407,2312,2313],{"class":409,"line":458},[407,2314,2315],{"class":528}," * 有了编号才认得出。编号一次性写进 params.nodeTitle 后就**不再变**——\n",[407,2317,2318],{"class":409,"line":464},[407,2319,2320],{"class":528}," * 跟着当前节点数实时算的话，删掉中间一个会让后面的全部改名，\n",[407,2322,2323,2326,2329],{"class":409,"line":470},[407,2324,2325],{"class":528}," * 用户提示词里已经写下的 ",[407,2327,2328],{"class":417},"@图片节点3",[407,2330,2331],{"class":528}," 就指向了别的东西。\n",[407,2333,2334],{"class":409,"line":480},[407,2335,1652],{"class":528},[407,2337,2338],{"class":409,"line":1477},[407,2339,2340],{"class":528}," * 取「同类已用过的最大编号 + 1」而不是「同类个数 + 1」：后者在删掉中间节点后\n",[407,2342,2343],{"class":409,"line":1483},[407,2344,2345],{"class":528}," * 会复用旧编号，画布上于是出现两个「图片节点 2」。\n",[407,2347,2348],{"class":409,"line":2139},[407,2349,970],{"class":528},[11,2351,2352],{},"两条规则都来自同一个要求：编号一旦被用户写进提示词，它就不能再变。实时计算看起来更「准确」，实际会让已经写下的引用指向别处。",[26,2354,2356,2357,2359],{"id":2355},"五-菜单与富文本输入框","五、",[15,2358,1994],{}," 菜单与富文本输入框",[11,2361,2362],{},"引用记号定义好了，接下来是让用户能舒服地打出来。",[11,2364,2365],{},[488,2366,2367],{},"菜单要分组。",[399,2369,2372],{"className":2370,"code":2371,"language":1327},[1325],"已引用           （已经连线的上游）\n素材引用 · 图片\n素材引用 · 视频\n素材引用 · 音频\n素材引用 · 文本\n",[15,2373,2371],{"__ignoreMap":183},[11,2375,2376],{},"一长条平铺的列表里全是「图片」，认不出是画布上哪一个。分组之后，已经连上来的排在最前。",[11,2378,2379],{},"分组渲染有一个具体的坑：",[399,2381,2383],{"className":691,"code":2382,"language":693,"meta":183,"style":183},"\u002F**\n * 菜单分组：已引用（已连线的上游）在前，其余按类型分节。\n *\n * 每项带上它在扁平列表中的下标——键盘上下键走的是扁平序，\n * 分组渲染时若各组自己从 0 数，高亮会跳到别的组去。\n *\u002F\n",[15,2384,2385,2389,2394,2398,2403,2408],{"__ignoreMap":183},[407,2386,2387],{"class":409,"line":410},[407,2388,950],{"class":528},[407,2390,2391],{"class":409,"line":184},[407,2392,2393],{"class":528}," * 菜单分组：已引用（已连线的上游）在前，其余按类型分节。\n",[407,2395,2396],{"class":409,"line":189},[407,2397,1652],{"class":528},[407,2399,2400],{"class":409,"line":452},[407,2401,2402],{"class":528}," * 每项带上它在扁平列表中的下标——键盘上下键走的是扁平序，\n",[407,2404,2405],{"class":409,"line":458},[407,2406,2407],{"class":528}," * 分组渲染时若各组自己从 0 数，高亮会跳到别的组去。\n",[407,2409,2410],{"class":409,"line":464},[407,2411,970],{"class":528},[11,2413,2414],{},[488,2415,2416],{},"滚轮不能缩放画布。",[399,2418,2420],{"className":691,"code":2419,"language":693,"meta":183,"style":183},"{\u002F* nowheel 不能少：xyflow 默认把滚轮当画布缩放，\n    没有它在菜单里滚一下就把整个画布缩放了，菜单本身纹丝不动 *\u002F}\n",[15,2421,2422,2430],{"__ignoreMap":183},[407,2423,2424,2427],{"class":409,"line":410},[407,2425,2426],{"class":413},"{",[407,2428,2429],{"class":528},"\u002F* nowheel 不能少：xyflow 默认把滚轮当画布缩放，\n",[407,2431,2432,2435],{"class":409,"line":184},[407,2433,2434],{"class":528},"    没有它在菜单里滚一下就把整个画布缩放了，菜单本身纹丝不动 *\u002F",[407,2436,483],{"class":413},[11,2438,2439],{},[488,2440,2441],{},"图标不用 emoji。",[399,2443,2445],{"className":691,"code":2444,"language":693,"meta":183,"style":183},"\u002F** 线性图标，不用 emoji：emoji 在各系统字体下大小\u002F基线都不一样，一排下来参差不齐 *\u002F\n",[15,2446,2447],{"__ignoreMap":183},[407,2448,2449],{"class":409,"line":410},[407,2450,2444],{"class":528},[11,2452,2453],{},[488,2454,2455],{},"输入框要做成富文本。",[11,2457,2458,2459,2461],{},"原先 ",[15,2460,2015],{}," 就是七个字。用户看不出那是一个整体，删的时候要一个字一个字退。",[11,2463,2464],{},"改成 chip 之后，数据层仍然是纯文本：",[399,2466,2468],{"className":691,"code":2467,"language":693,"meta":183,"style":183},"\u002F**\n * 画布提示词里的「模块」（chip）：素材引用与运镜。\n *\n * 提示词在数据层始终是**一段纯文本**（`一只猫@图片1（镜头左移）跑开`），\n * chip 只是这段文本在输入框里的渲染形态。这样存库\u002F发上游\u002FAI 助写回填全都不用改，\n * 而且用户手打 `@图片1` 也一样会显示成 chip。\n *\u002F\n",[15,2469,2470,2474,2479,2483,2488,2493,2498],{"__ignoreMap":183},[407,2471,2472],{"class":409,"line":410},[407,2473,950],{"class":528},[407,2475,2476],{"class":409,"line":184},[407,2477,2478],{"class":528}," * 画布提示词里的「模块」（chip）：素材引用与运镜。\n",[407,2480,2481],{"class":409,"line":189},[407,2482,1652],{"class":528},[407,2484,2485],{"class":409,"line":452},[407,2486,2487],{"class":528}," * 提示词在数据层始终是**一段纯文本**（`一只猫@图片1（镜头左移）跑开`），\n",[407,2489,2490],{"class":409,"line":458},[407,2491,2492],{"class":528}," * chip 只是这段文本在输入框里的渲染形态。这样存库\u002F发上游\u002FAI 助写回填全都不用改，\n",[407,2494,2495],{"class":409,"line":464},[407,2496,2497],{"class":528}," * 而且用户手打 `@图片1` 也一样会显示成 chip。\n",[407,2499,2500],{"class":409,"line":470},[407,2501,970],{"class":528},[11,2503,2504],{},"这条设计决定了实现的自由度：存库、发上游、AI 助写回填三处都不用动，因为它们看到的还是一样的字符串。",[11,2506,2507],{},"但解析必须是无损的：",[399,2509,2511],{"className":691,"code":2510,"language":693,"meta":183,"style":183},"\u002F**\n * 解析必须是**无损**的：text → 段落 → text 必须还原成原串，\n * 否则用户每敲一个字都可能被悄悄改写。parse\u002Fserialize 的往返有测试钉死。\n *\u002F\n",[15,2512,2513,2517,2522,2527],{"__ignoreMap":183},[407,2514,2515],{"class":409,"line":410},[407,2516,950],{"class":528},[407,2518,2519],{"class":409,"line":184},[407,2520,2521],{"class":528}," * 解析必须是**无损**的：text → 段落 → text 必须还原成原串，\n",[407,2523,2524],{"class":409,"line":189},[407,2525,2526],{"class":528}," * 否则用户每敲一个字都可能被悄悄改写。parse\u002Fserialize 的往返有测试钉死。\n",[407,2528,2529],{"class":409,"line":452},[407,2530,970],{"class":528},[11,2532,2533],{},[488,2534,2535],{},"chip 必须用原生 DOM 建。",[399,2537,2539],{"className":691,"code":2538,"language":693,"meta":183,"style":183},"\u002F**\n * 为什么不是 textarea：textarea 只能存纯文本，`@图片1` 就是七个字，\n * 用户看不出那是一个整体，删的时候还得一个字一个字退。\n *\n * **chip 必须用原生 DOM 建**：\n * contenteditable 内部交给 React 渲染的话，每次 re-render 重建节点会打掉\n * 光标位置与输入法组字状态，中文根本没法连续输入。\n *\u002F\n",[15,2540,2541,2545,2550,2555,2559,2564,2569,2574],{"__ignoreMap":183},[407,2542,2543],{"class":409,"line":410},[407,2544,950],{"class":528},[407,2546,2547],{"class":409,"line":184},[407,2548,2549],{"class":528}," * 为什么不是 textarea：textarea 只能存纯文本，`@图片1` 就是七个字，\n",[407,2551,2552],{"class":409,"line":189},[407,2553,2554],{"class":528}," * 用户看不出那是一个整体，删的时候还得一个字一个字退。\n",[407,2556,2557],{"class":409,"line":452},[407,2558,1652],{"class":528},[407,2560,2561],{"class":409,"line":458},[407,2562,2563],{"class":528}," * **chip 必须用原生 DOM 建**：\n",[407,2565,2566],{"class":409,"line":464},[407,2567,2568],{"class":528}," * contenteditable 内部交给 React 渲染的话，每次 re-render 重建节点会打掉\n",[407,2570,2571],{"class":409,"line":470},[407,2572,2573],{"class":528}," * 光标位置与输入法组字状态，中文根本没法连续输入。\n",[407,2575,2576],{"class":409,"line":480},[407,2577,970],{"class":528},[11,2579,2580],{},"最后一句是这个功能能不能用的分界线。中文输入有组字过程，React 重建 DOM 节点会打断它，用户打一个词会看到候选框消失。",[11,2582,2583],{},[488,2584,2585],{},"运镜也用同一种 chip。",[11,2587,2588,2589,781],{},"运镜的记号是全角括号：",[15,2590,2591],{},"（镜头左移）",[399,2593,2595],{"className":691,"code":2594,"language":693,"meta":183,"style":183},"\u002F**\n * 运镜在提示词里的写法：`（镜头左移）`。\n *\n * 全角括号是给模型看的分隔提示——正文写成「她回头（镜头左移）看向窗外」，\n * 镜头指令就落在它该发生的那一刻。用参数存整段后缀的话这个位置信息就没了。\n *\u002F\n",[15,2596,2597,2601,2606,2610,2615,2620],{"__ignoreMap":183},[407,2598,2599],{"class":409,"line":410},[407,2600,950],{"class":528},[407,2602,2603],{"class":409,"line":184},[407,2604,2605],{"class":528}," * 运镜在提示词里的写法：`（镜头左移）`。\n",[407,2607,2608],{"class":409,"line":189},[407,2609,1652],{"class":528},[407,2611,2612],{"class":409,"line":452},[407,2613,2614],{"class":528}," * 全角括号是给模型看的分隔提示——正文写成「她回头（镜头左移）看向窗外」，\n",[407,2616,2617],{"class":409,"line":458},[407,2618,2619],{"class":528}," * 镜头指令就落在它该发生的那一刻。用参数存整段后缀的话这个位置信息就没了。\n",[407,2621,2622],{"class":409,"line":464},[407,2623,970],{"class":528},[11,2625,2626],{},"起初运镜是一个独立参数，拼提示词时把后缀接在末尾。但镜头运动发生在语句里的某个时刻——「她回头」之后、还是「看向窗外」之前，结果不一样。改成内联记号之后，位置由用户决定。",[26,2628,2630],{"id":2629},"六两种记号的取舍","六、两种记号的取舍",[11,2632,2633],{},"引用体系最后收敛到两种记号：",[399,2635,2638],{"className":2636,"code":2637,"language":1327},[1325],"素材  @图片1        编号与连线顺序一一对应\n运镜  （镜头左移）   全角括号，落在它该发生的那一刻\n",[15,2639,2637],{"__ignoreMap":183},[11,2641,2642,2643,2646],{},"两者的共同点是",[488,2644,2645],{},"都可以被无损地解析回纯文本","，也都可以被用户手打出来。正则只认这两类：",[399,2648,2650],{"className":691,"code":2649,"language":693,"meta":183,"style":183},"\u002F** `@图片1`：类型名 + 1~2 位编号。两位是为了万一素材超过 9 个。 *\u002F\nconst MATERIAL_REF = \u002F@(图片|视频|音频|文本)(\\d{1,2})\u002Fg;\n",[15,2651,2652,2657],{"__ignoreMap":183},[407,2653,2654],{"class":409,"line":410},[407,2655,2656],{"class":528},"\u002F** `@图片1`：类型名 + 1~2 位编号。两位是为了万一素材超过 9 个。 *\u002F\n",[407,2658,2659,2661,2664,2666,2669,2673,2676,2679,2681,2684,2686,2689,2692,2695,2697,2700,2703],{"class":409,"line":184},[407,2660,700],{"class":417},[407,2662,2663],{"class":476}," MATERIAL_REF",[407,2665,706],{"class":417},[407,2667,2668],{"class":429}," \u002F",[407,2670,2672],{"class":2671},"sA_wV","@(图片",[407,2674,2675],{"class":417},"|",[407,2677,2678],{"class":2671},"视频",[407,2680,2675],{"class":417},[407,2682,2683],{"class":2671},"音频",[407,2685,2675],{"class":417},[407,2687,2688],{"class":2671},"文本)(",[407,2690,2691],{"class":476},"\\d",[407,2693,2694],{"class":417},"{1,2}",[407,2696,1065],{"class":2671},[407,2698,2699],{"class":429},"\u002F",[407,2701,2702],{"class":417},"g",[407,2704,918],{"class":413},[11,2706,2707],{},"这一轮改动的实质，是把脚本节点从「自己发明一套引用方式」改回「用画布已有的引用方式」。前者看起来更自由——不用迁就画布的连线、编号、渲染规则。代价是它在画布上不可见：没有连线、没有 chip、没有缩略图，用户看不出数据是怎么流的。",[11,2709,2710,2711,2714],{},"可组合的系统里，",[488,2712,2713],{},"新功能要么复用既有的表达方式，要么就得把新方式补进所有既有环节","——渲染、解析、连线、菜单、缩略图。脚本节点选过前者，走了一段之后又回到了后者，中间那段的成本就是这篇文章。",[1267,2716,2717],{},"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 .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 .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 .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sA_wV, html code.shiki .sA_wV{--shiki-default:#032F62;--shiki-dark:#DBEDFF}",{"title":183,"searchDepth":184,"depth":184,"links":2719},[2720,2721,2722,2723,2724,2726],{"id":2001,"depth":184,"text":2002},{"id":2050,"depth":184,"text":2051},{"id":2242,"depth":184,"text":2243},{"id":2271,"depth":184,"text":2272},{"id":2355,"depth":184,"text":2725},"五、@ 菜单与富文本输入框",{"id":2629,"depth":184,"text":2630},"内容流水线","2026-08-30",{},"\u002F2026-08-30-canvas-ref",{"title":1983,"description":1988},"2026-08-30-canvas-ref-提示词里的图片引用从哪来","脚本节点批量出分镜图，资产却没成组、分镜节点没连线、@ 引用不生效。根因是自造了一套画布不认识的引用格式，整条链路脱离原生体系。",[1286,2735,2736,2737,2738,2739],"引用体系","contenteditable","提示词","脚本节点","富文本","FCePXkKfWJEu7ut5v9W-crJUDCwiMVp6XIVPxFdayM8",{"id":2742,"title":2743,"body":2744,"column":355,"date":3411,"description":2748,"extension":199,"hero_image":200,"meta":3412,"navigation":202,"path":3413,"seo":3414,"series_id":3415,"severity":200,"stem":3416,"summary":3417,"tags":3418,"__hash__":3424},"posts\u002F2026-08-28-FA-023-图是好的链接死了.md","图是好的，链接死了",{"type":8,"value":2745,"toc":3404},[2746,2749,2752,2755,2759,2762,2768,2771,2774,2780,2783,2790,2793,2844,2847,2850,2854,2857,2860,2927,2930,2936,2983,2986,2990,2993,2996,3002,3005,3105,3112,3115,3194,3205,3211,3215,3218,3224,3227,3232,3235,3308,3314,3321,3324,3328,3334,3345,3348,3398,3401],[11,2747,2748],{},"画布开着十几分钟之后，上面的素材图集体变成「图片加载失败」。",[11,2750,2751],{},"刷新能好，但好十几分钟，然后再裂。用户以为是网络问题，我们一开始也这么以为——直到发现刷新只是换来另一条同样会死的链接。",[11,2753,2754],{},"三处独立的问题叠在一起。",[26,2756,2758],{"id":2757},"一每请求重建一次签名客户端","一、每请求重建一次签名客户端",[11,2760,2761],{},"起因是压测。素材库场景下测图片端点的吞吐：",[399,2763,2766],{"className":2764,"code":2765,"language":1327},[1325],"素材库场景压测（100 用户 × 24 张图）实测 \u002Fapi\u002Fmedia 卡在约 1400 req\u002Fs，\n同等并发下 \u002Fhealth 有 4100 req\u002Fs。\n",[15,2767,2765],{"__ignoreMap":183},[11,2769,2770],{},"差了三倍。定位到签名逻辑：每次请求都新建一个 S3 客户端。",[11,2772,2773],{},"对比两种写法：",[399,2775,2778],{"className":2776,"code":2777,"language":1327},[1325],"每请求新建 client + 签名  1.223ms\u002F次 → 818 次\u002F秒\u002F核\n复用 client 只签名        0.385ms\u002F次 → 2598 次\u002F秒\u002F核\n",[15,2779,2777],{"__ignoreMap":183},[11,2781,2782],{},"818 × 2 个 api 副本约等于 1636，与实测的 1400 吻合。",[11,2784,2785,2786,2789],{},"差的那部分不在构造函数上。构造函数本身只要 0.069ms，剩下的一毫秒多花在",[488,2787,2788],{},"首次签名","：新客户端要重新解析中间件栈、区域、凭证链，复用之后这些被记住。",[11,2791,2792],{},"改法是缓存，但缓存键不是「无脑单例」：",[399,2794,2796],{"className":691,"code":2795,"language":693,"meta":183,"style":183},"签名客户端按配置缓存。素材库一屏 24 张图，每张都重建 client 时实测 1.223ms\u002F次，\n复用后 0.385ms\u002F次——快 3.2 倍。\n\n按配置做键而不是裸单例：配置换了必须重建，否则会拿旧域名的 client 继续签，\n生产改配置不生效，测试之间也会互相污染。\n",[15,2797,2798,2814,2830,2834,2839],{"__ignoreMap":183},[407,2799,2800,2803,2806,2809,2811],{"class":409,"line":410},[407,2801,2802],{"class":413},"签名客户端按配置缓存。素材库一屏 ",[407,2804,2805],{"class":476},"24",[407,2807,2808],{"class":413}," 张图，每张都重建 client 时实测 1.223ms",[407,2810,2699],{"class":417},[407,2812,2813],{"class":413},"次，\n",[407,2815,2816,2819,2821,2824,2827],{"class":409,"line":184},[407,2817,2818],{"class":413},"复用后 0.385ms",[407,2820,2699],{"class":417},[407,2822,2823],{"class":413},"次——快 ",[407,2825,2826],{"class":476},"3.2",[407,2828,2829],{"class":413}," 倍。\n",[407,2831,2832],{"class":409,"line":189},[407,2833,1827],{"emptyLinePlaceholder":202},[407,2835,2836],{"class":409,"line":452},[407,2837,2838],{"class":413},"按配置做键而不是裸单例：配置换了必须重建，否则会拿旧域名的 client 继续签，\n",[407,2840,2841],{"class":409,"line":458},[407,2842,2843],{"class":413},"生产改配置不生效，测试之间也会互相污染。\n",[11,2845,2846],{},"键包含公开域名、端点、区域、桶名、路径风格、访问凭证。反向验证过：注入一个裸单例，三条测试转红。",[11,2848,2849],{},"这一处只解决吞吐，不解决裂图。",[26,2851,2853],{"id":2852},"二前端把签名-url-当永久地址存","二、前端把签名 URL 当永久地址存",[11,2855,2856],{},"裂图的直接原因在前端。",[11,2858,2859],{},"签名 URL 的有效期是 15 分钟（900 秒）。后端每次都现签，这个行为是对的。错的是前端把签好的 URL 存进了缓存，且没有有效期：",[399,2861,2863],{"className":691,"code":2862,"language":693,"meta":183,"style":183},"\u002F**\n * 签名 URL 的缓存寿命。\n *\n * 服务端签的是 900 秒，这里只敢存一半多一点——差值是留给「拿到 URL 到图片\n * 真正加载完」这段时间的。\n * 缓存不设期限会让画布开着超过 15 分钟后素材图**集体裂**：\n * 后端每次都现签是对的，栽的是前端把签好的 URL 当永久地址存了。\n *\u002F\nconst URL_CACHE_TTL_MS = 8 * 60 * 1000;\n",[15,2864,2865,2869,2874,2878,2883,2888,2893,2898,2902],{"__ignoreMap":183},[407,2866,2867],{"class":409,"line":410},[407,2868,950],{"class":528},[407,2870,2871],{"class":409,"line":184},[407,2872,2873],{"class":528}," * 签名 URL 的缓存寿命。\n",[407,2875,2876],{"class":409,"line":189},[407,2877,1652],{"class":528},[407,2879,2880],{"class":409,"line":452},[407,2881,2882],{"class":528}," * 服务端签的是 900 秒，这里只敢存一半多一点——差值是留给「拿到 URL 到图片\n",[407,2884,2885],{"class":409,"line":458},[407,2886,2887],{"class":528}," * 真正加载完」这段时间的。\n",[407,2889,2890],{"class":409,"line":464},[407,2891,2892],{"class":528}," * 缓存不设期限会让画布开着超过 15 分钟后素材图**集体裂**：\n",[407,2894,2895],{"class":409,"line":470},[407,2896,2897],{"class":528}," * 后端每次都现签是对的，栽的是前端把签好的 URL 当永久地址存了。\n",[407,2899,2900],{"class":409,"line":480},[407,2901,970],{"class":528},[407,2903,2904,2906,2909,2911,2914,2917,2920,2922,2925],{"class":409,"line":1477},[407,2905,700],{"class":417},[407,2907,2908],{"class":476}," URL_CACHE_TTL_MS",[407,2910,706],{"class":417},[407,2912,2913],{"class":476}," 8",[407,2915,2916],{"class":417}," *",[407,2918,2919],{"class":476}," 60",[407,2921,2916],{"class":417},[407,2923,2924],{"class":476}," 1000",[407,2926,918],{"class":413},[11,2928,2929],{},"8 分钟：服务端签 15 分钟，前端只存一半多，剩下的留给加载。",[11,2931,2932,2933,1244],{},"光靠 TTL 还不够。页面可能在后台挂着很久，计时器不准；而且用户的操作序列无法预判。所以补第二个机制——",[488,2934,2935],{},"把裂图当作过期信号",[399,2937,2939],{"className":691,"code":2938,"language":693,"meta":183,"style":183},"图片加载失败时调用：作废该 URL 的缓存并重新现签一次。\n签名 URL 会过期，光靠 TTL 猜不准（页面可能在后台挂很久）。裂图本身\n就是最可靠的过期信号——收到它就重签一次，让节点自己好起来。\n同一个 URL 只重试一次，避免真·坏图把请求打成死循环。\n",[15,2940,2941,2952,2968,2973],{"__ignoreMap":183},[407,2942,2943,2946,2949],{"class":409,"line":410},[407,2944,2945],{"class":413},"图片加载失败时调用：作废该 ",[407,2947,2948],{"class":476},"URL",[407,2950,2951],{"class":413}," 的缓存并重新现签一次。\n",[407,2953,2954,2957,2959,2962,2965],{"class":409,"line":184},[407,2955,2956],{"class":413},"签名 ",[407,2958,2948],{"class":476},[407,2960,2961],{"class":413}," 会过期，光靠 ",[407,2963,2964],{"class":476},"TTL",[407,2966,2967],{"class":413}," 猜不准（页面可能在后台挂很久）。裂图本身\n",[407,2969,2970],{"class":409,"line":189},[407,2971,2972],{"class":413},"就是最可靠的过期信号——收到它就重签一次，让节点自己好起来。\n",[407,2974,2975,2978,2980],{"class":409,"line":452},[407,2976,2977],{"class":413},"同一个 ",[407,2979,2948],{"class":476},[407,2981,2982],{"class":413}," 只重试一次，避免真·坏图把请求打成死循环。\n",[11,2984,2985],{},"一个 URL 只重试一次。真的坏图不能把请求打成死循环——测试里连续上报 5 次加载失败，断言请求数不超过 2。",[26,2987,2989],{"id":2988},"三面向浏览器的链路签了-15-分钟的直链","三、面向浏览器的链路，签了 15 分钟的直链",[11,2991,2992],{},"做到这里，症状没了。但同一个坑之前已经踩过一次，这一次是第三次。",[11,2994,2995],{},"前两次都在画布上：",[399,2997,3000],{"className":2998,"code":2999,"language":1327},[1325],"线上报障：画布上素材图集体「图片加载失败」。根因是这个端点返回了\nresolveAssetUrl 签的 15 分钟 S3 直链，而不是 \u002Fapi\u002Fmedia 稳定路径。\n只断言「有 url 字段」的用例抓不到这种错，必须钉住 URL 的形态。\n",[15,3001,2999],{"__ignoreMap":183},[11,3003,3004],{},"另一次在画布运行产物：",[399,3006,3008],{"className":691,"code":3007,"language":693,"meta":183,"style":183},"-    \u002F\u002F 读时现签，复用素材那边同一个签名实现\n-    signObjectUrl: (objectKey: string) => createPrivateObjectReadUrl(objectKey),\n+    \u002F\u002F 必须是 stableAssetUrl（\u002Fapi\u002Fmedia 稳定路径、7 天有效、走后端代理），\n+    \u002F\u002F 不能是 createPrivateObjectReadUrl 签的 15 分钟 S3 直链——这条 URL 是\n+    \u002F\u002F 直接交给浏览器渲染 \u003Cimg> 的，画布开十几分钟产物图就集体裂（线上报障过）。\n+    \u002F\u002F 「读时现签」只解决了「不存旧 URL」，没解决「签出来的只活 15 分钟」。\n+    signObjectUrl: (objectKey: string) => stableAssetUrl(objectKey, ''),\n",[15,3009,3010,3018,3046,3054,3061,3068,3075],{"__ignoreMap":183},[407,3011,3012,3015],{"class":409,"line":410},[407,3013,3014],{"class":417},"-",[407,3016,3017],{"class":528},"    \u002F\u002F 读时现签，复用素材那边同一个签名实现\n",[407,3019,3020,3022,3025,3028,3031,3033,3036,3038,3040,3043],{"class":409,"line":184},[407,3021,3014],{"class":417},[407,3023,3024],{"class":554},"    signObjectUrl",[407,3026,3027],{"class":413},": (",[407,3029,3030],{"class":718},"objectKey",[407,3032,1059],{"class":417},[407,3034,3035],{"class":476}," string",[407,3037,722],{"class":413},[407,3039,520],{"class":417},[407,3041,3042],{"class":554}," createPrivateObjectReadUrl",[407,3044,3045],{"class":413},"(objectKey),\n",[407,3047,3048,3051],{"class":409,"line":189},[407,3049,3050],{"class":417},"+",[407,3052,3053],{"class":528},"    \u002F\u002F 必须是 stableAssetUrl（\u002Fapi\u002Fmedia 稳定路径、7 天有效、走后端代理），\n",[407,3055,3056,3058],{"class":409,"line":452},[407,3057,3050],{"class":417},[407,3059,3060],{"class":528},"    \u002F\u002F 不能是 createPrivateObjectReadUrl 签的 15 分钟 S3 直链——这条 URL 是\n",[407,3062,3063,3065],{"class":409,"line":458},[407,3064,3050],{"class":417},[407,3066,3067],{"class":528},"    \u002F\u002F 直接交给浏览器渲染 \u003Cimg> 的，画布开十几分钟产物图就集体裂（线上报障过）。\n",[407,3069,3070,3072],{"class":409,"line":464},[407,3071,3050],{"class":417},[407,3073,3074],{"class":528},"    \u002F\u002F 「读时现签」只解决了「不存旧 URL」，没解决「签出来的只活 15 分钟」。\n",[407,3076,3077,3079,3081,3083,3085,3087,3089,3091,3093,3096,3099,3102],{"class":409,"line":470},[407,3078,3050],{"class":417},[407,3080,3024],{"class":554},[407,3082,3027],{"class":413},[407,3084,3030],{"class":718},[407,3086,1059],{"class":417},[407,3088,3035],{"class":476},[407,3090,722],{"class":413},[407,3092,520],{"class":417},[407,3094,3095],{"class":554}," stableAssetUrl",[407,3097,3098],{"class":413},"(objectKey, ",[407,3100,3101],{"class":429},"''",[407,3103,3104],{"class":413},"),\n",[11,3106,3107,3108,3111],{},"最后那句是这次的实质收获。「读时现签」听起来已经解决了过期问题——每次读都是新的。但签出来的东西只活 15 分钟，而浏览器渲染 ",[15,3109,3110],{},"\u003Cimg>"," 的 URL 会被页面持有到下一次刷新。两个时间尺度不匹配。",[11,3113,3114],{},"真正的修法是给「吐给浏览器」这条路单独一档地址，不再用签名直链，改走后端代理的稳定路径：",[399,3116,3118],{"className":691,"code":3117,"language":693,"meta":183,"style":183},"\u002F\u002F 稳定媒体 URL：给前端 \u003Cimg>\u002F\u003Cvideo> 用的持久地址，替代 15 分钟就过期的 S3 签名 URL——\n\u002F\u002F 页面长驻后图片重新加载 403 裂图的根治方案。请求到达 \u002Fapi\u002Fmedia 验签后 302 到读时现签的 S3 URL。\n\u002F\u002F HMAC 即凭证（与 slides raw 下载、local-business-promo blob 同范式，域分隔前缀防跨用），\n\u002F\u002F 不依赖登录态：img 标签发不出 Authorization 头。\n\u002F\u002F\n\u002F\u002F TTL 7 天：覆盖任何真实的页面停留场景。\n\u002F\u002F exp 对齐到天窗口：同一对象同一天内产出字节级相同的 URL，浏览器缓存可命中；\n\u002F\u002F 任意时刻拿到的 URL 剩余有效期至少 6 天，不存在「刚拿到就过期」。\nexport const MEDIA_URL_TTL_MS = 7 * 24 * 60 * 60 * 1000;\n",[15,3119,3120,3125,3130,3135,3140,3145,3150,3155,3160],{"__ignoreMap":183},[407,3121,3122],{"class":409,"line":410},[407,3123,3124],{"class":528},"\u002F\u002F 稳定媒体 URL：给前端 \u003Cimg>\u002F\u003Cvideo> 用的持久地址，替代 15 分钟就过期的 S3 签名 URL——\n",[407,3126,3127],{"class":409,"line":184},[407,3128,3129],{"class":528},"\u002F\u002F 页面长驻后图片重新加载 403 裂图的根治方案。请求到达 \u002Fapi\u002Fmedia 验签后 302 到读时现签的 S3 URL。\n",[407,3131,3132],{"class":409,"line":189},[407,3133,3134],{"class":528},"\u002F\u002F HMAC 即凭证（与 slides raw 下载、local-business-promo blob 同范式，域分隔前缀防跨用），\n",[407,3136,3137],{"class":409,"line":452},[407,3138,3139],{"class":528},"\u002F\u002F 不依赖登录态：img 标签发不出 Authorization 头。\n",[407,3141,3142],{"class":409,"line":458},[407,3143,3144],{"class":528},"\u002F\u002F\n",[407,3146,3147],{"class":409,"line":464},[407,3148,3149],{"class":528},"\u002F\u002F TTL 7 天：覆盖任何真实的页面停留场景。\n",[407,3151,3152],{"class":409,"line":470},[407,3153,3154],{"class":528},"\u002F\u002F exp 对齐到天窗口：同一对象同一天内产出字节级相同的 URL，浏览器缓存可命中；\n",[407,3156,3157],{"class":409,"line":480},[407,3158,3159],{"class":528},"\u002F\u002F 任意时刻拿到的 URL 剩余有效期至少 6 天，不存在「刚拿到就过期」。\n",[407,3161,3162,3164,3167,3170,3172,3175,3177,3180,3182,3184,3186,3188,3190,3192],{"class":409,"line":1477},[407,3163,1045],{"class":417},[407,3165,3166],{"class":417}," const",[407,3168,3169],{"class":476}," MEDIA_URL_TTL_MS",[407,3171,706],{"class":417},[407,3173,3174],{"class":476}," 7",[407,3176,2916],{"class":417},[407,3178,3179],{"class":476}," 24",[407,3181,2916],{"class":417},[407,3183,2919],{"class":476},[407,3185,2916],{"class":417},[407,3187,2919],{"class":476},[407,3189,2916],{"class":417},[407,3191,2924],{"class":476},[407,3193,918],{"class":413},[11,3195,3196,3197,3200,3201,3204],{},"路径形态是 ",[15,3198,3199],{},"\u002Fapi\u002Fmedia?key=…&exp=…&sig=…","。签名用 HMAC，不依赖登录态——",[15,3202,3203],{},"img"," 标签发不出 Authorization 头。",[11,3206,3207,3210],{},[15,3208,3209],{},"exp"," 对齐到天窗口这一条值得单独说：同一对象同一天内产出的 URL 字节级相同，浏览器的缓存能命中；而因为窗口按天滚动，任何时刻拿到的 URL 剩余有效期至少 6 天。",[26,3212,3214],{"id":3213},"四把规矩变成测试","四、把规矩变成测试",[11,3216,3217],{},"三档地址的分工写进了代码注释，放在存储工具的末尾：",[399,3219,3222],{"className":3220,"code":3221,"language":1327},[1325],"吐给浏览器（渲染 \u003Cimg>\u002F\u003Cvideo>）→ stableAssetUrl：返回 \u002Fapi\u002Fmedia 稳定路径，\n7 天有效，根治「页面长驻后签名过期裂图」\n交给上游拉取 → resolveUpstreamAssetUrl：6 小时\n本进程内立刻用 → resolveAssetUrl：15 分钟\n",[15,3223,3221],{"__ignoreMap":183},[11,3225,3226],{},"注释拦不住。这事已经栽过两次，两次的症状一模一样：",[1205,3228,3229],{},[11,3230,3231],{},"两次的症状一模一样：图刚打开好好的，画布放十几分钟就集体「图片加载失败」，刷新只是换来另一条同样 15 分钟后会死的链接，排查时极容易误判成前端缓存问题。",[11,3233,3234],{},"所以加了一条源码级断言。它不是接口测试——它读源码字符串，剥掉注释之后断言不该出现的函数名：",[399,3236,3238],{"className":691,"code":3237,"language":693,"meta":183,"style":183},"\u002F** 只活 15 分钟、且指向对象存储域名的签名函数——绝不能出现在面向浏览器的链路里 *\u002F\nconst SHORT_LIVED_SIGNERS = [\"createPrivateObjectReadUrl\", \"resolveAssetUrl\"];\n\n\u002F** 这些文件产出的 URL 会被前端直接塞进 \u003Cimg>\u002F\u003Cvideo> *\u002F\nconst BROWSER_FACING_FILES = [\n  \"canvas-flow\u002Frun-routes.ts\",\n  \"canvas-adapter\u002Fmaterial-url-route.ts\",\n];\n",[15,3239,3240,3245,3268,3272,3277,3289,3297,3304],{"__ignoreMap":183},[407,3241,3242],{"class":409,"line":410},[407,3243,3244],{"class":528},"\u002F** 只活 15 分钟、且指向对象存储域名的签名函数——绝不能出现在面向浏览器的链路里 *\u002F\n",[407,3246,3247,3249,3252,3254,3257,3260,3262,3265],{"class":409,"line":184},[407,3248,700],{"class":417},[407,3250,3251],{"class":476}," SHORT_LIVED_SIGNERS",[407,3253,706],{"class":417},[407,3255,3256],{"class":413}," [",[407,3258,3259],{"class":429},"\"createPrivateObjectReadUrl\"",[407,3261,433],{"class":413},[407,3263,3264],{"class":429},"\"resolveAssetUrl\"",[407,3266,3267],{"class":413},"];\n",[407,3269,3270],{"class":409,"line":189},[407,3271,1827],{"emptyLinePlaceholder":202},[407,3273,3274],{"class":409,"line":452},[407,3275,3276],{"class":528},"\u002F** 这些文件产出的 URL 会被前端直接塞进 \u003Cimg>\u002F\u003Cvideo> *\u002F\n",[407,3278,3279,3281,3284,3286],{"class":409,"line":458},[407,3280,700],{"class":417},[407,3282,3283],{"class":476}," BROWSER_FACING_FILES",[407,3285,706],{"class":417},[407,3287,3288],{"class":413}," [\n",[407,3290,3291,3294],{"class":409,"line":464},[407,3292,3293],{"class":429},"  \"canvas-flow\u002Frun-routes.ts\"",[407,3295,3296],{"class":413},",\n",[407,3298,3299,3302],{"class":409,"line":470},[407,3300,3301],{"class":429},"  \"canvas-adapter\u002Fmaterial-url-route.ts\"",[407,3303,3296],{"class":413},[407,3305,3306],{"class":409,"line":480},[407,3307,3267],{"class":413},[11,3309,3310,3311,781],{},"再加一条断言钉住 URL 的形态：产出的地址里不能出现 ",[15,3312,3313],{},"X-Amz-Signature",[11,3315,3316,3317,3320],{},"这是这次修法里唯一带点非常规的做法：",[488,3318,3319],{},"用测试来守一条架构约定","。普通的测试守行为，这条守的是「哪个函数可以在哪里被调用」。",[11,3322,3323],{},"之所以需要它，是因为这类错误的三个特征叠在一起：编译期查不出（函数签名都一样）、单测查不出（旧用例只断言「有 url 字段」）、运行时查不出（图在头十几分钟是好的）。三个环节都不拦，只能靠一条读源码的断言。",[26,3325,3327],{"id":3326},"五时间尺度","五、时间尺度",[11,3329,3330,3331,781],{},"回头看，这三处其实是同一个问题在三个层面的表现：",[488,3332,3333],{},"链接的有效期和它的使用方式不匹配",[123,3335,3336,3339,3342],{},[126,3337,3338],{},"签名客户端：复用的时间尺度是「进程生命周期」，实现给的是「单次请求」。",[126,3340,3341],{},"前端缓存：URL 被持有的时间尺度是「页面停留时长」，缓存给的是「无限」。",[126,3343,3344],{},"直链：浏览器的取用方式是「随时重新加载」，签名给的是 15 分钟。",[11,3346,3347],{},"修完之后的取值是有依据的，不是拍的：",[1708,3349,3350,3363],{},[1711,3351,3352],{},[1714,3353,3354,3357,3360],{},[1717,3355,3356],{},"用途",[1717,3358,3359],{},"有效期",[1717,3361,3362],{},"依据",[1730,3364,3365,3376,3387],{},[1714,3366,3367,3370,3373],{},[1735,3368,3369],{},"本进程立刻取用",[1735,3371,3372],{},"15 分钟",[1735,3374,3375],{},"签完就 fetch，够用且泄露窗口小",[1714,3377,3378,3381,3384],{},[1735,3379,3380],{},"交给上游排队拉取",[1735,3382,3383],{},"6 小时",[1735,3385,3386],{},"实测排队 10~25 分钟才轮到上游下载",[1714,3388,3389,3392,3395],{},[1735,3390,3391],{},"浏览器渲染",[1735,3393,3394],{},"7 天（按天对齐）",[1735,3396,3397],{},"覆盖任意页面停留，且同一天 URL 相同可命中缓存",[11,3399,3400],{},"最后一条还有一层：它不走签名直链，走后端代理。这换来两个好处——不受对象存储域名可达性影响，也不受签名有效期约束。代价是流量过后端，但素材图不走这条路的代价更大。",[1267,3402,3403],{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}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 .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":183,"searchDepth":184,"depth":184,"links":3405},[3406,3407,3408,3409,3410],{"id":2757,"depth":184,"text":2758},{"id":2852,"depth":184,"text":2853},{"id":2988,"depth":184,"text":2989},{"id":3213,"depth":184,"text":3214},{"id":3326,"depth":184,"text":3327},"2026-08-28",{},"\u002F2026-08-28-fa-023",{"title":2743,"description":2748},"FA-023","2026-08-28-FA-023-图是好的链接死了","画布上的素材图集体裂开，刷新只换来另一条同样会死的链接。三处独立问题：签名客户端每请求重建、前端把签名 URL 当永久地址存、面向浏览器的链路签了 15 分钟的直链。",[3419,3420,3421,3422,1290,3423],"S3","签名URL","缓存","性能","对象存储","LNHg4QB5lztHWhSI-OvrOM4XrnhLpHHPMlNgjTOxMA4",{"id":3426,"title":3427,"body":3428,"column":355,"date":4306,"description":3432,"extension":199,"hero_image":200,"meta":4307,"navigation":202,"path":4308,"seo":4309,"series_id":4310,"severity":200,"stem":4311,"summary":4312,"tags":4313,"__hash__":4321},"posts\u002F2026-08-24-FA-022-上游没报错片子不对.md","上游没报错，片子不对",{"type":8,"value":3429,"toc":4297},[3430,3433,3436,3439,3443,3446,3547,3553,3556,3562,3569,3572,3575,3579,3582,3589,3646,3661,3664,3849,3859,3865,3869,3872,3875,3878,3881,3890,3893,3896,3948,3951,3954,3971,3975,3978,3984,3991,3994,3997,4065,4068,4075,4078,4082,4085,4092,4095,4150,4153,4156,4160,4163,4166,4171,4174,4212,4215,4218,4241,4244,4247,4250,4253,4273,4280,4287,4294],[11,3431,3432],{},"视频链路的失败大多不响。",[11,3434,3435],{},"接口返回 200，任务状态是「出片中」，进度条在动。用户看到的是「这次没出好，再来一次」——因为重试有时候真的就好了。这类问题最难的地方在于它不像故障，像不稳定。",[11,3437,3438],{},"这一轮把视频链路从头到尾过了一遍，翻出六处不报错的地方。",[26,3440,3442],{"id":3441},"一渠道名猜不出来于是白提交一次","一、渠道名猜不出来，于是白提交一次",[11,3444,3445],{},"成本路由的第一步是知道当前任务跑在哪个上游渠道上。最初的实现是从任务 ID 猜：",[399,3447,3449],{"className":691,"code":3448,"language":693,"meta":183,"style":183},"\u002F** providerTaskId 前缀反推提交时用的上游渠道模型（渠道网关的 task_id 形如 `{model}_xxx`）。 *\u002F\nexport function upstreamModelFromTaskId(providerTaskId: string): string | null {\n  const idx = providerTaskId.lastIndexOf(\"_\");\n  return idx > 0 ? providerTaskId.slice(0, idx) : null;\n}\n",[15,3450,3451,3456,3488,3510,3543],{"__ignoreMap":183},[407,3452,3453],{"class":409,"line":410},[407,3454,3455],{"class":528},"\u002F** providerTaskId 前缀反推提交时用的上游渠道模型（渠道网关的 task_id 形如 `{model}_xxx`）。 *\u002F\n",[407,3457,3458,3460,3462,3465,3467,3470,3472,3474,3476,3478,3480,3483,3486],{"class":409,"line":184},[407,3459,1045],{"class":417},[407,3461,1048],{"class":417},[407,3463,3464],{"class":554}," upstreamModelFromTaskId",[407,3466,586],{"class":413},[407,3468,3469],{"class":718},"providerTaskId",[407,3471,1059],{"class":417},[407,3473,3035],{"class":476},[407,3475,1065],{"class":413},[407,3477,1059],{"class":417},[407,3479,3035],{"class":476},[407,3481,3482],{"class":417}," |",[407,3484,3485],{"class":476}," null",[407,3487,523],{"class":413},[407,3489,3490,3492,3495,3497,3500,3503,3505,3508],{"class":409,"line":189},[407,3491,892],{"class":417},[407,3493,3494],{"class":476}," idx",[407,3496,706],{"class":417},[407,3498,3499],{"class":413}," providerTaskId.",[407,3501,3502],{"class":554},"lastIndexOf",[407,3504,586],{"class":413},[407,3506,3507],{"class":429},"\"_\"",[407,3509,662],{"class":413},[407,3511,3512,3514,3517,3520,3522,3525,3527,3530,3532,3534,3537,3539,3541],{"class":409,"line":452},[407,3513,2142],{"class":417},[407,3515,3516],{"class":413}," idx ",[407,3518,3519],{"class":417},">",[407,3521,1118],{"class":476},[407,3523,3524],{"class":417}," ?",[407,3526,3499],{"class":413},[407,3528,3529],{"class":554},"slice",[407,3531,586],{"class":413},[407,3533,648],{"class":476},[407,3535,3536],{"class":413},", idx) ",[407,3538,1059],{"class":417},[407,3540,3485],{"class":476},[407,3542,918],{"class":413},[407,3544,3545],{"class":409,"line":458},[407,3546,483],{"class":413},[11,3548,3549,3550,781],{},"依据来自上游文档的示例：",[15,3551,3552],{},"videos-mini_xxx",[11,3554,3555],{},"实测拿到的任务 ID 长这样：",[399,3557,3560],{"className":3558,"code":3559,"language":1327},[1325],"task_O6hOdv…\nvid_a11d01…\n",[15,3561,3559],{"__ignoreMap":183},[11,3563,3564,3565,3568],{},"两个都没有渠道名。",[15,3566,3567],{},"lastIndexOf(\"_\")"," 要么取到一整个随机串，要么取不到下划线。判断恒为 false，于是「是否已在官方渠道」永远答错——官方渠道失败之后，会再白提交一次官方。",[11,3570,3571],{},"改法是别猜。提交时把实际用的上游模型显式回传并落库。",[11,3573,3574],{},"这条改动顺带解决了一个更隐蔽的问题：轮询每一轮会用返回的整个 payload 覆盖本地记录，把渠道字段冲掉。改成用局部变量持有，在覆盖时保留。",[26,3576,3578],{"id":3577},"二地址是相对的","二、地址是相对的",[11,3580,3581],{},"某一天开始，某条渠道的出片任务全部停在「出片中」，而上游那边片子早就出好了。",[11,3583,3584,3585,3588],{},"那批任务的特征是任务 ID 以 ",[15,3586,3587],{},"job_"," 开头。上游在这个渠道上把三个返回字段一并改成了相对路径：",[399,3590,3594],{"className":3591,"code":3592,"language":3593,"meta":183,"style":183},"language-json shiki shiki-themes github-light github-dark","{\n  \"url\": \"\u002Fv1\u002Fvideos\u002Fjob_c47bb21f...\u002Fcontent\",\n  \"video_url\": \"\u002Fv1\u002Fvideos\u002Fjob_c47bb21f...\u002Fcontent\",\n  \"metadata\": { \"video_url\": \"\u002Fv1\u002Fvideos\u002Fjob_c47bb21f...\u002Fcontent\" }\n}\n","json",[15,3595,3596,3600,3613,3624,3642],{"__ignoreMap":183},[407,3597,3598],{"class":409,"line":410},[407,3599,421],{"class":413},[407,3601,3602,3605,3608,3611],{"class":409,"line":184},[407,3603,3604],{"class":476},"  \"url\"",[407,3606,3607],{"class":413},": ",[407,3609,3610],{"class":429},"\"\u002Fv1\u002Fvideos\u002Fjob_c47bb21f...\u002Fcontent\"",[407,3612,3296],{"class":413},[407,3614,3615,3618,3620,3622],{"class":409,"line":189},[407,3616,3617],{"class":476},"  \"video_url\"",[407,3619,3607],{"class":413},[407,3621,3610],{"class":429},[407,3623,3296],{"class":413},[407,3625,3626,3629,3632,3635,3637,3639],{"class":409,"line":452},[407,3627,3628],{"class":476},"  \"metadata\"",[407,3630,3631],{"class":413},": { ",[407,3633,3634],{"class":476},"\"video_url\"",[407,3636,3607],{"class":413},[407,3638,3610],{"class":429},[407,3640,3641],{"class":413}," }\n",[407,3643,3644],{"class":409,"line":458},[407,3645,483],{"class":413},[11,3647,3648,3649,3652,3653,3656,3657,3660],{},"把相对路径交给 fetch 会抛 ",[15,3650,3651],{},"Failed to parse URL","，轮询每次 502，而任务状态由上游的 ",[15,3654,3655],{},"status"," 字段驱动，那个字段是 ",[15,3658,3659],{},"completed","。本地看到的是「拉取失败」，于是状态不推进。",[11,3662,3663],{},"修法简单：按状态端点的 origin 补成绝对地址。",[399,3665,3667],{"className":691,"code":3666,"language":693,"meta":183,"style":183},"\u002F\u002F 按状态端点的 origin 补成绝对地址（函数名已改写，逻辑原样）\nfunction absolutizeUpstreamUrl(url: string | null, baseUrl?: string): string | null {\n  if (!url) return null;\n  if (\u002F^https?:\\\u002F\\\u002F\u002Fi.test(url)) return url;\n  if (!baseUrl) return null;\n  try {\n    return new URL(url, baseUrl).toString();\n  } catch {\n    return null;\n  }\n}\n",[15,3668,3669,3674,3717,3738,3779,3796,3803,3823,3833,3841,3845],{"__ignoreMap":183},[407,3670,3671],{"class":409,"line":410},[407,3672,3673],{"class":528},"\u002F\u002F 按状态端点的 origin 补成绝对地址（函数名已改写，逻辑原样）\n",[407,3675,3676,3679,3682,3684,3687,3689,3691,3693,3695,3697,3700,3703,3705,3707,3709,3711,3713,3715],{"class":409,"line":184},[407,3677,3678],{"class":417},"function",[407,3680,3681],{"class":554}," absolutizeUpstreamUrl",[407,3683,586],{"class":413},[407,3685,3686],{"class":718},"url",[407,3688,1059],{"class":417},[407,3690,3035],{"class":476},[407,3692,3482],{"class":417},[407,3694,3485],{"class":476},[407,3696,433],{"class":413},[407,3698,3699],{"class":718},"baseUrl",[407,3701,3702],{"class":417},"?:",[407,3704,3035],{"class":476},[407,3706,1065],{"class":413},[407,3708,1059],{"class":417},[407,3710,3035],{"class":476},[407,3712,3482],{"class":417},[407,3714,3485],{"class":476},[407,3716,523],{"class":413},[407,3718,3719,3722,3725,3728,3731,3734,3736],{"class":409,"line":189},[407,3720,3721],{"class":417},"  if",[407,3723,3724],{"class":413}," (",[407,3726,3727],{"class":417},"!",[407,3729,3730],{"class":413},"url) ",[407,3732,3733],{"class":417},"return",[407,3735,3485],{"class":476},[407,3737,918],{"class":413},[407,3739,3740,3742,3744,3746,3749,3752,3754,3756,3760,3762,3765,3768,3771,3774,3776],{"class":409,"line":452},[407,3741,3721],{"class":417},[407,3743,3724],{"class":413},[407,3745,2699],{"class":429},[407,3747,3748],{"class":417},"^",[407,3750,3751],{"class":2671},"https",[407,3753,514],{"class":417},[407,3755,1059],{"class":2671},[407,3757,3759],{"class":3758},"snhLl","\\\u002F\\\u002F",[407,3761,2699],{"class":429},[407,3763,3764],{"class":417},"i",[407,3766,3767],{"class":413},".",[407,3769,3770],{"class":554},"test",[407,3772,3773],{"class":413},"(url)) ",[407,3775,3733],{"class":417},[407,3777,3778],{"class":413}," url;\n",[407,3780,3781,3783,3785,3787,3790,3792,3794],{"class":409,"line":458},[407,3782,3721],{"class":417},[407,3784,3724],{"class":413},[407,3786,3727],{"class":417},[407,3788,3789],{"class":413},"baseUrl) ",[407,3791,3733],{"class":417},[407,3793,3485],{"class":476},[407,3795,918],{"class":413},[407,3797,3798,3801],{"class":409,"line":464},[407,3799,3800],{"class":417},"  try",[407,3802,523],{"class":413},[407,3804,3805,3808,3811,3814,3817,3820],{"class":409,"line":470},[407,3806,3807],{"class":417},"    return",[407,3809,3810],{"class":417}," new",[407,3812,3813],{"class":554}," URL",[407,3815,3816],{"class":413},"(url, baseUrl).",[407,3818,3819],{"class":554},"toString",[407,3821,3822],{"class":413},"();\n",[407,3824,3825,3828,3831],{"class":409,"line":480},[407,3826,3827],{"class":413},"  } ",[407,3829,3830],{"class":417},"catch",[407,3832,523],{"class":413},[407,3834,3835,3837,3839],{"class":409,"line":1477},[407,3836,3807],{"class":417},[407,3838,3485],{"class":476},[407,3840,918],{"class":413},[407,3842,3843],{"class":409,"line":1483},[407,3844,563],{"class":413},[407,3846,3847],{"class":409,"line":2139},[407,3848,483],{"class":413},[11,3850,3851,3852,3854,3855,3858],{},"同一个字段还有一处更早的坑：状态刚翻 ",[15,3853,3659],{}," 的那一两秒里，中转端点的下载地址还没准备好，实测下载时刻比 ",[15,3856,3857],{},"completed_at"," 早 1.4 秒，抢着下载会吃 400。改成优先取元数据里的 CDN 直链。",[11,3860,3861,3862,781],{},"这两处合起来说明一件事：",[488,3863,3864],{},"上游返回的「完成」不等于「可以取货」",[26,3866,3868],{"id":3867},"三参考图的签名等不到排队结束","三、参考图的签名，等不到排队结束",[11,3870,3871],{},"这一处是最难查的。",[11,3873,3874],{},"用户反馈：参考图有时候生效，有时候不生效。同一张图，同一段提示词，跑两次结果不一样。",[11,3876,3877],{},"根因在签名有效期。交给上游拉取的参考图用的是 15 分钟签名（900 秒），而这个时长是按「浏览器加载一张图」的场景定的——够用。",[11,3879,3880],{},"但上游不是立刻拉图。出片任务要排队，然后才轮到它去下载参考素材。实测：",[1205,3882,3883],{},[11,3884,3885,3886,3889],{},"出片任务要排队 + 生成，实测 10~25 分钟才轮到上游真去拉参考图，15 分钟的签名那时早就过期了。上游拉不到图",[488,3887,3888],{},"不会报错","，只会静默按纯文本生成——表现就是「参考图时灵时不灵」，极难查。",[11,3891,3892],{},"上游的行为是：拉不到就按没有参考图处理。不报错，不降级提示，直接出一版纯文本结果的片子。",[11,3894,3895],{},"修法是给「交给上游」这条路单独一档签名，6 小时：",[399,3897,3899],{"className":691,"code":3898,"language":693,"meta":183,"style":183},"\u002F**\n * 交给上游 fetch 的对象的有效期：6 小时。\n * 出片任务要排队 + 生成，实测 10~25 分钟才轮到上游真去拉参考图……\n * 不要拿它签要吐给浏览器的链接：那条不需要这么长，白白放大签名泄露窗口。\n *\u002F\nexport const UPSTREAM_READ_TTL_SECONDS = 6 * 60 * 60;\n",[15,3900,3901,3905,3910,3915,3920,3924],{"__ignoreMap":183},[407,3902,3903],{"class":409,"line":410},[407,3904,950],{"class":528},[407,3906,3907],{"class":409,"line":184},[407,3908,3909],{"class":528}," * 交给上游 fetch 的对象的有效期：6 小时。\n",[407,3911,3912],{"class":409,"line":189},[407,3913,3914],{"class":528}," * 出片任务要排队 + 生成，实测 10~25 分钟才轮到上游真去拉参考图……\n",[407,3916,3917],{"class":409,"line":452},[407,3918,3919],{"class":528}," * 不要拿它签要吐给浏览器的链接：那条不需要这么长，白白放大签名泄露窗口。\n",[407,3921,3922],{"class":409,"line":458},[407,3923,970],{"class":528},[407,3925,3926,3928,3930,3933,3935,3938,3940,3942,3944,3946],{"class":409,"line":464},[407,3927,1045],{"class":417},[407,3929,3166],{"class":417},[407,3931,3932],{"class":476}," UPSTREAM_READ_TTL_SECONDS",[407,3934,706],{"class":417},[407,3936,3937],{"class":476}," 6",[407,3939,2916],{"class":417},[407,3941,2919],{"class":476},[407,3943,2916],{"class":417},[407,3945,2919],{"class":476},[407,3947,918],{"class":413},[11,3949,3950],{},"这段注释里的第二句是边界。签名 URL 是写在链接里的通行证，拿到就能读。有效期必须有天花板，但不能用同一个值套两种场景：浏览器那条 15 分钟够加载，上游那条要按排队时长算。",[11,3952,3953],{},"同一条链路上还有一处漏网的：画布视频节点的参考图仍在用 15 分钟档。修的时候加了注入点，让测试能断言「用的是长签、不是短签」：",[399,3955,3957],{"className":691,"code":3956,"language":693,"meta":183,"style":183},"it(\"video node: 参考 URL 用上游长签——15 分钟短签在上游排队后下载时已过期（线上事故）\")\n",[15,3958,3959],{"__ignoreMap":183},[407,3960,3961,3963,3965,3968],{"class":409,"line":410},[407,3962,583],{"class":554},[407,3964,586],{"class":413},[407,3966,3967],{"class":429},"\"video node: 参考 URL 用上游长签——15 分钟短签在上游排队后下载时已过期（线上事故）\"",[407,3969,3970],{"class":413},")\n",[26,3972,3974],{"id":3973},"四熔断会自愈判死没有意义","四、熔断会自愈，判死没有意义",[11,3976,3977],{},"线上出现过这样的错误：",[399,3979,3982],{"className":3980,"code":3981,"language":1327},[1325],"status=503  code=all_channels_circuit_broken\n模型 MiniMax-H3 的所有渠道当前不可用（熔断保护已触发）\n",[15,3983,3981],{"__ignoreMap":183},[11,3985,3986,3987,3990],{},"网关侧的全渠道熔断。它和普通 5xx 的区别在于",[488,3988,3989],{},"它会自愈","：网关探到上游恢复就自动合闸。",[11,3992,3993],{},"原实现把这类错误当成永久失败，用户看到报错，干等着手点重试。",[11,3995,3996],{},"改成转后台自动重试，接口返回 202：",[399,3998,4000],{"className":691,"code":3999,"language":693,"meta":183,"style":183},"\u002F**\n * 全渠道熔断后的后台重试参数。\n * 窗口 5 分钟：熔断通常几十秒到几分钟自愈，等太久不如让用户换模型；\n * 间隔 20 秒：太密只会一直撞在熔断上白烧网关配额。\n *\u002F\nconst CIRCUIT_RETRY_WINDOW_MS = 5 * 60 * 1000;\nconst CIRCUIT_RETRY_INTERVAL_MS = 20 * 1000;\n",[15,4001,4002,4006,4011,4016,4021,4025,4047],{"__ignoreMap":183},[407,4003,4004],{"class":409,"line":410},[407,4005,950],{"class":528},[407,4007,4008],{"class":409,"line":184},[407,4009,4010],{"class":528}," * 全渠道熔断后的后台重试参数。\n",[407,4012,4013],{"class":409,"line":189},[407,4014,4015],{"class":528}," * 窗口 5 分钟：熔断通常几十秒到几分钟自愈，等太久不如让用户换模型；\n",[407,4017,4018],{"class":409,"line":452},[407,4019,4020],{"class":528}," * 间隔 20 秒：太密只会一直撞在熔断上白烧网关配额。\n",[407,4022,4023],{"class":409,"line":458},[407,4024,970],{"class":528},[407,4026,4027,4029,4032,4034,4037,4039,4041,4043,4045],{"class":409,"line":464},[407,4028,700],{"class":417},[407,4030,4031],{"class":476}," CIRCUIT_RETRY_WINDOW_MS",[407,4033,706],{"class":417},[407,4035,4036],{"class":476}," 5",[407,4038,2916],{"class":417},[407,4040,2919],{"class":476},[407,4042,2916],{"class":417},[407,4044,2924],{"class":476},[407,4046,918],{"class":413},[407,4048,4049,4051,4054,4056,4059,4061,4063],{"class":409,"line":470},[407,4050,700],{"class":417},[407,4052,4053],{"class":476}," CIRCUIT_RETRY_INTERVAL_MS",[407,4055,706],{"class":417},[407,4057,4058],{"class":476}," 20",[407,4060,2916],{"class":417},[407,4062,2924],{"class":476},[407,4064,918],{"class":413},[11,4066,4067],{},"超窗才落失败，并给出下一步：提示用户可以在设置里换其它视频模型绕开。",[11,4069,4070,4071,4074],{},"计费安全靠的是每轮都是完整的一轮「预留 → 提交 →（失败则退款）」，每次新 ",[15,4072,4073],{},"operationId","。重试多少轮都不会重复扣费或挂住点数。",[11,4076,4077],{},"遇到非熔断错误或余额不足立即停手，不做无意义重试。",[26,4079,4081],{"id":4080},"五上游欠费报成了用户余额不足","五、上游欠费，报成了用户余额不足",[11,4083,4084],{},"上游渠道商返回的原话是「账号积分不足」。",[11,4086,4087,4088,4091],{},"这里说的是",[488,4089,4090],{},"渠道商那边","的账号。原实现直接透传给用户，用户看到「积分不足」就去充值页面，发现自己的余额好好的。",[11,4093,4094],{},"分成两句话：",[399,4096,4098],{"className":691,"code":4097,"language":693,"meta":183,"style":183},"if (\u002F积分不足|余额不足|quota|insufficient\u002Fi.test(raw)) {\n  return \"上游视频服务账号异常，各渠道均未能出片。这不是你的视频点问题，我们已记录，请稍后重试\";\n}\n",[15,4099,4100,4137,4146],{"__ignoreMap":183},[407,4101,4102,4104,4106,4108,4111,4113,4116,4118,4121,4123,4126,4128,4130,4132,4134],{"class":409,"line":410},[407,4103,875],{"class":417},[407,4105,3724],{"class":413},[407,4107,2699],{"class":429},[407,4109,4110],{"class":2671},"积分不足",[407,4112,2675],{"class":417},[407,4114,4115],{"class":2671},"余额不足",[407,4117,2675],{"class":417},[407,4119,4120],{"class":2671},"quota",[407,4122,2675],{"class":417},[407,4124,4125],{"class":2671},"insufficient",[407,4127,2699],{"class":429},[407,4129,3764],{"class":417},[407,4131,3767],{"class":413},[407,4133,3770],{"class":554},[407,4135,4136],{"class":413},"(raw)) {\n",[407,4138,4139,4141,4144],{"class":409,"line":184},[407,4140,2142],{"class":417},[407,4142,4143],{"class":429}," \"上游视频服务账号异常，各渠道均未能出片。这不是你的视频点问题，我们已记录，请稍后重试\"",[407,4145,918],{"class":413},[407,4147,4148],{"class":409,"line":189},[407,4149,483],{"class":413},[11,4151,4152],{},"用户侧余额不足是另一条分支，文案单独维护。",[11,4154,4155],{},"这条错误的处理原则后来推广到了前端：余额不足的提示改用中文，并给出缺口，不透传后端消息——后端文案变动不该影响用户看到的提示。",[26,4157,4159],{"id":4158},"六便宜渠道先原地重试再切官方","六、便宜渠道先原地重试，再切官方",[11,4161,4162],{},"同档模型在不同渠道上价格差得多。以 seedance-2 的 1080p 为例，便宜渠道是官方渠道的三分之一价。",[11,4164,4165],{},"原实现遇到便宜渠道失败就立刻切官方，把成本优势白扔了。但便宜渠道的失败多是暂时的：",[1205,4167,4168],{},[11,4169,4170],{},"「账号积分不足」是渠道商侧欠费、充值后即恢复，风控也是一阵一阵的。",[11,4172,4173],{},"改成两级挽救，顺序不能反：",[399,4175,4177],{"className":691,"code":4176,"language":693,"meta":183,"style":183},"\u002F** 便宜渠道生成失败后原地重试的次数与间隔——耗尽才允许切官方渠道。 *\u002F\nconst DEFAULT_CHEAP_RETRY_MAX = 10;\nconst DEFAULT_CHEAP_RETRY_DELAY_MS = 5_000;\n",[15,4178,4179,4184,4198],{"__ignoreMap":183},[407,4180,4181],{"class":409,"line":410},[407,4182,4183],{"class":528},"\u002F** 便宜渠道生成失败后原地重试的次数与间隔——耗尽才允许切官方渠道。 *\u002F\n",[407,4185,4186,4188,4191,4193,4196],{"class":409,"line":184},[407,4187,700],{"class":417},[407,4189,4190],{"class":476}," DEFAULT_CHEAP_RETRY_MAX",[407,4192,706],{"class":417},[407,4194,4195],{"class":476}," 10",[407,4197,918],{"class":413},[407,4199,4200,4202,4205,4207,4210],{"class":409,"line":189},[407,4201,700],{"class":417},[407,4203,4204],{"class":476}," DEFAULT_CHEAP_RETRY_DELAY_MS",[407,4206,706],{"class":417},[407,4208,4209],{"class":476}," 5_000",[407,4211,918],{"class":413},[11,4213,4214],{},"已经在官方渠道上的任务，两级都不做——同一个账号换不出新结果，直接走失败退款。",[11,4216,4217],{},"后期这套退避改成了按成本升序逐级走，不再手工连链：",[399,4219,4221],{"className":691,"code":4220,"language":693,"meta":183,"style":183},"\u002F**\n * 退避顺序由成本决定而不是由 fallbackChannelId 手工连链：配置表里已经有全部价格，\n * 再维护一份链表只会随调价腐烂。\n *\u002F\n",[15,4222,4223,4227,4232,4237],{"__ignoreMap":183},[407,4224,4225],{"class":409,"line":410},[407,4226,950],{"class":528},[407,4228,4229],{"class":409,"line":184},[407,4230,4231],{"class":528}," * 退避顺序由成本决定而不是由 fallbackChannelId 手工连链：配置表里已经有全部价格，\n",[407,4233,4234],{"class":409,"line":189},[407,4235,4236],{"class":528}," * 再维护一份链表只会随调价腐烂。\n",[407,4238,4239],{"class":409,"line":452},[407,4240,970],{"class":528},[11,4242,4243],{},"上限 3 跳。理由是阶梯本身可能有 4~5 条渠道，不设上限会让单个任务在提交或轮询期把整条链试穿，时间成本远超省下来的钱。三跳足以从最便宜走到官方兜底。",[11,4245,4246],{},"排序必须稳定——同价渠道的顺序如果会抖，首选渠道跟着抖，成本核算对不上账。",[26,4248,4249],{"id":4249},"这类失败为什么值得单独归档",[11,4251,4252],{},"六处里没有一处是「功能坏了」。功能都在跑，只是结果比预期差一点：",[123,4254,4255,4258,4261,4264,4267,4270],{},[126,4256,4257],{},"渠道名猜错 → 多提交一次，多花一份钱。",[126,4259,4260],{},"地址是相对路径 → 状态卡住，片子其实已经出好。",[126,4262,4263],{},"签名过期 → 参考图不生效，出一版纯文本结果。",[126,4265,4266],{},"熔断被当成永久失败 → 用户手动重试，多半能成。",[126,4268,4269],{},"上游欠费 → 用户跑去充值，发现不是自己的问题。",[126,4271,4272],{},"便宜渠道立刻切官方 → 每次都贵三倍。",[11,4274,4275,4276,4279],{},"它们的共同点是",[488,4277,4278],{},"失败被下游吸收掉了","。上游按「没有参考图」处理，本地按「生成中」等着，用户按「重试一次」处理。每一层都做了合理的降级，降级叠起来就把根因盖住了。",[11,4281,4282,4283,4286],{},"排查这类问题的入口不是日志，是",[488,4284,4285],{},"对照","：同一张图跑两次结果不同、同一段提示词两个渠道表现不同、同一个任务的成本和预期差三倍。能对照的地方，才看得出哪一层在悄悄降级。",[11,4288,4289,4290,4293],{},"补完这六处之后，剩下一条通用规则：凡是把 URL 交给外部系统，签名的有效期按那个系统的",[488,4291,4292],{},"实际取用时刻","算，不按发起时刻算。上游什么时候来拉，不由我们决定。",[1267,4295,4296],{},"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 .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);}html pre.shiki code .sA_wV, html code.shiki .sA_wV{--shiki-default:#032F62;--shiki-dark:#DBEDFF}html pre.shiki code .snhLl, html code.shiki .snhLl{--shiki-default:#22863A;--shiki-default-font-weight:bold;--shiki-dark:#85E89D;--shiki-dark-font-weight:bold}",{"title":183,"searchDepth":184,"depth":184,"links":4298},[4299,4300,4301,4302,4303,4304,4305],{"id":3441,"depth":184,"text":3442},{"id":3577,"depth":184,"text":3578},{"id":3867,"depth":184,"text":3868},{"id":3973,"depth":184,"text":3974},{"id":4080,"depth":184,"text":4081},{"id":4158,"depth":184,"text":4159},{"id":4249,"depth":184,"text":4249},"2026-08-24",{},"\u002F2026-08-24-fa-022",{"title":3427,"description":3432},"FA-022","2026-08-24-FA-022-上游没报错片子不对","视频链路上一批不报错的失败：猜不出渠道名导致重复提交、成片地址是相对路径、参考图签名在排队时就过期、熔断被当成永久失败、上游欠费显示成用户余额不足。",[4314,4315,4316,4317,4318,4319,4320],"视频生成","渠道路由","签名有效期","熔断","错误分类","Seedance","MiniMax H3","tTar2a7XP9J2IM85BYQ0cNt8K4kZy9UucIgTTiPob2o",{"id":4323,"title":4324,"body":4325,"column":1966,"date":4938,"description":4329,"extension":199,"hero_image":200,"meta":4939,"navigation":202,"path":4940,"seo":4941,"series_id":200,"severity":200,"stem":4942,"summary":4943,"tags":4944,"__hash__":4949},"posts\u002F2026-08-20-画布这一步深链运行历史与续跑.md","画布这一步：深链、运行历史与续跑",{"type":8,"value":4326,"toc":4928},[4327,4330,4333,4337,4340,4343,4349,4352,4366,4380,4383,4387,4390,4396,4399,4406,4409,4418,4421,4425,4428,4433,4461,4464,4469,4489,4496,4499,4503,4506,4595,4598,4601,4605,4608,4626,4635,4638,4641,4645,4648,4651,4671,4674,4679,4682,4714,4717,4809,4812,4818,4825,4829,4832,4835,4867,4872,4875,4881,4884,4888,4892,4895,4904,4910,4916,4922,4925],[11,4328,4329],{},"画布是这套平台里最复杂的界面。节点、连线、参数、批量框、运行状态，任何一件事都要在同一个视口里表达清楚。",[11,4331,4332],{},"这一天补齐八项操作。每一项都不难，难的是它们之间不能互相打架。",[26,4334,4336],{"id":4335},"一url-是唯一事实来源","一、URL 是唯一事实来源",[11,4338,4339],{},"原先画布是个单页状态机：打开就是列表，点进去切到编辑器，刷新回列表。",[11,4341,4342],{},"改成路由：",[399,4344,4347],{"className":4345,"code":4346,"language":1327},[1325],"\u002Fcanvas            列表\n\u002Fcanvas\u002F:id        编辑器\n",[15,4348,4346],{"__ignoreMap":183},[11,4350,4351],{},"四个行为一起对齐：",[123,4353,4354,4357,4360,4363],{},[126,4355,4356],{},"深链直达编辑器。",[126,4358,4359],{},"刷新不回列表。",[126,4361,4362],{},"后退回列表，而不是退出画布模块。",[126,4364,4365],{},"打开画布写入地址栏。",[11,4367,4368,4371,4372,4375,4376,4379],{},[15,4369,4370],{},"popstate"," 监听已有的解析函数，",[15,4373,4374],{},"pushState"," \u002F ",[15,4377,4378],{},"replaceState"," 写入。",[11,4381,4382],{},"这条改动看起来只是加了个路由，实际改变的是状态的归属：画布 ID 从组件内的 ref 变成了 URL 的一部分。后面几项都依赖它——运行历史要能链到具体的运行，批量框要能被分享，都要求「当前在看哪张画布」是一个可以从外部确定的量。",[26,4384,4386],{"id":4385},"二运行历史","二、运行历史",[11,4388,4389],{},"一个新面板，两个接口：",[399,4391,4394],{"className":4392,"code":4393,"language":1327},[1325],"GET \u002Fapi\u002Fcanvas-flow\u002Fruns?flowId=…      列表\nGET \u002Fapi\u002Fcanvas-flow\u002Fruns\u002F:id           快照\n",[15,4395,4393],{"__ignoreMap":183},[11,4397,4398],{},"列表默认取 10 条。点开某一条，用它的快照推导出画布上每个节点的状态，覆盖显示。",[11,4400,4401,4402,4405],{},"这个面板的数据来源和实时运行状态用的是同一套 ",[15,4403,4404],{},"nodeStates","——历史记录不是另一条平行显示，而是把画布切到那一次运行的视角。",[11,4407,4408],{},"登录态走单一来源：",[399,4410,4412],{"className":691,"code":4411,"language":693,"meta":183,"style":183},"\u002F\u002F 登录态只有 authToken() 一个来源（有守卫测试盯着，别直读 localStorage）\n",[15,4413,4414],{"__ignoreMap":183},[407,4415,4416],{"class":409,"line":410},[407,4417,4411],{"class":528},[11,4419,4420],{},"这条注释是守卫测试抓出来的结果，不是提前的设计。",[26,4422,4424],{"id":4423},"三复制粘贴","三、复制粘贴",[11,4426,4427],{},"复制粘贴里有两个决定值得记。",[11,4429,4430],{},[488,4431,4432],{},"用应用内剪贴板，不碰系统剪贴板。",[399,4434,4436],{"className":691,"code":4435,"language":693,"meta":183,"style":183},"let clipboard: FlowClipboardPayload | null = null;\n",[15,4437,4438],{"__ignoreMap":183},[407,4439,4440,4443,4446,4448,4451,4453,4455,4457,4459],{"class":409,"line":410},[407,4441,4442],{"class":417},"let",[407,4444,4445],{"class":413}," clipboard",[407,4447,1059],{"class":417},[407,4449,4450],{"class":554}," FlowClipboardPayload",[407,4452,3482],{"class":417},[407,4454,3485],{"class":476},[407,4456,706],{"class":417},[407,4458,3485],{"class":476},[407,4460,918],{"class":413},[11,4462,4463],{},"粘贴的内容是节点和边，格式带版本号。走系统剪贴板意味着把画布的 JSON 写进用户的剪贴板，用户去别处粘贴会看到一堆结构数据。应用内的模块级变量没有这个问题。",[11,4465,4466],{},[488,4467,4468],{},"没选节点时不拦截。",[399,4470,4472],{"className":691,"code":4471,"language":693,"meta":183,"style":183},"\u002F\u002F Cmd\u002FCtrl+C：复制选中的节点。\n\u002F\u002F 没选节点就不拦截——用户可能正在复制节点产物里的文字，抢了就是坏默认行为\n\u002F\u002F Cmd\u002FCtrl+V：粘贴。应用内剪贴板为空时同样放行系统默认行为\n",[15,4473,4474,4479,4484],{"__ignoreMap":183},[407,4475,4476],{"class":409,"line":410},[407,4477,4478],{"class":528},"\u002F\u002F Cmd\u002FCtrl+C：复制选中的节点。\n",[407,4480,4481],{"class":409,"line":184},[407,4482,4483],{"class":528},"\u002F\u002F 没选节点就不拦截——用户可能正在复制节点产物里的文字，抢了就是坏默认行为\n",[407,4485,4486],{"class":409,"line":189},[407,4487,4488],{"class":528},"\u002F\u002F Cmd\u002FCtrl+V：粘贴。应用内剪贴板为空时同样放行系统默认行为\n",[11,4490,4491,4492,4495],{},"这两条合起来是一个原则：",[488,4493,4494],{},"快捷键只在它有明确意图时生效","。用户按 Cmd+C 时可能是想复制提示词里的文字，此时抢过来是损失。",[11,4497,4498],{},"粘贴到画布时重新生成 ID（节点 ID 最多重试 20 次防碰撞），偏移 24 像素，粘贴的结果进入撤销栈并成为新的选中项。",[26,4500,4502],{"id":4501},"四一键整理布局","四、一键整理布局",[11,4504,4505],{},"按依赖深度分层的纯函数，不引布局引擎：",[399,4507,4509],{"className":691,"code":4508,"language":693,"meta":183,"style":183},"\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",[15,4510,4511,4515,4520,4524,4529,4534,4539,4543,4557,4571],{"__ignoreMap":183},[407,4512,4513],{"class":409,"line":410},[407,4514,950],{"class":528},[407,4516,4517],{"class":409,"line":184},[407,4518,4519],{"class":528}," * 一键整理布局：按依赖深度分层的纯函数。\n",[407,4521,4522],{"class":409,"line":189},[407,4523,1652],{"class":528},[407,4525,4526],{"class":409,"line":452},[407,4527,4528],{"class":528}," * 不引 dagre\u002Felk——画布的图是小规模 DAG（上限 100 节点），\n",[407,4530,4531],{"class":409,"line":458},[407,4532,4533],{"class":528}," * 「上游在左、下游在右、同层竖排」这一条规则就够读顺一张乱图，\n",[407,4535,4536],{"class":409,"line":464},[407,4537,4538],{"class":528}," * 引一个布局引擎为它的边缘能力买单不值。\n",[407,4540,4541],{"class":409,"line":470},[407,4542,970],{"class":528},[407,4544,4545,4547,4550,4552,4555],{"class":409,"line":480},[407,4546,700],{"class":417},[407,4548,4549],{"class":476}," COLUMN_GAP",[407,4551,706],{"class":417},[407,4553,4554],{"class":476}," 380",[407,4556,918],{"class":413},[407,4558,4559,4561,4564,4566,4569],{"class":409,"line":1477},[407,4560,700],{"class":417},[407,4562,4563],{"class":476}," ROW_GAP",[407,4565,706],{"class":417},[407,4567,4568],{"class":476}," 240",[407,4570,918],{"class":413},[407,4572,4573,4575,4578,4580,4583,4586,4589,4592],{"class":409,"line":1483},[407,4574,700],{"class":417},[407,4576,4577],{"class":476}," ORIGIN",[407,4579,706],{"class":417},[407,4581,4582],{"class":413}," { x: ",[407,4584,4585],{"class":476},"40",[407,4587,4588],{"class":413},", y: ",[407,4590,4591],{"class":476},"60",[407,4593,4594],{"class":413}," };\n",[11,4596,4597],{},"同列保持用户原有的上下相对顺序。这一条是这类功能能不能用的分界线——整理完之后用户还得能认出自己的图。",[11,4599,4600],{},"节点上限 100，单批最大 50 项，展开后的总任务上限 200。这几个数决定了上面那个判断成立：在这个规模内，一条规则够用。",[26,4602,4604],{"id":4603},"五便签不能做成节点","五、便签不能做成节点",[11,4606,4607],{},"画布上要有地方写注释。最自然的做法是加一个「便签节点」，但它的实现方式正好相反：",[399,4609,4611],{"className":691,"code":4610,"language":693,"meta":183,"style":183},"\u002F**\n * 画布便签：纯注释，不参与调度、连线与计费。\n *\u002F\n",[15,4612,4613,4617,4622],{"__ignoreMap":183},[407,4614,4615],{"class":409,"line":410},[407,4616,950],{"class":528},[407,4618,4619],{"class":409,"line":184},[407,4620,4621],{"class":528}," * 画布便签：纯注释，不参与调度、连线与计费。\n",[407,4623,4624],{"class":409,"line":189},[407,4625,970],{"class":528},[399,4627,4629],{"className":691,"code":4628,"language":693,"meta":183,"style":183},"\u002F\u002F 不是 xyflow 节点——它不参与连线、调度与计费，做成节点要在注册表、校验器、执行器三处逐一开豁免，成本远高于一张自绘卡片。\n",[15,4630,4631],{"__ignoreMap":183},[407,4632,4633],{"class":409,"line":410},[407,4634,4628],{"class":528},[11,4636,4637],{},"做成节点意味着它要在三处地方被显式排除。而排除逻辑每加一处，将来就多一处要维护的例外。做成一张画在流坐标系里的自绘卡片，这些例外一个都不需要。",[11,4639,4640],{},"契约里限制条数 100、单条 2000 字。",[26,4642,4644],{"id":4643},"六批量框","六、批量框",[11,4646,4647],{},"批量框是「框内的子图跑 N 遍」。它带来的第一件事是边界规则：",[11,4649,4650],{},"框内节点的输出不能接到框外，反过来可以。",[399,4652,4654],{"className":691,"code":4653,"language":693,"meta":183,"style":183},"\u002F\u002F 批量框边界规则：框内节点的输出不能接到框外（或另一个框）。\n\u002F\u002F 框内子图每份各跑一遍，往外接意味着下游要收 N 份——运行时不支持这种收束；\n\u002F\u002F 反方向（框外 → 框内）合法：同一上游共享给每份拷贝。\n",[15,4655,4656,4661,4666],{"__ignoreMap":183},[407,4657,4658],{"class":409,"line":410},[407,4659,4660],{"class":528},"\u002F\u002F 批量框边界规则：框内节点的输出不能接到框外（或另一个框）。\n",[407,4662,4663],{"class":409,"line":184},[407,4664,4665],{"class":528},"\u002F\u002F 框内子图每份各跑一遍，往外接意味着下游要收 N 份——运行时不支持这种收束；\n",[407,4667,4668],{"class":409,"line":189},[407,4669,4670],{"class":528},"\u002F\u002F 反方向（框外 → 框内）合法：同一上游共享给每份拷贝。\n",[11,4672,4673],{},"拒绝时的文案要给下一步：",[1205,4675,4676],{},[11,4677,4678],{},"批量框内的节点不能连到框外：框内每份各跑一遍，产物会直接进素材库。要串联处理就把目标节点也拖进框里。",[11,4680,4681],{},"第二件事是删除节点的连带处理。节点被删掉之后，批量框里会留下幽灵成员，而运行创建时会拒绝整张图：",[399,4683,4685],{"className":691,"code":4684,"language":693,"meta":183,"style":183},"\u002F**\n * 从所有批量框成员里剔除已删除的节点；成员清空的框一并删除。\n *\n * 删除节点必须同步清理：残留的幽灵成员会让 run-create 直接拒绝整张画布\n * （「批量框引用了图中不存在的节点」），用户面对的是一张再也跑不起来的图。\n *\u002F\n",[15,4686,4687,4691,4696,4700,4705,4710],{"__ignoreMap":183},[407,4688,4689],{"class":409,"line":410},[407,4690,950],{"class":528},[407,4692,4693],{"class":409,"line":184},[407,4694,4695],{"class":528}," * 从所有批量框成员里剔除已删除的节点；成员清空的框一并删除。\n",[407,4697,4698],{"class":409,"line":189},[407,4699,1652],{"class":528},[407,4701,4702],{"class":409,"line":452},[407,4703,4704],{"class":528}," * 删除节点必须同步清理：残留的幽灵成员会让 run-create 直接拒绝整张画布\n",[407,4706,4707],{"class":409,"line":458},[407,4708,4709],{"class":528}," * （「批量框引用了图中不存在的节点」），用户面对的是一张再也跑不起来的图。\n",[407,4711,4712],{"class":409,"line":464},[407,4713,970],{"class":528},[11,4715,4716],{},"第三件事是撤销栈。批量框要进快照：",[399,4718,4720],{"className":691,"code":4719,"language":693,"meta":183,"style":183},"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",[15,4721,4722,4734,4753,4769,4784,4789,4805],{"__ignoreMap":183},[407,4723,4724,4726,4729,4732],{"class":409,"line":410},[407,4725,1045],{"class":417},[407,4727,4728],{"class":417}," interface",[407,4730,4731],{"class":554}," GraphSnapshot",[407,4733,523],{"class":413},[407,4735,4736,4739,4742,4744,4747,4750],{"class":409,"line":184},[407,4737,4738],{"class":417},"  readonly",[407,4740,4741],{"class":718}," nodes",[407,4743,1059],{"class":417},[407,4745,4746],{"class":417}," readonly",[407,4748,4749],{"class":554}," CanvasFlowNode",[407,4751,4752],{"class":413},"[];\n",[407,4754,4755,4757,4760,4762,4764,4767],{"class":409,"line":189},[407,4756,4738],{"class":417},[407,4758,4759],{"class":718}," edges",[407,4761,1059],{"class":417},[407,4763,4746],{"class":417},[407,4765,4766],{"class":554}," CanvasFlowEdge",[407,4768,4752],{"class":413},[407,4770,4771,4773,4776,4778,4780,4782],{"class":409,"line":452},[407,4772,4738],{"class":417},[407,4774,4775],{"class":718}," selected",[407,4777,1059],{"class":417},[407,4779,4746],{"class":417},[407,4781,3035],{"class":476},[407,4783,4752],{"class":413},[407,4785,4786],{"class":409,"line":458},[407,4787,4788],{"class":528},"  \u002F** 批量框。撤销\u002F重做要连它一起回放，否则撤销删框后节点回来了框没了 *\u002F\n",[407,4790,4791,4793,4796,4798,4800,4803],{"class":409,"line":464},[407,4792,4738],{"class":417},[407,4794,4795],{"class":718}," batchGroups",[407,4797,1059],{"class":417},[407,4799,4746],{"class":417},[407,4801,4802],{"class":554}," CanvasFlowBatchGroup",[407,4804,4752],{"class":413},[407,4806,4807],{"class":409,"line":470},[407,4808,483],{"class":413},[11,4810,4811],{},"第四件事是状态聚合。批量框内一个节点对应多行执行状态，显示取哪个：",[399,4813,4816],{"className":4814,"code":4815,"language":1327},[1325],"failed > running > pending > cancelled > succeeded > idle\n",[15,4817,4815],{"__ignoreMap":183},[11,4819,4820,4821,4824],{},"原先只让 ",[15,4822,4823],{},"failed"," 优先。结果是第一份先成功、第二份还在跑的时候，节点就提前显示成「成功」。",[26,4826,4828],{"id":4827},"七续跑不重复扣费","七、续跑不重复扣费",[11,4830,4831],{},"这一项是这八项里唯一涉及钱的。",[11,4833,4834],{},"失败或被取消的运行，可以续跑。实现方式不是「重跑一遍」：",[399,4836,4837],{"className":691,"code":1140,"language":693,"meta":183,"style":183},[15,4838,4839,4843,4847,4851,4855,4859,4863],{"__ignoreMap":183},[407,4840,4841],{"class":409,"line":410},[407,4842,950],{"class":528},[407,4844,4845],{"class":409,"line":184},[407,4846,1151],{"class":528},[407,4848,4849],{"class":409,"line":189},[407,4850,1156],{"class":528},[407,4852,4853],{"class":409,"line":452},[407,4854,1161],{"class":528},[407,4856,4857],{"class":409,"line":458},[407,4858,1166],{"class":528},[407,4860,4861],{"class":409,"line":464},[407,4862,1171],{"class":528},[407,4864,4865],{"class":409,"line":470},[407,4866,970],{"class":528},[11,4868,4869,4870,1186],{},"关键在于复用判定落在行状态上，而不是「这次运行是新是旧」。所以续跑不需要额外的豁免逻辑：被判成功的节点带着原来的 ",[15,4871,1185],{},[11,4873,4874],{},"预估只算子集，余额检查同理。接口层有幂等入口（按用户 + 请求 ID 查重）和归属校验。不可重试的两种情形给出明确文案：",[399,4876,4879],{"className":4877,"code":4878,"language":1327},[1325],"这次运行已全部成功，没有可重试的节点\n运行还没结束，等它终结后再重试\n",[15,4880,4878],{"__ignoreMap":183},[11,4882,4883],{},"界面上的按钮从「重试」改成「重试失败节点」，带一句说明：",[1205,4885,4886],{},[11,4887,1209],{},[26,4889,4891],{"id":4890},"八八项之间的关系","八、八项之间的关系",[11,4893,4894],{},"单独看每一项，都是常规功能。放在一起时，出现了几条贯穿的取舍：",[11,4896,4897,4900,4901,4903],{},[488,4898,4899],{},"状态的归属要单一。"," 画布 ID 放 URL；运行状态用同一套 ",[15,4902,4404],{},"；撤销栈是唯一的变更入口。三处都收成一个来源之后，「刷新之后看到什么」才有确定答案。",[11,4905,4906,4909],{},[488,4907,4908],{},"例外要少。"," 便签不做成节点，就是为了避免在注册表、校验器、执行器三处开豁免。每开一处例外，就多一处将来会忘记的地方。",[11,4911,4912,4915],{},[488,4913,4914],{},"别抢用户的操作。"," 没选节点时不拦 Cmd+C；整理布局保留同列原有顺序；批量框拒绝时给下一步而不是只报错。",[11,4917,4918,4921],{},[488,4919,4920],{},"涉及钱的判定落在状态上。"," 续跑复用靠行状态，不靠运行的新旧；批量份数由框上显式配置，预估与执行读同一个函数。",[11,4923,4924],{},"最后一条是这一天唯一和故障档案有关的部分。同一天里，批量链路翻出一处行约定矛盾和一处从未命中的推断分支，两处的成因都是「两侧各写一份」。八项补齐之后，取值入口都比之前更集中——这不是巧合，是同一件事的两个方向。",[1267,4926,4927],{},"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":183,"searchDepth":184,"depth":184,"links":4929},[4930,4931,4932,4933,4934,4935,4936,4937],{"id":4335,"depth":184,"text":4336},{"id":4385,"depth":184,"text":4386},{"id":4423,"depth":184,"text":4424},{"id":4501,"depth":184,"text":4502},{"id":4603,"depth":184,"text":4604},{"id":4643,"depth":184,"text":4644},{"id":4827,"depth":184,"text":4828},{"id":4890,"depth":184,"text":4891},"2026-08-20",{},"\u002F2026-08-20",{"title":4324,"description":4329},"2026-08-20-画布这一步深链运行历史与续跑","一次补齐八项画布操作：URL 深链、小地图、运行历史、复制粘贴、批量框、一键整理布局、便签，以及失败运行的单节点续跑。",[1286,4945,4946,4947,1287,4948],"工作流","URL状态","撤销重做","单节点重试","eA-RfotRTWBXXppeiZFtcguPXjtpnVRWrkWgI5LKfY0",{"id":4951,"title":4952,"body":4953,"column":355,"date":5805,"description":4957,"extension":199,"hero_image":200,"meta":5806,"navigation":202,"path":5807,"seo":5808,"series_id":5809,"severity":200,"stem":5810,"summary":5811,"tags":5812,"__hash__":5818},"posts\u002F2026-08-19-FA-021-成本算错的时候没有任何报错.md","成本算错的时候，没有任何报错",{"type":8,"value":4954,"toc":5796},[4955,4958,4965,4968,4972,4979,4990,5006,5009,5021,5030,5043,5050,5053,5073,5077,5080,5083,5086,5093,5113,5122,5128,5132,5135,5138,5198,5205,5208,5254,5269,5272,5278,5282,5285,5288,5302,5307,5384,5390,5397,5407,5410,5444,5451,5462,5510,5514,5517,5539,5542,5545,5579,5586,5632,5639,5643,5646,5653,5659,5670,5756,5759,5762,5776,5779,5786,5793],[11,4956,4957],{},"这一天从后台的一个告警条开始。",[11,4959,4960,4961,4964],{},"后台模型管理页顶着一行提示：",[488,4962,4963],{},"12 个启用中的模型未配置成本，利润分成将按 0 成本计算（虚高）","。库里那 12 个模型的输入输出成本都填过。点进去看，值也在。",[11,4966,4967],{},"顺着这条线查下去，同一天里翻出四处独立的错，四处都不报错。",[26,4969,4971],{"id":4970},"一按量资源没乘用量","一、按量资源没乘用量",[11,4973,4974,4975,4978],{},"成本核算走的是 ",[15,4976,4977],{},"CalculateCost","。它对资源价格表的处理是一行赋值：",[399,4980,4984],{"className":4981,"code":4982,"language":4983,"meta":183,"style":183},"language-go shiki shiki-themes github-light github-dark","costRMB = rp.CostRMB \u002F\u002F 简化：暂不支持按量精细成本（需要 UsageRecord 记录用量字段）\n","go",[15,4985,4986],{"__ignoreMap":183},[407,4987,4988],{"class":409,"line":410},[407,4989,4982],{},[11,4991,4992,4995,4996,4999,5000,5002,5003,5005],{},[15,4993,4994],{},"CostRMB"," 的口径是「每 ",[15,4997,4998],{},"PerUnits"," 个单位的成本」，不是「一次调用的成本」。视频按秒计费，",[15,5001,4998],{}," 是 1 秒，",[15,5004,4994],{}," 是每秒成本。直接取这个值，等于把一部 10 秒的片子按 1 秒算成本。",[11,5007,5008],{},"后果不是少算一点：利润虚高数倍，代理分成按虚高的利润多付。账面上看不出异常，因为售价那一侧是对的。",[11,5010,5011,5012,40,5014,5017,5018,5020],{},"修法是用扣点反推用量。",[15,5013,4994],{},[15,5015,5016],{},"Rate"," 同口径（都是「每 ",[15,5019,4998],{}," 个单位的价\u002F成本」），所以点数之比就是用量之比：",[399,5022,5024],{"className":4981,"code":5023,"language":4983,"meta":183,"style":183},"costRMB = rp.CostRMB * float64(points) \u002F unitRate\n",[15,5025,5026],{"__ignoreMap":183},[407,5027,5028],{"class":409,"line":410},[407,5029,5023],{},[11,5031,5032,5035,5036,5039,5040,781],{},[15,5033,5034],{},"PER_CALL"," 的资源在这个公式下自然退化成「每次成本」，不用特判。视频资源（",[15,5037,5038],{},"VIDEO_IO","）另有一种情形：Kling 这类模型的输入单价是 0，只有输出秒有价，所以反推要用 ",[15,5041,5042],{},"OutputRate",[11,5044,5045,5046,5049],{},"还有一个细节：要用",[488,5047,5048],{},"折扣前","的点数。VIP 折扣只影响用户付多少，不影响真实用量。用实扣点数反推，等于让会员折扣把成本也打了折。",[11,5051,5052],{},"测试用例把这个口径钉死了：",[399,5054,5056],{"className":4981,"code":5055,"language":4983,"meta":183,"style":183},"\u002F\u002F 10 秒 → 扣 1000 点\nwant: 800 \u002F\u002F 分\n\u002F\u002F 若为 80 说明没乘用量，利润会虚高 10 倍\n",[15,5057,5058,5063,5068],{"__ignoreMap":183},[407,5059,5060],{"class":409,"line":410},[407,5061,5062],{},"\u002F\u002F 10 秒 → 扣 1000 点\n",[407,5064,5065],{"class":409,"line":184},[407,5066,5067],{},"want: 800 \u002F\u002F 分\n",[407,5069,5070],{"class":409,"line":189},[407,5071,5072],{},"\u002F\u002F 若为 80 说明没乘用量，利润会虚高 10 倍\n",[26,5074,5076],{"id":5075},"二图生视频把输入秒算进了成本","二、图生视频把输入秒算进了成本",[11,5078,5079],{},"上面那套「按点数反推」有个前提：点数只反映真实用量。图生视频不满足这个前提。",[11,5081,5082],{},"图生视频的扣点包含两部分——输入秒和输出秒。从总点数反推，会把输入秒也算成成本秒。以 480p 图生视频为例，传 5 秒生成 5 秒，成本被算成 ¥5，真实只有 ¥2.5。",[11,5084,5085],{},"利润被低估。这是与上一处反方向的错：一处虚高、一处虚低，互相不抵消，只是让账更乱。它还会触发假的「亏本」告警。",[11,5087,5088,5089,5092],{},"修法是别再反推，直接记真实用量。",[15,5090,5091],{},"UsageRecord"," 加一个字段：",[399,5094,5096],{"className":4981,"code":5095,"language":4983,"meta":183,"style":183},"\u002F\u002F 成本核算基准用量（视频=输出秒数、按量资源=单位数）。0 = 未记录，成本回落按点数反推。\n\u002F\u002F 必须单独记：图生视频的扣点含「输入秒」，从点数反推会把输入秒也算成成本秒，成本虚高、分成少付。\nBilledUnits int64 `gorm:\"not null;default:0\"`\n",[15,5097,5098,5103,5108],{"__ignoreMap":183},[407,5099,5100],{"class":409,"line":410},[407,5101,5102],{},"\u002F\u002F 成本核算基准用量（视频=输出秒数、按量资源=单位数）。0 = 未记录，成本回落按点数反推。\n",[407,5104,5105],{"class":409,"line":184},[407,5106,5107],{},"\u002F\u002F 必须单独记：图生视频的扣点含「输入秒」，从点数反推会把输入秒也算成成本秒，成本虚高、分成少付。\n",[407,5109,5110],{"class":409,"line":189},[407,5111,5112],{},"BilledUnits int64 `gorm:\"not null;default:0\"`\n",[11,5114,5115,5117,5118,5121],{},[15,5116,4977],{}," 优先用 ",[15,5119,5120],{},"BilledUnits","，没有值才回落到按点数反推。这样存量数据仍算得出来，新数据算得对。",[11,5123,5124,5125,5127],{},"下游要跟着改两处：扣费时写入 ",[15,5126,5120],{},"，结算时按实际输出秒回写——预扣按上限算，实际输出可能更短，成本基准要同步下调。",[26,5129,5131],{"id":5130},"三红名单的判据写反了","三、红名单的判据写反了",[11,5133,5134],{},"回到开头那 12 个模型。",[11,5136,5137],{},"后台判断「成本是否已配置」的条件是四项成本全不为 0：",[399,5139,5141],{"className":691,"code":5140,"language":693,"meta":183,"style":183},"const hasCost =\n  m.inputCostRmbPerMillion !== 0 &&\n  m.outputCostRmbPerMillion !== 0 &&\n  m.cacheInputCostRmbPerMillion !== 0 &&\n  m.cacheOutputCostRmbPerMillion !== 0;\n",[15,5142,5143,5152,5165,5176,5187],{"__ignoreMap":183},[407,5144,5145,5147,5150],{"class":409,"line":410},[407,5146,700],{"class":417},[407,5148,5149],{"class":476}," hasCost",[407,5151,1511],{"class":417},[407,5153,5154,5157,5160,5162],{"class":409,"line":184},[407,5155,5156],{"class":413},"  m.inputCostRmbPerMillion ",[407,5158,5159],{"class":417},"!==",[407,5161,1118],{"class":476},[407,5163,5164],{"class":417}," &&\n",[407,5166,5167,5170,5172,5174],{"class":409,"line":189},[407,5168,5169],{"class":413},"  m.outputCostRmbPerMillion ",[407,5171,5159],{"class":417},[407,5173,1118],{"class":476},[407,5175,5164],{"class":417},[407,5177,5178,5181,5183,5185],{"class":409,"line":452},[407,5179,5180],{"class":413},"  m.cacheInputCostRmbPerMillion ",[407,5182,5159],{"class":417},[407,5184,1118],{"class":476},[407,5186,5164],{"class":417},[407,5188,5189,5192,5194,5196],{"class":409,"line":458},[407,5190,5191],{"class":413},"  m.cacheOutputCostRmbPerMillion ",[407,5193,5159],{"class":417},[407,5195,1118],{"class":476},[407,5197,918],{"class":413},[11,5199,5200,5201,5204],{},"缓存创建成本按约定统一填 0——大部分模型没有这项。用 ",[15,5202,5203],{},"&&"," 串起来，等于要求一个按约定填 0 的字段非 0，条件永远不成立。",[11,5206,5207],{},"改成「输入或输出任一大于 0」：",[399,5209,5211],{"className":691,"code":5210,"language":693,"meta":183,"style":183},"const hasCost =\n  (m.inputCostRmbPerMillion ?? 0) > 0 || (m.outputCostRmbPerMillion ?? 0) > 0;\n",[15,5212,5213,5221],{"__ignoreMap":183},[407,5214,5215,5217,5219],{"class":409,"line":410},[407,5216,700],{"class":417},[407,5218,5149],{"class":476},[407,5220,1511],{"class":417},[407,5222,5223,5226,5228,5230,5232,5234,5236,5239,5242,5244,5246,5248,5250,5252],{"class":409,"line":184},[407,5224,5225],{"class":413},"  (m.inputCostRmbPerMillion ",[407,5227,912],{"class":417},[407,5229,1118],{"class":476},[407,5231,722],{"class":413},[407,5233,3519],{"class":417},[407,5235,1118],{"class":476},[407,5237,5238],{"class":417}," ||",[407,5240,5241],{"class":413}," (m.outputCostRmbPerMillion ",[407,5243,912],{"class":417},[407,5245,1118],{"class":476},[407,5247,722],{"class":413},[407,5249,3519],{"class":417},[407,5251,1118],{"class":476},[407,5253,918],{"class":413},[11,5255,5256,5257,5260,5261,5264,5265,5268],{},"同一个文件里还查出两处更隐蔽的：资源缺口的键取了 ",[15,5258,5259],{},"r.id || r.key","，而真实字段名是 ",[15,5262,5263],{},"resourceKey","，取出来是 ",[15,5266,5267],{},"undefined","；告警条只统计 LLM 模型，28 项生图\u002F视频\u002F工具的资源缺口在后台根本不显示。",[11,5270,5271],{},"前者是字段名读错，后者是漏了一半。修完之后告警条分开报数：",[399,5273,5276],{"className":5274,"code":5275,"language":1327},[1325],"⚠️ N 个模型、M 项资源（生图\u002F视频\u002F工具）未配置成本\n",[15,5277,5275],{"__ignoreMap":183},[26,5279,5281],{"id":5280},"四填进去的成本从来没进过库","四、填进去的成本，从来没进过库",[11,5283,5284],{},"前面三处都是算错。第四处是没算——因为成本值根本没存下来。",[11,5286,5287],{},"后台填资源成本，点保存，提示成功，回到列表看还是 0。",[11,5289,5290,5291,5293,5294,5297,5298,5301],{},"链路两端都为这个字段做了指针语义设计。Go 侧 ",[15,5292,4994],{}," 是 ",[15,5295,5296],{},"*float64","，判断 nil 就跳过写入——这个设计的意图是「请求里没带这个字段，就别动已有的值」。Node 侧的参数类型 ",[15,5299,5300],{},"costRmb"," 也是可选的。",[11,5303,5304,5305,781],{},"问题出在中间那层 zod schema：它没声明 ",[15,5306,5300],{},[399,5308,5310],{"className":691,"code":5309,"language":693,"meta":183,"style":183},"const upsertSchema = z.object({\n  resourceKey: z.string().trim().min(1).max(64),\n  \u002F\u002F ...\n  enabled: z.boolean(),\n  \u002F\u002F costRmb 不在列表里\n});\n",[15,5311,5312,5330,5361,5365,5375,5380],{"__ignoreMap":183},[407,5313,5314,5316,5319,5321,5324,5327],{"class":409,"line":410},[407,5315,700],{"class":417},[407,5317,5318],{"class":476}," upsertSchema",[407,5320,706],{"class":417},[407,5322,5323],{"class":413}," z.",[407,5325,5326],{"class":554},"object",[407,5328,5329],{"class":413},"({\n",[407,5331,5332,5335,5337,5339,5342,5344,5346,5348,5350,5352,5354,5356,5359],{"class":409,"line":184},[407,5333,5334],{"class":413},"  resourceKey: z.",[407,5336,1076],{"class":554},[407,5338,984],{"class":413},[407,5340,5341],{"class":554},"trim",[407,5343,984],{"class":413},[407,5345,992],{"class":554},[407,5347,586],{"class":413},[407,5349,659],{"class":476},[407,5351,999],{"class":413},[407,5353,1002],{"class":554},[407,5355,586],{"class":413},[407,5357,5358],{"class":476},"64",[407,5360,3104],{"class":413},[407,5362,5363],{"class":409,"line":189},[407,5364,761],{"class":528},[407,5366,5367,5370,5373],{"class":409,"line":452},[407,5368,5369],{"class":413},"  enabled: z.",[407,5371,5372],{"class":554},"boolean",[407,5374,738],{"class":413},[407,5376,5377],{"class":409,"line":458},[407,5378,5379],{"class":528},"  \u002F\u002F costRmb 不在列表里\n",[407,5381,5382],{"class":409,"line":464},[407,5383,667],{"class":413},[11,5385,5386,5387,5389],{},"zod 默认剥离未声明的字段。请求穿过这层之后，",[15,5388,5300],{}," 就没了，Go 侧拿到的 JSON 里没有这个键，判定 nil，跳过写入。",[11,5391,5392,5393,5396],{},"TypeScript 查不出来——",[15,5394,5395],{},"UpsertResourcePriceArgs.costRmb"," 本来就是可选的，前端传不传都合法。两端各自的类型检查都是绿的。",[11,5398,5399,5400,5402,5403,5406],{},"影响面是全部按 ",[15,5401,5263],{}," 计价的资源：图片、视频、文本、建站、工具。",[15,5404,5405],{},"cost_rmb"," 一直是 0，利润按 0 成本算，代理分成偏高。",[11,5408,5409],{},"修法是把这个字段声明出来，同时保留「不传就不动」的语义：",[399,5411,5413],{"className":691,"code":5412,"language":693,"meta":183,"style":183},"\u002F\u002F 必须 optional 而非 default(0)：billing 侧 CostRMB 是 *float64，nil 才跳过写入。\n\u002F\u002F 给默认值会让「只改售价」的请求把已配好的成本清零。\ncostRmb: z.number().nonnegative().optional(),\n",[15,5414,5415,5420,5425],{"__ignoreMap":183},[407,5416,5417],{"class":409,"line":410},[407,5418,5419],{"class":528},"\u002F\u002F 必须 optional 而非 default(0)：billing 侧 CostRMB 是 *float64，nil 才跳过写入。\n",[407,5421,5422],{"class":409,"line":184},[407,5423,5424],{"class":528},"\u002F\u002F 给默认值会让「只改售价」的请求把已配好的成本清零。\n",[407,5426,5427,5429,5431,5433,5435,5438,5440,5442],{"class":409,"line":189},[407,5428,5300],{"class":554},[407,5430,978],{"class":413},[407,5432,981],{"class":554},[407,5434,984],{"class":413},[407,5436,5437],{"class":554},"nonnegative",[407,5439,984],{"class":413},[407,5441,1012],{"class":554},[407,5443,738],{"class":413},[11,5445,5446,5447,5450],{},"这条注释值得留：",[15,5448,5449],{},"default(0)"," 看起来更安全，实际会把成本清零。这两个语义在这条链路上差别很大。",[11,5452,5453,5454,5457,5458,5461],{},"守卫测试也补上了。",[15,5455,5456],{},"schema-passthrough.test.ts"," 这个文件此前只盖了会员卡与充值套餐，资源计价因为 ",[15,5459,5460],{},"upsertSchema"," 没导出，成了盲区：",[399,5463,5465],{"className":691,"code":5464,"language":693,"meta":183,"style":183},"\u002F\u002F 「成本不能被剥离」\nexpect(p.data.costRmb).toBe(0.35);\n\u002F\u002F 「不带成本时保持 undefined，绝不落成 0」\nexpect(p.data.costRmb).toBeUndefined();\n\u002F\u002F 「显式填 0 是合法值，与不带该字段语义不同」\n",[15,5466,5467,5472,5489,5494,5505],{"__ignoreMap":183},[407,5468,5469],{"class":409,"line":410},[407,5470,5471],{"class":528},"\u002F\u002F 「成本不能被剥离」\n",[407,5473,5474,5477,5480,5483,5485,5487],{"class":409,"line":184},[407,5475,5476],{"class":554},"expect",[407,5478,5479],{"class":413},"(p.data.costRmb).",[407,5481,5482],{"class":554},"toBe",[407,5484,586],{"class":413},[407,5486,1757],{"class":476},[407,5488,662],{"class":413},[407,5490,5491],{"class":409,"line":189},[407,5492,5493],{"class":528},"\u002F\u002F 「不带成本时保持 undefined，绝不落成 0」\n",[407,5495,5496,5498,5500,5503],{"class":409,"line":452},[407,5497,5476],{"class":554},[407,5499,5479],{"class":413},[407,5501,5502],{"class":554},"toBeUndefined",[407,5504,3822],{"class":413},[407,5506,5507],{"class":409,"line":458},[407,5508,5509],{"class":528},"\u002F\u002F 「显式填 0 是合法值，与不带该字段语义不同」\n",[26,5511,5513],{"id":5512},"五还有一处不报错结算失败被吞掉","五、还有一处不报错：结算失败被吞掉",[11,5515,5516],{},"同一天查计费链路时，还看到 51 处这样写：",[399,5518,5520],{"className":691,"code":5519,"language":693,"meta":183,"style":183},"}).catch(() => undefined);\n",[15,5521,5522],{"__ignoreMap":183},[407,5523,5524,5527,5529,5532,5534,5537],{"class":409,"line":410},[407,5525,5526],{"class":413},"}).",[407,5528,3830],{"class":554},[407,5530,5531],{"class":413},"(() ",[407,5533,520],{"class":417},[407,5535,5536],{"class":476}," undefined",[407,5538,662],{"class":413},[11,5540,5541],{},"结算、退款、台账状态回写，失败一律静默。结算永久失败会让预留被对账任务全额退款，平台白送一次成片；退款失败则用户点数悬置。两种情况此前都留不下痕迹，无法对账。",[11,5543,5544],{},"51 处改成带标签的日志，行为不变：",[399,5546,5548],{"className":691,"code":5547,"language":693,"meta":183,"style":183},"}).catch((e) => console.error(\"[billing-ledger] 画布节点退款 pending 回写失败\", e));\n",[15,5549,5550],{"__ignoreMap":183},[407,5551,5552,5554,5556,5558,5561,5563,5565,5568,5571,5573,5576],{"class":409,"line":410},[407,5553,5526],{"class":413},[407,5555,3830],{"class":554},[407,5557,715],{"class":413},[407,5559,5560],{"class":718},"e",[407,5562,722],{"class":413},[407,5564,520],{"class":417},[407,5566,5567],{"class":413}," console.",[407,5569,5570],{"class":554},"error",[407,5572,586],{"class":413},[407,5574,5575],{"class":429},"\"[billing-ledger] 画布节点退款 pending 回写失败\"",[407,5577,5578],{"class":413},", e));\n",[11,5580,5581,5582,5585],{},"台账推进也加了行数校验。",[15,5583,5584],{},"updateMany"," 的结果原本被丢弃，现在会检查：",[399,5587,5589],{"className":691,"code":5588,"language":693,"meta":183,"style":183},"if (reservedMark.count === 0) {\n  \u002F\u002F Go 侧 opID 幂等兜底不会重复扣钱；0 行说明状态已被并发推进，留痕供对账排查\n  console.error(`[billing-ledger] 公众号图文文本预扣台账未推进 op=${operationId}`);\n}\n",[15,5590,5591,5604,5609,5628],{"__ignoreMap":183},[407,5592,5593,5595,5598,5600,5602],{"class":409,"line":410},[407,5594,875],{"class":417},[407,5596,5597],{"class":413}," (reservedMark.count ",[407,5599,881],{"class":417},[407,5601,1118],{"class":476},[407,5603,887],{"class":413},[407,5605,5606],{"class":409,"line":184},[407,5607,5608],{"class":528},"  \u002F\u002F Go 侧 opID 幂等兜底不会重复扣钱；0 行说明状态已被并发推进，留痕供对账排查\n",[407,5610,5611,5614,5616,5618,5621,5623,5626],{"class":409,"line":189},[407,5612,5613],{"class":413},"  console.",[407,5615,5570],{"class":554},[407,5617,586],{"class":413},[407,5619,5620],{"class":429},"`[billing-ledger] 公众号图文文本预扣台账未推进 op=${",[407,5622,4073],{"class":413},[407,5624,5625],{"class":429},"}`",[407,5627,662],{"class":413},[407,5629,5630],{"class":409,"line":452},[407,5631,483],{"class":413},[11,5633,5634,5635,5638],{},"对账任务的扫描也补了三处：查询错误不再吞掉、单轮 ",[15,5636,5637],{},"Limit(1000)","、逐条失败单独记日志。最后一条解决的是坏记录每 5 分钟被无声重扫一次的问题。",[26,5640,5642],{"id":5641},"六统一入口与单位","六、统一入口与单位",[11,5644,5645],{},"修完这四处，后台的成本录入还是散的：资源成本在一个面板，LLM 模型成本在另一个页面，基础生图、基础视频生成、小说正文、小说封面这四类没有成本入口。",[11,5647,5648,5649,5652],{},"新加了一个「成本配置」页，把资源成本和模型成本收到一处，每项同时显示售价和实时算出的毛利率。五个计价面板加 ",[15,5650,5651],{},"mode=\"price\" | \"cost\""," 参数：成本模式下售价只读、成本可编辑。",[11,5654,5655,5656,5658],{},"同一轮里还剥掉两处重复计价。电商长图的母版、分段、主图各有一套 ",[15,5657,5263],{},"，但它们和聊天、画布、漫剧、建站调的是同一个上游生图接口，默认价也完全相同（10\u002F20\u002F40）。后台要把同一份成本填四遍。并入标准生图价之后，删掉三个 key 派生函数和后台对应的 9 项配置。小说封面同理，并入 1K 标准生图价。",[11,5660,5661,5662,40,5664,5666,5667,5669],{},"成本输入框的单位也改了。原先标签写死「元\u002F次」，而 billing 侧的 ",[15,5663,4994],{},[15,5665,5016],{}," 同口径，是按每 ",[15,5668,4998],{}," 个单位算的。建站按月扣、公众号图文按千字扣、数字人成片按秒扣，都提示按次填，填进去的成本会差好几个数量级。",[399,5671,5673],{"className":691,"code":5672,"language":693,"meta":183,"style":183},"\u002F\u002F 成本单位必须跟计价单位一致——按月计费的填每月成本、按千字计费的填每千字成本。\n\u002F\u002F 写死「元\u002F次」会让人按整单成本填，利润与代理分成直接算错。\nexport function costUnitSuffix(unitLabel: string): string {\n  return unitLabel.replace(\u002F^每\\s*\u002F, \"\") || \"次\";\n}\n",[15,5674,5675,5680,5685,5711,5752],{"__ignoreMap":183},[407,5676,5677],{"class":409,"line":410},[407,5678,5679],{"class":528},"\u002F\u002F 成本单位必须跟计价单位一致——按月计费的填每月成本、按千字计费的填每千字成本。\n",[407,5681,5682],{"class":409,"line":184},[407,5683,5684],{"class":528},"\u002F\u002F 写死「元\u002F次」会让人按整单成本填，利润与代理分成直接算错。\n",[407,5686,5687,5689,5691,5694,5696,5699,5701,5703,5705,5707,5709],{"class":409,"line":189},[407,5688,1045],{"class":417},[407,5690,1048],{"class":417},[407,5692,5693],{"class":554}," costUnitSuffix",[407,5695,586],{"class":413},[407,5697,5698],{"class":718},"unitLabel",[407,5700,1059],{"class":417},[407,5702,3035],{"class":476},[407,5704,1065],{"class":413},[407,5706,1059],{"class":417},[407,5708,3035],{"class":476},[407,5710,523],{"class":413},[407,5712,5713,5715,5718,5721,5723,5725,5727,5730,5733,5735,5737,5739,5742,5744,5747,5750],{"class":409,"line":452},[407,5714,2142],{"class":417},[407,5716,5717],{"class":413}," unitLabel.",[407,5719,5720],{"class":554},"replace",[407,5722,586],{"class":413},[407,5724,2699],{"class":429},[407,5726,3748],{"class":417},[407,5728,5729],{"class":2671},"每",[407,5731,5732],{"class":476},"\\s",[407,5734,1540],{"class":417},[407,5736,2699],{"class":429},[407,5738,433],{"class":413},[407,5740,5741],{"class":429},"\"\"",[407,5743,722],{"class":413},[407,5745,5746],{"class":417},"||",[407,5748,5749],{"class":429}," \"次\"",[407,5751,918],{"class":413},[407,5753,5754],{"class":409,"line":458},[407,5755,483],{"class":413},[26,5757,5758],{"id":5758},"这几处错的共同点",[11,5760,5761],{},"四处错，四处都没有报错：",[123,5763,5764,5767,5770,5773],{},[126,5765,5766],{},"按量成本没乘用量，算出来是一个偏小的正数，看起来合法。",[126,5768,5769],{},"图生视频把输入秒算进成本，同样是个正数。",[126,5771,5772],{},"红名单判据要求一个按约定填 0 的字段非 0，条件永远为假，于是「已配置」和「未配置」互换。",[126,5774,5775],{},"schema 剥掉字段之后，Go 侧把「没带这个字段」当成「不要改」，跳过写入——一个设计得没错的语义，配上一条丢字段的链路。",[11,5777,5778],{},"没有一处会抛异常，没有一处会在日志里留下痕迹。它们只在一种情况下暴露：有人拿计算器和账本对了一遍。",[11,5780,5781,5782,5785],{},"成本链路的正确性没法靠运行观察。它需要一个独立的口径来源——这里是「扣点数与真实用量的比例」——以及把这个比例固定下来的测试。测试里那句 ",[15,5783,5784],{},"若为 80 说明没乘用量，利润会虚高 10 倍","，比任何断言值都重要：它写清了这条用例在防什么。",[11,5787,5788,5789,5792],{},"至于中间层 schema，教训更具体。zod 剥离未声明字段是设计行为，也是这类链路最容易被忽略的一环：两端的类型都允许字段缺失，只有中间那一层会把它拿走，而它什么都不说。守卫测试要覆盖的是",[488,5790,5791],{},"每一个会真正影响计费的字段","，不是覆盖文件。",[1267,5794,5795],{},"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 .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}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sA_wV, html code.shiki .sA_wV{--shiki-default:#032F62;--shiki-dark:#DBEDFF}",{"title":183,"searchDepth":184,"depth":184,"links":5797},[5798,5799,5800,5801,5802,5803,5804],{"id":4970,"depth":184,"text":4971},{"id":5075,"depth":184,"text":5076},{"id":5130,"depth":184,"text":5131},{"id":5280,"depth":184,"text":5281},{"id":5512,"depth":184,"text":5513},{"id":5641,"depth":184,"text":5642},{"id":5758,"depth":184,"text":5758},"2026-08-19",{},"\u002F2026-08-19-fa-021",{"title":4952,"description":4957},"FA-021","2026-08-19-FA-021-成本算错的时候没有任何报错","一天内查出四处成本口径错误：按量资源没乘用量、图生视频把输入秒算进成本、成本缺口红名单误判全部模型，以及后台填的成本因为中间一层 schema 没声明字段而从未落库。",[1291,5813,5814,5815,5816,5817],"成本核算","分账","zod","Schema","财务准确","lCiS21hyw3wpKljP0dnU0aI9X19e9KnG7LqEdjSF4bM",{"id":5820,"title":5821,"body":5822,"column":2727,"date":5805,"description":5826,"extension":199,"hero_image":200,"meta":6434,"navigation":202,"path":6435,"seo":6436,"series_id":200,"severity":200,"stem":6437,"summary":6438,"tags":6439,"__hash__":6444},"posts\u002F2026-08-19-三十集的项目点了十几轮提取.md","三十集的项目，点了十几轮提取",{"type":8,"value":5823,"toc":6425},[5824,5827,5830,5834,5837,5840,5843,5846,5850,5853,5918,5924,5927,6005,6008,6011,6015,6018,6021,6054,6057,6060,6154,6158,6161,6164,6179,6182,6185,6213,6216,6255,6258,6262,6265,6271,6274,6277,6280,6284,6290,6293,6320,6323,6330,6337,6388,6392,6395,6398,6409,6412,6415,6422],[11,5825,5826],{},"一个 30 集的项目，用户点了十几轮资产提取。",[11,5828,5829],{},"账单上是 198 次模型调用，共 145.77 元。其中约 95% 是重复的。",[26,5831,5833],{"id":5832},"一每轮都从第-1-集开始","一、每轮都从第 1 集开始",[11,5835,5836],{},"提取按 3 集一批，一个 30 集的项目是 10 批。",[11,5838,5839],{},"原先的实现是：不管上次跑到哪，每次提取都从第 1 集重跑到第 30 集。去重只在入库那一步做——已经有的资产不重复写库，但模型的调用照发，费用照扣。",[11,5841,5842],{},"于是用户为了修 2 批失败，把已经成功的那 8 批又花了一遍钱。而界面上的失败提示写的是「可再点一次」。",[11,5844,5845],{},"这句话本身没错。错的是再点一次的代价没有被说清，也没有被限制。",[26,5847,5849],{"id":5848},"二记账的粒度是批次","二、记账的粒度是「批次」",[11,5851,5852],{},"修法是记断点。粒度取批次，不是集也不是任务：",[399,5854,5856],{"className":691,"code":5855,"language":693,"meta":183,"style":183},"export interface ExtractBatchRecord {\n  readonly episodeNos: readonly number[];\n  readonly scriptHash: string;\n  readonly extractedAt: string; \u002F\u002F ISO 8601\n}\n",[15,5857,5858,5869,5885,5898,5914],{"__ignoreMap":183},[407,5859,5860,5862,5864,5867],{"class":409,"line":410},[407,5861,1045],{"class":417},[407,5863,4728],{"class":417},[407,5865,5866],{"class":554}," ExtractBatchRecord",[407,5868,523],{"class":413},[407,5870,5871,5873,5876,5878,5880,5883],{"class":409,"line":184},[407,5872,4738],{"class":417},[407,5874,5875],{"class":718}," episodeNos",[407,5877,1059],{"class":417},[407,5879,4746],{"class":417},[407,5881,5882],{"class":476}," number",[407,5884,4752],{"class":413},[407,5886,5887,5889,5892,5894,5896],{"class":409,"line":189},[407,5888,4738],{"class":417},[407,5890,5891],{"class":718}," scriptHash",[407,5893,1059],{"class":417},[407,5895,3035],{"class":476},[407,5897,918],{"class":413},[407,5899,5900,5902,5905,5907,5909,5911],{"class":409,"line":452},[407,5901,4738],{"class":417},[407,5903,5904],{"class":718}," extractedAt",[407,5906,1059],{"class":417},[407,5908,3035],{"class":476},[407,5910,1121],{"class":413},[407,5912,5913],{"class":528},"\u002F\u002F ISO 8601\n",[407,5915,5916],{"class":409,"line":458},[407,5917,483],{"class":413},[11,5919,5920,5921,781],{},"一个批次成功的条件是：跑完、解析成功、写进项目的 ",[15,5922,5923],{},"settings",[11,5925,5926],{},"判定键是「集号组合 + 正文哈希」：",[399,5928,5930],{"className":691,"code":5929,"language":693,"meta":183,"style":183},"export function buildBatchScriptHash(batchScriptText: string): string {\n  return createHash(\"sha256\").update(batchScriptText).digest(\"hex\").slice(0, 16);\n}\n",[15,5931,5932,5958,6001],{"__ignoreMap":183},[407,5933,5934,5936,5938,5941,5943,5946,5948,5950,5952,5954,5956],{"class":409,"line":410},[407,5935,1045],{"class":417},[407,5937,1048],{"class":417},[407,5939,5940],{"class":554}," buildBatchScriptHash",[407,5942,586],{"class":413},[407,5944,5945],{"class":718},"batchScriptText",[407,5947,1059],{"class":417},[407,5949,3035],{"class":476},[407,5951,1065],{"class":413},[407,5953,1059],{"class":417},[407,5955,3035],{"class":476},[407,5957,523],{"class":413},[407,5959,5960,5962,5965,5967,5970,5972,5975,5978,5981,5983,5986,5988,5990,5992,5994,5996,5999],{"class":409,"line":184},[407,5961,2142],{"class":417},[407,5963,5964],{"class":554}," createHash",[407,5966,586],{"class":413},[407,5968,5969],{"class":429},"\"sha256\"",[407,5971,999],{"class":413},[407,5973,5974],{"class":554},"update",[407,5976,5977],{"class":413},"(batchScriptText).",[407,5979,5980],{"class":554},"digest",[407,5982,586],{"class":413},[407,5984,5985],{"class":429},"\"hex\"",[407,5987,999],{"class":413},[407,5989,3529],{"class":554},[407,5991,586],{"class":413},[407,5993,648],{"class":476},[407,5995,433],{"class":413},[407,5997,5998],{"class":476},"16",[407,6000,662],{"class":413},[407,6002,6003],{"class":409,"line":189},[407,6004,483],{"class":413},[11,6006,6007],{},"带上哈希是为了处理剧本被改过的情况。集号组合相同但正文变了，哈希不同，这一批就要重跑。",[11,6009,6010],{},"解析失败的批次不记录，下次仍会重试。",[26,6012,6014],{"id":6013},"三一处必须只有一份实现","三、一处必须只有一份实现",[11,6016,6017],{},"这是这次改动里最容易埋雷的地方。",[11,6019,6020],{},"喂给模型的文本，和用来算哈希的文本，必须是同一份：",[399,6022,6024],{"className":691,"code":6023,"language":693,"meta":183,"style":183},"\u002F**\n * 这个函数是断点续传的地基，只能有这一份实现：\n * 喂给 LLM 的文本和算 hash 的文本必须逐字节相同，否则 hash 永远对不上，\n * 跳过判定永不命中——表现是「修了断点续传但还在全量重跑」，且测试用同一份\n * fixture 时两边照样一致、照样全绿，线上才发现。\n *\u002F\n",[15,6025,6026,6030,6035,6040,6045,6050],{"__ignoreMap":183},[407,6027,6028],{"class":409,"line":410},[407,6029,950],{"class":528},[407,6031,6032],{"class":409,"line":184},[407,6033,6034],{"class":528}," * 这个函数是断点续传的地基，只能有这一份实现：\n",[407,6036,6037],{"class":409,"line":189},[407,6038,6039],{"class":528}," * 喂给 LLM 的文本和算 hash 的文本必须逐字节相同，否则 hash 永远对不上，\n",[407,6041,6042],{"class":409,"line":452},[407,6043,6044],{"class":528}," * 跳过判定永不命中——表现是「修了断点续传但还在全量重跑」，且测试用同一份\n",[407,6046,6047],{"class":409,"line":458},[407,6048,6049],{"class":528}," * fixture 时两边照样一致、照样全绿，线上才发现。\n",[407,6051,6052],{"class":409,"line":464},[407,6053,970],{"class":528},[11,6055,6056],{},"最后半句是关键。如果两处各自拼文本，测试里两边都从同一个 fixture 拼，结果一定相同，用例全绿。只有线上真实的剧本内容才可能让两边出现差异——一个换行、一个前缀、一个空格。",[11,6058,6059],{},"所以文本构造被收口到一个函数：",[399,6061,6063],{"className":691,"code":6062,"language":693,"meta":183,"style":183},"export function buildBatchScriptText(episodes): string {\n  return episodes.map((ep) => `## 第 ${ep.episodeNo} 集\\n${ep.scriptText}`).join(\"\\n\\n\");\n}\n",[15,6064,6065,6087,6150],{"__ignoreMap":183},[407,6066,6067,6069,6071,6074,6076,6079,6081,6083,6085],{"class":409,"line":410},[407,6068,1045],{"class":417},[407,6070,1048],{"class":417},[407,6072,6073],{"class":554}," buildBatchScriptText",[407,6075,586],{"class":413},[407,6077,6078],{"class":718},"episodes",[407,6080,1065],{"class":413},[407,6082,1059],{"class":417},[407,6084,3035],{"class":476},[407,6086,523],{"class":413},[407,6088,6089,6091,6094,6096,6098,6101,6103,6105,6108,6110,6112,6115,6118,6121,6124,6126,6128,6131,6133,6135,6138,6140,6143,6146,6148],{"class":409,"line":184},[407,6090,2142],{"class":417},[407,6092,6093],{"class":413}," episodes.",[407,6095,712],{"class":554},[407,6097,715],{"class":413},[407,6099,6100],{"class":718},"ep",[407,6102,722],{"class":413},[407,6104,520],{"class":417},[407,6106,6107],{"class":429}," `## 第 ${",[407,6109,6100],{"class":413},[407,6111,3767],{"class":429},[407,6113,6114],{"class":413},"episodeNo",[407,6116,6117],{"class":429},"} 集",[407,6119,6120],{"class":476},"\\n",[407,6122,6123],{"class":429},"${",[407,6125,6100],{"class":413},[407,6127,3767],{"class":429},[407,6129,6130],{"class":413},"scriptText",[407,6132,5625],{"class":429},[407,6134,999],{"class":413},[407,6136,6137],{"class":554},"join",[407,6139,586],{"class":413},[407,6141,6142],{"class":429},"\"",[407,6144,6145],{"class":476},"\\n\\n",[407,6147,6142],{"class":429},[407,6149,662],{"class":413},[407,6151,6152],{"class":409,"line":189},[407,6153,483],{"class":413},[26,6155,6157],{"id":6156},"四预估和实际必须走同一套规划","四、预估和实际必须走同一套规划",[11,6159,6160],{},"只告诉用户「这次会跑几批」不够，还要让他们知道要花多少钱。所以加了一个只读的预估端点。",[11,6162,6163],{},"这个端点不触发模型调用，但它必须和真正提取走同一套规划逻辑：",[399,6165,6167],{"className":691,"code":6166,"language":693,"meta":183,"style":183},"\u002F\u002F 收集正文、读断点、算批次都在 service 里，和真正提取共用同一套——\n\u002F\u002F 路由自己抄一份的话，预估数字迟早和实际跑的批次对不上\n",[15,6168,6169,6174],{"__ignoreMap":183},[407,6170,6171],{"class":409,"line":410},[407,6172,6173],{"class":528},"\u002F\u002F 收集正文、读断点、算批次都在 service 里，和真正提取共用同一套——\n",[407,6175,6176],{"class":409,"line":184},[407,6177,6178],{"class":528},"\u002F\u002F 路由自己抄一份的话，预估数字迟早和实际跑的批次对不上\n",[11,6180,6181],{},"返回的字段是六个：总集数、待处理集数、总批数、待跑批数、跳过批数、预计点数。",[11,6183,6184],{},"这里有一个不起眼但要紧的口径：",[399,6186,6188],{"className":691,"code":6187,"language":693,"meta":183,"style":183},"\u002F**\n * 待处理的「集数」= 各待跑批次的集数之和。不能取最大集号：\n * 只剩最后一批（28\u002F29\u002F30 集）要跑时，最大集号是 30，界面会写成「本次将处理 30 集」，\n * 而实际只跑 3 集——这个数字正是用户判断该不该花钱的依据，不能骗人。\n *\u002F\n",[15,6189,6190,6194,6199,6204,6209],{"__ignoreMap":183},[407,6191,6192],{"class":409,"line":410},[407,6193,950],{"class":528},[407,6195,6196],{"class":409,"line":184},[407,6197,6198],{"class":528}," * 待处理的「集数」= 各待跑批次的集数之和。不能取最大集号：\n",[407,6200,6201],{"class":409,"line":189},[407,6202,6203],{"class":528}," * 只剩最后一批（28\u002F29\u002F30 集）要跑时，最大集号是 30，界面会写成「本次将处理 30 集」，\n",[407,6205,6206],{"class":409,"line":452},[407,6207,6208],{"class":528}," * 而实际只跑 3 集——这个数字正是用户判断该不该花钱的依据，不能骗人。\n",[407,6210,6211],{"class":409,"line":458},[407,6212,970],{"class":528},[11,6214,6215],{},"预估点数的单价来自实测：",[399,6217,6219],{"className":691,"code":6218,"language":693,"meta":183,"style":183},"\u002F**\n * 每批 LLM 调用的经验点数（生产实测 198 次均值 73.6 点）\n * 仅用于给用户看的预估，不用于实际计费\n *\u002F\nexport const ESTIMATED_POINTS_PER_BATCH = 75;\n",[15,6220,6221,6225,6230,6235,6239],{"__ignoreMap":183},[407,6222,6223],{"class":409,"line":410},[407,6224,950],{"class":528},[407,6226,6227],{"class":409,"line":184},[407,6228,6229],{"class":528}," * 每批 LLM 调用的经验点数（生产实测 198 次均值 73.6 点）\n",[407,6231,6232],{"class":409,"line":189},[407,6233,6234],{"class":528}," * 仅用于给用户看的预估，不用于实际计费\n",[407,6236,6237],{"class":409,"line":452},[407,6238,970],{"class":528},[407,6240,6241,6243,6245,6248,6250,6253],{"class":409,"line":458},[407,6242,1045],{"class":417},[407,6244,3166],{"class":417},[407,6246,6247],{"class":476}," ESTIMATED_POINTS_PER_BATCH",[407,6249,706],{"class":417},[407,6251,6252],{"class":476}," 75",[407,6254,918],{"class":413},[11,6256,6257],{},"那 198 次正好就是这次事故的 198 次。",[26,6259,6261],{"id":6260},"五确认框要说的三件事","五、确认框要说的三件事",[11,6263,6264],{},"前端在提取前弹一个确认框。它要说清三件事：跑多少、花多少、以及重提的代价。",[399,6266,6269],{"className":6267,"code":6268,"language":1327},[1325],"本次将处理 X 集（共 Y 集）的 P 批剧本（共 Q 批）。\n预计消耗 N 点算力（约 ¥M）。\n剧本改过想重新提取的话，全量重提会把这 Q 批重跑一遍，按首次提取的量重新扣费。\n",[15,6270,6268],{"__ignoreMap":183},[11,6272,6273],{},"第三句是这个框存在的理由。全量重提是一个合法操作——用户改了剧本，就该重跑。但它的代价要写明，而不是藏在按钮背后。",[11,6275,6276],{},"按钮文案跟着状态变：待跑批数为 0 时，按钮从「确认提取」变成「仍要全量重提」。",[11,6278,6279],{},"失败提示也改了。原先写「可再点一次」，现在说明只重跑没成功的批次。",[26,6281,6283],{"id":6282},"六settings-是整列读改写","六、settings 是整列读改写",[11,6285,6286,6287,6289],{},"断点记录存在项目的 ",[15,6288,5923],{}," 字段里。这个字段是 JSON，整列读改写，不支持部分更新。",[11,6291,6292],{},"所以写入必须拿锁：",[399,6294,6296],{"className":691,"code":6295,"language":693,"meta":183,"style":183},"await prisma.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${projectId}, 0)::bigint)`;\n",[15,6297,6298],{"__ignoreMap":183},[407,6299,6300,6303,6306,6309,6312,6315,6318],{"class":409,"line":410},[407,6301,6302],{"class":417},"await",[407,6304,6305],{"class":413}," prisma.",[407,6307,6308],{"class":554},"$executeRaw",[407,6310,6311],{"class":429},"`SELECT pg_advisory_xact_lock(hashtextextended(${",[407,6313,6314],{"class":413},"projectId",[407,6316,6317],{"class":429},"}, 0)::bigint)`",[407,6319,918],{"class":413},[11,6321,6322],{},"同一把锁已经用在剧本和角色的写入上。三个地方写同一个 JSON 字段，共用一把锁。",[11,6324,6325,6326,6329],{},"用 JSON 字段存断点，代价是没有独立的表、没有索引，查询要走 ",[15,6327,6328],{},"jsonb"," 函数。收益是零迁移——这张表的其他部分已经在用同一个字段存生产状态，再加一块比新开一张表更贴合现有结构。",[11,6331,6332,6333,6336],{},"预估端点的候选项目查询写成了一条 SQL，按 ",[15,6334,6335],{},"LIMIT 200"," 限制单轮扫描量：",[399,6338,6342],{"className":6339,"code":6340,"language":6341,"meta":183,"style":183},"language-sql shiki shiki-themes github-light github-dark","SELECT id FROM \"ComicWorkflowProject\"\nWHERE EXISTS (\n  SELECT 1 FROM jsonb_array_elements(\n    COALESCE((settings->'scriptGeneration'->'tasks'), '[]'::jsonb)\n  ) AS task\n  WHERE task->>'status' IN ('queued', 'running')\n    AND task->>'updatedAt' \u003C ${beforeIso}\n)\nLIMIT 200\n","sql",[15,6343,6344,6349,6354,6359,6364,6369,6374,6379,6383],{"__ignoreMap":183},[407,6345,6346],{"class":409,"line":410},[407,6347,6348],{},"SELECT id FROM \"ComicWorkflowProject\"\n",[407,6350,6351],{"class":409,"line":184},[407,6352,6353],{},"WHERE EXISTS (\n",[407,6355,6356],{"class":409,"line":189},[407,6357,6358],{},"  SELECT 1 FROM jsonb_array_elements(\n",[407,6360,6361],{"class":409,"line":452},[407,6362,6363],{},"    COALESCE((settings->'scriptGeneration'->'tasks'), '[]'::jsonb)\n",[407,6365,6366],{"class":409,"line":458},[407,6367,6368],{},"  ) AS task\n",[407,6370,6371],{"class":409,"line":464},[407,6372,6373],{},"  WHERE task->>'status' IN ('queued', 'running')\n",[407,6375,6376],{"class":409,"line":470},[407,6377,6378],{},"    AND task->>'updatedAt' \u003C ${beforeIso}\n",[407,6380,6381],{"class":409,"line":480},[407,6382,3970],{},[407,6384,6385],{"class":409,"line":1477},[407,6386,6387],{},"LIMIT 200\n",[26,6389,6391],{"id":6390},"七可恢复的前提是知道做到哪了","七、可恢复的前提是「知道做到哪了」",[11,6393,6394],{},"这类长流程任务的设计，难点从来不在「怎么继续」，而在「怎么知道该从哪继续」。",[11,6396,6397],{},"记进度的位置有三档可选：",[123,6399,6400,6403,6406],{},[126,6401,6402],{},"记任务：一个项目可能有几十个任务，任务之间的依赖关系要重建。",[126,6404,6405],{},"记集：粒度太细，集与集之间本来就要合并成批送模型。",[126,6407,6408],{},"记批次：粒度与实际的模型调用对齐。",[11,6410,6411],{},"选了批次。理由是它正好是一个「要么成功、要么没有发生过」的单位——批次里的 3 集要么一起被模型处理了，要么下次重新来。没有中间态。",[11,6413,6414],{},"哈希那一条也来自同一个思路：断点的有效性取决于输入有没有变。只记「第 1~3 集做过了」不够，还要记「当时那 3 集的正文是什么样」。哈希是这句话最省的表达。",[11,6416,6417,6418,6421],{},"这两条合起来，断点续传才成立：",[488,6419,6420],{},"进度记录必须和它依赖的输入绑定","。只记进度不记输入，改了内容之后要么错误跳过，要么完全失效——两种结果都不会报错。",[1267,6423,6424],{},"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 .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 .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":183,"searchDepth":184,"depth":184,"links":6426},[6427,6428,6429,6430,6431,6432,6433],{"id":5832,"depth":184,"text":5833},{"id":5848,"depth":184,"text":5849},{"id":6013,"depth":184,"text":6014},{"id":6156,"depth":184,"text":6157},{"id":6260,"depth":184,"text":6261},{"id":6282,"depth":184,"text":6283},{"id":6390,"depth":184,"text":6391},{},"\u002F2026-08-19",{"title":5821,"description":5826},"2026-08-19-三十集的项目点了十几轮提取","一个 30 集项目点了十几轮资产提取，198 次模型调用烧掉 145.77 元，其中 95% 是重复的。断点续传的实现方式与费用确认浮窗的设计取舍。",[6440,2727,1288,6441,6442,6443],"断点续传","费用确认","sha256","漫剧","ba1tqdoZIfz3BDTQBT1JpEuUjCsRv2BQ1xH9gebPvkw",{"id":6446,"title":6447,"body":6448,"column":6815,"date":6816,"description":6817,"extension":199,"hero_image":200,"meta":6818,"navigation":202,"path":6819,"seo":6820,"series_id":200,"severity":200,"stem":6821,"summary":6822,"tags":6823,"__hash__":6829},"posts\u002F2026-08-16-让会话记住目录让回答能停下.md","让会话记住目录，让回答能停下",{"type":8,"value":6449,"toc":6806},[6450,6456,6460,6463,6466,6469,6494,6497,6503,6510,6516,6520,6523,6537,6540,6543,6546,6550,6553,6558,6573,6576,6581,6590,6593,6598,6607,6610,6614,6617,6620,6625,6631,6634,6639,6648,6651,6656,6665,6668,6675,6680,6689,6692,6697,6700,6704,6707,6714,6718,6721,6724,6728,6731,6794,6797,6803],[11,6451,6452,6453],{},"同一天做了两件事。表面上一个关于文件系统，一个关于流式生成，实际在解决同一个问题：",[488,6454,6455],{},"长动作要么在一开始就能定好条件，要么在过程中能叫停。",[26,6457,6459],{"id":6458},"一工作目录原先只活在一次调用里","一、工作目录原先只活在一次调用里",[11,6461,6462],{},"工具调用要指定工作目录。原先的实现是每次调用各自带一个。",[11,6464,6465],{},"这在单次调用里没问题，但会话是连续的：用户在一个会话里说「看看这个项目」「改一下那个配置」「跑一下测试」，每次都指定一遍目录，重复且容易出错。",[11,6467,6468],{},"改成会话级能力。数据模型加两个字段：",[399,6470,6472],{"className":6339,"code":6471,"language":6341,"meta":183,"style":183},"-- 会话级工作文件夹：null 表示沿用设备上报的默认目录，对存量会话零影响。\nALTER TABLE \"Session\" ADD COLUMN \"workspaceDir\" TEXT;\n-- 桌面端注册时上报的本机默认工作文件夹；老版本客户端不上报则保持 null。\nALTER TABLE \"Device\" ADD COLUMN \"defaultWorkspaceDir\" TEXT;\n",[15,6473,6474,6479,6484,6489],{"__ignoreMap":183},[407,6475,6476],{"class":409,"line":410},[407,6477,6478],{},"-- 会话级工作文件夹：null 表示沿用设备上报的默认目录，对存量会话零影响。\n",[407,6480,6481],{"class":409,"line":184},[407,6482,6483],{},"ALTER TABLE \"Session\" ADD COLUMN \"workspaceDir\" TEXT;\n",[407,6485,6486],{"class":409,"line":189},[407,6487,6488],{},"-- 桌面端注册时上报的本机默认工作文件夹；老版本客户端不上报则保持 null。\n",[407,6490,6491],{"class":409,"line":452},[407,6492,6493],{},"ALTER TABLE \"Device\" ADD COLUMN \"defaultWorkspaceDir\" TEXT;\n",[11,6495,6496],{},"两个字段的分工是：设备上报它本机的默认目录，会话可以覆盖它。取值优先级：",[399,6498,6501],{"className":6499,"code":6500,"language":1327},[1325],"会话值 ?? 设备默认值\n",[15,6502,6500],{"__ignoreMap":183},[11,6504,6505,6506,6509],{},"迁移只用加列，",[15,6507,6508],{},"null"," 表示沿用设备默认值，存量会话不受影响。",[11,6511,6512,6513,781],{},"长度上限 512 字符，写在协议包里：",[15,6514,6515],{},"WORKSPACE_DIR_MAX_LENGTH = 512",[26,6517,6519],{"id":6518},"二四个执行入口共用同一个目录","二、四个执行入口共用同一个目录",[11,6521,6522],{},"目录信息要传到的位置比想象中多。同一件事在四个地方会被执行：",[123,6524,6525,6528,6531,6534],{},[126,6526,6527],{},"工具调用",[126,6529,6530],{},"Agent 团队运行",[126,6532,6533],{},"设备协作",[126,6535,6536],{},"计划任务",[11,6538,6539],{},"如果只有工具调用拿到会话目录，其余三个各自回落到设备默认值，用户会遇到「我在会话里设了目录，跑计划任务却跑到了别的地方」。",[11,6541,6542],{},"所以目录跟着会话走，四个入口都从同一个地方取。",[11,6544,6545],{},"跨端还有一层：桌面端主进程、预加载层、网页端桥接、聊天界面。四处都要能读能写，用户才能在会话里查看或切换目录。",[26,6547,6549],{"id":6548},"三路径校验的三个坑","三、路径校验的三个坑",[11,6551,6552],{},"工作目录涉及文件系统，三处安全处理值得单独写。",[11,6554,6555],{},[488,6556,6557],{},"相对路径要先解析成绝对路径。",[399,6559,6561],{"className":691,"code":6560,"language":693,"meta":183,"style":183},"\u002F\u002F 相对路径必须先解析成绝对路径再做高危校验，否则 ..\u002F..\u002F..\u002Fetc\u002Fpasswd\n\u002F\u002F 这类相对串匹配不上敏感路径正则会被直接放行\n",[15,6562,6563,6568],{"__ignoreMap":183},[407,6564,6565],{"class":409,"line":410},[407,6566,6567],{"class":528},"\u002F\u002F 相对路径必须先解析成绝对路径再做高危校验，否则 ..\u002F..\u002F..\u002Fetc\u002Fpasswd\n",[407,6569,6570],{"class":409,"line":184},[407,6571,6572],{"class":528},"\u002F\u002F 这类相对串匹配不上敏感路径正则会被直接放行\n",[11,6574,6575],{},"先做正则匹配再解析，等于给相对路径开了一条绕过通道——它们看起来不像敏感路径。",[11,6577,6578],{},[488,6579,6580],{},"打开目录前先确认它是目录。",[399,6582,6584],{"className":691,"code":6583,"language":693,"meta":183,"style":183},"\u002F\u002F open-workspace-dir 先 stat 确认是目录才放行，避免经 shell.openPath 执行可执行文件\n",[15,6585,6586],{"__ignoreMap":183},[407,6587,6588],{"class":409,"line":410},[407,6589,6583],{"class":528},[11,6591,6592],{},"用系统调用打开路径时，如果路径指向一个可执行文件，行为会变成执行它。",[11,6594,6595],{},[488,6596,6597],{},"目录不存在时要说明，不能静默回落。",[399,6599,6601],{"className":691,"code":6600,"language":693,"meta":183,"style":183},"\u002F\u002F 换机时会话目录在本机不存在则回落设备默认目录，并在工具结果中明示，不静默。\n",[15,6602,6603],{"__ignoreMap":183},[407,6604,6605],{"class":409,"line":410},[407,6606,6600],{"class":528},[11,6608,6609],{},"这一条和一个功能细节有关：会话可以跨设备打开。用户在公司电脑上设的目录，回家打开时本机不存在。此时回落是唯一选择，但用户必须知道发生了回落——否则他会以为工具跑在他指定的目录里。",[26,6611,6613],{"id":6612},"四停止生成","四、停止生成",[11,6615,6616],{},"第二个功能是让流式回答可以中途停止。",[11,6618,6619],{},"场景很具体：模型开始生成一段长回答，用户看前两段就知道方向不对。原先只能等它写完，或者刷新页面重新提问。",[11,6621,6622],{},[488,6623,6624],{},"停止信号走 Redis。",[399,6626,6629],{"className":6627,"code":6628,"language":1327},[1325],"POST \u002Fapi\u002Fchat\u002Fstop\n",[15,6630,6628],{"__ignoreMap":183},[11,6632,6633],{},"校验会话归属之后，往 Redis 写一个停止信号。选 Redis 而不是进程内变量，是因为生成可能发生在任意一个 pod 上——信号要跨 pod 生效。同一个会话重复调用是幂等的。",[11,6635,6636],{},[488,6637,6638],{},"生成侧轮询读信号。",[399,6640,6642],{"className":691,"code":6641,"language":693,"meta":183,"style":183},"\u002F\u002F 生成侧用节流轮询（300ms）读信号，轮边界与流式回调两处检查点；在途工具跑完再收尾\n",[15,6643,6644],{"__ignoreMap":183},[407,6645,6646],{"class":409,"line":410},[407,6647,6641],{"class":528},[11,6649,6650],{},"两个检查点：一个是每轮对话的边界，一个是流式回调。工具执行中途不打断——一个已经在跑的工具调用停下来会留下半截状态，等它跑完再收尾。",[11,6652,6653],{},[488,6654,6655],{},"停止必须落一条消息。",[399,6657,6659],{"className":691,"code":6658,"language":693,"meta":183,"style":183},"\u002F\u002F 停止一定落一条 assistant 消息并记 stoppedAt，否则前端会一直判成生成中\n",[15,6660,6661],{"__ignoreMap":183},[407,6662,6663],{"class":409,"line":410},[407,6664,6658],{"class":528},[11,6666,6667],{},"这是被前端状态机决定的。前端的「生成中」判定依据是「最后一条是不是用户消息」，如果停止之后不落一条 assistant 消息，界面会一直显示在生成。",[11,6669,6670,6671,6674],{},"数据库加一个字段：",[15,6672,6673],{},"Message.stoppedAt","。落这条消息时记下停止时刻——刷新、重进会话之后状态不该丢。",[11,6676,6677],{},[488,6678,6679],{},"结算按实际用量。",[399,6681,6683],{"className":691,"code":6682,"language":693,"meta":183,"style":183},"\u002F\u002F 结算走原有 settle 路径按实际用量多退少补；被停止的半截回答不进长期记忆\n",[15,6684,6685],{"__ignoreMap":183},[407,6686,6687],{"class":409,"line":410},[407,6688,6682],{"class":528},[11,6690,6691],{},"半截回答不入长期记忆这一条是必要的。记忆提取的输入是完整的一轮对话，半截内容提取出来的「用户偏好」会是错的。",[11,6693,6694],{},[488,6695,6696],{},"继续写。",[11,6698,6699],{},"停止之后，末条消息上给一个「继续写」。它不是重新生成——从停止的位置接着往下，上下文里包含已经写好的部分。",[26,6701,6703],{"id":6702},"五界面上只有一个按钮","五、界面上只有一个按钮",[11,6705,6706],{},"生成中，发送按钮变成停止按钮。桌面端和移动端两套布局都要改。",[11,6708,6709,6710,6713],{},"这个交互决定了一个细节：停止是一个",[488,6711,6712],{},"和发送同一位置","的操作。用户在等待时手指本来就停在那个位置，改文案比加一个新按钮更快。",[26,6715,6717],{"id":6716},"六桌面端版本","六、桌面端版本",[11,6719,6720],{},"这两个功能里，工作目录要桌面端配合。桌面端从 1.0.32 升到 1.0.33。",[11,6722,6723],{},"顺序上有依赖：云端先上线，桌面端包发出后功能才对用户可见。所以版本号更新是单独一个提交，不是为了记录，是为了标记「从哪个包开始这个功能可用」。",[26,6725,6727],{"id":6726},"七两个功能的共同点","七、两个功能的共同点",[11,6729,6730],{},"把它们放在一起看，都是「让用户在长动作里保留控制权」：",[1708,6732,6733,6745],{},[1711,6734,6735],{},[1714,6736,6737,6739,6742],{},[1717,6738],{},[1717,6740,6741],{},"工作目录",[1717,6743,6744],{},"停止生成",[1730,6746,6747,6758,6772,6783],{},[1714,6748,6749,6752,6755],{},[1735,6750,6751],{},"控制发生在",[1735,6753,6754],{},"事前指定",[1735,6756,6757],{},"事中叫停",[1714,6759,6760,6763,6768],{},[1735,6761,6762],{},"数据落点",[1735,6764,6765],{},[15,6766,6767],{},"Session.workspaceDir",[1735,6769,6770],{},[15,6771,6673],{},[1714,6773,6774,6777,6780],{},[1735,6775,6776],{},"跨端要求",[1735,6778,6779],{},"主进程 \u002F 预加载 \u002F 桥接 \u002F 界面",[1735,6781,6782],{},"桌面与移动两套布局",[1714,6784,6785,6788,6791],{},[1735,6786,6787],{},"不静默的地方",[1735,6789,6790],{},"目录不存在时回落要明示",[1735,6792,6793],{},"停止后必须落消息，界面不能一直显示生成中",[11,6795,6796],{},"四条对应关系里，最后一行是最容易做错的：两个功能都有「状态没落对，界面就一直显示在跑」的风险。工作目录那条如果静默回落，用户以为跑在 A 目录，实际跑在 B；停止那条如果不落消息，用户以为停了，界面说还在跑。",[11,6798,6799,6800],{},"改法和预想的一样朴素：",[488,6801,6802],{},"把状态放在能读到的地方，并且让用户看见。",[1267,6804,6805],{},"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}",{"title":183,"searchDepth":184,"depth":184,"links":6807},[6808,6809,6810,6811,6812,6813,6814],{"id":6458,"depth":184,"text":6459},{"id":6518,"depth":184,"text":6519},{"id":6548,"depth":184,"text":6549},{"id":6612,"depth":184,"text":6613},{"id":6702,"depth":184,"text":6703},{"id":6716,"depth":184,"text":6717},{"id":6726,"depth":184,"text":6727},"交付与更新","2026-08-16","同一天做了两件事。表面上一个关于文件系统，一个关于流式生成，实际在解决同一个问题：长动作要么在一开始就能定好条件，要么在过程中能叫停。",{},"\u002F2026-08-16",{"title":6447,"description":6817},"2026-08-16-让会话记住目录让回答能停下","两个看似无关的功能：把工作目录从单次工具调用提升为会话级能力；让正在生成的回答可以中途停止并继续。共同点是给长动作加上用户的控制权。",[212,6824,6825,6826,6827,6828],"会话状态","文件系统","路径校验","流式生成","中断","jiUeJkYnicdLCfFjfh09Sim0_nWpiER2NhMrT2FeRKQ",{"id":6831,"title":6832,"body":6833,"column":196,"date":7165,"description":6837,"extension":199,"hero_image":200,"meta":7166,"navigation":202,"path":7167,"seo":7168,"series_id":200,"severity":200,"stem":7169,"summary":7170,"tags":7171,"__hash__":7177},"posts\u002F2026-08-15-上线前我把整个仓库审了一遍.md","上线前，我把整个仓库审了一遍",{"type":8,"value":6834,"toc":7154},[6835,6838,6841,6845,6848,6868,6871,6874,6878,6881,6884,6887,6890,6894,6897,6900,6960,6963,6969,6973,6978,6981,6984,6991,6995,6998,7009,7012,7016,7019,7025,7028,7041,7044,7047,7051,7054,7061,7067,7070,7074,7077,7082,7099,7104,7118,7121,7124,7128,7131,7151],[11,6836,6837],{},"上线前做了一次全量静态审查。",[11,6839,6840],{},"本文只讲方法和结论分布。审查报告本身不入库，也不在这里复述——那份报告带内网拓扑、密钥存放位置和尚未修复问题的文件行号证据，公开它就等于公开一份攻击者的地图。",[26,6842,6844],{"id":6843},"一审查怎么进行","一、审查怎么进行",[11,6846,6847],{},"三个部分：",[243,6849,6850,6856,6862],{},[126,6851,6852,6855],{},[488,6853,6854],{},"人工列清单。"," 先按「一个系统可能从哪些地方被打穿」列出检查面，而不是按目录扫。",[126,6857,6858,6861],{},[488,6859,6860],{},"四个并行只读探查代理。"," 每个代理负责一组检查面，只读，不允许改任何文件。",[126,6863,6864,6867],{},[488,6865,6866],{},"关键证据人工逐条复核。"," 代理解释现象可以，但「这条算不算风险、算多重」由人定。",[11,6869,6870],{},"第三步是必要的。代理很容易把「写法不理想」报成「高危」，也很容易漏掉需要跨文件才能看出的问题。它适合做的是穷举和取证，不适合定级。",[11,6872,6873],{},"审查方式记为：静态审查（人工 + 4 个并行只读探查代理）+ 关键证据人工逐条复核。",[26,6875,6877],{"id":6876},"二只读约束的作用","二、只读约束的作用",[11,6879,6880],{},"让探查代理只能读，有两个好处。",[11,6882,6883],{},"一是没有副作用。审查过程中仓库保持原样，报告的结论可以对着同一个 commit 复现。",[11,6885,6886],{},"二是省掉了「代理顺手改一下」的干扰。审查和修改混在一起，会出现「报告说有问题的地方已经被改过」的状态，后续复核就无法对着证据走。",[11,6888,6889],{},"修改是审查之后单独一轮的事。",[26,6891,6893],{"id":6892},"三20-条与四个级别","三、20 条与四个级别",[11,6895,6896],{},"产出 20 条风险，编号 R01 到 R20。每一条有：位置、触发条件、影响、修复优先级。",[11,6898,6899],{},"按修复优先级分四档：",[1708,6901,6902,6915],{},[1711,6903,6904],{},[1714,6905,6906,6909,6912],{},[1717,6907,6908],{},"优先级",[1717,6910,6911],{},"条数",[1717,6913,6914],{},"排期",[1730,6916,6917,6928,6939,6950],{},[1714,6918,6919,6922,6925],{},[1735,6920,6921],{},"P0",[1735,6923,6924],{},"2",[1735,6926,6927],{},"本周内",[1714,6929,6930,6933,6936],{},[1735,6931,6932],{},"P1",[1735,6934,6935],{},"6",[1735,6937,6938],{},"两周内",[1714,6940,6941,6944,6947],{},[1735,6942,6943],{},"P2",[1735,6945,6946],{},"5",[1735,6948,6949],{},"一个月内",[1714,6951,6952,6955,6958],{},[1735,6953,6954],{},"P3",[1735,6956,6957],{},"7",[1735,6959,6949],{},[11,6961,6962],{},"另外有一个独立的「严重级别」列，按严重 \u002F 高 \u002F 中 \u002F 低分：1 \u002F 4 \u002F 7 \u002F 8。两列不对应是有意的——「有多严重」和「该多快修」是两件事。一条严重但需要停机才能修的问题，排期上反而不能放在本周。",[11,6964,6965,6966,781],{},"整体评级：",[488,6967,6968],{},"高",[26,6970,6972],{"id":6971},"四结论里最值得记的一句","四、结论里最值得记的一句",[1205,6974,6975],{},[11,6976,6977],{},"未发现 SQL 注入、越权与 IDOR、存储型 XSS 等直接可利用的应用漏洞。",[11,6979,6980],{},"这一句和「整体评级高」摆在一起看，是这个月里最值得记的一条工程结论。",[11,6982,6983],{},"风险分布和直觉不一致。业务代码那一侧没查出可直接利用的漏洞——那部分每天在改、每天在跑、每天有人看。问题更多集中在业务代码之外：构建、部署、配置、凭据流转、依赖引入、运行时加固。",[11,6985,6986,6987,6990],{},"这些东西的共同点是",[488,6988,6989],{},"平时不跑","。部署脚本一天执行几次，Dockerfile 改一次放很久，集群配置是照着文档抄的。没有日常反馈的东西，问题会一直留在那里，而且不会有人因为「它今天没出事」而去核对它。",[26,6992,6994],{"id":6993},"五修复的排期方式","五、修复的排期方式",[11,6996,6997],{},"路线图按三档排：P0 本周、P1 两周内、P2\u002FP3 一个月内。",[11,6999,7000,7001,7004,7005,7008],{},"排期依据不是严重级别，是两件事：",[488,7002,7003],{},"能不能立即修","，和",[488,7006,7007],{},"修它会不会动到线上","。一条严重但需要停机或改基础设施配置的问题，得等一个发布窗口；一条中等但改动只在一处代码里的问题，可以马上做。P2\u002FP3 是加固项，攒到下一个版本一起走。",[11,7010,7011],{},"这样做的好处是排期可执行。「两个月内修完 20 条」听起来完整，实际会卡在第一条需要停机的问题上。",[26,7013,7015],{"id":7014},"六报告为什么不入库","六、报告为什么不入库",[11,7017,7018],{},"仓库的忽略规则里专门有一条：",[399,7020,7023],{"className":7021,"code":7022,"language":1327},[1325],"# 安全审查报告：含内网拓扑、密钥存放路径与未修复漏洞的 file:line 证据，绝不入库\n",[15,7024,7022],{"__ignoreMap":183},[11,7026,7027],{},"三条理由，最后一条最关键：",[123,7029,7030,7033,7036],{},[126,7031,7032],{},"内网拓扑",[126,7034,7035],{},"密钥存放位置",[126,7037,7038],{},[488,7039,7040],{},"未修复漏洞的精确定位",[11,7042,7043],{},"前两条是常识。第三条是这类文档的特例：一份「待修复清单」在修完之前，本身就是攻击说明书。而它又必须有精确定位，否则修不动。",[11,7045,7046],{},"所以这份文档的流转方式和代码相反。代码进仓库、留历史；它不进仓库，修完一条删一条，修完的结论改记到提交信息里。",[26,7048,7050],{"id":7049},"七审查之后同一天修的东西","七、审查之后，同一天修的东西",[11,7052,7053],{},"审查和修复放在同一天做，这是这次的一个做法。",[11,7055,7056,7057,7060],{},"当天修的一批，主题集中在一类：",[488,7058,7059],{},"不报错的失败","。队列投递端断线后不重连、依赖返回的包装形状读错导致判定恒为假、入队不带权重插到最前面、参数格式不对被上游全部拒收、重试链被自己的去重压短、管理操作的幂等键缺失、用户可控的文件名直接进对象存储键、唯一漏掉来源校验的 IPC handler、并发删除后返回假成功、过期验证码被复活、临时文件不清理。",[11,7062,7063,7064,781],{},"这些已经有单独一篇记录，这里只提它们的共性：",[488,7065,7066],{},"都不抛异常",[11,7068,7069],{},"审查报告里那些「不进仓库」的条目和这些「当天修完」的条目，其实是同一批检查过出来的。区别只在于前者需要动基础设施、要等窗口，后者可以立刻改。",[26,7071,7073],{"id":7072},"八静态审查的边界","八、静态审查的边界",[11,7075,7076],{},"这一轮做的是静态审查，能覆盖和不能覆盖的东西都比较明确。",[11,7078,7079],{},[488,7080,7081],{},"能覆盖：",[123,7083,7084,7087,7090,7093,7096],{},[126,7085,7086],{},"配置与依赖：镜像标签、依赖来源、加密参数、权限配置。",[126,7088,7089],{},"错误处理：异常的吞掉与上抛、失败路径有没有留痕。",[126,7091,7092],{},"凭据流转：什么地方读、什么地方写、会不会进构建产物。",[126,7094,7095],{},"部署清单：运行用户、资源限制、网络策略、滚动更新参数。",[126,7097,7098],{},"代码层面的模式：路径拼接、命令拼接、输入校验的缺失。",[11,7100,7101],{},[488,7102,7103],{},"覆盖不到：",[123,7105,7106,7109,7112,7115],{},[126,7107,7108],{},"运行时竞态。两个请求同时到、并发删除、租约过期，这些要在真实并发下才暴露。",[126,7110,7111],{},"真实的流量特征。哪些入口真的被外部打到、参数实际长什么样，静态看只能推测。",[126,7113,7114],{},"业务逻辑漏洞。越权取数据这类问题需要理解「这个人该不该看到这条」，而业务意图不在代码里。",[126,7116,7117],{},"组合链。单看每一步都合规，串起来才是问题——这类要动态测试或者真实对抗。",[11,7119,7120],{},"第三类是最容易被静态审查漏掉的：代码里所有校验都写了，但校验的规则和业务规则不一致。",[11,7122,7123],{},"所以静态审查的产出不是「系统安全了」，而是「在它能看见的那一面里，还剩哪些要做」。",[26,7125,7127],{"id":7126},"九一次审查真正留下的东西","九、一次审查真正留下的东西",[11,7129,7130],{},"审完当天修掉的那批，留下的是代码。报告里那些没修的，留下的是三样东西：",[243,7132,7133,7139,7145],{},[126,7134,7135,7138],{},[488,7136,7137],{},"一份带精确定位和优先级的清单","，不进仓库，逐条消掉。",[126,7140,7141,7144],{},[488,7142,7143],{},"一个判断","：风险集中的地方和日常注意力集中的地方不一样。业务代码天天改，反而干净；构建部署配置平时没人动。",[126,7146,7147,7150],{},[488,7148,7149],{},"一次复核的习惯","：审查的结论要能对着一个 commit 复现，所以探查只读、修改单独一轮。",[11,7152,7153],{},"第 2 条比第 1 条重要。清单会修完，但「平时不跑的东西没人核对」这件事不会自己消失。",{"title":183,"searchDepth":184,"depth":184,"links":7155},[7156,7157,7158,7159,7160,7161,7162,7163,7164],{"id":6843,"depth":184,"text":6844},{"id":6876,"depth":184,"text":6877},{"id":6892,"depth":184,"text":6893},{"id":6971,"depth":184,"text":6972},{"id":6993,"depth":184,"text":6994},{"id":7014,"depth":184,"text":7015},{"id":7049,"depth":184,"text":7050},{"id":7072,"depth":184,"text":7073},{"id":7126,"depth":184,"text":7127},"2026-08-15",{},"\u002F2026-08-15",{"title":6832,"description":6837},"2026-08-15-上线前我把整个仓库审了一遍","一次上线前的静态安全审查：四个并行只读代理加人工逐条复核，产出 20 条分级风险与三档修复路线图。本文记录方法，不复述条目。",[7172,7173,7174,7175,7176],"安全审查","代码审查","风险分级","修复排期","Agent 协作","bnkC_2bMe8OfgB00HIfDUS9bdRwLUZb_WqDBvXtq__M",{"id":7179,"title":7180,"body":7181,"column":355,"date":8134,"description":7185,"extension":199,"hero_image":200,"meta":8135,"navigation":202,"path":8136,"seo":8137,"series_id":8138,"severity":200,"stem":8139,"summary":8140,"tags":8141,"__hash__":8148},"posts\u002F2026-08-13-FA-020-没人报错因为错误被吞了.md","没人报错，因为错误被吞了",{"type":8,"value":7182,"toc":8127},[7183,7186,7189,7193,7198,7201,7204,7234,7240,7265,7268,7271,7274,7311,7316,7340,7343,7567,7573,7578,7621,7631,7699,7704,7707,7714,7737,7740,7744,7749,7752,7758,7769,7776,7779,7806,7813,7818,7828,7831,7834,7838,7841,7847,7853,7859,7900,7906,7916,7922,7937,7943,7947,7952,7959,8019,8025,8061,8067,8071,8077,8091,8097,8100,8118,8124],[11,7184,7185],{},"上线前把整个仓库过了一遍，一边审一边修。这一天的提交里，有一个反复出现的形态。",[11,7187,7188],{},"它们都不抛异常，不写日志，不改变任何可见状态。接口返回成功，或者返回一句和真实原因无关的话。我把它们按「错误从哪一步消失」分成了四类。",[26,7190,7192],{"id":7191},"一连不上但不说","一、连不上，但不说",[11,7194,7195],{},[488,7196,7197],{},"投递端断了不重连。",[11,7199,7200],{},"建站提交报「服务繁忙，请稍后重试」，PRD 卡在人工评审。两个功能，同一个根因。",[11,7202,7203],{},"投递端 Redis 连接断开后从不重连：",[399,7205,7207],{"className":691,"code":7206,"language":693,"meta":183,"style":183},"readonly retryStrategy: (_attempt: number) => null;\n",[15,7208,7209],{"__ignoreMap":183},[407,7210,7211,7214,7217,7219,7222,7224,7226,7228,7230,7232],{"class":409,"line":410},[407,7212,7213],{"class":413},"readonly ",[407,7215,7216],{"class":554},"retryStrategy",[407,7218,3027],{"class":413},[407,7220,7221],{"class":718},"_attempt",[407,7223,1059],{"class":417},[407,7225,5882],{"class":476},[407,7227,722],{"class":413},[407,7229,520],{"class":417},[407,7231,3485],{"class":476},[407,7233,918],{"class":413},[11,7235,7236,7237,7239],{},"返回 ",[15,7238,6508],{}," 是「彻底放弃重连」。这个设计配的假设写在测试标题里——「短生命周期 producer」，用完即弃自然不必重连。但实际用法是懒加载单例：",[399,7241,7243],{"className":691,"code":7242,"language":693,"meta":183,"style":183},"producerQueue ??= new Queue(...)\n",[15,7244,7245],{"__ignoreMap":183},[407,7246,7247,7250,7253,7255,7258,7260,7263],{"class":409,"line":410},[407,7248,7249],{"class":413},"producerQueue ",[407,7251,7252],{"class":417},"??=",[407,7254,3810],{"class":417},[407,7256,7257],{"class":554}," Queue",[407,7259,586],{"class":413},[407,7261,7262],{"class":417},"...",[407,7264,3970],{"class":413},[11,7266,7267],{},"它活得和 api 进程一样久。Redis 抖一次、重启一次或主备切一次，投递端就再也连不回来。之后每次入队都抛错，路由回滚状态并返回 502，直到 pod 重启才恢复。",[11,7269,7270],{},"受影响的是全部 6 个用该连接的队列生产者：建站、PRD、公众号图文，以及画布的三个队列。这也解释了画布运行节点长期停滞。",[11,7272,7273],{},"改成退避重连，上限 3 秒：",[399,7275,7277],{"className":691,"code":7276,"language":693,"meta":183,"style":183},"retryStrategy: (attempt) => Math.min(attempt * 200, 3_000),\n",[15,7278,7279],{"__ignoreMap":183},[407,7280,7281,7283,7285,7288,7290,7292,7294,7296,7299,7301,7304,7306,7309],{"class":409,"line":410},[407,7282,7216],{"class":554},[407,7284,3027],{"class":413},[407,7286,7287],{"class":718},"attempt",[407,7289,722],{"class":413},[407,7291,520],{"class":417},[407,7293,1521],{"class":413},[407,7295,992],{"class":554},[407,7297,7298],{"class":413},"(attempt ",[407,7300,1540],{"class":417},[407,7302,7303],{"class":476}," 200",[407,7305,433],{"class":413},[407,7307,7308],{"class":476},"3_000",[407,7310,3104],{"class":413},[11,7312,7313],{},[488,7314,7315],{},"重连带来一个新问题。",[11,7317,7318,7319,7322,7323,7326,7327,7329,7330,7332,7333,4375,7336,7339],{},"上面这条修完后，第二个问题浮出来：冷启动或单例重建期间，BullMQ 的 ",[15,7320,7321],{},"waitUntilReady"," 只在 ",[15,7324,7325],{},"end"," 事件上 reject，而永续的 ",[15,7328,7216],{}," 让 ioredis 永不发 ",[15,7331,7325],{},"。结果是 ",[15,7334,7335],{},"add",[15,7337,7338],{},"getJob"," 无限挂起，HTTP 请求堆积。",[11,7341,7342],{},"给入队操作加硬超时：",[399,7344,7346],{"className":691,"code":7345,"language":693,"meta":183,"style":183},"export async function withProducerDeadline\u003CT>(\n  operation: Promise\u003CT>,\n  label: string,\n  timeoutMs = loadBullNumber(\"BULL_PRODUCER_OP_TIMEOUT_MS\", 900),\n): Promise\u003CT> {\n  const deadline = new Promise\u003Cnever>((_, reject) => {\n    timer = setTimeout(\n      () => reject(new Error(`[bull-producer] ${label} 在 ${timeoutMs}ms 内未完成：Redis 暂不可用`)),\n      timeoutMs,\n    );\n    timer.unref?.();\n  });\n  return await Promise.race([operation, deadline]);\n}\n",[15,7347,7348,7368,7385,7396,7418,7432,7467,7480,7518,7523,7528,7539,7544,7562],{"__ignoreMap":183},[407,7349,7350,7352,7355,7357,7360,7362,7365],{"class":409,"line":410},[407,7351,1045],{"class":417},[407,7353,7354],{"class":417}," async",[407,7356,1048],{"class":417},[407,7358,7359],{"class":554}," withProducerDeadline",[407,7361,1073],{"class":413},[407,7363,7364],{"class":554},"T",[407,7366,7367],{"class":413},">(\n",[407,7369,7370,7373,7375,7378,7380,7382],{"class":409,"line":184},[407,7371,7372],{"class":718},"  operation",[407,7374,1059],{"class":417},[407,7376,7377],{"class":554}," Promise",[407,7379,1073],{"class":413},[407,7381,7364],{"class":554},[407,7383,7384],{"class":413},">,\n",[407,7386,7387,7390,7392,7394],{"class":409,"line":189},[407,7388,7389],{"class":718},"  label",[407,7391,1059],{"class":417},[407,7393,3035],{"class":476},[407,7395,3296],{"class":413},[407,7397,7398,7401,7403,7406,7408,7411,7413,7416],{"class":409,"line":452},[407,7399,7400],{"class":718},"  timeoutMs",[407,7402,706],{"class":417},[407,7404,7405],{"class":554}," loadBullNumber",[407,7407,586],{"class":413},[407,7409,7410],{"class":429},"\"BULL_PRODUCER_OP_TIMEOUT_MS\"",[407,7412,433],{"class":413},[407,7414,7415],{"class":476},"900",[407,7417,3104],{"class":413},[407,7419,7420,7422,7424,7426,7428,7430],{"class":409,"line":458},[407,7421,1065],{"class":413},[407,7423,1059],{"class":417},[407,7425,7377],{"class":554},[407,7427,1073],{"class":413},[407,7429,7364],{"class":554},[407,7431,1083],{"class":413},[407,7433,7434,7436,7439,7441,7443,7445,7447,7450,7453,7456,7458,7461,7463,7465],{"class":409,"line":464},[407,7435,892],{"class":417},[407,7437,7438],{"class":476}," deadline",[407,7440,706],{"class":417},[407,7442,3810],{"class":417},[407,7444,7377],{"class":476},[407,7446,1073],{"class":413},[407,7448,7449],{"class":476},"never",[407,7451,7452],{"class":413},">((",[407,7454,7455],{"class":718},"_",[407,7457,433],{"class":413},[407,7459,7460],{"class":718},"reject",[407,7462,722],{"class":413},[407,7464,520],{"class":417},[407,7466,523],{"class":413},[407,7468,7469,7472,7474,7477],{"class":409,"line":470},[407,7470,7471],{"class":413},"    timer ",[407,7473,418],{"class":417},[407,7475,7476],{"class":554}," setTimeout",[407,7478,7479],{"class":413},"(\n",[407,7481,7482,7485,7487,7490,7492,7495,7498,7500,7503,7506,7509,7512,7515],{"class":409,"line":480},[407,7483,7484],{"class":413},"      () ",[407,7486,520],{"class":417},[407,7488,7489],{"class":554}," reject",[407,7491,586],{"class":413},[407,7493,7494],{"class":417},"new",[407,7496,7497],{"class":554}," Error",[407,7499,586],{"class":413},[407,7501,7502],{"class":429},"`[bull-producer] ${",[407,7504,7505],{"class":413},"label",[407,7507,7508],{"class":429},"} 在 ${",[407,7510,7511],{"class":413},"timeoutMs",[407,7513,7514],{"class":429},"}ms 内未完成：Redis 暂不可用`",[407,7516,7517],{"class":413},")),\n",[407,7519,7520],{"class":409,"line":1477},[407,7521,7522],{"class":413},"      timeoutMs,\n",[407,7524,7525],{"class":409,"line":1483},[407,7526,7527],{"class":413},"    );\n",[407,7529,7530,7533,7536],{"class":409,"line":2139},[407,7531,7532],{"class":413},"    timer.",[407,7534,7535],{"class":554},"unref",[407,7537,7538],{"class":413},"?.();\n",[407,7540,7541],{"class":409,"line":2180},[407,7542,7543],{"class":413},"  });\n",[407,7545,7547,7549,7552,7554,7556,7559],{"class":409,"line":7546},13,[407,7548,2142],{"class":417},[407,7550,7551],{"class":417}," await",[407,7553,7377],{"class":476},[407,7555,3767],{"class":413},[407,7557,7558],{"class":554},"race",[407,7560,7561],{"class":413},"([operation, deadline]);\n",[407,7563,7565],{"class":409,"line":7564},14,[407,7566,483],{"class":413},[11,7568,7569,7570,7572],{},"默认 900 毫秒。超时只放弃这一次等待，连接层仍在后台按 ",[15,7571,7216],{}," 重连。两件事互不冲突：重连是连接层的后台行为，单条命令该失败照样立刻失败。",[11,7574,7575],{},[488,7576,7577],{},"VIP 判定恒为假。",[399,7579,7581],{"className":691,"code":7580,"language":693,"meta":183,"style":183},"const isVip = await billing.getVipSummary?.(userId).then((s) => Boolean(s?.membershipVipBenefit));\n",[15,7582,7583],{"__ignoreMap":183},[407,7584,7585,7587,7590,7592,7594,7597,7600,7603,7606,7608,7611,7613,7615,7618],{"class":409,"line":410},[407,7586,700],{"class":417},[407,7588,7589],{"class":476}," isVip",[407,7591,706],{"class":417},[407,7593,7551],{"class":417},[407,7595,7596],{"class":413}," billing.",[407,7598,7599],{"class":554},"getVipSummary",[407,7601,7602],{"class":413},"?.(userId).",[407,7604,7605],{"class":554},"then",[407,7607,715],{"class":413},[407,7609,7610],{"class":718},"s",[407,7612,722],{"class":413},[407,7614,520],{"class":417},[407,7616,7617],{"class":554}," Boolean",[407,7619,7620],{"class":413},"(s?.membershipVipBenefit));\n",[11,7622,7623,7624,7627,7628,7630],{},"真实 client 返回的是 ",[15,7625,7626],{},"{ data: VipSummary }","，这里只读裸字段，取到 ",[15,7629,5267],{},"。VIP 判定恒 false，加速队列从未生效。两种形状都认之后才通：",[399,7632,7634],{"className":691,"code":7633,"language":693,"meta":183,"style":183},"const raw = s as { membershipVipBenefit?: boolean; data?: { membershipVipBenefit?: boolean } } | null;\nreturn Boolean(raw?.data?.membershipVipBenefit ?? raw?.membershipVipBenefit);\n",[15,7635,7636,7685],{"__ignoreMap":183},[407,7637,7638,7640,7643,7645,7648,7650,7653,7656,7658,7661,7663,7666,7668,7670,7672,7674,7676,7679,7681,7683],{"class":409,"line":410},[407,7639,700],{"class":417},[407,7641,7642],{"class":476}," raw",[407,7644,706],{"class":417},[407,7646,7647],{"class":413}," s ",[407,7649,903],{"class":417},[407,7651,7652],{"class":413}," { ",[407,7654,7655],{"class":718},"membershipVipBenefit",[407,7657,3702],{"class":417},[407,7659,7660],{"class":476}," boolean",[407,7662,1121],{"class":413},[407,7664,7665],{"class":718},"data",[407,7667,3702],{"class":417},[407,7669,7652],{"class":413},[407,7671,7655],{"class":718},[407,7673,3702],{"class":417},[407,7675,7660],{"class":476},[407,7677,7678],{"class":413}," } } ",[407,7680,2675],{"class":417},[407,7682,3485],{"class":476},[407,7684,918],{"class":413},[407,7686,7687,7689,7691,7694,7696],{"class":409,"line":184},[407,7688,3733],{"class":417},[407,7690,7617],{"class":554},[407,7692,7693],{"class":413},"(raw?.data?.membershipVipBenefit ",[407,7695,912],{"class":417},[407,7697,7698],{"class":413}," raw?.membershipVipBenefit);\n",[11,7700,7701],{},[488,7702,7703],{},"排队位次算错。",[11,7705,7706],{},"另一个入口（dub-enhance 包装渲染）入队时既不分配队列槽位，也不带 BullMQ priority。没有权重的任务反而插到全部加权任务之前，排队位次显示同时失真。",[11,7708,7709,7710,7713],{},"这个队列的公平调度设计值得记一笔。BullMQ 的 ",[15,7711,7712],{},"priority"," 是严格优先级：高优先级队列非空就一直取它，VIP 多的时候普通用户可能永远排不上。所以这里用「虚拟时间戳」当 priority——VIP 每个 +1、普通每个 +5，比例 5:1：",[399,7715,7717],{"className":691,"code":7716,"language":693,"meta":183,"style":183},"export const VIP_TO_NORMAL_RATIO = 5; \u002F\u002F 每 N 个 VIP 放行 1 个普通\n",[15,7718,7719],{"__ignoreMap":183},[407,7720,7721,7723,7725,7728,7730,7732,7734],{"class":409,"line":410},[407,7722,1045],{"class":417},[407,7724,3166],{"class":417},[407,7726,7727],{"class":476}," VIP_TO_NORMAL_RATIO",[407,7729,706],{"class":417},[407,7731,4036],{"class":476},[407,7733,1121],{"class":413},[407,7735,7736],{"class":528},"\u002F\u002F 每 N 个 VIP 放行 1 个普通\n",[11,7738,7739],{},"不带这个时间戳入队，等于把一个永远最小的值塞进队列，插到所有人前面。",[26,7741,7743],{"id":7742},"二发不出去但不说","二、发不出去，但不说",[11,7745,7746],{},[488,7747,7748],{},"参数格式不对，上游拒收。",[11,7750,7751],{},"线上实况：电商主图 4 张全部「生成失败」。日志里上游收到的是：",[399,7753,7756],{"className":7754,"code":7755,"language":1327},[1325],"resolution: 1536p  size: 1536x1536\n",[15,7757,7755],{"__ignoreMap":183},[11,7759,7760,7761,7764,7765,7768],{},"原始像素尺寸。上一家服务商能吃这种写法，新接入的这家只认比例，于是主模型报 400 ",[15,7762,7763],{},"pricing rule not matched","、兜底模型报 503 ",[15,7766,7767],{},"no available channel","。两个模型双双打不通。",[11,7770,7771,7772,7775],{},"根因是各业务线各有一张自己的尺寸表。电商主图那张表 15 个尺寸里只有 2 个与共享预设表重合，而 ",[15,7773,7774],{},"upstreamImageOptions"," 对认不出的尺寸原样透传。",[11,7777,7778],{},"修法是认不出的像素尺寸一律按宽高比就近吸附到上游支持的 13 个比例之一，绝不再把像素尺寸发出去。吸附用对数距离：",[399,7780,7782],{"className":691,"code":7781,"language":693,"meta":183,"style":183},"Math.abs(Math.log(aspect \u002F candidate.aspect))\n",[15,7783,7784],{"__ignoreMap":183},[407,7785,7786,7789,7792,7795,7798,7801,7803],{"class":409,"line":410},[407,7787,7788],{"class":413},"Math.",[407,7790,7791],{"class":554},"abs",[407,7793,7794],{"class":413},"(Math.",[407,7796,7797],{"class":554},"log",[407,7799,7800],{"class":413},"(aspect ",[407,7802,2699],{"class":417},[407,7804,7805],{"class":413}," candidate.aspect))\n",[11,7807,7808,7809,7812],{},"顺带发现一处吸附判错：",[15,7810,7811],{},"896x1152","（真实比值 0.778）被判成 4:5，而不是用户选的 3:4。显式登记电商那 15 个尺寸之后解决。",[11,7814,7815],{},[488,7816,7817],{},"重试链被自己的去重压短。",[11,7819,7820,7821,7824,7825,781],{},"生图失败的重试链配置形如 ",[15,7822,7823],{},"gpt-image-2-vip,gpt-image-2-vip,...,gpt-image-2","。链解析里原先带了去重，本意是防配置手滑写成 ",[15,7826,7827],{},"vip,vip",[11,7829,7830],{},"但「同一模型隔一会儿再试一次」正是这条链的主要用法——那个模型只是偶发失败，隔开再试命中率高。去重会把配置的 5 次重试静默压成 2 次，且没有任何提示。",[11,7832,7833],{},"去重删掉：配几次就重试几次。静默缩水比手滑危险。",[26,7835,7837],{"id":7836},"三写不进去但不说","三、写不进去，但不说",[11,7839,7840],{},"这一类最多。",[11,7842,7843,7846],{},[488,7844,7845],{},"站点解绑失败后照删记录。"," 托管侧的绑定会永远残留，而且再无重试机会——回收任务只扫在线和离线两种状态。改成抛错留给下一轮重试。",[11,7848,7849,7852],{},[488,7850,7851],{},"APK 幂等检查把故障当成「不存在」。"," 对象存储的 List 对「不存在」返回空结果而不是报错。代码走到这里只可能是存储故障。当成「不存在」去重复出包，既浪费构建，上传大概率同样失败。改为如实上抛，让调用方重试。",[11,7854,7855,7858],{},[488,7856,7857],{},"并发删除返回假成功。"," 记忆更新要先算向量再写库，中间用户可能把这条删了。写库时 0 行受影响，但函数返回成功，接口回 200。改成如实 404：",[399,7860,7862],{"className":691,"code":7861,"language":693,"meta":183,"style":183},"const affected = await prisma.$executeRawUnsafe(`UPDATE \"Memory\" SET ...`);\nreturn affected > 0;\n",[15,7863,7864,7887],{"__ignoreMap":183},[407,7865,7866,7868,7871,7873,7875,7877,7880,7882,7885],{"class":409,"line":410},[407,7867,700],{"class":417},[407,7869,7870],{"class":476}," affected",[407,7872,706],{"class":417},[407,7874,7551],{"class":417},[407,7876,6305],{"class":413},[407,7878,7879],{"class":554},"$executeRawUnsafe",[407,7881,586],{"class":413},[407,7883,7884],{"class":429},"`UPDATE \"Memory\" SET ...`",[407,7886,662],{"class":413},[407,7888,7889,7891,7894,7896,7898],{"class":409,"line":184},[407,7890,3733],{"class":417},[407,7892,7893],{"class":413}," affected ",[407,7895,3519],{"class":417},[407,7897,1118],{"class":476},[407,7899,918],{"class":413},[11,7901,7902,7905],{},[488,7903,7904],{},"验证码会复活。"," 取验证码与读 TTL 之间键刚好过期，代码会用整段 TTL 把已过期的码续回来。改成直接删除并返回「无验证码」。",[11,7907,7908,7911,7912,7915],{},[488,7909,7910],{},"临时文件越积越多。"," 桌面端写文件失败时不清临时文件，",[15,7913,7914],{},".tmp"," 在目录里堆着。修法是无论吞不吞错，写失败都清掉。",[11,7917,7918,7921],{},[488,7919,7920],{},"关闭 socket 抛错了。"," 这个方向写反了：",[399,7923,7925],{"className":691,"code":7924,"language":693,"meta":183,"style":183},"\u002F\u002F 旧写法只吞 Error 实例、反而把非 Error 抛出去，方向写反了——\n\u002F\u002F 在 error 事件回调里外溢会直接崩掉主进程。\n",[15,7926,7927,7932],{"__ignoreMap":183},[407,7928,7929],{"class":409,"line":410},[407,7930,7931],{"class":528},"\u002F\u002F 旧写法只吞 Error 实例、反而把非 Error 抛出去，方向写反了——\n",[407,7933,7934],{"class":409,"line":184},[407,7935,7936],{"class":528},"\u002F\u002F 在 error 事件回调里外溢会直接崩掉主进程。\n",[11,7938,7939,7942],{},[15,7940,7941],{},"try { sock.close() } catch { }"," 要吞的是全部，因为关闭是尽力而为。",[26,7944,7946],{"id":7945},"四该拦的入口没拦","四、该拦的入口没拦",[11,7948,7949],{},[488,7950,7951],{},"唯一漏掉来源校验的 IPC handler，经手账号密码。",[11,7953,7954,7955,7958],{},"桌面端有一组 IPC handler，每个都要先做来源校验。",[15,7956,7957],{},"yc:pair"," 是全文件唯一漏掉的，而它正是收账号密码的那个：",[399,7960,7962],{"className":691,"code":7961,"language":693,"meta":183,"style":183},"ipcMain.handle(\"yc:pair\", async (event, args) => {\n  \u002F\u002F 全文件唯一漏掉来源校验的 handler，且经手账号密码——补齐与其余 handler 一致的信任闸\n  assertTrustedSender(event);\n  \u002F\u002F ...\n});\n",[15,7963,7964,7998,8003,8011,8015],{"__ignoreMap":183},[407,7965,7966,7969,7972,7974,7977,7979,7982,7984,7987,7989,7992,7994,7996],{"class":409,"line":410},[407,7967,7968],{"class":413},"ipcMain.",[407,7970,7971],{"class":554},"handle",[407,7973,586],{"class":413},[407,7975,7976],{"class":429},"\"yc:pair\"",[407,7978,433],{"class":413},[407,7980,7981],{"class":417},"async",[407,7983,3724],{"class":413},[407,7985,7986],{"class":718},"event",[407,7988,433],{"class":413},[407,7990,7991],{"class":718},"args",[407,7993,722],{"class":413},[407,7995,520],{"class":417},[407,7997,523],{"class":413},[407,7999,8000],{"class":409,"line":184},[407,8001,8002],{"class":528},"  \u002F\u002F 全文件唯一漏掉来源校验的 handler，且经手账号密码——补齐与其余 handler 一致的信任闸\n",[407,8004,8005,8008],{"class":409,"line":189},[407,8006,8007],{"class":554},"  assertTrustedSender",[407,8009,8010],{"class":413},"(event);\n",[407,8012,8013],{"class":409,"line":452},[407,8014,761],{"class":528},[407,8016,8017],{"class":409,"line":458},[407,8018,667],{"class":413},[11,8020,8021,8024],{},[488,8022,8023],{},"用户可控的文件名直接进对象存储的 key。"," 知识库摄取有三种模式：文件、文本、URL。只有文件模式走了文件名消毒，文本与 URL 模式没走。两种模式的文件名同样来自用户输入：",[399,8026,8028],{"className":691,"code":8027,"language":693,"meta":183,"style":183},"\u002F\u002F name 是用户可控输入且直接进 S3 key，与 FILE 模式同样必须消毒（防目录穿越）\nfilename = sanitizeFilename((bodyObj.name as string) ?? \"document.txt\");\n",[15,8029,8030,8035],{"__ignoreMap":183},[407,8031,8032],{"class":409,"line":410},[407,8033,8034],{"class":528},"\u002F\u002F name 是用户可控输入且直接进 S3 key，与 FILE 模式同样必须消毒（防目录穿越）\n",[407,8036,8037,8040,8042,8045,8048,8050,8052,8054,8056,8059],{"class":409,"line":184},[407,8038,8039],{"class":413},"filename ",[407,8041,418],{"class":417},[407,8043,8044],{"class":554}," sanitizeFilename",[407,8046,8047],{"class":413},"((bodyObj.name ",[407,8049,903],{"class":417},[407,8051,3035],{"class":476},[407,8053,722],{"class":413},[407,8055,912],{"class":417},[407,8057,8058],{"class":429}," \"document.txt\"",[407,8060,662],{"class":413},[11,8062,8063,8066],{},[488,8064,8065],{},"批量管理操作没有幂等键。"," 批量封禁与调点、送卡不同，不带批次的幂等键，审计没法按批串联。补齐之后，同一批操作共用一个 key，重试可以辨认。",[26,8068,8070],{"id":8069},"五为什么这类错误特别难查","五、为什么这类错误特别难查",[11,8072,8073,8074,781],{},"静默失败的共同点是：",[488,8075,8076],{},"它产生一个看起来正常的结果",[123,8078,8079,8082,8085,8088],{},[126,8080,8081],{},"接口返回 502，文案是「服务繁忙，请稍后重试」——用户重试，还是 502，然后就去问是不是系统在维护。",[126,8083,8084],{},"上游 400，日志里记的是应用侧的成功调用，失败挂在上游账户下。要捞到那条原始报文才有线索。",[126,8086,8087],{},"重试次数从 5 变成 2，没有任何输出。只有对比配置和实际日志条数才能看出来。",[126,8089,8090],{},"站点解绑失败后记录照删，之后连重试的入口都没了。",[11,8092,8093,8094,8096],{},"排查时最容易走错的一步是相信「没有报错」等于「没有失败」。这一天修的地方里，有四处是先看到线上现象、再去代码里找到那个 ",[15,8095,3830],{}," 的——现象和根因之间没有任何日志把它们连起来。",[11,8098,8099],{},"这套修改的走向是一致的：把吞掉改成留痕，把「猜」改成「读」。",[123,8101,8102,8109,8112,8115],{},[126,8103,8104,8105,8108],{},"51 处 ",[15,8106,8107],{},".catch(() => undefined)"," 改成带标签的日志。行为不变，但下次失败能对上账。",[126,8110,8111],{},"队列投递失败时记录真实原因，用户可见文案不变。",[126,8113,8114],{},"上游账户错误与用户余额不足分开报。原先上游渠道商欠费会被透传成「余额不足」，用户跑去充值页面发现余额好好的。",[126,8116,8117],{},"认不出的模型一律按「不支持」处理，不用「兼容」处理。拦错用户看得见，静默丢掉没人知道。",[11,8119,8120,8121,781],{},"最后一条是这一天所有修改里唯一带判断的：",[488,8122,8123],{},"在不确定的行为之间，选那个会说出来的",[1267,8125,8126],{},"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 .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}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 .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 .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":183,"searchDepth":184,"depth":184,"links":8128},[8129,8130,8131,8132,8133],{"id":7191,"depth":184,"text":7192},{"id":7742,"depth":184,"text":7743},{"id":7836,"depth":184,"text":7837},{"id":7945,"depth":184,"text":7946},{"id":8069,"depth":184,"text":8070},"2026-08-13",{},"\u002F2026-08-13-fa-020",{"title":7180,"description":7185},"FA-020","2026-08-13-FA-020-没人报错因为错误被吞了","一次上线前审查翻出的十四处静默失败：连接断了不重连、参数发错被吞吐、写入失败当成功、该拦的入口没拦，全都不产生任何日志。",[8142,8143,8144,8145,8146,1288,8147],"错误处理","Redis","BullMQ","队列","schema","静默失败","Iin0XlG4x3C0DHtl-qxT86TOto6VLivT8uElo2NLCrM",{"id":8150,"title":8151,"body":8152,"column":355,"date":8688,"description":8156,"extension":199,"hero_image":200,"meta":8689,"navigation":202,"path":8690,"seo":8691,"series_id":8692,"severity":200,"stem":8693,"summary":8694,"tags":8695,"__hash__":8702},"posts\u002F2026-08-12-FA-019-生成中.md","生成中",{"type":8,"value":8153,"toc":8679},[8154,8157,8160,8163,8166,8170,8175,8178,8181,8186,8197,8207,8214,8219,8222,8229,8232,8235,8239,8242,8265,8281,8293,8305,8325,8335,8341,8344,8348,8351,8396,8402,8409,8413,8416,8493,8496,8511,8515,8518,8524,8527,8530,8547,8550,8565,8569,8572,8603,8606,8628,8635,8639,8642,8650,8653,8656,8673,8676],[11,8155,8156],{},"「生成中」是界面上最贵的一个词。",[11,8158,8159],{},"它看起来像在干活，所以用户会等。等到不耐烦了刷新，还是生成中，于是发一条消息来问。这时候后台其实什么也没发生——没有进程在跑，没有请求在飞，那条记录就躺在数据库里。",[11,8161,8162],{},"这一天把历史上所有能找到的僵死任务都捞了一遍。最久的停在生成中 450 小时，另一条 670 小时，还有一条报告任务 834 小时。按天算是 19 天、28 天、35 天。",[11,8164,8165],{},"它们不是同一类任务，但停在原地的原因只有三种。",[26,8167,8169],{"id":8168},"一三条不同的路","一、三条不同的路",[11,8171,8172],{},[488,8173,8174],{},"没有回收机制。",[11,8176,8177],{},"排查结果：超时判死此前各模块各自实现，覆盖不均。生图与智能报告只有请求驱动的惰性判死——要有人打开页面、发一个请求，才会触发一次检查。Agent 工作流、建站、电商工具箱、定时任务这四类完全没有回收机制。",[11,8179,8180],{},"任务停在生成中 450 小时以上的就是这几类。",[11,8182,8183],{},[488,8184,8185],{},"关闭进程时被硬杀。",[11,8187,8188,8189,8192,8193,8196],{},"出片、出图、写报告这类长任务用 ",[15,8190,8191],{},"void run(...)"," 直接跑在 api 进程里，不走队列，也不被 HTTP 请求持有。这意味着 fastify 的 ",[15,8194,8195],{},"app.close()"," 不会等它们。",[11,8198,8199,8200,8202,8203,8206],{},"发版滚动更新时，SIGTERM 一到、",[15,8201,8195],{}," 立刻返回、进程随即 ",[15,8204,8205],{},"exit(0)","，任务被硬杀在半路，数据库记录就永远停在「生成中」。那条卡 834 小时的任务就是这么来的。",[11,8208,8209,8210,8213],{},"顺带纠正一个此前的判断：7 个 worker 的关闭本来就是优雅的（等 BullMQ 在途 job 跑完之后才退出），宽限期也配了 60~660 秒。问题只在 api 这一侧，它的 ",[15,8211,8212],{},"terminationGracePeriodSeconds"," 此前没有配置，走的是默认 30 秒。",[11,8215,8216],{},[488,8217,8218],{},"回收机制本身是无限重试。",[11,8220,8221],{},"这是最隐蔽的一种。视频任务扣掉 1350 视频点之后长时间没有任何终态：既不出成片，也不报失败，更没有退款。",[11,8223,8224,8225,8228],{},"它不在统一 reaper 的覆盖范围内，但有自己的一套——",[15,8226,8227],{},"recoverManagedVideoGenerationTasks","。问题在这个函数做什么：它找出租约过期的 charging\u002Frunning 任务，重新抢租约、重新调度。",[11,8230,8231],{},"这是恢复，不是判死。上游只要一直不返回，任务就在 running 与重新调度之间来回，永远到不了 failed，退款链路也就永远不会被触发。",[11,8233,8234],{},"画布运行是同一个形状：worker 的恢复逻辑只扫 running（租约过期→标失败），完全不碰 queued；而画布运行的默认状态恰恰是 queued。队列投递一旦失败，run 就永久停在 queued。",[26,8236,8238],{"id":8237},"二判死机制的边界","二、判死机制的边界",[11,8240,8241],{},"统一 reaper 建立时定了几条边界，每一条都有具体的理由。",[11,8243,8244,8247,8248,56,8251,56,8254,56,8257,8260,8261,8264],{},[488,8245,8246],{},"只碰执行态。"," 合法的执行状态只有 running 之类的几种；",[15,8249,8250],{},"awaiting_*",[15,8252,8253],{},"*_ready",[15,8255,8256],{},"partial",[15,8258,8259],{},"draft"," 这些是人工闸门，按设计永远等用户操作。判死它们等于批量清空用户的待确认任务。测试用 ",[15,8262,8263],{},"where"," 捕获把这条钉死：",[399,8266,8268],{"className":691,"code":8267,"language":693,"meta":183,"style":183},"it(\"各规则查询条件里不含任何人工闸门态\")\n",[15,8269,8270],{"__ignoreMap":183},[407,8271,8272,8274,8276,8279],{"class":409,"line":410},[407,8273,583],{"class":554},[407,8275,586],{"class":413},[407,8277,8278],{"class":429},"\"各规则查询条件里不含任何人工闸门态\"",[407,8280,3970],{"class":413},[11,8282,8283],{},[488,8284,8285,8286,8289,8290,781],{},"用 ",[15,8287,8288],{},"createdAt"," 判龄，不用 ",[15,8291,8292],{},"updatedAt",[11,8294,8295,8296,8298,8299,8301,8302,8304],{},"这条是被迫学来的。视频任务自己的 recovery 每 60 秒抢一次租约，会把 ",[15,8297,8292],{}," 刷新一次——用 ",[15,8300,8292],{}," 的话，卡死任务永远不会变「旧」，这条规则等于没写。画布运行那张表干脆没有 ",[15,8303,8292],{}," 列。",[399,8306,8308],{"className":691,"code":8307,"language":693,"meta":183,"style":183},"\u002F\u002F 2) 用 createdAt 判龄。updatedAt 是 Prisma @updatedAt，而 video 自己的\n\u002F\u002F    recovery 每 60 秒抢一次租约就会刷新它——用 updatedAt 的话卡死任务永远\n\u002F\u002F    不会变「旧」，这条规则等于没写。\n",[15,8309,8310,8315,8320],{"__ignoreMap":183},[407,8311,8312],{"class":409,"line":410},[407,8313,8314],{"class":528},"\u002F\u002F 2) 用 createdAt 判龄。updatedAt 是 Prisma @updatedAt，而 video 自己的\n",[407,8316,8317],{"class":409,"line":184},[407,8318,8319],{"class":528},"\u002F\u002F    recovery 每 60 秒抢一次租约就会刷新它——用 updatedAt 的话卡死任务永远\n",[407,8321,8322],{"class":409,"line":189},[407,8323,8324],{"class":528},"\u002F\u002F    不会变「旧」，这条规则等于没写。\n",[11,8326,8327,8330,8331,8334],{},[488,8328,8329],{},"每个模块自己写 prisma 调用，不做声明式映射。"," 用 ",[15,8332,8333],{},"prisma[modelName]"," 那种写法，列名写错时类型检查和单测都发现不了，只有真库会炸。",[11,8336,8337,8340],{},[488,8338,8339],{},"Redis NX 锁。"," api 有多个副本，reaper 只允许跑一份。锁 TTL 55 秒，扫描间隔 60 秒。单轮单表最多处理 200 条，避免历史存量一次打满连接池。",[11,8342,8343],{},"阈值下限 60 秒，低于这个值视为配置错误并回落默认——防止误判死正在跑的任务。",[26,8345,8347],{"id":8346},"三三条资金约束","三、三条资金约束",[11,8349,8350],{},"视频任务的判死规则里，三条约束写在同一段注释里，都跟钱有关：",[399,8352,8354],{"className":691,"code":8353,"language":693,"meta":183,"style":183},"\u002F\u002F 视频本身慢（H3 2K 15 秒可能跑十几分钟），阈值给足 60 分钟再判死。\n\u002F\u002F 三条约束都不能动：\n\u002F\u002F 1) 只抓 running。charging 是「扣费进行中」，扣没扣成功不确定，判死它可能\n\u002F\u002F    给未实际扣费的任务发起退款；那条链路交给既有的补偿机制。\n\u002F\u002F 2) 用 createdAt 判龄。（略）\n\u002F\u002F 3) 置 refundStatus=pending，交给 recoverPendingVideoGenerationRefunds 按\n\u002F\u002F    operationId 退款。只判失败不退款，等于把用户已扣的视频点吞掉。\n\u002F\u002F    只对 refundStatus=none 的动手，避免覆盖已在退款流程中的任务。\n",[15,8355,8356,8361,8366,8371,8376,8381,8386,8391],{"__ignoreMap":183},[407,8357,8358],{"class":409,"line":410},[407,8359,8360],{"class":528},"\u002F\u002F 视频本身慢（H3 2K 15 秒可能跑十几分钟），阈值给足 60 分钟再判死。\n",[407,8362,8363],{"class":409,"line":184},[407,8364,8365],{"class":528},"\u002F\u002F 三条约束都不能动：\n",[407,8367,8368],{"class":409,"line":189},[407,8369,8370],{"class":528},"\u002F\u002F 1) 只抓 running。charging 是「扣费进行中」，扣没扣成功不确定，判死它可能\n",[407,8372,8373],{"class":409,"line":452},[407,8374,8375],{"class":528},"\u002F\u002F    给未实际扣费的任务发起退款；那条链路交给既有的补偿机制。\n",[407,8377,8378],{"class":409,"line":458},[407,8379,8380],{"class":528},"\u002F\u002F 2) 用 createdAt 判龄。（略）\n",[407,8382,8383],{"class":409,"line":464},[407,8384,8385],{"class":528},"\u002F\u002F 3) 置 refundStatus=pending，交给 recoverPendingVideoGenerationRefunds 按\n",[407,8387,8388],{"class":409,"line":470},[407,8389,8390],{"class":528},"\u002F\u002F    operationId 退款。只判失败不退款，等于把用户已扣的视频点吞掉。\n",[407,8392,8393],{"class":409,"line":480},[407,8394,8395],{"class":528},"\u002F\u002F    只对 refundStatus=none 的动手，避免覆盖已在退款流程中的任务。\n",[11,8397,8398,8399,8401],{},"第一条和第三条都是「不许误伤」。判死本身只做两件事：改状态、置退款标记。真正退钱由另一条既有链路按 ",[15,8400,4073],{}," 完成。",[11,8403,8404,8405,8408],{},"生图的阈值还多一层考虑：它的判死时刻",[488,8406,8407],{},"刻意大于","生图自身的惰性恢复阈值（633 秒）。那套恢复是「继续重试」，reaper 是「判死退款」。让恢复先有约 3 轮机会，恢复不了才由 reaper 终结。",[26,8410,8412],{"id":8411},"四覆盖范围是怎么长出来的","四、覆盖范围是怎么长出来的",[11,8414,8415],{},"这条机制不是一次写全的。初始 6 条规则，之后每遇到一个漏网的模块补一条：",[1708,8417,8418,8431],{},[1711,8419,8420],{},[1714,8421,8422,8425,8428],{},[1717,8423,8424],{},"加入日期",[1717,8426,8427],{},"规则",[1717,8429,8430],{},"阈值",[1730,8432,8433,8444,8453,8463,8472,8482],{},[1714,8434,8435,8438,8441],{},[1735,8436,8437],{},"08-11",[1735,8439,8440],{},"生图 \u002F 报告 \u002F Agent 工作流 \u002F 建站 \u002F 电商工具箱 \u002F 定时任务",[1735,8442,8443],{},"30 分钟",[1714,8445,8446,8448,8451],{},[1735,8447,8437],{},[1735,8449,8450],{},"演示文稿",[1735,8452,8443],{},[1714,8454,8455,8458,8460],{},[1735,8456,8457],{},"08-12",[1735,8459,2678],{},[1735,8461,8462],{},"60 分钟",[1714,8464,8465,8467,8470],{},[1735,8466,8457],{},[1735,8468,8469],{},"画布运行",[1735,8471,8443],{},[1714,8473,8474,8477,8480],{},[1735,8475,8476],{},"08-19",[1735,8478,8479],{},"漫剧正文",[1735,8481,8443],{},[1714,8483,8484,8487,8490],{},[1735,8485,8486],{},"08-26",[1735,8488,8489],{},"角色库",[1735,8491,8492],{},"20 分钟",[11,8494,8495],{},"演示文稿那条的补充理由值得单独看：它此前不在任何回收范围内，卡在「生成风格预览」670 小时。但它只判死真正在跑的两态：",[399,8497,8499],{"className":691,"code":8498,"language":693,"meta":183,"style":183},"\u002F\u002F 只判死真正在跑的两态。preview_ready 是「预览已出、等用户选风格」的人工闸门，\n\u002F\u002F 判死它等于把用户没来得及确认的演示文稿一把清空。\n",[15,8500,8501,8506],{"__ignoreMap":183},[407,8502,8503],{"class":409,"line":410},[407,8504,8505],{"class":528},"\u002F\u002F 只判死真正在跑的两态。preview_ready 是「预览已出、等用户选风格」的人工闸门，\n",[407,8507,8508],{"class":409,"line":184},[407,8509,8510],{"class":528},"\u002F\u002F 判死它等于把用户没来得及确认的演示文稿一把清空。\n",[26,8512,8514],{"id":8513},"五漫剧正文任务不在表里","五、漫剧正文：任务不在表里",[11,8516,8517],{},"08-19 这条是本轮最麻烦的一个，因为它的任务数据不在独立表里。",[11,8519,8520,8521,8523],{},"漫剧产线的任务存在项目的 ",[15,8522,5923],{}," JSON 数组里。统一 reaper 按表扫描，扫不到它。",[11,8525,8526],{},"现象是一条完整的失败链。批量生成正文时余额不足，中途失败不回滚已创建的任务，留下一批永不执行的 queued 僵尸。前端无差别接管僵尸并轮询，等不到终态。30 集正文全部生成好了，界面仍显示生成中，而且分集没有取消入口。",[11,8528,8529],{},"四处改动：",[123,8531,8532,8535,8541,8544],{},[126,8533,8534],{},"批量端点中途失败时，对已创建任务逐个取消并退款，各自兜错，不让一个步骤的失败中断其他回滚。",[126,8536,8537,8538,8540],{},"单独写一条回收规则。因为 ",[15,8539,5923],{}," 是整列读改写、不支持部分更新，它必须用与剧本、角色写入同一把咨询锁，否则并发修改会互相覆盖。",[126,8542,8543],{},"预留字符从 10000 校准到 3000。依据是生产实测的 70 条已结算记录：单集正文实际 1200~2116 字，实扣平均约 82 点，而资源价是 50 点\u002F1000 字。预留 3000 字（150 点）覆盖实测峰值。",[126,8545,8546],{},"生成对空文本与 JSON 解析失败自动重试一次，重试发生在结算之前，同一任务最终只结算一次。",[11,8548,8549],{},"前端还有一个假的终态要处理。轮询超时判死的阈值原本是 5 分钟，按「单次请求 120 秒 + 重试排队留一倍」推的。但进度条是按 10 分钟展开的——5 分钟判死会在进度才走到 85%、还在正常涨的时候把任务掐掉，界面自相矛盾。改成 12 分钟：",[399,8551,8553],{"className":691,"code":8552,"language":693,"meta":183,"style":183},"\u002F\u002F 12 分钟 = 等待进度条的 10 分钟基准再留 20% 余量。\n\u002F\u002F 代价是真死掉的任务要多等 7 分钟才被发现——用户随时可以点取消，不必等它。\n",[15,8554,8555,8560],{"__ignoreMap":183},[407,8556,8557],{"class":409,"line":410},[407,8558,8559],{"class":528},"\u002F\u002F 12 分钟 = 等待进度条的 10 分钟基准再留 20% 余量。\n",[407,8561,8562],{"class":409,"line":184},[407,8563,8564],{"class":528},"\u002F\u002F 代价是真死掉的任务要多等 7 分钟才被发现——用户随时可以点取消，不必等它。\n",[26,8566,8568],{"id":8567},"六优雅关闭要等到什么时候","六、优雅关闭要等到什么时候",[11,8570,8571],{},"回到 api 被硬杀那条。修法是登记在途任务，关闭时等它们收尾：",[399,8573,8575],{"className":691,"code":8574,"language":693,"meta":183,"style":183},"const drainTimeoutMs = Number(process.env.SHUTDOWN_DRAIN_TIMEOUT_MS ?? 120_000);\n",[15,8576,8577],{"__ignoreMap":183},[407,8578,8579,8581,8584,8586,8589,8592,8595,8598,8601],{"class":409,"line":410},[407,8580,700],{"class":417},[407,8582,8583],{"class":476}," drainTimeoutMs",[407,8585,706],{"class":417},[407,8587,8588],{"class":554}," Number",[407,8590,8591],{"class":413},"(process.env.",[407,8593,8594],{"class":476},"SHUTDOWN_DRAIN_TIMEOUT_MS",[407,8596,8597],{"class":417}," ??",[407,8599,8600],{"class":476}," 120_000",[407,8602,662],{"class":413},[11,8604,8605],{},"12 处长任务启动点改为登记。Deployment 的宽限期从默认 30 秒提到 150 秒：",[399,8607,8611],{"className":8608,"code":8609,"language":8610,"meta":183,"style":183},"language-yaml shiki shiki-themes github-light github-dark","# 必须大于 SHUTDOWN_DRAIN_TIMEOUT_MS（默认 120s）+ 断连时间，否则等不完就被 SIGKILL\nterminationGracePeriodSeconds: 150\n","yaml",[15,8612,8613,8618],{"__ignoreMap":183},[407,8614,8615],{"class":409,"line":410},[407,8616,8617],{"class":528},"# 必须大于 SHUTDOWN_DRAIN_TIMEOUT_MS（默认 120s）+ 断连时间，否则等不完就被 SIGKILL\n",[407,8619,8620,8623,8625],{"class":409,"line":184},[407,8621,8212],{"class":8622},"s9eBZ",[407,8624,3607],{"class":413},[407,8626,8627],{"class":476},"150\n",[11,8629,8630,8631,8634],{},"只登记「跑完就结束」的一次性任务。回收任务、恢复任务、定时任务这类常驻循环不登记——它们关闭时由 ",[15,8632,8633],{},"clearInterval"," 直接停，登记了反而会让等待永远等不完。",[26,8636,8638],{"id":8637},"七这类故障的代价在哪","七、这类故障的代价在哪",[11,8640,8641],{},"判死机制本身不难写。难的是它必须同时满足两件相反的事：",[123,8643,8644,8647],{},[126,8645,8646],{},"对真的死了的任务，尽快终结并退款，不让点数挂着。",[126,8648,8649],{},"对还在跑的任务，一次都不能误判。",[11,8651,8652],{},"这两件事靠阈值区分不了——视频任务正常可以跑十几分钟，而僵死的任务和正在跑的任务在数据库里长得一模一样。所以这个机制靠的是不断的排除：不碰人工闸门态、不碰扣费未定的状态、用不会被刷新的时间戳判龄、给恢复机制留出重试的机会。",[11,8654,8655],{},"补完之后覆盖到 11 条规则。每一条都是先出现一个具体的僵死任务，再补一条：",[123,8657,8658,8661,8664,8667,8670],{},[126,8659,8660],{},"「任务可停在生成中 450 小时以上」——补 6 类零覆盖的模块。",[126,8662,8663],{},"「卡 834 小时」——补关闭时等待在途任务。",[126,8665,8666],{},"「扣 1350 视频点后一直挂着」——补视频判死与退款。",[126,8668,8669],{},"「30 集全好了还显示生成中」——补漫剧产线。",[126,8671,8672],{},"「卡在生成风格预览 670 小时」——补演示文稿。",[11,8674,8675],{},"回头看，这条链路的每个缺口都是一类任务、一种状态、一次疏忽。补全的方式不是「想清楚所有情况」，而是让每一类任务都有一个出口：要么推进到终态，要么被判死并退款。中间没有第三种结果。",[1267,8677,8678],{},"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 .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 .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":183,"searchDepth":184,"depth":184,"links":8680},[8681,8682,8683,8684,8685,8686,8687],{"id":8168,"depth":184,"text":8169},{"id":8237,"depth":184,"text":8238},{"id":8346,"depth":184,"text":8347},{"id":8411,"depth":184,"text":8412},{"id":8513,"depth":184,"text":8514},{"id":8567,"depth":184,"text":8568},{"id":8637,"depth":184,"text":8638},"2026-08-12",{},"\u002F2026-08-12-fa-019",{"title":8151,"description":8156},"FA-019","2026-08-12-FA-019-生成中","排查发现多类任务没有终态：有的停在生成中 450 小时、有的 670 小时、有的 834 小时，有的扣了 1350 视频点后无限重试。统一判死与退款机制的建立过程。",[8696,8697,8698,8145,8699,8700,8701],"超时","任务回收","reaper","优雅关闭","退款","状态机","_V4suYEiCg_arxbiRKjHZEyvQ4wAx5wXes6JLAJzFtM",{"id":8704,"title":8705,"body":8706,"column":1966,"date":9716,"description":8710,"extension":199,"hero_image":200,"meta":9717,"navigation":202,"path":9718,"seo":9719,"series_id":200,"severity":200,"stem":9720,"summary":9721,"tags":9722,"__hash__":9728},"posts\u002F2026-08-08-同一张能力表抄了三份.md","同一张能力表，抄了三份",{"type":8,"value":8707,"toc":9708},[8708,8711,8714,8717,8721,8724,8871,8874,8880,8886,8889,8955,8958,8962,8965,8968,8989,8992,8995,9001,9016,9019,9023,9026,9029,9035,9038,9163,9166,9175,9178,9184,9380,9383,9387,9398,9401,9404,9464,9467,9528,9531,9564,9568,9571,9574,9580,9587,9590,9597,9604,9608,9611,9637,9643,9646,9696,9702,9705],[11,8709,8710],{},"用户反馈：设置里选完模型，分辨率档不对——Seedance-2.5 只有 480P 和 720P；另外 2.5 的上游支持到 29 秒，故事板那边应该跟着放开。",[11,8712,8713],{},"第一句说得对，2.5 确实只有 480p 和 720p。第二句也对，它确实支持 4~29 秒。",[11,8715,8716],{},"两条都对，说明我们发出去的档位和上游支持的对不上。查下去发现，同一份能力表被抄了三份。",[26,8718,8720],{"id":8719},"一三处各自漂移","一、三处各自漂移",[11,8722,8723],{},"后端有一张权威表，写清了每个模型支持哪些分辨率、哪些时长：",[399,8725,8727],{"className":691,"code":8726,"language":693,"meta":183,"style":183},"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",[15,8728,8729,8760,8789,8804,8819,8834,8849,8866],{"__ignoreMap":183},[407,8730,8731,8733,8736,8738,8740,8742,8745,8747,8750,8753,8756,8758],{"class":409,"line":410},[407,8732,700],{"class":417},[407,8734,8735],{"class":476}," MODEL_RESOLUTIONS",[407,8737,1059],{"class":417},[407,8739,1070],{"class":554},[407,8741,1073],{"class":413},[407,8743,8744],{"class":554},"VideoModel",[407,8746,433],{"class":413},[407,8748,8749],{"class":417},"readonly",[407,8751,8752],{"class":554}," VideoResolution",[407,8754,8755],{"class":413},"[]> ",[407,8757,418],{"class":417},[407,8759,523],{"class":413},[407,8761,8762,8765,8768,8771,8773,8776,8778,8781,8783,8786],{"class":409,"line":184},[407,8763,8764],{"class":429},"  \"seedance-2\"",[407,8766,8767],{"class":413},": [",[407,8769,8770],{"class":429},"\"480p\"",[407,8772,433],{"class":413},[407,8774,8775],{"class":429},"\"720p\"",[407,8777,433],{"class":413},[407,8779,8780],{"class":429},"\"1080p\"",[407,8782,433],{"class":413},[407,8784,8785],{"class":429},"\"4k\"",[407,8787,8788],{"class":413},"],\n",[407,8790,8791,8794,8796,8798,8800,8802],{"class":409,"line":189},[407,8792,8793],{"class":429},"  \"seedance-2-fast\"",[407,8795,8767],{"class":413},[407,8797,8770],{"class":429},[407,8799,433],{"class":413},[407,8801,8775],{"class":429},[407,8803,8788],{"class":413},[407,8805,8806,8809,8811,8813,8815,8817],{"class":409,"line":452},[407,8807,8808],{"class":429},"  \"seedance-2-mini\"",[407,8810,8767],{"class":413},[407,8812,8770],{"class":429},[407,8814,433],{"class":413},[407,8816,8775],{"class":429},[407,8818,8788],{"class":413},[407,8820,8821,8824,8826,8828,8830,8832],{"class":409,"line":458},[407,8822,8823],{"class":429},"  \"seedance-2.5\"",[407,8825,8767],{"class":413},[407,8827,8770],{"class":429},[407,8829,433],{"class":413},[407,8831,8775],{"class":429},[407,8833,8788],{"class":413},[407,8835,8836,8839,8841,8843,8845,8847],{"class":409,"line":464},[407,8837,8838],{"class":429},"  \"kling-v3\"",[407,8840,8767],{"class":413},[407,8842,8775],{"class":429},[407,8844,433],{"class":413},[407,8846,8780],{"class":429},[407,8848,8788],{"class":413},[407,8850,8851,8854,8856,8859,8861,8864],{"class":409,"line":470},[407,8852,8853],{"class":429},"  \"minimax-h3\"",[407,8855,8767],{"class":413},[407,8857,8858],{"class":429},"\"2k\"",[407,8860,433],{"class":413},[407,8862,8863],{"class":429},"\"768p\"",[407,8865,8788],{"class":413},[407,8867,8868],{"class":409,"line":480},[407,8869,8870],{"class":413},"};\n",[11,8872,8873],{},"前端有一份手抄的副本。它早就漂了，而且漂在两个方向。",[11,8875,8876,8879],{},[488,8877,8878],{},"多出的档位。"," 设置页的取档逻辑是写死的规则：「H3 两档、其余一律四档」。于是 2.5、fast、mini 这三个只有 480p 和 720p 的模型，界面上给出 1080P 和 4K。用户选了，选中即报未配价，或者直接被上游拒。",[11,8881,8882,8885],{},[488,8883,8884],{},"少掉的档位。"," Kling 只支持 720p 和 1080p，界面给的是四档。反过来的情况也存在：如果某个模型的档位比默认四档更多，界面也显示不出来。",[11,8887,8888],{},"时长那一处漂得更彻底。前端和后端各写了一份 4~15 的夹取：",[399,8890,8892],{"className":691,"code":8891,"language":693,"meta":183,"style":183},"\u002F\u002F 前端\nreturn Math.max(4, Math.min(15, value));\n\n\u002F\u002F 后端\nreturn Math.max(4, Math.min(15, value));\n",[15,8893,8894,8899,8924,8928,8933],{"__ignoreMap":183},[407,8895,8896],{"class":409,"line":410},[407,8897,8898],{"class":528},"\u002F\u002F 前端\n",[407,8900,8901,8903,8905,8907,8909,8912,8914,8916,8918,8921],{"class":409,"line":184},[407,8902,3733],{"class":417},[407,8904,1521],{"class":413},[407,8906,1002],{"class":554},[407,8908,586],{"class":413},[407,8910,8911],{"class":476},"4",[407,8913,1531],{"class":413},[407,8915,992],{"class":554},[407,8917,586],{"class":413},[407,8919,8920],{"class":476},"15",[407,8922,8923],{"class":413},", value));\n",[407,8925,8926],{"class":409,"line":189},[407,8927,1827],{"emptyLinePlaceholder":202},[407,8929,8930],{"class":409,"line":452},[407,8931,8932],{"class":528},"\u002F\u002F 后端\n",[407,8934,8935,8937,8939,8941,8943,8945,8947,8949,8951,8953],{"class":409,"line":458},[407,8936,3733],{"class":417},[407,8938,1521],{"class":413},[407,8940,1002],{"class":554},[407,8942,586],{"class":413},[407,8944,8911],{"class":476},[407,8946,1531],{"class":413},[407,8948,992],{"class":554},[407,8950,586],{"class":413},[407,8952,8920],{"class":476},[407,8954,8923],{"class":413},[11,8956,8957],{},"2.5 传 29 秒，被悄悄砍成 15。用户看不出为什么变短——没有任何提示，界面上显示的就是 15 秒。",[26,8959,8961],{"id":8960},"二被砍掉的秒数后面跟着钱","二、被砍掉的秒数后面跟着钱",[11,8963,8964],{},"时长那一处还不只是显示问题。",[11,8966,8967],{},"故事板按固定秒数切板。代码里是一个常量：",[399,8969,8971],{"className":691,"code":8970,"language":693,"meta":183,"style":183},"export const SHOT_SECONDS = 15;\n",[15,8972,8973],{"__ignoreMap":183},[407,8974,8975,8977,8979,8982,8984,8987],{"class":409,"line":410},[407,8976,1045],{"class":417},[407,8978,3166],{"class":417},[407,8980,8981],{"class":476}," SHOT_SECONDS",[407,8983,706],{"class":417},[407,8985,8986],{"class":476}," 15",[407,8988,918],{"class":413},[11,8990,8991],{},"按 15 秒一切。2.5 一板能放 29 秒，硬按 15 秒切，一集会被切成两倍数量的板。",[11,8993,8994],{},"板数翻倍就是出图与出片的费用翻倍。这不是理论推算——用户选 2.5 的动机就是长板数少切，结果切得和短时长模型一样多，还多花一倍钱。",[11,8996,8997,9000],{},[15,8998,8999],{},"comic-subshot.ts"," 里留着上限常量的注释，写明了它只是「模型未知时的保守默认」：",[399,9002,9004],{"className":691,"code":9003,"language":693,"meta":183,"style":183},"\u002F\u002F 上限只是「模型未知时的保守默认」——真正的上限逐模型不同（seedance-2.5 能到 29 秒），\n\u002F\u002F 调用方应把该模型的最大秒数作为 maxBoardSec 传进来。写死 15 的话，选了 2.5 也只切 15 秒一板。\n",[15,9005,9006,9011],{"__ignoreMap":183},[407,9007,9008],{"class":409,"line":410},[407,9009,9010],{"class":528},"\u002F\u002F 上限只是「模型未知时的保守默认」——真正的上限逐模型不同（seedance-2.5 能到 29 秒），\n",[407,9012,9013],{"class":409,"line":184},[407,9014,9015],{"class":528},"\u002F\u002F 调用方应把该模型的最大秒数作为 maxBoardSec 传进来。写死 15 的话，选了 2.5 也只切 15 秒一板。\n",[11,9017,9018],{},"调用方没传。",[26,9020,9022],{"id":9021},"三提示词长度四个数一个是实测出来的","三、提示词长度：四个数，一个是实测出来的",[11,9024,9025],{},"同一批里还有一个更贵的限制：提示词长度上限。",[11,9027,9028],{},"上游对提示词有硬上限，超过直接拒。各模型不一样，而这些值在很长一段时间里没有集中维护。表现是一条线上的完整失败：",[399,9030,9033],{"className":9031,"code":9032,"language":1327},[1325],"模型 seedance-2.5 的提示词不能超过 5000 个字符，当前为 18545 个字符\n",[15,9034,9032],{"__ignoreMap":183},[11,9036,9037],{},"接入本身没问题，是缺了长度约束。补完之后这张表长这样：",[399,9039,9041],{"className":691,"code":9040,"language":693,"meta":183,"style":183},"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",[15,9042,9043,9075,9086,9097,9107,9117,9128,9133,9143,9147],{"__ignoreMap":183},[407,9044,9045,9047,9050,9052,9055,9057,9060,9062,9064,9066,9068,9071,9073],{"class":409,"line":410},[407,9046,700],{"class":417},[407,9048,9049],{"class":476}," MODEL_PROMPT_LIMITS",[407,9051,1059],{"class":417},[407,9053,9054],{"class":554}," Partial",[407,9056,1073],{"class":413},[407,9058,9059],{"class":554},"Record",[407,9061,1073],{"class":413},[407,9063,8744],{"class":554},[407,9065,433],{"class":413},[407,9067,981],{"class":476},[407,9069,9070],{"class":413},">> ",[407,9072,418],{"class":417},[407,9074,523],{"class":413},[407,9076,9077,9079,9081,9084],{"class":409,"line":184},[407,9078,8853],{"class":429},[407,9080,3607],{"class":413},[407,9082,9083],{"class":476},"7000",[407,9085,3296],{"class":413},[407,9087,9088,9090,9092,9095],{"class":409,"line":189},[407,9089,8764],{"class":429},[407,9091,3607],{"class":413},[407,9093,9094],{"class":476},"2500",[407,9096,3296],{"class":413},[407,9098,9099,9101,9103,9105],{"class":409,"line":452},[407,9100,8793],{"class":429},[407,9102,3607],{"class":413},[407,9104,9094],{"class":476},[407,9106,3296],{"class":413},[407,9108,9109,9111,9113,9115],{"class":409,"line":458},[407,9110,8808],{"class":429},[407,9112,3607],{"class":413},[407,9114,9094],{"class":476},[407,9116,3296],{"class":413},[407,9118,9119,9121,9123,9126],{"class":409,"line":464},[407,9120,8823],{"class":429},[407,9122,3607],{"class":413},[407,9124,9125],{"class":476},"5000",[407,9127,3296],{"class":413},[407,9129,9130],{"class":409,"line":470},[407,9131,9132],{"class":528},"  \u002F\u002F 20260809 实测：发 2553 字被硬拒 `prompt: size must be between 0 and 2500`\n",[407,9134,9135,9137,9139,9141],{"class":409,"line":480},[407,9136,8838],{"class":429},[407,9138,3607],{"class":413},[407,9140,9094],{"class":476},[407,9142,3296],{"class":413},[407,9144,9145],{"class":409,"line":1477},[407,9146,8870],{"class":413},[407,9148,9149,9151,9153,9156,9158,9161],{"class":409,"line":1483},[407,9150,1045],{"class":417},[407,9152,3166],{"class":417},[407,9154,9155],{"class":476}," PROMPT_LIMIT_FALLBACK",[407,9157,706],{"class":417},[407,9159,9160],{"class":476}," 50000",[407,9162,918],{"class":413},[11,9164,9165],{},"这几个数的来源不同，注释里写清了哪个是实测的：",[1205,9167,9168],{},[11,9169,9170,9171,9174],{},"seedance-2.5 的 5000 是",[488,9172,9173],{},"实测出来的，文档只字未提","：线上一条 18545 字的脚本被拒，报文写「提示词不能超过 5000 个字符」。同批实测另两条渠道收 16500 字照样 200，所以这是 2.5 独有的限制，不能推广到整个系列。加新渠道前先拿超长 prompt 打一次，别等线上炸。",[11,9176,9177],{},"这段话是这张表里唯一带出处的一条。其余几个数是按上游文档填的，文档没提的只能等线上撞。",[11,9179,9180,9181,9183],{},"长度约束补齐之后，还有一个配套的预算计算。原先只有 H3 有预算，其余模型返回 ",[15,9182,6508],{},"，等于完全不限：",[399,9185,9187],{"className":691,"code":9186,"language":693,"meta":183,"style":183},"\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",[15,9188,9189,9194,9210,9215,9228,9232,9275,9290,9332,9355,9376],{"__ignoreMap":183},[407,9190,9191],{"class":409,"line":410},[407,9192,9193],{"class":528},"\u002F** 每秒成片对应的脚本篇幅。15 秒 → 7500 字，是人肉审稿与出片效果都合适的密度。 *\u002F\n",[407,9195,9196,9198,9200,9203,9205,9208],{"class":409,"line":184},[407,9197,1045],{"class":417},[407,9199,3166],{"class":417},[407,9201,9202],{"class":476}," CHARS_PER_SECOND",[407,9204,706],{"class":417},[407,9206,9207],{"class":476}," 500",[407,9209,918],{"class":413},[407,9211,9212],{"class":409,"line":189},[407,9213,9214],{"class":528},"\u002F** 留给用户自己追加修改的余量：脚本刚好顶满上限时，用户加一句就被上游拒了。 *\u002F\n",[407,9216,9217,9219,9222,9224,9226],{"class":409,"line":452},[407,9218,700],{"class":417},[407,9220,9221],{"class":476}," PROMPT_BUDGET_MARGIN",[407,9223,706],{"class":417},[407,9225,9207],{"class":476},[407,9227,918],{"class":413},[407,9229,9230],{"class":409,"line":458},[407,9231,1827],{"emptyLinePlaceholder":202},[407,9233,9234,9236,9238,9241,9243,9246,9248,9250,9252,9254,9256,9259,9261,9263,9265,9267,9269,9271,9273],{"class":409,"line":464},[407,9235,1045],{"class":417},[407,9237,1048],{"class":417},[407,9239,9240],{"class":554}," scriptCharBudget",[407,9242,586],{"class":413},[407,9244,9245],{"class":718},"targetModel",[407,9247,1059],{"class":417},[407,9249,3035],{"class":476},[407,9251,3482],{"class":417},[407,9253,5536],{"class":476},[407,9255,433],{"class":413},[407,9257,9258],{"class":718},"durationSec",[407,9260,3702],{"class":417},[407,9262,5882],{"class":476},[407,9264,1065],{"class":413},[407,9266,1059],{"class":417},[407,9268,5882],{"class":476},[407,9270,3482],{"class":417},[407,9272,3485],{"class":476},[407,9274,523],{"class":413},[407,9276,9277,9279,9282,9284,9287],{"class":409,"line":470},[407,9278,892],{"class":417},[407,9280,9281],{"class":476}," hardLimit",[407,9283,706],{"class":417},[407,9285,9286],{"class":554}," modelPromptHardLimit",[407,9288,9289],{"class":413},"(targetModel);\n",[407,9291,9292,9294,9296,9298,9301,9303,9306,9309,9312,9315,9317,9320,9323,9325,9327,9329],{"class":409,"line":480},[407,9293,3721],{"class":417},[407,9295,3724],{"class":413},[407,9297,3727],{"class":417},[407,9299,9300],{"class":413},"durationSec ",[407,9302,5746],{"class":417},[407,9304,9305],{"class":417}," !",[407,9307,9308],{"class":413},"Number.",[407,9310,9311],{"class":554},"isFinite",[407,9313,9314],{"class":413},"(durationSec) ",[407,9316,5746],{"class":417},[407,9318,9319],{"class":413}," durationSec ",[407,9321,9322],{"class":417},"\u003C=",[407,9324,1118],{"class":476},[407,9326,722],{"class":413},[407,9328,3733],{"class":417},[407,9330,9331],{"class":413}," hardLimit;\n",[407,9333,9334,9336,9339,9341,9343,9346,9349,9351,9353],{"class":409,"line":1477},[407,9335,892],{"class":417},[407,9337,9338],{"class":476}," byDuration",[407,9340,706],{"class":417},[407,9342,1521],{"class":413},[407,9344,9345],{"class":554},"round",[407,9347,9348],{"class":413},"(durationSec ",[407,9350,1540],{"class":417},[407,9352,9202],{"class":476},[407,9354,662],{"class":413},[407,9356,9357,9359,9362,9364,9366,9368,9371,9373],{"class":409,"line":1483},[407,9358,2142],{"class":417},[407,9360,9361],{"class":413}," hardLimit ",[407,9363,514],{"class":417},[407,9365,1521],{"class":413},[407,9367,992],{"class":554},[407,9369,9370],{"class":413},"(byDuration, hardLimit) ",[407,9372,1059],{"class":417},[407,9374,9375],{"class":413}," byDuration;\n",[407,9377,9378],{"class":409,"line":2139},[407,9379,483],{"class":413},[11,9381,9382],{},"按 15 秒算出来是 7500 字——上限撤销之后，脚本直接写到 1.8 万字，那也是生成耗时 193 秒的原因。",[26,9384,9386],{"id":9385},"四抄一份是必要的那就测试它","四、抄一份是必要的，那就测试它",[11,9388,9389,9390,9393,9394,9397],{},"前端为什么不能直接读后端那张表：",[15,9391,9392],{},"apps\u002Fweb"," 不能 import ",[15,9395,9396],{},"apps\u002Fapi","。而节点面板必须知道「这个模型支持哪些分辨率、哪些时长」，才能只给出跑得通的选项。",[11,9399,9400],{},"抄一份是必要的。那就把「不许走样」变成断言。",[11,9402,9403],{},"新增的共享包文件头写明了它的性质和守卫：",[399,9405,9407],{"className":691,"code":9406,"language":693,"meta":183,"style":183},"\u002F**\n * 生图 \u002F 视频的**上游真实能力**表。\n *\n * 这是 apps\u002Fapi 里两张表的镜像……\n *\n * **为什么要抄一份**：前端（apps\u002Fweb）不能 import apps\u002Fapi，而节点面板必须知道\n * 「这个模型支持哪些分辨率、哪些时长」才能只给出跑得通的选项。\n * 抄一份就有走样的风险，所以 apps\u002Fapi 里有一条对比测试逐项核对两边——\n * 谁改了上游表而没同步这里，测试立刻红。\n *\n * 硬规矩：这里只允许出现上游真支持的取值。多给一个选项，用户就会选到一个必然失败的组合。\n *\u002F\n",[15,9408,9409,9413,9418,9422,9427,9431,9436,9441,9446,9451,9455,9460],{"__ignoreMap":183},[407,9410,9411],{"class":409,"line":410},[407,9412,950],{"class":528},[407,9414,9415],{"class":409,"line":184},[407,9416,9417],{"class":528}," * 生图 \u002F 视频的**上游真实能力**表。\n",[407,9419,9420],{"class":409,"line":189},[407,9421,1652],{"class":528},[407,9423,9424],{"class":409,"line":452},[407,9425,9426],{"class":528}," * 这是 apps\u002Fapi 里两张表的镜像……\n",[407,9428,9429],{"class":409,"line":458},[407,9430,1652],{"class":528},[407,9432,9433],{"class":409,"line":464},[407,9434,9435],{"class":528}," * **为什么要抄一份**：前端（apps\u002Fweb）不能 import apps\u002Fapi，而节点面板必须知道\n",[407,9437,9438],{"class":409,"line":470},[407,9439,9440],{"class":528}," * 「这个模型支持哪些分辨率、哪些时长」才能只给出跑得通的选项。\n",[407,9442,9443],{"class":409,"line":480},[407,9444,9445],{"class":528}," * 抄一份就有走样的风险，所以 apps\u002Fapi 里有一条对比测试逐项核对两边——\n",[407,9447,9448],{"class":409,"line":1477},[407,9449,9450],{"class":528}," * 谁改了上游表而没同步这里，测试立刻红。\n",[407,9452,9453],{"class":409,"line":1483},[407,9454,1652],{"class":528},[407,9456,9457],{"class":409,"line":2139},[407,9458,9459],{"class":528}," * 硬规矩：这里只允许出现上游真支持的取值。多给一个选项，用户就会选到一个必然失败的组合。\n",[407,9461,9462],{"class":409,"line":2180},[407,9463,970],{"class":528},[11,9465,9466],{},"同时把取值这件事收成单点：",[399,9468,9470],{"className":691,"code":9469,"language":693,"meta":183,"style":183},"\u002F**\n * 前端渲染面板、后端校验参数都走这一个函数——两边各写一份判断，\n * 迟早出现「界面给得出、服务端不认」的组合。\n *\u002F\nexport function resolveDynamicOptions(kind: \"videoResolution\" | \"videoDuration\", params): readonly { value, label }[]\n",[15,9471,9472,9476,9481,9486,9490],{"__ignoreMap":183},[407,9473,9474],{"class":409,"line":410},[407,9475,950],{"class":528},[407,9477,9478],{"class":409,"line":184},[407,9479,9480],{"class":528}," * 前端渲染面板、后端校验参数都走这一个函数——两边各写一份判断，\n",[407,9482,9483],{"class":409,"line":189},[407,9484,9485],{"class":528}," * 迟早出现「界面给得出、服务端不认」的组合。\n",[407,9487,9488],{"class":409,"line":452},[407,9489,970],{"class":528},[407,9491,9492,9494,9496,9499,9501,9504,9506,9509,9511,9514,9516,9519,9521,9523,9525],{"class":409,"line":458},[407,9493,1045],{"class":417},[407,9495,1048],{"class":417},[407,9497,9498],{"class":554}," resolveDynamicOptions",[407,9500,586],{"class":413},[407,9502,9503],{"class":718},"kind",[407,9505,1059],{"class":417},[407,9507,9508],{"class":429}," \"videoResolution\"",[407,9510,3482],{"class":417},[407,9512,9513],{"class":429}," \"videoDuration\"",[407,9515,433],{"class":413},[407,9517,9518],{"class":718},"params",[407,9520,1065],{"class":413},[407,9522,1059],{"class":417},[407,9524,4746],{"class":417},[407,9526,9527],{"class":413}," { value, label }[]\n",[11,9529,9530],{},"细粒度那一档也保留了两份清单，且是有意不同：",[399,9532,9534],{"className":691,"code":9533,"language":693,"meta":183,"style":183},"\u002F**\n * 注意它与下面的 MODEL_DURATION_OPTIONS 是两回事、且**故意不同**：\n * 后者是「按钮行」的精简清单（seedance 只列 7 档，避免一排按钮太长），\n * 而滑块是细粒度选择，应当覆盖服务端真正接受的全部档位——\n * 否则用户拖不到 7\u002F9\u002F11\u002F13\u002F14 秒，白白削掉能力。\n *\u002F\n",[15,9535,9536,9540,9545,9550,9555,9560],{"__ignoreMap":183},[407,9537,9538],{"class":409,"line":410},[407,9539,950],{"class":528},[407,9541,9542],{"class":409,"line":184},[407,9543,9544],{"class":528}," * 注意它与下面的 MODEL_DURATION_OPTIONS 是两回事、且**故意不同**：\n",[407,9546,9547],{"class":409,"line":189},[407,9548,9549],{"class":528}," * 后者是「按钮行」的精简清单（seedance 只列 7 档，避免一排按钮太长），\n",[407,9551,9552],{"class":409,"line":452},[407,9553,9554],{"class":528}," * 而滑块是细粒度选择，应当覆盖服务端真正接受的全部档位——\n",[407,9556,9557],{"class":409,"line":458},[407,9558,9559],{"class":528}," * 否则用户拖不到 7\u002F9\u002F11\u002F13\u002F14 秒，白白削掉能力。\n",[407,9561,9562],{"class":409,"line":464},[407,9563,970],{"class":528},[26,9565,9567],{"id":9566},"五假绿的七条用例","五、假绿的七条用例",[11,9569,9570],{},"修的过程中还撞到一个测试问题，值得单独记。",[11,9572,9573],{},"改动完成后跑测试，有一组用例报了这个警告：",[399,9575,9578],{"className":9576,"code":9577,"language":1327},[1325],"This might cause false positive tests\n",[15,9579,9577],{"__ignoreMap":183},[11,9581,9582,9583,9586],{},"追下去发现是真的假绿。供应商迁移之后，配置加载在某些条件下会抛错，而那个抛错发生在 ",[15,9584,9585],{},"try"," 之外，成了未处理的 rejection。用例本身并不感知异常，于是照样通过。",[11,9588,9589],{},"具体是 7 条。",[11,9591,9592,9593,9596],{},"修法是让配置加载把异常收敛成一个明确的错误类型（缺 key 重试没有意义，归为永久失败），并在两个相关测试文件的模块加载阶段注入测试用 key。注入位置也有讲究——放 ",[15,9594,9595],{},"beforeEach"," 会因为跨文件执行顺序失效。",[11,9598,9599,9600,9603],{},"这件事和主题有关：",[488,9601,9602],{},"同一份能力表抄三份会漂，同一份配置在三个地方加载也会。"," 假绿的七条用例，测的是「配置能加载」，而配置在测试环境里根本没被加载。",[26,9605,9607],{"id":9606},"六收口之后","六、收口之后",[11,9609,9610],{},"同一批里还顺手修了两处同源问题：",[123,9612,9613,9623],{},[126,9614,9615,9616,88,9619,9622],{},"漫剧设置页那份模型列表是前端手写的副本，早已和后端漂开：列出「Seedance-2.0 Pro」和「可灵 V2」两个根本不存在的 ID，而真实的 ID 是 ",[15,9617,9618],{},"seedance-2-fast",[15,9620,9621],{},"kling-v3","。选中即被出片接口的 zod 拒掉。改成读取后端的模型清单接口。",[126,9624,9625,9626,4375,9629,9632,9633,9636],{},"长篇项目的设置页没有把 ",[15,9627,9628],{},"videoModel",[15,9630,9631],{},"videoResolution"," 传给设置组件，",[15,9634,9635],{},"onSave"," 也没往上收。用户选完模型点保存，没有报错，刷新之后回退到原来的值。",[11,9638,9639,9640,781],{},"三处问题的形式不同，成因是同一个：",[488,9641,9642],{},"同一份事实存在多个副本，而且没有一处是权威",[11,9644,9645],{},"收口之后的结构是三份，每一份都有明确职责：",[1708,9647,9648,9661],{},[1711,9649,9650],{},[1714,9651,9652,9655,9658],{},[1717,9653,9654],{},"位置",[1717,9656,9657],{},"职责",[1717,9659,9660],{},"守卫",[1730,9662,9663,9674,9685],{},[1714,9664,9665,9668,9671],{},[1735,9666,9667],{},"后端能力表",[1735,9669,9670],{},"权威来源",[1735,9672,9673],{},"被镜像表逐项对比",[1714,9675,9676,9679,9682],{},[1735,9677,9678],{},"共享包镜像表",[1735,9680,9681],{},"跨端取值",[1735,9683,9684],{},"对比测试（改一边不改另一边即红）",[1714,9686,9687,9690,9693],{},[1735,9688,9689],{},"前端 UI 清单",[1735,9691,9692],{},"只影响展示形态",[1735,9694,9695],{},"值必须来自共享包，不许写字面量",[11,9697,9698,9699],{},"硬规矩只有一条，写在镜像表里：",[488,9700,9701],{},"只允许出现上游真支持的取值。多给一个选项，用户就会选到一个必然失败的组合。",[11,9703,9704],{},"反过来说，少给一个选项的代价同样实在——2.5 的 29 秒被砍到 15，用户看到的是「这个模型没比别的强」，看不到的是我们没把它的能力交出去。",[1267,9706,9707],{},"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":183,"searchDepth":184,"depth":184,"links":9709},[9710,9711,9712,9713,9714,9715],{"id":8719,"depth":184,"text":8720},{"id":8960,"depth":184,"text":8961},{"id":9021,"depth":184,"text":9022},{"id":9385,"depth":184,"text":9386},{"id":9566,"depth":184,"text":9567},{"id":9606,"depth":184,"text":9607},"2026-08-08",{},"\u002F2026-08-08",{"title":8705,"description":8710},"2026-08-08-同一张能力表抄了三份","上游模型的分辨率、时长、提示词长度散落在三处各自维护，各自漂移。一次用户反馈把三处一起翻出来：多出的档位、被砍掉的秒数、翻倍的板数。",[9723,9724,9725,9726,9727],"模型能力","配置漂移","契约","前端后端一致","提示词长度","xrC9lTC00ghKpLq5ZuDyYhLFe2fpcs3SgCXkd9QeigE",{"id":9730,"title":9731,"body":9732,"column":196,"date":10618,"description":9736,"extension":199,"hero_image":200,"meta":10619,"navigation":202,"path":10620,"seo":10621,"series_id":200,"severity":200,"stem":10622,"summary":10623,"tags":10624,"__hash__":10631},"posts\u002F2026-08-02-首页改成八屏.md","首页改成八屏",{"type":8,"value":9733,"toc":10605},[9734,9737,9743,9746,9750,9753,9763,9772,9775,9778,9786,9793,9799,9805,9858,9861,9880,9885,9890,9893,9899,9903,9906,9909,9917,9920,9923,9929,9932,9959,9962,10068,10071,10077,10081,10084,10099,10113,10116,10131,10134,10137,10141,10147,10150,10153,10159,10162,10210,10214,10217,10220,10223,10229,10232,10235,10240,10246,10250,10253,10256,10262,10266,10271,10311,10321,10324,10353,10358,10361,10409,10415,10418,10475,10487,10490,10494,10497,10500,10505,10508,10511,10516,10520,10523,10593,10596,10602],[11,9735,9736],{},"这个博客的首页原来是连续滚动。改成了整屏分页，一页一件事，共八屏：",[399,9738,9741],{"className":9739,"code":9740,"language":1327},[1325],"首屏 \u002F 自我介绍 01 \u002F 自我介绍 02 \u002F 做过的事 \u002F 精选复盘 \u002F 项目 \u002F 专栏 \u002F 联系\n",[15,9742,9740],{"__ignoreMap":183},[11,9744,9745],{},"一屏容纳是逐档实测调出来的，不是估的。",[26,9747,9749],{"id":9748},"一吸附用谁的","一、吸附用谁的",[11,9751,9752],{},"第一个决定是吸附用哪套机制。",[11,9754,9755,9756,9759,9760,1244],{},"原生有 ",[15,9757,9758],{},"scroll-snap","，CSS 几行就能写。但这里用不了——页面上的平滑滚动是 Lenis 提供的，它用 JS 驱动 ",[15,9761,9762],{},"scrollTop",[1205,9764,9765],{},[11,9766,9767,9768,9771],{},"吸附用 Lenis 自带的 Snap 而不是 CSS scroll-snap——Lenis 是用 JS 驱动 scrollTop 的，和原生吸附会互相抢控制权、滚起来发抖。Snap 挂在同一个 Lenis 实例上没有这个问题，",[15,9769,9770],{},"onSnapComplete"," 还能驱动指示器。",[11,9773,9774],{},"两个机制都想控制滚动位置，结果就是抖。",[11,9776,9777],{},"挂在同一个实例上之后，吸附完成后还能拿到回调，右侧的页码指示器直接跟着它更新。",[26,9779,9781,9782,9785],{"id":9780},"二mandatory-是错的","二、",[15,9783,9784],{},"mandatory"," 是错的",[11,9787,9788,9789,9792],{},"第一版用 ",[15,9790,9791],{},"type: 'mandatory'","。用户反馈手感是「被抢走了」。",[11,9794,9795,9796,9798],{},"原因是 ",[15,9797,9784],{}," 在用户还在滑的时候就强行把页面拽向最近一页。滑动过程被插手，手感不是吸附，是失控。",[11,9800,9801,9802,1244],{},"改成 ",[15,9803,9804],{},"proximity",[399,9806,9808],{"className":691,"code":9807,"language":693,"meta":183,"style":183},"type: 'proximity',\ndistanceThreshold: '30%',\nduration: 1.1,\ndebounce: 500,\n",[15,9809,9810,9822,9834,9846],{"__ignoreMap":183},[407,9811,9812,9815,9817,9820],{"class":409,"line":410},[407,9813,9814],{"class":554},"type",[407,9816,3607],{"class":413},[407,9818,9819],{"class":429},"'proximity'",[407,9821,3296],{"class":413},[407,9823,9824,9827,9829,9832],{"class":409,"line":184},[407,9825,9826],{"class":554},"distanceThreshold",[407,9828,3607],{"class":413},[407,9830,9831],{"class":429},"'30%'",[407,9833,3296],{"class":413},[407,9835,9836,9839,9841,9844],{"class":409,"line":189},[407,9837,9838],{"class":554},"duration",[407,9840,3607],{"class":413},[407,9842,9843],{"class":476},"1.1",[407,9845,3296],{"class":413},[407,9847,9848,9851,9853,9856],{"class":409,"line":452},[407,9849,9850],{"class":554},"debounce",[407,9852,3607],{"class":413},[407,9854,9855],{"class":476},"500",[407,9857,3296],{"class":413},[11,9859,9860],{},"三条参数各管一件事：",[123,9862,9863,9868,9874],{},[126,9864,9865,9867],{},[15,9866,9804],{}," 只在停下来且已经接近边界时才轻推一把。",[126,9869,9870,9873],{},[15,9871,9872],{},"distanceThreshold: '30%'"," 定义「接近」——落点离边界不到 30% 视口高才吸附，停在页面中间就让它停着。",[126,9875,9876,9879],{},[15,9877,9878],{},"debounce: 500"," 等惯性完全停下来再判断，不在滑行中途插手。",[11,9881,9882,9884],{},[15,9883,9850],{}," 从 220 提到 500：",[1205,9886,9887],{},[11,9888,9889],{},"debounce 太小会在惯性还没停时就抢着吸附，手感发涩。",[11,9891,9892],{},"实测结果：",[399,9894,9897],{"className":9895,"code":9896,"language":1327},[1325],"滑动全程 0 反向帧（原来会被拽回）\n停在离边界 306px（超过 30% 阈值）时保持不动\n停在边界附近才对齐整页\n",[15,9898,9896],{"__ignoreMap":183},[26,9900,9902],{"id":9901},"三一屏装不装得下","三、一屏装不装得下",[11,9904,9905],{},"分页的前提是内容能装进一屏。这一条逐档量过。",[11,9907,9908],{},"首屏原高 921px：",[123,9910,9911,9914],{},[126,9912,9913],{},"1440×900 超出 89px",[126,9915,9916],{},"1366×768 超出 201px",[11,9918,9919],{},"精选复盘 788px，在 1366×768 超出 88px。",[11,9921,9922],{},"两个方向可选：砍内容，或者按档收紧间距。选了后者，分三档：",[399,9924,9927],{"className":9925,"code":9926,"language":1327},[1325],"min-width: 1024px and max-height: 820px   深压\nmin-width: 1024px and max-height: 900px   只压首屏\nmin-width: 1024px and min-height: 1000px  放大间距吃大屏留白\n",[15,9928,9926],{"__ignoreMap":183},[11,9930,9931],{},"中间那档的调整方式值得记一笔。为了挤出 27px，第一版直接砍了字号：",[399,9933,9937],{"className":9934,"code":9935,"language":9936,"meta":183,"style":183},"language-css shiki shiki-themes github-light github-dark","font-size: clamp(46px, 9.2vw, 132px)  →  clamp(30px, 5.4vw, 62px)\n","css",[15,9938,9939],{"__ignoreMap":183},[407,9940,9941,9944,9947,9950,9953,9956],{"class":409,"line":410},[407,9942,9943],{"class":8622},"font-size",[407,9945,9946],{"class":413},": clamp(46px, 9",[407,9948,9949],{"class":554},".2vw",[407,9951,9952],{"class":413},", 132px)  →  clamp(30px, 5",[407,9954,9955],{"class":554},".4vw",[407,9957,9958],{"class":413},", 62px)\n",[11,9960,9961],{},"砍掉一半多。首屏标题是整页的主体，砍字号会让整屏塌下来。改成全部从内边距要：",[399,9963,9965],{"className":9934,"code":9964,"language":9936,"meta":183,"style":183},"@media (min-width: 1024px) and (max-height: 900px) {\n  .page-hero :deep(.hero) { padding-top: 20px; padding-bottom: 24px; }\n  .page-hero :deep(.hero-foot) { margin-top: 26px; }\n}\n",[15,9966,9967,10003,10041,10064],{"__ignoreMap":183},[407,9968,9969,9972,9974,9977,9979,9982,9985,9987,9990,9992,9995,9997,9999,10001],{"class":409,"line":410},[407,9970,9971],{"class":417},"@media",[407,9973,3724],{"class":413},[407,9975,9976],{"class":476},"min-width",[407,9978,3607],{"class":413},[407,9980,9981],{"class":476},"1024",[407,9983,9984],{"class":417},"px",[407,9986,722],{"class":413},[407,9988,9989],{"class":417},"and",[407,9991,3724],{"class":413},[407,9993,9994],{"class":476},"max-height",[407,9996,3607],{"class":413},[407,9998,7415],{"class":476},[407,10000,9984],{"class":417},[407,10002,887],{"class":413},[407,10004,10005,10008,10011,10014,10017,10020,10022,10025,10027,10029,10032,10034,10036,10038],{"class":409,"line":184},[407,10006,10007],{"class":554},"  .page-hero",[407,10009,10010],{"class":413}," :deep(",[407,10012,10013],{"class":554},".hero",[407,10015,10016],{"class":413},") { ",[407,10018,10019],{"class":476},"padding-top",[407,10021,3607],{"class":413},[407,10023,10024],{"class":476},"20",[407,10026,9984],{"class":417},[407,10028,1121],{"class":413},[407,10030,10031],{"class":476},"padding-bottom",[407,10033,3607],{"class":413},[407,10035,2805],{"class":476},[407,10037,9984],{"class":417},[407,10039,10040],{"class":413},"; }\n",[407,10042,10043,10045,10047,10050,10052,10055,10057,10060,10062],{"class":409,"line":189},[407,10044,10007],{"class":554},[407,10046,10010],{"class":413},[407,10048,10049],{"class":554},".hero-foot",[407,10051,10016],{"class":413},[407,10053,10054],{"class":476},"margin-top",[407,10056,3607],{"class":413},[407,10058,10059],{"class":476},"26",[407,10061,9984],{"class":417},[407,10063,10040],{"class":413},[407,10065,10066],{"class":409,"line":452},[407,10067,483],{"class":413},[11,10069,10070],{},"只有 700px 可用高度那一档才让一档字号。三档实测：",[399,10072,10075],{"className":10073,"code":10074,"language":1327},[1325],"1440×900    132px\n1920×1080   132px\n1366×768     92.9px\n",[15,10076,10074],{"__ignoreMap":183},[26,10078,10080],{"id":10079},"四检测判据写错过一次","四、检测判据写错过一次",[11,10082,10083],{},"判断页面有没有溢出，第一版用的是：",[399,10085,10087],{"className":691,"code":10086,"language":693,"meta":183,"style":183},"scrollHeight > clientHeight\n",[15,10088,10089],{"__ignoreMap":183},[407,10090,10091,10094,10096],{"class":409,"line":410},[407,10092,10093],{"class":413},"scrollHeight ",[407,10095,3519],{"class":417},[407,10097,10098],{"class":413}," clientHeight\n",[11,10100,10101,10102,10105,10106,88,10109,10112],{},"这个判据永远为假。页面用的是 ",[15,10103,10104],{},"min-height","，内容超了页面会自己长高——",[15,10107,10108],{},"scrollHeight",[15,10110,10111],{},"clientHeight"," 一起变大，比值不变。",[11,10114,10115],{},"改成直接比可用高度：",[399,10117,10119],{"className":691,"code":10118,"language":693,"meta":183,"style":183},"\u002F\u002F 检测判据从 scrollHeight > clientHeight 改为直接比可用高度——\n\u002F\u002F 页面用的是 min-height，内容超了页面会自己长高，前者永远比不出溢出\n",[15,10120,10121,10126],{"__ignoreMap":183},[407,10122,10123],{"class":409,"line":410},[407,10124,10125],{"class":528},"\u002F\u002F 检测判据从 scrollHeight > clientHeight 改为直接比可用高度——\n",[407,10127,10128],{"class":409,"line":184},[407,10129,10130],{"class":528},"\u002F\u002F 页面用的是 min-height，内容超了页面会自己长高，前者永远比不出溢出\n",[11,10132,10133],{},"这个错误值得单独说：判据本身写错了，所以「三档零溢出」这个结论在修正之前是不成立的。",[11,10135,10136],{},"修正之后三档均为 8\u002F8 页零溢出。",[26,10138,10140],{"id":10139},"五不分页的三种情况","五、不分页的三种情况",[399,10142,10145],{"className":10143,"code":10144,"language":1327},[1325],"窄屏（\u003C 1024px）\n矮视口（\u003C 620px）\n减弱动效（prefers-reduced-motion）\n",[15,10146,10144],{"__ignoreMap":183},[11,10148,10149],{},"三种都退回连续滚动。",[11,10151,10152],{},"前两种有实测依据：",[399,10154,10157],{"className":10155,"code":10156,"language":1327},[1325],"实测 390px 下自我介绍 1448px、精选复盘 1114px，强行一屏只会截断内容\n",[15,10158,10156],{"__ignoreMap":183},[11,10160,10161],{},"第三种是硬要求。整屏吸附会强制改变滚动位置，这和「尊重减弱动效偏好」直接冲突。CSS 里也要同步禁用：",[399,10163,10165],{"className":9934,"code":10164,"language":9936,"meta":183,"style":183},"@media (prefers-reduced-motion: reduce) {\n  .page { min-height: 0; }\n  .pager { display: none; }\n}\n",[15,10166,10167,10174,10189,10206],{"__ignoreMap":183},[407,10168,10169,10171],{"class":409,"line":410},[407,10170,9971],{"class":417},[407,10172,10173],{"class":413}," (prefers-reduced-motion: reduce) {\n",[407,10175,10176,10179,10181,10183,10185,10187],{"class":409,"line":184},[407,10177,10178],{"class":554},"  .page",[407,10180,7652],{"class":413},[407,10182,10104],{"class":476},[407,10184,3607],{"class":413},[407,10186,648],{"class":476},[407,10188,10040],{"class":413},[407,10190,10191,10194,10196,10199,10201,10204],{"class":409,"line":189},[407,10192,10193],{"class":554},"  .pager",[407,10195,7652],{"class":413},[407,10197,10198],{"class":476},"display",[407,10200,3607],{"class":413},[407,10202,10203],{"class":476},"none",[407,10205,10040],{"class":413},[407,10207,10208],{"class":409,"line":452},[407,10209,483],{"class":413},[26,10211,10213],{"id":10212},"六分页之后每页只剩半屏内容","六、分页之后，每页只剩半屏内容",[11,10215,10216],{},"八屏分配完之后，出现一个反向问题：每页内容太少。",[11,10218,10219],{},"实测 1440×900 下多数页只填了 47%~63%，「做过的事」那页 63% 是空的。",[11,10221,10222],{},"既然分了页，每页就得撑得住。逐页补实：",[399,10224,10227],{"className":10225,"code":10226,"language":1327},[1325],"做过的事：加页首概览条（项目数\u002F时间跨度\u002F人手），每项补详细说明、关键指标与技术标签。308px → 548px\n项目：3 张卡补到 6 张，两行三列。362px → 511px\n联系：补引言、about 与 timeline 两个入口、站点信息带。452px → 791px\n专栏：每张卡补该栏最新一篇标题。512px → 597px\n自我介绍 01：四张能力卡各补首组的真实条目。504px → 596px\n自我介绍 02：补技术栈横带。522px → 603px\n",[15,10228,10226],{"__ignoreMap":183},[11,10230,10231],{},"填充率从 47%~63% 提到 61%~100%。",[11,10233,10234],{},"同一轮里把两个区块的入场动画也补了常驻循环，因为：",[1205,10236,10237],{},[11,10238,10239],{},"实测入场动画本来就在跑（轴线 scaleX 0→0.99、圆点带回弹、卡片 clip-path 从 78% 揭到 4%、指标读数往上跳），问题是太隐蔽：一次性、1 秒内结束、触发点又在区块刚露头时，等正眼看过去通常已经播完。",[399,10241,10244],{"className":10242,"code":10243,"language":1327},[1325],"触发点 top 88% → 95%，轴线 0.9s → 1.25s，圆点与节点错峰 0.11 → 0.16\n",[15,10245,10243],{"__ignoreMap":183},[26,10247,10249],{"id":10248},"七断点从-900-降到-820","七、断点从 900 降到 820",[11,10251,10252],{},"第一版把「矮视口」定在 900px 高。1440×900 的屏幕因此被当成矮视口，白白砍掉了列表摘要——精选复盘从 788px 缩到 389px。",[11,10254,10255],{},"而 1440×900 明明有空间。断点降到 820：",[399,10257,10260],{"className":10258,"code":10259,"language":1327},[1325],"820 以下   深压\n900 以下   只压首屏\n1000 以上  放大间距吃掉大屏留白\n",[15,10261,10259],{"__ignoreMap":183},[26,10263,10265],{"id":10264},"八两个-css-优先级问题","八、两个 CSS 优先级问题",[11,10267,10268],{},[488,10269,10270],{},"首屏被挤成一个框。",[399,10272,10274],{"className":9934,"code":10273,"language":9936,"meta":183,"style":183},".page { padding: 24px 0; }\n.page-hero { padding: 0; }\n",[15,10275,10276,10296],{"__ignoreMap":183},[407,10277,10278,10281,10283,10286,10288,10290,10292,10294],{"class":409,"line":410},[407,10279,10280],{"class":554},".page",[407,10282,7652],{"class":413},[407,10284,10285],{"class":476},"padding",[407,10287,3607],{"class":413},[407,10289,2805],{"class":476},[407,10291,9984],{"class":417},[407,10293,1118],{"class":476},[407,10295,10040],{"class":413},[407,10297,10298,10301,10303,10305,10307,10309],{"class":409,"line":184},[407,10299,10300],{"class":554},".page-hero",[407,10302,7652],{"class":413},[407,10304,10285],{"class":476},[407,10306,3607],{"class":413},[407,10308,648],{"class":476},[407,10310,10040],{"class":413},[11,10312,10313,10316,10317,10320],{},[15,10314,10315],{},".page-hero { padding: 0 }"," 写在媒体查询之前，被后面同优先级的 ",[15,10318,10319],{},".page { padding: ... }"," 覆盖了。首屏于是四边都留出内边距，看起来像一个框。",[11,10322,10323],{},"修法是在每一档里重新声明一次：",[399,10325,10327],{"className":9934,"code":10326,"language":9936,"meta":183,"style":183},"\u002F* 首屏必须满幅出血。这行不能省——上面那条 .page 同优先级且更靠后，\n   会把顶部声明的 .page-hero { padding: 0 } 覆盖掉。 *\u002F\n.page-hero { padding: 0; }\n",[15,10328,10329,10334,10339],{"__ignoreMap":183},[407,10330,10331],{"class":409,"line":410},[407,10332,10333],{"class":528},"\u002F* 首屏必须满幅出血。这行不能省——上面那条 .page 同优先级且更靠后，\n",[407,10335,10336],{"class":409,"line":184},[407,10337,10338],{"class":528},"   会把顶部声明的 .page-hero { padding: 0 } 覆盖掉。 *\u002F\n",[407,10340,10341,10343,10345,10347,10349,10351],{"class":409,"line":189},[407,10342,10300],{"class":554},[407,10344,7652],{"class":413},[407,10346,10285],{"class":476},[407,10348,3607],{"class":413},[407,10350,648],{"class":476},[407,10352,10040],{"class":413},[11,10354,10355],{},[488,10356,10357],{},"标题被大屏规则压小。",[11,10359,10360],{},"大屏档里有一条：",[399,10362,10364],{"className":9934,"code":10363,"language":9936,"meta":183,"style":183},".page :deep(.big) { font-size: clamp(34px, 3.4vw, 56px); }\n",[15,10365,10366],{"__ignoreMap":183},[407,10367,10368,10370,10372,10375,10377,10379,10381,10384,10386,10389,10391,10393,10396,10399,10401,10404,10406],{"class":409,"line":410},[407,10369,10280],{"class":554},[407,10371,10010],{"class":413},[407,10373,10374],{"class":554},".big",[407,10376,10016],{"class":413},[407,10378,9943],{"class":476},[407,10380,3607],{"class":413},[407,10382,10383],{"class":476},"clamp",[407,10385,586],{"class":413},[407,10387,10388],{"class":476},"34",[407,10390,9984],{"class":417},[407,10392,433],{"class":413},[407,10394,10395],{"class":476},"3.4",[407,10397,10398],{"class":417},"vw",[407,10400,433],{"class":413},[407,10402,10403],{"class":476},"56",[407,10405,9984],{"class":417},[407,10407,10408],{"class":413},"); }\n",[11,10410,10411,10412,10414],{},"首屏主标题的类名也是 ",[15,10413,10374],{},"。1920×1080 下它从 132px 被压到 56px，首屏整个塌掉。",[11,10416,10417],{},"改成排除首屏：",[399,10419,10421],{"className":9934,"code":10420,"language":9936,"meta":183,"style":183},"\u002F* 必须排除首屏——hero 里的 .big 是那个巨大的主标题，\n   被这条命中会从 132px 压到 56px，整个首屏塌掉 *\u002F\n.page:not(.page-hero) :deep(.big) { font-size: clamp(34px, 3.4vw, 56px); }\n",[15,10422,10423,10428,10433],{"__ignoreMap":183},[407,10424,10425],{"class":409,"line":410},[407,10426,10427],{"class":528},"\u002F* 必须排除首屏——hero 里的 .big 是那个巨大的主标题，\n",[407,10429,10430],{"class":409,"line":184},[407,10431,10432],{"class":528},"   被这条命中会从 132px 压到 56px，整个首屏塌掉 *\u002F\n",[407,10434,10435,10438,10440,10442,10445,10447,10449,10451,10453,10455,10457,10459,10461,10463,10465,10467,10469,10471,10473],{"class":409,"line":189},[407,10436,10437],{"class":554},".page:not",[407,10439,586],{"class":413},[407,10441,10300],{"class":554},[407,10443,10444],{"class":413},") :deep(",[407,10446,10374],{"class":554},[407,10448,10016],{"class":413},[407,10450,9943],{"class":476},[407,10452,3607],{"class":413},[407,10454,10383],{"class":476},[407,10456,586],{"class":413},[407,10458,10388],{"class":476},[407,10460,9984],{"class":417},[407,10462,433],{"class":413},[407,10464,10395],{"class":476},[407,10466,10398],{"class":417},[407,10468,433],{"class":413},[407,10470,10403],{"class":476},[407,10472,9984],{"class":417},[407,10474,10408],{"class":413},[11,10476,10477,10478,781,10481,10483,10484,10486],{},"两个问题的成因相同：",[488,10479,10480],{},"同一个类名在两种语义下被复用",[15,10482,10280],{}," 既是容器也是列表页的内边距来源，",[15,10485,10374],{}," 既是区块小标题也是首屏主标题。分开之后就没这个问题。",[11,10488,10489],{},"修完实测：hero 左 0、底 1、右 10（右侧那 10px 是站点滚动条本身的宽度），标题 132px 满值，8\u002F8 页仍零溢出。",[26,10491,10493],{"id":10492},"九加载页期间的一个时序漏洞","九、加载页期间的一个时序漏洞",[11,10495,10496],{},"这个站有一个启动加载页，首次访问时盖几秒。加载页盖着的时候，入场动画被暂停了——否则动画会在遮挡期间自己跑完。",[11,10498,10499],{},"副作用是页面切换的幕布动画也被冻住：",[1205,10501,10502],{},[11,10503,10504],{},"实测（加载页 3s 时点击）：t=745 路由已切到 \u002Fcolumns 而幕布 display:none，t=1406 幕布才出现并扫过。",[11,10506,10507],{},"路由在没有幕布的情况下切换了，等加载页结束、时间线恢复之后，被冻住的补间才补播。",[11,10509,10510],{},"结论是补一行守卫：加载页盖着时直接放行，不放幕布。理由是：",[1205,10512,10513],{},[11,10514,10515],{},"真实用户点不到——加载页是全屏遮罩。但浏览器前进\u002F后退、程序化跳转仍可能在窗口内触发导航。",[26,10517,10519],{"id":10518},"十整屏分页的账","十、整屏分页的账",[11,10521,10522],{},"这套改动最后收敛成一张数字表：",[1708,10524,10525,10535],{},[1711,10526,10527],{},[1714,10528,10529,10532],{},[1717,10530,10531],{},"项目",[1717,10533,10534],{},"数值",[1730,10536,10537,10545,10553,10561,10569,10577,10585],{},[1714,10538,10539,10542],{},[1735,10540,10541],{},"页数",[1735,10543,10544],{},"8",[1714,10546,10547,10550],{},[1735,10548,10549],{},"吸附类型",[1735,10551,10552],{},"proximity，阈值 30%，防抖 500ms",[1714,10554,10555,10558],{},[1735,10556,10557],{},"吸附时长",[1735,10559,10560],{},"1.1s",[1714,10562,10563,10566],{},[1735,10564,10565],{},"分页生效条件",[1735,10567,10568],{},"宽 ≥ 1024px 且 高 ≥ 620px 且 未开启减弱动效",[1714,10570,10571,10574],{},[1735,10572,10573],{},"溢出",[1735,10575,10576],{},"三档均 0\u002F8",[1714,10578,10579,10582],{},[1735,10580,10581],{},"填充率",[1735,10583,10584],{},"61%~100%（改前 47%~63%）",[1714,10586,10587,10590],{},[1735,10588,10589],{},"首屏标题",[1735,10591,10592],{},"132px（1440×900、1920×1080）、92.9px（1366×768）",[11,10594,10595],{},"每一条都有一次实测在后面。这类改动没别的办法——「一屏装不装得下」这件事只能量，量完才知道该压间距还是该砍内容。",[11,10597,10598,10599,10601],{},"中间还有两处是判断上的错，不是量的问题：一是用 ",[15,10600,9784],{}," 吸附（把用户的滑动抢走了），二是把「矮视口」定在 900（把有空间的屏幕当成没空间）。前者的修法是换模式，后者的修法是挪断点。",[1267,10603,10604],{},"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 pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}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 .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":183,"searchDepth":184,"depth":184,"links":10606},[10607,10608,10610,10611,10612,10613,10614,10615,10616,10617],{"id":9748,"depth":184,"text":9749},{"id":9780,"depth":184,"text":10609},"二、mandatory 是错的",{"id":9901,"depth":184,"text":9902},{"id":10079,"depth":184,"text":10080},{"id":10139,"depth":184,"text":10140},{"id":10212,"depth":184,"text":10213},{"id":10248,"depth":184,"text":10249},{"id":10264,"depth":184,"text":10265},{"id":10492,"depth":184,"text":10493},{"id":10518,"depth":184,"text":10519},"2026-08-02",{},"\u002F2026-08-02",{"title":9731,"description":9736},"2026-08-02-首页改成八屏","把博客首页从连续滚动改成八屏整屏分页。吸附用 Lenis 自带能力而不是 CSS 原生，断点按实测调整，每一档的溢出与填充率都有数字。",[10625,10626,10627,10628,10629,10630],"GSAP","Lenis","滚动吸附","响应式断点","页面结构","prefers-reduced-motion","2o9dKTJYPwCLxF__bLLN46O2x_0PMJoQgyZSxZpO428",{"id":10633,"title":10634,"body":10635,"column":196,"date":10923,"description":10639,"extension":199,"hero_image":200,"meta":10924,"navigation":202,"path":10925,"seo":10926,"series_id":200,"severity":200,"stem":10927,"summary":10928,"tags":10929,"__hash__":10933},"posts\u002F2026-07-29-一年八千次提交AI辅助开发工作流.md","一年八千次提交，我是怎么干的",{"type":8,"value":10636,"toc":10906},[10637,10640,10643,10646,10650,10653,10656,10659,10663,10666,10669,10683,10686,10690,10693,10696,10699,10703,10706,10709,10729,10732,10736,10739,10750,10753,10757,10760,10774,10777,10780,10784,10787,10790,10794,10797,10800,10803,10807,10810,10813,10836,10843,10846,10852,10855,10858,10861,10868,10871,10874,10900,10903],[11,10638,10639],{},"2026 上半年，主要项目累计提交 7787 次（其中 newCodex 因为是 fork 项目，1922 次提交含上游历史；纯新增代码的项目是 yun-claude、yun-claw、new-openclaw 等）。提交数看起来多，但每个提交背后的价值不在数量，而在流程的可重复性。这篇文章把工作流写清楚。",[26,10641,10642],{"id":10642},"流程的六个环节",[11,10644,10645],{},"整个开发周期分六步：需求澄清 → 设计文档审阅 → 拆实施计划 → 分阶段实现 → 代码审查 → 故障归档。每一步都有明确的输入输出和验证方式。",[31,10647,10649],{"id":10648},"_1-需求澄清","1. 需求澄清",[11,10651,10652],{},"开始前问清楚，不要猜。典型问题：这个功能要处理哪些用户场景？边界条件是什么？现有系统的哪部分会受影响？",[11,10654,10655],{},"这一步的产出是一份结构化的需求文档，包括功能范围、约束条件、风险假设。不是长篇幅的铺垫，而是一份清单式的澄清记录。",[11,10657,10658],{},"在 new-openclaw 项目里，这一步通常是用 Claude 的 superpowers:brainstorming skill 来展开。提问方式很重要：我会列出已知条件，然后问\"这个设计下会遇到什么问题？\"而不是\"你觉得应该怎么做？\"让 AI 在我的约束框架内工作。",[31,10660,10662],{"id":10661},"_2-设计文档与人工审阅","2. 设计文档与人工审阅",[11,10664,10665],{},"这是整个流程里最关键的检查点。花一小时审一份 200 行的设计文档，比花五小时审一份 2000 行的代码便宜得多。而且回头修改设计的成本远低于修改实现。",[11,10667,10668],{},"new-openclaw 项目现有 30 份设计文档（specs 目录），每份文档都包括：",[123,10670,10671,10674,10677,10680],{},[126,10672,10673],{},"范围：这个设计覆盖什么、不覆盖什么",[126,10675,10676],{},"决策与权衡：为什么选这个方案，放弃了什么",[126,10678,10679],{},"接口契约：如果涉及多个模块，清晰定义每个边界",[126,10681,10682],{},"风险清单：已知的坑和防护措施",[11,10684,10685],{},"写完设计文档以后，我会读一遍、问几个\"为什么\"，然后提出修改意见。这一步排除了 80% 的方向错误。常见的修改方向有：缩小范围（第一个版本不用处理那么多边界情况）、明确约束（系统资源、网络延迟、并发数的假设）、补充防护（熔断、限流、幂等）。",[31,10687,10689],{"id":10688},"_3-实施计划与-dod-定义","3. 实施计划与 DoD 定义",[11,10691,10692],{},"设计文档定下来以后，拆成实施计划。计划的粒度是\"一个可验证的功能单元\"——通常是一个小时到半天的工作量，完成后能单独验证成功。",[11,10694,10695],{},"计划文档里必须写清完成定义（DoD，Definition of Done）。不是\"实现登录功能\"，而是\"写出能拒绝无效格式的登录端点、覆盖单点故障下的重试、有端到端的冒烟测试\"。",[11,10697,10698],{},"new-openclaw 项目现有 36 份实施计划（plans 目录），跨度从一周的 S0 阶段（骨架 + Mock 后台）到数周的 S1 阶段（真实业务后台）。每份计划都带着清晰的 checklist，这样我在执行时能随时问 Claude：\"下一步应该是什么？\"而不是脑子里模糊地记着进度。",[31,10700,10702],{"id":10701},"_4-分阶段实现与逐步验证","4. 分阶段实现与逐步验证",[11,10704,10705],{},"有了计划以后，按顺序实现。关键是每一步都要有验证：单元测试、集成测试、或者一个小的 end-to-end 冒烟测试。验证不通过就停在这一步，不往下推。",[11,10707,10708],{},"这一步会用到三个代理：",[123,10710,10711,10717,10723],{},[126,10712,10713,10716],{},[488,10714,10715],{},"superpowers:test-driven-development"," —— 先写测试，再写实现",[126,10718,10719,10722],{},[488,10720,10721],{},"superpowers:subagent-driven-development"," —— 复杂任务拆成独立的子任务，并行推进",[126,10724,10725,10728],{},[488,10726,10727],{},"superpowers:systematic-debugging"," —— 遇到测试失败，用这个代理追根溯源，不要盲目修改代码",[11,10730,10731],{},"实施过程中如果发现设计假设错了（比如某个接口响应时间远超预期，或者并发场景下出现竞态条件），就停下来回到第 2 步重新审视设计，而不是继续往下推。",[31,10733,10735],{"id":10734},"_5-代码审查","5. 代码审查",[11,10737,10738],{},"实现完成以后，不是立即合并，而是过一遍 code-reviewer 代理。审查的重点不在代码风格（那个自动工具做），而在：",[123,10740,10741,10744,10747],{},[126,10742,10743],{},"这段代码实现的是设计文档里的哪一部分？偏离了吗？",[126,10745,10746],{},"错误处理有没有遗漏？边界情况有没有考虑？",[126,10748,10749],{},"有没有意外改动无关的代码？",[11,10751,10752],{},"审查通常能抓住两类问题。一类是逻辑问题：某个条件判断漏了一个分支，或者并发场景下两个操作的顺序反了。另一类是\"设计和实现对不上\"：实现了一个设计里没提到的特性，或者某个约束（比如\"这个值不能为空\"）没在代码里强制。",[31,10754,10756],{"id":10755},"_6-故障归档","6. 故障归档",[11,10758,10759],{},"系统上线以后，bug 是难免的。重要的是怎么处理它。new-openclaw 项目有一套 bug 知识库规范（CLAUDE.md 里定义），每个 bug 修好以后都要新建一份独立文档，包括：",[123,10761,10762,10765,10768,10771],{},[126,10763,10764],{},"现象和复现路径",[126,10766,10767],{},"根因分析：为什么会发生，触发链路是什么",[126,10769,10770],{},"修复方法：改了什么，为什么选这个方案",[126,10772,10773],{},"预防措施：代码改进、测试添加、还是构建期检查",[11,10775,10776],{},"80 份 bug 文档（截至 5 月中旬）不是问题的多，而是追根溯源的记录的多。下次遇到类似现象，能直接查库而不是重新排查。",[26,10778,10779],{"id":10779},"三条铁律",[31,10781,10783],{"id":10782},"_1-假设必须显式声明","1. 假设必须显式声明",[11,10785,10786],{},"不要猜。不清楚的地方就问，把问题写成澄清清单。\"这个 API 能处理多大的请求体？\"、\"离线场景下要缓存多长时间？\"、\"错误重试间隔是指数退避还是固定时间？\"。",[11,10788,10789],{},"这些问题看起来小，但决定了实现的复杂度和测试用例的多少。猜错了会导致前期设计精美，但方向错误，后面要推倒重来。",[31,10791,10793],{"id":10792},"_2-修改必须精确","2. 修改必须精确",[11,10795,10796],{},"只改需要改的部分。这听起来像常识，但在实际工作中容易出现\"顺手改一下边上的代码\"的情况 —— 格式不规范了，变量命名不一致了，某个函数太长了，\"顺便\"重构一下。",[11,10798,10799],{},"结果是一个改动影响了五个文件，代码审查花了双倍时间，引入了新 bug 的风险。",[11,10801,10802],{},"规则是：改动必须对应需求的某一行。格式、风格、无关的重构，单独立项，不要混在功能改动里。",[31,10804,10806],{"id":10805},"_3-成功标准必须可验证","3. 成功标准必须可验证",[11,10808,10809],{},"\"添加验证\"这个说法是模糊的。改写成：\"写出一个测试用例，输入非法邮箱格式，验证 API 返回 400；输入合法邮箱，验证返回 200 和预期数据结构\"。",[11,10811,10812],{},"每个 plan 文档里的 DoD 都是这样写的。拿一个阶段做例子：",[1205,10814,10815,10818],{},[11,10816,10817],{},"S0 阶段的 DoD：",[243,10819,10820,10823,10830,10833],{},[126,10821,10822],{},"openapi\u002Fapi-v1.yaml 包含 spec 全部 11 个端点的字段级 schema，可被 swagger-ui 加载 ✓",[126,10824,10825,10826,10829],{},"backend\u002F Mock 服务 ",[15,10827,10828],{},"npm start"," 后能响应全部端点，返回符合契约的 mock 数据 ✓",[126,10831,10832],{},"launcher\u002F Rust 项目能编译为 launcher.exe，运行后完成所有阶段扫描并输出 diagnostics.json ✓",[126,10834,10835],{},"至少一个 end-to-end 冒烟测试通过（launcher 上报 → mock 后台收到 → 审计日志记录） ✓",[11,10837,10838,10839,10842],{},"每一条都能通过一个具体的命令来验证。\"能工作\"太模糊，\"运行 ",[15,10840,10841],{},"npm test"," 且所有测试通过\"才是可验证的。",[26,10844,10845],{"id":10845},"流程失效的情况",[11,10847,10848,10849,781],{},"这套流程在一个关键点会失效：",[488,10850,10851],{},"需求本身没想清楚时",[11,10853,10854],{},"我遇到过的例子是这样的。客户说\"要支持 USB 存储检测\"，这个需求很清楚，所以设计文档写了 20 多页，规划了三个阶段，列出了 30 多个 test case。然后两周后，客户补充说\"哦对了，还要处理网络驱动器\"。",[11,10856,10857],{},"这时前面的设计和计划都要回头改。检测逻辑复杂了，测试场景翻倍，阶段划分要调整。这不是流程的问题，这是需求的问题。流程本身反而帮助我及时暴露了这个风险 —— 如果没有设计文档，可能要到代码审查阶段，甚至系统上线以后才发现这个遗漏。",[11,10859,10860],{},"应对办法是在第 1 步（需求澄清）多花时间。列出你想到的所有场景，问\"还有其他我忽略的情况吗？\"。不是要求完全预测未来，而是把已知的不确定性显式写出来，而不是假设需求是固定的。",[11,10862,10863,10864,10867],{},"另一个失效的情况是",[488,10865,10866],{},"设计和实现的沟通不畅","。如果设计文档是给另一个人读的（或者给 AI 代理读的），但执行者没有理解透彻，实现出来会偏离设计。预防办法是在开始实施前，再过一遍设计文档，确认\"我清楚要做什么\"。",[26,10869,10870],{"id":10870},"提交数字背后的故事",[11,10872,10873],{},"为什么能积累 7787 次提交？不是因为每次都在写新功能。真实的分布大概是：",[123,10875,10876,10882,10888,10894],{},[126,10877,10878,10881],{},[488,10879,10880],{},"功能实现","：40%",[126,10883,10884,10887],{},[488,10885,10886],{},"设计文档编写和迭代","：25%",[126,10889,10890,10893],{},[488,10891,10892],{},"测试编写","：20%",[126,10895,10896,10899],{},[488,10897,10898],{},"bug 修复与回归测试","：15%",[11,10901,10902],{},"关键是这些提交都有上下文。每个提交的 message 都指向一个设计文档或一个 plan 的某个环节，或者一个 bug 记录。下次有问题要追查根因时，能快速定位到那个提交，看当时的设计决策是什么。",[11,10904,10905],{},"另外，有 80 份 bug 记录和 66 份设计 + 计划文档（30 specs + 36 plans）这件事本身说明了一点：文档不是负担，文档是工作的实际产出。代码只是文档的一个落地形式。",{"title":183,"searchDepth":184,"depth":184,"links":10907},[10908,10916,10921,10922],{"id":10642,"depth":184,"text":10642,"children":10909},[10910,10911,10912,10913,10914,10915],{"id":10648,"depth":189,"text":10649},{"id":10661,"depth":189,"text":10662},{"id":10688,"depth":189,"text":10689},{"id":10701,"depth":189,"text":10702},{"id":10734,"depth":189,"text":10735},{"id":10755,"depth":189,"text":10756},{"id":10779,"depth":184,"text":10779,"children":10917},[10918,10919,10920],{"id":10782,"depth":189,"text":10783},{"id":10792,"depth":189,"text":10793},{"id":10805,"depth":189,"text":10806},{"id":10845,"depth":184,"text":10845},{"id":10870,"depth":184,"text":10870},"2026-07-29",{},"\u002F2026-07-29-ai",{"title":10634,"description":10639},"2026-07-29-一年八千次提交AI辅助开发工作流","从需求澄清到故障归档，一套系统化的 AI 辅助开发流程：先设计文档过审，再分阶段实施验证，最后代码审查与故障存档，三条铁律支撑整个流程。",[10930,4945,10931,7173,10932],"Claude","AI 辅助开发","文档驱动","DN7sqbGmg2UybSvRXTMLW-dj0UURXET3KsSNeW_1V9I",{"id":10935,"title":10936,"body":10937,"column":1966,"date":11238,"description":10941,"extension":199,"hero_image":200,"meta":11239,"navigation":202,"path":11240,"seo":11241,"series_id":200,"severity":200,"stem":11242,"summary":11243,"tags":11244,"__hash__":11251},"posts\u002F2026-07-26-峰值1987一个人维护AI平台的边界.md","一个人，能维护到多大规模？",{"type":8,"value":10938,"toc":11223},[10939,10942,10945,10948,10951,10983,10986,10989,10993,10996,11003,11018,11021,11028,11032,11035,11038,11041,11048,11055,11059,11062,11069,11073,11076,11079,11089,11100,11103,11107,11110,11113,11120,11124,11127,11130,11133,11137,11140,11143,11169,11172,11175,11178,11184,11198,11203,11217],[11,10940,10941],{},"从 2026-05-22 的 270 个用户到 07 下旬的峰值 1987 日活，这个平台的增长不是线性的。中间隔着四次明确的容量撞墙，每一次都留下了可复查的根因记录。这篇文章讲的是，什么条件下一个人能维护一个到达四位数日活规模的多租户平台。",[26,10943,10944],{"id":10944},"成长曲线与撞墙的四个节点",[11,10946,10947],{},"架构的设计前提是\"1 用户 = 1 容器\"。这个决策确定了，用户数与容器数就是同一个口径。没有\"日活 X 万、同时在线 Y 万\"这种双指标的混淆。",[11,10949,10950],{},"实际的规模序列是这样的：",[123,10952,10953,10959,10965,10971,10977],{},[126,10954,10955,10958],{},[488,10956,10957],{},"270 个 service","（05-22）：刚完成 ffmpeg 升级后的测量",[126,10960,10961,10964],{},[488,10962,10963],{},"372 RUNNING","（05-24）：滚动升级前的容器计数",[126,10966,10967,10970],{},[488,10968,10969],{},"379","（05-25）：五个 P0 patch 部署后稳定",[126,10972,10973,10976],{},[488,10974,10975],{},"574 用户","（06-03）：迁移到 K8s 时的基线，539 个 RUNNING",[126,10978,10979,10982],{},[488,10980,10981],{},"1987","（07 下旬）：最近测得的峰值",[11,10984,10985],{},"跨度是两个月，增速从 Swarm 期间的两周内 270→379（40% 增长）、到迁 K8s 后约八周 574→1987（约 3.5 倍）。这个加速度不是\"计划好的伸缩\"，而是每一次解决了瓶颈后，下一个瓶颈暴露出来。",[26,10987,10988],{"id":10988},"四堵撞过的墙",[31,10990,10992],{"id":10991},"第一堵容器网络-ip-池05-22fa-012","第一堵：容器网络 IP 池（05-22，FA-012）",[11,10994,10995],{},"用户报告\"AI 容器里没 ffmpeg\"。这本来是个 Dockerfile 一行 apt 的事。但打算上线这个修改时，意外发现 gwbridge（Docker Swarm 默认的容器网络）IP 地址段是 \u002F24，总共 253 个 IP，已经被 270 个用户的容器占满。13 个用户容器长期处于启动失败的循环中。",[11,10997,10998,10999,11002],{},"排查逻辑是这样的：一行 Dockerfile 改动不至于触发灰度风险，但灰度一个用户时，Swarm 需要给新容器分配 gwbridge 的 IP。如果池子满了，IP 分配失败，新容器启动失败，再加上老容器还没完全释放（endpoint 还在占位），就陷入了\"申请 → 失败 → retry\"的循环。这种症状看起来随机，用户看到的是\"我的容器启不来\"，系统看到的是\"又一个容器 cycling\"。直到看 ",[15,11000,11001],{},"docker info"," 的输出，才发现 gwbridge 已经 100% 满用。",[11,11004,11005,11006,11009,11010,11017],{},"根本原因在于一个不易察觉的配置陷阱：Docker Daemon 的配置文件里改了 ",[15,11007,11008],{},"default-address-pools","，但这个配置",[488,11011,11012,11013,11016],{},"只在 ",[15,11014,11015],{},"swarm init"," 那一刻消费一次","。已经创建的网络不会动。之前改过这个配置的人可能不知道这个行为，改了等于没改。",[11,11019,11020],{},"修复不能简单地改配置重启。我试过在 staging 环境用 dry-run 验证，写脚本探测不冲突的子网段，然后在 production 的 canary 验证阶段发现\"释放的 IP 立刻被其它 cycling 任务抢走\"这样的负反馈。所以流程变成：先 scale 0 所有失联的服务（停止它们 cycling，释放的位置不会被抢），然后手动删除旧的 gwbridge、用新 subnet 重建。最终把容量从 253 扩到了 4094，增长了 16 倍。13 个失联用户全部恢复。",[11,11022,11023,11024,11027],{},"这次的启示是",[488,11025,11026],{},"配置陷阱往往比代码 bug 更隐蔽","。因为配置改了看不出效果，维护者会觉得没改上去、会反复尝试，但每次尝试的假设都错了。",[31,11029,11031],{"id":11030},"第二堵单容器资源限制与内核参数05-25fa-013","第二堵：单容器资源限制与内核参数（05-25，FA-013）",[11,11033,11034],{},"五天后，部署了五个 P0 patch：B1 容器内存 burst factor 调整（硬限制改为内存 × 4）、B2 mcp register 的假失败救活逻辑、B3 ulimit nofile 扩大到 65536、B4 mcp cooldown 清理、B6 provision 接口幂等性。这一次的滚动升级从 05-25 凌晨 0:41 一直跑到 07:34，实际耗时 6 小时 42 分钟，原本预估只要 3 小时。",[11,11036,11037],{},"但更关键的数据是这个：升级前，平台里有一个重灾户容器一天被 OOM kill 了 247 次。这不是偶发故障——是每一天都在重复。升级完成一小时后，这个数字变成了 0。",[11,11039,11040],{},"这次的根因是九个缺陷的叠加。B1 的根因是硬限制设得太低（原来 93 个用户只有 500MB 硬限，远低于实际需要），B2 是 mcp register exit code 的错误判断（非零 exit 自动当做失败，但有时是因为配置覆盖了），B3 是文件描述符不足导致新连接打开失败。单个缺陷可能不致命，但聚在一起就是 OOM 风暴。更严重的是，一个用户的 OOM 不只影响那个用户——每次 OOM 都会触发内核的 swap 操作，拖累整机的 I\u002FO，让 cpa-api 主进程的事件循环卡顿 18 秒。前端看到的是全站变慢，实际根因可能是某个用户容器在反复 OOM。",[11,11042,11043,11044,11047],{},"部署前做了充分的 staging 验证和 production canary，分阶段升级了重灾户、然后全量升级、最后等完全收敛。期间遇到的问题是单容器的 ",[15,11045,11046],{},"docker service update"," 实际耗时约 60 秒（含 Swarm scheduler 延迟），并发调度起来比预期慢 2 倍。",[11,11049,11050,11051,11054],{},"这次的启示是：",[488,11052,11053],{},"当多个独立缺陷同时叠加时，表象看起来是单一故障（OOM 风暴），但根因分散在配置、参数、逻辑判断的不同层","。修复必须从全景取证开始，先理清每个环节的缺陷，再按优先级有序修复。一次部署才能彻底收敛，局部修复反而会留下隐患。",[31,11056,11058],{"id":11057},"第三堵编排层与自愈能力06-03迁-k8s","第三堵：编排层与自愈能力（06-03，迁 K8s）",[11,11060,11061],{},"到 06-03，用户已经涨到 574 个。继续打补丁的成本已经高于\"一次性迁移编排平台\"。Swarm 的问题不只是容量——还有调度自愈的缺失。任何故障都依赖人工介入，一个人无法 24 小时在线。决策很简单：从 Docker Swarm 迁到 Kubernetes。",[11,11063,11064,11065,11068],{},"这次迁移的复杂性不在于技术实现本身（用 COS 中转数据、转换 sub2api 密钥、把 124G 数据分批导入），而在于",[488,11066,11067],{},"理解新平台引入的新故障域","。K8s 有更细的控制粒度和自动调度，但同时也暴露了之前 Swarm 隐藏的问题。比如共享存储（NFS\u002FSFS）的客户端卡死，在 Swarm 时期可能因为容器分散在不同节点而被掩盖；但在 K8s 这样的细粒度编排下，如果一个节点的 NFS 客户端出问题，节点上的所有 pod 都会受影响。所以迁移不是终点，而是暴露新问题的起点。",[31,11070,11072],{"id":11071},"第四堵节点级存储与可观测性06-05fa-015","第四堵：节点级存储与可观测性（06-05，FA-015）",[11,11074,11075],{},"迁移两天后，某个节点上的 NFS 客户端在某个时刻 hang 住了。Kubernetes 不知道发生了什么，kubelet 仍然报告节点 Ready（因为 kubelet 本身没卡），但这个节点上 52 个实例的网关全部 DOWN 或 HUNG。这 52 个实例对应的是本该分散部署的用户容器，因为某种原因堆在了同一个节点上。",[11,11077,11078],{},"故障的症状可以分成几类。单个实例网关的 DOWN（容器无法启动、schema 非法）、HUNG（反复重启导致 adapter 504）、节点级的卡死（整节点上的容器创建\u002F删除都阻塞）、数据库与实际状态割裂（DB 里是 ERROR、但 pod 健康了）。每种症状对应不同的根因，需要不同的检测和自愈手段。",[11,11080,11081,11082,11085,11086,781],{},"最严重的是，",[488,11083,11084],{},"这个故障没有任何告警","。平台的监控指标全部正常，绿灯一片。直到用户反馈\"连不上面板\"，才被发现。这已经是故障发生几小时以后的事。故障本身是可逆的——节点冻死就人工 cordon、删除卡住的 pod、让其它节点重新调度。但",[488,11087,11088],{},"不可见的故障比故障本身更致命",[11,11090,11091,11092,11095,11096,11099],{},"这次事故直接导出了四层兜底的设计：L0 从配置和资源限制层面降低故障触发（比如 NFS 改 ",[15,11093,11094],{},"soft"," 参数而不是 ",[15,11097,11098],{},"hard","，这样网络抖动时容器报错退出而不是整个节点冻）；L1 加检测让故障可见（每 60 秒检测一遍\"节点上是否有 pod 卡 ContainerCreating、网关探测失败率多少\"）；L2 对低风险故障自动自愈（网关单次 DOWN 就重建 pod、DB 对账失败就修复状态）；L3 对高风险动作告警优先（节点冻死先推送告警、等人工确认再 drain）。",[26,11101,11102],{"id":11102},"三件真正决定可行性的事",[31,11104,11106],{"id":11105},"_1-文档即基础设施","1. 文档即基础设施",[11,11108,11109],{},"这个平台现在有 200+ 份设计文档与 80 份故障档案。这些不是为了\"好看\"存在的。",[11,11111,11112],{},"一个人无法对整个系统保持完整的心智模型。200+ 文档是唯一能让一个人记住系统全貌的方式。每当遇到新故障，能快速检索以前遇过的类似问题。每当需要做架构决策，能回溯当初为什么这样设计、放弃过哪些选项。",[11,11114,11115,11116,11119],{},"同时，",[488,11117,11118],{},"这些文档也是 AI 能有效介入的前提","。我可以把这些故障档案喂给模型，让它帮助排查新问题、验证修复方案、甚至生成监控规则。但前提是要把故障记录得清楚。空洞的\"修好了\"没有任何价值。",[31,11121,11123],{"id":11122},"_2-故障必须被归档","2. 故障必须被归档",[11,11125,11126],{},"同一个症状反复出现，每次修复可能只治了表象的一个侧面，真正的收敛需要理解全部的根因。这只有在每一次都写清档案的情况下才可能。",[11,11128,11129],{},"如果没有归档制度，第二次遇到类似症状时，维护者根本不知道第一次修复做过什么、为什么还没有彻底解决。\"又来了\"和\"这个问题还有遗留\"的反应完全不同。前者是被动应对、逐次救火，后者是主动追踪、系统解决。",[11,11131,11132],{},"实际上，平台里有不少故障走过了四到五次的修复周期。每一次修复时，回看之前的档案，能快速理清\"这一层已经改过，那一层还没触及\"。这种\"有案可查、有据可循\"的状态，把修复从赌博变成了可重复的流程——每一次问题复发时，不是从零开始排查，而是从已知的检查点继续。",[31,11134,11136],{"id":11135},"_3-自愈优先于告警告警优先于人工","3. 自愈优先于告警，告警优先于人工",[11,11138,11139],{},"在 FA-015 之前，平台大量依赖人工值守。任何问题都需要运维看到日志、理解现象、手动操作。一个人无法 24 小时在线。",[11,11141,11142],{},"FA-015 的教训直接导出了四层兜底的设计：",[123,11144,11145,11151,11157,11163],{},[126,11146,11147,11150],{},[488,11148,11149],{},"L0 预防","：从配置和资源限制的层面降低故障触发的概率。",[126,11152,11153,11156],{},[488,11154,11155],{},"L1 检测","：让不可见的故障变成可见——节点卡死、实例网关异常、数据库与实际状态割裂，全部要有独立的检测逻辑。",[126,11158,11159,11162],{},[488,11160,11161],{},"L2 自愈","：对于低风险的故障（单实例网关重启、DB 状态对账），直接自动修复。高风险的动作（节点 drain）先告警、等人工确认。",[126,11164,11165,11168],{},[488,11166,11167],{},"L3 告警","：自愈失败时、检测到新的异常时，推送给人。",[11,11170,11171],{},"这样的设计下，一个人维护平台的上限大幅抬高。不是因为个人能力变强了，而是系统能自动处理大多数故障，只把人类的决策能力用在最关键的地方。",[26,11173,11174],{"id":11174},"诚实的边界在哪",[11,11176,11177],{},"但这个设计也有明显的天花板：",[11,11179,11180,11183],{},[488,11181,11182],{},"真正撑不住的","（需要六小时以上连续操作、需要跨时区响应、需要多人交叉验证）：",[123,11185,11186,11189,11192,11195],{},[126,11187,11188],{},"数据库或存储层的重大故障。恢复涉及数据一致性检查，无法完全自动化。",[126,11190,11191],{},"涉及业务逻辑的错误。修复需要理解用户意图，不只是系统恢复。",[126,11193,11194],{},"密钥泄露或安全事件。需要立刻通知客户、协调应急处置、事后全面审计。",[126,11196,11197],{},"多个独立故障同时发生、相互放大的情况。需要多个人在不同维度分别操作。",[11,11199,11200,1244],{},[488,11201,11202],{},"如果重来一次，优先级这样排",[243,11204,11205,11208,11211,11214],{},[126,11206,11207],{},"最先做的是 L1 检测——让故障可见。这是一切自动化的前提。宁可产生虚报，也不能漏掉真实故障。",[126,11209,11210],{},"其次是 L0 预防——从配置、资源限制、网络参数这些基础设施层降低故障率。这些改动成本低、收益高。",[126,11212,11213],{},"然后才是 L2 自愈——只对低风险的故障做自动恢复。对于高风险操作，即使多花一个人工确认的时间，也要确保不会进一步破坏系统。",[126,11215,11216],{},"最后是文档和监控。这些不是\"最后的事情\"，而是贯穿全过程的——每个改动都要同步更新文档、每个故障都要写进档案。",[11,11218,11219,11220,11222],{},"那些回避的成本很高。曾经因为 NFS 挂载的 ",[15,11221,11098],{}," 参数导致节点冻死，这个参数的改动只需要改一行配置文件、然后滚动重启一次实例。但因为这个调整一直没做，就承受了几小时的无声故障。反过来说，那些看起来\"小\"的改动——改配置参数、改资源限制、加一个监控规则——才是最划算的投资。",{"title":183,"searchDepth":184,"depth":184,"links":11224},[11225,11226,11232,11237],{"id":10944,"depth":184,"text":10944},{"id":10988,"depth":184,"text":10988,"children":11227},[11228,11229,11230,11231],{"id":10991,"depth":189,"text":10992},{"id":11030,"depth":189,"text":11031},{"id":11057,"depth":189,"text":11058},{"id":11071,"depth":189,"text":11072},{"id":11102,"depth":184,"text":11102,"children":11233},[11234,11235,11236],{"id":11105,"depth":189,"text":11106},{"id":11122,"depth":189,"text":11123},{"id":11135,"depth":189,"text":11136},{"id":11174,"depth":184,"text":11174},"2026-07-26",{},"\u002F2026-07-26-1987ai",{"title":10936,"description":10941},"2026-07-26-峰值1987一个人维护AI平台的边界","从 270 到 1987 日活，每一次规模跃升前都先撞了一次墙。这条增长曲线记录的不是预设设计，而是每次故障都被完整归档后逐步演进出来的可行性边界。",[11245,11246,11247,11248,11249,11250],"Docker Swarm","Kubernetes","容器编排","运维自动化","故障自愈","规模扩展","D-mYcaOLOBT0PhwjmrqVVEwCE7hl8Tqi9I57HrRlt5U",{"id":11253,"title":11254,"body":11255,"column":1966,"date":11711,"description":11259,"extension":199,"hero_image":200,"meta":11712,"navigation":202,"path":11713,"seo":11714,"series_id":200,"severity":200,"stem":11715,"summary":11716,"tags":11717,"__hash__":11723},"posts\u002F2026-07-24-引入的第二天我决定不再跟随上游.md","引入的第二天，我决定不再跟随上游",{"type":8,"value":11256,"toc":11702},[11257,11260,11263,11266,11312,11315,11319,11322,11330,11333,11338,11344,11347,11351,11354,11372,11375,11385,11388,11394,11397,11506,11509,11512,11516,11519,11538,11541,11544,11562,11565,11568,11572,11575,11601,11604,11610,11613,11617,11620,11623,11629,11632,11638,11641,11648,11651,11655,11661,11664,11667,11673,11677,11680,11689,11692,11699],[11,11258,11259],{},"上一篇（07-22）刚定下「前端全量原样保留、定期人工同步上游」。",[11,11261,11262],{},"二十小时后，「定期同步」这条被废掉了。",[11,11264,11265],{},"时间线可以精确到小时：",[1708,11267,11268,11278],{},[1711,11269,11270],{},[1714,11271,11272,11275],{},[1717,11273,11274],{},"时间",[1717,11276,11277],{},"事件",[1730,11279,11280,11288,11296,11304],{},[1714,11281,11282,11285],{},[1735,11283,11284],{},"07-22 14:43",[1735,11286,11287],{},"接入决策文档定稿，写着「定期人工同步」",[1714,11289,11290,11293],{},[1735,11291,11292],{},"07-22 16:11",[1735,11294,11295],{},"fork 首次进仓库",[1714,11297,11298,11301],{},[1735,11299,11300],{},"07-23 11:20",[1735,11302,11303],{},"图运行设计文档定稿，前置决策第一条就是脱钩",[1714,11305,11306,11309],{},[1735,11307,11308],{},"07-23 11:42 \u002F 11:57 \u002F 12:32",[1735,11310,11311],{},"三个提交把「图运行 + 脱钩」一起落进主干",[11,11313,11314],{},"从写下调解决定到写下推翻决定，隔了约 20.5 小时。",[26,11316,11318],{"id":11317},"一推翻的理由","一、推翻的理由",[11,11320,11321],{},"原文不长，两句：",[1205,11323,11324],{},[11,11325,11326,11329],{},[488,11327,11328],{},"脱钩上游","：不再跟随上游同步。登记文件降级为引入历史存档，「最小改动\u002F过滤不删」纪律废除，前端代码从此按自有代码演进。",[11,11331,11332],{},"而脱钩的书面理由，写在登记文件的顶部：",[1205,11334,11335],{},[11,11336,11337],{},"决策背景：我们的产品形态（服务端图运行 \u002F DAG 调度）与上游的「逐节点手动推演」哲学根本分叉，同步收益趋近于零而冲突成本持续上升；商业授权为纸质合同（含 CLA 再许可权），脱钩无法律障碍。",[11,11339,11340,11341],{},"两句合起来是一条判据：",[488,11342,11343],{},"同步的收益，取决于我要不要改它给我的东西。",[11,11345,11346],{},"如果我的改动都在外围——加个鉴权、换个存储——那上游每次更新都是净收益，合进来就行。但如果我要改的正是它的执行语义，那上游的每次前进都会加深冲突，同步从收益变成了成本。",[26,11348,11350],{"id":11349},"二具体冲突是什么","二、具体冲突是什么",[11,11352,11353],{},"三条前置决策，第一条是脱钩，第二条解释了冲突在哪：",[1205,11355,11356],{},[243,11357,11358],{"start":184},[126,11359,11360,11363,11364,11367,11368,11371],{},[488,11361,11362],{},"路线 B + 服务端 Worker","：执行层不复用旧自研画布引擎运行时（",[488,11365,11366],{},"其端口模型与新前端的松散连线哲学冲突","），新写贴合新前端数据模型的图调度；但",[488,11369,11370],{},"编排基建照抄旧引擎设计","（BullMQ 队列、lease \u002F 恢复、计费包装模式——已验证过）。服务端形态也已被旧画布验证，不做客户端调度中间品。",[11,11373,11374],{},"这句话里有两个反对称的决定，值得拆开看。",[11,11376,11377,11380,11381,11384],{},[488,11378,11379],{},"不复用的是运行时。"," 旧自研引擎有一套端口模型：节点声明具名输入输出端口，连接受类型校验，不兼容的端口连不上。新前端没有端口——它的连线就是 ",[15,11382,11383],{},"{ fromNodeId, toNodeId }","，语义只有「上游产物是下游的素材」。",[11,11386,11387],{},"两套模型接不上。想复用旧运行时，就得给新前端的图补出端口信息，而补不出来——原始数据里没有。",[11,11389,11390,11393],{},[488,11391,11392],{},"照抄的是编排基建。"," 队列、租约、心跳、恢复循环、计费包装，这些全部沿用旧引擎的设计。理由写在括号里：「已验证过」。",[11,11395,11396],{},"具体到实现，这一部分是逐项搬的：",[1708,11398,11399,11412],{},[1711,11400,11401],{},[1714,11402,11403,11406,11409],{},[1717,11404,11405],{},"机制",[1717,11407,11408],{},"旧引擎",[1717,11410,11411],{},"新实现",[1730,11413,11414,11429,11439,11450,11460,11471,11483,11496],{},[1714,11415,11416,11419,11424],{},[1735,11417,11418],{},"队列名",[1735,11420,11421],{},[15,11422,11423],{},"canvas-workflow-runs",[1735,11425,11426],{},[15,11427,11428],{},"canvas-graph-runs",[1714,11430,11431,11434,11437],{},[1735,11432,11433],{},"租约时长",[1735,11435,11436],{},"60 秒",[1735,11438,11436],{},[1714,11440,11441,11444,11447],{},[1735,11442,11443],{},"心跳间隔",[1735,11445,11446],{},"15 秒",[1735,11448,11449],{},"20 秒",[1714,11451,11452,11455,11458],{},[1735,11453,11454],{},"恢复循环周期",[1735,11456,11457],{},"30 秒",[1735,11459,11457],{},[1714,11461,11462,11465,11468],{},[1735,11463,11464],{},"停滞判定",[1735,11466,11467],{},"300 秒（队列里待太久就重新入队）",[1735,11469,11470],{},"同",[1714,11472,11473,11476,11481],{},[1735,11474,11475],{},"入队幂等",[1735,11477,11478],{},[15,11479,11480],{},"jobId = runId",[1735,11482,11470],{},[1714,11484,11485,11488,11494],{},[1735,11486,11487],{},"计费包装",[1735,11489,11490,11491],{},"台账状态机 ",[15,11492,11493],{},"planned → charged → settled \u002F refund",[1735,11495,11470],{},[1714,11497,11498,11501,11504],{},[1735,11499,11500],{},"单副本并发",[1735,11502,11503],{},"2（生产环境 20）",[1735,11505,11470],{},[11,11507,11508],{},"租约、心跳、恢复这一套要解决的问题是：调度进程崩了怎么办。做法是让「正在执行」这个状态带一个有效期，执行者定期续期；一旦停止续期，另一个实例就能接管。",[11,11510,11511],{},"计费包装那层解决的是另一件事：调度器不许碰钱。节点执行时通过一个包装过的客户端扣费，包装层记台账，状态机的每一步都落库，于是「扣了没结算」「退了没记」这类状态可以事后对账。",[26,11513,11515],{"id":11514},"三保住的边界","三、保住的边界",[11,11517,11518],{},"脱钩之后，第一期的范围写成了五条目标：",[1205,11520,11521],{},[243,11522,11523,11526,11529,11532,11535],{},[126,11524,11525],{},"画布工具栏新增「运行」：全图运行；选中节点时为「运行到此节点」（只跑该节点及其未完成上游）。",[126,11527,11528],{},"服务端 Worker 执行：关浏览器照样跑完，重启可恢复，不重复执行、不重复扣费。",[126,11530,11531],{},"节点级状态可视：排队 \u002F 运行中 \u002F 完成 \u002F 失败角标 + 顶部运行进度；失败节点展示可读错误与重试。",[126,11533,11534],{},"运行记录落库：快照、每节点状态、产物、计费关联可追踪。",[126,11536,11537],{},"结果对账：用户不在线时产生的结果，下次打开画布自动回填。",[11,11539,11540],{},"第二条是这一期的核心。「关浏览器照样跑完」意味着执行必须在服务端，不能在客户端。这也解释了为什么不复用上游的调度——它是给「用户坐在画布前逐个点节点」设计的，那种形态下浏览器就是执行环境。",[11,11542,11543],{},"非目标同样是四条，其中第三条最长：",[1205,11545,11546],{},[123,11547,11548,11551,11556,11559],{},[126,11549,11550],{},"不做多人协同、不做可配置并发上限（沿用「就绪即并发」）。",[126,11552,11553],{},[488,11554,11555],{},"不引入端口 \u002F 类型校验 UI——连线保持上游的松散语义（这是保留该前端的核心理由）。",[126,11557,11558],{},"不做运行详情独立页面（进度浮层 + 节点角标够用，详情页二期）。",[126,11560,11561],{},"旧自研画布引擎不删除、不改动，仅作参考代码。",[11,11563,11564],{},"第二条是这次决策的边界线。脱钩的是「跟随上游」，不是「保留前端」——保留前端的理由正是它的松散连线，那么这条不能反悔。如果哪天要给连线加类型校验，等于承认保错了，得回到第一天重选。",[11,11566,11567],{},"第四条是给旧引擎的处置：不删、不动。它现在的作用是参考代码——编排基建那部分是照着它抄的。",[26,11569,11571],{"id":11570},"四崩溃与重复执行怎么处理","四、崩溃与重复执行怎么处理",[11,11573,11574],{},"服务端执行最容易出问题的地方是「执行者中途没了」。设计里分成几条路：",[123,11576,11577,11583,11589,11595],{},[126,11578,11579,11582],{},[488,11580,11581],{},"worker 崩溃","：租约过期，另一个实例接管。",[126,11584,11585,11588],{},[488,11586,11587],{},"取消","：运行置取消，待执行的节点一并置取消；正在跑的视频任务走既有的取消链路。",[126,11590,11591,11594],{},[488,11592,11593],{},"不自动重跑","：租约过期后接管的实例只做对账，不重新执行节点。要重跑由用户手动点重试。这一条是「一期从简」，因为自动重跑要先解决「上次到底跑没跑完」。",[126,11596,11597,11600],{},[488,11598,11599],{},"不双扣","：每个节点有一个幂等键，任何路径都不产生第二次扣费。",[11,11602,11603],{},"验收标准里有三条是直接对着这些路径写的：",[399,11605,11608],{"className":11606,"code":11607,"language":1327},[1325],"点运行后关闭浏览器，重开画布可见运行完成且产物就位\n删除 worker 进程后运行继续完成，无重复扣费\n重复点运行（同一请求 id）不产生第二个 run\n",[15,11609,11607],{"__ignoreMap":183},[11,11611,11612],{},"第二条用的是「删掉进程」这种验证方式，而不是注入一个异常。前者更接近真实故障。",[26,11614,11616],{"id":11615},"五纪律怎么废","五、纪律怎么废",[11,11618,11619],{},"废止一条纪律，落到文件上是一组具体动作。",[11,11621,11622],{},"登记文件的标题从",[399,11624,11627],{"className":11625,"code":11626,"language":1327},[1325],"# canvas-web 上游同步记录\n",[15,11628,11626],{"__ignoreMap":183},[11,11630,11631],{},"改成",[399,11633,11636],{"className":11634,"code":11635,"language":1327},[1325],"# canvas-web 上游引入历史（已脱钩，存档）\n",[15,11637,11635],{"__ignoreMap":183},[11,11639,11640],{},"「上游同步流程」那一节标为已废止，原来的频率说明用删除线划掉，后面加一句「已脱钩，不再执行」。改动清单那一节标题加上「已停止维护」，原来的登记要求划掉，留一句「以下为脱钩前的引入期改动存档」。",[11,11642,11643,11644,11647],{},"引入时那句「前端代码原样保留」也加了限定：「",[488,11645,11646],{},"引入初期","前端代码原样保留」。",[11,11649,11650],{},"这件事单独占一个实施任务，任务描述里写清了改哪几句、怎么改。把「废止纪律」当成一项要交付的工作，是因为纪律如果只在新文档里宣布作废、旧文件还挂着原有的要求，下一次有人翻到旧文件就会照它做。",[26,11652,11654],{"id":11653},"六这不是朝令夕改","六、这不是朝令夕改",[11,11656,11657,11658,781],{},"隔一天改主意，看上去像反复。区分点在于",[488,11659,11660],{},"被推翻的是哪一条",[11,11662,11663],{},"前一天定的三条里，两条一直没变：前端全量保留、不修改它的设计与交互。变的是第三条「定期人工同步上游」。",[11,11665,11666],{},"而这条之所以要变，是因为另一件事在同期确定了：执行要放在服务端，做成 DAG 调度。这件事一旦定下来，同步的性质就变了——从「拿上游的新功能」变成「不断解决我和上游之间的语义冲突」。",[11,11668,11669,11670],{},"「引入的目标已经达成」这种说法我没有写在当时的文档里，事后也不想补上去。当时的书面理由就是那条收益成本判断：",[488,11671,11672],{},"产品形态与上游哲学分叉之后，同步收益趋近于零，冲突成本持续上升。",[26,11674,11676],{"id":11675},"七脱钩之后","七、脱钩之后",[11,11678,11679],{},"值得一提的是后面发生了什么，因为它验证了这条判断的方向。",[11,11681,11682,11683,11685,11686,781],{},"这套 fork 出来的前端后来又经历了两轮变化，最后整个 fork 被移除——前端改由自研重写，队列名从 ",[15,11684,11428],{}," 换成了 ",[15,11687,11688],{},"canvas-flow-runs",[11,11690,11691],{},"所以「脱钩」不等于「稳定了」。它只是把一件事确定下来：这个模块从此按自有代码演进，好坏都由自己承担。",[11,11693,11694,11695,11698],{},"而 07-23 那天真正保住的，是判断里的另一半——",[488,11696,11697],{},"编排基建照抄旧引擎","。队列、租约、恢复、计费台账这套模式一直用到了今天的画布运行上。脱钩的是前端血缘，不是自己的工程积累。",[11,11700,11701],{},"这两件事分开看，是那一天最值得记的部分：丢掉的是「别人的演进」，留下的是「自己验证过的东西」。判断的依据不是血缘远近，是有没有跑过。",{"title":183,"searchDepth":184,"depth":184,"links":11703},[11704,11705,11706,11707,11708,11709,11710],{"id":11317,"depth":184,"text":11318},{"id":11349,"depth":184,"text":11350},{"id":11514,"depth":184,"text":11515},{"id":11570,"depth":184,"text":11571},{"id":11615,"depth":184,"text":11616},{"id":11653,"depth":184,"text":11654},{"id":11675,"depth":184,"text":11676},"2026-07-24",{},"\u002F2026-07-24",{"title":11254,"description":11259},"2026-07-24-引入的第二天我决定不再跟随上游","前一天刚定下「前端原样保留、定期同步上游」，二十小时后就废掉了同步这条。触发点是一条冲突：上游的松散连线与旧引擎的端口模型接不上。",[11718,11719,11720,8144,11721,11722],"开源引入","脱钩","DAG 调度","lease","架构决策","9ScepKcFB7xo_lsqtEin1HvIMnicAVeDNO14MnI56oQ",{"id":11725,"title":11726,"body":11727,"column":355,"date":12113,"description":11731,"extension":199,"hero_image":200,"meta":12114,"navigation":202,"path":12115,"seo":12116,"series_id":12117,"severity":200,"stem":12118,"summary":12119,"tags":12120,"__hash__":12126},"posts\u002F2026-07-23-FA-018-共享存储的subPath陷阱.md","一个 PVC 装所有用户，当时看不出有什么问题",{"type":8,"value":11728,"toc":12104},[11729,11732,11735,11739,11742,11745,11752,11796,11799,11809,11813,11819,11822,11833,11836,11840,11843,11846,11849,11866,11869,11876,11879,11894,11898,11901,11904,11915,11926,11929,11932,11935,11997,12000,12007,12010,12013,12039,12042,12053,12056,12059,12098,12101],[11,11730,11731],{},"平台从 Docker Swarm 迁移到 Kubernetes，存储架构做了一个看似理想的调整：用共享 PVC + subPath 替代原来的\"每用户独占 PVC\"。方案优势显而易见——管理简单（1 个 PVC 对 574 个）、自动扩展、成本低。当时看不出有什么缺点。",[11,11733,11734],{},"但实施中遇到了四个隐蔽的问题。",[26,11736,11738],{"id":11737},"权限错配bind-型数据的属主陷阱","权限错配：bind 型数据的属主陷阱",[11,11740,11741],{},"旧系统中，用户数据通过两种方式持久化：bind mount 和 Docker volume。迁移时需要从这两个来源完整导出数据。",[11,11743,11744],{},"对于 bind mount 型数据，文件属主通常是 10001（或其他固定 UID）。当我登上旧集群的宿主机，以普通用户身份尝试读取这些文件时，只能看到 13 个文件。权限拒绝。同一目录里其实有 4778 个文件，但大多数因为属主权限不匹配而不可见。",[11,11746,11747,11748,11751],{},"只有用 sudo 才能完整读到。所以迁移脚本必须用 ",[15,11749,11750],{},"sudo tar"," 来打包：",[399,11753,11757],{"className":11754,"code":11755,"language":11756,"meta":183,"style":183},"language-bash shiki shiki-themes github-light github-dark","sudo tar -czf \u002Ftmp\u002Fmig\u002F${uid}\u002Fdata.tgz \\\n  -C \u002Fmnt\u002Fyun-claw\u002Fusers\u002F${uid} .\n","bash",[15,11758,11759,11782],{"__ignoreMap":183},[407,11760,11761,11764,11767,11770,11773,11776,11779],{"class":409,"line":410},[407,11762,11763],{"class":554},"sudo",[407,11765,11766],{"class":429}," tar",[407,11768,11769],{"class":476}," -czf",[407,11771,11772],{"class":429}," \u002Ftmp\u002Fmig\u002F",[407,11774,11775],{"class":413},"${uid}",[407,11777,11778],{"class":429},"\u002Fdata.tgz",[407,11780,11781],{"class":476}," \\\n",[407,11783,11784,11787,11790,11793],{"class":409,"line":184},[407,11785,11786],{"class":476},"  -C",[407,11788,11789],{"class":429}," \u002Fmnt\u002Fyun-claw\u002Fusers\u002F",[407,11791,11792],{"class":413},"${uid} ",[407,11794,11795],{"class":429},".\n",[11,11797,11798],{},"这不是脚本设计的问题，而是 Kubernetes 环境下容器权限与宿主权限映射的一个陷阱。容器内运行的网关进程可能以容器用户身份运行，但宿主上的文件属主是不同的 UID。当两个权限体系接不上，访问就会失败。",[11,11800,11801,11802,11805,11806,11808],{},"新方案中，用户数据通过 subPath 挂到容器的 ",[15,11803,11804],{},"\u002Fdata"," 目录。如果 subPath 指向的目录是由 Kubernetes 在宿主机上创建的，属主可能是 root 或其他用户，容器内进程（以容器指定的 UID 运行）仍然会遭遇权限问题。这个风险需要在 Dockerfile 和容器启动时明确处理：容器内应该预先创建 ",[15,11807,11804],{}," 并设置正确的属主，或者使用 securityContext 的 fsGroup 或 runAsUser 来强制权限。",[26,11810,11812],{"id":11811},"subpath-目录创建的时机与属主处理","subPath 目录创建的时机与属主处理",[11,11814,11815,11816],{},"Kubernetes 对 subPath 的处理有个微妙之处：如果挂载的 subPath 目录在宿主机上不存在，kubelet 会自动创建它。但 ",[407,11817,11818],{},"待补：这个自动创建的目录的属主是什么？是 root 还是其他用户？容器的 securityContext 能否保证挂载后的权限符合预期？",[11,11820,11821],{},"设计文档中没有明确说明这一点。在实施前，需要验证：",[123,11823,11824,11827,11830],{},[126,11825,11826],{},"首次 subPath 目录不存在时，kubelet 创建它并将其属主设置为谁",[126,11828,11829],{},"容器的 securityContext（fsGroup、runAsUser）是否能在挂载时生效",[126,11831,11832],{},"是否应该预先在共享 PVC 上创建所有 subPath 目录并设置好属主，而不是依赖 Kubernetes 的隐式创建",[11,11834,11835],{},"没有明确的答案，就容易踩坑。一个保险做法是在 destroy 用户账户时清空 subPath 目录，同时在 provision 时让一个 init-container 验证或修复目录属主，确保容器进程有写权限。",[26,11837,11839],{"id":11838},"单-pvc-中的节点级故障域","单 PVC 中的节点级故障域",[11,11841,11842],{},"这是最严重的问题，也是与 FA-015（节点假 Ready）的关联点。",[11,11844,11845],{},"当一个节点的 NFS 客户端或到 SFS 的网络链路发生 hang 时，所有依赖共享 PVC 的 Pod 都会受影响。这不是共享 PVC 本身的问题，而是故障隔离的问题。",[11,11847,11848],{},"具体现象：",[123,11850,11851,11857,11860,11863],{},[126,11852,11853,11854,11856],{},"如果 NFS 挂载使用了硬 mount（",[15,11855,11098],{}," 参数是 NFS 的默认值），而网络故障或存储服务中断，NFS 客户端会无限重试",[126,11858,11859],{},"重试期间，所有试图访问 NFS 的进程都会被阻塞在 I\u002FO 上（D 状态，不可中断）",[126,11861,11862],{},"如果 kubelet 或 containerd 的某个操作（如创建容器的卷挂载步骤）陷入 I\u002FO 阻塞，整个节点就会冻死",[126,11864,11865],{},"该节点上的所有实例（无论是否在访问存储）都会因为无法创建或删除 Pod 而故障",[11,11867,11868],{},"这与独立 PVC 的效果截然不同。旧架构中，每个用户有独立 PVC，意味着如果某个 PVC 对应的存储有问题，只会影响这一个用户。其他用户的 PVC 可能分散在不同的存储卷甚至不同的节点上，相对独立。",[11,11870,11871,11872,11875],{},"新架构下，单个共享 PVC 承载所有用户，一旦这个 PVC 对应的网络链路或挂载出问题，",[488,11873,11874],{},"同节点上所有实例都会被殃及","。如果该节点碰巧堆积了 50 个实例，一次节点级的存储 hang 就会导致 50 个实例集体故障，且外表看起来是\"节点 Ready，但实例卡住\"——kubelet 的心跳正常，Pod 状态却卡在 ContainerCreating 或 Terminating。",[11,11877,11878],{},"这正是 FA-015 在 2026-06-05 观察到的现象：节点假 Ready，但大量实例网关无响应。防护措施包括：",[123,11880,11881,11888,11891],{},[126,11882,11883,11884,11887],{},"NFS 挂载参数改为软 mount（",[15,11885,11886],{},"soft,timeo=100,retrans=3","），让 I\u002FO 超时而不是无限等待",[126,11889,11890],{},"节点加健康检测，检查 ContainerCreating 堆积数和网关探测失败率，及早发现节点冻死",[126,11892,11893],{},"实例分散部署，用 topologySpreadConstraints 避免单节点堆积",[26,11895,11897],{"id":11896},"无-per-subpath-硬配额","无 per-subPath 硬配额",[11,11899,11900],{},"共享存储的架构决定了无法在存储层面按 subPath 限制用户配额。SFS（及 NFS 一般）没有 per-directory 的硬配额功能，只能在整个卷级别限制。",[11,11902,11903],{},"这意味着：",[123,11905,11906,11909,11912],{},[126,11907,11908],{},"无法阻止某个用户的数据膨胀而挤占其他用户的空间",[126,11910,11911],{},"无法在存储层面实现\"超额用户的写入失败\"",[126,11913,11914],{},"只能在应用层进行监控、告警和逻辑控制",[11,11916,11917,11918,11921,11922,11925],{},"新系统的方案是应用层监控：定期 ",[15,11919,11920],{},"du -sb \u002Fdata"," 扫描每个用户的目录大小，落库到 Instance 表，",[15,11923,11924],{},"\u002Fstatus"," 接口返回超额标志位。前端据此提示用户\"存储已满\"。这是纯监控，不阻断。如果用户无视提示继续写入，直到共享卷真的满了，才会出现\"所有用户集体写入失败\"的惨淡局面。",[11,11927,11928],{},"这个限制是方案选择的代价。",[26,11930,11931],{"id":11931},"为什么仍然选了这个方案",[11,11933,11934],{},"尽管有这些问题，平台仍然采用了共享 PVC + subPath 的方案。原因是对比了替代方案：",[1708,11936,11937,11953],{},[1711,11938,11939],{},[1714,11940,11941,11944,11947,11950],{},[1717,11942,11943],{},"方案",[1717,11945,11946],{},"优点",[1717,11948,11949],{},"缺点",[1717,11951,11952],{},"故障隔离",[1730,11954,11955,11969,11983],{},[1714,11956,11957,11960,11963,11966],{},[1735,11958,11959],{},"独立 PVC（旧架构）",[1735,11961,11962],{},"天然的每用户隔离；故障域清晰",[1735,11964,11965],{},"管理复杂（574 个 PVC）；扩展性差；成本高",[1735,11967,11968],{},"✓ 最好",[1714,11970,11971,11974,11977,11980],{},[1735,11972,11973],{},"共享 PVC + subPath（新架构）",[1735,11975,11976],{},"管理简单（1 个 PVC）；自动扩展；成本低",[1735,11978,11979],{},"故障隔离性差；无硬配额；权限管理复杂",[1735,11981,11982],{},"✗ 较差",[1714,11984,11985,11988,11991,11994],{},[1735,11986,11987],{},"对象存储（S3\u002FOSS）",[1735,11989,11990],{},"真正的多租户隔离；天然分布",[1735,11992,11993],{},"延迟高；成本更高；应用改造大",[1735,11995,11996],{},"✓ 最好，但代价大",[11,11998,11999],{},"独立 PVC 的管理开销是关键问题。574 个用户意味着 574 个 PVC 对象、574 条 PV 绑定、574 个存储卷。每次实例创建、删除或迁移都要涉及 PVC 的生命周期管理。扩展到 5000 用户时，这种开销会成为瓶颈。对象存储虽然隔离性最好，但需要应用层改造（兼容 S3 API、处理延迟、调整备份策略），而且成本更高。",[11,12001,12002,12003,12006],{},"共享 PVC 方案的核心优势是",[488,12004,12005],{},"运维简洁","：增删用户只需改 subPath（一行 YAML），不涉及存储层操作。代价是故障隔离性从\"用户级\"降到\"节点级\"。",[26,12008,12009],{"id":12009},"适用边界",[11,12011,12012],{},"这个方案适合以下场景：",[123,12014,12015,12021,12027,12033],{},[126,12016,12017,12020],{},[488,12018,12019],{},"用户数量有上限","（几百到几千）。用户过多时，共享卷的单点压力会成问题。",[126,12022,12023,12026],{},[488,12024,12025],{},"用户数据量可控","（单用户通常 GB 级）。如果单用户数据量达 TB，一次 du 扫描会拖累整体。",[126,12028,12029,12032],{},[488,12030,12031],{},"可以接受节点级故障隔离","。只要网络和存储配置足够稳定（硬化 NFS 参数、冗余链路），节点 hang 的概率不会很高。",[126,12034,12035,12038],{},[488,12036,12037],{},"能够实施应用层监控和告警","。无硬配额，就必须有实时监控。",[11,12040,12041],{},"不适合的场景包括：",[123,12043,12044,12047,12050],{},[126,12045,12046],{},"超大规模用户（万级以上）",[126,12048,12049],{},"用户数据特别不均衡（少数用户占大头）的场景",[126,12051,12052],{},"对故障隔离要求极高的系统（比如金融交易）",[26,12054,12055],{"id":12055},"防护与调整",[11,12057,12058],{},"基于这四个问题，实施中做了以下调整：",[243,12060,12061,12070,12080,12086,12092],{},[126,12062,12063,12066,12067,12069],{},[488,12064,12065],{},"权限处理","：容器 Dockerfile 中预先创建 ",[15,12068,11804],{}," 并设置正确的属主；启动脚本检查并修复权限。",[126,12071,12072,12075,12076,12079],{},[488,12073,12074],{},"NFS 参数硬化","：StorageClass 的挂载选项改为 ",[15,12077,12078],{},"soft,timeo=100,retrans=3,intr","，避免硬 mount 导致节点冻。",[126,12081,12082,12085],{},[488,12083,12084],{},"节点健康检测","：cron 每 60 秒检查各节点的 ContainerCreating 堆积和网关探测失败率，及早发现故障。",[126,12087,12088,12091],{},[488,12089,12090],{},"实例分散","：StatefulSet 加 topologySpreadConstraints，避免单节点堆积 50+ 个实例。",[126,12093,12094,12097],{},[488,12095,12096],{},"应用层配额","：\u002Fstatus 接口返回 diskUsedMi 和 overQuota 标志位，前端告警用户。",[11,12099,12100],{},"这些措施不能消除风险，但可以大幅降低故障概率和影响范围。",[1267,12102,12103],{},"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}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 .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":183,"searchDepth":184,"depth":184,"links":12105},[12106,12107,12108,12109,12110,12111,12112],{"id":11737,"depth":184,"text":11738},{"id":11811,"depth":184,"text":11812},{"id":11838,"depth":184,"text":11839},{"id":11896,"depth":184,"text":11897},{"id":11931,"depth":184,"text":11931},{"id":12009,"depth":184,"text":12009},{"id":12055,"depth":184,"text":12055},"2026-07-23",{},"\u002F2026-07-23-fa-018-subpath",{"title":11726,"description":11731},"FA-018","2026-07-23-FA-018-共享存储的subPath陷阱","K8s 共享 PVC + subPath 方案在实施中暴露的四个陷阱：权限不匹配、目录创建时机、节点级故障域、无 per-subPath 配额。",[11246,12121,12122,12123,12124,12125,11952],"存储","subPath","PVC","NFS","权限","4pJJyzbII4OOc9jJS74HUpUfyVc9CfxdE5ovso8ylSE",{"id":12128,"title":12129,"body":12130,"column":1966,"date":12532,"description":12134,"extension":199,"hero_image":200,"meta":12533,"navigation":202,"path":12534,"seo":12535,"series_id":200,"severity":200,"stem":12536,"summary":12537,"tags":12538,"__hash__":12542},"posts\u002F2026-07-22-皮是它的脏器是我的.md","皮是它的，脏器是我的",{"type":8,"value":12131,"toc":12521},[12132,12135,12138,12141,12153,12156,12159,12163,12166,12186,12189,12192,12203,12206,12209,12213,12216,12221,12233,12239,12245,12254,12275,12278,12284,12290,12293,12297,12300,12346,12349,12356,12362,12371,12374,12378,12381,12384,12392,12395,12398,12402,12405,12415,12418,12421,12425,12428,12444,12447,12450,12454,12457,12460,12463,12466,12470,12473,12480,12483,12486,12489,12493,12496,12502,12508,12514],[11,12133,12134],{},"自研的可视化工作流画布上线验证过一轮。结论是前端不够好。",[11,12136,12137],{},"此时面对的选择是：继续改自研的前端，或者引入一个已经成熟的。",[11,12139,12140],{},"最后引了一个开源的画布工作台——React 19 + zustand + Vite 的全栈项目，AGPL-3.0 加 CLA。决策原文写在一句话里：",[1205,12142,12143],{},[11,12144,12145,12146,12149,12150],{},"其前端",[488,12147,12148],{},"全量原样","作为我们的新画布产品（所有页面、功能、设计不动），把它的服务层从「用户自填 API key + 浏览器本地存储」改接到我们的平台（Bearer 鉴权、预扣-结算计费、眼部打码合规、Provider 网关、服务端多租户存储）——",[488,12151,12152],{},"换皮不换脏器，方向反过来：皮是它的，脏器是我们的。",[11,12154,12155],{},"引入开源项目通常的做法是「拿它的核心能力，自己写界面」。这一条方向相反：界面全部保留，只换背后的东西。",[11,12157,12158],{},"本文是决策记录，不是交付成果。",[26,12160,12162],{"id":12161},"一判断是怎么下的","一、判断是怎么下的",[11,12164,12165],{},"同一天的决策记录里写着两条：",[1205,12167,12168],{},[243,12169,12170,12177],{},[126,12171,12172,12173,12176],{},"用户判断现有自研画布「功能缺陷严重，只有计费层值得保留」；要它的",[488,12174,12175],{},"所有功能和页面","，喜欢它的设计。",[126,12178,12179,12182,12183],{},[488,12180,12181],{},"书面商业授权已拿到","。工程侧仍要求：协议文件归档进公司合规存档；覆盖闭源 SaaS、二次修改；以仓库 CLA 为基础覆盖全部贡献者代码。",[488,12184,12185],{},"归档完成前代码不进仓库。",[11,12187,12188],{},"第一条解释为什么换。要的是功能和页面本身——不是「参考它的设计思路」，是整套拿来用。",[11,12190,12191],{},"第二条是一条硬门槛。AGPL-3.0 的常规要求对闭源 SaaS 是冲突的，所以走商业授权；授权是纸质合同，由本人保管，不入库。但「不入库」和「不归档」是两件事：代码进仓库之前，协议必须先归档。实施计划里把这条写成了前置条件——",[1205,12193,12194],{},[11,12195,12196,12199,12200],{},[488,12197,12198],{},"授权门槛","：Task 8（fork 引入）动手前必须确认商业授权协议已归档。",[488,12201,12202],{},"Task 1-7 是纯我们的代码，不受此限制。",[11,12204,12205],{},"所以当天的工作顺序是：先写适配层（自己的代码），再引 fork。上午到下午三点多，落的是画布适配层的契约、脚手架、生图端点、任务表、改图端点、音频端点——全部是自有代码。fork 直到 16:11 才进仓库。",[11,12207,12208],{},"上游代码在引入之前只在一个只读目录里做参考，不允许复制进仓库。",[26,12210,12212],{"id":12211},"二被替换的五项","二、被替换的五项",[11,12214,12215],{},"保留前端的前提是「只换服务层」。服务层具体是这五项：",[11,12217,12218],{},[488,12219,12220],{},"用户自填 API key → 平台注入且锁定。",[1205,12222,12223],{},[11,12224,12225,12226,12228,12229,12232],{},"启动引导模块：读取平台登录 token，写入 config store：各 channel ",[15,12227,3699],{}," = 平台适配层地址、",[15,12230,12231],{},"apiKey"," = token；锁定为不可编辑。\nconfig 页：隐藏「模型\u002FAPI key\u002FbaseUrl」配置区块与 WebDAV 区块，保留主题等本地偏好。\n未登录访问：跳回平台登录页。",[11,12234,12235,12236,781],{},"验收清单里对应一条：",[15,12237,12238],{},"config 页无任何自填 key 入口",[11,12240,12241,12244],{},[488,12242,12243],{},"浏览器本地存储 → 服务端多租户存储。"," 上游把画布项目存在 IndexedDB 里。改成实现它的 persist storage 接口，转成服务端读写。",[11,12246,12247,12250,12251,1244],{},[488,12248,12249],{},"直连模型 → 平台适配层。"," 这一项之所以可行，是因为上游的生成调用走的是",[488,12252,12253],{},"可配 baseUrl 的 OpenAI 兼容契约",[1205,12255,12256],{},[11,12257,12258,12259,56,12262,56,12265,56,12268,56,12271,12274],{},"生成调用走可配 baseUrl 的 OpenAI 兼容契约（",[15,12260,12261],{},"\u002Fv1\u002Fimages\u002Fgenerations",[15,12263,12264],{},"\u002Fv1\u002Fimages\u002Fedits",[15,12266,12267],{},"\u002Fv1\u002Fvideos",[15,12269,12270],{},"\u002Fv1\u002Faudio\u002Fspeech",[15,12272,12273],{},"\u002Fv1\u002Fresponses","）——我们做一层兼容适配路由即可接管全部生成。",[11,12276,12277],{},"视频那条还有一条原生契约路径，映射成本更低。适配层做的事是：鉴权 → 预扣 → 打码 → 走平台网关 → 结算或退款。",[11,12279,12280,12283],{},[488,12281,12282],{},"计费。"," 每个端点一次调用对应一个计费操作，复用平台已有的预扣-结算-退款基建，幂等键取前端请求 id。",[11,12285,12286,12289],{},[488,12287,12288],{},"合规。"," 视频生成提交上游之前先做眼部打码，沿用平台既有的约束。",[11,12291,12292],{},"这五项里，前两项改的是前端的入口，后三项拦在适配层。适配层是新增的，不改上游代码。",[26,12294,12296],{"id":12295},"三改动面有多大","三、改动面有多大",[11,12298,12299],{},"引入的规模需要摆出来看：",[1708,12301,12302,12312],{},[1711,12303,12304],{},[1714,12305,12306,12309],{},[1717,12307,12308],{},"项",[1717,12310,12311],{},"数量",[1730,12313,12314,12322,12330,12338],{},[1714,12315,12316,12319],{},[1735,12317,12318],{},"保留页面",[1735,12320,12321],{},"8 个（首页、图片、视频、资产、提示词、画布、画布详情、配置）",[1714,12323,12324,12327],{},[1735,12325,12326],{},"前端源码",[1735,12328,12329],{},"146 个文件、约 25,000 行",[1714,12331,12332,12335],{},[1735,12333,12334],{},"依赖",[1735,12336,12337],{},"29 个 + 9 个开发依赖",[1714,12339,12340,12343],{},[1735,12341,12342],{},"本地改动登记",[1735,12344,12345],{},"28 条",[11,12347,12348],{},"25,000 行代码里，自己改的那部分是薄的。所有改动集中在少量新增文件和明确标记的最小补丁里。",[11,12350,12351,12352,12355],{},"新增的部分只放一个目录：",[15,12353,12354],{},"src\u002Fplatform\u002F","。这是画布前端与平台之间唯一的接缝，引入时它是个空目录，所有平台相关代码都写在这里。",[11,12357,12358,12359,12361],{},"配置页的裁剪就是这种薄改动的一个例子。平台版不让用户自填 API key，也不使用 WebDAV 同步。做法是在标签页数组末尾加一个 ",[15,12360,2151],{},"，而不是把代码块删掉：",[1205,12363,12364],{},[11,12365,12366,12367,12370],{},"平台版不让用户自填 API key、不用 WebDAV 同步。",[488,12368,12369],{},"用过滤而非删除代码块","，把上游同步冲突面降到最低",[11,12372,12373],{},"同类的地方还有几处：某个页面文件保留不删、某个面板组件保留不删，用不上就不渲染。多留一份死代码，换回来的是将来同步时少一处冲突。",[26,12375,12377],{"id":12376},"四把纪律写成文件","四、把纪律写成文件",[11,12379,12380],{},"改动最小化这件事，靠人记是记不住的。所以有一份登记文件，每次改上游代码都往上加一条：文件、改了什么、为什么。",[11,12382,12383],{},"纪律写在文件里：",[1205,12385,12386],{},[11,12387,12388,12391],{},[488,12389,12390],{},"成本控制的唯一阀门","：本地改动必须最小化，且每一处都登记在下方清单里。任何新增的本地 patch 都要追加记录，否则下次同步会踩雷。",[11,12393,12394],{},"同步流程也写好了：拉上游新版本 → 与登记的基线版本做差异 → 评估冲突 → 挑有价值的改动 → 双环境对照回归 → 更新基线。",[11,12396,12397],{},"基线信息记在同一个文件里：上游版本号、基线提交、引入日期、许可证、商业授权状态。",[26,12399,12401],{"id":12400},"五部署方式里有一条硬约束","五、部署方式里有一条硬约束",[11,12403,12404],{},"画布前端不是整页跳转，而是同源 iframe 嵌在平台外壳里，挂在同域子路径下。",[11,12406,12407,12408,781],{},"这里有一条不能动的约束：",[488,12409,12410,12411,12414],{},"iframe 绝对不能加 ",[15,12412,12413],{},"sandbox"," 属性",[11,12416,12417],{},"加了之后 iframe 会变成不透明源，里面读不到平台的登录 token，「平台注入」这条链路直接失效。已经有测试把这条锁住了。",[11,12419,12420],{},"这条约束的由来值得记：它不是一个可以商量的配置项，而是「同源」这个前提的具体后果。一旦给 iframe 加了 sandbox，前面那五项替换里的第一项——平台注入 token——就没有实现路径了。",[26,12422,12424],{"id":12423},"六非目标","六、非目标",[11,12426,12427],{},"决策记录里写了四条不做的事：",[1205,12429,12430],{},[123,12431,12432,12435,12438,12441],{},[126,12433,12434],{},"不修改它的视觉设计与交互（品牌融合另议）。",[126,12436,12437],{},"不在 P0 引入本地助手。",[126,12439,12440],{},"不迁移旧自研画布的项目数据到新画布。",[126,12442,12443],{},"不自动跟随上游 release。",[11,12445,12446],{},"第三条还需要一个交代：旧画布的项目数据怎么办。决策是先不迁——理由后面写进另一份文档时会更清楚，这里先记结论：旧数据不迁移。",[11,12448,12449],{},"第四条是「定期人工同步」，不是「自动跟随」。这条在第二天就改了，是下一篇的事。",[26,12451,12453],{"id":12452},"七已知的六处差异","七、已知的六处差异",[11,12455,12456],{},"融合版和上游独立部署之间，有六处用户能感知的差异：模型来源、计费、打码、可选模型集合、存储位置、本地 Agent。",[11,12458,12459],{},"这六处是主动选择的结果，不是遗漏，所以在文档里逐条列出来，作为验收时的对照基线。",[11,12461,12462],{},"对应的验收方式是双环境并排：上游的独立部署和融合版放在一起，同一套操作序列逐页跑一遍，除这六处之外行为要一致。",[11,12464,12465],{},"其余验收里有两条是钱和合规相关的：每类生成动作断言只扣一次费、失败退款、视频走视频点；含真人脸的图生视频，提交上游前必须已经打码。",[26,12467,12469],{"id":12468},"八一段没有写进文档的判断","八、一段没有写进文档的判断",[11,12471,12472],{},"收尾说一件文档里没有的事。",[11,12474,12475,12476,12479],{},"「保留前端的松散连线语义，是因为它对创作类工具是优点」——这个论证在当时的文档里",[488,12477,12478],{},"不存在","。文档给出的理由只有两句：功能缺陷严重、只有计费层值得保留；喜欢它的设计。",[11,12481,12482],{},"第二天写下一份设计时，出现的表述是「连线保持上游的松散语义（这是保留该前端的核心理由）」。也就是说，这个判断是在需要决定「要不要给连线加类型校验」的时候才被明确写下来的，而不是引入当天就有的论证。",[11,12484,12485],{},"把理由补写在事后是可以的，但我要分清哪句是当时的判断、哪句是后来的归纳。当时就是嫌它功能少、界面简陋——这个理由本身足够支撑引入，不需要再加一层架构上的正当性。",[11,12487,12488],{},"九天后这套东西被推翻重写，原因正是这条松散连线。那是另一段。",[26,12490,12492],{"id":12491},"九这份决策记录留了什么","九、这份决策记录留了什么",[11,12494,12495],{},"三条可复用的：",[11,12497,12498,12501],{},[488,12499,12500],{},"替换面要窄，而且要可枚举。"," 「只换服务层」这句话之所以站得住，是因为能列出五项具体的东西。如果列不出来，说明改动面还没摸清。",[11,12503,12504,12507],{},[488,12505,12506],{},"纪律要写成文件，不能靠记。"," 改动登记清单和唯一阀门那句话，是把「最小改动」从愿望变成可执行检查的唯一办法。",[11,12509,12510,12513],{},[488,12511,12512],{},"硬门槛要挡在动手之前。"," 授权归档挡在 fork 引入之前，实施顺序就是照这个排的。",[11,12515,12516,12517,12520],{},"以及一条当时没写、后来才证明重要的：",[488,12518,12519],{},"引入之前要明确「不做什么」","。四条非目标里，「不修改它的视觉设计与交互」这条后来一直没破。",{"title":183,"searchDepth":184,"depth":184,"links":12522},[12523,12524,12525,12526,12527,12528,12529,12530,12531],{"id":12161,"depth":184,"text":12162},{"id":12211,"depth":184,"text":12212},{"id":12295,"depth":184,"text":12296},{"id":12376,"depth":184,"text":12377},{"id":12400,"depth":184,"text":12401},{"id":12423,"depth":184,"text":12424},{"id":12452,"depth":184,"text":12453},{"id":12468,"depth":184,"text":12469},{"id":12491,"depth":184,"text":12492},"2026-07-22",{},"\u002F2026-07-22",{"title":12129,"description":12134},"2026-07-22-皮是它的脏器是我的","引入一个开源的画布工作台：前端全量原样保留，只把服务层换掉。这是一份决策记录——记录了替换范围、必须守的纪律，以及这条路线的前提条件。",[11718,12539,12540,1286,12541,11722],"AGPL","商业授权","适配层","D44h3dlSSIxaZOf5Ibn32rrg16XDXidPihqn55egVvk",{"id":12544,"title":12545,"body":12546,"column":196,"date":12674,"description":12550,"extension":199,"hero_image":200,"meta":12675,"navigation":202,"path":12676,"seo":12677,"series_id":200,"severity":200,"stem":12678,"summary":12679,"tags":12680,"__hash__":12686},"posts\u002F2026-07-20-远程桌面管理器DPAPI与60万次迭代.md","密码，不该由我保管",{"type":8,"value":12547,"toc":12668},[12548,12551,12555,12558,12561,12564,12578,12581,12585,12588,12591,12594,12597,12600,12611,12614,12617,12620,12626,12632,12638,12648,12662,12665],[11,12549,12550],{},"远程服务器资料管理工具需要妥善保存账户凭据。这个工具采用两层加密设计：本机存储用 Windows DPAPI，备份使用基于密码的密钥派生。两层各有边界，理解这些边界对使用决策至关重要。",[26,12552,12554],{"id":12553},"第一层dpapi-的便利与代价","第一层：DPAPI 的便利与代价",[11,12556,12557],{},"本机存储的凭据使用 Windows DPAPI（Data Protection API）加密，由当前 Windows 用户身份保护。这是 Windows 内置的用户级加密机制，密钥由操作系统管理，与登录用户的 SID 和机器的本地安全数据库绑定。不需要用户记一个额外的主密码，启动应用即可直接使用保存的凭据。",[11,12559,12560],{},"DPAPI 的好处显而易见：启动应用即用，无需输入密码，用户体验最优。代价是它的加密密钥锁定在当前用户、当前机器。凭据无法直接迁移到另一台电脑或另一个 Windows 用户——这不是工具的限制，而是 DPAPI 的设计约束。Windows 操作系统就是这样设计的，其他工具也无法绕过。",[11,12562,12563],{},"实际操作中的含义很明确：",[123,12565,12566,12569,12572,12575],{},[126,12567,12568],{},"重装 Windows 前必须先导出备份。原有凭据会因为用户 SID 变化和机器密钥更新而无法解密，即便登录同一账户也不行。",[126,12570,12571],{},"更换电脑前需要先创建备份并妥善保存备份密码，目标电脑上导入时需要重新输入这个备份密码。",[126,12573,12574],{},"在同一电脑上切换 Windows 用户登录，旧用户的凭据对新用户完全不可见，因为加密密钥是用户级的。",[126,12576,12577],{},"多用户共享一台电脑的场景下，凭据不会跨用户暴露。",[11,12579,12580],{},"这些限制会在实际使用中暴露出来——比如忙于工作时重装系统忽略了导出，或者在公用工作电脑上多个人使用。正因为如此，工具强制要求提供备份机制。备份不是可选功能，而是必需的。",[26,12582,12584],{"id":12583},"第二层备份加密的固定迭代设计","第二层：备份加密的固定迭代设计",[11,12586,12587],{},"备份采用 PBKDF2-SHA256（基于密码的密钥派生函数 2，使用 SHA-256 哈希）加密，固定执行 600,000 次迭代。用户创建备份时设置一个密码，导入时输入这个密码。PBKDF2 通过重复应用哈希函数来增加破解难度，迭代次数越多，从密码派生密钥所需的计算时间越长，攻击者进行暴力破解也需要投入成倍的计算资源。",[11,12589,12590],{},"600,000 次迭代的来源是什么？这是一个有意识的设计决定，而不是随意选择。迭代次数越多，暴力破解的成本越高，但加密和解密的耗时也越长。设计者需要在两者之间找到平衡点：够强以抵御现代硬件的破解能力，又不能强到让普通用户的导入操作变得难以忍受。600,000 次这个数字反映的是这个平衡的结果。",[11,12592,12593],{},"为什么固定而非可配置？这是一个纪律问题。可配置听起来更灵活、更给用户掌控权，但在实践中会诱使用户为了更快的备份导入速度而降低迭代次数，从而削弱安全强度。安全不应该由便利让步。人们往往倾向于选择快速方案，尤其当他们没有安全专业知识时。固定的迭代次数消除了这种选择权，保证了所有备份都有相同的防护等级。工具的职责是做出最合理的决定，而不是把这个决定推给用户。",[26,12595,12596],{"id":12596},"性能实测与数据解读",[11,12598,12599],{},"在 Windows 11 构建机上，对 1 MiB 大小的备份数据进行加密与解密的完整往返，预热缓存后的连续五次耗时分别为：108.919 ms、104.079 ms、100.418 ms、94.933 ms、103.470 ms。中位数为 103.470 ms。这意味着从你按下\"导入备份\"到凭据被解密并加载到内存，大约需要 100 毫秒的等待时间。",[11,12601,12602,12603,12606,12607,12610],{},"这组数据收集的目的是",[488,12604,12605],{},"记录性能表现","。它提供了一个具体的参考：用户在 Windows 11 系统上可以预期导入备份的延迟大约是这个量级。这对评估工具的可用性很有用。但它明确",[488,12608,12609],{},"不","作为调整迭代次数的依据。这种表述听起来有些冗余，但它是设计纪律的一部分——必须写下来的目的是防止后续有人看到\"100 毫秒确实有点慢\"就建议降低迭代次数。防止的是这样的推理：因为性能数据显示延迟不够快，所以降低迭代次数。这个逻辑链条在安全工程中是禁止的。",[11,12612,12613],{},"相反，如果实践证明 100 毫秒对用户体验构成问题，正确的做法是要么接受这个成本作为安全性的代价，要么在未来硬件更新换代后自然加速。绝不是削弱密钥派生强度。性能和安全的权衡应该在上层的需求决策中做，而不是在密码学参数中做。",[26,12615,12616],{"id":12616},"工具的明确边界",[11,12618,12619],{},"这个工具的安全设计有明确的保护范围和限制：",[11,12621,12622,12625],{},[488,12623,12624],{},"DPAPI 层的限制","：本机凭据的安全性最终依赖于 Windows 用户密码。如果 Windows 账户被破解，攻击者用该账户登录电脑，DPAPI 解密会自动进行。如果用户以管理员身份运行工具（虽然不需要管理员权限），攻击者获得管理员权限后理论上也可能绕过某些保护。安全链的强度由最弱的一环决定——如果你的 Windows 用户密码很弱，或者电脑物理上被他人访问，DPAPI 的保护就名存实亡。",[11,12627,12628,12631],{},[488,12629,12630],{},"备份密码的强度","：导入备份时用户设置的密码决定了备份的抗暴力破解能力。PBKDF2 提供的防护再强，也无法弥补一个简单密码的缺陷。\"123456\"这样的备份密码，在 600,000 次迭代和现代 GPU 的破解能力面前，可能在几秒到几分钟内被破解。",[11,12633,12634,12637],{},[488,12635,12636],{},"系统策略的约束","：工具运行在 Windows 系统上，不会绕过任何系统级的安全机制。Windows SmartScreen 对未签名程序的警告、远程桌面连接的安全确认对话、Group Policy 的限制——这些工具都无法绕过。安装包为未签名的内部制品，Windows SmartScreen 会在首次运行时显示\"未知发布者\"警告。这不是工具的缺陷，而是系统安全策略的正常行为。",[11,12639,12640,12643,12644,12647],{},[488,12641,12642],{},"加密设计的范围","：这套两层加密设计防的是",[488,12645,12646],{},"离线攻击","——攻击者获得了备份文件或本机的加密数据，在没有用户交互的情况下尝试破解。它防不了的情况：",[123,12649,12650,12653,12656,12659],{},[126,12651,12652],{},"备份密码通过社工或偷看被直接获取",[126,12654,12655],{},"备份文件在网络传输过程中被中间人截获（如果使用不安全的传输方式）",[126,12657,12658],{},"凭据被恶意软件在内存中窃取（工具启动后、密码解密到内存这段时间内）",[126,12660,12661],{},"Windows 账户本身被已经登录电脑的恶意软件控制",[11,12663,12664],{},"对这些风险的防护需要用户在安全习惯和网络安全措施上自行补足——设置强密码、在信任的网络上操作、定期更新系统补丁、使用反恶意软件工具。",[11,12666,12667],{},"使用这套工具前要明确：本机凭据带来了便利，代价是将安全依赖在 Windows 用户身份上；备份凭据提供了迁移能力，代价是密码强度必须由用户自己把关。都不是\"一次设置永久安全\"的方案，都需要持续的安全意识和维护。",{"title":183,"searchDepth":184,"depth":184,"links":12669},[12670,12671,12672,12673],{"id":12553,"depth":184,"text":12554},{"id":12583,"depth":184,"text":12584},{"id":12596,"depth":184,"text":12596},{"id":12616,"depth":184,"text":12616},"2026-07-20",{},"\u002F2026-07-20-dpapi60",{"title":12545,"description":12550},"2026-07-20-远程桌面管理器DPAPI与60万次迭代","两层加密保护远程桌面凭据，本机用 DPAPI 便利性换易用性，备份用 PBKDF2 固定 60 万迭代；为什么迭代次数不可配置，性能数据如何解读。",[12681,12682,12683,12684,12685],"Windows","DPAPI","PBKDF2","凭据存储","加密设计","TzKUoZoFLtAsO_VcB1TOOdWOpwcoDj6AUwEdS6d7pv0",{"id":12688,"title":12689,"body":12690,"column":196,"date":12808,"description":12694,"extension":199,"hero_image":200,"meta":12809,"navigation":202,"path":12810,"seo":12811,"series_id":200,"severity":200,"stem":12812,"summary":12813,"tags":12814,"__hash__":12818},"posts\u002F2026-07-17-把设计文档变成可讲的演示.md","转不成演示的设计文档，本来就没讲清",{"type":8,"value":12691,"toc":12802},[12692,12695,12701,12704,12707,12710,12713,12731,12738,12742,12745,12751,12761,12769,12772,12775,12778,12781,12784,12787,12790,12793,12796,12799],[11,12693,12694],{},"一年积累了 200 多份设计文档，最初想法是直接拿这些文档去讲。结果发现这些东西不适合讲。设计文档是给人写的，演示文稿是给人听的，形式完全不同。",[11,12696,12697,12698,781],{},"解决这个问题的思路不是\"写个通用 PPT 编辑器\"，而是\"从结构化文档一键生成演示\"。本质差异在这里——编辑器要处理用户的任意编辑行为，演示工具只要转换",[488,12699,12700],{},"已有的结构",[26,12702,12703],{"id":12703},"为什么选单向转换而不是编辑器",[11,12705,12706],{},"设计文档有稳定的模板：背景、方案、架构、取舍、参考。这个顺序不是随意的，恰好就是讲一个设计时的叙述顺序。",[11,12708,12709],{},"用户不需要\"先生成再改\"，需要的是\"文档秒变幻灯片\"。一旦你改，就回到编辑器的坑里去了——要支持拖拽、删除、排版，工作量爆炸，而且多数用户不会调，生成好的东西就是定版。",[11,12711,12712],{},"这个判断来自实际数据。我的工具做两个决策：",[243,12714,12715,12725],{},[126,12716,12717,12720,12721,12724],{},[488,12718,12719],{},"产物是自包含 HTML","，不是 Office 文件（",[15,12722,12723],{},".pptx"," 需要可编辑格式，门槛高；HTML 在浏览器里就能放映，自包含意味着内联了所有 CSS、JS、图片）。",[126,12726,12727,12730],{},[488,12728,12729],{},"内容由 LLM 生成，不开放编辑面板","（用一个\"预览挑选\"的两阶段流程，让用户在 3 种风格的封面里选一个，然后生成整份）。",[11,12732,12733,12734,12737],{},"这两个约束听起来很严格，实际上契合了需求的本质：",[488,12735,12736],{},"文档的目的是记录决策，演示的目的是讲述决策","。不需要演示过程中再改决策，改了就回到文档去改。",[26,12739,12741],{"id":12740},"什么样的结构能转什么样的不能","什么样的结构能转、什么样的不能",[11,12743,12744],{},"设计文档要是能一键转成演示，必须满足几个条件。",[11,12746,12747,12750],{},[488,12748,12749],{},"架构图可以直接映射","。我的演示工具支持 AI 生图，也支持用户指定图片 URL。当文档里写了架构设计时，LLM 看到这个描述会生成对应的图，然后内联到演示文稿里。舞台是固定的 1920×1080，所有图片容器有最小尺寸约束（GSAP 时间轴动画要能在任意时间点求值，不能靠动态布局）。",[11,12752,12753,12756,12757,12760],{},[488,12754,12755],{},"关键数据用表格记录最不容易犯错","。表格这种结构容易转化——",[407,12758,12759],{},"待补：具体表格如何转成演示页的实现规则","。如果文档里的数据以段落文字形式写，转成演示时就卡住了，LLM 要从自然语言反推结构。",[11,12762,12763,1244,12766],{},[488,12764,12765],{},"取舍（trade-off）部分",[407,12767,12768],{},"待补：设计文档中的取舍说明如何映射成演示内容的规则",[11,12770,12771],{},"一个更深的观察：文档转不出来好演示，通常不是演示工具的问题，是文档本身没讲清楚。",[11,12773,12774],{},"我见过一类项目文档，有 20 多份模板文件，但只要换个配色其他全一样。这说明什么？说明那些\"模板\"实际上没有结构差异，只有视觉差异。结构不清，自然转不出演示。或者反过来说，如果要证明自己的文档结构是清晰的，试试能不能一键转成演示——转不出来就是信号，说明需要先梳理文档本身。",[26,12776,12777],{"id":12777},"工具的硬约束",[11,12779,12780],{},"演示不同于其他产物，有独特的硬约束。我的工具选择了 1920×1080 的固定舞台，所有动画用 GSAP 时间轴。这些看起来像限制，实际上是为了保证可靠性。",[11,12782,12783],{},"固定舞台尺寸意味着设计者写风格预设时要算好留白和排版，不能寄希望于\"容器自适应就行了\"。GSAP 时间轴的特点是能在任意时间点求值（渲染管线会 seek 到任意帧截图），不能用 CSS animation 这种依赖真实时间流逝的东西。这限制了动效，但换来的是确定性——动效一定会在预期时间点发生，不会因为网络卡而错位。",[11,12785,12786],{},"生成流程分两个阶段，有个细节很实用。第一阶段只生成 3 个风格的封面单页，第二阶段才生成整份演示文稿。这个设计看似多一步，实际上优雅地解决了两个问题。一是给用户选择风格的机会（不是非此即彼的\"生成或不生成\"）；二是掩盖异步生图的等待时间（生图 1-2 分钟，正好被用户在选择封面时吸收了）。",[26,12788,12789],{"id":12789},"从文档能否转演示看结构清晰度",[11,12791,12792],{},"最后回到起点。为什么要做这个工具？",[11,12794,12795],{},"表面原因是 200 多份文档用演讲方式讲会更有力。深层原因是这个过程本身就是对文档质量的检验。",[11,12797,12798],{},"一份设计文档如果结构清晰——背景交代得清，方案对比得充分，架构图画得明确，取舍理由说得透彻——那转成演示就是平移内容，不费劲。转不出来、或者转出来很别扭，就是信号，说明某个环节讲得不够好。",[11,12800,12801],{},"这个反馈机制比任何 review 注释都直白。\"这个表述为什么转不成幻灯片？\"往往能逼出真实的问题——\"哦，因为我其实还没想清楚这个方案为什么比另一个好\"。",{"title":183,"searchDepth":184,"depth":184,"links":12803},[12804,12805,12806,12807],{"id":12703,"depth":184,"text":12703},{"id":12740,"depth":184,"text":12741},{"id":12777,"depth":184,"text":12777},{"id":12789,"depth":184,"text":12789},"2026-07-17",{},"\u002F2026-07-17",{"title":12689,"description":12694},"2026-07-17-把设计文档变成可讲的演示","不做通用PPT编辑器，只做文档→演示的单向转换；关键在识别文档的稳定结构。",[8450,12815,12816,12817,10625],"文档结构","设计工具","HTML","Reh87_TKXZ8nl5BhDhHzVOONAzwRS-Im8LTYrIfeuNk",{"id":12820,"title":12821,"body":12822,"column":1966,"date":13103,"description":13104,"extension":199,"hero_image":200,"meta":13105,"navigation":202,"path":13106,"seo":13107,"series_id":200,"severity":200,"stem":13108,"summary":13109,"tags":13110,"__hash__":13116},"posts\u002F2026-07-14-分销体系的账本设计.md","一笔充值，要拆成几条流水？",{"type":8,"value":12823,"toc":13093},[12824,12831,12834,12838,12841,12844,12851,12862,12869,12873,12876,12882,12885,12888,12891,12902,12905,12913,12920,12924,12927,12938,12941,12952,12959,12963,12966,12972,12975,12986,12989,12993,12996,12999,13002,13013,13016,13024,13027,13038,13041,13044,13049,13056,13061,13072,13075,13078,13081,13087,13090],[11,12825,12826,12827,12830],{},"单笔用户充值，背后是一次复杂的资金拆分：用户充值 100 元，既是平台的收入，也是分销代理的佣金来源，可能还有上级代理的层级提成。这些数字必须同时记录、互相平衡、永不重复。这不是数据流通的问题，是",[488,12828,12829],{},"现金流的问题","——差一分钱就是漏账，重复一次就是挪用。",[11,12832,12833],{},"分销账本设计的核心就四条铁律和一个恒等式。遵循它们，系统能撑到任何规模；跳过其中任何一条，早晚会在对账时翻车。",[26,12835,12837],{"id":12836},"规则一佣金计算基数要先定死","规则一：佣金计算基数要先定死",[11,12839,12840],{},"从什么数字出发算佣金？这个问题比看起来复杂。",[11,12842,12843],{},"通常的选项有三个：订单总金额、用户实付金额、或者订单到账净额。乍看没区别，一旦遇上退款就完全不同。",[11,12845,12846,12847,12850],{},"采用的方案是",[488,12848,12849],{},"用户充值净额","（billing 系统中已确认到账的实付分）。理由很直白：",[243,12852,12853,12856,12859],{},[126,12854,12855],{},"退款处理天然免疫。用户充值后退款，billing 的该用户账户余额已经扣掉，充值净额自动反映了这笔冲销。分成计算只需聚合这个净额乘以比例，不用单独写退款冲正逻辑。",[126,12857,12858],{},"避免应收账款。如果以订单金额算，还没到账时代理已经看得到分成，这在 reporting-only 设计下容易造成认知错位（代理以为钱已经是他的，实际还在支付处理中）。",[126,12860,12861],{},"同源唯一。billing 是平台的权威账本，分成的基数来自这里，对账时只需验证\"代理分成之和 + 平台收入 = billing 总充值\"，一个公式搞定。",[11,12863,12864,12865,12868],{},"反过来说，如果公司后续引入退款主动冲补（而非被动扣减），这个基数设定会变得复杂。但在初期，",[488,12866,12867],{},"基数 = billing 已确认充值"," 是最简洁的切口。",[26,12870,12872],{"id":12871},"规则二结算时点决定了数据流向","规则二：结算时点决定了数据流向",[11,12874,12875],{},"到底是在订单成交时计提佣金，还是账期结束时一次性结算？",[11,12877,12878,12879,781],{},"这里的选择是 ",[488,12880,12881],{},"reporting-only 只读聚合，实时查询，不计提、不累积",[11,12883,12884],{},"具体含义是：代理看到的\"我的分成\"不是一条条流水记录，而是每次查询时现场计算出来的聚合数字。算法是\"我名下所有用户的充值净额总和 × 我的佣金比例\"。没有单独的\"分成计提\"操作，没有一条条的\"分成到账\"记录。",[11,12886,12887],{},"好处和代价是对偶的。",[11,12889,12890],{},"好处：",[123,12892,12893,12896,12899],{},[126,12894,12895],{},"免除计提时点的争议。不用决定在订单成交时、支付完成时、还是 T+1 时计提，因为根本不计提。",[126,12897,12898],{},"天然避免双扣。既然分成不落库不累积，就不存在\"发放一次、又重复发放一次\"的并发风险。同一笔充值无论被查询多少次，贡献的佣金永远相同。",[126,12900,12901],{},"简化对账。代理的分成数字永远等于\"最新充值净额 × 比例\"，无需追溯历史。",[11,12903,12904],{},"代价：",[123,12906,12907,12910],{},[126,12908,12909],{},"代理无法看到\"分成流水\"。有些运营场景下，需要展示\"哪笔订单产生了多少佣金\"这样的明细，reporting-only 做不了（可以通过关联用户的充值明细变通，但那是用户维度的流水，不是分成维度的）。",[126,12911,12912],{},"退款时必须同步。如果用户退了 50 块钱，billing 系统立刻反映这笔扣减，代理的分成下一秒查询就会跌下来。这对代理来说是透明的（分成就是动态的），但运营沟通时需要提前说清楚。",[11,12914,12915,12916,12919],{},"选择 reporting-only 的核心原因是：",[488,12917,12918],{},"初期不出金、无提现","。既然分成只是一个数字展示、不涉及真金白银的打款，那就不用建立复杂的流水账体系。等到未来做提现时，可以在 reporting-only 的基础上加一层\"快照 + 冻结\"机制（即每个提现周期开始时拍一个快照，这个快照才是可提的分成额）。",[26,12921,12923],{"id":12922},"规则三层级上限和循环检测","规则三：层级上限和循环检测",[11,12925,12926],{},"分销是分多少层级？",[11,12928,12929,12930,12933,12934,12937],{},"首期方案是",[488,12931,12932],{},"单层","。用户通过一个特定的渠道码注册（如 ",[15,12935,12936],{},"AB-48210377","），永久绑定到某个代理。代理无法有\"上级代理\"，也就无法有\"上级佣金\"这样的递归结构。",[11,12939,12940],{},"这一约束看起来很强，但在无提现的 reporting-only 下，是合理的。理由是：",[243,12942,12943,12946,12949],{},[126,12944,12945],{},"简化代理运维。总台只需管理一套代理的佣金比例（per-代理），不用维护代理之间的树形关系。",[126,12947,12948],{},"避免环形链。单层天然杜绝了\"A 的上级是 B，B 的上级是 A\"这类配置错误。多层结构下，环形检测本身又是一个故障点。",[126,12950,12951],{},"初期够用。大多数分销场景早期就是\"直销商（代理）→ 用户\"的二元关系，不需要分级。",[11,12953,12954,12955,12958],{},"但要注意，这个约束是",[488,12956,12957],{},"数据模型层的","，不是业务规则层的。如果未来需要升级到多层结构，数据模型需要改（Channel 表可能要加 parentResellerId 等），但已经发出去的单层记录无需回溯改造——它们天然是单层的。",[26,12960,12962],{"id":12961},"规则四幂等键设计防重复计提","规则四：幂等键设计（防重复计提）",[11,12964,12965],{},"同一笔充值可能被多个系统调用、被回调多次。分成必须严格幂等：无论这笔充值被聚合几次，贡献给代理的佣金永远是\"充值额 × 比例\"这一个数字，不能是两倍、三倍。",[11,12967,12968,12969,781],{},"因为采用 reporting-only + 只读聚合的设计，幂等性",[488,12970,12971],{},"自动满足",[11,12973,12974],{},"推理如下：",[243,12976,12977,12980,12983],{},[126,12978,12979],{},"billing 侧的充值流水本身是幂等的。同一个订单号的充值，billing 确保只入账一次（通过订单号的唯一性约束）。",[126,12981,12982],{},"分成聚合是无状态的。每次查询时，服务端都是\"遍历该代理名下的用户 → 调用 billing 的 summaryByUsers 接口 → 汇总充值净额 → 乘以比例\"。这个聚合过程不依赖任何之前的计提记录。",[126,12984,12985],{},"结论：即使 billing 错误地返回了同一笔充值两次，聚合结果也只会包含一次（因为底层是\"用户ID → 总充值净额\"的映射，不是\"订单 → 充值\"的流水列表）。",[11,12987,12988],{},"相反，如果设计成\"订单成交时计提一条分成记录\"的模式，就需要在分成记录上加幂等键（如 orderId），确保同一订单的分成只计提一次。这个幂等键检查本身就是一个额外的故障点。",[26,12990,12992],{"id":12991},"一个恒等式对账的唯一标准","一个恒等式：对账的唯一标准",[11,12994,12995],{},"前面四条规则规范了流程，但最后的验证还是要靠一个简单的数学公式。",[11,12997,12998],{},"$$\n\\sum_^{n} \\text{Commission}_i + \\text{PlatformNetIncome} = \\text{TotalTopup}\n$$",[11,13000,13001],{},"其中：",[123,13003,13004,13007,13010],{},[126,13005,13006],{},"$\\text{Commission}_i$ 是第 $i$ 个代理的分成（所有名下用户的充值净额 × 佣金比例）",[126,13008,13009],{},"$\\text{PlatformNetIncome}$ 是平台的净收入（总充值 - 所有代理的分成总额）",[126,13011,13012],{},"$\\text{TotalTopup}$ 是 billing 系统的总充值额（所有用户的到账充值之和）",[11,13014,13015],{},"这个等式是唯一可靠的对账标尺。任何时刻，只要这个等式不成立，就说明某个环节出了问题：",[123,13017,13018,13021],{},[126,13019,13020],{},"等式左侧大于右侧 → 某个代理的分成算重了，或者平台收入算多了",[126,13022,13023],{},"等式左侧小于右侧 → 某个代理的分成算少了，或者某笔收入漏了",[11,13025,13026],{},"而且这个等式不需要建立任何新表。完全可以通过查询三个现成的数据源验证：",[243,13028,13029,13032,13035],{},[126,13030,13031],{},"billing 的 SummaryByUsers（每个用户的充值净额）",[126,13033,13034],{},"代理表的 commissionRate（每个代理的佣金比例）",[126,13036,13037],{},"一行 SQL 的聚合（sum 和乘法）",[26,13039,13040],{"id":13040},"技术实现的两个关键点",[11,13042,13043],{},"光有规则还不够，实现层要支撑这些规则。素材中的设计有两个细节值得指出。",[11,13045,13046,781],{},[488,13047,13048],{},"其一，billing 要暴露 summaryByUsers 接口",[11,13050,13051,13052,13055],{},"分成聚合依赖\"按用户ID汇总充值净额和消耗\"这个操作。如果 billing 侧没有这个批量接口，代理端就得自己拼接多个单用户查询，性能和一致性都会打折扣。实现计划里新增的 ",[15,13053,13054],{},"POST \u002Fanalytics\u002Fsummary-by-users"," 就是为了这个。",[11,13057,13058,781],{},[488,13059,13060],{},"其二，reseller 侧的所有查询要服务端注入 channelId",[11,13062,13063,13064,13067,13068,13071],{},"代理登录后调用 ",[15,13065,13066],{},"\u002Fapi\u002Freseller\u002Fsummary"," 或 ",[15,13069,13070],{},"\u002Fapi\u002Freseller\u002Fusers","，后端不能信任请求体里的 channelId 参数。而是从 token 解析出代理身份 → 查表得出该代理对应的 channelId → 强制注入到查询条件里。这是防代理 A 越权查看代理 B 渠道的唯一有效方式。",[11,13073,13074],{},"代码里体现为：从 Admin token 反查 Channel 表的 resellerId 字段，确保该 Admin 只能看自己那行 Channel。",[26,13076,13077],{"id":13077},"与既有分账设计的呼应",[11,13079,13080],{},"这套账本设计不是凭空造出来的。平台此前在另一个系统里实现过五类角色的分账体系（Platform、Agency、Escort、Distributor、Merchant），每个角色各维护一张 Ledger 流水表。那个设计的核心思想是\"分账入表\"——即每一笔影响各角色收入的交易，都要对应地在各自的 Ledger 表里落一条记录。",[11,13082,13083,13084,781],{},"当前这套分销设计采用了相反的思路：不建 Ledger 表，而是在查询时通过聚合来推导分成。这的背景是 reporting-only 属性（不出金、只展示），使得可以接受动态聚合的方案。但底层的思想是一致的——",[488,13085,13086],{},"通过对账恒等式来保证多角色之间的收支平衡",[26,13088,13089],{"id":13089},"结尾",[11,13091,13092],{},"账本设计的目标不是漂亮的表格或丰富的报表，而是一个简单的数学等式永远成立。一旦等式破裂，对账人员立刻能定位是哪个环节失守。这比事后扑火要高效得多。",{"title":183,"searchDepth":184,"depth":184,"links":13094},[13095,13096,13097,13098,13099,13100,13101,13102],{"id":12836,"depth":184,"text":12837},{"id":12871,"depth":184,"text":12872},{"id":12922,"depth":184,"text":12923},{"id":12961,"depth":184,"text":12962},{"id":12991,"depth":184,"text":12992},{"id":13040,"depth":184,"text":13040},{"id":13077,"depth":184,"text":13077},{"id":13089,"depth":184,"text":13089},"2026-07-14","单笔用户充值，背后是一次复杂的资金拆分：用户充值 100 元，既是平台的收入，也是分销代理的佣金来源，可能还有上级代理的层级提成。这些数字必须同时记录、互相平衡、永不重复。这不是数据流通的问题，是现金流的问题——差一分钱就是漏账，重复一次就是挪用。",{},"\u002F2026-07-14",{"title":12821,"description":13104},"2026-07-14-分销体系的账本设计","分销系统最容易在财务对账出错。单笔充值需同时产生平台收入、代理佣金等多条记录，必须在同一事务内闭合。四条规则与一个恒等式是账本设计的全部。",[13111,13112,13113,13114,13115],"分销","账本设计","对账","幂等性","佣金结算","Q3NC9Z8JBJ7e2MLQFH10q68Igpbp2fMF5gT-do6Rxaw",{"id":13118,"title":13119,"body":13120,"column":355,"date":13553,"description":13554,"extension":199,"hero_image":200,"meta":13555,"navigation":202,"path":13556,"seo":13557,"series_id":13558,"severity":200,"stem":13559,"summary":13560,"tags":13561,"__hash__":13567},"posts\u002F2026-07-11-FA-017-长任务异步化.md","三个超时机制，一起把长任务掐死了",{"type":8,"value":13121,"toc":13545},[13122,13132,13135,13138,13144,13150,13156,13163,13169,13173,13176,13179,13185,13188,13199,13205,13209,13212,13219,13225,13228,13242,13246,13249,13260,13306,13321,13328,13332,13335,13338,13356,13359,13367,13370,13385,13388,13391,13536,13539,13542],[11,13123,13124,13125,13128,13129,781],{},"数字人口播的「分析」功能从视频提取口播文稿、分镜脚本、结构卖点，需要下载视频、压缩、调 Gemini 视觉 API，耗时从几十秒到几分钟。最初同步实现：前端调 ",[15,13126,13127],{},"POST \u002Fapi\u002Fworkflow\u002Fdub\u002Fanalyze-parsed","，后端直接跑完再返回。这个方案暴露的问题不是单点，而是",[488,13130,13131],{},"三个独立的缺陷叠加",[11,13133,13134],{},"网关超时、CDN 空闲切断、客户端重试——这三个没有一个是\"bug\"，每个都是合理的系统设计。但组合在一起就是连锁灾难：客户端看不到结果（超时或连接断），认为失败了重试，后端却已经扣过费了；或者分析其实已跑完，但因为响应发不出去，重试又跑了一遍。退款时无法幂等判断，某些用户最终被多扣了好几倍。",[11,13136,13137],{},"具体来说，三个问题各自是什么：",[11,13139,13140,13143],{},[488,13141,13142],{},"第一个问题是网关超时","。云平台的入口网关（API Gateway）有一个固定的读超时，通常设为几十秒。当后端分析耗时超过这个阈值，网关主动断连接，客户端收到 504 或连接重置，但后端的分析进程并不知道客户端已经走了，继续跑完整个流程。如果分析结果的退款逻辑放在 try-catch 的 finally 里，此时 HTTP 响应已发不出去，客户就看到\"分析失败\"，但款已经扣了。",[11,13145,13146,13149],{},[488,13147,13148],{},"第二个问题是 CDN 空闲切断","。不少 CDN 和负载均衡层有这样的策略：如果一条 HTTP 连接在某个时间段内没有数据往返，就认为它已死而主动关闭。这与 FA-013 那次踩到的问题一样——长连接因为没有心跳数据而被 CDN 中间件切断，导致请求被突然中断。这个机制在分析请求上也会造成麻烦：如果分析用时较长，连接就可能被切，响应发不出去。",[11,13151,13152,13155],{},[488,13153,13154],{},"第三个问题是客户端重试导致重复扣费","。当客户端没有收到响应（网关超时或 CDN 切断），一般会重试这个请求。问题在于这次重试是一条完全独立的请求，后端会把它当作新任务处理，再次扣费。而且如果第一次的分析实际上已经跑完了，就会出现\"分析跑了两遍、扣费扣了两遍、用户只看到了一个结果\"的情况。如果第一次分析中途被 CDN 切了，第二次重试重新开始，那扣费可能是三倍。而退款的时候，因为业务流程上没有幂等设计，很难追溯哪个 operationId 应该被退，哪个不应该。",[11,13157,13158,13159,13162],{},"这三个问题是",[488,13160,13161],{},"叠加的","，不是独立的。它们共同指向一个根本的架构问题。",[11,13164,13165,13166,781],{},"根本原因一个：",[488,13167,13168],{},"不该让耗时任务挂在 HTTP 连接上",[26,13170,13172],{"id":13171},"异步任务是必需的不是可选的","异步任务是必需的，不是可选的",[11,13174,13175],{},"解决方案很简单：把分析改成异步任务模式。",[11,13177,13178],{},"前端提交分析请求直接返回任务 ID（HTTP 耗时极短：校验参数、建数据库记录、预扣费）。后端后台 worker 跑分析重逻辑，完成后持久化结果。前端轮询查询状态，得到结果后停止。",[399,13180,13183],{"className":13181,"code":13182,"language":1327},[1325],"客户端              后端\n  |                 |\n  +--提交---------->|\n  |             创建任务，返回 ID\n  |\u003C--返回 ID-----+\n  |                 |（后台运行分析）\n  |                 |\n  +--轮询--------->|\n  |             查询状态\n  |\u003C--返回状态---+\n  |                 |\n  |（重复轮询）\n  +--轮询--------->|\n  |             查询状态\n  |\u003C--返回结果---+\n  |                 |\n",[15,13184,13182],{"__ignoreMap":183},[11,13186,13187],{},"好处显而易见：",[123,13189,13190,13193,13196],{},[126,13191,13192],{},"单次 HTTP 请求的耗时从分钟级降到秒级，不会触发网关或 CDN 的超时。",[126,13194,13195],{},"后端分析的运行生命周期与 HTTP 连接完全解耦。即使连接在中途被切，已经启动的分析会继续在后台跑，结果最终还是会被保存。",[126,13197,13198],{},"客户端重试不会产生新的分析任务。只要实现幂等判重，同一个请求在短时间内重复提交也只会产生一个任务。",[11,13200,13201,13202,781],{},"但异步化不是万灵药，它把问题转移到了三个新的维度上：",[488,13203,13204],{},"幂等、持久化、失败语义",[26,13206,13208],{"id":13207},"幂等同一请求只产生一个任务","幂等：同一请求只产生一个任务",[11,13210,13211],{},"假设客户端因为网络抖动，在短时间内连续发了两次\"开始分析\"请求，后端收到两条独立的 HTTP 请求。如果没有幂等设计，就会创建两个任务、扣两次费。",[11,13213,13214,13215,13218],{},"幂等的实现最直接的办法是在",[488,13216,13217],{},"提交阶段就判重","：为这个用户、这个资源设置一个\"只有一个进行中的分析任务\"的约束。素材中的设计是这样做的：",[399,13220,13223],{"className":13221,"code":13222,"language":1327},[1325],"if 该用户已有 status=running && kind=analyze_parsed 的任务:\n    return 409 Conflict（分析已在进行中）\n",[15,13224,13222],{"__ignoreMap":183},[11,13226,13227],{},"这样双击提交同一个视频，第一次返回 201 + taskId，第二次返回 409，客户端看到 409 就知道不要再建新任务，拿之前返回的 ID 去轮询。",[11,13229,13230,13231,13234,13235,13237,13238,13241],{},"另一个幂等层次是",[488,13232,13233],{},"计费操作的幂等","。预扣费的时候用一个全局唯一的 ",[15,13236,4073],{},"（比如 ",[15,13239,13240],{},"dub-analyze:{uuid}","），这个 ID 关联了这次预扣。如果扣费 API 被重复调用，因为 operationId 重复，系统只会扣一次。退款时也用同一个 operationId，保证退款与预扣能正确配对。",[26,13243,13245],{"id":13244},"持久化进程重启后任务不丢","持久化：进程重启后任务不丢",[11,13247,13248],{},"后台分析 worker 可能因为发版、机器重启、容器被杀等原因在中途退出。如果任务的状态只保存在内存里，进程一死任务就丢了。",[11,13250,13251,13252,13255,13256,13259],{},"持久化的关键是",[488,13253,13254],{},"任务状态表","。素材中复用了既有的 ",[15,13257,13258],{},"SkyhumanTask"," 表，包含字段：",[123,13261,13262,13268,13273,13278,13284,13290,13295,13300],{},[126,13263,13264,13267],{},[15,13265,13266],{},"id",": 任务 ID",[126,13269,13270,13272],{},[15,13271,3655],{},": 进度（running \u002F completed \u002F failed）",[126,13274,13275,13277],{},[15,13276,4073],{},": 幂等 ID",[126,13279,13280,13283],{},[15,13281,13282],{},"chargedPoints",": 预扣费用",[126,13285,13286,13289],{},[15,13287,13288],{},"resultPayload",": 分析结果的 JSON",[126,13291,13292,13294],{},[15,13293,5570],{},": 失败原因",[126,13296,13297,13299],{},[15,13298,8292],{},": 最后更新时间戳",[126,13301,13302,13305],{},[15,13303,13304],{},"sourceObjectKey",": 源视频对象路径（用于幂等判重和重试）",[11,13307,13308,13309,13312,13313,13316,13317,13320],{},"任务创建时插一条 ",[15,13310,13311],{},"status=running"," 的记录。后台 worker 定期通过 heartbeat（每 30 秒做一次空 update，仅触碰 updatedAt）来证明自己还活着。分析完成时更新为 ",[15,13314,13315],{},"status=completed, resultPayload=...","；失败时更新为 ",[15,13318,13319],{},"status=failed, error=..."," 并触发退款。",[11,13322,13323,13324,13327],{},"这样即使 worker 进程突然死了，任务记录还在数据库里。新启起的 worker 可以扫描表里的 ",[15,13325,13326],{},"status=running && updatedAt 过期"," 的任务，判断它们已经没有 worker 在跑了，执行清理逻辑。",[26,13329,13331],{"id":13330},"失败语义区分任务失败和查询失败","失败语义：区分\"任务失败\"和\"查询失败\"",[11,13333,13334],{},"异步设计引入了一个新的错误维度：客户端查询任务状态时得到的 404，可能是\"这个任务 ID 不存在\"（用户输错了），也可能是\"任务 ID 之前存在但已被清理\"（进程清理了过期数据）。这两种情况的含义很不一样。",[11,13336,13337],{},"更重要的是，前端轮询收到的错误需要区分是否应该重试：",[123,13339,13340,13350],{},[126,13341,13342,13345,13346,13349],{},[488,13343,13344],{},"任务本身失败","（",[15,13347,13348],{},"status=failed, error=\"Gemini API 返回错误\"","）：这种情况不该重试，因为重试也会失败。应该直接向用户展示失败信息，并根据 operationId 触发退款。",[126,13351,13352,13355],{},[488,13353,13354],{},"查询接口失败","（500、网络超时）：这种情况应该重试查询，因为任务本身可能还在继续跑。",[11,13357,13358],{},"素材中的设计把这两类区分得很清楚：",[123,13360,13361,13364],{},[126,13362,13363],{},"任务最终的状态（completed 或 failed）持久化到数据库，客户端查询会拿到明确的业务语义。",[126,13365,13366],{},"查询接口的 HTTP 错误（5xx）是技术层故障，客户端应该重试。",[11,13368,13369],{},"同时，后端的 reaper 机制（定期扫表清理超期任务）也遵循这个语义：",[123,13371,13372,13378],{},[126,13373,13374,13375,13377],{},"如果一个 ",[15,13376,13311],{}," 的任务超过 90 秒没有 heartbeat 更新，说明 worker 已死，reaper 会强制置为 failed 并退款。",[126,13379,13380,13381,13384],{},"但这个强制置为 failed 不应该抛错，因为此时可能有其他进程也想更新这个任务。更新语句应该带条件：",[15,13382,13383],{},"UPDATE SkyhumanTask SET status=failed WHERE id=? AND status=running","，这样如果 reaper 和其他 worker 同时操作，只有一方会成功。",[26,13386,13387],{"id":13387},"具体实现模式",[11,13389,13390],{},"数字人口播的分析异步化采用了这样的模式：",[243,13392,13393,13437,13483,13506],{},[126,13394,13395,13398,13399],{},[488,13396,13397],{},"提交阶段","（同步）：",[123,13400,13401,13404,13407,13413,13419,13426,13432],{},[126,13402,13403],{},"校验用户和资源",[126,13405,13406],{},"判重：是否已有进行中的任务（409）",[126,13408,13409,13412],{},[15,13410,13411],{},"getObject"," 拉视频，检测时长（若≤0 返 400 不扣费）",[126,13414,13415,13418],{},[15,13416,13417],{},"chargeResource"," 预扣费用，获得 operationId",[126,13420,13421,13422,13425],{},"创建 ",[15,13423,13424],{},"SkyhumanTask{ status:running, operationId, sourceObjectKey }"," 记录",[126,13427,13428,13431],{},[15,13429,13430],{},"scheduleTask"," 丢到后台队列",[126,13433,7236,13434],{},[15,13435,13436],{},"{ taskId }",[126,13438,13439,1244,13442],{},[488,13440,13441],{},"后台运行阶段",[123,13443,13444,13447,13450,13457,13463,13473,13480],{},[126,13445,13446],{},"Worker 取出任务记录",[126,13448,13449],{},"开启 heartbeat 定时器（每 30s update 一次 updatedAt）",[126,13451,13452,13453,13456],{},"执行 ",[15,13454,13455],{},"analyzeDubVisionOnly","（下载→压缩→调用 Gemini→解析）",[126,13458,13459,13460],{},"成功：",[15,13461,13462],{},"update( status:completed, resultPayload )",[126,13464,13465,13466,13469,13470],{},"失败：",[15,13467,13468],{},"update( status:failed, error )"," + ",[15,13471,13472],{},"refundResource(operationId)",[126,13474,13475,13476,13479],{},"所有 update 都带条件 ",[15,13477,13478],{},"WHERE id=? AND status=running","（防被 reaper 覆盖）",[126,13481,13482],{},"Finally 清理 heartbeat 定时器",[126,13484,13485,13488,13489],{},[488,13486,13487],{},"查询阶段","（前端轮询）：",[123,13490,13491,13500,13503],{},[126,13492,13493,13496,13497],{},[15,13494,13495],{},"GET \u002Fapi\u002Fworkflow\u002Fdub\u002Ftasks\u002F:id"," 返回 ",[15,13498,13499],{},"{ status, resultPayload, error }",[126,13501,13502],{},"若 status=completed 或 failed，停止轮询",[126,13504,13505],{},"若查询接口返回 5xx，则后续重试",[126,13507,13508,13511,13512],{},[488,13509,13510],{},"清理阶段","（reaper）：",[123,13513,13514,13517,13524,13527,13533],{},[126,13515,13516],{},"后台定期扫表",[126,13518,13519,13520,13523],{},"对于 ",[15,13521,13522],{},"status=running && updatedAt > 90s"," 的任务，判定 worker 已死",[126,13525,13526],{},"若 providerTaskId（这里没有）存在，调用上游的 finalize API",[126,13528,13529,13530,13532],{},"若不存在，直接 ",[15,13531,13472],{}," + 置 failed",[126,13534,13535],{},"本设计无 providerTaskId，所以 reaper 自动走退款分支，无需改动 reaper 代码",[11,13537,13538],{},"这个模式的关键是三道防线：提交时判重（409）、后台心跳（定期 touch）、reaper 兜底（自动退款）。任何一个环节出问题，都有后续环节来补救。",[26,13540,13541],{"id":13541},"防线缺一不可",[11,13543,13544],{},"长耗时任务不能挂在 HTTP 连接上不只是超时问题，而是连接脆弱性与重复处理的共谋。异步化表面是\"返回 ID、后台跑\"这一步，真正的难点在三个维度的同时保证：幂等（不重复扣费）、持久化（不丢数据）、失败语义（不破坏重试逻辑）。缺一就会在某个场景暴露。",{"title":183,"searchDepth":184,"depth":184,"links":13546},[13547,13548,13549,13550,13551,13552],{"id":13171,"depth":184,"text":13172},{"id":13207,"depth":184,"text":13208},{"id":13244,"depth":184,"text":13245},{"id":13330,"depth":184,"text":13331},{"id":13387,"depth":184,"text":13387},{"id":13541,"depth":184,"text":13541},"2026-07-11","数字人口播的「分析」功能从视频提取口播文稿、分镜脚本、结构卖点，需要下载视频、压缩、调 Gemini 视觉 API，耗时从几十秒到几分钟。最初同步实现：前端调 POST \u002Fapi\u002Fworkflow\u002Fdub\u002Fanalyze-parsed，后端直接跑完再返回。这个方案暴露的问题不是单点，而是三个独立的缺陷叠加。",{},"\u002F2026-07-11-fa-017",{"title":13119,"description":13554},"FA-017","2026-07-11-FA-017-长任务异步化","长耗时分析请求（几十秒到分钟级）用同步 HTTP 导致网关超时、CDN切断、客户端重试重复扣费。改异步任务需同时解决幂等、持久化、失败语义。",[13562,13563,13564,1288,13565,13566],"异步任务","HTTP超时","CDN","任务队列","微服务","ARFDvBMGX3tw-E3Rj_gw1RYEjrykplO6u1dxtdS7Uto",{"id":13569,"title":13570,"body":13571,"column":2727,"date":14240,"description":13575,"extension":199,"hero_image":200,"meta":14241,"navigation":202,"path":14242,"seo":14243,"series_id":200,"severity":200,"stem":14244,"summary":14245,"tags":14246,"__hash__":14252},"posts\u002F2026-07-08-视频包装与数字人配音.md","配音时长不可控——那就让画面跟着它走",{"type":8,"value":13572,"toc":14231},[13573,13576,13580,13586,13589,13592,13625,13628,13642,13645,13648,13655,13813,13826,13829,13840,13843,13846,13853,13861,13903,13909,13912,13923,13929,13938,13941,13952,13955,13958,13964,13999,14005,14037,14043,14060,14063,14069,14105,14111,14130,14135,14164,14167,14170,14173,14176,14190,14193,14196,14199,14218,14221,14228],[11,13574,13575],{},"数字人口播的完整链路已能生成对口型视频原片，但从音频出发反向约束画面节奏、再分层合成包装成可发布的成片，这一环涉及三个工程难点，都不是生成问题，而是一致性与路径确定性问题。",[26,13577,13579],{"id":13578},"tts-时长驱动的反向工作流","TTS 时长驱动的反向工作流",[11,13581,13582,13583,781],{},"MiMo TTS 客户端同步调用返回 base64 音频与实际音长（单位秒），这个时长是后续所有操作的源头。关键约束在于",[488,13584,13585],{},"时长由上游决定，不能人为指定",[11,13587,13588],{},"拿一个具体例子：用户输入 60 字的口播文案，送给 MiMo preset 模式（冰糖音色、wav 格式）。MiMo 返回的不是\"这段话应该播 30 秒\"，而是\"我合成出来的音频实际是 28.5 秒\"。TTS 生成的时长由语速、音色、模型版本等因素共同决定，变量太多了。",[11,13590,13591],{},"在这个约束下，整个包装流程必须反向适配：",[243,13593,13594,13604,13616],{},[126,13595,13596,13599,13600,13603],{},[488,13597,13598],{},"TTS 合成音频"," → 得到 ",[15,13601,13602],{},"audioDurationSec","（来自 ffprobe 或 MiMo 返回）",[126,13605,13606,13609,13610,13612,13613,13615],{},[488,13607,13608],{},"飞天对口型"," → 输入 ",[15,13611,13602],{},"，输出\"这个长度的人物口播视频\"（飞天返回 ",[15,13614,9838],{},"）",[126,13617,13618,13621,13622,13624],{},[488,13619,13620],{},"HyperFrames 包装"," → 模板中的时间线也以这个 ",[15,13623,9838],{}," 为基准构建字幕、转场、片头片尾的时间点",[11,13626,13627],{},"如果反过来做——先定好画面框架是 60 秒，再想办法让音频往里塞——就陷入了\"剪音频、变速播放、或者音视不同步\"的泥潭。",[11,13629,13630,13631,13634,13635,13638,13639,13641],{},"实现上，MiMo 音频落地后先上传我方 S3，用 ffprobe 测长作为权威值（",[15,13632,13633],{},"probeVideoDurationSec"," 对音频也适用），这个测值再被传给飞天 ",[15,13636,13637],{},"create_by_audio"," 接口。飞天不是按输入时长生成固定时长的视频，而是真正根据音轨长度对口型——返回的 ",[15,13640,9838],{}," 几乎就等于输入的音频时长（考虑 AAC 编码器的 priming delay，实测偏差 0.64 帧即 21.33ms，可忽略）。",[26,13643,13644],{"id":13644},"数字人作为独立图层而非重新生成",[11,13646,13647],{},"一个常见的工程误区：既然最终成片是\"数字人 + 包装\"，能不能让 HyperFrames 直接去生成数字人？不能。飞天数字人是外部供应商接口，响应慢（异步任务，等待 2-5 分钟常态），接口也不开放生成能力给外部工具（只提供对口型 API）。再者，用户可能想用同一份配音试多个数字人形象、或试多个包装模板，重新生成整条视频的成本太高。",[11,13649,13650,13651,13654],{},"设计决策是把飞天原片（已完成对口型的 MP4 文件）当作 HyperFrames composition 里的一个 ",[15,13652,13653],{},"\u003Cvideo>"," 图层。模板里声明这样的结构：",[399,13656,13660],{"className":13657,"code":13658,"language":13659,"meta":183,"style":183},"language-html shiki shiki-themes github-light github-dark","\u003Cdiv data-track-index=\"0\">\n  \u003Cvideo data-start=\"0s\" data-duration=\"$MAIN_VIDEO_DURATION\" \n          data-has-audio=\"true\" \n          src=\"$MAIN_VIDEO_URL\">\n  \u003C\u002Fvideo>\n\u003C\u002Fdiv>\n\n\u003Cdiv data-track-index=\"1\">\n  \u003C!-- 字幕、花字、角标等包装层 -->\n\u003C\u002Fdiv>\n\n\u003Cdiv data-track-index=\"2\">\n  \u003C!-- 片头片尾、转场 -->\n\u003C\u002Fdiv>\n","html",[15,13661,13662,13680,13707,13719,13731,13740,13749,13753,13768,13773,13781,13785,13800,13805],{"__ignoreMap":183},[407,13663,13664,13666,13669,13672,13674,13677],{"class":409,"line":410},[407,13665,1073],{"class":413},[407,13667,13668],{"class":8622},"div",[407,13670,13671],{"class":554}," data-track-index",[407,13673,418],{"class":413},[407,13675,13676],{"class":429},"\"0\"",[407,13678,13679],{"class":413},">\n",[407,13681,13682,13685,13688,13691,13693,13696,13699,13701,13704],{"class":409,"line":184},[407,13683,13684],{"class":413},"  \u003C",[407,13686,13687],{"class":8622},"video",[407,13689,13690],{"class":554}," data-start",[407,13692,418],{"class":413},[407,13694,13695],{"class":429},"\"0s\"",[407,13697,13698],{"class":554}," data-duration",[407,13700,418],{"class":413},[407,13702,13703],{"class":429},"\"$MAIN_VIDEO_DURATION\"",[407,13705,13706],{"class":413}," \n",[407,13708,13709,13712,13714,13717],{"class":409,"line":189},[407,13710,13711],{"class":554},"          data-has-audio",[407,13713,418],{"class":413},[407,13715,13716],{"class":429},"\"true\"",[407,13718,13706],{"class":413},[407,13720,13721,13724,13726,13729],{"class":409,"line":452},[407,13722,13723],{"class":554},"          src",[407,13725,418],{"class":413},[407,13727,13728],{"class":429},"\"$MAIN_VIDEO_URL\"",[407,13730,13679],{"class":413},[407,13732,13733,13736,13738],{"class":409,"line":458},[407,13734,13735],{"class":413},"  \u003C\u002F",[407,13737,13687],{"class":8622},[407,13739,13679],{"class":413},[407,13741,13742,13745,13747],{"class":409,"line":464},[407,13743,13744],{"class":413},"\u003C\u002F",[407,13746,13668],{"class":8622},[407,13748,13679],{"class":413},[407,13750,13751],{"class":409,"line":470},[407,13752,1827],{"emptyLinePlaceholder":202},[407,13754,13755,13757,13759,13761,13763,13766],{"class":409,"line":480},[407,13756,1073],{"class":413},[407,13758,13668],{"class":8622},[407,13760,13671],{"class":554},[407,13762,418],{"class":413},[407,13764,13765],{"class":429},"\"1\"",[407,13767,13679],{"class":413},[407,13769,13770],{"class":409,"line":1477},[407,13771,13772],{"class":528},"  \u003C!-- 字幕、花字、角标等包装层 -->\n",[407,13774,13775,13777,13779],{"class":409,"line":1483},[407,13776,13744],{"class":413},[407,13778,13668],{"class":8622},[407,13780,13679],{"class":413},[407,13782,13783],{"class":409,"line":2139},[407,13784,1827],{"emptyLinePlaceholder":202},[407,13786,13787,13789,13791,13793,13795,13798],{"class":409,"line":2180},[407,13788,1073],{"class":413},[407,13790,13668],{"class":8622},[407,13792,13671],{"class":554},[407,13794,418],{"class":413},[407,13796,13797],{"class":429},"\"2\"",[407,13799,13679],{"class":413},[407,13801,13802],{"class":409,"line":7546},[407,13803,13804],{"class":528},"  \u003C!-- 片头片尾、转场 -->\n",[407,13806,13807,13809,13811],{"class":409,"line":7564},[407,13808,13744],{"class":413},[407,13810,13668],{"class":8622},[407,13812,13679],{"class":413},[11,13814,13815,13818,13819,13821,13822,13825],{},[15,13816,13817],{},"data-has-audio=\"true\""," 这一行是命门——没有它，渲染器会给 ",[15,13820,13653],{}," 默认加 ",[15,13823,13824],{},"muted","，成片就没声音。",[11,13827,13828],{},"这样做的好处显而易见：",[123,13830,13831,13834,13837],{},[126,13832,13833],{},"数字人原片一旦生成就不动，支持单独重做配音（只需重新调用 TTS 和飞天，不影响包装渲染）",[126,13835,13836],{},"同一个配音可套多个模板，不需要重新对口型",[126,13838,13839],{},"包装层的 HTML\u002FCSS 改动不会触发数字人重生成，迭代快",[11,13841,13842],{},"缺点是需要镜像内内置 Chromium 和 FFmpeg（官方渲染镜像实测 3.72GB），与 API 镜像分离部署。但这换来的是确定性输出和可控的环境——生产机、开发机、CI 跑同一个 composition，出片应该帧级一致（除了字体、系统库这类版本差异导致的微调，都锁版本了）。",[26,13844,13845],{"id":13845},"成片路径的幂等性与状态机",[11,13847,13848,13849,13852],{},"用户选定模板、确认方案后，系统提交一个 ",[15,13850,13851],{},"VideoRenderJob","：输入模板 ID、原片 key、字幕 cues、音频 URL 等，输出成片 objectKey。同一份输入如果重试或重新提交，必须得到同样的输出（或者快速失败）。",[11,13854,13855,13856,13067,13859,1244],{},"状态流转是 ",[15,13857,13858],{},"queued → preparing → rendering → uploading → completed",[15,13860,4823],{},[123,13862,13863,13873,13879,13892,13898],{},[126,13864,13865,13868,13869,13872],{},[488,13866,13867],{},"queued","：任务入队，等待 worker 消费。这一步是防并发上限的信号量检查——飞天有并发限制（错误码 1001），Redis 信号量 ",[15,13870,13871],{},"yunclaude:dub:sky:sem"," 兜底（触顶不直接失败，改为入队等待）。",[126,13874,13875,13878],{},[488,13876,13877],{},"preparing","：下载原片、组装模板（注入数据、渲染占位符）。这一步的幂等性来自 objectKey 的内容寻址——同一个原片 key、同一个模板版本，组装出的 composition.html 比特级相同。",[126,13880,13881,13884,13885,56,13888,13891],{},[488,13882,13883],{},"rendering","：Chromium 逐帧捕获、FFmpeg 合成。这是确定性的关键——环境锁定（Node 24、Chromium 版本、FFmpeg 版本、字体版本都在镜像里写死），同一个 composition 渲染多次输出帧级一致。M1 POC 中用 seek 点验证：",[15,13886,13887],{},"t=4.5s→第 135 帧",[15,13889,13890],{},"t=30.0s→第 900 帧","（读原片内置计数器，nb_frames=1800），长时间点零漂移。",[126,13893,13894,13897],{},[488,13895,13896],{},"uploading","：上传 OSS，记录 objectKey。返回给前端时用现签（每次读时重新签，不存短时 URL）。",[126,13899,13900,13902],{},[488,13901,4823],{},"：任何一步异常，立即 rollback。计费侧已扣的视频点全额退款（resource operationId 幂等）。",[11,13904,13905,13906,13908],{},"失败的兜底是 reaper（BullMQ 的死信队列处理），轮询 ",[15,13907,13311],{}," 超期（>10 分钟）的任务，标记为失败并补退款。",[26,13910,13911],{"id":13911},"音画同步与字幕对齐",[11,13913,13914,13915,13918,13919,13922],{},"成片里字幕何时出现、何时消失，这些时间点由分析步生成的 ",[15,13916,13917],{},"subtitleCues"," 定义（格式 WebVTT）。一个 cue 的结构是 ",[15,13920,13921],{},"start → end"," + 文本，例如：",[399,13924,13927],{"className":13925,"code":13926,"language":1327},[1325],"00:05.000 --> 00:08.500\n这是一段口播文案\n",[15,13928,13926],{"__ignoreMap":183},[11,13930,13931,13932,13934,13935,13937],{},"HyperFrames 的字幕图层根据这些 cues 生成动画：start 时刻淡入，end 时刻淡出。整个成片的时间线参考都来自主视频（",[15,13933,13653],{}," 元素），而主视频的时长就是飞天返回的 ",[15,13936,9838],{},"——由音频长度决定。",[11,13939,13940],{},"一个细节：AAC-LC 编码器有 priming delay（约 1024 samples@48kHz = 21.33ms），所以成片里音频起点和视频起点存在一个已知的 21ms 偏移。但这是编码器层的常数，不是渲染问题，接受即可。",[11,13942,13943,13944,13947,13948,13951],{},"关键词高亮（比如把卖点词着色为黄色）需要在分析时做标记，例如 HTML 标签：",[15,13945,13946],{},"\u003Cspan class=\"highlight\">关键词\u003C\u002Fspan>","，然后 CSS 定义颜色。模板编写规约里明确禁止动画 ",[15,13949,13950],{},"letterSpacing"," 等布局属性（会在逐帧捕获时 snap 到整数像素产生抖动），只允许 transform（x\u002Fy\u002Fscale\u002Fopacity）。",[26,13953,13954],{"id":13954},"成片路径的端到端烟测",[11,13956,13957],{},"发版前的验收分六个阶段：",[11,13959,13960,13963],{},[488,13961,13962],{},"A. 发版前置","（缺一项线上就炸）",[123,13965,13966,13977,13983,13993,13996],{},[126,13967,13968,13969,13972,13973,13976],{},"后台已配渲染单价（resourceKey ",[15,13970,13971],{},"video_render_sec","），且 ",[15,13974,13975],{},"enabled"," 勾选",[126,13978,13979,13980,13615],{},"灰度名单已配置（环境变量 ",[15,13981,13982],{},"VIDEO_RENDER_HTML_USER_IDS",[126,13984,13985,13986,13989,13990,13615],{},"发版机 ",[15,13987,13988],{},"release.sh"," 已改（支持第 6 个镜像 ",[15,13991,13992],{},"yc-video-render",[126,13994,13995],{},"K8s 集群配额已提升（requestQuota 新增 2C\u002F2Gi、limitsQuota 新增 4C\u002F4Gi）",[126,13997,13998],{},"构建机磁盘充足（≥10GB 空闲）",[11,14000,14001,14004],{},[488,14002,14003],{},"B. 发版后基础设施","（不通过立即 rollout undo）",[123,14006,14007,14013,14020,14027,14034],{},[126,14008,14009,14010,14012],{},"迁移已执行（",[15,14011,13851],{}," 表已建）",[126,14014,14015,14016,14019],{},"渲染 worker 已起（",[15,14017,14018],{},"READY 1\u002F1","，镜像 tag 与本次发版一致）",[126,14021,14022,14023,14026],{},"容器内 hyperframes CLI 可用（",[15,14024,14025],{},"hyperframes --version"," 返回 0.7.70）",[126,14028,14029,14030,14033],{},"容器内中文字体已装（",[15,14031,14032],{},"fc-list :lang=zh"," 非空）",[126,14035,14036],{},"worker 连上 Redis 队列（日志无 crash loop）",[11,14038,14039,14042],{},[488,14040,14041],{},"C. 回归防线：未放量用户零感知","（最高优先级）",[123,14044,14045,14048,14051,14054],{},[126,14046,14047],{},"不在灰度名单的用户进增强步看不到\"包装模板\"卡",[126,14049,14050],{},"旧链路（ffmpeg）完整出片，字幕\u002F转场\u002FBGM 都在",[126,14052,14053],{},"旧链路仍生成 AI 插片，计费时间线出现 seedance 扣费",[126,14055,14056,14057,14059],{},"未放量时 ",[15,14058,13851],{}," 表没有新行",[11,14061,14062],{},"这段最重要是因为旧链路服务着所有现存数字人用户。一个新功能开关不应该波及灰度外的用户。",[11,14064,14065,14068],{},[488,14066,14067],{},"D. 灰度用户正向流程","（核心价值）",[123,14070,14071,14074,14080,14087,14090,14093,14096,14099,14102],{},[126,14072,14073],{},"模板列表可见：增强步看到\"不加包装\"+\"美食探店\"两张卡",[126,14075,14076,14077,14079],{},"选模板后出片最终 ",[15,14078,3659],{},"，可播放",[126,14081,14082,14083,14086],{},"成片有口播声音（这是 ",[15,14084,14085],{},"data-has-audio"," 命门检验）",[126,14088,14089],{},"片头\u002F片尾\u002F角标都在：0-3s 品牌片头、右上角全程角标、最后 3s CTA",[126,14091,14092],{},"中文不乱码：片头标题与字幕汉字正常",[126,14094,14095],{},"字幕关键词高亮：关键词黄色，其余白色，标签没被打碎",[126,14097,14098],{},"进度实时可见：出片过程中进度条推进，不死在一个数字",[126,14100,14101],{},"成片链接可下载且长期有效：15 分钟后刷新页面仍能播放",[126,14103,14104],{},"渲染时长符合预期：60s 成片≤5 分钟（M1 POC 实测 39.5s）",[11,14106,14107,14110],{},[488,14108,14109],{},"E. 资金正确性","（看后台账单，不能只看页面）",[123,14112,14113,14118,14121,14124,14127],{},[126,14114,14115,14116,13615],{},"扣的是视频点不是算力点（计费时间线条目 title 首段是 ",[15,14117,13687],{},[126,14119,14120],{},"不再扣 seedance 插片钱（灰度用户的时间线里没有插片扣费）",[126,14122,14123],{},"结算按实际秒数（units ≈ 成片时长整秒向上取整）",[126,14125,14126],{},"余额不足时不产生任务、不扣费",[126,14128,14129],{},"重复提交不重复扣费（operationId 幂等）",[11,14131,14132],{},[488,14133,14134],{},"F. 异常路径",[123,14136,14137,14143,14149,14152,14155,14158],{},[126,14138,14139,14140,14142],{},"渲染失败会退款：任务置 ",[15,14141,4823],{},"，计费时间线出现退款条目",[126,14144,14145,14146,14148],{},"失败同步反映到增强任务：增强任务也变 ",[15,14147,4823],{},"，前端不会永远停在\"渲染中\"",[126,14150,14151],{},"排队中可取消并全额退：返回成功，退款到账",[126,14153,14154],{},"已在渲染中不可取消：返回 409，不退款（CPU 已烧）",[126,14156,14157],{},"worker 重启不丢任务：任务被重新捞起或置失败退款",[126,14159,14160,14161,14163],{},"超时判失败：>10 分钟置 ",[15,14162,4823],{}," 并退款",[11,14165,14166],{},"每一条都写了具体的检查命令和判据。比如验证成片有声音，就是直接播放、耳朵听；验证字幕关键词高亮，就是肉眼看颜色；验证退款，就是对比出片前后的计费时间线。",[26,14168,14169],{"id":14169},"设计的权衡",[11,14171,14172],{},"这套方案的成本是什么？",[11,14174,14175],{},"首先是镜像大小：官方渲染镜像装了 280 多个 apt 包、node_modules、Chromium 和 FFmpeg，构建出的镜像实测 3.72GB，单独占用一个 K8s node pool，构建时间约 10 分钟，推拉镜像耗时显著上升。但这是\"要么装进 API 镜像肥到 6GB+、拖累三个部署，要么单独镜像\"的取舍——选了单独。",[11,14177,14178,14179,14181,14182,14185,14186,14189],{},"其次是模板编写规约的学习成本。一个看起来\"普通的网页动画\"可能在逐帧 seek 渲染时炸：GSAP 退场动画必须挂在 clip 内层并补 hard kill，禁止 ",[15,14180,13950],{}," 等布局属性，中文字体必须显式 ",[15,14183,14184],{},"@font-face","（容器内用 Noto Sans CJK）。",[15,14187,14188],{},"hyperframes check"," 作为模板上架的强制 gate，能一次性拦截这类问题。",[11,14191,14192],{},"获得的是什么？確定性输出。同一个模板、同一份配音，渲染 100 次得到 100 个帧级一致的成片（前提是输入稳定，不变数字人形象、不变字体库版本）。这对 CI 自动回归、成片质量审核都很有意义。还有灵活性：配音、数字人、包装模板可以独立迭代，不互相阻塞。",[26,14194,14195],{"id":14195},"线上部署的最后两步",[11,14197,14198],{},"发版后的两个关键项，缺一项都会导致灰度用户无法下单：",[243,14200,14201,14210],{},[126,14202,14203,1244,14206,14209],{},[488,14204,14205],{},"后台配价",[15,14207,14208],{},"resourceKey=video_render_sec"," 的单价必须填（单位元\u002F秒），且勾选 enabled。没配的话 chargeResource 会返回 404，用户点开始出片就直接报错。",[126,14211,14212,1244,14215,14217],{},[488,14213,14214],{},"灰度名单",[15,14216,13982],{}," 环境变量决定了谁能看到模板卡。留空 = 全员走旧 ffmpeg 链路（可用于紧急回滚），指定用户 ID = 该用户进入新链路。发版初期应只填 1-2 个测试账号。",[11,14219,14220],{},"这两个配置是代码之外的硬依赖，容易遗漏。烟测清单里放在最前面，作为\"不做后面全走不通\"的前置项。",[11,14222,14223,14224,14227],{},"整个方案的核心原则是",[488,14225,14226],{},"分层隔离与路径确定性","：TTS 决定时长、飞天决定人像、HyperFrames 决定包装，各层独立演进，成片路径从输入到输出一条流水线，无分支、无条件、无随机。这对一个产生可发布物料的流水线来说，是底线。",[1267,14229,14230],{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}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}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);}",{"title":183,"searchDepth":184,"depth":184,"links":14232},[14233,14234,14235,14236,14237,14238,14239],{"id":13578,"depth":184,"text":13579},{"id":13644,"depth":184,"text":13644},{"id":13845,"depth":184,"text":13845},{"id":13911,"depth":184,"text":13911},{"id":13954,"depth":184,"text":13954},{"id":14169,"depth":184,"text":14169},{"id":14195,"depth":184,"text":14195},"2026-07-08",{},"\u002F2026-07-08",{"title":13570,"description":13575},"2026-07-08-视频包装与数字人配音","数字人口播成片的最后一环：配音时长不可控，如何反向驱动画面？原片如何作为独立图层无损合成？成片路径如何保证确定性？",[14247,14248,14249,14250,14251],"TTS","数字人","HyperFrames","音视同步","流水线设计","ISg-n2hvjyCKUW_WMTk0Ais8ji7YhOK5rXPmNyUBWQk",{"id":14254,"title":14255,"body":14256,"column":6815,"date":15061,"description":14260,"extension":199,"hero_image":200,"meta":15062,"navigation":202,"path":15063,"seo":15064,"series_id":200,"severity":200,"stem":15065,"summary":15066,"tags":15067,"__hash__":15073},"posts\u002F2026-07-07-桌面端只做壳不装页面.md","桌面端只做壳，不装页面",{"type":8,"value":14257,"toc":15051},[14258,14261,14264,14271,14275,14278,14288,14409,14415,14433,14447,14466,14479,14483,14489,14498,14504,14508,14514,14520,14523,14529,14595,14598,14601,14616,14619,14635,14642,14662,14665,14669,14672,14682,14689,14706,14709,14800,14806,14822,14825,14832,14836,14839,14864,14870,14880,14886,14949,14956,14966,14985,14988,14992,14999,15006,15009,15013,15016,15022,15025,15028,15032,15035,15038,15045,15048],[11,14259,14260],{},"Windows 客户端有两种做法。",[11,14262,14263],{},"一种是把前端打进安装包，装完之后本地有一份完整页面。另一种是桌面端只做壳，窗口加载远端地址。这条线选了后者。",[11,14265,14266,14267,14270],{},"选择的结果是：7 月 7 日一天发了两个版本（1.0.19、1.0.20），两次都只动了 ",[15,14268,14269],{},"apps\u002Fdesktop"," 自己的代码。",[26,14272,14274],{"id":14273},"一壳里没有页面","一、壳里没有页面",[11,14276,14277],{},"最直接的证据是渲染进程的产物。",[11,14279,14280,14283,14284,14287],{},[15,14281,14282],{},"electron-vite"," 的渲染进程配置指向 ",[15,14285,14286],{},"src\u002Frenderer\u002Findex.html","，而这个文件全部内容是：",[399,14289,14291],{"className":13657,"code":14290,"language":13659,"meta":183,"style":183},"\u003C!DOCTYPE html>\n\u003Chtml>\n\u003Chead>\n  \u003Cmeta charset=\"UTF-8\" \u002F>\n  \u003Ctitle>创玩猩球\u003C\u002Ftitle>\n\u003C\u002Fhead>\n\u003Cbody>\n  \u003Cdiv id=\"app\">Loading...\u003C\u002Fdiv>\n\u003C\u002Fbody>\n\u003C\u002Fhtml>\n",[15,14292,14293,14306,14314,14323,14341,14355,14363,14372,14393,14401],{"__ignoreMap":183},[407,14294,14295,14298,14301,14304],{"class":409,"line":410},[407,14296,14297],{"class":413},"\u003C!",[407,14299,14300],{"class":8622},"DOCTYPE",[407,14302,14303],{"class":554}," html",[407,14305,13679],{"class":413},[407,14307,14308,14310,14312],{"class":409,"line":184},[407,14309,1073],{"class":413},[407,14311,13659],{"class":8622},[407,14313,13679],{"class":413},[407,14315,14316,14318,14321],{"class":409,"line":189},[407,14317,1073],{"class":413},[407,14319,14320],{"class":8622},"head",[407,14322,13679],{"class":413},[407,14324,14325,14327,14330,14333,14335,14338],{"class":409,"line":452},[407,14326,13684],{"class":413},[407,14328,14329],{"class":8622},"meta",[407,14331,14332],{"class":554}," charset",[407,14334,418],{"class":413},[407,14336,14337],{"class":429},"\"UTF-8\"",[407,14339,14340],{"class":413}," \u002F>\n",[407,14342,14343,14345,14348,14351,14353],{"class":409,"line":458},[407,14344,13684],{"class":413},[407,14346,14347],{"class":8622},"title",[407,14349,14350],{"class":413},">创玩猩球\u003C\u002F",[407,14352,14347],{"class":8622},[407,14354,13679],{"class":413},[407,14356,14357,14359,14361],{"class":409,"line":464},[407,14358,13744],{"class":413},[407,14360,14320],{"class":8622},[407,14362,13679],{"class":413},[407,14364,14365,14367,14370],{"class":409,"line":470},[407,14366,1073],{"class":413},[407,14368,14369],{"class":8622},"body",[407,14371,13679],{"class":413},[407,14373,14374,14376,14378,14381,14383,14386,14389,14391],{"class":409,"line":480},[407,14375,13684],{"class":413},[407,14377,13668],{"class":8622},[407,14379,14380],{"class":554}," id",[407,14382,418],{"class":413},[407,14384,14385],{"class":429},"\"app\"",[407,14387,14388],{"class":413},">Loading...\u003C\u002F",[407,14390,13668],{"class":8622},[407,14392,13679],{"class":413},[407,14394,14395,14397,14399],{"class":409,"line":1477},[407,14396,13744],{"class":413},[407,14398,14369],{"class":8622},[407,14400,13679],{"class":413},[407,14402,14403,14405,14407],{"class":409,"line":1483},[407,14404,13744],{"class":413},[407,14406,13659],{"class":8622},[407,14408,13679],{"class":413},[11,14410,14411,14412,781],{},"没有 ",[15,14413,14414],{},"\u003Cscript>",[11,14416,14417,14418,14421,14422,14425,14426,13345,14429,14432],{},"构建产物三个文件：",[15,14419,14420],{},"out\u002Fmain\u002Findex.js","（884K）、",[15,14423,14424],{},"out\u002Fpreload\u002Findex.cjs","（4K）、",[15,14427,14428],{},"out\u002Frenderer\u002Findex.html",[488,14430,14431],{},"147 字节","）。",[11,14434,14435,14436,4375,14439,14442,14443,14446],{},"全仓搜索 ",[15,14437,14438],{},"loadFile",[15,14440,14441],{},"file:\u002F\u002F","，",[15,14444,14445],{},"apps\u002Fdesktop\u002Fsrc\u002F"," 下没有任何一处加载本地 renderer。主窗口创建完就一件事：",[399,14448,14450],{"className":691,"code":14449,"language":693,"meta":183,"style":183},"void win.loadURL(cfg.webUrl);\n",[15,14451,14452],{"__ignoreMap":183},[407,14453,14454,14457,14460,14463],{"class":409,"line":410},[407,14455,14456],{"class":417},"void",[407,14458,14459],{"class":413}," win.",[407,14461,14462],{"class":554},"loadURL",[407,14464,14465],{"class":413},"(cfg.webUrl);\n",[11,14467,14468,14469,56,14472,56,14475,14478],{},"打包配置里的文件白名单也只列了三项：",[15,14470,14471],{},"out\u002F**\u002F*",[15,14473,14474],{},"resources\u002F**\u002F*",[15,14476,14477],{},"package.json","。前端产物不在其中——不是通过某个「排除 web」的配置项排除掉的，是从来没被放进来过。",[26,14480,14482],{"id":14481},"二换来的东西","二、换来的东西",[11,14484,14485,14488],{},[488,14486,14487],{},"web 发版不动桌面端。"," 页面改动走云端发版，用户下次打开就是新版本，不需要装任何东西。这也是 7 月 7 日能一天发两版的原因：两次改动都只涉及桌面端自己的代码。",[11,14490,14491,14494,14495,14497],{},[488,14492,14493],{},"更新包小。"," OTA 只依赖 ",[15,14496,14269],{}," 源码加一个协议包。更新包的内容就是主进程、预加载、几个本地二进制。",[11,14499,14500,14503],{},[488,14501,14502],{},"两端的版本可以独立走。"," 没有「前端必须等桌面端一起发」这种依赖。",[26,14505,14507],{"id":14506},"三代价","三、代价",[11,14509,14510,14513],{},[488,14511,14512],{},"离线完全不可用。"," 桌面端的 README 里，「暂未实现」清单下有一行：",[399,14515,14518],{"className":14516,"code":14517,"language":1327},[1325],"- 离线模式与本地回放\n",[15,14519,14517],{"__ignoreMap":183},[11,14521,14522],{},"没有本地缓存页、没有离线兜底。断网时窗口是空的。",[11,14524,14525,14528],{},[488,14526,14527],{},"首屏依赖网络。"," 主窗口的加载失败处理只做了一件事——标记这次启动是成功的：",[399,14530,14532],{"className":691,"code":14531,"language":693,"meta":183,"style":183},"win.webContents.on(\"did-fail-load\", (_e, _code, _desc, _url, isMainFrame) => {\n  if (isMainFrame) launchGuard.markLaunchSucceeded();\n});\n",[15,14533,14534,14579,14591],{"__ignoreMap":183},[407,14535,14536,14539,14542,14544,14547,14550,14553,14555,14558,14560,14563,14565,14568,14570,14573,14575,14577],{"class":409,"line":410},[407,14537,14538],{"class":413},"win.webContents.",[407,14540,14541],{"class":554},"on",[407,14543,586],{"class":413},[407,14545,14546],{"class":429},"\"did-fail-load\"",[407,14548,14549],{"class":413},", (",[407,14551,14552],{"class":718},"_e",[407,14554,433],{"class":413},[407,14556,14557],{"class":718},"_code",[407,14559,433],{"class":413},[407,14561,14562],{"class":718},"_desc",[407,14564,433],{"class":413},[407,14566,14567],{"class":718},"_url",[407,14569,433],{"class":413},[407,14571,14572],{"class":718},"isMainFrame",[407,14574,722],{"class":413},[407,14576,520],{"class":417},[407,14578,523],{"class":413},[407,14580,14581,14583,14586,14589],{"class":409,"line":184},[407,14582,3721],{"class":417},[407,14584,14585],{"class":413}," (isMainFrame) launchGuard.",[407,14587,14588],{"class":554},"markLaunchSucceeded",[407,14590,3822],{"class":413},[407,14592,14593],{"class":409,"line":189},[407,14594,667],{"class":413},[11,14596,14597],{},"这个设计的用意是区分「渲染进程起不来」和「网页加载失败」：网页加载失败说明沙箱正常，不该触发降级。但它也意味着主窗口没有错误页——加载失败时用户看到的是一块白窗口。",[11,14599,14600],{},"对比之下，agent 的内置浏览器有兜底页，注释写得很清楚：",[399,14602,14604],{"className":691,"code":14603,"language":693,"meta":183,"style":183},"\u002F\u002F 加载失败\u002F渲染崩溃时展示的兜底页：把失败原因和 URL 明明白白写出来，\n\u002F\u002F 取代过去那块「无提示纯白窗口」。\n",[15,14605,14606,14611],{"__ignoreMap":183},[407,14607,14608],{"class":409,"line":410},[407,14609,14610],{"class":528},"\u002F\u002F 加载失败\u002F渲染崩溃时展示的兜底页：把失败原因和 URL 明明白白写出来，\n",[407,14612,14613],{"class":409,"line":184},[407,14614,14615],{"class":528},"\u002F\u002F 取代过去那块「无提示纯白窗口」。\n",[11,14617,14618],{},"主窗口这一条还没补。",[11,14620,14621,14624,14625,56,14628,56,14631,14634],{},[488,14622,14623],{},"两端版本要各自维护兼容边界。"," 这条是最不确定的。目前桌面端和 web 之间只有三个地址契约（",[15,14626,14627],{},"webUrl",[15,14629,14630],{},"apiBase",[15,14632,14633],{},"wsUrl","），没有版本号交换，也没有「桌面端版本过低就拒绝服务」的门槛。",[11,14636,14637,14638,14641],{},"设备注册时会上报 ",[15,14639,14640],{},"appVersion","，云端落库——但它的用途是记录，不是校验。兼容靠的是「能力清单 + 可选字段」：老版本不带某个字段时，新接口按缺省处理。协议包里有对应的测试：",[399,14643,14645],{"className":691,"code":14644,"language":693,"meta":183,"style":183},"it(\"没有工作目录时不带该字段，老桌面端行为不变\", ...)\n",[15,14646,14647],{"__ignoreMap":183},[407,14648,14649,14651,14653,14656,14658,14660],{"class":409,"line":410},[407,14650,583],{"class":554},[407,14652,586],{"class":413},[407,14654,14655],{"class":429},"\"没有工作目录时不带该字段，老桌面端行为不变\"",[407,14657,433],{"class":413},[407,14659,7262],{"class":417},[407,14661,3970],{"class":413},[11,14663,14664],{},"测试清单里有一条 P1 是「旧桌面端连新后端不崩」。这条列在那里本身就说明它还没有被系统验证过。",[26,14666,14668],{"id":14667},"四壳里真正装的是什么","四、壳里真正装的是什么",[11,14670,14671],{},"如果桌面端什么都不装，它就没必要存在。装的是三类本地运行时。",[11,14673,14674,14677,14678,14681],{},[488,14675,14676],{},"Python 运行时。"," 打包一份 Python 3.12.7 的 embed 版本，让 AI 的 ",[15,14679,14680],{},"python xxx.py"," 在任何客户机器上都能跑，不需要客户自己装 Python。",[11,14683,14684,14685,14688],{},"它必须走 ",[15,14686,14687],{},"extraResources"," 而不是进 asar，配置里写了原因：",[399,14690,14694],{"className":14691,"code":14692,"language":14693,"meta":183,"style":183},"language-js shiki shiki-themes github-light github-dark","\u002F\u002F 自带 Python 运行时：二进制必须走 extraResources（解压到 app\u002Fresources 下、不进 asar 才能执行）。\n\u002F\u002F 由 scripts\u002Ffetch-pyruntime.mjs 预先下载到 resources\u002Fpyruntime\u002F\u003C平台>\u002F。\n","js",[15,14695,14696,14701],{"__ignoreMap":183},[407,14697,14698],{"class":409,"line":410},[407,14699,14700],{"class":528},"\u002F\u002F 自带 Python 运行时：二进制必须走 extraResources（解压到 app\u002Fresources 下、不进 asar 才能执行）。\n",[407,14702,14703],{"class":409,"line":184},[407,14704,14705],{"class":528},"\u002F\u002F 由 scripts\u002Ffetch-pyruntime.mjs 预先下载到 resources\u002Fpyruntime\u002F\u003C平台>\u002F。\n",[11,14707,14708],{},"运行时目录从安装目录解析，也不可能从网络取：",[399,14710,14712],{"className":691,"code":14711,"language":693,"meta":183,"style":183},"const base = join(resourcesPath, \"pyruntime\", sub);\nconst exe = process.platform === \"win32\" ? join(base, \"python.exe\") : join(base, \"bin\", \"python3\");\nif (!existsSync(exe)) return null;\n",[15,14713,14714,14735,14780],{"__ignoreMap":183},[407,14715,14716,14718,14721,14723,14726,14729,14732],{"class":409,"line":410},[407,14717,700],{"class":417},[407,14719,14720],{"class":476}," base",[407,14722,706],{"class":417},[407,14724,14725],{"class":554}," join",[407,14727,14728],{"class":413},"(resourcesPath, ",[407,14730,14731],{"class":429},"\"pyruntime\"",[407,14733,14734],{"class":413},", sub);\n",[407,14736,14737,14739,14742,14744,14747,14749,14752,14754,14756,14759,14762,14764,14766,14768,14770,14773,14775,14778],{"class":409,"line":184},[407,14738,700],{"class":417},[407,14740,14741],{"class":476}," exe",[407,14743,706],{"class":417},[407,14745,14746],{"class":413}," process.platform ",[407,14748,881],{"class":417},[407,14750,14751],{"class":429}," \"win32\"",[407,14753,3524],{"class":417},[407,14755,14725],{"class":554},[407,14757,14758],{"class":413},"(base, ",[407,14760,14761],{"class":429},"\"python.exe\"",[407,14763,722],{"class":413},[407,14765,1059],{"class":417},[407,14767,14725],{"class":554},[407,14769,14758],{"class":413},[407,14771,14772],{"class":429},"\"bin\"",[407,14774,433],{"class":413},[407,14776,14777],{"class":429},"\"python3\"",[407,14779,662],{"class":413},[407,14781,14782,14784,14786,14788,14791,14794,14796,14798],{"class":409,"line":189},[407,14783,875],{"class":417},[407,14785,3724],{"class":413},[407,14787,3727],{"class":417},[407,14789,14790],{"class":554},"existsSync",[407,14792,14793],{"class":413},"(exe)) ",[407,14795,3733],{"class":417},[407,14797,3485],{"class":476},[407,14799,918],{"class":413},[11,14801,14802,14805],{},[488,14803,14804],{},"VC++ 运行库。"," 安装期静默装，已装则跳过，避免多余的 UAC 弹窗。",[11,14807,14808,14811,14812,56,14815,56,14818,14821],{},[488,14809,14810],{},"Electron 自己的运行库。"," 有一个设计文档专门钉了一份「核心文件清单」：主程序 exe、",[15,14813,14814],{},"ffmpeg.dll",[15,14816,14817],{},"chrome_elf.dll",[15,14819,14820],{},"resources\u002Fapp.asar","，检查发生在安装包解包完成之后、启动新程序之前。",[11,14823,14824],{},"这份清单来自一次真实故障：用户从旧版自动更新之后，启动时报「找不到 ffmpeg.dll」。安装包里其实有这个文件——错误发生在 Electron 主程序加载阶段，那时应用自己的错误处理还没机会运行，所以只能靠安装器检查。",[11,14826,14827,14828,14831],{},"这三类东西的共同点是",[488,14829,14830],{},"它们不能从远端加载","。Python 解释器、运行库 dll、Electron 自身，都必须在本地文件系统上。这才是壳存在的理由，不是为了渲染页面。",[26,14833,14835],{"id":14834},"五发版链路","五、发版链路",[11,14837,14838],{},"四个步骤，脚本里编号就是四段：",[399,14840,14842],{"className":11754,"code":14841,"language":11756,"meta":183,"style":183},"# [1\u002F4] 同步源码（只覆盖源码，保留 node_modules \u002F pyruntime \u002F dist）\n# [2\u002F4] 构建 dist:win（含 pyruntime、签名）\n# [3\u002F4] 校验 latest.yml 版本 == $VERSION\n# [4\u002F4] 上传 OTA\n",[15,14843,14844,14849,14854,14859],{"__ignoreMap":183},[407,14845,14846],{"class":409,"line":410},[407,14847,14848],{"class":528},"# [1\u002F4] 同步源码（只覆盖源码，保留 node_modules \u002F pyruntime \u002F dist）\n",[407,14850,14851],{"class":409,"line":184},[407,14852,14853],{"class":528},"# [2\u002F4] 构建 dist:win（含 pyruntime、签名）\n",[407,14855,14856],{"class":409,"line":189},[407,14857,14858],{"class":528},"# [3\u002F4] 校验 latest.yml 版本 == $VERSION\n",[407,14860,14861],{"class":409,"line":452},[407,14862,14863],{"class":528},"# [4\u002F4] 上传 OTA\n",[11,14865,14866,14869],{},[488,14867,14868],{},"为什么是「scp 源码到构建机」而不是在构建机上 clone。"," 构建机无法非交互拉取私有仓库（HTTPS 没有 tty，SSH 公钥没授权），所以从工作机把源码推过去，在一个「依赖已装、运行时齐全、密钥在系统环境变量」的固定构建目录里出包。",[11,14871,14872,14875,14876,14879],{},[488,14873,14874],{},"为什么走镜像。"," electron-builder 在国内拉 GitHub releases 会超时，报的是 ",[15,14877,14878],{},"Timeout awaiting 'request' for 600000ms","。构建前设两个镜像环境变量绕开。",[11,14881,14882,14885],{},[488,14883,14884],{},"版本校验有两道。"," 第一道在脚本里，出包之后比对产物清单里的版本和目标版本，不符就中止，不上传：",[399,14887,14889],{"className":11754,"code":14888,"language":11756,"meta":183,"style":183},"[[ \"$ONVER\" == \"$VERSION\" ]] || { echo \"产物版本($ONVER) != 目标($VERSION)，中止上传。\" >&2; exit 1; }\n",[15,14890,14891],{"__ignoreMap":183},[407,14892,14893,14896,14898,14901,14903,14906,14909,14912,14914,14917,14919,14921,14924,14927,14929,14932,14934,14937,14940,14942,14945,14947],{"class":409,"line":410},[407,14894,14895],{"class":413},"[[ ",[407,14897,6142],{"class":429},[407,14899,14900],{"class":413},"$ONVER",[407,14902,6142],{"class":429},[407,14904,14905],{"class":417}," ==",[407,14907,14908],{"class":429}," \"",[407,14910,14911],{"class":413},"$VERSION",[407,14913,6142],{"class":429},[407,14915,14916],{"class":413}," ]] ",[407,14918,5746],{"class":417},[407,14920,7652],{"class":413},[407,14922,14923],{"class":476},"echo",[407,14925,14926],{"class":429}," \"产物版本(",[407,14928,14900],{"class":413},[407,14930,14931],{"class":429},") != 目标(",[407,14933,14911],{"class":413},[407,14935,14936],{"class":429},")，中止上传。\"",[407,14938,14939],{"class":417}," >&2",[407,14941,1121],{"class":413},[407,14943,14944],{"class":476},"exit",[407,14946,915],{"class":476},[407,14948,10040],{"class":413},[11,14950,14951,14952,14955],{},"第二道在产物选择里：只允许当前版本的三件套（安装包、blockmap、清单）上传，缺任何一个直接抛错。这一道是为了处理「",[15,14953,14954],{},"dist"," 目录不清、旧版安装包残留」的情况。",[11,14957,14958,14961,14962,14965],{},[488,14959,14960],{},"客户端侧。"," 更新走 ",[15,14963,14964],{},"electron-updater"," 的 generic provider，指向一个静态目录：",[399,14967,14969],{"className":14691,"code":14968,"language":14693,"meta":183,"style":183},"publish: [{ provider: \"generic\", url: updateUrl }],\n",[15,14970,14971],{"__ignoreMap":183},[407,14972,14973,14976,14979,14982],{"class":409,"line":410},[407,14974,14975],{"class":554},"publish",[407,14977,14978],{"class":413},": [{ provider: ",[407,14980,14981],{"class":429},"\"generic\"",[407,14983,14984],{"class":413},", url: updateUrl }],\n",[11,14986,14987],{},"启动时拉清单文件、比较版本、下载、静默安装。",[26,14989,14991],{"id":14990},"六版本号的唯一来源","六、版本号的唯一来源",[11,14993,14994,14995,14998],{},"版本号只在 ",[15,14996,14997],{},"apps\u002Fdesktop\u002Fpackage.json"," 一处。产物名和清单里的版本都由它决定。",[11,15000,15001,15002,15005],{},"与之配套的一条纪律：",[488,15003,15004],{},"绝不能复用旧版本号","。客户端判断「要不要更新」靠的是版本比较，版本号相同就等于没有新版本，用户那边什么都不会发生——而这种失败是静默的，服务端看到的是上传成功。",[11,15007,15008],{},"脚本的这条判断还有一个前置的护栏：工作区有未提交的已跟踪改动时拒绝发版。因为发出去的是工作区源码，不是某个 commit。",[26,15010,15012],{"id":15011},"七一处没对齐的地方","七、一处没对齐的地方",[11,15014,15015],{},"签名。桌面端 README 写的是：",[399,15017,15020],{"className":15018,"code":15019,"language":1327},[1325],"当前 Windows 安装器未接入 CA 代码签名，用户首次安装可能看到 SmartScreen 提示。\n拿到证书后再通过 WIN_CSC_LINK \u002F WIN_CSC_KEY_PASSWORD 接入签名流程。\n",[15,15021,15019],{"__ignoreMap":183},[11,15023,15024],{},"而发版 skill 里写的是构建机已配 signtool、产出无 SmartScreen 强拦。两处说法不一致，仓库里也搜不到任何签名实现——那两个环境变量只出现在这一行文档里。",[11,15026,15027],{},"这两句话都写在 7 月上旬，中间没有改动过配置。真实状态是：安装包没有 CA 签名，首次安装会出现 SmartScreen 提示。文档里那句「无强拦」应该改掉。",[26,15029,15031],{"id":15030},"八这条路线适合什么","八、这条路线适合什么",[11,15033,15034],{},"选它之前要接受三个前提：用户始终在线、接受首次打开有网络等待、接受两端各自演进。",[11,15036,15037],{},"换来的是发版节奏的自由：改一个页面不需要用户装新版本，改壳里的东西不需要等前端一起走。前者让迭代变快，后者让迭代变危险——OTA 是推给所有人的，很难回滚。",[11,15039,15040,15041,15044],{},"所以这条路线上的纪律集中在一点：",[488,15042,15043],{},"发版前的校验要做足","。两道版本门禁、一条工作区护栏、一个只构建不上传的选项。四道关卡守的都是同一件事：不要把一个身份不明的包推给全部用户。",[11,15046,15047],{},"有一处边界想清楚过：安装包必须包含哪些文件。这份清单不是设计出来的，是被一次「找不到 ffmpeg.dll」的故障逼出来的。清单上的文件缺任何一个，安装器就在启动新程序之前停下来——因为在那个时刻，应用自己的任何补救代码都还没机会运行。",[1267,15049,15050],{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}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}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 .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}",{"title":183,"searchDepth":184,"depth":184,"links":15052},[15053,15054,15055,15056,15057,15058,15059,15060],{"id":14273,"depth":184,"text":14274},{"id":14481,"depth":184,"text":14482},{"id":14506,"depth":184,"text":14507},{"id":14667,"depth":184,"text":14668},{"id":14834,"depth":184,"text":14835},{"id":14990,"depth":184,"text":14991},{"id":15011,"depth":184,"text":15012},{"id":15030,"depth":184,"text":15031},"2026-07-07",{},"\u002F2026-07-07",{"title":14255,"description":14260},"2026-07-07-桌面端只做壳不装页面","桌面端只加载远端地址，不把前端打进安装包。这条路线换来了独立的发版节奏，代价是离线不可用、首屏依赖网络，以及壳里必须自带的那部分本地运行时。",[15068,212,15069,15070,15071,15072],"Electron","OTA","electron-builder","打包","版本管理","ElNTxouudoGHuSXPhaMwM-u23sOVeK4HA3GAGBnmito",{"id":15075,"title":15076,"body":15077,"column":196,"date":15348,"description":15081,"extension":199,"hero_image":200,"meta":15349,"navigation":202,"path":15350,"seo":15351,"series_id":200,"severity":200,"stem":15352,"summary":15353,"tags":15354,"__hash__":15358},"posts\u002F2026-07-05-克制的网页动效.md","这个动效，是在帮用户还是在展示我？",{"type":8,"value":15078,"toc":15334},[15079,15082,15085,15089,15092,15095,15098,15105,15109,15112,15115,15118,15121,15127,15131,15134,15140,15143,15150,15153,15157,15163,15166,15173,15180,15184,15191,15198,15213,15216,15219,15222,15228,15234,15240,15243,15252,15261,15276,15279,15282,15285,15288,15291,15294,15301,15304,15307,15318,15324,15331],[11,15080,15081],{},"前端做了一轮动效设计，反复遇到同一个问题：这个动效是在帮助用户理解界面，还是只在展示\"我会做动效\"。区分的标准不是感觉，是三条具体的判据。",[26,15083,15084],{"id":15084},"动效的三条判据",[31,15086,15088],{"id":15087},"判据一是否传达了状态变化","判据一：是否传达了状态变化",[11,15090,15091],{},"用户操作后，系统进入新的状态。动效的作用是让这个状态变化可感知。",[11,15093,15094],{},"加载中。一个网络请求发出去了，但数据还没回来，用户看不到任何反馈会以为卡了。打字指示（三个点循环脉冲、错开时间）就是这类必做的动效。用户不需要理解\"这是一个脉冲\"，只需要看到\"系统在做什么\"。",[11,15096,15097],{},"成功和失败。表单提交后，服务器返回 200 还是 400，用户需要知道。Toast 从屏幕边滑入、带上颜色标记（绿色还是红色）和文案，用户秒懂结果。这里动效的信息密度很高：位置（从边界滑入说明是一个通知）、方向、颜色都在传达信息。",[11,15099,15100,15101,15104],{},"这一类动效 ",[488,15102,15103],{},"该做","。不做的代价是用户体验割裂——界面突然变了，用户需要花脑力去理解\"发生了什么\"。",[31,15106,15108],{"id":15107},"判据二是否建立了空间关系","判据二：是否建立了空间关系",[11,15110,15111],{},"用户在多个界面间切换，每个界面有不同的内容。动效的作用是让用户在心理上形成\"我从 A 地来到了 B 地\"的感觉。",[11,15113,15114],{},"页面切换。新页面从右侧滑入或从中央缩放进来，老页面退出。用户的注意力自然跟随移动，不会觉得\"屏幕闪了一下\"；反而能通过位移方向推断页面的层级关系。",[11,15116,15117],{},"弹窗。一个模态框从中央向外弹出、带回弹感，用户知道\"这个内容是浮在上面的、暂时的\"。而如果弹窗突然出现，用户需要花力气解析\"这是什么、它和后面的页面什么关系\"。",[11,15119,15120],{},"展开与收起。列表条目逐个滑入，后出现的条目在前面条目之后，这叫 stagger 编排。用户可以跟踪每一项的出现，而不是\"列表突然全填满了，我从哪里开始看\"。导航栏的 active 指示器平移而不是闪烁切换，用户能看到\"我从这里跳到那里\"。",[11,15122,15123,15124,15126],{},"这一类动效也 ",[488,15125,15103],{},"。代价同样高：没有这些线索，界面就像一个无生命的状态表格，用户要主动扫描才能判断当前在哪、怎么走。",[31,15128,15130],{"id":15129},"判据三装饰性动效","判据三：装饰性动效",[11,15132,15133],{},"一个元素 hover 时发光、卡片有光扫效果、logo 漂浮、按钮涟漪……这些都是装饰。它们不传达任何状态变化，不建立空间关系，就是\"看起来更高级\"。",[11,15135,15136,15137],{},"品牌要求这些吗？可能。会提升用户体验吗？证据不足。",[488,15138,15139],{},"该砍。",[11,15141,15142],{},"这里的逻辑是：每一帧动画都要用户的 GPU 和浏览器去渲染。装饰性动效的每一帧都是在\"花钱不赚钱\"。当装饰堆积到一定程度，就会出现性能问题。",[11,15144,15145,15146,15149],{},"实践中的折衷是：装饰性动效只保留在",[488,15147,15148],{},"关键时刻","。用户花钱了、获得了成功的反馈，这时撒纸屑。余额减少了，扣点粒子迸发。这些装饰是在庆祝或强调，而不是无处不在。",[26,15151,15152],{"id":15152},"性能与可访问性的硬要求",[31,15154,15156],{"id":15155},"_60fps-底线","60fps 底线",[11,15158,15159,15160,781],{},"动效要稳定在 60fps，否则就是卡顿伪装成流畅。这意味着",[488,15161,15162],{},"只动 transform、opacity 和 filter，不动 layout 属性",[11,15164,15165],{},"为什么？改变 width、height、margin 或 left\u002Ftop 会触发重排（reflow）。浏览器要重算文档流、重排页面，这会花很长时间，远超单帧预算。动画自动掉帧。",[11,15167,15168,15169,15172],{},"实例：一个列表容器用 height 从隐藏到展开的动画，看起来像\"列表展开\"，实际上每帧都在触发重排。改成用 ",[15,15170,15171],{},"scaleY"," transform 动画，性能立刻改善。",[11,15174,15175,15176,15179],{},"另一个细节：长列表的逐项入场动画。当列表项数足够多时，用 stagger 从上往下依次滑入，屏幕可见范围内的项动画效果还不错，但超出视口的项同样在运行动画逻辑——整个列表的动画状态都要参与计算，对内存和渲染管线造成压力。硬要求是：",[488,15177,15178],{},"大列表只对首屏可见项做动画","，不可见项直接渲染到最终状态。",[31,15181,15183],{"id":15182},"prefers-reduced-motion必须尊重用户的选择","prefers-reduced-motion：必须尊重用户的选择",[11,15185,15186,15187,15190],{},"系统级的「减少动态效果」开关（macOS 辅助功能、Windows 显示设置）对应 CSS 媒体查询 ",[15,15188,15189],{},"prefers-reduced-motion: reduce","。一部分用户打开这个设置（通常因为前庭功能障碍、晕动症或认知障碍），动效会让他们头晕或注意力分散。",[11,15192,15193,15194,15197],{},"硬要求：",[488,15195,15196],{},"当系统选择 reduced-motion 时，所有动效必须降级","。降级方案是什么？最简单且有效的方案是淡入淡出。用户看不到位移、没有高频闪烁，界面从无到有、从有到无。装饰性的粒子、纸屑、光扫一律不渲染。",[11,15199,15200,15201,15204,15205,15208,15209,15212],{},"实现方式是在应用根部用一个全局的 ",[15,15202,15203],{},"MotionConfig"," 接管 ",[15,15206,15207],{},"reducedMotion=\"user\"","。React 的 ",[15,15210,15211],{},"useReducedMotion()"," hook 会读系统偏好。检查返回值，该短路的短路，该降速的降速。",[11,15214,15215],{},"没有这一步，上线的产品会有一小部分用户会因为你的动效而无法使用你的产品。",[26,15217,15218],{"id":15218},"参数选择的逻辑",[11,15220,15221],{},"时长有三个档位。",[11,15223,15224,15227],{},[488,15225,15226],{},"Micro（0.17 秒 \u002F 170ms）","：按钮 hover 抬升、开关滑动、输入框 focus 发光。这些是每秒可能发生多次的交互，用户期望立刻看到反馈。太慢了会让交互感觉迟钝。",[11,15229,15230,15233],{},[488,15231,15232],{},"Standard（0.34 秒 \u002F 340ms）","：消息气泡进场、列表项滑入、Toast 弹出。这是用户注意到的变化，需要一些时间让眼睛跟随，但不能太长。动效过长就开始让人觉得\"卡\"而不是\"流畅\"。",[11,15235,15236,15239],{},[488,15237,15238],{},"Emphasis（0.48 秒 \u002F 480ms）","：页面切换的主体。新页面的进入需要最长的时间，因为整个屏幕都在变。但退出只需要 ~0.36 秒（75% 的进入时长），用户离开某个页面应该更快、不需要过度演示。",[11,15241,15242],{},"缓动曲线有两种常见类型。",[11,15244,15245,13345,15248,15251],{},[488,15246,15247],{},"OutExpo",[15,15249,15250],{},"[0.16, 1, 0.3, 1]","）：开始快、结束慢。用在列表项进场、消息气泡滑入。因为是内容从无到有，用户的注意力已经被吸引，动画可以前置地快速到达目标位置，最后用缓冲平稳落地。感觉是\"内容飘进来了\"。",[11,15253,15254,13345,15257,15260],{},[488,15255,15256],{},"SoftSpring",[15,15258,15259],{},"[0.34, 1.4, 0.5, 1]","）：轻微的超调回弹。用在弹窗进出、输入框 focus。因为是用户主动触发的强交互，回弹感可以传递\"系统在听我\"的反馈。不是生硬的到位，而是\"弹\"到位。",[11,15262,15263,15264,15267,15268,15271,15272,15275],{},"Spring 预设有三档：",[15,15265,15266],{},"snappy","（stiffness 520, damping 30）用于最快反应、",[15,15269,15270],{},"bouncy","（380, 22）用于入场、",[15,15273,15274],{},"smooth","（260, 32）用于页面转场。数字越大振荡越少，但响应越快；数字小了振荡次数多、柔软感强。",[11,15277,15278],{},"这些参数都是经过测试的。如果你随意加长动效时间想让用户\"看清每一帧\"，代价是整个产品的交互节奏都会崩。快动效之间的间隙会积累，让用户觉得系统在思考，而不是在响应。",[26,15280,15281],{"id":15281},"时长过长的代价",[11,15283,15284],{},"某些设计师认为慢动效显得\"精致\"。实践中这是错的。",[11,15286,15287],{},"首先，从用户体验的角度：动效的目的是传达信息，不是让用户等待。当一个必要的动效（如页面转场）变得太长，用户在反馈到来之前就开始扫视下一个操作目标，或者认为系统在卡。更长的等待就变成了对用户时间的浪费。",[11,15289,15290],{},"其次，从性能的角度：更长的动效意味着更多的帧要渲染。一个快速的动效和一个冗长的动效相比，后者不仅占用的渲染时间更多，对 GPU 和内存的压力也更大。在移动设备上、在 GPU 受限的情况下，这直接转化成帧率下降、电池耗尽、设备发热。",[11,15292,15293],{},"第三，从注意力的角度：冗长的动效会让用户的焦点分散。正在播放的动画仍在吸引视觉注意，用户无法提前扫视接下来的内容。这不是在尊重用户，而是在强制停留。",[11,15295,15296,15297,15300],{},"在实施 yun-claude 的动效设计时，判断的标准是：",[488,15298,15299],{},"动效的时长应该足够用户感知状态变化，但不足够让用户等待","。所以页面进入用 0.48s（完整展示），退出只用 0.36s（快速离开），打字指示用 0.17s（微交互要快）。这个分层是为了加速工作流，而不是为了炫技。",[26,15302,15303],{"id":15303},"小结",[11,15305,15306],{},"写三条判据是为了在每个 feature 决策点问自己：",[123,15308,15309,15312,15315],{},[126,15310,15311],{},"这个动效在传达用户需要知道的状态变化吗？",[126,15313,15314],{},"这个动效在建立空间或层级关系吗？",[126,15316,15317],{},"如果都不是，它只是装饰。",[11,15319,15320,15321,15323],{},"如果是前两者，动效 ",[488,15322,15103],{},"。保证 60fps、尊重 reduced-motion、控制好时长参数。",[11,15325,15326,15327,15330],{},"如果是装饰，",[488,15328,15329],{},"该砍","。或者保留到特定时刻（成功、失败、关键反馈），而不是无处不在。",[11,15332,15333],{},"动效不是越多越好，也不是越慢越高级。克制地用、精准地用，动效才能真正帮助用户理解界面。",{"title":183,"searchDepth":184,"depth":184,"links":15335},[15336,15341,15345,15346,15347],{"id":15084,"depth":184,"text":15084,"children":15337},[15338,15339,15340],{"id":15087,"depth":189,"text":15088},{"id":15107,"depth":189,"text":15108},{"id":15129,"depth":189,"text":15130},{"id":15152,"depth":184,"text":15152,"children":15342},[15343,15344],{"id":15155,"depth":189,"text":15156},{"id":15182,"depth":189,"text":15183},{"id":15218,"depth":184,"text":15218},{"id":15281,"depth":184,"text":15281},{"id":15303,"depth":184,"text":15303},"2026-07-05",{},"\u002F2026-07-05",{"title":15076,"description":15081},"2026-07-05-克制的网页动效","三条判据分辨哪些动效值得做、哪些该砍，以及性能与可访问性的硬要求。",[15355,15356,15357,366],"交互动效","Web 性能","无障碍","nAZWp03XcfG3E4VVq7VsNOXAWYCi5XBAj6SGZflH3Hk",{"id":15360,"title":15361,"body":15362,"column":2727,"date":15660,"description":15366,"extension":199,"hero_image":200,"meta":15661,"navigation":202,"path":15662,"seo":15663,"series_id":200,"severity":200,"stem":15664,"summary":15665,"tags":15666,"__hash__":15670},"posts\u002F2026-07-02-工作流迁移与复用.md","搬一条生产线，难的不是业务逻辑",{"type":8,"value":15363,"toc":15649},[15364,15367,15370,15373,15376,15383,15386,15392,15395,15398,15405,15420,15423,15426,15442,15445,15464,15467,15470,15483,15486,15492,15518,15529,15532,15535,15538,15545,15548,15551,15554,15557,15564,15567,15596,15611,15614,15617,15643,15646],[11,15365,15366],{},"旧平台上已经跑通了漫剧和小说两条完整的创作工作流。新平台已具备图片生成、异步任务、用户鉴权和资源计费的基础设施。现在需要把这两条链路搬过来，但不是照搬代码，而是复用领域逻辑重新适配。",[11,15368,15369],{},"迁移看似是一个代码挪动的问题，实际上是一个架构解耦的问题。两个平台在三个维度上产生了紧密耦合，直接挪动代码会导致新平台继承旧平台的设计包袱。",[26,15371,15372],{"id":15372},"三处不兼容的耦合",[31,15374,15375],{"id":15375},"任务队列与调度模型",[11,15377,15378,15379,15382],{},"旧平台的工作流采用进程内任务 Map 和命令式 API 网关。漫剧工作流从剧本拆分镜头、生成资产图、生成视频、拼接整集——这一系列操作都是通过 ",[15,15380,15381],{},"__api\u002Fcomic_*"," 这样的命令端点来驱动的，任务状态存在内存 Map 里，重启进程就丢了。",[11,15384,15385],{},"新平台设计了一套资源计费底座，任务需要经历预留（reserve）→ 执行 → 结算（settle）→ 失败时退款（refund）的流程。以小说工作流为例，每个阶段（设定、世界观、角色、分卷、大纲、章节）都是独立任务，生成前要冻结估算的算力点，生成后按实际输出字符数结算，多退少补。这套计费流程嵌入到新平台的 Billing 服务里，不能绕过。",[11,15387,15388,15389,781],{},"如果我直接把旧平台的任务调度逻辑搬过来，新平台的任务会跳过 reserve 这一步，造成\"已生成但余额不足无法扣费\"的问题。同样，旧平台的视频任务如果失败只是标记 failed，没有退款逻辑，新平台则需要原子地调用 ",[15,15390,15391],{},"RefundCharge",[11,15393,15394],{},"两边的任务模型在原语层面就不兼容。",[31,15396,15397],{"id":15397},"存储路径与对象约定",[11,15399,15400,15401,15404],{},"旧平台用本地 JSON 文件存储小说项目数据。小说作品、设定、世界观、角色这些结构化阶段的原始输出被序列化到磁盘上，依赖一套约定好的目录结构。漫剧项目也类似，源项目中通过 ",[15,15402,15403],{},"featureDir(userId, 'comic')"," 这样的函数来确定资产文件的存储根路径。",[11,15406,15407,15408,15411,15412,15415,15416,15419],{},"新平台统一使用 S3 \u002F OOS 对象存储加 Prisma 数据库。项目的元数据（作品标题、阶段状态、版本历史）存 Prisma 表，生成的内容（图片、视频、结构化文本）存对象存储并记录 key。例如小说的 ",[15,15409,15410],{},"NovelSection"," 表存储化后的 JSON 结构和展示文本，章节正文存在 ",[15,15413,15414],{},"NovelChapterVersion"," 表的 ",[15,15417,15418],{},"content"," 字段。",[11,15421,15422],{},"如果我从旧平台直接挪过来一套\"读本地 JSON\"的逻辑，新平台就要维护两套存储系统。更重要的是，新平台的计费系统依赖持久化的结构化数据——只有把规范化后的展示文本存进表里，后台才能追溯某次扣费对应的具体内容。",[31,15424,15425],{"id":15425},"计费模型与资源定价",[11,15427,15428,15429,56,15432,15435,15436,13067,15438,15441],{},"旧平台对小说的计费可能是按生成 token 次数、按模型调用、或者干脆不计费。新平台设计了一套通用资源计费框架：每个资源有一个 key（例如 ",[15,15430,15431],{},"image_generation",[15,15433,15434],{},"novel_text_output","），配套一个定价模型（",[15,15437,5034],{},[15,15439,15440],{},"PER_UNIT","），后台可以随时调价。",[11,15443,15444],{},"对小说工作流，新平台的选择是按可见字符数计费，不按 token。这意味着：",[123,15446,15447,15450,15457],{},[126,15448,15449],{},"用户最终看到的文本去掉空白后的字符数才会被计入。",[126,15451,15452,15453,15456],{},"模型原始输出如果是 JSON 格式，那么 ",[15,15454,15455],{},"{}"," 括号、字段名、引号、逗号、缩进都不算——只计算最终展示的内容。",[126,15458,15459,15460,15463],{},"每个阶段的计费单位是 ",[15,15461,15462],{},"算力点 \u002F 千字","，后台可配置，用户无感知。",[11,15465,15466],{},"从旧平台的某种计费方式切换到这套新模型，需要重新梳理每个环节的计费触发点。如果只是把旧的生成函数搬过来调用一遍，新平台的 Billing 根本没有机会介入。",[26,15468,15469],{"id":15469},"迁移方案的抽象层次",[11,15471,15472,15473,56,15476,56,15479,15482],{},"面对这三处耦合，如果采取\"完全抽象\"的思路——把工作流定义成一个通用的步骤编排引擎，每个平台只需要实现 ",[15,15474,15475],{},"execute_step",[15,15477,15478],{},"store_result",[15,15480,15481],{},"charge_resource"," 这样的抽象接口——理论上很优雅，但代价是引入了一层不必要的复杂性。通用引擎要支持各种平台的差异，势必要留下很多可配置项，导致理解和维护难度上升。",[11,15484,15485],{},"实际采取的方案是有选择的复用：",[11,15487,15488,15491],{},[488,15489,15490],{},"复用稳定部分","：工作流的步骤编排和领域逻辑。小说工作流的七个阶段顺序（设定 → 宏观 → 世界观 → 角色 → 分卷 → 拆章 → 正文）是领域知识，与平台无关。这套提示词组装、JSON 解析、规范化、展示文本提取的逻辑，从旧平台完整迁移到新平台，TypeScript 化但不改核心算法。漫剧工作流的四阶段（剧本 → 资产 → 分镜 → 成片）也是如此。",[11,15493,15494,15497,15498,56,15501,56,15503,56,15506,15509,15510,15513,15514,15517],{},[488,15495,15496],{},"重新实现易变部分","：任务调度、存储、计费。小说模块新增 ",[15,15499,15500],{},"NovelProject",[15,15502,15410],{},[15,15504,15505],{},"NovelChapter",[15,15507,15508],{},"NovelTask"," 等 Prisma 模型，完全按新平台的设计来。生成任务在开始前调用 ",[15,15511,15512],{},"billing.reserveResource()","，成功后提取展示文本、调用 ",[15,15515,15516],{},"billing.settleResource()","，失败时退款。这套接口与 Image Generation 任务的流程完全一致，在平台已有的基础上建造。",[11,15519,15520,15521,15524,15525,15528],{},"具体的模型调用、LLM 的参数、生成的超时策略这些\"具体模型调用\"的细节，留在各自的适配层。例如小说模块的 ",[15,15522,15523],{},"novel-generation.ts"," 只负责组装提示词、调用 LLM、解析结果，不涉及数据库操作；数据库操作交给 ",[15,15526,15527],{},"novel-service.ts","。这样领域逻辑与平台逻辑的边界清晰，后续不同平台可以并行维护。",[26,15530,15531],{"id":15531},"关键的取舍决定",[11,15533,15534],{},"完全抽象的诱惑在于\"一套代码支持多平台\"的承诺。但这需要引入足够的可配置性和接口设计，而代价是灵活性反而下降——当某个平台需要特殊处理某个步骤时，通用引擎不得不打补丁。这在两个平台差异较大的情况下尤其成立。",[11,15536,15537],{},"选择有针对性的复用，意味着承认重复：数据模型要各写一份，任务调度逻辑要各写一份。但这个重复是可控的，因为它们在各自平台内部是一致的。小说模块的任务预留\u002F结算逻辑完全复用 Image Generation 已有的那套，不需要新增抽象层。",[11,15539,15540,15541,15544],{},"另一个关键的取舍是用户模型的配置。旧平台可能让用户选择小说生成用哪个模型，新平台则完全隐藏模型选择，由后台配置或环境变量决定。这简化了前端和 API 的设计——用户请求体里根本不接受 ",[15,15542,15543],{},"model"," 字段，Billing 也不需要按模型定价。代价是失去了\"用户自行选择成本与质量的平衡\"的灵活性，但换来了计费模型的清晰和后台的可控。",[11,15546,15547],{},"这类决定需要在迁移前明确：哪些能力是目标平台\"必须有\"的，哪些是\"很有但可以先不做\"的。漫剧工作流迁移时明确排除了白模（3D 白模式）视频相关的所有功能，不复制 Blender 依赖、不暴露白模提示词输入、成片阶段只处理普通视频。这个决定减少了迁移的复杂性，也避免了在新平台上重新部署 Blender 和白模渲染的基础设施。",[26,15549,15550],{"id":15550},"稳定部分与易变部分的边界",[11,15552,15553],{},"这个分法的关键在于，稳定部分要确实稳定。领域逻辑（各阶段的提示词、章节的上下文组装、长篇的记忆管理）在两个平台上是一样的，因为它反映的是小说创作或漫剧创作的规律。但是，一旦涉及\"这个阶段的输入从哪里读、输出存到哪里、失败后怎么处理\"，就已经是平台相关的。",[11,15555,15556],{},"以小说的世界观生成为例：",[11,15558,15559,15560,15563],{},"稳定部分是 ",[15,15561,15562],{},"worldPrompt(previousSections, projectSettings)"," 这个函数，它组装 LLM 提示词，描述\"根据设定和前序阶段的输出，生成世界观\"。这个函数与平台无关。",[11,15565,15566],{},"易变部分是：",[123,15568,15569,15576,15579,15589],{},[126,15570,15571,15572,15575],{},"生成前如何预留算力点（新平台调 ",[15,15573,15574],{},"billing.reserveResource","，旧平台可能不调）",[126,15577,15578],{},"生成结果是一个 JSON 对象，如何从中提取展示文本（两个平台的数据模型可能不同，但提取逻辑应该是一样的——属于稳定部分）",[126,15580,15581,15582,15585,15586,13615],{},"提取后的展示文本存到哪里（新平台的 ",[15,15583,15584],{},"NovelSection.displayText"," 字段，旧平台可能是本地文件 ",[15,15587,15588],{},"sections\u002Fworld.json",[126,15590,15591,15592,15595],{},"失败时如何处理（新平台调 ",[15,15593,15594],{},"billing.refundResource","，旧平台可能是清除临时文件）",[11,15597,15598,15599,56,15602,15604,15605,56,15607,15610],{},"这样划分后，新平台只需要在适配层（",[15,15600,15601],{},"novel-routes.ts",[15,15603,15527],{},"）重新实现存储和计费部分，核心生成逻辑（",[15,15606,15523],{},[15,15608,15609],{},"novel-prompts.ts","）从旧平台迁移过来。",[26,15612,15613],{"id":15613},"实际的迁移清单",[11,15615,15616],{},"从这个分析，迁移的具体工作清单变成：",[243,15618,15619,15625,15631,15637],{},[126,15620,15621,15624],{},[488,15622,15623],{},"评估可复用的代码"," — 在旧平台找出真正与平台无关的部分。对小说工作流，这包括提示词、JSON 解析、规范化逻辑。对漫剧工作流，这包括分镜拆分的逻辑、资产提取的规则。",[126,15626,15627,15630],{},[488,15628,15629],{},"定义新平台的合同"," — 每个工作流步骤的输入输出是什么，资源消耗如何计量。小说阶段的输出是规范化 JSON 加展示文本，消耗的资源是可见字符数。漫剧镜头的输出是镜头配置和首帧图片，消耗的资源是视频秒数（按模型和分辨率估算）。",[126,15632,15633,15636],{},[488,15634,15635],{},"实现平台适配层"," — 数据模型（Prisma 表）、任务调度（与 Billing 集成）、错误处理和重试。这部分代码是新平台特有的，不能也不应该复用。",[126,15638,15639,15642],{},[488,15640,15641],{},"测试边界"," — 在适配层写测试，验证计费逻辑、任务状态机、错误恢复。在稳定部分写测试，验证生成质量、规范化正确性。",[11,15644,15645],{},"不采用这样的分层，而是直接把旧平台的 Next.js 路由、本地文件操作、命令式 API 端点搬到新平台，结果是新平台沦为旧平台代码的容器。后续需要升级某个依赖、调整计费模型、或对接新的生成模型时，都会发现改一个地方影响到另一个地方。",[11,15647,15648],{},"设置清晰的边界，允许有选择的重复，是避免这个问题的方法。",{"title":183,"searchDepth":184,"depth":184,"links":15650},[15651,15656,15657,15658,15659],{"id":15372,"depth":184,"text":15372,"children":15652},[15653,15654,15655],{"id":15375,"depth":189,"text":15375},{"id":15397,"depth":189,"text":15397},{"id":15425,"depth":189,"text":15425},{"id":15469,"depth":184,"text":15469},{"id":15531,"depth":184,"text":15531},{"id":15550,"depth":184,"text":15550},{"id":15613,"depth":184,"text":15613},"2026-07-02",{},"\u002F2026-07-02",{"title":15361,"description":15366},"2026-07-02-工作流迁移与复用","两条生产工作流从旧平台迁移到新平台，难点不在业务逻辑重写，而在解耦三处平台依赖——任务调度、存储约定、计费接口。",[15667,4945,13566,15668,13565,15669],"架构","平台化","计费系统","zEJUEqZgsQkHJOxHWOoc4WL1iYD_NaAli4VZYCye-ZY",{"id":15672,"title":15673,"body":15674,"column":1966,"date":15974,"description":15678,"extension":199,"hero_image":200,"meta":15975,"navigation":202,"path":15976,"seo":15977,"series_id":200,"severity":200,"stem":15978,"summary":15979,"tags":15980,"__hash__":15986},"posts\u002F2026-06-29-记忆星系长期记忆可视化工作台.md","模型记错了，用户得能删掉",{"type":8,"value":15675,"toc":15964},[15676,15679,15682,15686,15692,15695,15698,15701,15704,15711,15714,15717,15749,15752,15755,15821,15828,15835,15838,15841,15883,15886,15889,15893,15896,15899,15902,15905,15908,15911,15914,15917,15920,15927,15933,15936,15939,15946,15949,15952,15955,15958,15961],[11,15677,15678],{},"长期记忆存起来容易，用户看不见也管不了。记忆一旦不可见，就会累积错误信息并持续污染后续对话——模型出错时没有纠正入口，错误就永久留存。",[11,15680,15681],{},"我在 yun-claude 的记忆设计中遇到的问题正是这个。聊天系统能自动从对话抽取持久要点并入库，但页面还是传统的卡片列表，看不出记忆的类型、重要性、使用频次。更严重的是，用户无法编辑或删除那些被错误标记的记忆。所以这次升级的核心不是加一个炫彩的可视化，而是把记忆管理变成一个真正可用的信息工作台。",[26,15683,15685],{"id":15684},"表格优先而不是星河优先","表格优先，而不是星河优先",[11,15687,15688,15689,781],{},"设计的第一个决策是：",[488,15690,15691],{},"主界面用表格承载记忆列表，不用星河画布作主体交互",[11,15693,15694],{},"这听起来反直觉。当初考虑过让星河画布成为核心——节点按重要性大小分布、按创建时间环形排列、搜索命中时高亮。视觉上是漂亮的，也更有\"知识宇宙沉淀\"的产品感。但约束改变了这个选择：",[11,15696,15697],{},"第一，信息密度。表格一屏可以显示 10-20 条记忆的核心属性（标题、类型、重要性、标签、创建时间），并支持排序和筛选。星河画布要展示相同的信息量，就必须让节点变小、缩放调整、甚至分屏展示——交互成本陡升。而用户——AI agent 的主人——需要快速浏览和定位记忆，不是浏览艺术装置。",[11,15699,15700],{},"第二，编辑成本。星河上的节点编辑通常要额外打开面板或弹窗。如果记忆管理的主要工作是\"检查是否有错记、删除重复、调整分类\"，那星河就不是最优方案。对比之下，表格 + 右侧详情面板的结构让用户可以同时看到列表和正在编辑的项目，没有上下文切换。",[11,15702,15703],{},"第三，用户规模。当前 yun-claude 没有真实用户，推断单个用户的记忆条数会比较少（几十到几百）。在这个规模下，表格就足够快了，不必依赖图形加速或向量索引的复杂优化。如果将来规模增大再考虑换方案。",[11,15705,15706,15707,15710],{},"所以最终设计是：",[488,15708,15709],{},"表格为主工作台，右侧详情面板负责编辑与整理，星系可视化只作为辅助视图（后续可选）","。这不是缺乏视觉想象力，而是对工作流的务实权衡。代价是产品感稍弱，但可用性强得多。",[26,15712,15713],{"id":15713},"五类语义记忆与设计令牌",[11,15715,15716],{},"为了让记忆可见可管，将长期记忆分成五类：",[123,15718,15719,15725,15731,15737,15743],{},[126,15720,15721,15724],{},[488,15722,15723],{},"核心记忆","（CORE）：与用户身份、核心项目、重要约束直接相关。模型在每轮对话发送前都应该检索。",[126,15726,15727,15730],{},[488,15728,15729],{},"常驻记忆","（PERMANENT）：用户的工作背景、技术栈偏好、团队结构等长期背景。",[126,15732,15733,15736],{},[488,15734,15735],{},"临时记忆","（TEMPORARY）：短期的任务进度、当前问题、临时约束。生命周期短。",[126,15738,15739,15742],{},[488,15740,15741],{},"知识星云","（KNOWLEDGE）：用户分享的文档要点、API 文档摘录、最佳实践。主要用于 RAG 增强。",[126,15744,15745,15748],{},[488,15746,15747],{},"其他","（OTHER）：模型无法明确分类或用户手动标记的记忆。",[11,15750,15751],{},"每一类的关键差异是生命周期和召回策略。核心记忆应该高频被注入 prompt，临时记忆应该自动清理，知识类应该被 RAG 系统共同使用。",[11,15753,15754],{},"为了在视觉上强化这个分类，为每类配置了一个独立的语义色：",[1708,15756,15757,15769],{},[1711,15758,15759],{},[1714,15760,15761,15764,15767],{},[1717,15762,15763],{},"类型",[1717,15765,15766],{},"颜色",[1717,15768,3356],{},[1730,15770,15771,15782,15792,15802,15812],{},[1714,15772,15773,15776,15779],{},[1735,15774,15775],{},"核心",[1735,15777,15778],{},"Amber-500",[1735,15780,15781],{},"节点、筛选按钮、卡片左边线",[1714,15783,15784,15787,15790],{},[1735,15785,15786],{},"常驻",[1735,15788,15789],{},"Sky-500",[1735,15791,15781],{},[1714,15793,15794,15797,15800],{},[1735,15795,15796],{},"临时",[1735,15798,15799],{},"Teal-500",[1735,15801,15781],{},[1714,15803,15804,15807,15810],{},[1735,15805,15806],{},"知识",[1735,15808,15809],{},"Violet-500",[1735,15811,15781],{},[1714,15813,15814,15816,15819],{},[1735,15815,15747],{},[1735,15817,15818],{},"Slate-500",[1735,15820,15781],{},[11,15822,15823,15824,15827],{},"关键的设计决策是：",[488,15825,15826],{},"颜色不只是装饰，而是结构的一部分","。在表格、筛选条、详情面板的边线上，用户都能看到同一个颜色，强化类型认知。这样的一致性是可信的信息工作台的标志——用户一眼知道哪条记忆是核心、哪条是临时。",[11,15829,15830,15831,15834],{},"颜色值不是散落在各个组件里的魔法数字，而是集中在一个 ",[15,15832,15833],{},"memoryStyles.ts"," 文件中管理。任何需要记忆类型颜色的地方都从这里引用。这样改一个颜色时不必跨多个文件搜索替换，也不会出现同类型在不同地方显示不同色的尴尬局面。",[26,15836,15837],{"id":15837},"记忆模型与后端约束",[11,15839,15840],{},"后端为每条记忆增加了结构化字段：",[123,15842,15843,15848,15853,15859,15865,15871,15877],{},[126,15844,15845,15847],{},[15,15846,14347],{},"：节点或列表行的标题，从正文前 18 个字符生成",[126,15849,15850,15852],{},[15,15851,9814],{},"：五类之一",[126,15854,15855,15858],{},[15,15856,15857],{},"importance","：1-100 的重要性分值，决定节点大小和列表排序",[126,15860,15861,15864],{},[15,15862,15863],{},"tags","：字符串数组，最多 8 个标签",[126,15866,15867,15870],{},[15,15868,15869],{},"lastUsedAt","：最近被召回的时间",[126,15872,15873,15876],{},[15,15874,15875],{},"usedCount","：被召回次数",[126,15878,15879,15882],{},[15,15880,15881],{},"metadata","：扩展字段，保存来源会话、模型、抽取动作等",[11,15884,15885],{},"当模型抽取记忆时，输出包含这些结构化字段。服务端必须做兜底和校验：type 非法时改为 OTHER、importance 超范围时裁剪、tags 去重去空白最多保留 8 个。如果抽取失败，仍然保存正文并用默认字段（importance=50, type=OTHER）。",[11,15887,15888],{},"这些默认值的设计是为了降级优雅。即便结构化抽取失败，记忆也不会丢失，只是分类不精准——这是可以接受的，因为用户随后可以手动调整。",[26,15890,15892],{"id":15891},"搜索筛选与编辑","搜索、筛选与编辑",[11,15894,15895],{},"用户在表格上方有一个搜索框和类型筛选条。搜索时调用后端的语义搜索接口，命中的记忆在表格中高亮，并自动选中最高相关性的一条，打开右侧详情面板。语义搜索基于向量数据库的余弦相似度匹配——记忆文本被嵌入为 4096 维向量，查询时也转换为向量并与库内存储按相似度排序召回，只返回分值达到阈值（≥0.3）的结果。这样的匹配比关键词搜索精准度高，也支持语义近似的记忆关联（比如\"TypeScript 后端\"和\"TS 服务端\"会被认为相关）。搜索失败时保留当前星河状态并 toast 提示，不中断工作流。",[11,15897,15898],{},"筛选可以按五类过滤，清除筛选则回到全量视图。如果当前选中的记忆被筛选隐藏了，系统会自动选中可见记忆中最高重要性的那一条；如果没有可见记忆，则关闭详情面板显示空状态。",[11,15900,15901],{},"详情面板里，用户可以编辑标题、正文、类型、标签和重要性。编辑表单使用产品内设计，不弹浏览器原生弹窗。保存后立即更新列表和节点（如果有星河视图的话）。这样用户修改一条记忆时，不必刷新页面或等待后台同步，改动立刻可见。",[11,15903,15904],{},"删除是另一个关键能力。必须让用户能删除被错误标记的记忆，否则错误就成了永久污染源。删除按钮放在详情面板的危险操作区，点击后需确认。删除成功后，该条记忆从表格和星河中消失，列表自动选中下一条（如果有的话）。",[26,15906,15907],{"id":15907},"星河与移动端降级",[11,15909,15910],{},"虽然主界面是表格，但在设计中保留了星河作为可视化补充。桌面端在表格下方或侧边可以显示一个小星图，让用户看到记忆的空间分布——核心记忆聚在中心，临时记忆分散在外围。星河上的节点与表格关联：点击表格行时，星河也高亮对应节点；点击星河节点时，表格定位到对应行。",[11,15912,15913],{},"但星河不是强制项。第一版的实现可能不包含完整星河，而是先确保表格工作台完整可用。星河可以作为后续的增强——用户如果觉得需要可视化辅助，才加上去。",[11,15915,15916],{},"移动端设计上，星河更是不现实（屏幕太小）。所以移动端完全降级为列表视图，点击列表项后通过底部抽屉显示详情和编辑表单。搜索和类型 tabs 放在顶部，保持核心交互可用。这样既避免了表格在手机上的横向溢出，也保证了可用性。",[26,15918,15919],{"id":15919},"节点布局与稳定性",[11,15921,15922,15923,15926],{},"如果星河要实现，一个重要的设计细节是：",[488,15924,15925],{},"节点位置必须稳定","。即便刷新页面或重新打开应用，同一批记忆应该保持相同的位置，这样用户才能形成空间记忆——\"核心记忆总在中心，临时的在右上角\"。",[11,15928,15929,15930,15932],{},"这意味着节点位置不能是随机的实时布局，也不能用物理模拟（那会每次都算不同的位置）。用 ",[15,15931,8288],{}," 字段参与布局计算，保证确定性：同一条记忆的创建时间固定了，它在环形排列中的角度也就固定了。结合 importance 决定距离中心的远近，位置就完全由数据驱动，刷新后毫厘不差。",[26,15934,15935],{"id":15935},"一致性与可维护性",[11,15937,15938],{},"这个设计的关键约束是一致性。如果记忆在表格中是 Amber-500（核心），那在星河、筛选、详情面板的边线上也必须是 Amber-500。任何拆散这个一致性的修改都会破坏用户的心智模型。",[11,15940,15941,15942,15945],{},"所以在设计系统里明确了这一点：",[488,15943,15944],{},"新增或调整记忆类型视觉时，先更新设计文档里的语义色板和 memoryStyles.ts，再在各组件中引用，不允许组件各自复制色值","。这样的约束看似严苛，但它保证了长期的可维护性——下次要改颜色时，只需改一个文件。",[26,15947,15948],{"id":15948},"代价与权衡",[11,15950,15951],{},"这个设计的代价是什么？",[11,15953,15954],{},"第一，视觉冲击力比不上星河优先。表格是务实的设计，不够\"黑科技\"感。如果产品定位是\"AI 记忆系统\"要卖视觉冲击，这个方案会显得保守。",[11,15956,15957],{},"第二，星河的潜力没有完全释放。放弃了复杂的关系推理、自由漫游、力导向布局这些\"高级\"可视化特性，原因就是表格工作台不需要它们，而加上去反而添加复杂度。",[11,15959,15960],{},"第三，设计系统的维护成本提高了。一致性的要求意味着每次改动都要考虑全局影响。但这其实是长期收益——减少了 bug 和不一致的可能。",[11,15962,15963],{},"这些代价对 yun-claude 是可以接受的，因为当前用户还不多，重点是让产品可用，而不是炫技。如果将来用户规模上升，数百条甚至千条记忆的管理场景出现，表格 + 星河的混合界面可能不够，那时再考虑更激进的可视化方案。现在，信息工作台的优先级明确高于视觉体验。",{"title":183,"searchDepth":184,"depth":184,"links":15965},[15966,15967,15968,15969,15970,15971,15972,15973],{"id":15684,"depth":184,"text":15685},{"id":15713,"depth":184,"text":15713},{"id":15837,"depth":184,"text":15837},{"id":15891,"depth":184,"text":15892},{"id":15907,"depth":184,"text":15907},{"id":15919,"depth":184,"text":15919},{"id":15935,"depth":184,"text":15935},{"id":15948,"depth":184,"text":15948},"2026-06-29",{},"\u002F2026-06-29",{"title":15673,"description":15678},"2026-06-29-记忆星系长期记忆可视化工作台","表格优先的记忆管理：用高信息密度工作台承载持久要点，可视化只作辅助，设计令牌贯穿全局。",[15981,15982,15983,15984,15985],"长期记忆","信息工作台","设计决策","语义搜索","设计令牌","gsJKxlNQ-FiADsw2ca6e5WJo-_uyYDFZ55iuiMr4jQE",{"id":15988,"title":15989,"body":15990,"column":1966,"date":16456,"description":15994,"extension":199,"hero_image":200,"meta":16457,"navigation":202,"path":16458,"seo":16459,"series_id":200,"severity":200,"stem":16460,"summary":16461,"tags":16462,"__hash__":16466},"posts\u002F2026-06-27-多桶钱包与资源计价.md","「赠送额度不能提现」——这句话得写进数据结构",{"type":8,"value":15991,"toc":16448},[15992,15995,15998,16009,16015,16018,16037,16040,16047,16053,16060,16085,16092,16098,16112,16115,16122,16131,16169,16179,16184,16215,16220,16253,16259,16262,16269,16283,16294,16297,16300,16303,16317,16332,16335,16341,16364,16375,16412,16421,16424,16431,16442,16445],[11,15993,15994],{},"平台上要计费的资源有完全不同的\"形状\"：文本对话按 token、网络搜索按次、OCR 按页数、视频按时长。用一个\"积分余额\"和一套计费逻辑没法准确体现这些成本，更没法承载营销活动。这篇讲两项基础改造：把钱包从单一永久池重做为多个桶（带有效期和可用范围），以及把计价从隐式约定改成后台可配的资源价表。",[26,15996,15997],{"id":15997},"单一余额的局限性",[11,15999,16000,16001,16004,16005,16008],{},"现有账户模型是这样的：每个用户一条 ",[15,16002,16003],{},"Account"," 记录，字段就一个 ",[15,16006,16007],{},"Balance","。所有充值、赠送、活动都加到这个数字上。问题在于这个模型无法表达——也无法强制——不同金钱的用途限制和有效期。",[11,16010,16011,16012,16014],{},"比如月卡产品要求：每月发放 1000 点，有效期 30 天，到期清零。如果这 1000 点也落在 ",[15,16013,16007],{}," 里，那么\"到期清零\"这个约束就只能靠业务代码到处判断：扣费时检查来源是否过期、统计报表时提醒用户余额即将失效。一旦业务代码有一处漏判或遗忘，就会有用户在余额本该清零的那一刻发现账户还能扣费，或者收不到过期提醒。",[11,16016,16017],{},"类似的规则还有：赠送额度不能提现（充值时不计入），活动额度只能用于特定功能（订阅用户才能用），订阅档位要按周期重置。这些规则堆到业务代码里，复杂度爆炸。",[11,16019,16020,16021,16024,16025,16028,16029,16032,16033,16036],{},"真正的解决方案是把规则",[488,16022,16023],{},"落在数据结构","上：改用\"多桶钱包\"。每一笔发放都是一个桶，带自己的 ",[15,16026,16027],{},"Remaining","（剩余点数）和 ",[15,16030,16031],{},"ExpiresAt","（过期时间），以及预留的 ",[15,16034,16035],{},"AllowedModels","（可用模型白名单，先不启用）。余额变成了多个桶的求和，扣费时按优先级顺序消耗——最早过期的先扣，确保快要失效的点不会被浪费。",[26,16038,16039],{"id":16039},"多桶钱包的设计",[11,16041,16042,16043,16046],{},"核心数据结构是一张 ",[15,16044,16045],{},"PointBucket"," 表：",[399,16048,16051],{"className":16049,"code":16050,"language":1327},[1325],"ID          | UserID | Remaining | ExpiresAt      | Source       | AllowedModels\n-----------|--------|-----------|----------------|--------------|---------------\n1          | user-a | 500       | NULL           | topup        | (空，不限)\n2          | user-a | 100       | 2026-07-27     | membership   | (空)\n3          | user-a | 50        | NULL           | redeem       | (空)\n",[15,16052,16050],{"__ignoreMap":183},[11,16054,16055,16056,16059],{},"User-a 的总余额就是 ",[15,16057,16058],{},"500 + 100 + 50 = 650"," 点。当他发起一个消耗 200 点的回合时：",[243,16061,16062,16072,16075,16078],{},[126,16063,16064,16065,16068,16069,16071],{},"锁定该用户存活的桶（",[15,16066,16067],{},"ExpiresAt IS NULL OR ExpiresAt > now()","），按 ",[15,16070,16031],{}," 升序排列",[126,16073,16074],{},"第 2 行那个 100 点（7 天后过期）会先被消耗，变成 0；剩余还需 100 点",[126,16076,16077],{},"第 1 行那个 500 点（永久）消耗 100，变成 400",[126,16079,16080,16081,16084],{},"记录这次扣费从哪些桶各扣了多少，存在 ",[15,16082,16083],{},"UsageRecord.Allocations"," 里",[11,16086,16087,16088,16091],{},"为什么要记录分配？因为结算时可能多退：模型只发出了 150 个 token，实际扣费少于预期 200 点。退费时要按",[488,16089,16090],{},"逆序","原路退回——最后扣的（永久的）先退，保证临时点不会被复活。而且如果某个原桶已经过期了，那笔退款就跳过（点本就该没了，不用还）。",[11,16093,16094,16095,16097],{},"每个桶本身没有\"花费\"的概念，只有\"剩余\"。所有的花费都是透过 ",[15,16096,5091],{}," 记录的。这样的好处是：",[123,16099,16100,16103,16106,16109],{},[126,16101,16102],{},"多退少补的逻辑精确而无歧义",[126,16104,16105],{},"每笔支出都能精确审计追溯到来源桶",[126,16107,16108],{},"临时点和永久点永远不串味",[126,16110,16111],{},"防止了\"用完临时点还能从永久点倒流\"这类漏洞",[26,16113,16114],{"id":16114},"发放与消费的原语",[11,16116,16117,16118,16121],{},"平台所有入账路径（充值、兑换、活动赠送、订阅月度发放）都调同一个 ",[15,16119,16120],{},"GrantPoints"," 函数：",[399,16123,16125],{"className":4981,"code":16124,"language":4983,"meta":183,"style":183},"GrantPoints(tx, userID, amount, expiresAt, source)\n",[15,16126,16127],{"__ignoreMap":183},[407,16128,16129],{"class":409,"line":410},[407,16130,16124],{},[123,16132,16133,16143,16149],{},[126,16134,16135,16138,16139,16142],{},[15,16136,16137],{},"amount > 0"," 才会建桶；",[15,16140,16141],{},"amount \u003C= 0"," 无效",[126,16144,16145,16148],{},[15,16146,16147],{},"expiresAt = nil"," 表示永久（所有现有充值、赠送都是永久）",[126,16150,16151,16154,16155,56,16158,56,16161,16164,16165,16168],{},[15,16152,16153],{},"source"," 标记来源：",[15,16156,16157],{},"topup",[15,16159,16160],{},"redeem",[15,16162,16163],{},"adjust","（管理员调整）、",[15,16166,16167],{},"membership","（订阅）",[11,16170,16171,16172,88,16175,16178],{},"消费侧是 ",[15,16173,16174],{},"Reserve",[15,16176,16177],{},"Settle"," 的一对原语：",[11,16180,16181,16183],{},[488,16182,16174],{}," 是预扣。给定一个操作 ID、用户、估计消费点数，它会：",[243,16185,16186,16192,16195,16209],{},[126,16187,16188,16189,13615],{},"查存活桶、按最早过期先排序、加行级锁（",[15,16190,16191],{},"FOR UPDATE",[126,16193,16194],{},"贪心从前往后扣，直到凑满估计值或余额不足",[126,16196,16197,16198,16200,16201,16204,16205,16208],{},"写 ",[15,16199,5091],{}," 记录 ",[15,16202,16203],{},"status=reserved","，包含 ",[15,16206,16207],{},"Allocations"," 分配",[126,16210,16211,16212],{},"余额不足则报错，",[488,16213,16214],{},"整个事务回滚、不写记录、不建空桶",[11,16216,16217,16219],{},[488,16218,16177],{}," 是结算。给定操作 ID 和真实消费，它会：",[243,16221,16222,16225,16231,16241,16247],{},[126,16223,16224],{},"幂等检查：如果记录已经 settled 或 refunded，直接返回",[126,16226,16227,16228],{},"计算 ",[15,16229,16230],{},"delta = actual - reserved",[126,16232,16233,16234,16237,16238,16240],{},"如果 ",[15,16235,16236],{},"delta \u003C 0","（多退），按 ",[15,16239,16207],{}," 逆序精确退回原桶",[126,16242,16233,16243,16246],{},[15,16244,16245],{},"delta > 0","（少补），尽力从存活桶再扣；不足仍结算（不卡死）",[126,16248,16249,16250],{},"标记记录为 ",[15,16251,16252],{},"settled",[11,16254,16255,16256,16258],{},"为什么 settle 时\"少补不足仍结算\"？因为 reserve 已经用保守估计（input + maxOutputTokens）去预扣了，",[15,16257,16245],{}," 的缺口通常微小。而且结算不能卡死——已经发生的消费是既成事实，不因为后续余额不足而回滚。",[26,16260,16261],{"id":16261},"并发与幂等的保证",[11,16263,16264,16265,16268],{},"同一用户的并发请求怎么防止双扣？方案很简单：",[488,16266,16267],{},"事务内 FOR UPDATE 锁定存活桶","。Postgres 会按行级锁串行化对同一用户的并发扣费。",[11,16270,16271,16272,16275,16276,16278,16279,16282],{},"幂等性靠 ",[15,16273,16274],{},"OperationID","（对话回合 ID）作主键。同一操作重试 reserve，会查到已存的 ",[15,16277,5091],{},"，直接返回其 ",[15,16280,16281],{},"ReservedPoints","，不重复扣。Settle 同理——已结的记录不再改。",[11,16284,16285,16286,16289,16290,16293],{},"过期的预留怎么处理？有一个后台对账任务，每日扫一遍超过 10 分钟还没 settle 的 ",[15,16287,16288],{},"reserved"," 记录，调 ",[15,16291,16292],{},"Settle(opID, 0)"," 让它全额退回原桶。这个任务即使失败也无碍——正确性不依赖它，它只是账务清理。",[26,16295,16296],{"id":16296},"资源计价的统一框架",[11,16298,16299],{},"现在回到另一个问题：不同资源的单位不一样。对话要精确到 token 计数，网络搜索只能按次（一次搜索多少点），OCR 要按页数。长期来看，还会有 embedding 按千 token、视频按秒等等。",[11,16301,16302],{},"如果每个功能都自己定义一套计费规则，就会出现：",[123,16304,16305,16308,16311,16314],{},[126,16306,16307],{},"有些用\"PriceRule + token 累加\"的复杂方式（对话双倍率）",[126,16309,16310],{},"有些用\"固定点数\"（网搜 10 点\u002F次）",[126,16312,16313],{},"有些用\"浮点折算\"（embedding 每 1000 token 多少点）",[126,16315,16316],{},"改价时无法审计，历史账单对不上",[11,16318,16319,16320,16323,16324,16327,16328,16331],{},"正确的做法是建一个",[488,16321,16322],{},"通用资源价表"," ",[15,16325,16326],{},"ResourcePrice","，所有资源都必须先配价，消费时统一调 ",[15,16329,16330],{},"Charge()"," 接口。",[11,16333,16334],{},"表的结构是这样的：",[399,16336,16339],{"className":16337,"code":16338,"language":1327},[1325],"ResourceKey  | DisplayName  | PricingType | Rate | PerUnits\n-------------|--------------|-------------|------|----------\nwebsearch    | 网络搜索     | PER_CALL    | 10   | 1\nocr          | OCR识别      | PER_UNIT    | 5    | 1\nembedding    | 文本嵌入     | PER_UNIT    | 0.2  | 1000\nvideo.gen    | 视频生成     | PER_UNIT    | 100  | 60\n",[15,16340,16338],{"__ignoreMap":183},[123,16342,16343,16352],{},[126,16344,16345,16347,16348,16351],{},[15,16346,5034],{},"：每次调用固定点数。比如网搜一次 10 点，参数 ",[15,16349,16350],{},"units"," 无关",[126,16353,16354,16356,16357,16360,16361,13615],{},[15,16355,15440],{},"：按单位线性计费。比如 OCR 每页 5 点（",[15,16358,16359],{},"perUnits=1","），embedding 每 1000 token 0.2 点（",[15,16362,16363],{},"perUnits=1000",[11,16365,16366,16367,16370,16371,16374],{},"消费时调 ",[15,16368,16369],{},"Quote(resourceKey, units)"," 得到应扣点数，然后 ",[15,16372,16373],{},"Charge(opID, userID, resourceKey, units)"," 一次性扣费，包括：",[243,16376,16377,16383,16398,16405],{},[126,16378,16379,16380,16382],{},"查 ",[15,16381,16326],{},"（必须 enabled）",[126,16384,16385,16386,16388,16389,16392,16393,16388,16395],{},"按公式计费：",[15,16387,5034],{}," 时取 ",[15,16390,16391],{},"ceil(rate)","；",[15,16394,15440],{},[15,16396,16397],{},"ceil(rate * units \u002F perUnits)",[126,16399,16400,16401,16404],{},"用多桶钱包的 ",[15,16402,16403],{},"Reserve\u002FSettle"," 逻辑扣分配额",[126,16406,16197,16407,13345,16409,13615],{},[15,16408,5091],{},[15,16410,16411],{},"status=settled",[11,16413,16414,16415,16417,16418,16420],{},"这样的好处是：改价只需改 ",[15,16416,16326],{}," 里一行。所有历史的 ",[15,16419,5091],{}," 还是记着原来的实际扣点，不会被改价影响。新的消费按新价表计算。审计时能看到每笔消费用的是什么价表。",[26,16422,16423],{"id":16423},"为什么是现在",[11,16425,16426,16427,16430],{},"这两项改造之所以现在做，而不是等到实际需求出现，原因很直接：",[488,16428,16429],{},"项目未上线、没有真实用户或活钱","。这意味着：",[243,16432,16433,16436,16439],{},[126,16434,16435],{},"没有历史数据需要迁移——全量重写钱包表无成本",[126,16437,16438],{},"没有活跃用户会在改造期间遇到不一致的余额计算",[126,16440,16441],{},"如果后续发现设计有漏洞，改数据结构的成本最低",[11,16443,16444],{},"一旦上线、产生真实用户和消费记录，再想重做多桶就会牵扯数据迁移、兼容旧账户、处理部分迁移失败等等复杂性。现在这两项都是\"必须一次做对\"的基础设施，做对的成本远小于上线后被迫改的成本。",[1267,16446,16447],{},"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":183,"searchDepth":184,"depth":184,"links":16449},[16450,16451,16452,16453,16454,16455],{"id":15997,"depth":184,"text":15997},{"id":16039,"depth":184,"text":16039},{"id":16114,"depth":184,"text":16114},{"id":16261,"depth":184,"text":16261},{"id":16296,"depth":184,"text":16296},{"id":16423,"depth":184,"text":16423},"2026-06-27",{},"\u002F2026-06-27",{"title":15989,"description":15994},"2026-06-27-多桶钱包与资源计价","平台核心计费改造：余额从单一池拆成多个受限的桶，资源计价从各自为政统一为声明式框架。",[15669,16463,13114,16464,16465],"钱包设计","并发控制","PostgreSQL","zdVJJHPsW7FogLIbgtLSbU0tygzyS7NEvHwyOQXihk8",{"id":16468,"title":16469,"body":16470,"column":2727,"date":16788,"description":16474,"extension":199,"hero_image":200,"meta":16789,"navigation":202,"path":16790,"seo":16791,"series_id":200,"severity":200,"stem":16792,"summary":16793,"tags":16794,"__hash__":16801},"posts\u002F2026-06-25-长文写作的记忆分层.md","三十万字之后，AI 该记住什么？",{"type":8,"value":16471,"toc":16779},[16472,16475,16478,16481,16484,16487,16490,16493,16497,16500,16506,16514,16520,16525,16531,16536,16542,16547,16558,16561,16565,16568,16574,16579,16585,16590,16595,16600,16606,16611,16617,16622,16625,16628,16632,16635,16638,16641,16644,16647,16654,16692,16703,16714,16717,16720,16729,16738,16750,16763,16766,16773,16776],[11,16473,16474],{},"长篇小说写到三十万字后，\"记住前面写了什么\"变成最大的瓶颈。角色在第五章确立的设定，到第二十章突然对不上；伏笔埋了二十章没回收；世界观里的时间线出现逻辑洞；前面说过的势力关系后面又改了。全量注入上一章或全书到上下文里根本不可能，纯向量检索又容易因为相似度不够而漏掉关键的硬约束。",[11,16476,16477],{},"这个问题需要分层的记忆系统。根据信息的稳定性和用途把记忆分成三层：设定层、事实层、文本层。每层的存储方式和检索策略完全不同。",[26,16479,16480],{"id":16480},"为什么不能统一用向量库",[11,16482,16483],{},"一个很自然的想法是\"把所有信息存到向量库里，需要时向量检索\"。但设定信息不行。",[11,16485,16486],{},"世界规则、人物档案这类信息是硬约束，一旦因为向量相似度不够高而没被召回，就会产生直接的设定冲突。模型可能生成\"主角这章掌握了某个禁忌知识\"，但系统没召回\"世界规则里明确说这个知识是自杀性的\"，结果后续剧情完全跑偏。向量检索是概率性的，概率性检索不能承载硬约束。",[11,16488,16489],{},"事实信息（已发生的事件、角色状态变化、伏笔）可以用向量检索，因为\"不完美的匹配\"还能通过更多文本从模型推理出来。如果系统检索到\"角色在某章失去了一条胳膊\"这个事实后又检索到\"这个事件在剧情中的含义是……\"，模型有足够的上下文修复漏掉的细节。",[11,16491,16492],{},"原文（正文片段）是纯量级的数据，全量注入的成本太高，用摘要替代是更经济的做法。",[26,16494,16496],{"id":16495},"设定层结构化存储全量或定向注入","设定层：结构化存储、全量或定向注入",[11,16498,16499],{},"设定层装的是世界级的硬规则和静态档案，包括：",[11,16501,16502,16505],{},[488,16503,16504],{},"世界规则","（约 600 字压缩）",[123,16507,16508,16511],{},[126,16509,16510],{},"现实是否稳定、超常能力是否公开、死亡是否可逆、信息获取是否受限",[126,16512,16513],{},"这个世界\"允许什么、不允许什么\"",[11,16515,16516,16519],{},[488,16517,16518],{},"系统性规则","（通常 4-6 条）",[123,16521,16522],{},[126,16523,16524],{},"力量体系的上限、交易与代价的原理、禁忌知识的危害方式",[11,16526,16527,16530],{},[488,16528,16529],{},"主要势力","（通常 3-5 个）",[123,16532,16533],{},[126,16534,16535],{},"每个势力的名称、目标、常用手段、与其他势力的关系",[11,16537,16538,16541],{},[488,16539,16540],{},"核心人物档案","（通常 4-8 个）",[123,16543,16544],{},[126,16545,16546],{},"每个人物的身份、核心目标、掌握的信息、心理底线",[11,16548,16549,16550,16553,16554,16557],{},"这些东西在整部小说中是不变的（或变化极缓），写作的任何环节都需要遵循它们。设定层必须在调用 AI 生成章节时",[488,16551,16552],{},"全量注入","或",[488,16555,16556],{},"按需定向注入","。全量注入适合小说世界相对简洁的情况；如果世界复杂设定众多，可以做定向注入——比如这一章涉及势力 A，就只注入 A 的档案和相关规则。",[11,16559,16560],{},"关键是：设定层的记忆片段永远不能因为上下文窗口压力而被省略。这是整个系统的天花板。",[26,16562,16564],{"id":16563},"事实层向量检索-时间过滤","事实层：向量检索 + 时间过滤",[11,16566,16567],{},"事实层装的是已经发生的、会影响后续剧情的信息。",[11,16569,16570,16573],{},[488,16571,16572],{},"章节摘要","（每章一句话）",[123,16575,16576],{},[126,16577,16578],{},"\"主角从势力 B 得到了关键物品 X，但同时被势力 A 注意到了\"",[11,16580,16581,16584],{},[488,16582,16583],{},"角色动态状态","（追加式、非覆盖）",[123,16586,16587],{},[126,16588,16589],{},"某角色掌握了什么新信息、失去了什么能力、和某人的关系发生了什么变化",[11,16591,16592],{},[488,16593,16594],{},"伏笔记录",[123,16596,16597],{},[126,16598,16599],{},"埋下的伏笔及其状态（待回收 \u002F 已回收）、涉及的章号",[11,16601,16602,16605],{},[488,16603,16604],{},"时间线","（关键事件及其时间距离）",[123,16607,16608],{},[126,16609,16610],{},"\"第五章后的第三天，X 事件发生\"",[11,16612,16613,16616],{},[488,16614,16615],{},"世界新设定","（剧情中确立、原设定没有的）",[123,16618,16619],{},[126,16620,16621],{},"\"这个世界原本不知道 X，但第十二章中 Y 揭露了 X\"",[11,16623,16624],{},"这一层用向量检索的原因是数据量大（几十万字的小说可能有几百条事实）且不需要完美精确。模型在知道\"五章前主角失去了左腿\"和\"十章前主角加入了某组织\"的基础上，能够合理推理出后续事件。即使系统漏掉了某条非关键事实，模型也不太会产生硬冲突。",[11,16626,16627],{},"时间过滤很重要。检索结果应该默认优先最近的事件，因为离当前章节越近的事件通常影响力越大。\"三章前角色的转折\"比\"五十章前的背景\"更应该被注入。",[26,16629,16631],{"id":16630},"文本层只取最近段落","文本层：只取最近段落",[11,16633,16634],{},"文本层就是原始的正文片段，用于衔接语气和细节。一个几十万字的小说，全部原文根本放不进上下文。",[11,16636,16637],{},"解决方案是简单的：只注入前一章的结尾（约 800 字），这足以让模型维持住文笔连贯性和情感线的延续。对于需要回顾很久之前的情节细节的场景，用事实层的摘要替代——\"第五章中，X 因为 Y 而死亡\"这一条摘要比翻出整个第五章的原文高效得多。",[11,16639,16640],{},"当然也存在\"某章需要直接引用或高度呼应某个很久以前的场景细节\"的情况。这时候文本层可以临时扩大范围，但这是特例，不是常态。",[26,16642,16643],{"id":16643},"具体的实现策略",[11,16645,16646],{},"设定层在每次生成章节前直接注入——要么全部、要么按这章涉及的范围选择。不需要任何检索逻辑，就是结构化的数据块。",[11,16648,16649,16650,16653],{},"事实层维护一个 ",[15,16651,16652],{},"memory.json"," 文件，存储：",[123,16655,16656,16662,16668,16674,16680,16686],{},[126,16657,16658,16661],{},[15,16659,16660],{},"synopsis","：全书概要，每章更新后重写，封顶 1000 字",[126,16663,16664,16667],{},[15,16665,16666],{},"chapterDigests","：逐章一句话摘要",[126,16669,16670,16673],{},[15,16671,16672],{},"characters","：角色的动态状态（与静态档案分开）",[126,16675,16676,16679],{},[15,16677,16678],{},"foreshadow","：伏笔列表，标记待回收或已回收",[126,16681,16682,16685],{},[15,16683,16684],{},"timeline","：关键事件及章号和相对时间",[126,16687,16688,16691],{},[15,16689,16690],{},"worldFacts","：剧情中新确立的设定事实",[11,16693,16694,16695,16698,16699,16702],{},"每章生成完成后，系统调用一次 AI，输入\"旧记忆 + 本章正文\"，要求输出",[488,16696,16697],{},"增量","——这一章新增了什么伏笔、角色状态如何变化、有没有新设定。然后用纯函数 ",[15,16700,16701],{},"normalizeMemory()"," 合并增量到旧记忆里：新伏笔追加、已回收伏笔置状态、角色按 name upsert（同 ID 的覆盖）、synopsis 重写、timeline 追加。",[11,16704,16705,16706,16709,16710,16713],{},"生成下一章时，",[15,16707,16708],{},"buildChapterMessages()"," 的流程变成：注入设定块 → 注入记忆块（由 ",[15,16711,16712],{},"buildMemoryContext()"," 生成） → 注入上一章结尾 → 生成新章。记忆块里包含：最近 6 章摘要、全部角色现状、最后 8 条未回收伏笔、最后 5 条时间线、最后 10 条世界新设定。这些都是硬上限，防止记忆块因为章数增多而无限膨胀。",[26,16715,16716],{"id":16716},"一致性与失败处理",[11,16718,16719],{},"这套流水线的核心难点不是单点生成质量，而是一致性和失败恢复。",[11,16721,16722,16725,16726,16728],{},[488,16723,16724],{},"一致性来自增量而不是合并","。每章记忆更新要求 AI 只输出增量，而不是整个记忆的重写。这样设计有两个好处：一是防止 AI 每次都改掉前面的内容（一种无意义的膨胀），二是增量小得多，解析 JSON 时出错的概率降低。合并逻辑交给纯函数 ",[15,16727,16701],{},"，这个函数可以单测，保证逻辑稳定。",[11,16730,16731,16734,16735,16737],{},[488,16732,16733],{},"同一章重建幂等","。由于 ",[15,16736,16666],{}," 按章号存储，相同章号的摘要会覆盖而不是追加，所以即使记忆重建时对同一章调用多次，也不会出现重复记录。角色状态的 upsert 机制也是同理。",[11,16739,16740,16743,16744,14442,16746,16749],{},[488,16741,16742],{},"记忆更新失败不影响正文","。每章正文先落盘、记忆更新在后。如果记忆更新失败（比如 AI 返回格式错误、JSON 解析异常），降级处理：用这章的 title 或 summary 作为摘要追加到 ",[15,16745,16666],{},[15,16747,16748],{},"lastChapterNo"," 照常推进，其余记忆保持不变。这样下一章的生成可以继续进行，用户不会察觉到记忆层的故障。",[11,16751,16752,16755,16756,16759,16760,16762],{},[488,16753,16754],{},"重建任务的断点续跑","。存量书如果想补全记忆，后台可以跑 ",[15,16757,16758],{},"runMemoryRebuild()"," 任务，按章序遍历，跳过 ",[15,16761,16748],{}," 以内的章（已纳入过的）。这个任务可停、可继续，防止重复扣费。",[26,16764,16765],{"id":16765},"记忆分层的边界",[11,16767,16768,16769,16772],{},"这个设计对应的问题空间是：",[488,16770,16771],{},"长篇写作中保持内容一致性和连贯性","。它解决的是系统层面的记忆管理，不覆盖人工的内容审校和设定调整。",[11,16774,16775],{},"如果作者在某个时刻决定\"我要改前面某角色的设定\"，那是人为修改设定层、然后手动落盘的过程。系统的记忆不会自动同步这种改动，因为改动涉及主观判断。类似的，如果某章生成出来的内容和记忆产生冲突（比如模型莫名其妙加了个新势力），那也需要人工介入编辑，而不是靠记忆系统自动修正。",[11,16777,16778],{},"记忆系统的职责是：提供足够的上下文约束，让模型生成时减少无意义的冲突；一旦冲突出现，系统快速降级，不中断工作流。",{"title":183,"searchDepth":184,"depth":184,"links":16780},[16781,16782,16783,16784,16785,16786,16787],{"id":16480,"depth":184,"text":16480},{"id":16495,"depth":184,"text":16496},{"id":16563,"depth":184,"text":16564},{"id":16630,"depth":184,"text":16631},{"id":16643,"depth":184,"text":16643},{"id":16716,"depth":184,"text":16716},{"id":16765,"depth":184,"text":16765},"2026-06-25",{},"\u002F2026-06-25",{"title":16469,"description":16474},"2026-06-25-长文写作的记忆分层","长篇到几十万字后，用分层记忆解决剧情断裂和设定崩坏——设定层硬约束、事实层概率检索、文本层按需取用，层级的存储与检索策略完全不同。",[16795,16796,16797,16798,16799,16800],"长篇写作","记忆管理","内容生成","AI辅助创作","向量检索","一致性维护","IK_-YIn1bPi-9X-l1KlQn3a-D0cTe45drq3MhS7m-AA",{"id":16803,"title":16804,"body":16805,"column":1966,"date":17045,"description":16809,"extension":199,"hero_image":200,"meta":17046,"navigation":202,"path":17047,"seo":17048,"series_id":200,"severity":200,"stem":17049,"summary":17050,"tags":17051,"__hash__":17056},"posts\u002F2026-06-23-Agent团队工作流分工汇合失败隔离.md","切成 N 份并行，产出反而更差",{"type":8,"value":16806,"toc":17034},[16807,16810,16813,16817,16820,16823,16843,16846,16852,16856,16859,16866,16873,16879,16883,16886,16892,16899,16903,16906,16912,16918,16925,16945,16952,16956,16959,16965,16975,16981,16984,16988,16991,16997,17000,17003,17023,17029,17031],[11,16808,16809],{},"编排引擎能协调多个 Agent 执行，但简单的并行分工往往不如单 Agent 产出。每个 Agent 的输出质量波动大、对前序结果的依赖理解不到位、最后需要某个角色费力整合——这些都是分工设计的通病。真正有效的多 Agent 协作不在并行度有多高，而在三个设计问题：一个任务该怎么切，多个输出怎么合，失败怎么隔离。",[26,16811,16812],{"id":16812},"三种分工形态",[31,16814,16816],{"id":16815},"按维度分评审类任务","按维度分：评审类任务",[11,16818,16819],{},"最典型的是代码审查、文档评审这类需要多个视角的任务。不同 Agent 各自专注一个维度，然后汇总。",[11,16821,16822],{},"代码审查为例，可以分给三个 Agent：",[123,16824,16825,16831,16837],{},[126,16826,16827,16830],{},[488,16828,16829],{},"安全 Agent","：检查输入验证、SQL 注入防护、密钥处理。",[126,16832,16833,16836],{},[488,16834,16835],{},"性能 Agent","：检查 N+1 查询、无界查询、热 key。",[126,16838,16839,16842],{},[488,16840,16841],{},"架构 Agent","：检查模块边界、耦合、错误处理完整性。",[11,16844,16845],{},"三个 Agent 各自给出意见清单，然后由主 Agent 或一个汇总角色做两件事：一是去重（同一个问题不重复列），二是优先级排序（P0 阻塞还是 P2 建议）。这时汇合规则很关键——不是简单地把三份清单拼起来，而是按某个一致的优先级框架重新整理，确保同一类问题在不同 Agent 眼里的严重程度评价一致。",[11,16847,16848,16849,781],{},"适用场景：需要多角度评估、各角度相对独立、",[488,16850,16851],{},"容忍的是审查遗漏（宁可多查一遍也不要漏掉）",[31,16853,16855],{"id":16854},"按对象分批量改造类任务","按对象分：批量改造类任务",[11,16857,16858],{},"一个大仓库有 20 个相似微服务要做改造。主 Agent 分析整体方案后，将 2 到 3 个服务分配给每个子 Agent，各自按统一的改造清单执行。比如统一升级依赖版本、改配置项名、加监控埋点。",[11,16860,16861,16862,16865],{},"分工的要点是",[488,16863,16864],{},"对象的独立性","——每个微服务的改造之间没有依赖关系，子 Agent 互相不阻塞。汇合时，主 Agent 的职责是验收每个微服务的改动是否符合标准（检查改了哪些文件、是否有遗漏、有没有多改东西）。",[11,16867,16868,16869,16872],{},"这里的风险在并发覆盖。如果两个 Agent 同时改同一个服务的同一个文件，后一个的改动会覆盖前一个——这是文件系统的天然行为。解决办法是为每个 Agent 创建一个",[488,16870,16871],{},"独立的工作副本","（worktree、文件夹、docker 卷），改完后再由主 Agent 统一合并回主分支。主 Agent 用三方合并工具（比如语言的 AST diff 或 git3way）来处理冲突。",[11,16874,16875,16876,781],{},"适用场景：对象多、改造逻辑重复、",[488,16877,16878],{},"对象之间独立、改动范围清晰",[31,16880,16882],{"id":16881},"按阶段分流水线任务","按阶段分：流水线任务",[11,16884,16885],{},"任务有明确的前后依赖。比如一个大文档的内容写作、技术校对、编辑润色、排版，这四个阶段必须顺序执行。或者编译系统的代码分析、验证、代码生成、打包，也是顺序的。",[11,16887,16888,16889,16891],{},"这时分工的核心是",[488,16890,8701],{},"。不同的 Agent 对应不同的状态，前一个 Agent 的输出成为后一个 Agent 的输入。状态机保证流转的合法性——不能跳过某个阶段也不能倒退（除非显式回流）。",[11,16893,16894,16895,16898],{},"朝廷的\"三省六部\"模型就是这种形态。任务经历太子分诊→中书规划→门下审议→尚书派发→六部执行→复审→完成。每个阶段由一个 Agent 负责，前一个阶段的决策记在进度日志里，后续 Agent 可以看到。门下在审议时如果发现中书规划有问题，不是直接改，而是",[488,16896,16897],{},"封驳回流","——把任务退回中书重新规划。这样做的好处是每个 Agent 的职责边界清晰，改错了地方能追溯。",[26,16900,16902],{"id":16901},"汇合设计的核心仲裁规则","汇合设计的核心：仲裁规则",[11,16904,16905],{},"多 Agent 分工的最大陷阱是汇合环节。两个常见的错误做法：",[11,16907,16908,16911],{},[488,16909,16910],{},"错误 1：没有汇合，只是拼接。"," 三个 Agent 的代码审查意见各成一份清单，就这样输出三份。这样做等于没有整合，接收方面对信息爆炸，反而降低了效率。",[11,16913,16914,16917],{},[488,16915,16916],{},"错误 2：汇合逻辑是临时的。"," 主 Agent 看了三份审查意见，用一段 prompt 说\"帮我归纳一下\"，希望生成一个漂亮的总结。但每次运行的结果都不一样，因为没有明确的规则。哪些问题合并为一条，哪些要分开列？什么叫\"重要\"，重要到什么程度才要列在最前面？",[11,16919,16920,16921,16924],{},"正确做法是",[488,16922,16923],{},"明确的仲裁规则","。比如：",[123,16926,16927,16933,16939],{},[126,16928,16929,16932],{},[488,16930,16931],{},"多数投票","：如果三个维度都指出某个问题，它就是 P0。",[126,16934,16935,16938],{},[488,16936,16937],{},"优先级数组","：安全 > 性能 > 架构。安全问题永远排在前面，除非同一个问题在不同维度的严重程度不同（这时取最高级别）。",[126,16940,16941,16944],{},[488,16942,16943],{},"专门的汇总 Agent","：把三份意见作为输入，给它一个明确的 prompt：遵循优先级数组，去重，输出一个按优先级排序的单一清单。这个汇总 Agent 自己不做审查，只做聚合。",[11,16946,16947,16948,16951],{},"汇合规则要写成",[488,16949,16950],{},"可验证的、确定性的","。最好用代码表达——不是用 prompt 模糊地说\"要紧的放前面\"，而是用数据结构明确地定义\"这个 category 的所有问题得分是 500 点，那个 category 是 100 点，然后按总分排序\"。如果用 Agent 实现汇总，也要给它一个包括规则表的完整输入，而不是靠 Agent 自己理解\"什么叫重要\"。",[26,16953,16955],{"id":16954},"失败隔离防止级联故障","失败隔离：防止级联故障",[11,16957,16958],{},"按阶段分工时，失败隔离特别关键。如果中书在规划阶段卡住了（比如 token 用完、模型不稳定），后续的门下、尚书、六部都要等。这时需要明确的停滞检测和回流机制。",[11,16960,16961,16964],{},[488,16962,16963],{},"停滞检测","：监控每个阶段的时间戳。如果某个 Agent 的产出超过设定的阈值（比如 10 分钟）还没推进到下一个阶段，系统判定为\"停滞\"并发起重试。",[11,16966,16967,16970,16971,16974],{},[488,16968,16969],{},"重试策略","：不是无限重试。设一个上限，比如同一个 Agent 最多重试 2 次。如果还是失败，任务进入\"升级\"流程。升级的意思是把这个任务提交给上一级的更强模型或更全能的 Agent，尝试跳过当前卡点。如果升级也失败了，任务标记为 ",[15,16972,16973],{},"Blocked","，等待人工介入。",[11,16976,16977,16980],{},[488,16978,16979],{},"回流机制","：某些故障需要回流而不是重试。比如门下在审议时发现中书的规划有根本性缺陷（不是中书执行失败，而是逻辑本身有问题），就应该把任务退回给中书，让中书重新规划。这不是门下的工作，而是系统的状态机明确允许的一个合法的流转。",[11,16982,16983],{},"记录每一次流转的决策链：为什么这个任务从 Assigned 进入 Review，Review 时查到了什么问题，决定进入 PendingConfirm，等待确认。这样下来，一个任务的整个生命周期都是可追溯的。",[26,16985,16987],{"id":16986},"反模式共享资源竞争","反模式：共享资源竞争",[11,16989,16990],{},"按对象分工时容易出现的一个设计陷阱是多个 Agent 直接竞争修改同一个共享资源。比如多个 Agent 并行修改同一个代码库的多个文件。每个 Agent 读文件→改→写回。但如果两个 Agent 几乎同时读、再几乎同时写，后写的改动就会覆盖先写的。这不是 Agent 能力问题，而是分工没有隔离。",[11,16992,16993,16994,781],{},"解决办法：",[488,16995,16996],{},"隔离写操作，在主控层面统一合并",[11,16998,16999],{},"具体做法是给每个 Agent 独立的工作副本（可以是 git worktree、隔离的文件夹、容器卷），Agent 的改动都落在各自的副本里。改完后，由主 Agent 负责把所有副本的改动统一合并回主仓库——用三方合并工具（git 的 3way merge、AST diff 等）自动处理无冲突部分，对冲突提出明确的警告。",[11,17001,17002],{},"关键细节：",[123,17004,17005,17011,17017],{},[126,17006,17007,17010],{},[488,17008,17009],{},"每个 Agent 的改动是独立的","。即使两个 Agent 同时改，也不会互相覆盖，因为文件系统层面是隔离的。",[126,17012,17013,17016],{},[488,17014,17015],{},"合并是确定性的","。不是靠 prompt 让某个 Agent 去\"调和\"两份改动，而是用确定的合并算法。",[126,17018,17019,17022],{},[488,17020,17021],{},"可追溯","。记录哪个文件来自哪个 Agent，哪些行被改了，有没有合并冲突。故障时能指到具体改动。",[11,17024,17025,17026,781],{},"这个原理在分布式系统里很常见（COW、WAL 等），用在 Agent 编排设计上也是一样的：",[488,17027,17028],{},"隔离并发写，集中化合并",[26,17030,13089],{"id":13089},[11,17032,17033],{},"有效的多 Agent 工作流从来不是\"并行度高就行\"。关键是明确的分工形态（维度、对象、阶段）、确定性的汇合规则（不靠 prompt 模糊其辞）、以及完善的失败隔离（停滞检测、重试上限、合法的回流）。随之而来的是可追溯的决策链和低 token 成本——因为没有浪费在重复尝试或产出冲突上，每个 Agent 清楚自己该干什么。",{"title":183,"searchDepth":184,"depth":184,"links":17035},[17036,17041,17042,17043,17044],{"id":16812,"depth":184,"text":16812,"children":17037},[17038,17039,17040],{"id":16815,"depth":189,"text":16816},{"id":16854,"depth":189,"text":16855},{"id":16881,"depth":189,"text":16882},{"id":16901,"depth":184,"text":16902},{"id":16954,"depth":184,"text":16955},{"id":16986,"depth":184,"text":16987},{"id":13089,"depth":184,"text":13089},"2026-06-23",{},"\u002F2026-06-23-agent",{"title":16804,"description":16809},"2026-06-23-Agent团队工作流分工汇合失败隔离","编排引擎就位后，多 Agent 分工的关键不在并行度，而在分权、汇合规则与失败隔离。",[17052,17053,8701,17054,17055],"编排引擎","多Agent工作流","失败恢复","工作流设计","a5qRoUeBy2NiskG4ziomHLThjwypYegrkP6b4WMUalQ",{"id":17058,"title":17059,"body":17060,"column":1966,"date":18287,"description":18288,"extension":199,"hero_image":200,"meta":18289,"navigation":202,"path":18290,"seo":18291,"series_id":200,"severity":200,"stem":18292,"summary":18293,"tags":18294,"__hash__":18299},"posts\u002F2026-06-21-Ultracode多Agent编排引擎重写.md","一次扇出，就能把机器打满",{"type":8,"value":17061,"toc":18270},[17062,17068,17071,17074,17081,17088,17095,17098,17101,17107,17113,17119,17122,17129,17282,17296,17299,17302,17309,17352,17359,17362,17369,17464,17481,17498,17502,17508,17511,17528,17543,17556,17559,17562,17568,17582,17592,17595,17598,17601,17608,17611,17614,17621,17636,17643,17646,17649,17684,17698,17701,17704,17707,18179,18182,18186,18189,18195,18202,18205,18215,18218,18221,18224,18227,18234,18240,18246,18249,18264,18267],[11,17063,17064,17065,17067],{},"单 Agent 处理复杂任务时，上下文上限和职责混乱很难避免。当任务规模扩大、需要多个专职代理时，简单的顺序派发不够用——需要一个真正的执行引擎，而不是一串 ",[15,17066,6302],{}," 调用。Ultracode 的重写把这个执行引擎从写死的轮次评委模式升级为通用、分阶段、可观察的编排系统。",[26,17069,17070],{"id":17070},"为什么需要重写",[11,17072,17073],{},"原有 Ultracode 的核心逻辑是循环评委投票：单个 worker 代理做任务 → 固定的 9 个 judge 代理投票 → 收敛检测 → autoFix 落盘。这个模型有三个硬伤。",[11,17075,17076,17077,17080],{},"其一，",[488,17078,17079],{},"职责混乱","。所有代理都用同一套 prompt 模板，judge 和 worker 没有差别的实现，评委们也不知道自己要对抗什么。结果是许多时间花在重复评判已稳定的地方，而不是聚焦新引入的变化。",[11,17082,17083,17084,17087],{},"其二，",[488,17085,17086],{},"并发失控","。原架构是串行的：worker 做完 → 再起 9 个 judge。如果拆成了 10 个子任务，那就是 10×（1 + 9）= 100 个 agent 跑，没有并发上限的制约，大任务轻易触发网关限流。",[11,17089,17090,17091,17094],{},"其三，",[488,17092,17093],{},"观察盲区","。系统只在所有 judge 投票完后汇报结果。长任务跑到一半，用户看不见进展、无法判断是真的在做还是卡住了，也没办法中途调整策略。",[26,17096,17097],{"id":17097},"三个核心决策",[11,17099,17100],{},"重写围绕三个硬需求展开。",[11,17102,17103,17106],{},[488,17104,17105],{},"第一，并发必须有上限。"," 没有信号量的话，一个大的代理分派会立刻把网关打满 429，然后不得不在代码里加退避——但那是被动的、出了问题才补。正确做法是从一开始就设定并发的快慢桶：默认 10 并发（可调 5 ~ 15），队列存 100 个任务，当网关响应 429 时指数退避（100ms → 5s 重试，最多 3 次）。这样既防止了失控的并发喷发，也保证了任务不会丢失。",[11,17108,17109,17112],{},[488,17110,17111],{},"第二，失败必须能隔离。"," 一个子任务失败不应该拦截整个编排流程，但要能标记为不可用、向下游传播。比如拆解阶段有个 worker 崩了，应该照样让其他 worker 继续跑，最后的综合阶段在做决策时看到「该 worker 失败」的标记，转入降级策略。这要求每个 agent 的执行结果都被独立记录：成功、失败、超时等状态都是数据，而不是异常。",[11,17114,17115,17118],{},[488,17116,17117],{},"第三，执行过程必须可观察。"," 不是只返回最终结果，而是全程发事件：某阶段开始 → 该阶段有 N 个代理排队 → 某代理开始跑 → 它完成了（消耗多少 token、多少成本、耗时几秒）。中间可以实时看树形结构，长任务跑到一半时能判断是否要停、是否有机会加速。",[26,17120,17121],{"id":17121},"从编排计划到分阶段执行",[11,17123,17124,17125,17128],{},"重写后的架构围绕一个数据结构：",[488,17126,17127],{},"编排计划","（Plan）。",[399,17130,17132],{"className":691,"code":17131,"language":693,"meta":183,"style":183},"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",[15,17133,17134,17144,17156,17160,17164,17172,17183,17195,17199,17203,17211,17222,17232,17254,17265,17277],{"__ignoreMap":183},[407,17135,17136,17139,17142],{"class":409,"line":410},[407,17137,17138],{"class":417},"interface",[407,17140,17141],{"class":554}," Plan",[407,17143,523],{"class":413},[407,17145,17146,17149,17151,17154],{"class":409,"line":184},[407,17147,17148],{"class":718},"  phases",[407,17150,1059],{"class":417},[407,17152,17153],{"class":554}," Phase",[407,17155,4752],{"class":413},[407,17157,17158],{"class":409,"line":189},[407,17159,483],{"class":413},[407,17161,17162],{"class":409,"line":452},[407,17163,1827],{"emptyLinePlaceholder":202},[407,17165,17166,17168,17170],{"class":409,"line":458},[407,17167,17138],{"class":417},[407,17169,17153],{"class":554},[407,17171,523],{"class":413},[407,17173,17174,17177,17179,17181],{"class":409,"line":464},[407,17175,17176],{"class":718},"  title",[407,17178,1059],{"class":417},[407,17180,3035],{"class":476},[407,17182,918],{"class":413},[407,17184,17185,17188,17190,17193],{"class":409,"line":470},[407,17186,17187],{"class":718},"  agents",[407,17189,1059],{"class":417},[407,17191,17192],{"class":554}," AgentTask",[407,17194,4752],{"class":413},[407,17196,17197],{"class":409,"line":480},[407,17198,483],{"class":413},[407,17200,17201],{"class":409,"line":1477},[407,17202,1827],{"emptyLinePlaceholder":202},[407,17204,17205,17207,17209],{"class":409,"line":1483},[407,17206,17138],{"class":417},[407,17208,17192],{"class":554},[407,17210,523],{"class":413},[407,17212,17213,17216,17218,17220],{"class":409,"line":2139},[407,17214,17215],{"class":718},"  id",[407,17217,1059],{"class":417},[407,17219,3035],{"class":476},[407,17221,918],{"class":413},[407,17223,17224,17226,17228,17230],{"class":409,"line":2180},[407,17225,7389],{"class":718},[407,17227,1059],{"class":417},[407,17229,3035],{"class":476},[407,17231,918],{"class":413},[407,17233,17234,17237,17239,17242,17244,17247,17249,17252],{"class":409,"line":7546},[407,17235,17236],{"class":718},"  role",[407,17238,1059],{"class":417},[407,17240,17241],{"class":429}," 'worker'",[407,17243,3482],{"class":417},[407,17245,17246],{"class":429}," 'judge'",[407,17248,3482],{"class":417},[407,17250,17251],{"class":429}," 'synth'",[407,17253,918],{"class":413},[407,17255,17256,17259,17261,17263],{"class":409,"line":7564},[407,17257,17258],{"class":718},"  prompt",[407,17260,1059],{"class":417},[407,17262,3035],{"class":476},[407,17264,918],{"class":413},[407,17266,17268,17271,17273,17275],{"class":409,"line":17267},15,[407,17269,17270],{"class":718},"  inputsFrom",[407,17272,3702],{"class":417},[407,17274,3035],{"class":476},[407,17276,4752],{"class":413},[407,17278,17280],{"class":409,"line":17279},16,[407,17281,483],{"class":413},[11,17283,17284,17285,17288,17289,17291,17292,17295],{},"计划定义了多个阶段，每个阶段内有若干代理并行执行。key point 是 ",[15,17286,17287],{},"inputsFrom","——如果某个 judge 要对抗式检查前面三个 worker 的产出，它的 ",[15,17290,17287],{}," 就引用那三个 worker 的 id。引擎执行时会把前序输出自动拼接到 prompt 前，规则很简单：",[488,17293,17294],{},"只能引用更早阶段的输出","，否则计划非法。",[11,17297,17298],{},"最终产出规定为最后一个阶段所有代理的输出拼接。通常最后一个阶段是单个 synth（综合器），所以最终答案就是它的输出。",[26,17300,17301],{"id":17301},"执行引擎的三层保证",[11,17303,17304,17305,17308],{},"新的执行引擎 ",[15,17306,17307],{},"runPlan"," 接收这份计划和一个回调函数，逐阶段推进：",[243,17310,17311,17320,17334],{},[126,17312,17313,17316,17317,17319],{},[488,17314,17315],{},"校验阶段","：计划的 id 要唯一、",[15,17318,17287],{}," 只能指向更早阶段的存在 id、每个阶段至少有一个代理。非法计划直接返回失败，不浪费 API 调用。",[126,17321,17322,17325,17326,17329,17330,17333],{},[488,17323,17324],{},"并发执行","：每个阶段启动后，引擎用一个信号量控制并发。假设有 5 个代理要在某阶段跑，并发限是 10，那么 5 个直接入队并行。前面某个代理完成后，队列里的下一个立刻启动，永远不超过 10 个。每个代理在启动时发 ",[15,17327,17328],{},"agent_start"," 事件，完成时发 ",[15,17331,17332],{},"agent_end"," 事件（包含成功与否、消耗、耗时）。",[126,17335,17336,17339,17340,4375,17343,4375,17346,4375,17349,14432],{},[488,17337,17338],{},"护栏检查","：每启动新代理前检查三条红线——已用总成本超预算、已跑过的代理总数超上限（200）、墙钟超 4 小时。任一触发就停，返回已累计的结果和停止原因（",[15,17341,17342],{},"done",[15,17344,17345],{},"budget",[15,17347,17348],{},"max_agents",[15,17350,17351],{},"timeout",[11,17353,17354,17355,17358],{},"这样设计的好处是，即便一个代理失败，也不卡管线——该代理的 ",[15,17356,17357],{},"ok:false"," 被记录，下一代理照样启动。最后发现有人失败时，综合器可以选择降级方案（比如忽略这个 worker 的产出、或者用备选逻辑）。",[26,17360,17361],{"id":17361},"失败隔离与结果合并",[11,17363,17364,17365,17368],{},"每个代理的执行结果是一个 ",[15,17366,17367],{},"AgentRunResult"," 对象：",[399,17370,17372],{"className":691,"code":17371,"language":693,"meta":183,"style":183},"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",[15,17373,17374,17383,17394,17405,17416,17427,17438,17449,17460],{"__ignoreMap":183},[407,17375,17376,17378,17381],{"class":409,"line":410},[407,17377,17138],{"class":417},[407,17379,17380],{"class":554}," AgentRunResult",[407,17382,523],{"class":413},[407,17384,17385,17388,17390,17392],{"class":409,"line":184},[407,17386,17387],{"class":718},"  ok",[407,17389,1059],{"class":417},[407,17391,7660],{"class":476},[407,17393,918],{"class":413},[407,17395,17396,17399,17401,17403],{"class":409,"line":189},[407,17397,17398],{"class":718},"  text",[407,17400,1059],{"class":417},[407,17402,3035],{"class":476},[407,17404,918],{"class":413},[407,17406,17407,17410,17412,17414],{"class":409,"line":452},[407,17408,17409],{"class":718},"  costUsd",[407,17411,1059],{"class":417},[407,17413,5882],{"class":476},[407,17415,918],{"class":413},[407,17417,17418,17421,17423,17425],{"class":409,"line":458},[407,17419,17420],{"class":718},"  tokensIn",[407,17422,1059],{"class":417},[407,17424,5882],{"class":476},[407,17426,918],{"class":413},[407,17428,17429,17432,17434,17436],{"class":409,"line":464},[407,17430,17431],{"class":718},"  tokensOut",[407,17433,1059],{"class":417},[407,17435,5882],{"class":476},[407,17437,918],{"class":413},[407,17439,17440,17443,17445,17447],{"class":409,"line":470},[407,17441,17442],{"class":718},"  turns",[407,17444,1059],{"class":417},[407,17446,5882],{"class":476},[407,17448,918],{"class":413},[407,17450,17451,17454,17456,17458],{"class":409,"line":480},[407,17452,17453],{"class":718},"  elapsedMs",[407,17455,1059],{"class":417},[407,17457,5882],{"class":476},[407,17459,918],{"class":413},[407,17461,17462],{"class":409,"line":1477},[407,17463,483],{"class":413},[11,17465,17466,17467,17470,17471,17473,17474,17476,17477,17480],{},"引擎在 ",[15,17468,17469],{},"outputs"," 字典里按代理 id 存下所有输出文本，无论成功失败。如果某个 worker 失败了（比如网络超时、模型拒绝），它的 ",[15,17472,17357],{}," 和错误信息文本被记录；下游的 judge 如果设了 ",[15,17475,17287],{}," 引它，拿到的是「",[407,17478,17479],{},"失败","」的标记而不是代码。synth 在综合时看到这个标记，决定是否落地降级逻辑。这样避免了一个环节的失败导致后续环节无法启动的僵局。",[11,17482,17483,17484,17487,17488,4375,17491,4375,17494,17497],{},"计量在引擎层统一累加：每个代理完成后，它的 token 消耗、成本、耗时都加进全局统计。最终 ",[15,17485,17486],{},"PlanRunResult"," 返回的 ",[15,17489,17490],{},"totalCostUsd",[15,17492,17493],{},"totalTokensIn",[15,17495,17496],{},"totalTokensOut"," 是所有代理的总和，这个数字在实时树的统计行里显示。用户能看到这一轮 Ultracode 到底花了多少钱、消耗了多少 token。",[26,17499,17501],{"id":17500},"ai-编排器从任务到计划","AI 编排器：从任务到计划",[11,17503,17504,17505,781],{},"光有执行引擎不够，还需要一个组件把用户的任务自动转成合法的计划——这就是 ",[488,17506,17507],{},"AI 编排器",[11,17509,17510],{},"编排器是一个简单的 LLM 调用：告诉模型「我需要编排一个多代理方案解决这个任务」，模型回复一份 JSON Plan。它决定什么时候拆多阶段、什么时候加 judge、什么时候单 worker 就够了。核心要点：",[123,17512,17513,17516,17519],{},[126,17514,17515],{},"简单任务（比如\"翻译这段文本\"）→ 单 phase、单 worker，不过度工程化。",[126,17517,17518],{},"复杂任务 → 典型三阶段：Phase 1 拆解并行（多 worker）→ Phase 2 对抗验证（judge 们各自检查）→ Phase 3 综合（synth 合并）。",[126,17520,17521,17522,17524,17525,17527],{},"每个 judge 的 ",[15,17523,17287],{}," 指向前面的 worker，每个 synth 的 ",[15,17526,17287],{}," 指向全部前序输出。",[11,17529,17530,17531,17534,17535,17538,17539,17542],{},"Role 在执行时有默认映射。",[15,17532,17533],{},"worker"," 代理给全工具、maxTurns 设 16，让它有充分的时间思考和调用工具。",[15,17536,17537],{},"judge"," 代理不给文件工具（纯推理，避免在主目录空转浪费时间），maxTurns 设 4 就够（只需审查、不需实现）。",[15,17540,17541],{},"synth"," 不给任何工具（纯思考与综合），maxTurns 也是 4。这个映射可以在配置里调整，但默认值经过真机验证，不浪费 token 又保证完成度。",[11,17544,17545,17546,17548,17549,17552,17553,17555],{},"解析时有三道防线。第一，如果 LLM 完全乱来（乱文本、无 JSON、字段缺失），fallback 到「单 phase 单 worker」。第二，如果 Plan 结构合法但逻辑不符（id 重复、",[15,17547,17287],{}," 引同阶段的输出、总代理数超 200），",[15,17550,17551],{},"validatePlan"," 拦下来，转 fallback。第三，编排器代理本身崩了（返回 ",[15,17554,17357],{},"），直接 fallback。永远不抛异常、永远返回一个合法 Plan——这是对聊天服务的基本承诺。",[11,17557,17558],{},"Fallback 计划取用户任务的首行作为标签（约 24 字截断），生成一个单 phase 单 worker 的方案。即便编排器完全失败，系统也能降到「让单个 worker 代理尽力而为」的状态，至少能产出一个结果。这个降级极端简单，但对容错性至关重要。",[26,17560,17561],{"id":17561},"单代理执行器的文本捕获修复",[11,17563,17564,17565,781],{},"升级执行引擎时顺手修了一个隐藏的 bug：",[488,17566,17567],{},"空文本",[11,17569,17570,17571,17574,17575,17578,17579,17581],{},"之前的架构里，SDK 的消息循环会从 ",[15,17572,17573],{},"result"," 类消息里只读成本和轮数，但丢掉了最终文本字段。在聊天里没事，因为文本通过 ",[15,17576,17577],{},"onText"," 事件流式积累。但在 Ultracode 的低 maxTurns 子代理里（比如只跑 3 轮的 judge），最终答案常常只在 SDK 返回的 ",[15,17580,17573],{}," 对象的 text 字段里，一旦被丢就变成空字符串，后续评委拿不到内容。",[11,17583,17584,17585,17588,17589,17591],{},"修法是扩展 SDK message 的消费方式：新增一个 ",[15,17586,17587],{},"finalText"," 字段（从 result 消息的 text 取出），新执行器的文本获取规则是「优先用流式累加的 assistant 文本（非空），否则用 ",[15,17590,17587],{},"」。两层保险，确保没有空文本掉落。",[26,17593,17594],{"id":17594},"实时执行树与折叠视觉",[11,17596,17597],{},"执行过程中，每个事件都被渲染成一棵树：顶部脉动蓝点 + 「Ultracode」+ 实时统计行（已用时间 · 代理数 · tokens · 成本）；下面是若干折叠的阶段分组，每个阶段显示代理数和进度圆点；展开时是一张表，每行一个代理（状态图标 + 名字 + tokens + 工具调用数 + 成本 + 耗时）。",[11,17599,17600],{},"运行中，running 的行背景高亮蓝色，queued 的行显「排队」，done 的行打 ✓，failed 的行打 ✗。进度圆点动态着色，一眼看本阶段的完成度。任务跑完后，树自动折成一行摘要（「Ultracode · 7 代理 · 142.6k tokens · $0.21」），点击展开看完整细节。",[11,17602,17603,17604,17607],{},"这一层的数据从执行引擎的事件流 → 聊天的 IPC → 前端组件。组件本地维护计时（用 ",[15,17605,17606],{},"setInterval"," 每秒更新已用时间），不依赖轮询服务端。组件卸载时清理 interval，避免泄漏。",[26,17609,17610],{"id":17610},"并发与失败的权衡",[11,17612,17613],{},"这套系统在三个坐标轴上做了权衡。",[11,17615,17616,17617,17620],{},"其一是",[488,17618,17619],{},"吞吐 vs 限流","。并发默认 10，可调到 5（保守） ~ 15（激进）。10 这个数字经验得来：足够快（百代理任务不会太慢），又不会轻易撞 429。网关如果返回限流，退避队列自动延缓重试（100ms 起，指数增长到 5 秒，最多 3 轮），不需要外层干涉。这样既防止了脉冲式的并发喷发把网关打崩，也保证了排队的任务不会丢失。",[11,17622,17623,17624,17627,17628,17631,17632,17635],{},"其二是",[488,17625,17626],{},"自治 vs 安全","。主代理可以派任意数量的子代理、动态赋予工具权限，但 ",[15,17629,17630],{},"danger-guard","（防 ",[15,17633,17634],{},"rm -rf"," 等灾难命令）对所有子代理强制生效，不可绕过、不可被赋权赋进去。这是不可协商的红线。某个 judge 即便被指示「删掉这个目录」，系统也会直接 deny，日志记录尝试，让主代理知道。",[11,17637,17638,17639,17642],{},"其三是",[488,17640,17641],{},"完美 vs 可用","。原架构追求收敛一致的答案（所有 judge 投票通过），这导致许多任务无限收敛。新架构则是分阶段推进：每个阶段内，所有并行的代理跑完后再进入下一个阶段，不需要等待它们\"一致\"。如果最后发现有代理失败，synth 在综合时看到失败标记，主动降级（比如跳过那个输入、用其他输出补充）。终止条件是「超轮数（500）\u002F 超成本 \u002F 超墙钟（240 分钟）」，保证任务总能在有限时间内完成。这三条护栏是硬边界，任一触发立刻停，无论是否\"完美\"。",[26,17644,17645],{"id":17645},"测试验证与真机硬门槛",[11,17647,17648],{},"新引擎在单元测试里用 mock executor 验证：",[123,17650,17651,17660,17666,17672,17678],{},[126,17652,17653,17656,17657,17659],{},[488,17654,17655],{},"编排计划的校验","：id 唯一性、",[15,17658,17287],{}," 只能引更早阶段、无环依赖等；非法计划直接 reject。",[126,17661,17662,17665],{},[488,17663,17664],{},"并发与信号量","：手动注入延迟的 executor，验证同时跑的代理数永不超过并发上限；队列机制正确。",[126,17667,17668,17671],{},[488,17669,17670],{},"护栏触发","：构造会超成本、超代理数、超超时的场景，验证在对的时刻停止、返回对应的 stopReason。",[126,17673,17674,17677],{},[488,17675,17676],{},"失败隔离","：某代理 ok:false，后续代理照样启动；最终输出字典里该代理的值是错误信息而不是空。",[126,17679,17680,17683],{},[488,17681,17682],{},"事件流","：逐代理验证 agent_start 和 agent_end 事件的顺序和内容。",[11,17685,17686,17687,17693,17694,17697],{},"但光有单测不够。真机验收要求：",[488,17688,17689,17690,17692],{},"手写一份单 phase 单 worker 的最小计划，喂给 ",[15,17691,17307],{}," 走真实的 SDK 调用","。断言返回的 ",[15,17695,17696],{},"finalOutput"," 非空（确认空文本 bug 已修）、事件链完整、成本与 token 数据透出。这是硬门槛——不过此测试，不算完成。",[26,17699,17700],{"id":17700},"一份具体的编排例子",[11,17702,17703],{},"假设任务是「用 TypeScript 各写 3 个独立纯函数（isPalindrome \u002F isPrime \u002F fibonacci），对抗式验证后综合为一份带测试的最终代码」。",[11,17705,17706],{},"编排器生成的计划看起来像：",[399,17708,17710],{"className":3591,"code":17709,"language":3593,"meta":183,"style":183},"{\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",[15,17711,17712,17716,17728,17736,17741,17753,17760,17806,17844,17882,17887,17892,17896,17907,17913,17962,18008,18055,18060,18065,18070,18082,18089,18157,18162,18168,18174],{"__ignoreMap":183},[407,17713,17714],{"class":409,"line":410},[407,17715,421],{"class":413},[407,17717,17718,17721,17723,17726],{"class":409,"line":184},[407,17719,17720],{"class":476},"  \"objective\"",[407,17722,3607],{"class":413},[407,17724,17725],{"class":429},"\"实现三个算法函数并通过对抗验证\"",[407,17727,3296],{"class":413},[407,17729,17730,17733],{"class":409,"line":189},[407,17731,17732],{"class":476},"  \"phases\"",[407,17734,17735],{"class":413},": [\n",[407,17737,17738],{"class":409,"line":452},[407,17739,17740],{"class":413},"    {\n",[407,17742,17743,17746,17748,17751],{"class":409,"line":458},[407,17744,17745],{"class":476},"      \"title\"",[407,17747,3607],{"class":413},[407,17749,17750],{"class":429},"\"拆解并行实现\"",[407,17752,3296],{"class":413},[407,17754,17755,17758],{"class":409,"line":464},[407,17756,17757],{"class":476},"      \"agents\"",[407,17759,17735],{"class":413},[407,17761,17762,17765,17768,17770,17773,17775,17778,17780,17783,17785,17788,17790,17793,17795,17798,17800,17803],{"class":409,"line":470},[407,17763,17764],{"class":413},"        {",[407,17766,17767],{"class":476},"\"id\"",[407,17769,3607],{"class":413},[407,17771,17772],{"class":429},"\"w1\"",[407,17774,433],{"class":413},[407,17776,17777],{"class":476},"\"label\"",[407,17779,3607],{"class":413},[407,17781,17782],{"class":429},"\"实现 isPalindrome\"",[407,17784,433],{"class":413},[407,17786,17787],{"class":476},"\"role\"",[407,17789,3607],{"class":413},[407,17791,17792],{"class":429},"\"worker\"",[407,17794,433],{"class":413},[407,17796,17797],{"class":476},"\"prompt\"",[407,17799,3607],{"class":413},[407,17801,17802],{"class":429},"\"...\"",[407,17804,17805],{"class":413},"},\n",[407,17807,17808,17810,17812,17814,17817,17819,17821,17823,17826,17828,17830,17832,17834,17836,17838,17840,17842],{"class":409,"line":480},[407,17809,17764],{"class":413},[407,17811,17767],{"class":476},[407,17813,3607],{"class":413},[407,17815,17816],{"class":429},"\"w2\"",[407,17818,433],{"class":413},[407,17820,17777],{"class":476},[407,17822,3607],{"class":413},[407,17824,17825],{"class":429},"\"实现 isPrime\"",[407,17827,433],{"class":413},[407,17829,17787],{"class":476},[407,17831,3607],{"class":413},[407,17833,17792],{"class":429},[407,17835,433],{"class":413},[407,17837,17797],{"class":476},[407,17839,3607],{"class":413},[407,17841,17802],{"class":429},[407,17843,17805],{"class":413},[407,17845,17846,17848,17850,17852,17855,17857,17859,17861,17864,17866,17868,17870,17872,17874,17876,17878,17880],{"class":409,"line":1477},[407,17847,17764],{"class":413},[407,17849,17767],{"class":476},[407,17851,3607],{"class":413},[407,17853,17854],{"class":429},"\"w3\"",[407,17856,433],{"class":413},[407,17858,17777],{"class":476},[407,17860,3607],{"class":413},[407,17862,17863],{"class":429},"\"实现 fibonacci\"",[407,17865,433],{"class":413},[407,17867,17787],{"class":476},[407,17869,3607],{"class":413},[407,17871,17792],{"class":429},[407,17873,433],{"class":413},[407,17875,17797],{"class":476},[407,17877,3607],{"class":413},[407,17879,17802],{"class":429},[407,17881,483],{"class":413},[407,17883,17884],{"class":409,"line":1483},[407,17885,17886],{"class":413},"      ]\n",[407,17888,17889],{"class":409,"line":2139},[407,17890,17891],{"class":413},"    },\n",[407,17893,17894],{"class":409,"line":2180},[407,17895,17740],{"class":413},[407,17897,17898,17900,17902,17905],{"class":409,"line":7546},[407,17899,17745],{"class":476},[407,17901,3607],{"class":413},[407,17903,17904],{"class":429},"\"对抗验证\"",[407,17906,3296],{"class":413},[407,17908,17909,17911],{"class":409,"line":7564},[407,17910,17757],{"class":476},[407,17912,17735],{"class":413},[407,17914,17915,17917,17919,17921,17924,17926,17928,17930,17933,17935,17937,17939,17942,17944,17947,17949,17951,17954,17956,17958,17960],{"class":409,"line":17267},[407,17916,17764],{"class":413},[407,17918,17767],{"class":476},[407,17920,3607],{"class":413},[407,17922,17923],{"class":429},"\"j1\"",[407,17925,433],{"class":413},[407,17927,17777],{"class":476},[407,17929,3607],{"class":413},[407,17931,17932],{"class":429},"\"检查 isPalindrome\"",[407,17934,433],{"class":413},[407,17936,17787],{"class":476},[407,17938,3607],{"class":413},[407,17940,17941],{"class":429},"\"judge\"",[407,17943,433],{"class":413},[407,17945,17946],{"class":476},"\"inputsFrom\"",[407,17948,8767],{"class":413},[407,17950,17772],{"class":429},[407,17952,17953],{"class":413},"], ",[407,17955,17797],{"class":476},[407,17957,3607],{"class":413},[407,17959,17802],{"class":429},[407,17961,17805],{"class":413},[407,17963,17964,17966,17968,17970,17973,17975,17977,17979,17982,17984,17986,17988,17990,17992,17994,17996,17998,18000,18002,18004,18006],{"class":409,"line":17279},[407,17965,17764],{"class":413},[407,17967,17767],{"class":476},[407,17969,3607],{"class":413},[407,17971,17972],{"class":429},"\"j2\"",[407,17974,433],{"class":413},[407,17976,17777],{"class":476},[407,17978,3607],{"class":413},[407,17980,17981],{"class":429},"\"检查 isPrime\"",[407,17983,433],{"class":413},[407,17985,17787],{"class":476},[407,17987,3607],{"class":413},[407,17989,17941],{"class":429},[407,17991,433],{"class":413},[407,17993,17946],{"class":476},[407,17995,8767],{"class":413},[407,17997,17816],{"class":429},[407,17999,17953],{"class":413},[407,18001,17797],{"class":476},[407,18003,3607],{"class":413},[407,18005,17802],{"class":429},[407,18007,17805],{"class":413},[407,18009,18011,18013,18015,18017,18020,18022,18024,18026,18029,18031,18033,18035,18037,18039,18041,18043,18045,18047,18049,18051,18053],{"class":409,"line":18010},17,[407,18012,17764],{"class":413},[407,18014,17767],{"class":476},[407,18016,3607],{"class":413},[407,18018,18019],{"class":429},"\"j3\"",[407,18021,433],{"class":413},[407,18023,17777],{"class":476},[407,18025,3607],{"class":413},[407,18027,18028],{"class":429},"\"检查 fibonacci\"",[407,18030,433],{"class":413},[407,18032,17787],{"class":476},[407,18034,3607],{"class":413},[407,18036,17941],{"class":429},[407,18038,433],{"class":413},[407,18040,17946],{"class":476},[407,18042,8767],{"class":413},[407,18044,17854],{"class":429},[407,18046,17953],{"class":413},[407,18048,17797],{"class":476},[407,18050,3607],{"class":413},[407,18052,17802],{"class":429},[407,18054,483],{"class":413},[407,18056,18058],{"class":409,"line":18057},18,[407,18059,17886],{"class":413},[407,18061,18063],{"class":409,"line":18062},19,[407,18064,17891],{"class":413},[407,18066,18068],{"class":409,"line":18067},20,[407,18069,17740],{"class":413},[407,18071,18073,18075,18077,18080],{"class":409,"line":18072},21,[407,18074,17745],{"class":476},[407,18076,3607],{"class":413},[407,18078,18079],{"class":429},"\"综合\"",[407,18081,3296],{"class":413},[407,18083,18085,18087],{"class":409,"line":18084},22,[407,18086,17757],{"class":476},[407,18088,17735],{"class":413},[407,18090,18092,18094,18096,18098,18101,18103,18105,18107,18110,18112,18114,18116,18119,18121,18123,18125,18127,18129,18131,18133,18135,18137,18139,18141,18143,18145,18147,18149,18151,18153,18155],{"class":409,"line":18091},23,[407,18093,17764],{"class":413},[407,18095,17767],{"class":476},[407,18097,3607],{"class":413},[407,18099,18100],{"class":429},"\"s1\"",[407,18102,433],{"class":413},[407,18104,17777],{"class":476},[407,18106,3607],{"class":413},[407,18108,18109],{"class":429},"\"整合最终代码与测试\"",[407,18111,433],{"class":413},[407,18113,17787],{"class":476},[407,18115,3607],{"class":413},[407,18117,18118],{"class":429},"\"synth\"",[407,18120,433],{"class":413},[407,18122,17946],{"class":476},[407,18124,8767],{"class":413},[407,18126,17772],{"class":429},[407,18128,433],{"class":413},[407,18130,17816],{"class":429},[407,18132,433],{"class":413},[407,18134,17854],{"class":429},[407,18136,433],{"class":413},[407,18138,17923],{"class":429},[407,18140,433],{"class":413},[407,18142,17972],{"class":429},[407,18144,433],{"class":413},[407,18146,18019],{"class":429},[407,18148,17953],{"class":413},[407,18150,17797],{"class":476},[407,18152,3607],{"class":413},[407,18154,17802],{"class":429},[407,18156,483],{"class":413},[407,18158,18160],{"class":409,"line":18159},24,[407,18161,17886],{"class":413},[407,18163,18165],{"class":409,"line":18164},25,[407,18166,18167],{"class":413},"    }\n",[407,18169,18171],{"class":409,"line":18170},26,[407,18172,18173],{"class":413},"  ]\n",[407,18175,18177],{"class":409,"line":18176},27,[407,18178,483],{"class":413},[11,18180,18181],{},"执行时：Phase 1 三个 worker 并行跑（互相独立）→ Phase 2 三个 judge 并行验证（各自消费一个 worker 的产出）→ Phase 3 synth 综合所有输入产出最终答案。如果某个 worker 失败了（比如网络超时），judge 仍继续验证其他 worker，synth 在综合时看到「w1 失败」的标记，决定采用降级方案。",[26,18183,18185],{"id":18184},"外部集成思考档位与聊天路由","外部集成：思考档位与聊天路由",[11,18187,18188],{},"在聊天层，Ultracode 和普通回复一样，是一条消息的「思考档位」。用户在 composer 选普通 \u002F 深度 \u002F Ultra（对应 normal \u002F extended \u002F ultracode），送出消息时聊天 endpoint 判断档位，如果是 Ultra 就走编排管线。",[11,18190,18191,18192,18194],{},"管线是：接收任务 → 编排器生成 Plan → 执行引擎 ",[15,18193,17307],{}," 按阶段跑 → 事件流实时推到前端树 → 完成时最后的 synth 输出成为聊天的消息内容，落库作为一条 message。整个过程对聊天是透明的——用户看到的只是一条消息，但这条消息的产生过程被完整记录了（每个 phase、每个 agent、每个事件），支持稍后点击树节点回放具体某个代理的输出。",[11,18196,18197,18198,18201],{},"这个设计的好处是，Ultracode 不再是一个独立屏幕，而是聊天的一个模式。消息历史、搜索、导出等聊天功能自动支持 Ultracode 的任务——不需要单独维护一套 ",[15,18199,18200],{},"\u002Fultracode"," 屏的数据模型。",[26,18203,18204],{"id":18204},"从原架构到新架构的演变",[11,18206,18207,18208,18211,18212,781],{},"回看原有的评委-轮次模式和新的分阶段编排，区别在于",[488,18209,18210],{},"谁决策编排拓扑","和",[488,18213,18214],{},"何时收敛",[11,18216,18217],{},"原架构是「一个 worker + 9 个评委 + 收敛循环」的固定管线。优点是逻辑简单、有明确的投票机制；缺点是不敏捷——即便 worker 搞定了，还要等 9 个 judge 都投票，任何一个 judge 的异议都可能推进新一轮，导致无限循环。",[11,18219,18220],{},"新架构则是「LLM 编排器规划拓扑、通用引擎执行、阶段内并发、失败不拦管线」。编排器看到复杂任务时自主拆多 phase（比如 10 个并行 worker），而不是冗长的轮次。即便某个 worker 失败，judge 照样验证其他成功的 worker，synth 在综合时主动决定降级。这样既不浪费时间在已稳定的输出上重复评判，也保证了管线的吞吐。",[11,18222,18223],{},"从成本角度，新架构潜在地消耗更少——同样的任务规模，原架构是 1 worker + 9 judge × N 轮，新架构是 N worker + N judge（对抗验证）+ 1 synth，通常少好几倍。真机上见过单个任务从原架构的「200+ 代理跑了 6 轮」降到新架构的「30 代理一轮过」。",[26,18225,18226],{"id":18226},"仍未解决的边界情况",[11,18228,18229,18230,18233],{},"设计中也有已知的限制。其一，",[488,18231,18232],{},"子代理递归不支持","——即子代理再派下一层子代理。SDK 的 Task 工具在子代理的上下文不可用，这是 SDK 层的约束，引擎无法绕过。实践中这个限制影响不大，因为 Ultracode 的典型用途是「任务 → 多 worker 并行 → 对抗验证」的三层，很少需要递归。",[11,18235,17083,18236,18239],{},[488,18237,18238],{},"动态编排调整","。计划一旦生成后就是固定的，运行中不能动态插入新 phase 或新 agent。如果某个阶段的结果出乎预料，主代理不能中途改变后续阶段的代理配置。这是为了保证计划的确定性和可回放性——一份计划应该在相同的输入下产出相同的执行树。未来如果真需要这个能力，可以在编排器层面实现「多轮编排」（第一轮生成初步计划、看结果后再微调）。",[11,18241,17090,18242,18245],{},[488,18243,18244],{},"编排器本身的稳定性","。LLM 产出畸形 Plan 的风险永远存在，fallback 机制可以兜底但不能杜绝。未来可以考虑对编排器的输出做更激进的验证、甚至让编排器自己生成测试 case 来验证拓扑设计。但这超出了 SP 子项目的范围。",[26,18247,18248],{"id":18248},"后续迭代方向",[11,18250,18251,18252,18255,18256,18259,18260,18263],{},"这套引擎的设计目标是「通用」，所以留了扩展点。首先是",[488,18253,18254],{},"编排策略","——现在编排器是简单的「LLM 一次调用」，未来可以升级为「判断复杂度 → 按复杂度选用编排模板 → 微调参数」，甚至「用历史成功案例做 few-shot 引导」。其次是",[488,18257,18258],{},"收敛检测的进阶","——现在的判据是「连续 N 轮无新问题」，太粗糙。可以升级为「按问题类型（语法 vs 逻辑 vs 性能）分别统计、不同类型不同收敛阈值」。第三是",[488,18261,18262],{},"成本可见与预算控制","——现在成本相加没有细颗粒度控制（只有绝对上限），可以做分阶段的预算、或者基于历史任务的成本预测。",[11,18265,18266],{},"但这些都是后话。眼下重要的是让核心引擎的三个决策——并发上限、失败隔离、实时可观察——都落地并验证。",[1267,18268,18269],{},"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":183,"searchDepth":184,"depth":184,"links":18271},[18272,18273,18274,18275,18276,18277,18278,18279,18280,18281,18282,18283,18284,18285,18286],{"id":17070,"depth":184,"text":17070},{"id":17097,"depth":184,"text":17097},{"id":17121,"depth":184,"text":17121},{"id":17301,"depth":184,"text":17301},{"id":17361,"depth":184,"text":17361},{"id":17500,"depth":184,"text":17501},{"id":17561,"depth":184,"text":17561},{"id":17594,"depth":184,"text":17594},{"id":17610,"depth":184,"text":17610},{"id":17645,"depth":184,"text":17645},{"id":17700,"depth":184,"text":17700},{"id":18184,"depth":184,"text":18185},{"id":18204,"depth":184,"text":18204},{"id":18226,"depth":184,"text":18226},{"id":18248,"depth":184,"text":18248},"2026-06-21","单 Agent 处理复杂任务时，上下文上限和职责混乱很难避免。当任务规模扩大、需要多个专职代理时，简单的顺序派发不够用——需要一个真正的执行引擎，而不是一串 await 调用。Ultracode 的重写把这个执行引擎从写死的轮次评委模式升级为通用、分阶段、可观察的编排系统。",{},"\u002F2026-06-21-ultracodeagent",{"title":17059,"description":18288},"2026-06-21-Ultracode多Agent编排引擎重写","从顺序调用升级为分阶段扇出的通用执行引擎，用 AI 规划编排拓扑、并发控制防爆、失败隔离传播。",[18295,18296,18297,18298,10930],"多代理编排","Agent 引擎","任务并发","实时可视化","AV6bLD0-HzCYqijQdQ1dyCaZGHEeRLnBt7idYgeJaDk",{"id":18301,"title":18302,"body":18303,"column":2727,"date":18567,"description":18307,"extension":199,"hero_image":200,"meta":18568,"navigation":202,"path":18569,"seo":18570,"series_id":200,"severity":200,"stem":18571,"summary":18572,"tags":18573,"__hash__":18578},"posts\u002F2026-06-19-漫剧生产流水线分镜素材渲染.md","同一个角色，跨 60 个镜头不能换脸",{"type":8,"value":18304,"toc":18558},[18305,18308,18311,18314,18320,18325,18331,18335,18338,18361,18364,18378,18382,18388,18391,18398,18405,18409,18412,18432,18438,18445,18448,18452,18455,18461,18467,18481,18487,18494,18497,18500,18520,18523,18533,18539,18545,18551],[11,18306,18307],{},"漫剧（漫画形态的短剧）从剧本到成片的生产链路远长于单纯的文生图。剧本 → 分镜 → 角色与场景素材 → 逐镜生成 → 合成渲染，每一环的产出都是下一环的输入，且需要保持跨镜的一致性。如果某一镜失败，必须能在不影响已完成部分的前提下重试。",[26,18309,18310],{"id":18310},"为什么要分阶段",[11,18312,18313],{},"剧本从创意到成片经过多个中间态，每个中间态都是可编辑、可回退、可重复使用的产物。分阶段的关键：",[11,18315,18316,18319],{},[488,18317,18318],{},"输入输出契约的明确性","。每个阶段接收前一阶段的输出，产出下一阶段的输入。比如剧本阶段输出结构化的Markdown（约定好场景标题、角色台词、舞台提示的格式），分镜阶段才能用LLM稳定地解析。如果剧本是自由文本，分镜的LLM解析就会出现\"有时识别到5镜，有时识别到7镜\"的不确定性。",[11,18321,18322,18324],{},[488,18323,17676],{},"。成片阶段某一镜的视频生成失败（比如Ark服务的内容审核拒绝），应该只影响那一镜的重试，不能拖垮整个成片流程。这需要每一阶段的产出都能被独立操作——镜头列表可以编辑某一镜而不重新生成全部；资产库里的某个角色形象可以独立重生成。",[11,18326,18327,18330],{},[488,18328,18329],{},"人工介入点","。素材库、分镜表都是可编辑的中间产物，创作者可以在这些点做审核和调整，而不是端到端黑盒生成。",[26,18332,18334],{"id":18333},"阶段一剧本与分镜结构化","阶段一：剧本与分镜结构化",[11,18336,18337],{},"剧本本身是Markdown自由文本，但为了让后续阶段能稳定解析，需要约定轻量的标记：",[123,18339,18340,18346,18352],{},[126,18341,18342,18343],{},"场景标题：",[15,18344,18345],{},"## 场景 N:标题",[126,18347,18348,18349],{},"角色台词：",[15,18350,18351],{},"角色名：台词",[126,18353,18354,18355,13067,18358],{},"舞台提示：",[15,18356,18357],{},"(斜体提示)",[15,18359,18360],{},"*斜体*",[11,18362,18363],{},"这些约定不强制校验（v1保留灵活性），但供分镜阶段的LLM解析时参考。字数统计时去除标记后计数。",[11,18365,18366,18369,18370,18373,18374,18377],{},[488,18367,18368],{},"版本快照","是这一阶段的核心设计。Script Doctor AI产生修改建议时，系统先存当前版本快照（reason=",[15,18371,18372],{},"pre_ai","），再让AI整稿覆盖，覆盖后再建一条快照（reason=",[15,18375,18376],{},"ai_write","）。这样创作者可以随时「撤销」回某个时刻的版本。",[26,18379,18381],{"id":18380},"阶段二素材库与一致性","阶段二：素材库与一致性",[11,18383,18384,18385,781],{},"从剧本和故事圣经抽取角色和场景，生成可复用的图片素材。这一阶段的难点",[488,18386,18387],{},"不在单张图的生成质量，而在跨镜的一致性",[11,18389,18390],{},"一个角色在第1镜和第5镜出现，两张图片必须是同一个人——脸型、发色、服饰都要对应。如果靠Prompt描述来维持一致（\"一个穿蓝色衣服的女性，20岁左右，棕色头发…\"），生成出来的两张图几乎不可能完全一致。",[11,18392,18393,18394,18397],{},"解决方案：",[488,18395,18396],{},"把角色固化成可复用资产","。从剧本首次提到某个角色时，生成一次角色立绘，存入项目级资产库。后续分镜中再提到这个角色，不再重新生成，而是直接引用库里的立绘。场景同理——虽然不同镜头的场景可能视角不同（客厅的远景vs近景），但起点的关键元素（颜色、家具、灯光风格）要保持一致，也要存入场景库。",[11,18399,18400,18401,18404],{},"资产存储采用相对路径，锁定机制防止意外覆盖。生成时使用",[15,18402,18403],{},"runImageGeneration","加2小时退避重试和size回退。状态机跟踪每个资产的生成进度（pending→generating→ready或failed）。上传路由校验文件类型、大小和MIME，存盘时用assetId派生路径防目录穿越。",[26,18406,18408],{"id":18407},"阶段三逐镜分镜表","阶段三：逐镜分镜表",[11,18410,18411],{},"从剧本和资产库，用LLM拆分镜。输出结构化的镜头列表，每镜包含：",[123,18413,18414,18417,18420,18423,18426,18429],{},[126,18415,18416],{},"sceneHeading：场景标题",[126,18418,18419],{},"description：画面描述（用于生成首帧）",[126,18421,18422],{},"motionDesc：运镜和动作（后续SP4图生视频用）",[126,18424,18425],{},"dialogue：台词",[126,18427,18428],{},"visibleAssetIds：该镜涉及的角色\u002F场景资产ID列表",[126,18430,18431],{},"durationSec：时长",[11,18433,18434,18437],{},[488,18435,18436],{},"并行度与失败隔离","。分镜表生成后，逐镜生成首帧是一个可高并发的任务。BullMQ的il_COMIC队列处理这些job，并发度由worker数量控制。某一镜的首帧生成失败（比如参考资产图太多超过上限），置该镜的frameStatus为failed，其他镜头继续进行，不互相阻塞。失败的镜头可以单独点「重生成」。",[11,18439,18440,18441,18444],{},"生成首帧时用",[15,18442,18443],{},"runImageEdit","（图生图），把visibleAssetIds指向的资产图作为参考（最多5张），确保新生成的画面里的人物和环境与资产库对应。如果某镜没有关联资产（visibleAssetIds为空），退化到纯文生图。",[11,18446,18447],{},"分镜表本身可编辑。创作者可以调整每镜的描述、调整时长、增删镜头、改变资产关联。这些编辑操作走PATCH，不需要重新生成分镜。",[26,18449,18451],{"id":18450},"阶段四渲染合成与续跑","阶段四：渲染合成与续跑",[11,18453,18454],{},"逐镜图生视频，再用ffmpeg按顺序拼接成成片。v1版本的合成只处理视频序列的顺序拼接，不包括字幕、转场、配乐等后期元素——这些在后续版本中补齐。",[11,18456,18457,18460],{},[488,18458,18459],{},"续跑的关键","：每个镜头的videoPath一旦写入就不再改写。处理job时，先检查这一镜是否已有videoPath；如果有，跳过生成，直接进入下一镜。这样即使整集渲染中途中断（比如火山Ark服务宕机、ffmpeg进程被杀），重新触发时只需补齐缺失的镜头，不会重复扣费或重复调用Ark。",[11,18462,18463,18466],{},[488,18464,18465],{},"进度回写","。每生成完一镜的视频，更新Job的progress字段反映完成镜数。前端轮询获取进度，展示\"第3\u002F12镜已完成\"的UI。",[11,18468,18469,18472,18473,18476,18477,18480],{},[488,18470,18471],{},"Ark API","。调用",[15,18474,18475],{},"POST https:\u002F\u002Fark.cn-beijing.volces.com\u002Fapi\u002Fv3\u002Fcontents\u002Fgenerations\u002Ftasks","，入参包含首帧公网URL和描述文本，轮询task直到succeeded状态才能下载video_url。首帧URL必须是公开可达的，因为Ark无法访问鉴权路由。系统新增路由",[15,18478,18479],{},"GET \u002Fapi\u002Fcomic\u002Fpublic\u002Fframe\u002F[shotId]","（无鉴权，shotId是cuid不可猜），供Ark拉取。",[11,18482,18483,18486],{},[488,18484,18485],{},"超时与退避","。Ark请求设30分钟超时，失败时429状态码进行指数退避重试。内容审核拒绝（违反社区政策）时，Ark返回特定错误码，映射到友好中文提示\"建议尝试非写实风格\"，提示用户调整prompt后重试。",[11,18488,18489,18490,18493],{},"ffmpeg拼接采用concat demuxer。生成的最终视频存入",[15,18491,18492],{},"videos\u002Ffinal-\u003CepisodeId>.mp4","，路径由episodeId派生防穿越。",[26,18495,18496],{"id":18496},"跨阶段的一致性保证",[11,18498,18499],{},"整个流水线的稳定性关键：",[243,18501,18502,18508,18514],{},[126,18503,18504,18507],{},[488,18505,18506],{},"资产库的冻结","。一旦素材阶段完成，后续分镜和渲染阶段引用的资产ID必须指向已锁定的资产。资产库提供lock机制，锁定的资产不能被重生成或删除，防止后续镜头生成时资产被意外覆盖。",[126,18509,18510,18513],{},[488,18511,18512],{},"版本快照链","。剧本的每次修改都记录快照，分镜由该时刻的剧本生成。如果剧本后来改了，已生成的分镜保持不变（不会自动更新），创作者需要明确点「重新生成分镜」才会用最新剧本重新拆。",[126,18515,18516,18519],{},[488,18517,18518],{},"归属校验","。每个操作都绑定到userId和projectId。asset→project→userId的链路确保用户A生成的资产对用户B完全不可见，也不会因为user B的分镜生成而被修改。",[26,18521,18522],{"id":18522},"失败处理设计",[11,18524,18525,18528,18529,18532],{},[488,18526,18527],{},"LLM解析失败","。分镜拆解时LLM返回的JSON格式错误或关键字段缺失，",[15,18530,18531],{},"parseNovelJson","失败→不建脏数据到数据库，给前端返回友好错误提示\"无法理解剧本结构，请检查格式\"。",[11,18534,18535,18538],{},[488,18536,18537],{},"图片生成失败","。某镜首帧出图失败，该镜frameStatus=failed，msg字段记录错误（\"参考图过多\"、\"描述文本违反政策\"等）。同集的其他镜继续生成。用户可以修改这一镜的描述，单独重试。",[11,18540,18541,18544],{},[488,18542,18543],{},"视频生成中断","。若Ark不可达或超时，该镜videoStatus=failed，整集renderStatus=failed。前端展示\"第7镜生成失败\"，用户点「重试」时，系统检查已有videoPath的镜头（第1-6镜）都跳过，只重新生成第7镜及后续。",[11,18546,18547,18550],{},[488,18548,18549],{},"并发冲突","。剧本在自动保存中，同时AI写入修改，系统比对updatedAt时间戳，冲突时给前端返回\"冲突，请确认是保留用户编辑还是接受AI修改\"，不静默覆盖。",[11,18552,18553,18554,18557],{},"漫剧流水线的设计核心是",[488,18555,18556],{},"把每个中间产物结构化并可编辑","，这样一致性问题转化为资产库的管理问题，失败也能精确定位到某个镜头而不必推倒重来。",{"title":183,"searchDepth":184,"depth":184,"links":18559},[18560,18561,18562,18563,18564,18565,18566],{"id":18310,"depth":184,"text":18310},{"id":18333,"depth":184,"text":18334},{"id":18380,"depth":184,"text":18381},{"id":18407,"depth":184,"text":18408},{"id":18450,"depth":184,"text":18451},{"id":18496,"depth":184,"text":18496},{"id":18522,"depth":184,"text":18522},"2026-06-19",{},"\u002F2026-06-19",{"title":18302,"description":18307},"2026-06-19-漫剧生产流水线分镜素材渲染","漫剧生成比单纯文生图复杂得多，需要在剧本→素材→分镜→成片四阶段维持一致性并优雅处理失败。",[6443,18574,18575,18576,18577],"生成式AI","流水线架构","一致性保证","失败处理","ttSH4piWZ0XkyGksnHttUQ-rqJYVJYrg723AuYLQdA8",{"id":18580,"title":18581,"body":18582,"column":355,"date":19454,"description":19455,"extension":199,"hero_image":200,"meta":19456,"navigation":202,"path":19457,"seo":19458,"series_id":19459,"severity":200,"stem":19460,"summary":19461,"tags":19462,"__hash__":19468},"posts\u002F2026-06-17-FA-016-WS永久断开三条路径.md","日志里没有重连记录——它根本没在重连",{"type":8,"value":18583,"toc":19442},[18584,18595,18602,18609,18612,18615,18619,18626,18689,18695,18759,18768,18772,18775,18893,18904,18908,18914,18969,18972,18975,18978,18993,18996,18999,19002,19009,19249,19259,19262,19272,19285,19295,19298,19301,19308,19359,19362,19365,19368,19371,19374,19419,19436,19439],[11,18585,18586,18587,18590,18591,18594],{},"从 2026-05-11 开始，多台便携包客户反馈 WebSocket 断线后面板卡死。网关进程（",[15,18588,18589],{},"openclaw_gateway.exe","）明确在运行，",[15,18592,18593],{},"\u002Fhealth"," 返回 200，但面板显示\"已停止重连，请手动刷新\"——用户必须手动重启才能恢复。",[11,18596,18597,18598,18601],{},"关键线索在控制台：看不到任何 ",[15,18599,18600],{},"[ws] 计划重连"," 的日志。",[11,18603,18604,18605,18608],{},"通常断线后客户端会频繁尝试重连，日志里应该是满屏的重连记录。没有日志意味着问题不在重连失败，而在",[488,18606,18607],{},"压根没启动重连机制","。说明客户端进入了某个永久休眠状态。",[26,18610,18611],{"id":18611},"三条独立的永久断开路径",[11,18613,18614],{},"代码审查发现了三条完全不同的、独立的永久断开路径。任意一条命中，就会停止调度任何重连计时器。",[31,18616,18618],{"id":18617},"路径-a凭据刷新异常","路径 A：凭据刷新异常",[11,18620,18621,18622,18625],{},"在 ",[15,18623,18624],{},"_scheduleReconnect"," 方法末尾，每次普通重连都会调用：",[399,18627,18631],{"className":18628,"code":18629,"language":18630,"meta":183,"style":183},"language-javascript shiki shiki-themes github-light github-dark","this._reconnectTimer = setTimeout(() => {\n  if (!this._intentionalClose) {\n    this._refreshCredentialsAndReconnect(0)\n  }\n}, delay)\n","javascript",[15,18632,18633,18651,18664,18680,18684],{"__ignoreMap":183},[407,18634,18635,18638,18641,18643,18645,18647,18649],{"class":409,"line":410},[407,18636,18637],{"class":476},"this",[407,18639,18640],{"class":413},"._reconnectTimer ",[407,18642,418],{"class":417},[407,18644,7476],{"class":554},[407,18646,5531],{"class":413},[407,18648,520],{"class":417},[407,18650,523],{"class":413},[407,18652,18653,18655,18657,18659,18661],{"class":409,"line":184},[407,18654,3721],{"class":417},[407,18656,3724],{"class":413},[407,18658,3727],{"class":417},[407,18660,18637],{"class":476},[407,18662,18663],{"class":413},"._intentionalClose) {\n",[407,18665,18666,18669,18671,18674,18676,18678],{"class":409,"line":189},[407,18667,18668],{"class":476},"    this",[407,18670,3767],{"class":413},[407,18672,18673],{"class":554},"_refreshCredentialsAndReconnect",[407,18675,586],{"class":413},[407,18677,648],{"class":476},[407,18679,3970],{"class":413},[407,18681,18682],{"class":409,"line":452},[407,18683,563],{"class":413},[407,18685,18686],{"class":409,"line":458},[407,18687,18688],{"class":413},"}, delay)\n",[11,18690,18691,18692,18694],{},"而 ",[15,18693,18673],{}," 是异步方法，catch 分支没有任何后续处理：",[399,18696,18698],{"className":18628,"code":18697,"language":18630,"meta":183,"style":183},"} catch (e) {\n  console.error('[ws] 刷新凭据失败:', e)\n  this._setConnected(false, 'error', `凭据刷新失败: ${e}`)\n}\n",[15,18699,18700,18710,18724,18755],{"__ignoreMap":183},[407,18701,18702,18705,18707],{"class":409,"line":410},[407,18703,18704],{"class":413},"} ",[407,18706,3830],{"class":417},[407,18708,18709],{"class":413}," (e) {\n",[407,18711,18712,18714,18716,18718,18721],{"class":409,"line":184},[407,18713,5613],{"class":413},[407,18715,5570],{"class":554},[407,18717,586],{"class":413},[407,18719,18720],{"class":429},"'[ws] 刷新凭据失败:'",[407,18722,18723],{"class":413},", e)\n",[407,18725,18726,18729,18731,18734,18736,18739,18741,18744,18746,18749,18751,18753],{"class":409,"line":189},[407,18727,18728],{"class":476},"  this",[407,18730,3767],{"class":413},[407,18732,18733],{"class":554},"_setConnected",[407,18735,586],{"class":413},[407,18737,18738],{"class":476},"false",[407,18740,433],{"class":413},[407,18742,18743],{"class":429},"'error'",[407,18745,433],{"class":413},[407,18747,18748],{"class":429},"`凭据刷新失败: ${",[407,18750,5560],{"class":413},[407,18752,5625],{"class":429},[407,18754,3970],{"class":413},[407,18756,18757],{"class":409,"line":452},[407,18758,483],{"class":413},[11,18760,16233,18761,13067,18764,18767],{},[15,18762,18763],{},"api.readOpenclawConfig()",[15,18765,18766],{},"api.autoPairDevice()"," 抛错——磁盘 IO 抖、Rust 端处理器忙、Tauri IPC 队列阻塞——这次重连就永久终止。便携磁盘的 IO 抖动 + 配置文件读写争抢时最容易发生。",[31,18769,18771],{"id":18770},"路径-b认证失败后的强制关闭","路径 B：认证失败后的强制关闭",[11,18773,18774],{},"当 WebSocket 收到 1008 unauthorized 响应时的处理逻辑：",[399,18776,18778],{"className":18628,"code":18777,"language":18630,"meta":183,"style":183},"if (this._authRetryCount \u003C 2) {\n  this._authRetryCount++\n  this._refreshCredentialsAndReconnect()\n  return\n}\nthis._setConnected(false, 'auth_failed', `认证失败: ${e.reason}。请检查 Gateway Token 配置。`)\nthis._intentionalClose = true   \u002F\u002F ← 永久关闭标记\nthis._flushPending()\nreturn\n",[15,18779,18780,18798,18808,18819,18824,18828,18862,18877,18888],{"__ignoreMap":183},[407,18781,18782,18784,18786,18788,18791,18793,18796],{"class":409,"line":410},[407,18783,875],{"class":417},[407,18785,3724],{"class":413},[407,18787,18637],{"class":476},[407,18789,18790],{"class":413},"._authRetryCount ",[407,18792,1073],{"class":417},[407,18794,18795],{"class":476}," 2",[407,18797,887],{"class":413},[407,18799,18800,18802,18805],{"class":409,"line":184},[407,18801,18728],{"class":476},[407,18803,18804],{"class":413},"._authRetryCount",[407,18806,18807],{"class":417},"++\n",[407,18809,18810,18812,18814,18816],{"class":409,"line":189},[407,18811,18728],{"class":476},[407,18813,3767],{"class":413},[407,18815,18673],{"class":554},[407,18817,18818],{"class":413},"()\n",[407,18820,18821],{"class":409,"line":452},[407,18822,18823],{"class":417},"  return\n",[407,18825,18826],{"class":409,"line":458},[407,18827,483],{"class":413},[407,18829,18830,18832,18834,18836,18838,18840,18842,18845,18847,18850,18852,18854,18857,18860],{"class":409,"line":464},[407,18831,18637],{"class":476},[407,18833,3767],{"class":413},[407,18835,18733],{"class":554},[407,18837,586],{"class":413},[407,18839,18738],{"class":476},[407,18841,433],{"class":413},[407,18843,18844],{"class":429},"'auth_failed'",[407,18846,433],{"class":413},[407,18848,18849],{"class":429},"`认证失败: ${",[407,18851,5560],{"class":413},[407,18853,3767],{"class":429},[407,18855,18856],{"class":413},"reason",[407,18858,18859],{"class":429},"}。请检查 Gateway Token 配置。`",[407,18861,3970],{"class":413},[407,18863,18864,18866,18869,18871,18874],{"class":409,"line":470},[407,18865,18637],{"class":476},[407,18867,18868],{"class":413},"._intentionalClose ",[407,18870,418],{"class":417},[407,18872,18873],{"class":476}," true",[407,18875,18876],{"class":528},"   \u002F\u002F ← 永久关闭标记\n",[407,18878,18879,18881,18883,18886],{"class":409,"line":480},[407,18880,18637],{"class":476},[407,18882,3767],{"class":413},[407,18884,18885],{"class":554},"_flushPending",[407,18887,18818],{"class":413},[407,18889,18890],{"class":409,"line":1477},[407,18891,18892],{"class":417},"return\n",[11,18894,18895,18896,18899,18900,18903],{},"设置 ",[15,18897,18898],{},"_intentionalClose = true"," 之后，所有重连逻辑都被 ",[15,18901,18902],{},"if (!this._intentionalClose)"," 短路。即使只是 Gateway 重启窗口碰好出现 token 短暂不匹配，也会一次性把客户端打死，必须刷新页面才能恢复。",[31,18905,18907],{"id":18906},"路径-c快速重连配额耗尽","路径 C：快速重连配额耗尽",[11,18909,18910,18911,1244],{},"定义了常数 ",[15,18912,18913],{},"MAX_RECONNECT_ATTEMPTS = 60",[399,18915,18917],{"className":18628,"code":18916,"language":18630,"meta":183,"style":183},"if (this._reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {\n  this._setConnected(false, 'error', `连接失败，已停止重连。请手动刷新页面重试。`)\n  return\n}\n",[15,18918,18919,18938,18961,18965],{"__ignoreMap":183},[407,18920,18921,18923,18925,18927,18930,18933,18936],{"class":409,"line":410},[407,18922,875],{"class":417},[407,18924,3724],{"class":413},[407,18926,18637],{"class":476},[407,18928,18929],{"class":413},"._reconnectAttempts ",[407,18931,18932],{"class":417},">=",[407,18934,18935],{"class":476}," MAX_RECONNECT_ATTEMPTS",[407,18937,887],{"class":413},[407,18939,18940,18942,18944,18946,18948,18950,18952,18954,18956,18959],{"class":409,"line":184},[407,18941,18728],{"class":476},[407,18943,3767],{"class":413},[407,18945,18733],{"class":554},[407,18947,586],{"class":413},[407,18949,18738],{"class":476},[407,18951,433],{"class":413},[407,18953,18743],{"class":429},[407,18955,433],{"class":413},[407,18957,18958],{"class":429},"`连接失败，已停止重连。请手动刷新页面重试。`",[407,18960,3970],{"class":413},[407,18962,18963],{"class":409,"line":189},[407,18964,18823],{"class":417},[407,18966,18967],{"class":409,"line":452},[407,18968,483],{"class":413},[11,18970,18971],{},"客户机晚上挂机，Gateway 因为便携磁盘 GC 或 Windows 休眠争抢资源短暂掉线，客户端尝试 60 次仍未成功重连，就永久停摆。早上用户回来面板已成死链。",[26,18973,18974],{"id":18974},"为什么三条缺陷同时存在",[11,18976,18977],{},"这是逐步累加的历史债：",[123,18979,18980,18987,18990],{},[126,18981,18982,18983,18986],{},"路径 B 是在修复\"避免无限自动配对循环\"时添加的保护，但用错了对象——",[15,18984,18985],{},"_intentionalClose=true"," 本来是用户主动断开的语义，不该用在被动失败上",[126,18988,18989],{},"路径 C 是早期\"避免无穷重试\"的防护，但 60 次后完全放弃而不留任何复活路径是绝对错误",[126,18991,18992],{},"路径 A 是在\"凭据 reload\"改动时把所有重连都改走 refreshCredentials，没留意 catch 分支已经成了终态",[11,18994,18995],{},"三条各自独立、都能单独打死客户端，组合在一起命中率非常高。",[26,18997,18998],{"id":18998},"修复方案",[11,19000,19001],{},"核心思想是永不彻底放弃。任何\"快速重连配额耗尽\"的分支都转入慢轮询，留出窗口让用户改配置或等待 Gateway 恢复后能自动复连。",[11,19003,19004,19005,19008],{},"新增辅助方法 ",[15,19006,19007],{},"_schedulePoll(delayMs, kind)"," 作为慢轮询的触发器：",[399,19010,19012],{"className":18628,"code":19011,"language":18630,"meta":183,"style":183},"const AUTH_RETRY_LIMIT = 2\nconst SLOW_POLL_DELAY_AUTH = 60_000      \u002F\u002F 认证持续失败：1 分钟探一次\nconst SLOW_POLL_DELAY_GENERAL = 300_000  \u002F\u002F 一般持续失败：5 分钟探一次\n\n_schedulePoll(delayMs, kind) {\n  this._clearReconnectTimer()\n  this._reconnectAttempts = 0\n  if (kind === 'auth') this._authRetryCount = 0\n  this._reconnectState = 'scheduled'\n  this._pendingReconnect = true\n  this._reconnectTimer = setTimeout(() => {\n    this._reconnectTimer = null\n    if (this._intentionalClose) return\n    this._reconnectState = 'attempting'\n    if (kind === 'auth') {\n      this._refreshCredentialsAndReconnect(0)\n    } else {\n      this._doConnect()\n    }\n  }, delayMs)\n}\n",[15,19013,19014,19026,19041,19056,19060,19068,19079,19090,19112,19124,19136,19152,19163,19177,19188,19200,19215,19225,19236,19240,19245],{"__ignoreMap":183},[407,19015,19016,19018,19021,19023],{"class":409,"line":410},[407,19017,700],{"class":417},[407,19019,19020],{"class":476}," AUTH_RETRY_LIMIT",[407,19022,706],{"class":417},[407,19024,19025],{"class":476}," 2\n",[407,19027,19028,19030,19033,19035,19038],{"class":409,"line":184},[407,19029,700],{"class":417},[407,19031,19032],{"class":476}," SLOW_POLL_DELAY_AUTH",[407,19034,706],{"class":417},[407,19036,19037],{"class":476}," 60_000",[407,19039,19040],{"class":528},"      \u002F\u002F 认证持续失败：1 分钟探一次\n",[407,19042,19043,19045,19048,19050,19053],{"class":409,"line":189},[407,19044,700],{"class":417},[407,19046,19047],{"class":476}," SLOW_POLL_DELAY_GENERAL",[407,19049,706],{"class":417},[407,19051,19052],{"class":476}," 300_000",[407,19054,19055],{"class":528},"  \u002F\u002F 一般持续失败：5 分钟探一次\n",[407,19057,19058],{"class":409,"line":452},[407,19059,1827],{"emptyLinePlaceholder":202},[407,19061,19062,19065],{"class":409,"line":458},[407,19063,19064],{"class":554},"_schedulePoll",[407,19066,19067],{"class":413},"(delayMs, kind) {\n",[407,19069,19070,19072,19074,19077],{"class":409,"line":464},[407,19071,18728],{"class":476},[407,19073,3767],{"class":413},[407,19075,19076],{"class":554},"_clearReconnectTimer",[407,19078,18818],{"class":413},[407,19080,19081,19083,19085,19087],{"class":409,"line":470},[407,19082,18728],{"class":476},[407,19084,18929],{"class":413},[407,19086,418],{"class":417},[407,19088,19089],{"class":476}," 0\n",[407,19091,19092,19094,19097,19099,19102,19104,19106,19108,19110],{"class":409,"line":480},[407,19093,3721],{"class":417},[407,19095,19096],{"class":413}," (kind ",[407,19098,881],{"class":417},[407,19100,19101],{"class":429}," 'auth'",[407,19103,722],{"class":413},[407,19105,18637],{"class":476},[407,19107,18790],{"class":413},[407,19109,418],{"class":417},[407,19111,19089],{"class":476},[407,19113,19114,19116,19119,19121],{"class":409,"line":1477},[407,19115,18728],{"class":476},[407,19117,19118],{"class":413},"._reconnectState ",[407,19120,418],{"class":417},[407,19122,19123],{"class":429}," 'scheduled'\n",[407,19125,19126,19128,19131,19133],{"class":409,"line":1483},[407,19127,18728],{"class":476},[407,19129,19130],{"class":413},"._pendingReconnect ",[407,19132,418],{"class":417},[407,19134,19135],{"class":476}," true\n",[407,19137,19138,19140,19142,19144,19146,19148,19150],{"class":409,"line":2139},[407,19139,18728],{"class":476},[407,19141,18640],{"class":413},[407,19143,418],{"class":417},[407,19145,7476],{"class":554},[407,19147,5531],{"class":413},[407,19149,520],{"class":417},[407,19151,523],{"class":413},[407,19153,19154,19156,19158,19160],{"class":409,"line":2180},[407,19155,18668],{"class":476},[407,19157,18640],{"class":413},[407,19159,418],{"class":417},[407,19161,19162],{"class":476}," null\n",[407,19164,19165,19168,19170,19172,19175],{"class":409,"line":7546},[407,19166,19167],{"class":417},"    if",[407,19169,3724],{"class":413},[407,19171,18637],{"class":476},[407,19173,19174],{"class":413},"._intentionalClose) ",[407,19176,18892],{"class":417},[407,19178,19179,19181,19183,19185],{"class":409,"line":7564},[407,19180,18668],{"class":476},[407,19182,19118],{"class":413},[407,19184,418],{"class":417},[407,19186,19187],{"class":429}," 'attempting'\n",[407,19189,19190,19192,19194,19196,19198],{"class":409,"line":17267},[407,19191,19167],{"class":417},[407,19193,19096],{"class":413},[407,19195,881],{"class":417},[407,19197,19101],{"class":429},[407,19199,887],{"class":413},[407,19201,19202,19205,19207,19209,19211,19213],{"class":409,"line":17279},[407,19203,19204],{"class":476},"      this",[407,19206,3767],{"class":413},[407,19208,18673],{"class":554},[407,19210,586],{"class":413},[407,19212,648],{"class":476},[407,19214,3970],{"class":413},[407,19216,19217,19220,19223],{"class":409,"line":18010},[407,19218,19219],{"class":413},"    } ",[407,19221,19222],{"class":417},"else",[407,19224,523],{"class":413},[407,19226,19227,19229,19231,19234],{"class":409,"line":18057},[407,19228,19204],{"class":476},[407,19230,3767],{"class":413},[407,19232,19233],{"class":554},"_doConnect",[407,19235,18818],{"class":413},[407,19237,19238],{"class":409,"line":18062},[407,19239,18167],{"class":413},[407,19241,19242],{"class":409,"line":18067},[407,19243,19244],{"class":413},"  }, delayMs)\n",[407,19246,19247],{"class":409,"line":18072},[407,19248,483],{"class":413},[11,19250,19251,19252,19254,19255,19258],{},"慢轮询命中后失败会重新进入 ",[15,19253,18624],{}," 快速重连周期（因为 ",[15,19256,19257],{},"_reconnectAttempts"," 重置为 0），相当于\"快速 60 次 → 慢一次 → 快速 60 次 → 慢一次\"的循环，永不放弃。",[11,19260,19261],{},"三条修复并行：",[11,19263,19264,19267,19268,19271],{},[488,19265,19266],{},"Fix A","：普通重连直接走 ",[15,19269,19270],{},"_doConnect()","，凭据刷新隔离到认证失败分支，避免配置读取错误牵连普通重连。",[11,19273,19274,19277,19278,19281,19282,19284],{},[488,19275,19276],{},"Fix B","：认证失败耗尽后转入 ",[15,19279,19280],{},"_schedulePoll('auth')","，不再设置 ",[15,19283,18985],{},"，给认证恢复留出 60 秒的探测窗口。",[11,19286,19287,19290,19291,19294],{},[488,19288,19289],{},"Fix C","：重连次数超过 MAX_RECONNECT_ATTEMPTS 后转入 ",[15,19292,19293],{},"_schedulePoll('general')","，设置 UI 状态为\"连接持续失败，300 秒后重试\"而不是终态。",[11,19296,19297],{},"慢轮询失败后重新进入快速重连周期，形成\"快速 60 次 → 慢一次 → 快速 60 次\"的循环，永不放弃。",[26,19299,19300],{"id":19300},"防回归验证",[11,19302,19303,19304,19307],{},"新增 ",[15,19305,19306],{},"wakou-full\u002Fsrc\u002Flib\u002Fws-client.slow-poll.test.js","，覆盖 7 个 case：",[123,19309,19310,19318,19330,19333,19342,19349,19354],{},[126,19311,19312,19313,19315,19316],{},"Fix A：普通重连命中 ",[15,19314,19233],{},"，不调用 ",[15,19317,18673],{},[126,19319,19320,19321,19324,19325,19327,19328],{},"Fix C：MAX_RECONNECT_ATTEMPTS 后状态是 ",[15,19322,19323],{},"reconnecting","（非终态 ",[15,19326,5570],{},"），5 分钟后触发 ",[15,19329,19233],{},[126,19331,19332],{},"Fix C：第二轮慢轮询失败后还能再调度快速重连",[126,19334,19335,19336,19338,19339],{},"Fix B：",[15,19337,19280],{}," 等待 60 秒调用 ",[15,19340,19341],{},"_refreshCredentialsAndReconnect(0)",[126,19343,19335,19344,19346,19347],{},[15,19345,19293],{}," 等待后调用 ",[15,19348,19233],{},[126,19350,19351,19353],{},[15,19352,18985],{}," 时慢轮询应跳过",[126,19355,19356,19358],{},[15,19357,18673],{}," 抛错路径调度慢轮询",[11,19360,19361],{},"测试全部通过（7\u002F7）。",[26,19363,19364],{"id":19364},"外部因素的叠加",[11,19366,19367],{},"这个时期同步发生了另一个外部问题：CDN 对 WebSocket 空闲连接会在 4-9 分钟后强制切断。客户端收到连接中断后根据指数退避策略重连，如果恰好命中上述三条路径之一，就进入永久断开状态。这解释了为什么故障特别在长时间挂机后高发——空闲足够长，CDN 必定切断，而后续重连很容易落入某条缺陷路径。两个问题各自独立，但组合效果是\"挂机过夜必死\"。",[26,19369,19370],{"id":19370},"排查验证",[11,19372,19373],{},"故障修复后，可以用以下方式验证重连状态：",[399,19375,19377],{"className":18628,"code":19376,"language":18630,"meta":183,"style":183},"__clawpanelWsClient.getConnectionInfo()\n\u002F\u002F {\n\u002F\u002F   connected: false,\n\u002F\u002F   reconnectState: 'scheduled' | 'attempting',\n\u002F\u002F   reconnectAttempts: 0~60,\n\u002F\u002F   ...\n\u002F\u002F }\n",[15,19378,19379,19389,19394,19399,19404,19409,19414],{"__ignoreMap":183},[407,19380,19381,19384,19387],{"class":409,"line":410},[407,19382,19383],{"class":413},"__clawpanelWsClient.",[407,19385,19386],{"class":554},"getConnectionInfo",[407,19388,18818],{"class":413},[407,19390,19391],{"class":409,"line":184},[407,19392,19393],{"class":528},"\u002F\u002F {\n",[407,19395,19396],{"class":409,"line":189},[407,19397,19398],{"class":528},"\u002F\u002F   connected: false,\n",[407,19400,19401],{"class":409,"line":452},[407,19402,19403],{"class":528},"\u002F\u002F   reconnectState: 'scheduled' | 'attempting',\n",[407,19405,19406],{"class":409,"line":458},[407,19407,19408],{"class":528},"\u002F\u002F   reconnectAttempts: 0~60,\n",[407,19410,19411],{"class":409,"line":464},[407,19412,19413],{"class":528},"\u002F\u002F   ...\n",[407,19415,19416],{"class":409,"line":470},[407,19417,19418],{"class":528},"\u002F\u002F }\n",[11,19420,19421,19424,19425,19428,19429,19424,19432,19435],{},[15,19422,19423],{},"reconnectState='scheduled'"," 且 ",[15,19426,19427],{},"reconnectAttempts=0"," 表示在慢轮询窗口正常工作；",[15,19430,19431],{},"reconnectState='idle'",[15,19433,19434],{},"connected=false"," 是老的永久断开路径。",[11,19437,19438],{},"重连机制里避免静默终态是最基本的要求。任何异常路径都必须有可见的状态提示和后续操作入口，否则用户端看到的就是死机。这次故障的根本教训是，不能让客户端在任何情况下进入\"不可恢复\"的状态而没有任何提示。慢轮询的引入给了所有失败情景一个\"最后的机会\"，即使前面的快速重连机制彻底耗尽了，用户等待足够长的时间后系统仍有自动恢复的可能。",[1267,19440,19441],{},"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 .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 .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 .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":183,"searchDepth":184,"depth":184,"links":19443},[19444,19449,19450,19451,19452,19453],{"id":18611,"depth":184,"text":18611,"children":19445},[19446,19447,19448],{"id":18617,"depth":189,"text":18618},{"id":18770,"depth":189,"text":18771},{"id":18906,"depth":189,"text":18907},{"id":18974,"depth":184,"text":18974},{"id":18998,"depth":184,"text":18998},{"id":19300,"depth":184,"text":19300},{"id":19364,"depth":184,"text":19364},{"id":19370,"depth":184,"text":19370},"2026-06-17","从 2026-05-11 开始，多台便携包客户反馈 WebSocket 断线后面板卡死。网关进程（openclaw_gateway.exe）明确在运行，\u002Fhealth 返回 200，但面板显示\"已停止重连，请手动刷新\"——用户必须手动重启才能恢复。",{},"\u002F2026-06-17-fa-016-ws",{"title":18581,"description":19455},"FA-016","2026-06-17-FA-016-WS永久断开三条路径","客户端重连逻辑中三处独立的\"静默终止\"缺陷导致 WS 永久断开，任何一条路径命中就不再调度重连计时器。",[19463,19464,19465,19466,19467],"WebSocket","Node.js","客户端重连机制","异常处理","便携包","qaISKpAwjYnhiSdxEMYYnJtJ-A8QaOq-djOEJ0W9-bM",{"id":19470,"title":19471,"body":19472,"column":6815,"date":19944,"description":19476,"extension":199,"hero_image":200,"meta":19945,"navigation":202,"path":19946,"seo":19947,"series_id":200,"severity":200,"stem":19948,"summary":19949,"tags":19950,"__hash__":19954},"posts\u002F2026-06-16-把高风险发版固化成skill.md","不让 AI 碰发版，不等于靠人记住每一步",{"type":8,"value":19473,"toc":19938},[19474,19477,19481,19484,19487,19506,19516,19519,19523,19526,19529,19646,19653,19656,19663,19692,19695,19702,19706,19709,19712,19719,19789,19800,19861,19864,19879,19886,19889,19905,19912,19915,19918,19929,19932,19935],[11,19475,19476],{},"发版从来不应该由人工记忆驱动。5 月那一轮 OTA 系列故障（FA-005 到 FA-008）和跨版本步进导致的漏 DLL 事故反复说明了这一点：发版是不可逆的、影响全部用户的、细节繁琐的操作，任何一个环节遗漏或顺序错误就导致客户端卡死、文件不一致、激活页死循环。我现在不是让 AI 执行发版——那是禁地——而是把 5 个已经踏过所有坑的发版流程全部固化成 skill，让执行者用最小脑力成本跑完完整链路，而不是依赖文档翻译和记忆。",[26,19478,19480],{"id":19479},"触发词不该触发时绝对不触发","触发词：不该触发时绝对不触发",[11,19482,19483],{},"一个 skill 最危险的时刻是被错误调用。\"现在是什么版本\"听起来像发版，\"改一下版本号\"听起来像发版，但它们完全不是。",[11,19485,19486],{},"Wakou 发版 skill 的触发词清单（必读触发词段）就是为了卡死这条线：",[123,19488,19489,19495],{},[126,19490,19491,19492],{},"触发：",[15,19493,19494],{},"发版 \u002F 发布 \u002F release \u002F 打个新版本 \u002F OTA \u002F OTA 死循环 \u002F 版本 bump \u002F \u002Fwakou-release",[126,19496,19497,19498,19501,19502,19505],{},"不触发：",[15,19499,19500],{},"改一下 package.json 版本号","（用户只想改文件），",[15,19503,19504],{},"看看现在是什么版本","（只查询）",[11,19507,19508,19509,16323,19512,19515],{},"反面教材我都经历过。改一下配置文件、改一个环境变量、查一个版本号，因为没有明确的触发词界限，最后莫名其妙走到了发版脚本的某一步。Connector OTA skill 同样列明了不触发场景：",[15,19510,19511],{},"\"Connector 现在是什么版本\"(只查询)",[15,19513,19514],{},"\"改 tauri.conf 版本号\"(用户只想改文件)"," — 这些都不触发，即使命令里含了 version、release 这类关键字。",[11,19517,19518],{},"这条最反直觉但最重要：不该触发时被触发，等于主动送一次误操作机会给自动化流程。",[26,19520,19522],{"id":19521},"步骤不可跳过验证链条硬卡","步骤不可跳过：验证链条硬卡",[11,19524,19525],{},"一旦 skill 确定要执行，每一步都必须有验证关卡，前一步没通过绝不进下一步。我用 Wakou 全自动发版流程作例子。",[11,19527,19528],{},"预检（Step 1）是整个流程的闸门。跑这些 Bash 检查：",[399,19530,19532],{"className":11754,"code":19531,"language":11756,"meta":183,"style":183},"cd \u002FUsers\u002Fxingye\u002FAi\u002Fnew-openclaw\ngit status --short                                          # 工作区干净\ntest -x \u002Fopt\u002Fhomebrew\u002Fbin\u002Fsshpass                           # ssh 工具\ntest -x \u002Fopt\u002Fhomebrew\u002Fbin\u002Fminisign                          # 签名工具\ntest -x \u003C签名 CLI 路径>                                      # 内部签名工具就位\ntest -f \u003C发版签名私钥路径>                                   # 私钥就位\ntest -f \u003CCDN 上传凭据路径>                                   # CDN 凭据就位\n",[15,19533,19534,19542,19556,19569,19581,19607,19626],{"__ignoreMap":183},[407,19535,19536,19539],{"class":409,"line":410},[407,19537,19538],{"class":476},"cd",[407,19540,19541],{"class":429}," \u002FUsers\u002Fxingye\u002FAi\u002Fnew-openclaw\n",[407,19543,19544,19547,19550,19553],{"class":409,"line":184},[407,19545,19546],{"class":554},"git",[407,19548,19549],{"class":429}," status",[407,19551,19552],{"class":476}," --short",[407,19554,19555],{"class":528},"                                          # 工作区干净\n",[407,19557,19558,19560,19563,19566],{"class":409,"line":189},[407,19559,3770],{"class":476},[407,19561,19562],{"class":476}," -x",[407,19564,19565],{"class":429}," \u002Fopt\u002Fhomebrew\u002Fbin\u002Fsshpass",[407,19567,19568],{"class":528},"                           # ssh 工具\n",[407,19570,19571,19573,19575,19578],{"class":409,"line":452},[407,19572,3770],{"class":476},[407,19574,19562],{"class":476},[407,19576,19577],{"class":429}," \u002Fopt\u002Fhomebrew\u002Fbin\u002Fminisign",[407,19579,19580],{"class":528},"                          # 签名工具\n",[407,19582,19583,19585,19587,19590,19593,19596,19599,19602,19604],{"class":409,"line":458},[407,19584,3770],{"class":476},[407,19586,19562],{"class":476},[407,19588,19589],{"class":417}," \u003C",[407,19591,19592],{"class":429},"签名",[407,19594,19595],{"class":429}," CLI",[407,19597,19598],{"class":429}," 路",[407,19600,19601],{"class":413},"径",[407,19603,3519],{"class":417},[407,19605,19606],{"class":528},"                                      # 内部签名工具就位\n",[407,19608,19609,19611,19614,19616,19619,19621,19623],{"class":409,"line":464},[407,19610,3770],{"class":476},[407,19612,19613],{"class":476}," -f",[407,19615,19589],{"class":417},[407,19617,19618],{"class":429},"发版签名私钥路",[407,19620,19601],{"class":413},[407,19622,3519],{"class":417},[407,19624,19625],{"class":528},"                                   # 私钥就位\n",[407,19627,19628,19630,19632,19634,19636,19639,19641,19643],{"class":409,"line":470},[407,19629,3770],{"class":476},[407,19631,19613],{"class":476},[407,19633,19589],{"class":417},[407,19635,13564],{"class":429},[407,19637,19638],{"class":429}," 上传凭据路",[407,19640,19601],{"class":413},[407,19642,3519],{"class":417},[407,19644,19645],{"class":528},"                                   # CDN 凭据就位\n",[11,19647,19648,19649,19652],{},"任何一条 fail，流程停止并告诉用户修什么。如果 git 工作区脏，不是自动 stash，而是问用户：工作区有未提交改动，要先 commit 还是 stash？发版会自动 bump 三个版本文件并 commit。这样做的目的很明确——让用户",[488,19650,19651],{},"清晰意识到","发版会改动工作区，而不是闭着眼睛走入自动流程。",[11,19654,19655],{},"收三个密码（Step 2）也有安全约定。不要把密码记到内存或 commit 进文件，只读 shell 的 export。如果用户在过去几分钟内说过这些密码（在 conversation context 里能直接拿到），可以直接传 env 跑；否则必须用户主动导入。",[11,19657,19658,19659,19662],{},"跑 release.sh（Step 3）用 ",[15,19660,19661],{},"run_in_background=true"," 避免 5-15 分钟阻塞主线，把输出 tee 到日志。然后（Step 4）用 Monitor 工具监控 10 个 stage 的进度：",[243,19664,19665,19668,19671,19674,19677,19680,19683,19686,19689],{},[126,19666,19667],{},"preflight",[126,19669,19670],{},"detect FROM_VERSION",[126,19672,19673],{},"tar + scp source to Win build host",[126,19675,19676],{},"build on Win",[126,19678,19679],{},"stage + diff-pack OTA full.zip",[126,19681,19682],{},"minisign full.zip + release-manifest.json",[126,19684,19685],{},"upload to CDN",[126,19687,19688],{},"admin: create release + PATCH rollout",[126,19690,19691],{},"git tag + commit version bump",[11,19693,19694],{},"每个 stage 完成给用户报一句。出现 ✗ FAILED 立刻 surface 错误。没有\"继续试试能不能救\"，就是暴露失败。",[11,19696,19697,19698,19701],{},"这整套链条的目的是",[488,19699,19700],{},"让人工审核点布满全流程","。不是机器自动发版，而是 AI 陪着用户一步步走完发版，每步都看得清、验得出。",[26,19703,19705],{"id":19704},"故障处置内联把坑写在流程里","故障处置内联：把坑写在流程里",[11,19707,19708],{},"OTA 系列故障的教训不应该沉在 bug 复盘里。我把它们直接铺进 skill 的执行逻辑。",[11,19710,19711],{},"Wakou 发版 skill 有整整一章叫「🚨 阻塞性 bug 处置 SOP」。列出了从 G19 到 G31 的 11 个已知 bug：",[11,19713,19714,19715,19718],{},"G19 是 OTA modal 反复弹。原因是 state base 残留 stale ",[15,19716,19717],{},"download.zip.minisig","，当时的修复是：",[399,19720,19722],{"className":11754,"code":19721,"language":11756,"meta":183,"style":183},"# 预检 sanity#3: data\u002F 不删\nsanity#3 强制阻断 deletes 命中 data\u002F。某次发版失败提示 sanity#3 一定是 build 步骤搞坏了 data\u002F 目录的同步，不要绕过 sanity check 去强发，先 debug 为什么 data\u002F 出现在 deletes 列表。\n",[15,19723,19724,19729],{"__ignoreMap":183},[407,19725,19726],{"class":409,"line":410},[407,19727,19728],{"class":528},"# 预检 sanity#3: data\u002F 不删\n",[407,19730,19731,19734,19737,19740,19743,19746,19749,19752,19755,19758,19761,19764,19767,19770,19773,19776,19779,19781,19784,19786],{"class":409,"line":184},[407,19732,19733],{"class":554},"sanity#3",[407,19735,19736],{"class":429}," 强制阻断",[407,19738,19739],{"class":429}," deletes",[407,19741,19742],{"class":429}," 命中",[407,19744,19745],{"class":429}," data\u002F。某次发版失败提示",[407,19747,19748],{"class":429}," sanity#3",[407,19750,19751],{"class":429}," 一定是",[407,19753,19754],{"class":429}," build",[407,19756,19757],{"class":429}," 步骤搞坏了",[407,19759,19760],{"class":429}," data\u002F",[407,19762,19763],{"class":429}," 目录的同步，不要绕过",[407,19765,19766],{"class":429}," sanity",[407,19768,19769],{"class":429}," check",[407,19771,19772],{"class":429}," 去强发，先",[407,19774,19775],{"class":429}," debug",[407,19777,19778],{"class":429}," 为什么",[407,19780,19760],{"class":429},[407,19782,19783],{"class":429}," 出现在",[407,19785,19739],{"class":429},[407,19787,19788],{"class":429}," 列表。\n",[11,19790,19791,19792,19795,19796,19799],{},"G25 是跨用户机器后 chat 权限错。根因是 ",[15,19793,19794],{},"sessions\u002Faudit"," 含绝对路径，guardian sync 扩散。这不是\"修了以后的故事\"，而是",[488,19797,19798],{},"发版前防御 checklist"," 的一部分：",[399,19801,19803],{"className":11754,"code":19802,"language":11756,"meta":183,"style":183},"# 发版前防御 checklist (每次发版前在 build host 做完整新用户回归)\n# 3. dist 内 grep C:\\Users\\ 应该 0 命中（防 G25 类绝对路径泄露）\nssh CHANCHING@\u003C构建机> 'powershell -Command \"\n  Get-ChildItem D:\\wakou-build\\dist-X.Y.Z\\WakouPanelPortable -Recurse -File -Include *.json,*.jsonl |\n    Where-Object { $_.Length -lt 200KB } |\n    Select-String C:\\\\Users\\\\Administrator -SimpleMatch -List |\n    Select-Object FullName\n\"'\n",[15,19804,19805,19810,19815,19836,19841,19846,19851,19856],{"__ignoreMap":183},[407,19806,19807],{"class":409,"line":410},[407,19808,19809],{"class":528},"# 发版前防御 checklist (每次发版前在 build host 做完整新用户回归)\n",[407,19811,19812],{"class":409,"line":184},[407,19813,19814],{"class":528},"# 3. dist 内 grep C:\\Users\\ 应该 0 命中（防 G25 类绝对路径泄露）\n",[407,19816,19817,19820,19823,19825,19828,19831,19833],{"class":409,"line":189},[407,19818,19819],{"class":554},"ssh",[407,19821,19822],{"class":429}," CHANCHING@",[407,19824,1073],{"class":417},[407,19826,19827],{"class":429},"构建",[407,19829,19830],{"class":413},"机",[407,19832,3519],{"class":417},[407,19834,19835],{"class":429}," 'powershell -Command \"\n",[407,19837,19838],{"class":409,"line":452},[407,19839,19840],{"class":429},"  Get-ChildItem D:\\wakou-build\\dist-X.Y.Z\\WakouPanelPortable -Recurse -File -Include *.json,*.jsonl |\n",[407,19842,19843],{"class":409,"line":458},[407,19844,19845],{"class":429},"    Where-Object { $_.Length -lt 200KB } |\n",[407,19847,19848],{"class":409,"line":464},[407,19849,19850],{"class":429},"    Select-String C:\\\\Users\\\\Administrator -SimpleMatch -List |\n",[407,19852,19853],{"class":409,"line":470},[407,19854,19855],{"class":429},"    Select-Object FullName\n",[407,19857,19858],{"class":409,"line":480},[407,19859,19860],{"class":429},"\"'\n",[11,19862,19863],{},"任何一项 fail → 不要 GA，修了再 bump 一个 patch 版本。",[11,19865,19866,19867,19870,19871,19874,19875,19878],{},"Connector OTA skill 的故障字典（§4）则是一张病症与病因的对照表。\"signature verification failed\" 对应的处置是检查 ",[15,19868,19869],{},"TAURI_SIGNING_PRIVATE_KEY"," 是不是 .sec 文件",[488,19872,19873],{},"base64 编码后","的内容。\"OTA 不跨级但用户期望直跳\" 的处置是调整 ",[15,19876,19877],{},"findNextVersion"," 逻辑或在灰度模式放上一个 manifest。",[11,19880,19881,19882,19885],{},"关键在",[488,19883,19884],{},"处置是写死在 skill 里","，不是留在事后复盘让人去翻。OpenClaw Runtime 发版 skill 的故障字典甚至铺了 10 条常见陷阱：BuildKit context 缓存不清导致改动没编进去、gwbridge IP 池满导致 service 无法起、孤儿 docker-proxy 占着 host port、磁盘满导致 openclaw.json 被写成 0 字节。",[11,19887,19888],{},"每一条都带着：\"现象是什么、根因是什么、怎么修\"。拿 BuildKit 缓存那条：",[1205,19890,19891],{},[11,19892,19893,19894,19897,19898,19901,19902,19904],{},"症状：rsync\u002FSFTP 把新源推上 prod repo，源文件 grep 确认是新的，但 docker build 出来的镜像里还是旧 bundle。根因：",[15,19895,19896],{},"--no-cache-filter"," 不可靠，BuildKit 的 build context 层仍可能命中缓存。修法：clawpanel 源改动后，必须用整体 ",[15,19899,19900],{},"--no-cache","，不要用 ",[15,19903,19896],{},"。代价：单次 ~20-30 分钟。但这是唯一能确保改动真编进去的方式。",[11,19906,19907,19908,19911],{},"这不是\"高级技巧\"或\"经验之谈\"。这是",[488,19909,19910],{},"发版的必读须知","，写进 skill 里才能确保每次都不遗漏。",[26,19913,19914],{"id":19914},"为什么这样做有效",[11,19916,19917],{},"高风险操作的安全性来自流程的确定性，不来自执行者的谨慎。人会忘、会打错、会跳步。但固化的流程不会：",[123,19919,19920,19923,19926],{},[126,19921,19922],{},"触发词明确化杀死了\"我不是故意调用发版\"的那类误操作。",[126,19924,19925],{},"步骤验证链条把每一步的前置条件和验收标准写成 Bash 检查，没法绕过。",[126,19927,19928],{},"故障处置内联把一个多月里积累的坑提前封死在流程里，新一轮发版不会重蹈覆辙。",[11,19930,19931],{},"5 个 skill 现在覆盖了：Wakou panel OTA、Connector 桌面端 OTA、OpenClaw runtime 容器全量发布、Codex 助手静默更新、加上客服远程调试的风险边界。它们没有一个是让 AI 执行发版的——都是让 AI 陪着用户，一步步走完一个铺满护栏的流程。",[11,19933,19934],{},"这是把\"不要让 AI 碰发版\"这条禁区，变成了\"AI 执行发版的框架，确保每一步都验证、每个坑都已知、每种故障都有救法\"的可行路径。",[1267,19936,19937],{},"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 pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}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 .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}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":183,"searchDepth":184,"depth":184,"links":19939},[19940,19941,19942,19943],{"id":19479,"depth":184,"text":19480},{"id":19521,"depth":184,"text":19522},{"id":19704,"depth":184,"text":19705},{"id":19914,"depth":184,"text":19914},"2026-06-16",{},"\u002F2026-06-16-skill",{"title":19471,"description":19476},"2026-06-16-把高风险发版固化成skill","发版是明确不交给 AI 自主执行的操作。通过触发词精确化、步骤验证链条、故障处置内联，把流程固化到 skill，让高风险操作失败概率趋零。",[19951,15069,19952,19953,11245],"发版流程","自动化","Tauri","WbjJuf61wSEx4d5Iov2KhPdG1N6hik51RAl8rAGgug8",{"id":19956,"title":19957,"body":19958,"column":6815,"date":20293,"description":19962,"extension":199,"hero_image":200,"meta":20294,"navigation":202,"path":20295,"seo":20296,"series_id":200,"severity":200,"stem":20297,"summary":20298,"tags":20299,"__hash__":20303},"posts\u002F2026-06-15-Connector两次发版复盘.md","两天连发两版，第二版是为了补第一版",{"type":8,"value":19959,"toc":20283},[19960,19963,19967,19970,19977,19988,19991,20017,20027,20031,20034,20039,20046,20061,20072,20077,20080,20095,20105,20110,20117,20120,20125,20139,20142,20146,20153,20156,20167,20171,20174,20177,20180,20185,20188,20193,20204,20209,20212,20216,20223,20229,20232,20251,20254,20260,20263,20270,20280],[11,19961,19962],{},"桌面端连接组件在一周内连发两版。1.0.10 修了 WebSocket 端点未被消费导致的 17 分钟周期断线；1.0.11 处理紧随其后暴露的僵尸令牌问题与发版脚本漏洞。这两次发版既是问题的递进修复，也是工程实践上从被动补救走向主动设计的转折。",[26,19964,19966],{"id":19965},"_101017-分钟的周期","1.0.10：17 分钟的周期",[11,19968,19969],{},"用户反馈从五月中旬开始稳定出现：Connector 约每 17 分钟自动断线一次，断后立即重连。现象本身明确，但根因指向了一个被忽视的设计缺陷。",[11,19971,19972,19973,19976],{},"服务端在五月下旬已切换 WebSocket 入口地址。原来的端点经 CDN 反代，该 CDN 的 idle 超时配置在 4-9 分钟，时间不固定。一旦断开，客户端用相同的令牌重连，再次击中 idle 超时，形成周期。这本该不是问题——服务端在 ",[15,19974,19975],{},"\u002Fauto-pair"," 响应里已返回新的 WebSocket 地址，客户端只需消费这个字段切换端点即可。",[11,19978,19979,19980,19983,19984,19987],{},"但客户端代码从未读取过这个字段。ws_runner 始终硬拼 base_url 生成连接地址，App.tsx 只提取了 ",[15,19981,19982],{},"device_token","，对 ",[15,19985,19986],{},"ws_endpoint"," 视而不见。结果是服务端返了新地址，客户端装作没看见，继续走旧路径。旧路径经 CDN，CDN idle 触发，17 分钟周期形成。",[11,19989,19990],{},"修复需要四处联动：",[123,19992,19993,19999,20008,20011],{},[126,19994,19995,19996,19998],{},"在 AppState 加一个 ",[15,19997,19986],{}," 字段，用 RwLock 包装以便运行时读取",[126,20000,20001,20004,20005,20007],{},[15,20002,20003],{},"set_device_token"," 命令新增参数，收到服务端返的 ",[15,20006,19986],{}," 时校验并持久化到 config.json",[126,20009,20010],{},"ws_runner 启动与每次重连前都从 RwLock 读取最新地址，不再拼装",[126,20012,20013,20014,20016],{},"App.tsx 消费 ",[15,20015,19986],{}," 字段，传给 invoke",[11,20018,20019,20020,20023,20024,20026],{},"兼容性处理也做了：env 变量 ",[15,20021,20022],{},"CPA_WS_ENDPOINT"," 可强制指定（灰度用），服务端没返 ",[15,20025,19986],{}," 时退化到旧路径拼装，config.json 校验失败时保留现有值不覆盖。",[31,20028,20030],{"id":20029},"_1010-发版中的五个坑","1.0.10 发版中的五个坑",[11,20032,20033],{},"代码改好了，但发版流程成了另一个故事。",[11,20035,20036],{},[488,20037,20038],{},"坑 1：假 1.0.10",[11,20040,20041,20042,20045],{},"发版脚本在 Windows 构建机上只 scp 了 tauri.conf.json 这一个文件，没有同步整个仓源码。构建机的 git HEAD 还停在 1.0.8 的 commit，Cargo.toml workspace.package.version 还是 ",[15,20043,20044],{},"0.1.0","。结果是 cargo build 出来的二进制是 1.0.8 代码，但 NSIS 打包时用了 tauri.conf.json 里的\"1.0.10\"字符串当文件名——实际上是 1.0.8 代码套上 1.0.10 的皮。",[11,20047,20048,20049,20052,20053,20056,20057,20060],{},"应急是用 ",[15,20050,20051],{},"git ls-files | tar over ssh"," 把真 1.0.10 源码同步过去，清掉 macOS metadata 的 ",[15,20054,20055],{},"._*"," 文件（tar 直接打了这些，Windows 上 tauri-build 把 ",[15,20058,20059],{},"capabilities\u002F._default.json"," 当 JSON 解析失败），重新 build。",[11,20062,20063,20064,20067,20068,20071],{},"1.0.11 的发版脚本改成了完整源码同步加 ",[15,20065,20066],{},"tar --exclude='._*'"," 和 Win 端 ",[15,20069,20070],{},"Remove-Item '._*'"," 双保险。",[11,20073,20074],{},[488,20075,20076],{},"坑 2：ssh exit code 谜团",[11,20078,20079],{},"phase B 跑完整发版脚本时，step 3（Win build）完成了——NSIS bundle 和 minisign 签名都成功写出来了——但 ssh 仍然返 exit code 1，导致脚本静默退出，step 4-10 全跳过。",[11,20081,20082,20083,20086,20087,20090,20091,20094],{},"当时推测是 ",[15,20084,20085],{},"ssh ... | tail -10"," 触发 SIGPIPE，但这个推测被现场查证打脸了：tail 会完整消费 stdin，不会向上游发 SIGPIPE。真根因当时没查清楚，可能是 PowerShell 某个 cmdlet 设了非零 ",[15,20088,20089],{},"$LASTEXITCODE","，也可能是 ssh 在大量 escape sequence 输出时本身返非零，也可能是脚本框架的 ",[15,20092,20093],{},"pipefail"," 组合出了问题。",[11,20096,20097,20098,88,20101,20104],{},"1.0.11 用了一个兜底方案：脚本监测 build 成功的标志——",[15,20099,20100],{},"Finished 1 bundle at:",[15,20102,20103],{},"Finished 1 updater signature at:"," 双命中才认为成功，忽略 ssh exit code。这个兜底已经把\"ssh exit code 非零\"这条问题路径变成了已知但可控的状态：即使 ssh 返错，只要 marker 出现了，就继续执行后续步骤。1.0.11 发版时 log 记录了这次抗性：「ssh exit=1 非零，但 build marker 命中，视为成功」。",[11,20106,20107],{},[488,20108,20109],{},"坑 3：gitlink 边界",[11,20111,20112,20113,20116],{},"发版脚本 step 10 尝试 ",[15,20114,20115],{},"git add cpa-connector\u002F...\u002Ftauri.conf.json","，但因为 cpa-connector 是 gitlink（160000 mode）不是真 submodule，git 会静默跳过这条跨边界的路径。结果 tauri.conf.json 没被 add 进 root 仓的 commit。",[11,20118,20119],{},"这里需要在 cpa-connector 子仓内完成一次独立 commit（包含 Cargo.toml、Cargo.lock、package.json、tauri.conf.json），然后回到 root 仓 add 整个 gitlink 指针。1.0.11 的发版脚本改成了这个流程。",[11,20121,20122],{},[488,20123,20124],{},"坑 4 和 5：参数漏传与版本号不同步",[11,20126,20127,20128,20131,20132,20135,20136,20138],{},"Task 9 重构了前端的 ",[15,20129,20130],{},"tryAutoPairOnce"," 这个 helper 时，body 从 ",[15,20133,20134],{},"{ hostname, os, connector_version }"," 意外退化成了 ",[15,20137,15455],{},"，导致服务端拿不到客户端版本号。同时 package.json 里的 version 字段还停留在 1.0.0，跟 Cargo.toml 的 1.0.9 完全脱节。",[11,20140,20141],{},"这两个都算是发版前的信息丢失，1.0.10 发版后才被指出。",[31,20143,20145],{"id":20144},"_1010-的验证与回滚","1.0.10 的验证与回滚",[11,20147,20148,20149,20152],{},"1.0.10 在 5 月 27 日上午 10:34 开始滚动更新。测试机验证了 23 分钟无断线（vs 之前每 17 分钟必断），服务端日志开始出现 ",[15,20150,20151],{},"host=\u003C直连 WS 域名>"," 的访问。4 小时窗口内，新 WS 入口命中 992 次，旧入口仍有 209566 次（因为 1.0.9 客户端的 OTA 是自然滚动，不是强推）。",[11,20154,20155],{},"但随后用户报了一个投诉，初看像是 1.0.10 的问题。经过排查发现是 openclaw-runtime 1.0.23 的一个独立 schema 问题，与 1.0.10 无关。这件事触发了一个 P0，但因为根因被快速定位到另一个模块，没有对 1.0.10 造成滚动阻断。",[11,20157,20158,20159,20162,20163,20166],{},"1.0.10 本身的回滚预案是改 ",[15,20160,20161],{},"apps\u002Fapi\u002Fsrc\u002Fconnector\u002Fupdates.ts"," 的 RELEASE_CHAIN 数组，删掉 ",[15,20164,20165],{},"'1.0.10'"," 这一项，再重部署服务端。已升级的客户端不会自动回退（Tauri updater 不支持降级），但新检查的 1.0.9 用户就不会继续升级了。这一设计决策埋下了一个伏笔：一旦版本链里出现过某个版本，再想把它从升级路径上彻底抹掉就很困难，因为已升级的用户成了\"污染源\"，他们可能带着该版本的各种遗留问题继续在线。这正是分阶段发版与步进升级设计要反复考量的权衡点。",[26,20168,20170],{"id":20169},"_1011两个小时内的闭环","1.0.11：两个小时内的闭环",[11,20172,20173],{},"1.0.10 发版后的第四个小时，某个客户端用失效的令牌反复击中 WebSocket 4401 拒绝，服务端 30 分钟内记录了 5638 次 reject，来自 2 个孤儿 device，token 前缀稳定但 DB 里查不到。",[11,20175,20176],{},"这是另一个被延期的问题。客户端收到 4401（token 失效）时没有自愈逻辑，只会 exponential backoff 后用同一个失效 token 再试一次，陷入\"僵尸状态\"——在线但永远连不上 WS。与其说这是 1.0.10 的新问题，不如说是 1.0.10 的发版暴露了原本就存在的设计缺陷。",[11,20178,20179],{},"1.0.11 同步推进了三个方向的修复：",[11,20181,20182],{},[488,20183,20184],{},"服务端黑名单",[11,20186,20187],{},"cpa-api 新增一个 TokenBlacklist 类，维护一个 LRU 缓存。收到 4401（token 失效）后，把这个 token 加进黑名单，5 分钟内的重试直接返 4401，不走 DB 查询。这样做的好处是降低 DB 压力，坏处是增加内存占用，但 5 分钟的 TTL 和 LRU 限容使得这个成本可控。",[11,20189,20190],{},[488,20191,20192],{},"客户端自愈",[11,20194,20195,20196,20199,20200,20203],{},"改造 ws.rs 的错误类型，让 4401 close code 能被单独识别为 ",[15,20197,20198],{},"WsError::TokenInvalid","。ws_runner 收到这个错误时，立即清空 in-memory 的 device_token（Mutex 设为 None），emit 一个 ",[15,20201,20202],{},"connector:\u002F\u002Ftoken-invalid"," 事件，不 sleep 直接进入下一轮 loop。frontend 端 listen 这个事件，触发 auto-pair 重新获取 token。这样一来，一次失效就能在 5-15 秒内自愈，不会陷入无限重试。",[11,20205,20206],{},[488,20207,20208],{},"发版脚本全自动化",[11,20210,20211],{},"1.0.10 里 step 4-10 需要手工兜底。1.0.11 把 step 1（版本 bump）改成了在子仓内完成 commit（idempotent skip 检查保证重跑不会失败），step 3 加了 build marker 检查，step 4 也用同样的 exit code 捕获逻辑。最后 step 1-10 全部自动完成。",[31,20213,20215],{"id":20214},"_1011-的数据","1.0.11 的数据",[11,20217,20218,20219,20222],{},"1.0.11 在 5 月 27 日下午 14:30 发版。23 分钟内，那个主要的 zombie token（前缀记录为 ",[15,20220,20221],{},"ct_UAGorMM","）的命中频率从发版前的 47\u002F分钟 降到 0.17\u002F分钟。对比是精确的：TTL 是 5 分钟，日志里看到的恰好是 5 分钟周期的 4 次 first-hit——14:35、14:40、14:45、14:50，说明黑名单在按秒级别的精度工作。",[11,20224,20225,20228],{},[15,20226,20227],{},"invalid or revoked token"," reject 日志整体从 53\u002F分钟 降到 3\u002F分钟，94% 的噪声被消除。",[11,20230,20231],{},"从发版开始到完成的总耗时是 80 分钟（spec 起草 → 发版完成），远快于 1.0.10 的 12 小时——主要是因为没有了\"假版本\"和手工兜底这两个坑。",[11,20233,20234,20235,20238,20239,20242,20243,20246,20247,20250],{},"客户端配置清理的验证是这样的：启动 1.0.11 时，旧配置目录（",[15,20236,20237],{},"%APPDATA%\u002Fcpa\u002Fcpa-connector\u002F","）存在且有历史 session.json，cleanup 把它 rename 成 ",[15,20240,20241],{},".bak.20260527-141556","；第二次启动时我手动造了一个 fake session.json，它又被 rename 成了不同时间戳的 ",[15,20244,20245],{},".bak","；第三次启动时老目录已经不存在，log 输出 ",[15,20248,20249],{},"DEBUG no legacy config dir to clean up","，幂等。软删除策略保留了原文件，方便后续排查。",[26,20252,20253],{"id":20253},"发版复盘该记录什么",[11,20255,20256,20257,781],{},"这两次发版的共同教训是：",[488,20258,20259],{},"预期效果与实际效果的差异往往来自非功能层面，发版前遗漏的验证会以人工兜底的成本在发版中浮现",[11,20261,20262],{},"1.0.10 的预期是一次提交、一次 build、一个发版脚本的无缝运行。实际遇到了\"代码没同步\"、\"metadata 污染\"、\"git 跨边界\"、\"参数漏传\"这四个层次递进的问题，每一个都需要在发版当时即时判断、即时应急、即时修复。如果这些问题在代码审查或发版前的干运行中被抓到，成本会低一个数量级。",[11,20264,20265,20266,20269],{},"1.0.11 的收益是对 1.0.10 的这些漏洞做了结构化的修复，但更重要的是建立了",[488,20267,20268],{},"验收指标清单","。版本号同步检查、发版脚本的每一步都有 success marker、gitlink 操作必须在子仓内完成、参数完整性的代码审查项。这不是一次性的修补，而是对\"发版这件事\"的流程重塑。",[11,20271,20272,20273,20276,20277,781],{},"回滚判据也值得明确。1.0.10 的触发条件是\"ws rejected 反升或 connector 启动失败率 > 0.1%\"，对应的回滚操作是修改 RELEASE_CHAIN 截断升级路径。但正如前面提到的，这个方案有一个根本限制：",[488,20274,20275],{},"已升级的用户成了污染源，无法通过服务端操作让他们回退","。这意味着一旦发出去的版本有不可接受的 bug，修复也必须通过新版本修补，不能指望用户自动回到前一个版本。这推导出一个硬性要求：",[488,20278,20279],{},"发版前的验证必须足够彻底，因为回滚的代价极高",[11,20281,20282],{},"1.0.10 和 1.0.11 的连续发版正好说明了这一点。1.0.10 解决了一个明确的功能问题，但在过程中埋了四个流程坑；1.0.11 不光修了功能缺陷（4401 自愈），也补上了流程漏洞（发版脚本全自动）。如果 1.0.10 能在发版前避免那些坑，1.0.11 就没必要冲这么紧。如果不能，1.0.11 这一次的\"加固\"就成了对下一轮发版的保险——每一次发版都可能遗留新的坑，但流程上的防御等级在递增。",{"title":183,"searchDepth":184,"depth":184,"links":20284},[20285,20289,20292],{"id":19965,"depth":184,"text":19966,"children":20286},[20287,20288],{"id":20029,"depth":189,"text":20030},{"id":20144,"depth":189,"text":20145},{"id":20169,"depth":184,"text":20170,"children":20290},[20291],{"id":20214,"depth":189,"text":20215},{"id":20253,"depth":184,"text":20253},"2026-06-15",{},"\u002F2026-06-15-connector",{"title":19957,"description":19962},"2026-06-15-Connector两次发版复盘","两天内连发两版解决 WS 端点变更与僵尸令牌问题，从手动兜底到全自动发版的演进。",[20300,19463,20301,19953,20302],"Connector","发版","Rust","z88KhEQF4il5fu5KEpSAcE6LACpC1v_CsLysuzrCk3Q",{"id":20305,"title":20306,"body":20307,"column":196,"date":20974,"description":20311,"extension":199,"hero_image":200,"meta":20975,"navigation":202,"path":20976,"seo":20977,"series_id":200,"severity":200,"stem":20978,"summary":20979,"tags":20980,"__hash__":20984},"posts\u002F2026-06-13-微信与Obsidian的智能捕获管线.md","让模型决定分类，但不让它碰文件系统",{"type":8,"value":20308,"toc":20962},[20309,20312,20316,20319,20326,20336,20339,20359,20362,20365,20401,20404,20418,20422,20425,20431,20434,20454,20457,20460,20465,20558,20570,20575,20581,20584,20589,20600,20603,20606,20632,20635,20638,20641,20644,20690,20696,20702,20715,20718,20721,20724,20730,20801,20807,20860,20865,20870,20873,20876,20879,20885,20896,20903,20907,20910,20913,20924,20927,20930,20933,20953,20956,20959],[11,20310,20311],{},"在微信里记笔记，内容要进 Obsidian 知识库，手工转换既慢又容易出错。写一个 Obsidian 插件把格式规则固定化，也无法适应微信和公众号笔记五花八门的样式。我决定把 Claude 加进来：收消息→分类→起标题→格式化→落盘，再回执通知。",[26,20313,20315],{"id":20314},"核心架构claude-决策node-写盘","核心架构：Claude 决策，Node 写盘",[11,20317,20318],{},"首先明确职责边界，这是避免混乱的关键。",[11,20320,20321,20322,20325],{},"Claude 的工作是",[488,20323,20324],{},"纯决策","：给定一条消息和分类表，输出结构化结果——归入哪个分类、建新文档还是追加到现有文档、标题是什么、标签列表、正文格式化后长什么样。结果都用 JSON 表示。",[11,20327,20328,20329,20332,20333,781],{},"Node 的工作是",[488,20330,20331],{},"确定性执行","：接收 Claude 的决策结果后，负责所有文件读写——新建文档、追加到已有文档、处理图片、管理去重、并发序列化。",[488,20334,20335],{},"不让 Claude 直接操盘文件系统",[11,20337,20338],{},"这个分工的好处：",[243,20340,20341,20347,20353],{},[126,20342,20343,20346],{},[488,20344,20345],{},"风险隔离","：LLM 的输出有概率不确定（分类可能不准），但文件操作必须确定。如果 Claude 直接写盘，分类抖动可能污染已有文档、重复建文件、覆盖历史记录。现在坏决策最多只会建错新文件，不会改坏既有内容。",[126,20348,20349,20352],{},[488,20350,20351],{},"幂等与并发","：并发来的消息按消息 ID 去重，串行通过写入队列写盘。没有 LLM 的非决定性卡在关键路径上。",[126,20354,20355,20358],{},[488,20356,20357],{},"可测试性","：Claude 的逻辑用假实现隔离测试，vault-writer 的逻辑用临时目录单测。",[26,20360,20361],{"id":20361},"数据流与核心组件",[11,20363,20364],{},"单条消息的完整生命周期：",[243,20366,20367,20373,20379,20385,20391],{},[126,20368,20369,20372],{},[488,20370,20371],{},"listener","：WeChatFerry 监听主号私聊，提取消息ID、文本、图片，构造 Capture 对象。",[126,20374,20375,20378],{},[488,20376,20377],{},"dedup","：msgId 按已处理集合去重（持久化到 processed.json，防 WCF 重发导致重复落盘）。",[126,20380,20381,20384],{},[488,20382,20383],{},"classifier","：遍历 vault 列出已有文档按分类分组，把消息文本+分类表描述发给 Claude。Claude 一次性决定归类、action(new|append)、新标题、标签、正文。失败或超时降级为 00-Inbox 原样保存。",[126,20386,20387,20390],{},[488,20388,20389],{},"vault-writer","：按决策新建或追加文件。图片从临时区移到 vault 附件目录。所有操作走串行队列，天然排斥并发冲突。",[126,20392,20393,20396,20397,20400],{},[488,20394,20395],{},"receipt","：回执主号：\"✓ 新建 → ",[407,20398,20399],{},"01-技术","，标题：Redis 锁续期\"。",[11,20402,20403],{},"失败处理分两层：",[123,20405,20406,20412],{},[126,20407,20408,20411],{},[488,20409,20410],{},"第一层","：classifier 异常→降级到 Inbox 分类，原消息文本作为 body。",[126,20413,20414,20417],{},[488,20415,20416],{},"第二层","：vault-writer 也失败→回执错误信息，原消息不丢（至少进了 dedup）。",[26,20419,20421],{"id":20420},"claude-分类的提示词设计","Claude 分类的提示词设计",[11,20423,20424],{},"Claude 的 prompt 包三部分信息：",[399,20426,20429],{"className":20427,"code":20428,"language":1327},[1325],"【可选分类】\n- 01-技术: 技术笔记、踩坑、方案、命令、代码片段\n- 02-项目: 具体项目的记录、进展、待办\n...\n- 00-Inbox: 兜底：拿不准归哪类的进这里\n\n【各分类下已有文档标题】\n- 01-技术: Redis 分布式锁 | OTA 增量包大小优化 | Python 装饰器\n- 02-项目: Q2 KPI 指标 | 客户需求评审\n...\n\n【规则】\n1. 只能选上面列出的分类 key\n2. 若与某个\"已有文档标题\"主题相似且确信，用 action=append，targetFile 填该标题；否则 action=new\n3. 拿不准就 00-Inbox + action=new\n4. 只输出一个 ```json 代码块，字段：category, action(new|append), targetFile, title, tags(数组), body\n",[15,20430,20428],{"__ignoreMap":183},[11,20432,20433],{},"关键设计点：",[123,20435,20436,20442,20448],{},[126,20437,20438,20441],{},[488,20439,20440],{},"传已有标题清单","而不是全部文档：vault 大了之后这会撑爆 token，后续优化改向量检索 Top-N 候选。v1 库空，列全量可接受。",[126,20443,20444,20447],{},[488,20445,20446],{},"action=append 时 targetFile 必须来自该分类的已有标题清单","：确保 append 不会创建新文件。如果 Claude 编造了不存在的标题，validator 改回 action=new。",[126,20449,20450,20453],{},[488,20451,20452],{},"拿不准就 new","：错建新文件\u003C错污染已有文档。",[26,20455,20456],{"id":20456},"文件写入的不可变原则",[11,20458,20459],{},"vault-writer 的核心逻辑：",[11,20461,20462,1244],{},[488,20463,20464],{},"新建",[399,20466,20468],{"className":18628,"code":20467,"language":18630,"meta":183,"style":183},"路径: \u003Cvault>\u002F\u003Ccategory>\u002F\u003C安全标题>.md\n内容:\n---\ntitle: Redis 分布式锁续期方案\ncategory: 01-技术\ntags: [redis, 分布式锁, 并发]\ncreated: 2026-06-13 14:30\nsource: wechat\n---\n\n\u003CClaude 格式化后的正文>\n\n![[20260613-1430-image.png]]\n",[15,20469,20470,20495,20500,20505,20510,20515,20520,20525,20530,20534,20538,20549,20553],{"__ignoreMap":183},[407,20471,20472,20475,20478,20481,20484,20487,20489,20492],{"class":409,"line":410},[407,20473,20474],{"class":554},"路径",[407,20476,20477],{"class":413},": \u003C",[407,20479,20480],{"class":8622},"vault",[407,20482,20483],{"class":413},">\u002F\u003C",[407,20485,20486],{"class":8622},"category",[407,20488,20483],{"class":413},[407,20490,20491],{"class":476},"安全标题",[407,20493,20494],{"class":413},">.md\n",[407,20496,20497],{"class":409,"line":184},[407,20498,20499],{"class":413},"内容:\n",[407,20501,20502],{"class":409,"line":189},[407,20503,20504],{"class":413},"---\n",[407,20506,20507],{"class":409,"line":452},[407,20508,20509],{"class":413},"title: Redis 分布式锁续期方案\n",[407,20511,20512],{"class":409,"line":458},[407,20513,20514],{"class":413},"category: 01-技术\n",[407,20516,20517],{"class":409,"line":464},[407,20518,20519],{"class":413},"tags: [redis, 分布式锁, 并发]\n",[407,20521,20522],{"class":409,"line":470},[407,20523,20524],{"class":413},"created: 2026-06-13 14:30\n",[407,20526,20527],{"class":409,"line":480},[407,20528,20529],{"class":413},"source: wechat\n",[407,20531,20532],{"class":409,"line":1477},[407,20533,20504],{"class":413},[407,20535,20536],{"class":409,"line":1483},[407,20537,1827],{"emptyLinePlaceholder":202},[407,20539,20540,20542,20544,20547],{"class":409,"line":2139},[407,20541,1073],{"class":413},[407,20543,10930],{"class":476},[407,20545,20546],{"class":554}," 格式化后的正文",[407,20548,13679],{"class":413},[407,20550,20551],{"class":409,"line":2180},[407,20552,1827],{"emptyLinePlaceholder":202},[407,20554,20555],{"class":409,"line":7546},[407,20556,20557],{"class":413},"![[20260613-1430-image.png]]\n",[123,20559,20560,20563],{},[126,20561,20562],{},"文件名去除 Windows 非法字符、限长 80 字。",[126,20564,20565,20566,20569],{},"重名加紧凑时间后缀（",[15,20567,20568],{},"Redis 锁-1430.md","），不覆盖。",[11,20571,20572,1244],{},[488,20573,20574],{},"追加",[399,20576,20579],{"className":20577,"code":20578,"language":1327},[1325],"原内容 \u003C不改一字>\n\n## 14:30 追加\n\n\u003C新增内容>\n\n![[img2.png]]\n",[15,20580,20578],{"__ignoreMap":183},[11,20582,20583],{},"只在文末加小节，绝不改写旧内容。追加同样走串行队列，多个消息对同一文档的追加不会交错。",[11,20585,20586,1244],{},[488,20587,20588],{},"图片处理",[11,20590,20591,20592,20595,20596,20599],{},"从临时区移到 ",[15,20593,20594],{},"\u003Cvault>\u002F99-附件\u002F\u003C紧凑时间戳>-\u003C原文件名>.png","，正文用 wikilink 引用 ",[15,20597,20598],{},"![[20260613-1430-image.png]]","。Obsidian 自动刷新并识别嵌入。",[26,20601,20602],{"id":20602},"去重与幂等",[11,20604,20605],{},"WCF 有重发行为，msgId 相同的消息可能来两次。processed.json 记录已处理的 msgId：",[399,20607,20609],{"className":3591,"code":20608,"language":3593,"meta":183,"style":183},"[\"msg_id_1\", \"msg_id_2\", \"msg_id_3\"]\n",[15,20610,20611],{"__ignoreMap":183},[407,20612,20613,20616,20619,20621,20624,20626,20629],{"class":409,"line":410},[407,20614,20615],{"class":413},"[",[407,20617,20618],{"class":429},"\"msg_id_1\"",[407,20620,433],{"class":413},[407,20622,20623],{"class":429},"\"msg_id_2\"",[407,20625,433],{"class":413},[407,20627,20628],{"class":429},"\"msg_id_3\"",[407,20630,20631],{"class":413},"]\n",[11,20633,20634],{},"listener 每条消息来时检查，命中过则忽略。标记操作在最后，确保消息全部处理成功才记录。",[11,20636,20637],{},"这设计下，网络抖动导致的消息重发、甚至小号掉线重连后的历史消息再次推送，都不会重复落盘。",[26,20639,20640],{"id":20640},"中转站密钥隔离",[11,20642,20643],{},"Claude Code CLI 的调用走 spawn 子进程，环境变量注入：",[399,20645,20647],{"className":18628,"code":20646,"language":18630,"meta":183,"style":183},"const env = {\n  ANTHROPIC_BASE_URL: config.baseUrl,      \u002F\u002F https:\u002F\u002Fkey.agtk.cn\n  ANTHROPIC_AUTH_TOKEN: config.authToken,  \u002F\u002F 用户自注册的 key\n};\nawait runCommand(nodePath, args, { timeoutMs, env });\n",[15,20648,20649,20660,20668,20676,20680],{"__ignoreMap":183},[407,20650,20651,20653,20656,20658],{"class":409,"line":410},[407,20652,700],{"class":417},[407,20654,20655],{"class":476}," env",[407,20657,706],{"class":417},[407,20659,523],{"class":413},[407,20661,20662,20665],{"class":409,"line":184},[407,20663,20664],{"class":413},"  ANTHROPIC_BASE_URL: config.baseUrl,      ",[407,20666,20667],{"class":528},"\u002F\u002F https:\u002F\u002Fkey.agtk.cn\n",[407,20669,20670,20673],{"class":409,"line":189},[407,20671,20672],{"class":413},"  ANTHROPIC_AUTH_TOKEN: config.authToken,  ",[407,20674,20675],{"class":528},"\u002F\u002F 用户自注册的 key\n",[407,20677,20678],{"class":409,"line":452},[407,20679,8870],{"class":413},[407,20681,20682,20684,20687],{"class":409,"line":458},[407,20683,6302],{"class":417},[407,20685,20686],{"class":554}," runCommand",[407,20688,20689],{"class":413},"(nodePath, args, { timeoutMs, env });\n",[11,20691,20692,20695],{},[15,20693,20694],{},".env"," 文件里存关键数据：",[399,20697,20700],{"className":20698,"code":20699,"language":1327},[1325],"ANTHROPIC_BASE_URL=https:\u002F\u002Fkey.agtk.cn\nANTHROPIC_AUTH_TOKEN=sk_xxxx\n",[15,20701,20699],{"__ignoreMap":183},[11,20703,20704,20707,20708,20710,20711,20714],{},[15,20705,20706],{},".gitignore"," 排除 ",[15,20709,20694],{},"，不进代码库。自包含发行包也不带任何密钥——用户在 setup 时从 ",[15,20712,20713],{},"key.agtk.cn"," 自己取 key 粘贴，由客户自付费用。",[11,20716,20717],{},"这样做的风险隐含：内容会经第三方中转站再到 Claude（而非直连 claude.ai）。setup 里明确风险告知和确认。",[26,20719,20720],{"id":20720},"配置的参数化",[11,20722,20723],{},"三个配置文件：",[11,20725,20726,20729],{},[488,20727,20728],{},"config.yaml","：业务配置",[399,20731,20733],{"className":8608,"code":20732,"language":8610,"meta":183,"style":183},"vault_path: \"D:\\\\Obsidian\\\\MyVault\"\nattachments_dir: \"99-附件\"\nowner_wxid: \"wxid_main\"           # 首条私聊自动绑定\nmodel: \"claude-opus-4-8\"          # 用户选择\nrequest_timeout_ms: 30000\n",[15,20734,20735,20756,20766,20779,20791],{"__ignoreMap":183},[407,20736,20737,20740,20742,20745,20748,20751,20753],{"class":409,"line":410},[407,20738,20739],{"class":8622},"vault_path",[407,20741,3607],{"class":413},[407,20743,20744],{"class":429},"\"D:",[407,20746,20747],{"class":476},"\\\\",[407,20749,20750],{"class":429},"Obsidian",[407,20752,20747],{"class":476},[407,20754,20755],{"class":429},"MyVault\"\n",[407,20757,20758,20761,20763],{"class":409,"line":184},[407,20759,20760],{"class":8622},"attachments_dir",[407,20762,3607],{"class":413},[407,20764,20765],{"class":429},"\"99-附件\"\n",[407,20767,20768,20771,20773,20776],{"class":409,"line":189},[407,20769,20770],{"class":8622},"owner_wxid",[407,20772,3607],{"class":413},[407,20774,20775],{"class":429},"\"wxid_main\"",[407,20777,20778],{"class":528},"           # 首条私聊自动绑定\n",[407,20780,20781,20783,20785,20788],{"class":409,"line":452},[407,20782,15543],{"class":8622},[407,20784,3607],{"class":413},[407,20786,20787],{"class":429},"\"claude-opus-4-8\"",[407,20789,20790],{"class":528},"          # 用户选择\n",[407,20792,20793,20796,20798],{"class":409,"line":458},[407,20794,20795],{"class":8622},"request_timeout_ms",[407,20797,3607],{"class":413},[407,20799,20800],{"class":476},"30000\n",[11,20802,20803,20806],{},[488,20804,20805],{},"categories.yaml","：分类表，改它即改 Claude 的分类依据",[399,20808,20810],{"className":8608,"code":20809,"language":8610,"meta":183,"style":183},"- key: \"01-技术\"\n  desc: \"技术笔记、踩坑、方案、命令、代码片段\"\n- key: \"02-项目\"\n  desc: \"具体项目的记录、进展、待办\"\n...\n",[15,20811,20812,20825,20835,20846,20855],{"__ignoreMap":183},[407,20813,20814,20817,20820,20822],{"class":409,"line":410},[407,20815,20816],{"class":413},"- ",[407,20818,20819],{"class":8622},"key",[407,20821,3607],{"class":413},[407,20823,20824],{"class":429},"\"01-技术\"\n",[407,20826,20827,20830,20832],{"class":409,"line":184},[407,20828,20829],{"class":8622},"  desc",[407,20831,3607],{"class":413},[407,20833,20834],{"class":429},"\"技术笔记、踩坑、方案、命令、代码片段\"\n",[407,20836,20837,20839,20841,20843],{"class":409,"line":189},[407,20838,20816],{"class":413},[407,20840,20819],{"class":8622},[407,20842,3607],{"class":413},[407,20844,20845],{"class":429},"\"02-项目\"\n",[407,20847,20848,20850,20852],{"class":409,"line":452},[407,20849,20829],{"class":8622},[407,20851,3607],{"class":413},[407,20853,20854],{"class":429},"\"具体项目的记录、进展、待办\"\n",[407,20856,20857],{"class":409,"line":458},[407,20858,20859],{"class":554},"...\n",[11,20861,20862,20864],{},[488,20863,20694],{},"：敏感信息（密钥、中转站地址）",[399,20866,20868],{"className":20867,"code":20699,"language":1327},[1325],[15,20869,20699],{"__ignoreMap":183},[11,20871,20872],{},"三层分离使得：业务逻辑能复用，分类策略能快速迭代（改 YAML 即可），敏感信息不泄露。",[26,20874,20875],{"id":20875},"自包含发行包",[11,20877,20878],{},"最终产物是一个 zip，无需用户装 Node、不连公共 npm 源：",[399,20880,20883],{"className":20881,"code":20882,"language":1327},[1325],"obsidian-weix-0.1.0.zip\n├─ node\\node.exe              （便携 Node）\n├─ app\\\n│  ├─ src\\                      （核心代码）\n│  ├─ node_modules\\            （预装依赖）\n│  └─ templates\\               （配置模板）\n├─ install.bat\n└─ start.bat\n",[15,20884,20882],{"__ignoreMap":183},[11,20886,20887,20888,20891,20892,20895],{},"用户下载、解压、双击 ",[15,20889,20890],{},"install.bat","→setup 向导→粘贴 key→输入 vault 路径→风险确认→生成配置。然后双击 ",[15,20893,20894],{},"start.bat"," 启动。",[11,20897,20898,20899,20902],{},"构建机在 Windows x64 编译，执行 ",[15,20900,20901],{},"npm install","（得到目标平台的 native 产物），打包后上传到项目专用 CDN 分发。",[26,20904,20906],{"id":20905},"为什么不是-obsidian-插件","为什么不是 Obsidian 插件",[11,20908,20909],{},"Obsidian 插件跑在浏览器沙盒里，无法直接调 WCF、spawn CLI、管理进程生命周期。插件能做的是提供 UI 让用户手工输入内容，或者轮询本地文件监听外部写入。",[11,20911,20912],{},"相比之下，独立 Node 进程的好处：",[243,20914,20915,20918,20921],{},[126,20916,20917],{},"常驻内存，实时监听 WCF 消息事件。",[126,20919,20920],{},"能 spawn Claude Code CLI 并注入中转站密钥（需要环境变量隔离）。",[126,20922,20923],{},"确定性串行写盘，无沙盒限制。",[11,20925,20926],{},"代价是多了一个进程，但换来的是解耦和可靠性。",[26,20928,20929],{"id":20929},"工程检查清单",[11,20931,20932],{},"这套管线从设计到代码，需要覆盖的点：",[123,20934,20935,20938,20941,20944,20947,20950],{},[126,20936,20937],{},"单元测试：文件名净化、时间格式化、Markdown 渲染、去重、分类降级、append 格式。",[126,20939,20940],{},"集成测试：classifier 对中转站打桩；vault-writer 写临时目录校验产物；并发投递验证队列串行。",[126,20942,20943],{},"幂等性：msgId 去重持久化，重发消息不重建文件。",[126,20945,20946],{},"并发安全：写入队列保证串行，追加操作不交错。",[126,20948,20949],{},"失败降级：Claude 超时 30s 后重试 1 次，仍失败降级 Inbox；vault-writer 失败也降级。",[126,20951,20952],{},"脱敏：密钥进 .env 不进代码库，配置项参数化。",[11,20954,20955],{},"写盘前必须问清自己：如果这条消息落地失败了，会不会丢掉？不会——最坏情况进 00-Inbox 原文保存。如果 Claude 分类抖动，会改坏其他文档吗？不会——append 时校验 targetFile 存在性，不存在改成 new。",[11,20957,20958],{},"这些约束叠加起来，保证了管线的韧性。",[1267,20960,20961],{},"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 .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}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 .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":183,"searchDepth":184,"depth":184,"links":20963},[20964,20965,20966,20967,20968,20969,20970,20971,20972,20973],{"id":20314,"depth":184,"text":20315},{"id":20361,"depth":184,"text":20361},{"id":20420,"depth":184,"text":20421},{"id":20456,"depth":184,"text":20456},{"id":20602,"depth":184,"text":20602},{"id":20640,"depth":184,"text":20640},{"id":20720,"depth":184,"text":20720},{"id":20875,"depth":184,"text":20875},{"id":20905,"depth":184,"text":20906},{"id":20929,"depth":184,"text":20929},"2026-06-13",{},"\u002F2026-06-13-obsidian",{"title":20306,"description":20311},"2026-06-13-微信与Obsidian的智能捕获管线","设计一套通过微信私聊向 Claude 发送内容，自动分类、起标题、格式化后落入本机 Obsidian 的管线，核心是让 Claude 只做决策，Node 确定性写盘。",[20750,10930,19464,20981,20982,20983],"微信","知识管理","流水线","etSXBPunJdcpyR1gjx9AvLgkzRosbGDmUbZj8-W7kkY",{"id":20986,"title":20987,"body":20988,"column":1966,"date":21636,"description":20992,"extension":199,"hero_image":200,"meta":21637,"navigation":202,"path":21638,"seo":21639,"series_id":200,"severity":200,"stem":21640,"summary":21641,"tags":21642,"__hash__":21648},"posts\u002F2026-06-11-LLM网关的账号鉴权与反绕过.md","用量要算钱，那密钥就不能给客户端",{"type":8,"value":20989,"toc":21623},[20990,20993,20996,21000,21003,21075,21078,21088,21121,21197,21200,21204,21217,21288,21293,21300,21304,21307,21311,21335,21339,21342,21350,21358,21364,21379,21383,21386,21389,21431,21434,21437,21450,21453,21456,21459,21573,21576,21579,21605,21611,21613,21620],[11,20991,20992],{},"按用量计费的 LLM 中转服务面临一个根本矛盾：客户端是完全攻击者可控的代码，应用逻辑最终依赖模型 API，但鉴权与计费只能在服务端进行。一旦客户端能绕过网关直连上游，计费和配额就失效了——用户可以白嫖，也可以把你的中转当成代理给别人用。这不是\"更好的用户体验\"问题，而是商业模式破裂。",[26,20994,20995],{"id":20995},"绕过的三条路径与对应防堵",[31,20997,20999],{"id":20998},"路径-1客户端持有上游密钥","路径 1：客户端持有上游密钥",[11,21001,21002],{},"当前常见的方案是：桌面端登录后拿到平台的 API 密钥，本地保存，直接用这个密钥去请求上游模型。客户端代码大致像这样：",[399,21004,21008],{"className":21005,"code":21006,"language":21007,"meta":183,"style":183},"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",[15,21009,21010,21015,21033,21055,21066,21071],{"__ignoreMap":183},[407,21011,21012],{"class":409,"line":410},[407,21013,21014],{"class":528},"\u002F\u002F 不安全的旧做法\n",[407,21016,21017,21019,21022,21024,21027,21030],{"class":409,"line":184},[407,21018,700],{"class":417},[407,21020,21021],{"class":476}," apiKey",[407,21023,706],{"class":417},[407,21025,21026],{"class":554}," readLocallyStoredApiKey",[407,21028,21029],{"class":413},"(); ",[407,21031,21032],{"class":528},"\u002F\u002F 明文或\"加密\"存储\n",[407,21034,21035,21037,21040,21042,21044,21047,21049,21052],{"class":409,"line":189},[407,21036,700],{"class":417},[407,21038,21039],{"class":476}," response",[407,21041,706],{"class":417},[407,21043,7551],{"class":417},[407,21045,21046],{"class":554}," fetch",[407,21048,586],{"class":413},[407,21050,21051],{"class":429},"'https:\u002F\u002Fupstream-api.example\u002Fv1\u002Fmessages'",[407,21053,21054],{"class":413},", {\n",[407,21056,21057,21060,21063],{"class":409,"line":452},[407,21058,21059],{"class":413},"  headers: { ",[407,21061,21062],{"class":429},"'x-api-key'",[407,21064,21065],{"class":413},": apiKey },\n",[407,21067,21068],{"class":409,"line":458},[407,21069,21070],{"class":413},"  body: messagePayload\n",[407,21072,21073],{"class":409,"line":464},[407,21074,667],{"class":413},[11,21076,21077],{},"这个方案的问题在于：密钥是长期的、可移植的、不可吊销的。即使密钥被加密存储在本地，一个决心充分的用户可以解密、导出，然后用 curl 直连上游——根本不需要走你的客户端。更糟的是，这个密钥可能被多个用户共享（意外泄露或故意倒卖）。",[11,21079,21080,21083,21084,21087],{},[488,21081,21082],{},"防堵方案","：不向客户端下发真实的上游密钥，只下发短期凭证。具体实现是引入一个专用的 ",[15,21085,21086],{},"llmToken","（不同于普通的 accessToken）：",[123,21089,21090,21100,21115],{},[126,21091,21092,21095,21096,21099],{},[488,21093,21094],{},"签发","：用户请求 ",[15,21097,21098],{},"\u002Fapi\u002Fllm\u002Ftoken"," 端点时，后端校验 accessToken 有效→用户处于激活状态→当前设备的 session 未被撤销，然后生成一个 JWT 格式的 llmToken，TTL 设为 1–2 小时（足以覆盖一次长对话，但不会无限期存活）。",[126,21101,21102,21105,21106,56,21108,56,21111,21114],{},[488,21103,21104],{},"绑定","：llmToken 包含 ",[15,21107,1199],{},[15,21109,21110],{},"sessionId",[15,21112,21113],{},"deviceId"," 三个标识。前两个用于账号级的全局控制（用户被禁用、session 被远程踢下线），最后一个用于防倒卖（即使密钥被导出，也只能在绑定的设备上用）。",[126,21116,21117,21120],{},[488,21118,21119],{},"校验","：每次 LLM 调用网关时，网关验证签名→拆出 userId\u002FsessionId\u002FdeviceId→实时复校用户状态与 session（不依赖 token 的 TTL）。",[399,21122,21124],{"className":21005,"code":21123,"language":21007,"meta":183,"style":183},"\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",[15,21125,21126,21131,21151,21170,21189,21193],{"__ignoreMap":183},[407,21127,21128],{"class":409,"line":410},[407,21129,21130],{"class":528},"\u002F\u002F 防堵后的安全做法\n",[407,21132,21133,21135,21138,21140,21142,21145,21148],{"class":409,"line":184},[407,21134,700],{"class":417},[407,21136,21137],{"class":476}," llmToken",[407,21139,706],{"class":417},[407,21141,7551],{"class":417},[407,21143,21144],{"class":554}," fetchLlmToken",[407,21146,21147],{"class":413},"(accessToken, deviceId); ",[407,21149,21150],{"class":528},"\u002F\u002F 短期 token\n",[407,21152,21153,21155,21157,21159,21161,21163,21165,21168],{"class":409,"line":189},[407,21154,700],{"class":417},[407,21156,21039],{"class":476},[407,21158,706],{"class":417},[407,21160,7551],{"class":417},[407,21162,21046],{"class":554},[407,21164,586],{"class":413},[407,21166,21167],{"class":429},"'https:\u002F\u002Fgateway.example\u002Fapi\u002Fllm\u002Fv1\u002Fmessages'",[407,21169,21054],{"class":413},[407,21171,21172,21174,21177,21179,21182,21184,21186],{"class":409,"line":452},[407,21173,21059],{"class":413},[407,21175,21176],{"class":429},"'authorization'",[407,21178,3607],{"class":413},[407,21180,21181],{"class":429},"`Bearer ${",[407,21183,21086],{"class":413},[407,21185,5625],{"class":429},[407,21187,21188],{"class":413}," },\n",[407,21190,21191],{"class":409,"line":458},[407,21192,21070],{"class":413},[407,21194,21195],{"class":409,"line":464},[407,21196,667],{"class":413},[11,21198,21199],{},"相比之下，即使用户拿到了 llmToken，这个 token 也只能在原设备上用 1–2 小时。过期后需要重新签发，而签发必须经过 accessToken 校验（意味着如果账号被禁用或 session 被撤销，签发会立即失败）。不再有永久密钥在客户端浮动。",[31,21201,21203],{"id":21202},"路径-2直接改配置指向上游","路径 2：直接改配置指向上游",[11,21205,21206,21207,21209,21210,21213,21214,781],{},"客户端代码中通常有一个 ",[15,21208,3699],{}," 配置，指向网关：",[15,21211,21212],{},"https:\u002F\u002Fgateway.example\u002Fapi\u002Fllm","。攻击者可以修改客户端代码或配置文件，改成直接指向上游：",[15,21215,21216],{},"https:\u002F\u002Fupstream-api.example",[399,21218,21220],{"className":21005,"code":21219,"language":21007,"meta":183,"style":183},"\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",[15,21221,21222,21227,21244,21258],{"__ignoreMap":183},[407,21223,21224],{"class":409,"line":410},[407,21225,21226],{"class":528},"\u002F\u002F 攻击者修改配置后\n",[407,21228,21229,21231,21234,21236,21239,21241],{"class":409,"line":184},[407,21230,700],{"class":417},[407,21232,21233],{"class":476}," baseUrl",[407,21235,706],{"class":417},[407,21237,21238],{"class":429}," 'https:\u002F\u002Fupstream-api.example'",[407,21240,1121],{"class":413},[407,21242,21243],{"class":528},"\u002F\u002F 绕过网关\n",[407,21245,21246,21248,21250,21252,21255],{"class":409,"line":189},[407,21247,700],{"class":417},[407,21249,21021],{"class":476},[407,21251,706],{"class":417},[407,21253,21254],{"class":413}," storedToken; ",[407,21256,21257],{"class":528},"\u002F\u002F 不管这个 token 从哪来\n",[407,21259,21260,21262,21264,21266,21268,21270,21272,21275,21277,21280,21283,21285],{"class":409,"line":452},[407,21261,700],{"class":417},[407,21263,21039],{"class":476},[407,21265,706],{"class":417},[407,21267,7551],{"class":417},[407,21269,21046],{"class":554},[407,21271,586],{"class":413},[407,21273,21274],{"class":429},"`${",[407,21276,3699],{"class":413},[407,21278,21279],{"class":429},"}\u002Fv1\u002Fmessages`",[407,21281,21282],{"class":413},", { ",[407,21284,7262],{"class":417},[407,21286,21287],{"class":413}," });\n",[11,21289,21290,21292],{},[488,21291,21082],{},"：配置不要让客户端掌握。关键的网关地址与上游地址都应该由服务端下发或在部署时写死，而不是嵌在客户端配置文件里。同时，客户端应该有一个\"配置验证\"环节——比如在启动时校验 baseUrl 是否和预期值匹配，或者网关做请求签名验证。",[11,21294,21295,21296,21299],{},"但更根本的防堵来自",[488,21297,21298],{},"路径 1 的解决方案","：即使改了 baseUrl，也改不了 llmToken 的签发机制。直连上游时，你没有有效的、绑定了 deviceId 的短期 token，请求会直接失败。上游服务也不认识你的 llmToken（它只认 sub2api 的密钥），所以伪造一个不可能成功。",[31,21301,21303],{"id":21302},"路径-3复用他人凭据","路径 3：复用他人凭据",[11,21305,21306],{},"如果 llmToken 没有绑定，或绑定得太松散，用户 A 可以把自己的 token 分享给用户 B 用。这样用户 B 就能免费消耗用户 A 的额度，甚至整个平台的额度都被少数用户共享。",[11,21308,21309,1244],{},[488,21310,21082],{},[243,21312,21313,21319,21329],{},[126,21314,21315,21318],{},[488,21316,21317],{},"deviceId 绑定","：llmToken 中包含发起签发请求的设备 ID。网关在验证 token 时，检查当前请求的设备 ID 是否和 token 中的 deviceId 一致。设备 ID 可以基于硬件特征（MAC 地址、CPU 序列号）或操作系统本地生成的 UUID。设备难以伪造（虽然 Electron 应用中可以被修改，但需要重新编译客户端）。",[126,21320,21321,21324,21325,21328],{},[488,21322,21323],{},"sessionId 绑定","：llmToken 还绑定了当前登录的 session ID。如果用户 A 在设备 D1 登录，生成的 token 包含 ",[15,21326,21327],{},"sessionId='sess-abc'","。用户 B 不可能有相同的 sessionId（除非他也登录用户 A 的账号，但那时就真的是同一个账号了）。",[126,21330,21331,21334],{},[488,21332,21333],{},"即时吊销","：单设备互踢（用户 A 在另一台设备登录时，D1 的 session 被撤销）或远程封禁都会立即生效。网关不仅校验 token 的签名和 TTL，还会查一遍数据库确认 session 未被撤销。所以即使倒卖者拿到了别人的 token，一旦源用户被禁或 session 被踢，这个 token 就废了。",[26,21336,21338],{"id":21337},"网关的计费保障金额一致性的困境","网关的计费保障：金额一致性的困境",[11,21340,21341],{},"防住绕过只是第一步，还需要保证计费的完整性：请求被处理，就必须被正确计费；计费成功了，请求才能真正完成。否则会出现两类灾难：",[243,21343,21344,21347],{},[126,21345,21346],{},"请求已发出但计费失败 → 白嫖了额度",[126,21348,21349],{},"计费成功但请求被中断 → 用户被重复扣费",[11,21351,21352,21357],{},[488,21353,21354],{},[407,21355,21356],{},"待补：事务一致性"," 这涉及数据库事务、网关层幂等键设计等细节。当前素材中关于\"请求处理与计费如何保证在同一事务边界内\"的设计不足。",[11,21359,21360,21361,1244],{},"实践中采用的策略是 ",[488,21362,21363],{},"fail-closed",[123,21365,21366,21369,21376],{},[126,21367,21368],{},"余额查询走缓存（Redis 或进程内 LRU），TTL 设为 30–60 秒。命中且大于 0 就放行；命中且小于等于 0 就直接拒绝（HTTP 402）；未命中则同步查一次上游服务。",[126,21370,21371,21372,21375],{},"上游服务失败时（网络问题、超时）",[488,21373,21374],{},"拒绝该请求","（不放行，也不允许用户花钱）。这比\"放行再补扣\"更宁可一时用户不可用，也不容忍\"余额未知却已消费\"的场景。",[126,21377,21378],{},"缓存 TTL 窗口内（30–60 秒），同一用户的余额不会从正变负而立即被拦截。这个窗口内的透支是允许的代价，换来的是减少与上游服务的往返压力。缓存失败后有限重试（通常 1 次），仍失败就拒绝。",[26,21380,21382],{"id":21381},"网关的实时复校不相信-token-的-ttl","网关的实时复校：不相信 Token 的 TTL",[11,21384,21385],{},"即使 llmToken 的 TTL 设成 2 小时，不能依赖这个 2 小时直到 token 过期。如果用户账号在 1 小时后被禁用或 session 被远程踢下线，第二小时的请求不应该还能用旧 token 成功发出。",[11,21387,21388],{},"网关的每一次请求处理链都包括：",[243,21390,21391,21400,21403,21406,21415,21422,21425,21428],{},[126,21392,21393,21394,13067,21397,13615],{},"提取请求头中的 token（可能来自 ",[15,21395,21396],{},"x-api-key",[15,21398,21399],{},"authorization: Bearer",[126,21401,21402],{},"验证签名与 TTL 有效",[126,21404,21405],{},"从 token 中拆出 userId、sessionId、deviceId",[126,21407,21408,21409,21411,21412],{},"实时查数据库：用户的 ",[15,21410,3655],{}," 字段是否仍为 ",[15,21413,21414],{},"active",[126,21416,21417,21418,21421],{},"实时查数据库：该 sessionId 对应的 ",[15,21419,21420],{},"revokedAt"," 是否为空",[126,21423,21424],{},"余额门控：该用户的余额是否 > 0",[126,21426,21427],{},"限流：该用户的请求速率是否超过限额",[126,21429,21430],{},"通过全部检查后，用服务端保管的上游密钥代替 token，转发请求",[11,21432,21433],{},"前三步是 token 的格式与密码学验证，不需要 I\u002FO。后四步都走数据库查询，引入延迟但保证了最新状态。这样即使 token 本身还有 1 小时有效期，如果用户状态变了，下一次请求立即失败。",[26,21435,21436],{"id":21436},"限流与防刷",[11,21438,21439,21440,21442,21443,21445,21446,21449],{},"同时还需要防止单个用户刷爆系统。",[15,21441,21086],{}," 签发端点（",[15,21444,21098],{},"）本身需要限流，防止用户持续刷 token。",[15,21447,21448],{},"\u002Fapi\u002Fllm\u002Fv1\u002F*"," 代理端点需要按用户限流，比如限制每分钟最多 60 个请求。如果有用户超过限额，返回 HTTP 429 并告知。",[11,21451,21452],{},"限流计数在多实例部署时走 Redis 保证一致，单机时可用进程内 LRU 缓存（精度够用）。",[26,21454,21455],{"id":21455},"错误分类与用户体验",[11,21457,21458],{},"网关需要明确地区分不同的失败原因，这样客户端才能正确响应：",[1708,21460,21461,21477],{},[1711,21462,21463],{},[1714,21464,21465,21468,21471,21474],{},[1717,21466,21467],{},"错误",[1717,21469,21470],{},"HTTP 状态",[1717,21472,21473],{},"原因",[1717,21475,21476],{},"客户端处理",[1730,21478,21479,21495,21511,21526,21541,21557],{},[1714,21480,21481,21486,21489,21492],{},[1735,21482,21483],{},[15,21484,21485],{},"llm_token_invalid",[1735,21487,21488],{},"401",[1735,21490,21491],{},"token 签名失败、过期或格式错",[1735,21493,21494],{},"重新签发，签发失败则回登录页",[1714,21496,21497,21502,21505,21508],{},[1735,21498,21499],{},[15,21500,21501],{},"user_disabled",[1735,21503,21504],{},"403",[1735,21506,21507],{},"账号被禁用",[1735,21509,21510],{},"提示用户账号已禁用，登出",[1714,21512,21513,21518,21520,21523],{},[1735,21514,21515],{},[15,21516,21517],{},"session_revoked",[1735,21519,21488],{},[1735,21521,21522],{},"该 session 被撤销（异地登录踢下线）",[1735,21524,21525],{},"提示\"已在其他设备登录\"，回登录页",[1714,21527,21528,21533,21536,21538],{},[1735,21529,21530],{},[15,21531,21532],{},"insufficient_balance",[1735,21534,21535],{},"402",[1735,21537,4115],{},[1735,21539,21540],{},"提示充值，不做重试",[1714,21542,21543,21548,21551,21554],{},[1735,21544,21545],{},[15,21546,21547],{},"rate_limited",[1735,21549,21550],{},"429",[1735,21552,21553],{},"请求过于频繁",[1735,21555,21556],{},"指数退避重试",[1714,21558,21559,21564,21567,21570],{},[1735,21560,21561],{},[15,21562,21563],{},"upstream_error",[1735,21565,21566],{},"502",[1735,21568,21569],{},"上游服务错误或超时",[1735,21571,21572],{},"提示\"服务暂时不可用，请稍后重试\"",[26,21574,21575],{"id":21575},"设计权衡与代价",[11,21577,21578],{},"这套防绕过方案的代价是什么？",[243,21580,21581,21587,21593,21599],{},[126,21582,21583,21586],{},[488,21584,21585],{},"网关成为热路径","：每次 LLM 调用都必须经过网关，包括数据库查询。高并发时网关可能成为瓶颈。缓解方法是无状态设计（水平扩展）+ 缓存（减少数据库压力）+ 上游超时控制（防止卡住）。",[126,21588,21589,21592],{},[488,21590,21591],{},"可用性与安全的权衡","：fail-closed 策略意味着当上游服务的余额查询接口不可用时，所有用户都无法发起 LLM 调用。这是一个 all-or-nothing 的决策——宁可整个平台短时间无法用，也不允许\"余额未知但已消费\"的场景。代价是需要对上游服务的可用性做严格监控与告警，并预留人工切换开关（紧急放行）。",[126,21594,21595,21598],{},[488,21596,21597],{},"长会话的 Token 重取","：1–2 小时的 TTL 意味着长对话可能中途需要重取 token。由于 Electron 应用运行中无法热更环境变量，需要在应用层实现\"token 即将过期时自动重取\"的逻辑。如果 token 过期后仍在续取，accessToken 本身可能也已过期，这时需要用 refreshToken 刷新。处理不当会导致长对话中途断连。",[126,21600,21601,21604],{},[488,21602,21603],{},"设备 ID 的可信度","：设备 ID 基于硬件或本地 UUID，Electron 应用中理论上可以被修改（重新编译、patch 二进制）。这个防护针对的是\"非专业用户倒卖 token\"的场景，不能防住\"决心充分的开发者自己修改客户端\"。但那类用户通常会干脆把客户端改成绕过整个 login 流程，直接注入自己的上游 key，不会去倒卖 token。",[11,21606,21607,21610],{},[488,21608,21609],{},"这些代价都是可接受的","，因为目标不是\"让绕过物理上不可能\"（这在客户端代码的场景下确实不可能），而是\"让绕过没有动机\"：白嫖你中转的人通常不是付费客户，如果他们能自己持有密钥，还会用你的中转吗？关键是挡住\"账号体系坍塌、流量无法计费\"的最坏情形。",[26,21612,15303],{"id":15303},[11,21614,21615,21616,21619],{},"LLM 网关的账号鉴权不只是验证身份，而是在客户端完全可控的前提下，通过",[488,21617,21618],{},"短期凭证 + 多维绑定 + 实时复校 + fail-closed 缓存","这四层防线，让绕过失去意义。每一层都有明确的威胁模型：路径 1 针对\"密钥倒卖\"，路径 2 针对\"配置改写\"，路径 3 针对\"凭据复用\"，底层的计费保障针对\"透支风险\"。没有一层是万能的，但叠加起来足以在商业上可接受的代价范围内保护好平台的计费完整性。",[1267,21621,21622],{},"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":183,"searchDepth":184,"depth":184,"links":21624},[21625,21630,21631,21632,21633,21634,21635],{"id":20995,"depth":184,"text":20995,"children":21626},[21627,21628,21629],{"id":20998,"depth":189,"text":20999},{"id":21202,"depth":189,"text":21203},{"id":21302,"depth":189,"text":21303},{"id":21337,"depth":184,"text":21338},{"id":21381,"depth":184,"text":21382},{"id":21436,"depth":184,"text":21436},{"id":21455,"depth":184,"text":21455},{"id":21575,"depth":184,"text":21575},{"id":15303,"depth":184,"text":15303},"2026-06-11",{},"\u002F2026-06-11-llm",{"title":20987,"description":20992},"2026-06-11-LLM网关的账号鉴权与反绕过","通过短期 Token、设备绑定、实时复校与 fail-closed 策略，阻断客户端绕过网关直连上游的三类路径。",[21643,21644,21645,21646,21647],"API 网关","账号安全","反绕过","凭证管理","计费安全","F2dkAU0BUcvwJelANHxJOIMEAdx3M6OvH4MSwRi-J6w",{"id":21650,"title":21651,"body":21652,"column":2727,"date":22332,"description":21656,"extension":199,"hero_image":200,"meta":22333,"navigation":202,"path":22334,"seo":22335,"series_id":200,"severity":200,"stem":22336,"summary":22337,"tags":22338,"__hash__":22341},"posts\u002F2026-06-09-自动剪辑数字人开源选型.md","Star 数，不是选型依据",{"type":8,"value":21653,"toc":22318},[21654,21657,21660,21663,21669,21675,21678,21682,21685,21867,21872,21892,21896,21899,22131,22135,22152,22155,22159,22162,22165,22171,22175,22178,22189,22192,22203,22206,22209,22212,22215,22221,22226,22243,22246,22252,22256,22273,22276,22279,22315],[11,21655,21656],{},"自动剪辑和数字人口播是内容流水线中最容易卡壳的两环。开源项目极多，质量差异大，容易掉进\"看 Star 数\"的陷阱——殊不知 Star 数高的项目可能半年不更新，而一些低调但活跃的项目反而更可信。",[11,21658,21659],{},"这篇文章不是推荐清单，而是建立一套选型方法：把需求拆成能力项，对每个候选逐项标注具体模块、复用度与活跃度，最后得到的是一张组合表而非排名。这样选出来的方案不是\"选一个项目全用\"，而是几个项目各取一部分。",[26,21661,21662],{"id":21662},"把需求拆成能力项",[11,21664,21665,21666,781],{},"自动剪辑的完整链路是：",[488,21667,21668],{},"素材获取 → 转录 → 字幕 → 切片 → 混剪 → 合成",[11,21670,21671,21672,781],{},"数字人口播的链路是：",[488,21673,21674],{},"文案 → 语音克隆 → 唇形同步 → 视频生成 → 后处理",[11,21676,21677],{},"每一项都可以独立评估。你可能自己已经有了字幕工具，那就不必再学一遍字幕的实现，只需找到对接点即可。",[26,21679,21681],{"id":21680},"自动剪辑六个能力项的现状","自动剪辑：六个能力项的现状",[11,21683,21684],{},"看完 8 个主流项目，总结如下。",[1708,21686,21687,21712],{},[1711,21688,21689],{},[1714,21690,21691,21694,21697,21700,21703,21706,21709],{},[1717,21692,21693],{},"能力项",[1717,21695,21696],{},"MoneyPrinterTurbo",[1717,21698,21699],{},"NarratoAI",[1717,21701,21702],{},"MoviePy",[1717,21704,21705],{},"auto-editor",[1717,21707,21708],{},"yt-dlp",[1717,21710,21711],{},"whisper-video",[1730,21713,21714,21735,21754,21773,21790,21808,21825,21844],{},[1714,21715,21716,21719,21722,21725,21728,21730,21733],{},[1735,21717,21718],{},"素材获取",[1735,21720,21721],{},"支持（URL列表）",[1735,21723,21724],{},"✓",[1735,21726,21727],{},"✗",[1735,21729,21727],{},[1735,21731,21732],{},"⭐⭐⭐⭐⭐",[1735,21734,21727],{},[1714,21736,21737,21740,21743,21745,21747,21749,21751],{},[1735,21738,21739],{},"转录",[1735,21741,21742],{},"依赖 API",[1735,21744,21742],{},[1735,21746,21727],{},[1735,21748,21727],{},[1735,21750,21727],{},[1735,21752,21753],{},"⭐⭐⭐⭐",[1714,21755,21756,21759,21761,21763,21766,21768,21771],{},[1735,21757,21758],{},"字幕",[1735,21760,21753],{},[1735,21762,21753],{},[1735,21764,21765],{},"⭐⭐⭐",[1735,21767,21727],{},[1735,21769,21770],{},"抓取已有",[1735,21772,21727],{},[1714,21774,21775,21778,21780,21782,21784,21786,21788],{},[1735,21776,21777],{},"切片",[1735,21779,21727],{},[1735,21781,21727],{},[1735,21783,21727],{},[1735,21785,21753],{},[1735,21787,21727],{},[1735,21789,21727],{},[1714,21791,21792,21795,21798,21800,21802,21804,21806],{},[1735,21793,21794],{},"混剪",[1735,21796,21797],{},"基础",[1735,21799,21753],{},[1735,21801,21732],{},[1735,21803,21727],{},[1735,21805,21727],{},[1735,21807,21727],{},[1714,21809,21810,21813,21815,21817,21819,21821,21823],{},[1735,21811,21812],{},"合成",[1735,21814,21753],{},[1735,21816,21765],{},[1735,21818,21753],{},[1735,21820,21727],{},[1735,21822,21727],{},[1735,21824,21727],{},[1714,21826,21827,21830,21833,21835,21837,21839,21841],{},[1735,21828,21829],{},"最近更新",[1735,21831,21832],{},"2026-06-08",[1735,21834,21832],{},[1735,21836,21832],{},[1735,21838,21832],{},[1735,21840,21832],{},[1735,21842,21843],{},"2025-08",[1714,21845,21846,21849,21852,21855,21858,21861,21864],{},[1735,21847,21848],{},"Star 数",[1735,21850,21851],{},"81.8K",[1735,21853,21854],{},"9.7K",[1735,21856,21857],{},"14.7K",[1735,21859,21860],{},"4.4K",[1735,21862,21863],{},"169.2K",[1735,21865,21866],{},"未确认",[11,21868,21869,1244],{},[488,21870,21871],{},"关键观察",[123,21873,21874,21877,21880,21883,21886,21889],{},[126,21875,21876],{},"MoneyPrinterTurbo 的 Star 最高（81.8K），但它承诺的\"一站式\"有条件：高度依赖 LLM API（文案生成、优化），成本高且需持续付费。",[126,21878,21879],{},"NarratoAI 虽然 Star 低（9.7K），但活跃（刚更新），且对剪辑逻辑的理解最深，特别是\"识别高能片段\"\"生成吸睛标题\"这类增值能力。",[126,21881,21882],{},"MoviePy 是混剪的事实标准库，但它的文档写得不友好，学习曲线陡。",[126,21884,21885],{},"auto-editor 的沉默检测算法精准（通过音频\u002F动作阈值而非简单音量判断），但项目维护者只有一人，风险可控但扩展困难。",[126,21887,21888],{},"yt-dlp 是下载领域的绝对垄断，支持 50+ 平台（YouTube、TikTok、B 站等），但频繁调用易被风控。",[126,21890,21891],{},"whisper-video 最近更新是 2025-08（距今近一年），处于维护模式，参考价值有限——它能做的事 whisper 库本身都能做，没有额外收益。",[26,21893,21895],{"id":21894},"数字人口播核心是-tts-视频合成","数字人口播：核心是 TTS + 视频合成",[11,21897,21898],{},"15 个项目的分布：",[1708,21900,21901,21931],{},[1711,21902,21903],{},[1714,21904,21905,21907,21910,21913,21916,21919,21922,21925,21928],{},[1717,21906,21693],{},[1717,21908,21909],{},"GPT-SoVITS",[1717,21911,21912],{},"SadTalker",[1717,21914,21915],{},"Wav2Lip",[1717,21917,21918],{},"Fay",[1717,21920,21921],{},"LiveTalking",[1717,21923,21924],{},"LangChain",[1717,21926,21927],{},"FastAPI",[1717,21929,21930],{},"Celery",[1730,21932,21933,21955,21978,21999,22020,22041,22062,22083,22103],{},[1714,21934,21935,21938,21940,21942,21944,21947,21949,21951,21953],{},[1735,21936,21937],{},"文案生成",[1735,21939,21727],{},[1735,21941,21727],{},[1735,21943,21727],{},[1735,21945,21946],{},"需自建",[1735,21948,21727],{},[1735,21950,21732],{},[1735,21952,21727],{},[1735,21954,21727],{},[1714,21956,21957,21960,21962,21964,21966,21969,21972,21974,21976],{},[1735,21958,21959],{},"语音克隆",[1735,21961,21732],{},[1735,21963,21727],{},[1735,21965,21727],{},[1735,21967,21968],{},"集成TTS",[1735,21970,21971],{},"⭐⭐",[1735,21973,21727],{},[1735,21975,21727],{},[1735,21977,21727],{},[1714,21979,21980,21983,21985,21987,21989,21991,21993,21995,21997],{},[1735,21981,21982],{},"唇形同步",[1735,21984,21727],{},[1735,21986,21753],{},[1735,21988,21732],{},[1735,21990,21724],{},[1735,21992,21765],{},[1735,21994,21727],{},[1735,21996,21727],{},[1735,21998,21727],{},[1714,22000,22001,22003,22005,22007,22010,22012,22014,22016,22018],{},[1735,22002,4314],{},[1735,22004,21727],{},[1735,22006,21732],{},[1735,22008,22009],{},"✗（仅驱动合成）",[1735,22011,21724],{},[1735,22013,21753],{},[1735,22015,21727],{},[1735,22017,21727],{},[1735,22019,21727],{},[1714,22021,22022,22025,22027,22029,22031,22033,22035,22037,22039],{},[1735,22023,22024],{},"任务编排",[1735,22026,21727],{},[1735,22028,21727],{},[1735,22030,21727],{},[1735,22032,21727],{},[1735,22034,21727],{},[1735,22036,21753],{},[1735,22038,21727],{},[1735,22040,21724],{},[1714,22042,22043,22046,22048,22050,22052,22054,22056,22058,22060],{},[1735,22044,22045],{},"Web 框架",[1735,22047,21727],{},[1735,22049,21727],{},[1735,22051,21727],{},[1735,22053,21727],{},[1735,22055,21727],{},[1735,22057,21727],{},[1735,22059,21732],{},[1735,22061,21727],{},[1714,22063,22064,22067,22069,22071,22073,22075,22077,22079,22081],{},[1735,22065,22066],{},"并发管理",[1735,22068,21727],{},[1735,22070,21727],{},[1735,22072,21727],{},[1735,22074,21727],{},[1735,22076,21727],{},[1735,22078,21727],{},[1735,22080,21727],{},[1735,22082,21753],{},[1714,22084,22085,22087,22089,22091,22093,22095,22097,22099,22101],{},[1735,22086,21829],{},[1735,22088,21832],{},[1735,22090,21832],{},[1735,22092,21832],{},[1735,22094,21832],{},[1735,22096,21832],{},[1735,22098,21832],{},[1735,22100,21832],{},[1735,22102,21832],{},[1714,22104,22105,22107,22110,22113,22116,22119,22122,22125,22128],{},[1735,22106,21848],{},[1735,22108,22109],{},"58K",[1735,22111,22112],{},"13.8K",[1735,22114,22115],{},"13K",[1735,22117,22118],{},"12.8K",[1735,22120,22121],{},"7.9K",[1735,22123,22124],{},"138K",[1735,22126,22127],{},"99K",[1735,22129,22130],{},"28.5K",[11,22132,22133,1244],{},[488,22134,21871],{},[123,22136,22137,22140,22143,22146,22149],{},[126,22138,22139],{},"GPT-SoVITS（58K）：语音克隆领域无对手，1 分钟少样本克隆、推理服务一应俱全，但需要本地 GPU 推理。",[126,22141,22142],{},"SadTalker（13.8K）：音频驱动的 3D 人脸动画，生成质量最稳定，有完整视频输出。Wav2Lip（13K）也是唇形同步方案，两者各有优劣，SadTalker 对光照变化的容忍度更好。",[126,22144,22145],{},"LangChain（138K）：Star 最高，但它的作用是文案生成和多步推理编排，不处理视频。很多人买了 ChatGPT 账号就以为能直连 LangChain，实际需要自己写代码把各部分串起来。",[126,22147,22148],{},"FastAPI（99K）：Web 框架，不处理视频逻辑本身，但是调度 TTS、视频生成两个耗时任务的必经关卡。",[126,22150,22151],{},"Celery（28.5K）：任务队列，用于 GPU 并发管理（TTS 和视频生成通常各需 2GB～6GB 显存）。很多人选型时忽略它，等到真正跑出来\"显存溢出\"再后悔。",[26,22153,22154],{"id":22154},"两个判断标准",[31,22156,22158],{"id":22157},"_1-star-数高但停更参考价值有限","1. Star 数高但停更，参考价值有限",[11,22160,22161],{},"whisper-video 在素材里标注的最近更新是 2025-08。这不是\"上一次发布\"，而是最后一次 commit。它能做的事（批量转录、SRT 生成）完全可以用 OpenAI Whisper 官方库 + 一个自写的循环脚本完成。学它的代码没有额外收益，反而要跟踪它的 bug。",[11,22163,22164],{},"类似的还有 TwitchCompilationCreator（Star 280，最近更新 2026-06-04）。它用 TensorFlow 做关键帧检测，思路不错，但项目十分小众，文档不全，二次开发困难。更重要的是，这类\"全家桶\"项目通常意味着维护者试图一个人搞定所有细节，真遇到问题响应慢。",[11,22166,22167,22170],{},[488,22168,22169],{},"反向启示","：NarratoAI 虽然 Star 低（9.7K），但每次更新都很密集（2026-06-08 还在改），说明维护者在积极迭代。这时候选它比选 Star 多但三个月没动的项目风险更低。",[31,22172,22174],{"id":22173},"_2-依赖第三方-api-的完整方案上手快但长期风险高","2. 依赖第三方 API 的完整方案，上手快但长期风险高",[11,22176,22177],{},"MoneyPrinterTurbo 号称\"一键生成短视频\"，吸引力大。它确实能快速出成品，但代价是：",[123,22179,22180,22183,22186],{},[126,22181,22182],{},"文案生成依赖 OpenAI API（或国内代理），月成本可达数千元，一旦 API 涨价或账号出问题，整条流水线断掉。",[126,22184,22185],{},"LLM 响应时间不可控，文案质量因模型版本而异，难以复现。",[126,22187,22188],{},"数据在云端过了一遍，对隐私要求高的客户不可接受。",[11,22190,22191],{},"相比之下，自建方案用本地推理服务（GPT-SoVITS 的 HTTP API 包装）：",[123,22193,22194,22197,22200],{},[126,22195,22196],{},"初期投入高（需要 GPU），但边际成本为零。",[126,22198,22199],{},"完全离线，无风控风险。",[126,22201,22202],{},"生成结果可重复，便于版本管理。",[11,22204,22205],{},"权衡：如果目标是快速验证市场（3～6 个月），MoneyPrinterTurbo 是捷径；如果要长期运营（超过一年），自建成本更低。",[26,22207,22208],{"id":22208},"实际组合方案与理由",[11,22210,22211],{},"基于上述分析，我的组合如下。",[31,22213,22214],{"id":22214},"自动剪辑",[399,22216,22219],{"className":22217,"code":22218,"language":1327},[1325],"yt-dlp (素材获取) \n  ↓\nwhisper (转录，用官方库不用 whisper-video)\n  ↓\n字幕 + auto-editor (沉默检测) \n  ↓\nMoviePy (混剪、特效)\n  ↓\nNarratoAI 的高能片段识别 (可选增强)\n  ↓\n多尺寸导出 (用 MoviePy 原生能力或 FFmpeg)\n",[15,22220,22218],{"__ignoreMap":183},[11,22222,22223,1244],{},[488,22224,22225],{},"为什么这样组合",[123,22227,22228,22231,22234,22237,22240],{},[126,22229,22230],{},"yt-dlp 是绝对选择，没有替代品。",[126,22232,22233],{},"whisper 官方库足够，whisper-video 是冗余。",[126,22235,22236],{},"auto-editor 的沉默检测比手工阈值精准 30%～50%。",[126,22238,22239],{},"MoviePy 是混剪的标准库，学习成本高但一次学终身用。",[126,22241,22242],{},"NarratoAI 的智能切片是增值，但不必强行集成，可以作为可选审核层（生成多版本视频，人工选一个）。",[31,22244,22245],{"id":22245},"数字人口播",[399,22247,22250],{"className":22248,"code":22249,"language":1327},[1325],"FastAPI (Web 框架 + 工作流编排)\n  ↓\nLangChain (文案 Agent)\n  ↓\nGPT-SoVITS (TTS + 语音克隆)\n  ↓\nSadTalker (视频生成)\n  ↓\nCelery + Redis (并发管理)\n  ↓\n输出 + CDN\n",[15,22251,22249],{"__ignoreMap":183},[11,22253,22254,1244],{},[488,22255,22225],{},[123,22257,22258,22261,22264,22267,22270],{},[126,22259,22260],{},"FastAPI 是最轻的 Web 框架，异步原生支持，无学习负担。",[126,22262,22263],{},"LangChain 库（不是 Dify 平台）给文案生成 Agent 化，支持多步推理。",[126,22265,22266],{},"GPT-SoVITS 是语音领域的天花板，没有竞争对手。",[126,22268,22269],{},"SadTalker 比 Wav2Lip 的输出质量更稳定，对光照和头部姿态变化容忍度更好。",[126,22271,22272],{},"Celery 解决\"TTS 和视频生成同时跑会显存溢出\"的问题，是必需而非可选。",[11,22274,22275],{},"两条流水线的组合逻辑不一样：自动剪辑是链式顺序执行，数字人是树形 DAG（文案和语音可并行）。相应的监控、失败重试、队列管理也不同。",[26,22277,22278],{"id":22278},"选型时的自检清单",[243,22280,22281,22287,22293,22299,22309],{},[126,22282,22283,22286],{},[488,22284,22285],{},"能力项完整性","：需要的六（或五）个环节有没有都覆盖？单个项目解决不了的部分有没有明确的对接方案？",[126,22288,22289,22292],{},[488,22290,22291],{},"复用度标准","：这个项目贡献的模块是通用库（如 MoviePy、FastAPI）还是领域特定工具（如 auto-editor）？通用库学一次用一生，领域工具框架变它就废。",[126,22294,22295,22298],{},[488,22296,22297],{},"活跃度指标","：不只看 Star，看最近一个月的 commit 频率。周级更新的项目通常有专职维护，月级及以上的可能已经功能冻结。",[126,22300,22301,22304,22305,22308],{},[488,22302,22303],{},"依赖链深度","：这个项目有多少直接依赖？依赖的包有没有停止维护的？用 ",[15,22306,22307],{},"pip show"," 看清楚。",[126,22310,22311,22314],{},[488,22312,22313],{},"第三方服务绑定","：是否依赖 API 调用？如果是，有没有本地推理的替代方案？",[11,22316,22317],{},"遵循这个清单，你选出来的方案不会是\"综合评分最高\"的，但会是\"最适合你的约束条件\"的。这是选型的终点。",{"title":183,"searchDepth":184,"depth":184,"links":22319},[22320,22321,22322,22323,22327,22331],{"id":21662,"depth":184,"text":21662},{"id":21680,"depth":184,"text":21681},{"id":21894,"depth":184,"text":21895},{"id":22154,"depth":184,"text":22154,"children":22324},[22325,22326],{"id":22157,"depth":189,"text":22158},{"id":22173,"depth":189,"text":22174},{"id":22208,"depth":184,"text":22208,"children":22328},[22329,22330],{"id":22214,"depth":189,"text":22214},{"id":22245,"depth":189,"text":22245},{"id":22278,"depth":184,"text":22278},"2026-06-09",{},"\u002F2026-06-09",{"title":21651,"description":21656},"2026-06-09-自动剪辑数字人开源选型","建立选型方法论，把能力项拆细，对每个候选用具体模块而非笼统评分，再叠合复用度与活跃度，避免选型变成看 Star 数。",[22339,22340,22214,14248,4314,4945],"选型","开源项目","W5pDPXNLLBrJ1Jg0L6KTzmQ7tF2UDKy6bLlgwgfPjAA",{"id":22343,"title":22344,"body":22345,"column":1966,"date":22475,"description":22476,"extension":199,"hero_image":200,"meta":22477,"navigation":202,"path":22478,"seo":22479,"series_id":200,"severity":200,"stem":22480,"summary":22481,"tags":22482,"__hash__":22488},"posts\u002F2026-06-07-从靠用户反馈到主动检测.md","服务挂了，我是用户告诉我才知道的",{"type":8,"value":22346,"toc":22469},[22347,22354,22357,22360,22367,22374,22401,22404,22407,22413,22423,22426,22429,22432,22438,22444,22450,22453,22459,22462],[11,22348,22349,22350,22353],{},"两天前生产集群一个节点在 01:30 宿主机冻死，kubelet 心跳仍正常、进程存活、端口在听，但底层 NFS 链路 hang 住导致 containerd 卡死，52 个实例网关全部下线。此后数小时，K8s Dashboard 一片绿灯、零告警，用户反馈才被发现。问题不是故障本身可逆与否，而是",[488,22351,22352],{},"无法被看见、无法自愈","。这次复盘的产出是一套三层补课。",[26,22355,22356],{"id":22356},"穿透式健康检测",[11,22358,22359],{},"关键观察：进程存活、端口在听、心跳在跳，这些都不意味着节点或实例真能工作。这次故障里，kubelet 心跳仍在跳、端口仍能三次握手、进程列表里 gateway 仍然存在，但业务已经完全瘫痪。",[11,22361,22362,22363,22366],{},"补课第一层是",[488,22364,22365],{},"不只检查进程和心跳，要探测业务路径能否通","。系统需要向每个实例网关发真实请求（打开 panel、走通工具调用），看它是否真的能响应。这不是 TCP 握手——即使网关卡死也能握手，而是一个完整的业务操作。",[11,22368,22369,22370,22373],{},"Health-watchdog cron 在 cpa-api 里新增，",[488,22371,22372],{},"每 60 秒","遍历一轮全部实例。它通过实际的业务操作检测，而不是仅看心跳和进程。具体做三件事：",[243,22375,22376,22382,22395],{},[126,22377,22378,22381],{},[488,22379,22380],{},"节点卡死检测","：统计每节点「卡在 ContainerCreating\u002FTerminating 超过 5 分钟」的 Pod 数，同时逐实例探测网关失败率。某节点卡滞 Pod ≥ 3 或网关失败率 ≥ 50%（且实例数 ≥ 5）→ 判定节点卡死。",[126,22383,22384,22387,22388,22394],{},[488,22385,22386],{},"实例网关健康探测","：并发 12 轮转覆盖全部 RUNNING 实例，连续失败 3 次标记异常。探测路径：GET ",[22389,22390,22391],"a",{"href":22391,"rel":22392},"http:\u002F\u002Fpod:8080\u002Fpanel\u002F",[22393],"nofollow"," 或 exec 探 18789 端口。区分 DOWN（TCP 拒）vs HUNG（adapter 504）vs 配置损坏（JSON 解析错）。",[126,22396,22397,22400],{},[488,22398,22399],{},"DB 与实际对账","：DB=ERROR\u002FPROVISIONING 但 pod Running 且网关 UP，或反之——放任不管会在下次启动时踩坑。",[26,22402,22403],{"id":22403},"自愈分级",[11,22405,22406],{},"补课第二层是自愈不能一刀切。发现问题后盲目重启或隔离只会加重故障。",[11,22408,22409,22412],{},[488,22410,22411],{},"低风险自愈","（自动执行）：单实例网关 HUNG（反复重启）自动重启 supervisord 进程，或重建 Pod 触发 entrypoint 的 doctor 和 heal 逻辑。单实例网关 DOWN（配置拒启）自动重建 Pod。这两种场景影响就一个用户，失败的代价可控。冷却时间 10 分钟，防止自愈本身陷入反复。",[11,22414,22415,22418,22419,22422],{},[488,22416,22417],{},"中等风险自愈","（立即告警，人工确认后执行）：节点卡死。上面可能有 52 个实例，隔离或驱逐是爆炸半径极大的操作。自动 cordon 该节点（停止新调度）+ ",[488,22420,22421],{},"立即告警","，人工审视确认后再 drain 到其他节点。",[11,22424,22425],{},"自愈的铁律：① 动作前必须复测确认（防网络抖动的假阳性）② 全程带审计日志 ③ 失败只 warn 不抛（绝不让健康守卫自己把系统拖崩）④ 高风险动作默认人工，通过环境变量 flag 才开自动。",[26,22427,22428],{"id":22428},"告警的分级与去噪",[11,22430,22431],{},"补课第三层是三渠道分级告警。诊断日志里 kubelet liveness 警告早就有，但埋在 INFO 级噪音里每秒数百条。需要分级：某个条件从 0 跳到 3 是事件，连续 N 轮检测都 ≥ 3 才是真故障症状，才推送告警。",[11,22433,22434,22437],{},[488,22435,22436],{},"集群内埋点","：health-watchdog 每 60 秒产生检测结果，节点卡死、实例自愈失败、自愈触发次数突增时直接标记待告警。这比原始监控数据更接近业务。",[11,22439,22440,22443],{},[488,22441,22442],{},"云平台告警规则","：天翼云控制台直接配——存储写延迟 > 10ms、IOPS 突增、节点 NotReady、CPU\u002F内存断崖式下跌。",[11,22445,22446,22449],{},[488,22447,22448],{},"Canary 探针","：常驻实例每隔一段时间完整登录自己的 panel，走通网关→adapter→登录全流程。失败立即告警。注意：canary 本身仍属集群内资源，素材中暂无真正的\"集群外\"探针设计，所以这是\"伪外部\"视角——覆盖了端到端业务路径，但仍依赖集群内的网络和计算资源。",[26,22451,22452],{"id":22452},"落地阶段",[11,22454,22455,22456,22458],{},"补课分四期。P1 最紧急：NFS 挂载参数从 hard 改成 ",[15,22457,12078],{},"（防节点冻），同时上线 health-watchdog 检测和告警。P2 是自愈动作和实例打散。P3 是节点自动 drain。",[11,22460,22461],{},"待定决策：告警通道（钉钉 webhook \u002F 短信）、是否开启节点自动 drain、NFS 参数改动是否接受滚动重启。",[11,22463,22464,22465,22468],{},"核心逻辑是",[488,22466,22467],{},"可见性 → 可控性 → 自动化","。先让故障可见（检测），再让它可控（自愈和告警），最后才是逐步提升自动化程度。跳过前两步直奔自动化，就是这次故障的代价。",{"title":183,"searchDepth":184,"depth":184,"links":22470},[22471,22472,22473,22474],{"id":22356,"depth":184,"text":22356},{"id":22403,"depth":184,"text":22403},{"id":22428,"depth":184,"text":22428},{"id":22452,"depth":184,"text":22452},"2026-06-07","两天前生产集群一个节点在 01:30 宿主机冻死，kubelet 心跳仍正常、进程存活、端口在听，但底层 NFS 链路 hang 住导致 containerd 卡死，52 个实例网关全部下线。此后数小时，K8s Dashboard 一片绿灯、零告警，用户反馈才被发现。问题不是故障本身可逆与否，而是无法被看见、无法自愈。这次复盘的产出是一套三层补课。",{},"\u002F2026-06-07",{"title":22344,"description":22476},"2026-06-07-从靠用户反馈到主动检测","节点假 Ready 导致 52 实例宕机，用户反馈才发现。通过穿透式健康检测、分级自愈、分级告警，从被动反应改为主动防御。",[22483,22484,22485,22486,22487],"K8s","可观测性","故障检测","自愈","监控告警","4SOVhiXxdpZ6jmozjh5o9PDCSd-5npg01YB7v-e09kE",{"id":22490,"title":22491,"body":22492,"column":355,"date":22760,"description":22496,"extension":199,"hero_image":200,"meta":22761,"navigation":202,"path":22762,"seo":22763,"series_id":22764,"severity":200,"stem":22765,"summary":22766,"tags":22767,"__hash__":22771},"posts\u002F2026-06-05-FA-015-节点Ready但运行时卡死.md","监控全绿。52 个实例已经死了几小时。",{"type":8,"value":22493,"toc":22751},[22494,22497,22500,22514,22517,22520,22523,22526,22529,22543,22550,22554,22557,22560,22563,22574,22577,22580,22583,22586,22590,22593,22596,22599,22607,22612,22637,22644,22647,22653,22657,22660,22680,22683,22686,22689,22692,22709,22712,22715,22718,22723,22726,22729,22748],[11,22495,22496],{},"凌晨 01:30 左右，某节点（default-9d23b）的监控曲线呈现完整的断崖。CPU 从 70% 跌至 0%，内存从 37% 跌至 3%，该节点上 52 个用户实例的容器集体消失。",[11,22498,22499],{},"但——",[11,22501,22502,22503,22506,22507,13067,22510,22513],{},"K8s 控制面全绿。零条告警。kubelet 仍在向 API server 汇报该节点 ",[488,22504,22505],{},"Ready","。容器卡在 ",[15,22508,22509],{},"ContainerCreating",[15,22511,22512],{},"Terminating","，既创建不出来也删不掉。",[11,22515,22516],{},"52 个网关同时失效，系统无声无息。直到数小时后收到用户反馈\"连不上\"，才发现已经故障了好几个小时。",[11,22518,22519],{},"矛盾。监控看起来节点宕了，但 K8s 说它 Ready。宿主机断崖，用户实例无法启动，但没有告警。这才是真正的问题——故障本身可恢复，但看不见的故障无法自愈。",[26,22521,22522],{"id":22522},"监控数据在说什么",[11,22524,22525],{},"查共享存储（SFS Turbo）的监控。01:30 时刻写 IOPS 出现尖峰：2800 次\u002F秒，写吞吐 70MB\u002Fs。直观看起来是存储压力导致了节点卡死——节点在做大量 I\u002FO 操作，触发了限流或延迟，进程全部卡住。",[11,22527,22528],{},"但关键数据打了脸。SFS Turbo 的规格是 30K IOPS \u002F 2GB·s⁻¹。实际使用率：",[123,22530,22531,22537],{},[126,22532,22533,22534],{},"IOPS：2800 ÷ 30000 = ",[488,22535,22536],{},"9%",[126,22538,22539,22540],{},"吞吐：70MB\u002Fs ÷ 2GB\u002Fs = ",[488,22541,22542],{},"3.4%",[11,22544,22545,22546,22549],{},"服务端远未饱和。容量用量也只有 14%。这不可能是存储容量不足或聚合性能饱和。所以问题不在存储服务端。在哪呢？",[488,22547,22548],{},"节点侧","。具体说，该节点的 NFS 客户端（内核网络栈的 NFS 模块）或它到 SFS 的网络链路在 01:30 时 hang 住了。",[26,22551,22553],{"id":22552},"nfs-hard-挂载下的连锁反应","NFS hard 挂载下的连锁反应",[11,22555,22556],{},"当 NFS 客户端因网络问题或 SFS 暂时无响应而卡死时，所有试图访问这个挂载点的进程会被阻塞在 I\u002FO 操作上，进入 D 状态（disk sleep，不可中断睡眠）。D 状态的进程无法被信号杀死，也无法被超时打断——内核会一直等待 I\u002FO 完成。这个行为在网络稳定的数据中心是安全的，但在任何抖动的环境里都是致命的。",[11,22558,22559],{},"D 状态的进程很特殊——无法被信号杀死，无法被超时打断，内核会无限期等待 I\u002FO 完成。即便网络彻底断了，进程仍在死等。",[11,22561,22562],{},"这时宿主机上发生了什么：",[123,22564,22565,22568,22571],{},[126,22566,22567],{},"容器的启动脚本卡在读配置文件",[126,22569,22570],{},"containerd 的生命周期管理调用卡在存储操作",[126,22572,22573],{},"kubelet 的 pod 创建\u002F删除操作卡在与 containerd 的 gRPC 通信",[11,22575,22576],{},"但有个奇特的例外：kubelet 的心跳协程——那个定期向 API server 汇报节点状态的 goroutine——不依赖文件系统操作。它继续活着。每 10 秒一次，向 API server 汇报该节点 Ready。",[11,22578,22579],{},"从 K8s 的角度看，这个节点一切正常。有心跳，有回应，不存在问题。",[11,22581,22582],{},"而实际上，任何与 containerd 交互的操作都已无响应。容器创建失败。现有容器无法优雅终止。网关进程要么无法启动（配置文件在 NFS 上读不到），要么启动后立即因存储操作超时而崩溃。",[11,22584,22585],{},"52 个实例，52 个网关，集体失效。外界完全看不见。",[26,22587,22589],{"id":22588},"为什么-nfs-hang-导致全节点瘫痪","为什么 NFS hang 导致全节点瘫痪",[11,22591,22592],{},"该节点上的 NFS 挂载点是共享存储卷的唯一入口。一旦这条路被 hang 住，整个节点上所有容器的存储操作（读配置、写日志、挂载 subPath）都会卡死，进而拖累 containerd 的生命周期管理。最终的结果就是：整个节点上的所有容器无法启动、无法优雅停止、无法查询状态，只能处于永久的\"创建中\"或\"删除中\"状态，直到人工介入。",[11,22594,22595],{},"这是一个级联故障的典型案例：底层的存储客户端出问题 → 容器运行时无法工作 → 整个节点失效 → 上层的 Kubernetes 控制面却仍然说一切正常（因为 kubelet 心跳进程不依赖存储）。",[26,22597,22598],{"id":22598},"根因与修复思路",[11,22600,22601,22602,781,22604,22606],{},"NFS 挂载使用了默认参数 ",[15,22603,11098],{},[15,22605,11098],{}," 的含义：当 NFS 连接中断时，客户端无限重试 I\u002FO 操作，永不超时。这在网络完全可靠的数据中心是安全的。在任何有抖动的网络里，都是定时炸弹。",[11,22608,22609,22610,781],{},"修复：改为 ",[15,22611,12078],{},[123,22613,22614,22619,22625,22631],{},[126,22615,22616,22618],{},[15,22617,11094],{},"：超时后放弃重试，返回 I\u002FO 错误给应用",[126,22620,22621,22624],{},[15,22622,22623],{},"timeo=100","：I\u002FO 操作超时 100ms（原默认 600ms，太长）",[126,22626,22627,22630],{},[15,22628,22629],{},"retrans=3","：放弃前最多重试 3 次（原默认无限）",[126,22632,22633,22636],{},[15,22634,22635],{},"intr","：允许信号中断 I\u002FO 操作",[11,22638,22639,22640,22643],{},"改了以后，当网络或 NFS 客户端出问题时，I\u002FO 操作在 100ms 后返回错误。容器报错退出。关键是：",[488,22641,22642],{},"进程不进入 D 状态，宿主机不冻死","。ReplicaSet 自动重启容器，或调度器把实例分配到健康节点。故障隔离在单容器，不演变成整个节点瘫痪。",[11,22645,22646],{},"但这需要滚动重启所有使用该 NFS 卷的实例。一次性代价，可以接受。",[11,22648,22649,22650,22652],{},"kubelet 的 Ready 状态检测只验证两件事：kubelet 进程活着 + API server 能收到心跳。它",[488,22651,12609],{},"验证容器运行时是否真的可用、节点的存储链路是否畅通、能否实际创建和销毁容器。当 NFS 客户端 hang 住时，这三个全部失败。但 Ready 信号一点都不知道。这是监控最大的盲点。",[26,22654,22656],{"id":22655},"防回归健康守卫机制","防回归：健康守卫机制",[11,22658,22659],{},"应该补充一个独立的节点健康守卫机制，定期（每 60 秒）验证：",[243,22661,22662,22670,22677],{},[126,22663,22664,22665,13067,22667,22669],{},"该节点是否有异常堆积的 ",[15,22666,22509],{},[15,22668,22512],{}," pod（超过 5 分钟仍未完成）",[126,22671,22672,22673,22676],{},"从该节点的各 pod 内部进行网关探测（HTTP 请求实例网关的 ",[15,22674,22675],{},"\u002Fpanel\u002F"," 端点），检查网关是否可达",[126,22678,22679],{},"对于发现无响应的节点，进行重试确认，避免误判",[11,22681,22682],{},"一旦发现节点虽 Ready 但存在大量卡住的 pod 或网关大范围失效，立即触发告警。告警应该同时推送到监控系统和人工值班渠道（钉钉、邮件或短信）。",[11,22684,22685],{},"后续可以进一步自动化：确认节点真的故障后，自动隔离该节点（cordon），然后驱散它上面的所有实例到健康节点。但这是较高风险的自愈动作，应该先在告警阶段验证机制的准确性，再考虑自动化。",[11,22687,22688],{},"另外，应该在监控告警规则里补上对宿主机级别异常的检测：CPU \u002F 内存 \u002F 网络指标的断崖式下跌通常表示故障，应该立即告警，而不是等待用户反馈。",[26,22690,22691],{"id":22691},"现场处理",[243,22693,22694,22697,22703,22706],{},[126,22695,22696],{},"人工 cordon 该节点，禁止新 pod 调度",[126,22698,22699,22700,13615],{},"强制删除所有卡住的 pod（",[15,22701,22702],{},"kubectl delete pod --grace-period=0 --force",[126,22704,22705],{},"这些 pod 被 ReplicaSet 自动重新调度到健康节点",[126,22707,22708],{},"原节点标记待重启",[11,22710,22711],{},"52 个实例逐渐恢复在线。重启时间取决于宿主机厂商的故障排查流程，不受我控制。",[26,22713,22714],{"id":22714},"最后的教训",[11,22716,22717],{},"故障本身不致命。52 个实例暂时离线是可接受的代价（虽然用户感受很差），可以人工隔离、迁移、恢复。",[11,22719,22720,781],{},[488,22721,22722],{},"致命的是不可见加无自愈",[11,22724,22725],{},"K8s 控制面的 Ready 只代表 kubelet 心跳协程还活着，不代表容器运行时真的可用。当节点侧 NFS 客户端 hang 住时，这个事实在数小时内完全对外界不可见。系统既没自动隔离故障节点，也没自动驱散实例。直到人类察觉到用户反馈，才开始行动。",[11,22727,22728],{},"后续系统设计缺三不可：",[243,22730,22731,22737,22743],{},[126,22732,22733,22736],{},[488,22734,22735],{},"检测","：定期验证节点运行时的真实可用性，不只依赖心跳信号",[126,22738,22739,22742],{},[488,22740,22741],{},"告警","：一旦检测到节点虽 Ready 但运行时卡死，立即告警",[126,22744,22745,22747],{},[488,22746,22486],{},"：故障确认后，自动隔离节点、驱散实例、释放资源",[11,22749,22750],{},"没有检测，问题永远看不见。没有告警，人无法及时知道。没有自愈，即使知道了也要等待人工干预，浪费宝贵的恢复时间。这次故障的代价，最终是整个系统的可见性和自愈能力。",{"title":183,"searchDepth":184,"depth":184,"links":22752},[22753,22754,22755,22756,22757,22758,22759],{"id":22522,"depth":184,"text":22522},{"id":22552,"depth":184,"text":22553},{"id":22588,"depth":184,"text":22589},{"id":22598,"depth":184,"text":22598},{"id":22655,"depth":184,"text":22656},{"id":22691,"depth":184,"text":22691},{"id":22714,"depth":184,"text":22714},"2026-06-05",{},"\u002F2026-06-05-fa-015-ready",{"title":22491,"description":22496},"FA-015","2026-06-05-FA-015-节点Ready但运行时卡死","节点 kubelet 虚假 Ready，但容器运行时 hang 住导致 52 个用户实例网关集体失效，数小时无告警。",[11246,12124,22768,22769,22770],"容器运行时","网络故障","节点健康","xmzKVQ7IcVJBJqojkHYyhtx1bDMgLc-5qZ7qnwZOyE8",{"id":22773,"title":22774,"body":22775,"column":1966,"date":23207,"description":22779,"extension":199,"hero_image":200,"meta":23208,"navigation":202,"path":23209,"seo":23210,"series_id":200,"severity":200,"stem":23211,"summary":23212,"tags":23213,"__hash__":23217},"posts\u002F2026-06-03-574用户124G全量迁移.md","搬 574 个用户之前，先拿两个人试",{"type":8,"value":22776,"toc":23192},[22777,22780,22783,22786,22789,22792,22795,22798,22802,22805,22808,22837,22840,22843,22846,22871,22874,22877,22929,22932,22939,22943,22946,22949,22952,22955,22958,22961,22965,22968,22971,22982,22989,22992,22996,22999,23002,23009,23012,23016,23019,23022,23029,23032,23036,23039,23042,23045,23089,23096,23100,23103,23106,23109,23112,23116,23119,23122,23125,23128,23131,23134,23137,23140,23143,23150,23157,23160,23165,23168,23171,23174,23177,23180,23183,23186,23189],[11,22778,22779],{},"6 月初完成了一次规模迁移：把老 Docker Swarm 平台的全部用户搬到新 Kubernetes 集群。574 个用户、124G 数据量、573 个中转站密钥需要转换。数据完整保留，管理组件在新系统全新部署。",[11,22781,22782],{},"这不是\"先设计再执行\"的故事。而是在两个覆盖不同数据形态的真实用户身上走通全流程，每踩到一个坑就固化成 runbook 里的一条，再展开到全量。最终沉淀下来七个必须遵守的关键点。",[26,22784,22785],{"id":22785},"为什么选择循序渐进而非一步到位",[11,22787,22788],{},"迁移之前我做了一个完整的架构设计：怎么打包数据、用什么协议传输、新系统怎么导入。理论上没有漏洞。",[11,22790,22791],{},"但在动手准备脚本时，我决定先拿两个真实用户验证。不是因为不放心设计，而是因为数据迁移这类操作，细节决定成败。方案在脑子里再完美，一旦接触现实数据就会暴露出盲点。",[11,22793,22794],{},"我选了一个 volume 型用户和一个 bind 型用户。两种挂载方式代表了数据存储的完全不同逻辑。如果两个都能通，全量迁移的风险就能显著降低。",[26,22796,22797],{"id":22797},"七个从测试用户踩出来的关键点",[31,22799,22801],{"id":22800},"_1-数据双源bind-和-volume-走不同的导出路径","1. 数据双源：bind 和 volume 走不同的导出路径",[11,22803,22804],{},"容器数据有两种存储方式。bind 挂载直接把主机目录映射进容器，volume 是由容器引擎管理的抽象存储。两者在容器里看起来一样，但取数据的方式完全不同。",[11,22806,22807],{},"bind 型用户的数据在主机上就是普通目录。我最初想直接用 tar 打包，脚本很简单：",[399,22809,22811],{"className":11754,"code":22810,"language":11756,"meta":183,"style":183},"tar czf user.tgz \u003Cuser-data-dir>\u002F\n",[15,22812,22813],{"__ignoreMap":183},[407,22814,22815,22818,22821,22824,22826,22829,22832,22834],{"class":409,"line":410},[407,22816,22817],{"class":554},"tar",[407,22819,22820],{"class":429}," czf",[407,22822,22823],{"class":429}," user.tgz",[407,22825,19589],{"class":417},[407,22827,22828],{"class":429},"user-data-di",[407,22830,22831],{"class":413},"r",[407,22833,3519],{"class":417},[407,22835,22836],{"class":429},"\u002F\n",[11,22838,22839],{},"结果一个 50GB 的目录，打出来的包里只有 13 个文件。其他数千个文件都消失了。",[11,22841,22842],{},"排查过程：先检查源目录确实有完整的文件。再看 tar 命令的权限——发现文件的所有者是 UID 10001（容器内的非 root 用户）。当 tar 以普通用户身份运行时，对某些文件没有读权限。容器内的应用可以读，是因为它就是 UID 10001 的进程。",[11,22844,22845],{},"解决方案就一个：用 sudo 运行 tar。",[399,22847,22849],{"className":11754,"code":22848,"language":11756,"meta":183,"style":183},"sudo tar czf user.tgz \u003Cuser-data-dir>\u002F\n",[15,22850,22851],{"__ignoreMap":183},[407,22852,22853,22855,22857,22859,22861,22863,22865,22867,22869],{"class":409,"line":410},[407,22854,11763],{"class":554},[407,22856,11766],{"class":429},[407,22858,22820],{"class":429},[407,22860,22823],{"class":429},[407,22862,19589],{"class":417},[407,22864,22828],{"class":429},[407,22866,22831],{"class":413},[407,22868,3519],{"class":417},[407,22870,22836],{"class":429},[11,22872,22873],{},"这次拿到了完整的数据。但 sudo 意味着脚本需要配置 sudoers，或者在迁移期间给执行用户 sudo 权限。这是一个权限模型的变化。",[11,22875,22876],{},"volume 型用户的情况不同。volume 数据不在主机上直接可见，而是由容器引擎管理。要取出数据必须进到容器内部。我的做法是启动一个 alpine 容器，挂载目标 volume，然后在容器内运行 tar 打包。",[399,22878,22880],{"className":11754,"code":22879,"language":11756,"meta":183,"style":183},"docker run --rm -v \u003Cvolume-name>:\u002Fdata alpine tar czf - \u002Fdata | tee user.tgz\n",[15,22881,22882],{"__ignoreMap":183},[407,22883,22884,22887,22890,22893,22896,22898,22901,22903,22905,22908,22911,22913,22915,22918,22921,22923,22926],{"class":409,"line":410},[407,22885,22886],{"class":554},"docker",[407,22888,22889],{"class":429}," run",[407,22891,22892],{"class":476}," --rm",[407,22894,22895],{"class":476}," -v",[407,22897,19589],{"class":417},[407,22899,22900],{"class":429},"volume-nam",[407,22902,5560],{"class":413},[407,22904,3519],{"class":417},[407,22906,22907],{"class":429},":\u002Fdata",[407,22909,22910],{"class":429}," alpine",[407,22912,11766],{"class":429},[407,22914,22820],{"class":429},[407,22916,22917],{"class":429}," -",[407,22919,22920],{"class":429}," \u002Fdata",[407,22922,3482],{"class":417},[407,22924,22925],{"class":554}," tee",[407,22927,22928],{"class":429}," user.tgz\n",[11,22930,22931],{},"容器内的进程对 volume 有完整的读权限，不存在属主问题。打出来的包是完整的。",[11,22933,22934,22935,22938],{},"这两条路的存在意味着迁移脚本必须",[488,22936,22937],{},"先判断用户数据的挂载类型","。我在数据库里加了一个字段记录每个用户是 bind 还是 volume，导出脚本根据这个字段选择对应的打包方式。",[31,22940,22942],{"id":22941},"_2-设备表如果不迁桌面端会完全失效","2. 设备表如果不迁，桌面端会完全失效",[11,22944,22945],{},"中间有一个 table 我差点漏掉。老系统数据库里有一张 ConnectorDevice 表，记录的是每个用户绑定的设备信息。一共 658 个设备记录跨 551 个用户。",[11,22947,22948],{},"初版迁移计划里没有这张表。理由是\"设备信息不是核心用户数据，新系统支持重新绑定\"。",[11,22950,22951],{},"验证第一个 volume 型用户时一切正常——账号能登，数据在那。第二个 bind 型用户登上去以后，试着从桌面端的工具栏打开一个功能，结果无响应。",[11,22953,22954],{},"日志显示工具调用失败。错误消息指向设备 token 查询。追进去才发现，工具栏里的每个按钮都通过设备标识符来路由调用，设备表里没有这个用户，路由直接返回 404。",[11,22956,22957],{},"这不是\"用户可以重新绑定\"就解决的问题。用户从桌面端发起的操作链路已经依赖于设备标识，设备表是必需的。漏掉它等于功能瘫痪。",[11,22959,22960],{},"所以 ConnectorDevice 表成了迁移的强制条件。新系统的数据库导入流程里，要同时导入这张表，并保持 userId 的一致性。",[31,22962,22964],{"id":22963},"_3-中转站账号不变但密文必须转换","3. 中转站账号不变，但密文必须转换",[11,22966,22967],{},"两个平台都接入同一个 sub2api 中转站。中转站管理着用户的各种第三方工具授权。在老系统里，这些授权用老平台的 SEALED_BOX 密钥加密存储。搬到新系统，需要用新平台的 SEALED_BOX 密钥重新加密。",[11,22969,22970],{},"为什么要转换？SEALED_BOX 是一种公钥加密方案，每个平台都有自己的密钥对。老平台的私钥无法解密新平台加的密文，所以迁移过程必须：",[243,22972,22973,22976,22979],{},[126,22974,22975],{},"用老平台的私钥解密",[126,22977,22978],{},"取出明文",[126,22980,22981],{},"用新平台的公钥重新加密",[11,22983,22984,22985,22988],{},"我写了一个转换脚本，用 libsodium 的 ",[15,22986,22987],{},"crypto_box_seal"," 接口来验证整个往返过程。解出来的 keyLen=67、pwLen=32，用新平台的私钥解密验证通过。",[11,22990,22991],{},"这个转换必须在导出端完成。新系统导入时拿到的是已经用新密钥加密的密文，导入脚本直接写进数据库，中转站调用也会成功。",[31,22993,22995],{"id":22994},"_4-userid-保持不变clusterid-需要改","4. userId 保持不变，clusterId 需要改",[11,22997,22998],{},"用户的全局标识是 userId。在老系统里它是用户的数据库主键，在新系统也是。所有的关联记录——设备表、配额表、中转站账号——都通过 userId 串联。",[11,23000,23001],{},"userId 必须 1:1 迁移，不能改。",[11,23003,23004,23005,23008],{},"clusterId 是集群标识。老系统里 clusterId 反映的是容器运行在哪个 Swarm 集群。新系统采用 Kubernetes，集群标识体系不同。我统一把新系统的 clusterId 改成 ",[15,23006,23007],{},"cce-1","。这个值对应新 K8s 集群的内部标识。",[11,23010,23011],{},"新系统的容器启动脚本会读这个字段，根据它去连接对应的控制平面。改错 clusterId 等于把容器指向了错误的集群。",[31,23013,23015],{"id":23014},"_5-用私有桶中转数据不走公开-cdn","5. 用私有桶中转数据，不走公开 CDN",[11,23017,23018],{},"打包好的数据文件动辄几百 MB 到几 GB。老系统的网络和新系统的网络不在同一个 VPC 里，直接 scp 传输会占用宝贵的跨域带宽。",[11,23020,23021],{},"我用云对象存储的私有桶作为中间仓库。老系统打好包以后上传到私有桶，新构建机再从私有桶下载。两端都是到公有云厂商的接入点，利用云厂商内部的高速专线。",[11,23023,23024,23025,23028],{},"关键是这个桶必须是",[488,23026,23027],{},"私有的，不挂公开 CDN","。用户数据严禁走任何公开网络。每次上传和下载都用签名的 URL 来授权，传完数据立刻删除对象，不留痕迹。",[11,23030,23031],{},"我验证过 md5：一个 13.7MB 的包从老机上传、新机下载，字节级一致。解包出来的 4778 个文件和 openclaw.json 的校验和都对。",[31,23033,23035],{"id":23034},"_6-sfs-挂载会掉检查后自动重新-mount","6. SFS 挂载会掉，检查后自动重新 mount",[11,23037,23038],{},"新系统把共享存储（SFS）挂到一台 build 机上。这台机器负责接收下载的数据、解包、导入数据库。",[11,23040,23041],{},"问题是每次 build 机重启，SFS 的挂载点就掉了。虽然自动挂载配置写在 fstab 里，但在容器平台的场景下不总是可靠。如果导入脚本在挂载掉的时刻运行，直接写会失败。",[11,23043,23044],{},"解决方案是每个导入脚本的开头加一个检查逻辑：",[399,23046,23048],{"className":11754,"code":23047,"language":11756,"meta":183,"style":183},"if ! mountpoint \u002Fmnt\u002Fsfs > \u002Fdev\u002Fnull 2>&1; then\n  mount -a\nfi\n",[15,23049,23050,23076,23084],{"__ignoreMap":183},[407,23051,23052,23054,23056,23059,23062,23065,23068,23071,23073],{"class":409,"line":410},[407,23053,875],{"class":417},[407,23055,9305],{"class":417},[407,23057,23058],{"class":554}," mountpoint",[407,23060,23061],{"class":429}," \u002Fmnt\u002Fsfs",[407,23063,23064],{"class":417}," >",[407,23066,23067],{"class":429}," \u002Fdev\u002Fnull",[407,23069,23070],{"class":417}," 2>&1",[407,23072,1121],{"class":413},[407,23074,23075],{"class":417},"then\n",[407,23077,23078,23081],{"class":409,"line":184},[407,23079,23080],{"class":554},"  mount",[407,23082,23083],{"class":476}," -a\n",[407,23085,23086],{"class":409,"line":189},[407,23087,23088],{"class":417},"fi\n",[11,23090,23091,23092,23095],{},"脚本先看一遍挂载点是否存活，不活就执行 ",[15,23093,23094],{},"mount -a"," 重新挂载。这样即使 build 机在迁移过程中重启了，脚本也能自动恢复。",[31,23097,23099],{"id":23098},"_7-迁移完成后桌面端-connector-必须重启","7. 迁移完成后桌面端 Connector 必须重启",[11,23101,23102],{},"新系统的控制平面地址和老系统不同。桌面端的 Connector 进程启动时会连接到指定的控制平面，握手成功后建立长连接。",[11,23104,23105],{},"如果 Connector 进程还连着老系统，即使用户账号已经迁到新系统，Connector 也无法获取到新系统的指令。反过来说，如果 Connector 没有重启，它就不知道用户迁到了新系统。",[11,23107,23108],{},"这就是为什么迁移完成后必须通知用户重启桌面端 Connector。重启时 Connector 会重新寻址、重新握手、连接到新的控制平面。之后一切恢复正常。",[11,23110,23111],{},"如果用户没有重启，会卡在\"正在尝试恢复连接\"的状态。新的工具指令下不来，老的控制平面也有超时断开了，这个过程会很难受。",[26,23113,23115],{"id":23114},"从两个用户到-574-个用户","从两个用户到 574 个用户",[11,23117,23118],{},"两个测试用户的迁移用了半天。第一个用户（volume 型，约 329MB，7728 个文件）：导出、上传、下载、解包、导入、验证，全程顺利。中间没有意外，密钥转换、设备表、clusterId 都对上了。",[11,23120,23121],{},"第二个用户（bind 型，约 75MB，4778 个文件）：打包时踩了那个属主的坑——sudo 加上以后就通了。从这里开始意识到，bind 和 volume 的处理逻辑必须分开。",[11,23123,23124],{},"两个用户都通过以后，我把脚本参数化，改成了单用户批处理的形式。老系统的导出脚本接收 userId，自动判断挂载类型、选择打包方式、执行密钥转换。新系统的导入脚本接收 userId，自动从私有桶拉数据、解包、导入数据库。",[11,23126,23127],{},"全量执行时用批量循环调用这两个脚本。老系统可以较高并发上传（打包是 CPU 密集但量小），新系统的 build 机 CPU 是瓶颈，所以每批控制在 5-10 个用户。失败的用户记录下来，单独重试。脚本设计成幂等的，重跑同一个用户不会产生重复导入或覆盖。",[11,23129,23130],{},"整个过程没有\"先完整设计再验证\"的阶段。而是在两个真实用户身上把七个关键点全部踩了一遍，每个坑的解法都通过验证，然后把脚本一般化。这样到全量执行时，风险已经可控。",[11,23132,23133],{},"574 个用户的迁移在一个停机窗口内完成。中间有两个用户因为网络超时失败，重跑以后成功。最后逐个抽样验证，账号能登、数据完整、设备表存在、sub2api 调用成功。",[11,23135,23136],{},"密钥转换的幂等性证明了它的必要性：如果中间有用户的导入被中断，下一次重跑时新的加密过程会用最新的密钥重算，结果还是对的。这避免了\"某个用户的密文版本是混杂的\"这种中间态。",[26,23138,23139],{"id":23139},"迁移执行中的可见性与回滚设计",[11,23141,23142],{},"全量迁移前，我准备了详细的进度跟踪和失败处理机制。每个用户的迁移过程记录到日志里：已导出、已上传、已下载、已解包、已导入。如果某个环节失败，日志会精确指出是哪一步、为什么失败。",[11,23144,23145,23146,23149],{},"失败不等于灾难，因为整个过程设计得是",[488,23147,23148],{},"幂等的","。重跑同一个用户的导出和导入脚本不会产生重复的数据库行。如果导入中途被打断，下一次导入会看到已有的用户记录，跳过创建步骤，只更新增量数据（比如新一轮的密钥转换）。",[11,23151,23152,23153,23156],{},"老系统的数据在整个迁移期间处于",[488,23154,23155],{},"只读状态","。没有删除任何源数据，只是读取、转换、上传。一旦新系统稳定运行，老系统作为完整的回滚备份而继续保活。这是保险的做法——如果新系统在某个时刻崩溃或数据破损，我可以立刻切回老系统，损失只是中间几个小时的新增数据。",[11,23158,23159],{},"两个测试用户通过后，迁移在一个停机窗口内展开。574 个用户分批处理，老系统的上传和新系统的导入并行进行。中间有两个用户因为网络超时导致下载失败，重新跑了一遍就成功了。这证实了脚本的幂等性和容错能力。",[11,23161,23162,23163,781],{},"迁移完成后，我逐个抽样验证。登录账号，检查数据目录是否完整，跑 md5 校验和（与打包时的 hash 对比），用新系统的接口调用 sub2api 看密钥是否正确转换。设备表的记录数和新系统现存的设备数应该吻合。clusterId 在所有用户上是一致的 ",[15,23164,23007],{},[11,23166,23167],{},"这些检查都通过以后，才通知用户切换到新系统，并提醒重启桌面端 Connector。",[26,23169,23170],{"id":23170},"为什么数据迁移总是出其不意",[11,23172,23173],{},"事后看，七个关键点里有五个是在测试阶段才浮出来的。最初的设计文档对其中三个（bind 属主问题、设备表漏掉、SFS 挂载掉线）完全没有预见。",[11,23175,23176],{},"这不是设计不够仔细，而是这类问题的特性：它们涉及多个系统的交界面。bind 挂载的属主问题不会出现在任何单一组件的文档里，而是 Linux 文件系统权限、Docker 挂载、tar 命令的组合特性。设备表漏掉是因为新系统的功能链路和老系统不同，在纸面上看不出来。SFS 的挂载掉线需要真实的硬件重启来复现。",[11,23178,23179],{},"如果先花两周完整设计，再花两周编码实现，再花一周全量执行，那么遇到这些问题时已经是 go-live 前几个小时，后果会很严重。",[11,23181,23182],{},"改成\"两个用户验证 → 踩坑固化 → 全量执行\"的流程，则是在可控的范围内把风险提前释放。两个用户的数据量不大（总共不到 500MB），失败了重来也快。从踩坑到修复脚本，整个周期不超过一天。新系统的导入逻辑也是在这个过程中逐步完善的——先是最基础的\"把数据解开、写进数据库\"，然后加上\"密钥转换\"，再加上\"设备表导入\"，最后加上\"幂等检查和错误恢复\"。",[11,23184,23185],{},"每一层都是在真实数据验证下加上去的，不是基于假设。",[11,23187,23188],{},"新系统稳定运行两周以后，我才把老 Swarm 系统下线。期间没有发现任何数据不一致或功能失效。574 个用户和它们的 124G 数据完整迁移到了新平台。",[1267,23190,23191],{},"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}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 .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 .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}",{"title":183,"searchDepth":184,"depth":184,"links":23193},[23194,23195,23204,23205,23206],{"id":22785,"depth":184,"text":22785},{"id":22797,"depth":184,"text":22797,"children":23196},[23197,23198,23199,23200,23201,23202,23203],{"id":22800,"depth":189,"text":22801},{"id":22941,"depth":189,"text":22942},{"id":22963,"depth":189,"text":22964},{"id":22994,"depth":189,"text":22995},{"id":23014,"depth":189,"text":23015},{"id":23034,"depth":189,"text":23035},{"id":23098,"depth":189,"text":23099},{"id":23114,"depth":184,"text":23115},{"id":23139,"depth":184,"text":23139},{"id":23170,"depth":184,"text":23170},"2026-06-03",{},"\u002F2026-06-03-574124g",{"title":22774,"description":22779},"2026-06-03-574用户124G全量迁移","老 Swarm 平台向新 K8s 集群迁移 574 用户及其 124G 用户数据，以测试用户踩出的七个关键点固化成执行 runbook。",[11246,11245,23214,23215,23216],"数据迁移","生产实战","运维","dlB_dynUu1DA8l32l1REN8u3JzSU2dAQIPWDJ6MkAjY",{"id":23219,"title":23220,"body":23221,"column":1966,"date":23412,"description":23225,"extension":199,"hero_image":200,"meta":23413,"navigation":202,"path":23414,"seo":23415,"series_id":200,"severity":200,"stem":23416,"summary":23417,"tags":23418,"__hash__":23421},"posts\u002F2026-06-01-为什么从Swarm迁到K8s.md","不是撑不住，是每次撑不住都要停机",{"type":8,"value":23222,"toc":23402},[23223,23226,23229,23232,23238,23245,23248,23251,23254,23257,23260,23263,23270,23284,23287,23298,23301,23308,23311,23314,23317,23320,23323,23326,23329,23332,23335,23338,23341,23344,23358,23361,23368,23372,23375,23381,23387,23393,23396,23399],[11,23224,23225],{},"老集群跑了 270 个用户，用的是单机 Swarm。现在的决策是用 K8s（天翼云 CCSE 托管版）搭新集群，只接新用户，老 Swarm 冻结。这个选择看起来很标准——当然要上 K8s 了。但真实的理由不是\"K8s 功能更完整\"，而是\"Swarm 在增长路上的停机代价无法接受\"。",[26,23227,23228],{"id":23228},"容量上限与扩容的代价",[11,23230,23231],{},"Swarm 单节点可以跑 432 个容器（现网实测）。老集群 270 用户对应 270 个容器，还算轻松。但架构是 1 用户 = 1 容器，目标规模是四位数用户。也就是说，最终要跑四位数容器。",[11,23233,23234,23235,781],{},"这不只是节点数的问题。问题在网络。Swarm 内置的 overlay 网络（gwbridge）有容量上限。当容器数逼近上限时，添加新容器需要扩容 gwbridge——这是一个网络级操作，不能在线扩展，",[488,23236,23237],{},"必须停机重建",[11,23239,23240,23241,23244],{},"这不是偶发维护，而是",[488,23242,23243],{},"必然会反复碰上的扩容事件","。实际碰过这个坑（FA-012 的经历）：当 gwbridge 容量不够，无法添加新容器，唯一的办法就是停掉整个集群，手工扩容网络，重新启动。一次停机下来，不只是那一刻的业务中断，而是周期性地陷入\"快要撑不住就停机扩容\"的循环。",[11,23246,23247],{},"现网 05 下旬 270 用户到 05-25 的 379 个 service，两周内增长 40%。这样的增速外推，容量墙会频繁碰到。当容器数从 270 扩到 400、500、更多，在单个 gwbridge 的容量上限框架内，每次都需要网络重建。每一次都是停机。Swarm 没有办法避免这个循环——架构的天花板是固定的。",[26,23249,23250],{"id":23250},"单点故障与缺失的调度层",[11,23252,23253],{},"Swarm 的故障转移很基础。节点掉线，那台机器的容器就掉线，没有自动转移。想要高可用，需要靠上层应用做心跳和重连。",[11,23255,23256],{},"K8s 有调度层。Pod 宕机或节点故障，调度器自动拉起新副本。对于这个场景——用户容器本身是无状态的（数据在外挂卷上）——自动重调度能大幅降低故障时间。特别是在四位数用户的规模下，\"某个用户的容器挂了\"这种低级故障完全不应该触发告警。",[11,23258,23259],{},"Swarm 没有这个能力。",[26,23261,23262],{"id":23262},"存储的跨节点瓶颈",[11,23264,23265,23266,23269],{},"老集群用的是节点本地的 Docker volume（",[15,23267,23268],{},"docker volume vol-\u003CuserId>","）。每个用户的数据就住在某台机器的磁盘上。这意味着：",[243,23271,23272,23278],{},[126,23273,23274,23277],{},[488,23275,23276],{},"无法迁移","。用户容器只能跑在那台机器上。节点满了？要么扩新节点并重新分配用户，要么受限于现有分布。",[126,23279,23280,23283],{},[488,23281,23282],{},"无法多副本","。同一用户的容器无法在多个节点上同时运行（数据一致性）。高可用做不了。",[11,23285,23286],{},"新方案用 SFS Turbo（NAS）。用户数据存在共享存储上，任何节点的容器都能挂上。这提供了：",[123,23288,23289,23292,23295],{},[126,23290,23291],{},"容器可以迁移和重调度",[126,23293,23294],{},"多副本有了存储基础",[126,23296,23297],{},"节点故障时数据仍在",[26,23299,23300],{"id":23300},"滚动升级的成本",[11,23302,23303,23304,23307],{},"老集群 379 个 service（05-25 实测），逐个更新镜像需要 6 小时 42 分（FA-013）。这是因为 Swarm 的 ",[15,23305,23306],{},"docker service update --image"," 是串行的，一个 service 一个 service 更新，两个 service 之间要等待它完全稳定。每个 service 的更新时间不长（几秒到几分钟），但乘以 379 就成了接近 7 小时的操作。",[11,23309,23310],{},"K8s 的 StatefulSet 或 Deployment 支持配置并发度。可以配置同时更新 10 个、20 个 Pod，甚至 50 个。滚动更新的总时间取决于最慢的那个 Pod 的启动时间，而不是 Pod 总数。这和 Swarm 的串行更新是完全不同的量级。",[11,23312,23313],{},"从 6 小时 42 分到可并发的升级方式，这对日常开发效率的影响是根本性的。尤其是要频繁迭代、测试新镜像时。每一次新版本上线，Swarm 都要等一个接近 7 小时的窗口，这会成为开发速度的瓶颈。K8s 消除了这个瓶颈。",[26,23315,23316],{"id":23316},"健康检查与自动修复",[11,23318,23319],{},"Swarm 的健康检查能力很有限。主要靠端口监听和进程存在性。应用启动了、端口在监听，Swarm 就认为健康。但一个应用可能端口开了、进程活着，内部逻辑却已经卡死。",[11,23321,23322],{},"K8s 的 readiness probe 和 liveness probe 可以执行命令、发 HTTP 请求。这样就能真正检测\"这个容器能处理请求吗\"，而不只是\"进程还在吗\"。",[11,23324,23325],{},"探测失败时，K8s 会自动杀掉容器重启。这在无状态应用场景下非常有效。Swarm 需要外层监控系统来做这个事。",[26,23327,23328],{"id":23328},"时间敏感性",[11,23330,23331],{},"当前规模是 270 用户，两周增长 40%（到 379 个 service）。这样的增速外推，目标规模四位数用户意味着持续高增长。在这个成长期，Swarm 的网络扩容停机代价会反复出现，每一次都会打断业务。等规模更大时，停机影响会更严重、数据迁移工作量更大。",[11,23333,23334],{},"K8s 没有这样的容量壁垒。同一个集群，从几十个 Pod 扩到几千个 Pod，只需要加节点。网络、调度、存储都是弹性的，无需停机。",[11,23336,23337],{},"这就是为什么现在做迁移是时间敏感的。当下的工作量相对可控；等到用户数翻倍或翻三倍，迁移的代价会指数级上升（既有用户的数据迁移、关键增长期的双平台维护风险）。",[26,23339,23340],{"id":23340},"决策的框架",[11,23342,23343],{},"对比两条路的总成本：",[123,23345,23346,23352],{},[126,23347,23348,23351],{},[488,23349,23350],{},"路 A（继续打补丁）","：在 Swarm 上增加节点、优化配置、加监控系统来补偿不足。但每到一定规模，网络扩容的停机代价不会减少。长期成本是运维压力 + 周期性停机。",[126,23353,23354,23357],{},[488,23355,23356],{},"路 B（一次迁移）","：现在投入迁移工作，切到原生支持这些能力的平台。前期工作量大，但后续增长时基础设施自动适应。",[11,23359,23360],{},"当下的工作量还在可控范围。如果等到用户数翻倍或更多，成本会更高（迁移数据量大、影响用户更多、迁移期间的风险更大）。",[11,23362,23363,23364,23367],{},"这就是为什么现在做迁移，而不是等。不是 K8s \"更流行\"，而是",[488,23365,23366],{},"继续在 Swarm 上增长，每一次容量瓶颈都要停机，这个代价在时间尺度上是递增的","。Swarm 没有办法绕过这个约束——架构的天花板就是天花板。",[26,23369,23371],{"id":23370},"k8s-的新问题","K8s 的新问题",[11,23373,23374],{},"迁移当然不是免费的。K8s 引入了新的复杂度：",[11,23376,23377,23380],{},[488,23378,23379],{},"集群管理复杂度上升","。现网 Swarm 是单机。K8s 即使用托管版（无需自己维护 etcd 和 master），节点管理、网络规划、存储配置都比之前复杂。",[11,23382,23383,23386],{},[488,23384,23385],{},"共享存储的新故障模式","。老 Swarm 用本地磁盘，故障隔离。新架构用 SFS Turbo（NAS），所有节点的容器都通过 NFS 挂载同一存储。这提供了跨节点调度的便利，代价是共享存储成为关键路径。NFS server 故障、网络拥塞、单个节点的 NFS 挂载超时或 hang，都会拖垮相关容器——这是迁移前就能预见、但只有真实跑起来才能验证的风险。这类故障需要更精细的网络监控、存储多副本（尤其是 RDS 和 Redis 直接从 HA 单机开始）、以及节点级的故障转移能力。",[11,23388,23389,23392],{},[488,23390,23391],{},"迁移期间的双平台维护","。新 K8s 集群上线，老 Swarm 继续跑现网用户。应用代码要同时支持两种编排器（Swarm 和 K8s）。新功能开发既要在 Swarm 上实现，也要在 K8s 上实现。这段时间的开发和测试工作量翻倍。",[11,23394,23395],{},"但这些都是可管理的风险。NFS 故障可以通过更好的监控、存储多副本、主备转移来缓解。共享存储是现代云原生的标准架构，业界经验充足。双平台维护的代价是时间有限的——一旦新集群稳定、旧 Swarm 下线，这块工作就消失了。",[11,23397,23398],{},"反过来，留在 Swarm 上，网络扩容停机的代价会一次次出现，直到彻底无法扩展。",[11,23400,23401],{},"那不是一个可以解决的问题。那是架构的天花板。",{"title":183,"searchDepth":184,"depth":184,"links":23403},[23404,23405,23406,23407,23408,23409,23410,23411],{"id":23228,"depth":184,"text":23228},{"id":23250,"depth":184,"text":23250},{"id":23262,"depth":184,"text":23262},{"id":23300,"depth":184,"text":23300},{"id":23316,"depth":184,"text":23316},{"id":23328,"depth":184,"text":23328},{"id":23340,"depth":184,"text":23340},{"id":23370,"depth":184,"text":23371},"2026-06-01",{},"\u002F2026-06-01-swarmk8s",{"title":23220,"description":23225},"2026-06-01-为什么从Swarm迁到K8s","平台从 Swarm 迁到 K8s 的核心理由不是功能完整度，而是增长代价——每次容量扩张都需要停机。",[11245,11246,11247,23419,23420,11722],"扩容","网络规划","nrMECzYpRn5CJjiRIjGoDsXv5fT_7US92KV5AhRChWc",{"id":23423,"title":23424,"body":23425,"column":355,"date":24082,"description":23429,"extension":199,"hero_image":200,"meta":24083,"navigation":202,"path":24084,"seo":24085,"series_id":24086,"severity":200,"stem":24087,"summary":24088,"tags":24089,"__hash__":24093},"posts\u002F2026-05-30-FA-014-前端部署五层缓存.md","每一层都对——用户看到的还是旧版",{"type":8,"value":23426,"toc":24074},[23427,23430,23433,23473,23476,23479,23529,23532,23555,23558,23561,23568,23571,23601,23604,23610,23613,23616,23622,23625,23629,23632,23675,23686,23692,23699,23708,23712,23715,23749,23752,23755,23758,23767,23779,23782,23789,23819,23825,23828,23831,23839,23842,23850,23853,24047,24058,24061,24068,24071],[11,23428,23429],{},"改了页面样式。构建完成了。原子部署也完成了。反复确认四次。每一次都验证过了。",[11,23431,23432],{},"第一次：看产物目录。",[399,23434,23436],{"className":11754,"code":23435,"language":11756,"meta":183,"style":183},"ls -la dist\u002F\ngrep 'src=\"' dist\u002Findex.html | head -1\n# 输出: src=\"\u002Fassets\u002FMarkdown-a1b2c3d4.js\"（新 hash）\n",[15,23437,23438,23449,23468],{"__ignoreMap":183},[407,23439,23440,23443,23446],{"class":409,"line":410},[407,23441,23442],{"class":554},"ls",[407,23444,23445],{"class":476}," -la",[407,23447,23448],{"class":429}," dist\u002F\n",[407,23450,23451,23454,23457,23460,23462,23465],{"class":409,"line":184},[407,23452,23453],{"class":554},"grep",[407,23455,23456],{"class":429}," 'src=\"'",[407,23458,23459],{"class":429}," dist\u002Findex.html",[407,23461,3482],{"class":417},[407,23463,23464],{"class":554}," head",[407,23466,23467],{"class":476}," -1\n",[407,23469,23470],{"class":409,"line":189},[407,23471,23472],{"class":528},"# 输出: src=\"\u002Fassets\u002FMarkdown-a1b2c3d4.js\"（新 hash）\n",[11,23474,23475],{},"新的。",[11,23477,23478],{},"第二次：从宿主机 curl，看容器返回的。",[399,23480,23482],{"className":11754,"code":23481,"language":11756,"meta":183,"style":183},"curl -sk --resolve \u003Cdomain>:443:127.0.0.1 https:\u002F\u002F\u003Cdomain>\u002F | grep 'src='\n",[15,23483,23484],{"__ignoreMap":183},[407,23485,23486,23489,23492,23495,23497,23500,23503,23505,23508,23511,23513,23515,23517,23519,23521,23523,23526],{"class":409,"line":410},[407,23487,23488],{"class":554},"curl",[407,23490,23491],{"class":476}," -sk",[407,23493,23494],{"class":476}," --resolve",[407,23496,19589],{"class":417},[407,23498,23499],{"class":429},"domai",[407,23501,23502],{"class":413},"n",[407,23504,3519],{"class":417},[407,23506,23507],{"class":429},":443:127.0.0.1",[407,23509,23510],{"class":429}," https:\u002F\u002F",[407,23512,1073],{"class":417},[407,23514,23499],{"class":429},[407,23516,23502],{"class":413},[407,23518,3519],{"class":417},[407,23520,2699],{"class":429},[407,23522,3482],{"class":417},[407,23524,23525],{"class":554}," grep",[407,23527,23528],{"class":429}," 'src='\n",[11,23530,23531],{},"还是老 hash。矛盾。进容器确认文件在不在。",[399,23533,23535],{"className":11754,"code":23534,"language":11756,"meta":183,"style":183},"docker exec cpa-caddy ls -la \u002Fopt\u002Fcpa\u002Frepo\u002Fapps\u002Fweb\u002Fdist\u002F\n",[15,23536,23537],{"__ignoreMap":183},[407,23538,23539,23541,23544,23547,23550,23552],{"class":409,"line":410},[407,23540,22886],{"class":554},[407,23542,23543],{"class":429}," exec",[407,23545,23546],{"class":429}," cpa-caddy",[407,23548,23549],{"class":429}," ls",[407,23551,23445],{"class":476},[407,23553,23554],{"class":429}," \u002Fopt\u002Fcpa\u002Frepo\u002Fapps\u002Fweb\u002Fdist\u002F\n",[11,23556,23557],{},"在。文件确实是新的。",[11,23559,23560],{},"第三次确认：Caddy 配置有没有缓存。",[11,23562,23563,23564,23567],{},"检查了。没启用。",[15,23565,23566],{},"file_server"," 每次都直接 stat。那问题不在 Caddy 的内存。",[11,23569,23570],{},"第四次：验证浏览器。",[399,23572,23574],{"className":11754,"code":23573,"language":11756,"meta":183,"style":183},"curl -s https:\u002F\u002F\u003Cdomain>\u002F | grep 'src='\n",[15,23575,23576],{"__ignoreMap":183},[407,23577,23578,23580,23583,23585,23587,23589,23591,23593,23595,23597,23599],{"class":409,"line":410},[407,23579,23488],{"class":554},[407,23581,23582],{"class":476}," -s",[407,23584,23510],{"class":429},[407,23586,1073],{"class":417},[407,23588,23499],{"class":429},[407,23590,23502],{"class":413},[407,23592,3519],{"class":417},[407,23594,2699],{"class":429},[407,23596,3482],{"class":417},[407,23598,23525],{"class":554},[407,23600,23528],{"class":429},[11,23602,23603],{},"用户端拿到的仍是老 hash 指向。",[11,23605,23606,23607,781],{},"三个观察结果，互相矛盾：文件系统新了，容器内的文件也是新的，Caddy 没有缓存，但用户端还是老样式。不能同时都是对的。这意味着问题不在任何一个环节本身，而在环节之间的",[488,23608,23609],{},"链接",[26,23611,23612],{"id":23612},"五层缓存链路",[11,23614,23615],{},"问题的根源是缓存在多个环节堆积。前端静态资源从产生到用户浏览器要经过五层：",[399,23617,23620],{"className":23618,"code":23619,"language":1327},[1325],"浏览器磁盘缓存 ← CDN 边缘节点 ← 反向代理（容器内） ← bind mount ← 产物目录\n",[15,23621,23619],{"__ignoreMap":183},[11,23623,23624],{},"部署过程只触及最内层的产物目录，上面四层完全无感知。",[26,23626,23628],{"id":23627},"docker-bind-mount-的-inode-陷阱","Docker bind mount 的 inode 陷阱",[11,23630,23631],{},"原子部署的标准做法：",[399,23633,23635],{"className":11754,"code":23634,"language":11756,"meta":183,"style":183},"mv dist dist.OLD-$(date +%s)        # 原 dist 改名，inode 100\nmv dist.NEW dist                     # 新 dist 改名为 dist，inode 200\n",[15,23636,23637,23663],{"__ignoreMap":183},[407,23638,23639,23642,23645,23648,23651,23654,23657,23660],{"class":409,"line":410},[407,23640,23641],{"class":554},"mv",[407,23643,23644],{"class":429}," dist",[407,23646,23647],{"class":429}," dist.OLD-",[407,23649,23650],{"class":413},"$(",[407,23652,23653],{"class":554},"date",[407,23655,23656],{"class":429}," +%s",[407,23658,23659],{"class":413},")        ",[407,23661,23662],{"class":528},"# 原 dist 改名，inode 100\n",[407,23664,23665,23667,23670,23672],{"class":409,"line":184},[407,23666,23641],{"class":554},[407,23668,23669],{"class":429}," dist.NEW",[407,23671,23644],{"class":429},[407,23673,23674],{"class":528},"                     # 新 dist 改名为 dist，inode 200\n",[11,23676,23677,23678,23681,23682,23685],{},"改名后，宿主机的 ",[15,23679,23680],{},"\u002Fopt\u002Fcpa\u002Frepo\u002Fapps\u002Fweb\u002Fdist"," 现在指向 inode 200。但容器内的 bind mount 有个细节：它在启动时绑定了",[488,23683,23684],{},"inode 编号","，不是路径。",[11,23687,23688,23689,23691],{},"容器启动时，宿主机的 dist 指向 inode 100。bind mount 说：\"我要挂载 inode 100\"。容器内的 ",[15,23690,23680],{}," 永远指向那个 inode。",[11,23693,23694,23695,23698],{},"部署后，宿主机的路径改了，指向 inode 200。但容器内仍然看着 inode 100。这就是为什么 ",[15,23696,23697],{},"docker exec"," 看到的文件是新的（内核在路径后面找文件），但容器运行的 Caddy 进程看不到（它的 bind mount 被钉死在了旧 inode）。",[11,23700,23701,23702,23704,23705,23707],{},"那 ",[15,23703,23697],{}," 为什么能看到新文件呢？因为 ",[15,23706,23697],{}," 实际上是在宿主机侧做的文件系统查询，走的是宿主机最新的路径指向。容器内的 Caddy 进程走的是它挂载时定下来的 inode。",[26,23709,23711],{"id":23710},"根因三个矛盾现象的同一原因","根因：三个矛盾现象的同一原因",[11,23713,23714],{},"这个故障的隐蔽之处在于，三个表面上没有关联的观察结果其实由同一个原因产生：",[243,23716,23717,23723,23732],{},[126,23718,23719,23722],{},[488,23720,23721],{},"文件系统看得是新的","：宿主机的产物目录已经更新，inode 200 的新文件确实存在。",[126,23724,23725,23728,23729,23731],{},[488,23726,23727],{},"容器内看到的是旧的","：因为 bind mount 在启动时绑定了 inode 编号，容器内 ",[15,23730,23680],{}," 永远指向 inode 100，即便宿主机的路径已经指向 inode 200。",[126,23733,23734,23737,23738,23741,23742,23745,23746,23748],{},[488,23735,23736],{},"入口 HTML 缓存了旧 hash","：这是最隐蔽的一层。假设容器确实能看到新文件，问题仍然可能出现——如果浏览器或上游 CDN 缓存了旧的 ",[15,23739,23740],{},"index.html","，拿到的 HTML 里仍然指向旧的 ",[15,23743,23744],{},"app-old-hash.js","，那么即便所有新资源都已上线，浏览器也会去请求旧文件。而 Vite 生成的文件名带内容 hash，理论上 hash 变了就是新 URL，应该不会命中旧缓存。但前提是获取 ",[15,23747,23740],{}," 的请求本身不被缓存。",[11,23750,23751],{},"实际排查中后两个问题叠加了。宿主机部署了新文件，但容器看不到（inode 问题）；即便容器能看到，用户端也可能先从 CDN 拿到缓存的旧 HTML，指向旧的资源 hash，然后再次请求这些旧资源。",[26,23753,23754],{"id":23754},"修复方案与选择",[11,23756,23757],{},"Docker bind mount 的 inode 绑定问题有两个解决方向：",[11,23759,23760,23763,23766],{},[488,23761,23762],{},"方案 A：重启容器（推荐）",[23764,23765],"br",{},"\n重新启动 Caddy 容器会重新挂载卷，绑定最新的 inode。单次重启耗时少于 2 秒，操作简单，无需改动部署脚本。重启后验证容器返回的 hash 是否已更新为最新值。",[11,23768,23769,23772,23774,23775,23778],{},[488,23770,23771],{},"方案 B：改用覆盖式同步",[23764,23773],{},"\n用 ",[15,23776,23777],{},"rsync --delete dist.NEW\u002F dist\u002F"," 直接覆盖文件，而不是整个目录改名。这样 dist 目录本身的 inode 保持不变，容器内的 bind mount 始终指向同一个 inode，但目录内的文件被替换。",[11,23780,23781],{},"方案 A 更简洁，决定采纳。但这只解决了第三层（bind mount）的问题。",[11,23783,23784,23785,23788],{},"还需处理第二层和第一层的缓存。CDN 边缘节点不一定完全遵守 origin 返回的 ",[15,23786,23787],{},"Cache-Control: must-revalidate, no-cache"," 头，不同地区节点的失效时间不同。必须主动向 CDN 服务商提交清缓存请求。",[399,23790,23792],{"className":11754,"code":23791,"language":11756,"meta":183,"style":183},"# 部署完成后，手动提交 purge 请求，涉及四个 URL\n# https:\u002F\u002F\u003Cdomain>\u002F\n# https:\u002F\u002F\u003Cdomain>\u002Findex.html\n# https:\u002F\u002F\u003Cadmin-domain>\u002F\n# https:\u002F\u002F\u003Cadmin-domain>\u002Findex.html\n",[15,23793,23794,23799,23804,23809,23814],{"__ignoreMap":183},[407,23795,23796],{"class":409,"line":410},[407,23797,23798],{"class":528},"# 部署完成后，手动提交 purge 请求，涉及四个 URL\n",[407,23800,23801],{"class":409,"line":184},[407,23802,23803],{"class":528},"# https:\u002F\u002F\u003Cdomain>\u002F\n",[407,23805,23806],{"class":409,"line":189},[407,23807,23808],{"class":528},"# https:\u002F\u002F\u003Cdomain>\u002Findex.html\n",[407,23810,23811],{"class":409,"line":452},[407,23812,23813],{"class":528},"# https:\u002F\u002F\u003Cadmin-domain>\u002F\n",[407,23815,23816],{"class":409,"line":458},[407,23817,23818],{"class":528},"# https:\u002F\u002F\u003Cadmin-domain>\u002Findex.html\n",[11,23820,23821,23822,23824],{},"CDN 侧清缓存一般 5-10 分钟生效全网。对于浏览器磁盘缓存，用户拿到新的 ",[15,23823,23740],{}," 后，浏览器会发现资源 URL（带新 hash）和本地缓存不符，自动重新请求，所以无需单独处理——只要确保上游返回的是新 HTML。",[26,23826,23827],{"id":23827},"防回归",[11,23829,23830],{},"这个故障暴露了两个流程漏洞：",[11,23832,23833,23836,23838],{},[488,23834,23835],{},"1. 部署验证不完整",[23764,23837],{},"\n部署脚本完成后没有自动验证生效。从现象看是\"改样式不生效\"，实际排查才发现是系统问题，前面四次盲改毫无意义。",[11,23840,23841],{},"新增验证步骤：部署后必须从外网 curl 验证 hash，而不是从宿主机或容器内部检查。",[11,23843,23844,23847,23849],{},[488,23845,23846],{},"2. 缓存链路的文档缺失",[23764,23848],{},"\n五层缓存各有各的失效条件和刷新手段，没有集中文档时，新同学很容易漏掉某一层。",[11,23851,23852],{},"后续在部署 SOP 中增加这份 checklist：",[399,23854,23856],{"className":11754,"code":23855,"language":11756,"meta":183,"style":183},"# 1. 执行部署（rsync + atomic mv）\n... rsync apps\u002Fweb\u002Fdist ... && mv dist.NEW dist ...\n\n# 2. 重启容器（修复 inode 绑定）\nsudo docker restart cpa-caddy\nsleep 3\n\n# 3. 验证容器返回新 hash\ncurl -sk --resolve \u003Cdomain>:443:127.0.0.1 https:\u002F\u002F\u003Cdomain>\u002F | grep 'src=' | head -1\n# 应匹配 dist\u002Findex.html 里的 hash\n\n# 4. 清 CDN 缓存（手动或 API）\n# 进入 CDN 控制台，提交 purge 请求：\n# \u003Cdomain>\u002F\n# \u003Cdomain>\u002Findex.html\n# \u003Cadmin-domain>\u002F\n# \u003Cadmin-domain>\u002Findex.html\n\n# 5. 等待 5-10 分钟后，从外网再次验证\ncurl -s https:\u002F\u002F\u003Cdomain>\u002F | grep 'src='\n# 应该是最新 hash\n",[15,23857,23858,23863,23888,23892,23897,23910,23918,23922,23927,23970,23975,23979,23984,23989,23994,23999,24004,24009,24013,24018,24042],{"__ignoreMap":183},[407,23859,23860],{"class":409,"line":410},[407,23861,23862],{"class":528},"# 1. 执行部署（rsync + atomic mv）\n",[407,23864,23865,23867,23870,23873,23876,23879,23881,23883,23885],{"class":409,"line":184},[407,23866,7262],{"class":476},[407,23868,23869],{"class":429}," rsync",[407,23871,23872],{"class":429}," apps\u002Fweb\u002Fdist",[407,23874,23875],{"class":429}," ...",[407,23877,23878],{"class":413}," && ",[407,23880,23641],{"class":554},[407,23882,23669],{"class":429},[407,23884,23644],{"class":429},[407,23886,23887],{"class":429}," ...\n",[407,23889,23890],{"class":409,"line":189},[407,23891,1827],{"emptyLinePlaceholder":202},[407,23893,23894],{"class":409,"line":452},[407,23895,23896],{"class":528},"# 2. 重启容器（修复 inode 绑定）\n",[407,23898,23899,23901,23904,23907],{"class":409,"line":458},[407,23900,11763],{"class":554},[407,23902,23903],{"class":429}," docker",[407,23905,23906],{"class":429}," restart",[407,23908,23909],{"class":429}," cpa-caddy\n",[407,23911,23912,23915],{"class":409,"line":464},[407,23913,23914],{"class":554},"sleep",[407,23916,23917],{"class":476}," 3\n",[407,23919,23920],{"class":409,"line":470},[407,23921,1827],{"emptyLinePlaceholder":202},[407,23923,23924],{"class":409,"line":480},[407,23925,23926],{"class":528},"# 3. 验证容器返回新 hash\n",[407,23928,23929,23931,23933,23935,23937,23939,23941,23943,23945,23947,23949,23951,23953,23955,23957,23959,23961,23964,23966,23968],{"class":409,"line":1477},[407,23930,23488],{"class":554},[407,23932,23491],{"class":476},[407,23934,23494],{"class":476},[407,23936,19589],{"class":417},[407,23938,23499],{"class":429},[407,23940,23502],{"class":413},[407,23942,3519],{"class":417},[407,23944,23507],{"class":429},[407,23946,23510],{"class":429},[407,23948,1073],{"class":417},[407,23950,23499],{"class":429},[407,23952,23502],{"class":413},[407,23954,3519],{"class":417},[407,23956,2699],{"class":429},[407,23958,3482],{"class":417},[407,23960,23525],{"class":554},[407,23962,23963],{"class":429}," 'src='",[407,23965,3482],{"class":417},[407,23967,23464],{"class":554},[407,23969,23467],{"class":476},[407,23971,23972],{"class":409,"line":1483},[407,23973,23974],{"class":528},"# 应匹配 dist\u002Findex.html 里的 hash\n",[407,23976,23977],{"class":409,"line":2139},[407,23978,1827],{"emptyLinePlaceholder":202},[407,23980,23981],{"class":409,"line":2180},[407,23982,23983],{"class":528},"# 4. 清 CDN 缓存（手动或 API）\n",[407,23985,23986],{"class":409,"line":7546},[407,23987,23988],{"class":528},"# 进入 CDN 控制台，提交 purge 请求：\n",[407,23990,23991],{"class":409,"line":7564},[407,23992,23993],{"class":528},"# \u003Cdomain>\u002F\n",[407,23995,23996],{"class":409,"line":17267},[407,23997,23998],{"class":528},"# \u003Cdomain>\u002Findex.html\n",[407,24000,24001],{"class":409,"line":17279},[407,24002,24003],{"class":528},"# \u003Cadmin-domain>\u002F\n",[407,24005,24006],{"class":409,"line":18010},[407,24007,24008],{"class":528},"# \u003Cadmin-domain>\u002Findex.html\n",[407,24010,24011],{"class":409,"line":18057},[407,24012,1827],{"emptyLinePlaceholder":202},[407,24014,24015],{"class":409,"line":18062},[407,24016,24017],{"class":528},"# 5. 等待 5-10 分钟后，从外网再次验证\n",[407,24019,24020,24022,24024,24026,24028,24030,24032,24034,24036,24038,24040],{"class":409,"line":18067},[407,24021,23488],{"class":554},[407,24023,23582],{"class":476},[407,24025,23510],{"class":429},[407,24027,1073],{"class":417},[407,24029,23499],{"class":429},[407,24031,23502],{"class":413},[407,24033,3519],{"class":417},[407,24035,2699],{"class":429},[407,24037,3482],{"class":417},[407,24039,23525],{"class":554},[407,24041,23528],{"class":429},[407,24043,24044],{"class":409,"line":18072},[407,24045,24046],{"class":528},"# 应该是最新 hash\n",[11,24048,24049,24050,24053,24054,24057],{},"目前 ",[15,24051,24052],{},"release-connector.sh"," 只处理后端服务和链配置，前端改动不频繁，单独拉出一个 ",[15,24055,24056],{},"release-web.sh"," 脚本会更清晰，包含上述步骤。",[26,24059,24060],{"id":24060},"隐蔽点总结",[11,24062,24063,24064,24067],{},"五层缓存中，最容易被忽视的是：",[488,24065,24066],{},"即便下层的资源文件都更新了，上层缓存的入口 HTML 如果不更新，用户浏览器拿到的仍是指向旧资源 hash 的 HTML","。因为 HTML 本身通常没有被标记为 immutable，且如果被 CDN 或浏览器缓存，就会导致\"文件都对，但浏览器看不到新版本\"的假象。只有 HTML 这一个引入点的 hash 改变了，整个依赖树才会指向新的资源。",[11,24069,24070],{},"这也解释了为什么\"产物目录看着没问题，容器内也看着没问题，但用户端就是看不到\"——问题不在文件是否存在，而在从启动到用户的整条链路中，任何一层缓存都可能吞掉更新信号。Docker bind mount 的 inode 绑定恰好是最隐蔽的那一层，因为容器内的 stat 调用看起来成功了，但返回的是旧 inode 的元数据。",[1267,24072,24073],{},"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 .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}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 .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}",{"title":183,"searchDepth":184,"depth":184,"links":24075},[24076,24077,24078,24079,24080,24081],{"id":23612,"depth":184,"text":23612},{"id":23627,"depth":184,"text":23628},{"id":23710,"depth":184,"text":23711},{"id":23754,"depth":184,"text":23754},{"id":23827,"depth":184,"text":23827},{"id":24060,"depth":184,"text":24060},"2026-05-30",{},"\u002F2026-05-30-fa-014",{"title":23424,"description":23429},"FA-014","2026-05-30-FA-014-前端部署五层缓存","改样式后部署完成但用户端看不到，排查发现五层缓存全链失效，最隐蔽的陷阱是 Docker bind mount 的 inode 绑定。",[24090,13564,3421,24091,24092],"Docker","前端部署","原子部署","CGuiaZICRCpHOOXKdoMigh6WUy7sjGN-7iXKVf1fxmQ",{"id":24095,"title":24096,"body":24097,"column":6815,"date":24637,"description":24638,"extension":199,"hero_image":200,"meta":24639,"navigation":202,"path":24640,"seo":24641,"series_id":200,"severity":200,"stem":24642,"summary":24643,"tags":24644,"__hash__":24647},"posts\u002F2026-05-27-步进升级为什么增量包不能跨版本跳.md","跳过一个撤回的版本，客户就少了个 DLL",{"type":8,"value":24098,"toc":24624},[24099,24106,24109,24115,24121,24124,24131,24134,24137,24143,24146,24152,24157,24161,24164,24170,24174,24177,24183,24190,24193,24197,24204,24207,24210,24213,24216,24220,24223,24227,24235,24239,24250,24255,24266,24270,24273,24277,24288,24292,24300,24304,24315,24321,24328,24331,24334,24337,24366,24369,24372,24378,24384,24387,24390,24442,24445,24548,24555,24569,24572,24583,24590,24615,24621],[11,24100,24101,24102,24105],{},"1.0.7 客户升级到 1.0.9 后启动崩溃，报 ",[15,24103,24104],{},"STATUS_DLL_NOT_FOUND","。根因在于增量包的构造逻辑与发版序列的交互：任何\"中间 GA 版本被撤回且后续版本以它作为 baseline\"的组合，都会让跳过撤回版本的客户拿到不完整的增量包。",[26,24107,24108],{"id":24108},"增量包的设计前提",[11,24110,24111,24112,24114],{},"OTA 更新包是二进制差分产物——",[15,24113,13988],{}," 在构造增量包时，只列入「FROM → TO 两个相邻版本之间 sha256 实际不同的文件」。以 1.0.8 → 1.0.9 的增量为例：",[399,24116,24119],{"className":24117,"code":24118,"language":1327},[1325],"1.0.8 dist ↔ 1.0.9 dist\n  vcruntime140.dll (sha256: abc...)  vcruntime140.dll (sha256: abc...)\n                                    ↑ 一致，不进差分包\n  msvcp140.dll (sha256: def...)     msvcp140.dll (sha256: def...)\n                                    ↑ 一致，不进差分包\n  launcher.exe (sha256: old)        launcher.exe (sha256: new)\n                                    ↑ 不同，进差分包\n",[15,24120,24118],{"__ignoreMap":183},[11,24122,24123],{},"相比全量包（500+ MB），增量包通常只有 100-200 MB。对于国内弱网、公网、海外长途网络来说，包体积直接影响下载时间和失败率。选择差分包的代价是必须确保客户的升级链路完整。",[11,24125,24126,24127,24130],{},"这个设计基于一个隐含的假设：",[488,24128,24129],{},"客户严格按发布顺序逐版升级","（1.0.7 → 1.0.8 → 1.0.9 → ...）。每跳一个版本，就叠加一次「FROM→TO」的相邻增量。所有累积变更都会被依次应用，最终到达完整的目标版本。只要这个假设成立，差分机制就是 sound 的。但一旦中间版本被 recall 且升序逻辑允许跳过，假设就被破坏了。",[26,24132,24133],{"id":24133},"发版序列与撤回的交互",[11,24135,24136],{},"关键的发版序列是这样的：",[399,24138,24141],{"className":24139,"code":24140,"language":1327},[1325],"1.0.5  GA   无 VCRuntime DLL\n1.0.6  recalled\n1.0.7  GA   无 VCRuntime DLL\n1.0.8  GA   ↓ 首次 Copy-VcRuntime，dist 含 9 个 VC++ Runtime DLL\n       后 recalled（因 OTA_TRASH_CORRUPT 现场卡死）\n1.0.9  GA   继承 1.0.8 build，含 VCRuntime DLL\n       baseline = 1.0.8（diff 算相邻两版）\n       minClient = 1.0.8（改为 1.0.7 后触发此 bug）\n",[15,24142,24140],{"__ignoreMap":183},[11,24144,24145],{},"当 1.0.9 的 minClient 被改为 1.0.7 后，升序逻辑会为 1.0.7 客户返回 1.0.9 的 manifest。minClient 是一个关键的版本约束字段——它表示\"升级到当前版本前，客户必须至少是什么版本\"。修改 minClient 的动机通常是想扩大升级适配范围（比如从 1.0.8 放宽到 1.0.7），但这个改动隐含了一个假设：\"当前版本及其所有依赖都能兼容从 minClient 开始的所有中间版本\"。在这个案例中，这个假设被打破了。",[399,24147,24150],{"className":24148,"code":24149,"language":1327},[1325],"G26 升序查询（1.0.7 客户，seek current 之后最小 GA）：\n  候选 1.0.8 → recalled，跳过\n  候选 1.0.9 → GA，minClient=1.0.7≤1.0.7 满足 → 命中，返回 1.0.9 manifest\n",[15,24151,24149],{"__ignoreMap":183},[11,24153,24154,24155,781],{},"1.0.7 客户 apply 1.0.9 的差分包后，系统启动时 launcher.exe 试图加载 VCRUNTIME140.dll，但客户机的 dist 里没有这个文件——1.0.8 引入的 9 个 DLL 永远没有被应用过。Windows 加载器在 LoadLibrary 阶段直接终结进程，报 ",[15,24156,24104],{},[26,24158,24160],{"id":24159},"为什么-107-看起来能启动升级后就崩溃","为什么 1.0.7 看起来能启动，升级后就崩溃",[11,24162,24163],{},"这里需要澄清一个关键点。1.0.7 的 dist 里确实没有 VCRuntime DLL，但 1.0.7 版本的应用仍然能启动。原因在于 launcher.exe 是用 1.0.7 时代的 toolchain build 的，它依赖的 MSVC CRT 版本恰好在干净 Windows 的 System32 里能找到（或者该客户机预装了对应版本的 Visual C++ Redistributable）。这是一个\"侥幸\"——1.0.7 构建时选用的工具链版本较旧，CRT 的 ABI 足够稳定，系统库能满足。",[11,24165,24166,24167,24169],{},"一旦升级到 1.0.9，launcher.exe 是用更新的 toolchain build 的，它依赖的 CRT 版本更新了，链接的 API 集合也有变化。客户机的 System32 里没有这个新版 CRT。Windows PE 加载器会在 LoadLibrary 阶段就找不到 VCRUNTIME140.dll，进程在主函数之前被系统杀掉（",[15,24168,24104],{},"）。应用的错误处理机制根本没机会启动——没有 try-catch，没有日志，只有系统错误弹窗和退出码。",[26,24171,24173],{"id":24172},"为什么会漏-dll","为什么会漏 DLL",[11,24175,24176],{},"这是差分包设计与版本跳过的碰撞。1.0.9 的差分包不包含 vcruntime140.dll，原因看似无害：",[399,24178,24181],{"className":24179,"code":24180,"language":1327},[1325],"1.0.8 dist                1.0.9 dist\n  vcruntime140.dll          vcruntime140.dll\n  sha256: \u003Chash>            sha256: \u003Chash>（同一个 build 产物，哈希相同）\n  → 两边都有，且相同 → 不进差分包\n",[15,24182,24180],{"__ignoreMap":183},[11,24184,24185,24186,24189],{},"但这个逻辑基于一个前提：",[488,24187,24188],{},"从 1.0.8 升级来","。如果客户是从 1.0.7 升级，那么它的 dist 里根本没有 vcruntime140.dll，差分包也不会帮它补上——因为 1.0.9 diff 算法看的是\"1.0.8 有且 1.0.9 也有\" 的文件。",[11,24191,24192],{},"任何「中间 GA 版本引入新文件 + 该版本被 recall + 后续版本用它作 baseline」的组合，都会造成同样的问题。这不是 DLL 特有的现象，而是增量包机制本身的约束。",[26,24194,24196],{"id":24195},"为什么-108-会引入-dll","为什么 1.0.8 会引入 DLL",[11,24198,24199,24200,24203],{},"顺便说一下 1.0.8 为什么成为\"罪魁祸首\"。1.0.8 是第一个在 build-portable.ps1 中加入 ",[15,24201,24202],{},"Copy-VcRuntime"," 的版本，它主动把整套 MSVC redist DLL（9 个）旁置到 dist，这样便携包就不再依赖系统级安装的 Visual C++ Redistributable。这个改动本身是对的，但由于 1.0.8 随后因 OTA_TRASH_CORRUPT 问题被 recall，它成了\"引入新文件但被撤回\"的典型。",[11,24205,24206],{},"1.0.9 继承了 1.0.8 的 build，自然也含有这 9 个 DLL，但 1.0.9 的差分包相对 1.0.8 计算，所以这些 DLL 不会再被列入。结果就是：1.0.7 客户跳过 1.0.8，直接升到 1.0.9，永远收不到这些 DLL。",[11,24208,24209],{},"同时，还有一个独立的关联问题：修复脚本 Fix-VCRuntime.bat 本身因为编码问题（UTF-8 vs GBK 的冲突）无法正常执行，所以即使用户尝试手动修复，脚本也会报错。这是打包流程和脚本编码的两个独立根因，在同一时间窗口内暴露了出来。",[26,24211,24212],{"id":24212},"修复方案的权衡",[11,24214,24215],{},"面对这个问题，有两条主要路径可选：",[31,24217,24219],{"id":24218},"方案-a全量兜底","方案 A：全量兜底",[11,24221,24222],{},"跨版本升级时直接下发完整 full.zip，而不是差分包。",[11,24224,24225,1244],{},[488,24226,11946],{},[123,24228,24229,24232],{},[126,24230,24231],{},"简单粗暴，一个包包含所有文件，不存在漏 DLL 的可能",[126,24233,24234],{},"不需要调整发版流程",[11,24236,24237,1244],{},[488,24238,11949],{},[123,24240,24241,24244,24247],{},[126,24242,24243],{},"包体积大（差分包通常 100-200 MB，全量 500+ MB）",[126,24245,24246],{},"客户网络环境差时升级时间长，失败率高",[126,24248,24249],{},"CDN 带宽成本增加",[11,24251,24252,1244],{},[488,24253,24254],{},"适用场景",[123,24256,24257,24260,24263],{},[126,24258,24259],{},"一次性应急（发 hotfix 回滚）",[126,24261,24262],{},"用户基数小、网络条件好",[126,24264,24265],{},"跨度非常大（比如 1.0.1 → 2.0.0）的少见场景",[31,24267,24269],{"id":24268},"方案-b严格步进","方案 B：严格步进",[11,24271,24272],{},"服务端按发布链下发下一个版本，不允许跨级。即使是 recalled 版本也保留在链上，只是不作为升级终点。",[11,24274,24275,1244],{},[488,24276,11946],{},[123,24278,24279,24282,24285],{},[126,24280,24281],{},"包体积小，增量包通常 100-200 MB",[126,24283,24284],{},"每个客户走相同的升级链路，问题复现容易排查",[126,24286,24287],{},"充分利用增量包设计的初衷",[11,24289,24290,1244],{},[488,24291,11949],{},[123,24293,24294,24297],{},[126,24295,24296],{},"升级链路长（1.0.7 → 1.0.9 时需要先升 1.0.8，即使 1.0.8 被 recalled）",[126,24298,24299],{},"如果某个中间版本有严重问题（比如 OTA 卡死），客户升级时仍会卡在它上面",[11,24301,24302,1244],{},[488,24303,24254],{},[123,24305,24306,24309,24312],{},[126,24307,24308],{},"绝大多数常规发版",[126,24310,24311],{},"网络条件多变的客户群",[126,24313,24314],{},"需要严格控制升级质量的生产环境",[11,24316,24317,24320],{},[488,24318,24319],{},"为什么选择 B","：有几个关键因素。首先，客户群分布广（企业内网、弱网、移动网络混合），300+ MB 的全量包在弱网场景下升级失败率会明显增加，反而增加了支持成本。其次，一旦引入全量兜底，就容易形成\"跨版本时总是下发全量\"的惯性，长期来看放弃了增量包的设计初衷。最后，严格步进虽然升级链路长，但每个版本的 DL + apply 成本都是可预测的，失败时也容易重试。",[11,24322,24323,24324,24327],{},"通过让 recalled 版本保持在链上（只改为 non-GA 状态），升序逻辑会自动跳过它们，下一个 GA 版本会包含完整的累积增量。1.0.9 被 recall 后，G26 升序对 1.0.7 客户会自动推荐 1.0.10；1.0.10 的差分是相对 1.0.7 算的（通过 ",[15,24325,24326],{},"FROM_VERSION=1.0.7"," 显式指定），包含了 1.0.8 引入的 9 个 DLL 以及后续的所有变更。这样既保持了包体积小的优势，又确保了跨越 recalled 版本的客户能拿到完整增量。1.0.10 的差分包最关键的是 Removes 为 0——任何跨版本的客户 apply 都不会留下废文件。",[26,24329,24330],{"id":24330},"为什么撤回版本不能从链上摘掉",[11,24332,24333],{},"这里有一个容易忽视的细节：即使某个版本被 recall，也不能从版本链中物理删除。",[11,24335,24336],{},"如果把 1.0.8 从数据库里删掉，升序逻辑会直接跳过它，导致 1.0.7 客户被推荐到 1.0.9——此时就回到了原来的问题。修复的关键是让升序逻辑仍然\"看到\" 1.0.8 的存在（用于确定差分链路），但把它的 rollout 状态改为 non-GA，使得它永远不会被作为升级终点。",[399,24338,24340],{"className":6339,"code":24339,"language":6341,"meta":183,"style":183},"-- 这样的做法是错的（会复现问题）：\nDELETE FROM releases WHERE version = '1.0.8';\n\n-- 正确的做法：\nUPDATE releases SET rollout_status = 'recalled' WHERE version = '1.0.8';\n",[15,24341,24342,24347,24352,24356,24361],{"__ignoreMap":183},[407,24343,24344],{"class":409,"line":410},[407,24345,24346],{},"-- 这样的做法是错的（会复现问题）：\n",[407,24348,24349],{"class":409,"line":184},[407,24350,24351],{},"DELETE FROM releases WHERE version = '1.0.8';\n",[407,24353,24354],{"class":409,"line":189},[407,24355,1827],{"emptyLinePlaceholder":202},[407,24357,24358],{"class":409,"line":452},[407,24359,24360],{},"-- 正确的做法：\n",[407,24362,24363],{"class":409,"line":458},[407,24364,24365],{},"UPDATE releases SET rollout_status = 'recalled' WHERE version = '1.0.8';\n",[11,24367,24368],{},"换句话说，版本链的完整性是优于减少数据库记录的。每次新版本发布时，baseline 计算和 diff 生成都要以完整的版本序列为基础。所以即使 1.0.8 和 1.0.9 都被 recall，它们仍然需要在数据库中占据一个位置，作为\"这个版本存在过，但用户不应该升级到它\"的标记。升序逻辑看到 recalled 标记后会直接跳过，继续往后找第一个 GA 版本。",[11,24370,24371],{},"修复后的数据库状态是：",[399,24373,24376],{"className":24374,"code":24375,"language":1327},[1325],"1.0.7   GA         minClient=1.0.5\n1.0.8   recalled\n1.0.9   recalled   ← 这一步至关重要\n1.0.10  GA         minClient=1.0.7\n",[15,24377,24375],{"__ignoreMap":183},[11,24379,24380,24381,24383],{},"现在 1.0.7 客户升序时会依次检查 1.0.8（recalled，跳）→ 1.0.9（recalled，跳）→ 1.0.10（GA，命中）。1.0.10 的差分包是通过 ",[15,24382,24326],{}," 显式指定的，所以它包含 1.0.8 和 1.0.9 引入的所有文件。",[26,24385,24386],{"id":24386},"预防和后续",[11,24388,24389],{},"立即修复是发 1.0.10 并 recall 1.0.9，已在 2026-05-11 19:42 完成。发布 1.0.10 时的关键操作是显式指定 baseline：",[399,24391,24393],{"className":11754,"code":24392,"language":11756,"meta":183,"style":183},"FROM_VERSION=1.0.7 \\\nMINISIGN_PASSWORD='...' ADMIN_PASS='...' BUILD_HOST_PASSWORD='...' \\\nROLLOUT_STATUS=whitelist \\\n  bash scripts\u002Frelease.sh 1.0.10\n",[15,24394,24395,24407,24423,24431],{"__ignoreMap":183},[407,24396,24397,24400,24402,24405],{"class":409,"line":410},[407,24398,24399],{"class":413},"FROM_VERSION",[407,24401,418],{"class":417},[407,24403,24404],{"class":429},"1.0.7",[407,24406,11781],{"class":554},[407,24408,24409,24412,24415,24418,24421],{"class":409,"line":184},[407,24410,24411],{"class":413},"MINISIGN_PASSWORD=",[407,24413,24414],{"class":429},"'...'",[407,24416,24417],{"class":429}," ADMIN_PASS='...'",[407,24419,24420],{"class":429}," BUILD_HOST_PASSWORD='...'",[407,24422,11781],{"class":476},[407,24424,24425,24428],{"class":409,"line":189},[407,24426,24427],{"class":413},"ROLLOUT_STATUS=whitelist ",[407,24429,24430],{"class":476},"\\\n",[407,24432,24433,24436,24439],{"class":409,"line":452},[407,24434,24435],{"class":429},"  bash",[407,24437,24438],{"class":429}," scripts\u002Frelease.sh",[407,24440,24441],{"class":476}," 1.0.10\n",[11,24443,24444],{},"这样 diff 生成引擎会计算 1.0.7 ↔ 1.0.10 的完整差异，而不是默认的\"上一个 GA\"（1.0.9）。然后执行 PATCH 和 recall 操作：",[399,24446,24448],{"className":11754,"code":24447,"language":11756,"meta":183,"style":183},"# 1. PATCH 1.0.10 → ga\ncurl -X PATCH \"$ADMIN\u002Fadmin\u002Freleases\u002F$ID\u002Frollout\" -d '{\"status\":\"ga\"}'\n\n# 2. recall 1.0.9（防止升序仍然推荐给 1.0.7 客户）\nbash scripts\u002Frelease\u002Frecall.sh \u003Crelease-id>\n\n# 3. 清 redis rollout-wrapper cache（立即生效，不等待 cache 过期）\nredis-cli --scan --pattern 'cache:rollout-wrapper:*' | xargs -r redis-cli DEL\n",[15,24449,24450,24455,24485,24489,24494,24511,24515,24520],{"__ignoreMap":183},[407,24451,24452],{"class":409,"line":410},[407,24453,24454],{"class":528},"# 1. PATCH 1.0.10 → ga\n",[407,24456,24457,24459,24462,24465,24467,24470,24473,24476,24479,24482],{"class":409,"line":184},[407,24458,23488],{"class":554},[407,24460,24461],{"class":476}," -X",[407,24463,24464],{"class":429}," PATCH",[407,24466,14908],{"class":429},[407,24468,24469],{"class":413},"$ADMIN",[407,24471,24472],{"class":429},"\u002Fadmin\u002Freleases\u002F",[407,24474,24475],{"class":413},"$ID",[407,24477,24478],{"class":429},"\u002Frollout\"",[407,24480,24481],{"class":476}," -d",[407,24483,24484],{"class":429}," '{\"status\":\"ga\"}'\n",[407,24486,24487],{"class":409,"line":189},[407,24488,1827],{"emptyLinePlaceholder":202},[407,24490,24491],{"class":409,"line":452},[407,24492,24493],{"class":528},"# 2. recall 1.0.9（防止升序仍然推荐给 1.0.7 客户）\n",[407,24495,24496,24498,24501,24503,24506,24509],{"class":409,"line":458},[407,24497,11756],{"class":554},[407,24499,24500],{"class":429}," scripts\u002Frelease\u002Frecall.sh",[407,24502,19589],{"class":417},[407,24504,24505],{"class":429},"release-i",[407,24507,24508],{"class":413},"d",[407,24510,13679],{"class":417},[407,24512,24513],{"class":409,"line":464},[407,24514,1827],{"emptyLinePlaceholder":202},[407,24516,24517],{"class":409,"line":470},[407,24518,24519],{"class":528},"# 3. 清 redis rollout-wrapper cache（立即生效，不等待 cache 过期）\n",[407,24521,24522,24525,24528,24531,24534,24536,24539,24542,24545],{"class":409,"line":480},[407,24523,24524],{"class":554},"redis-cli",[407,24526,24527],{"class":476}," --scan",[407,24529,24530],{"class":476}," --pattern",[407,24532,24533],{"class":429}," 'cache:rollout-wrapper:*'",[407,24535,3482],{"class":417},[407,24537,24538],{"class":554}," xargs",[407,24540,24541],{"class":476}," -r",[407,24543,24544],{"class":429}," redis-cli",[407,24546,24547],{"class":429}," DEL\n",[11,24549,24550,24551,24554],{},"预防层面，在 ",[15,24552,24553],{},"wakou-release"," skill 中加了三条新检测项（G27\u002FG28\u002FG29）：",[243,24556,24557,24560,24563],{},[126,24558,24559],{},"检查 releases 表里有没有「ga → recalled 但后续版本 baseline 还指着它」的组合",[126,24561,24562],{},"评估是否存在客户群可能跨过 recalled 版本拿增量",[126,24564,24565,24566],{},"如果是，强制新版本 specify ",[15,24567,24568],{},"FROM_VERSION=\u003C最老活跃 GA 版本>",[11,24570,24571],{},"这三条检测在每次发版前自动跑，有问题的组合会在 release.sh 阶段被拦住，不允许走到\"上传 CDN\"这一步。",[11,24573,24574,24575,24578,24579,24582],{},"后续架构层改进（未实施）：admin server 的 G26 解析逻辑可以加「baseline 链路完整性检查」。具体是：当升序逻辑返回 manifest 给客户时，校验 ",[15,24576,24577],{},"client.version → release.baseline_version"," 之间的所有版本是否都是 GA（无 recalled）。如果链路中存在 recalled 版本，admin 直接返回错误码（比如 ",[15,24580,24581],{},"EXTRACT_FROM_OLDER_BASELINE","）给 OTA worker，让客户 fallback 到全量包请求，而不是无声给一个不完整的差分。",[11,24584,24585,24586,24589],{},"这个改进的收益是\"防御性更强\"——即使人肉操作出了问题（比如遗漏了 ",[15,24587,24588],{},"FROM_VERSION="," 指定），系统也能主动检测并避免分发坏包。但代价是需要修改：",[123,24591,24592,24598,24605,24608],{},[126,24593,24594,24597],{},[15,24595,24596],{},"release-manifest.json"," 的 schema（添加链路检查标记）",[126,24599,24600,24601,24604],{},"admin 的 ",[15,24602,24603],{},"update.ts::resolveBonjourCliPath"," 逻辑",[126,24606,24607],{},"OTA worker 的错误码处理（识别新的 fallback 信号）",[126,24609,24610,24611,24614],{},"CDN 需要同时提供 ",[15,24612,24613],{},"full-from-empty.zip","（或允许 diff 降级到 full）",[11,24616,24617,24618,24620],{},"鉴于目前的发版流程和人肉检测已经能防住这个问题，架构层改进被标记为后续单独需求，暂时先依赖流程检查和 ",[15,24619,24588],{}," 人肉兜底。",[1267,24622,24623],{},"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 .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}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 .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":183,"searchDepth":184,"depth":184,"links":24625},[24626,24627,24628,24629,24630,24631,24635,24636],{"id":24108,"depth":184,"text":24108},{"id":24133,"depth":184,"text":24133},{"id":24159,"depth":184,"text":24160},{"id":24172,"depth":184,"text":24173},{"id":24195,"depth":184,"text":24196},{"id":24212,"depth":184,"text":24212,"children":24632},[24633,24634],{"id":24218,"depth":189,"text":24219},{"id":24268,"depth":189,"text":24269},{"id":24330,"depth":184,"text":24330},{"id":24386,"depth":184,"text":24386},"2026-05-27","1.0.7 客户升级到 1.0.9 后启动崩溃，报 STATUS_DLL_NOT_FOUND。根因在于增量包的构造逻辑与发版序列的交互：任何\"中间 GA 版本被撤回且后续版本以它作为 baseline\"的组合，都会让跳过撤回版本的客户拿到不完整的增量包。",{},"\u002F2026-05-27",{"title":24096,"description":24638},"2026-05-27-步进升级为什么增量包不能跨版本跳","增量包是相邻两版的差分，跳过中间版本会漏掉某版本引入的文件，导致客户启动崩溃。",[15069,24645,15072,24646,19951],"增量更新","二进制差分","mO5egKlEmvxpoLQ8R1GYBlnXzKl1bq1NoHYzI2X5h-0",{"id":24649,"title":24650,"body":24651,"column":355,"date":25183,"description":24655,"extension":199,"hero_image":200,"meta":25184,"navigation":202,"path":25185,"seo":25186,"series_id":25187,"severity":200,"stem":25188,"summary":25189,"tags":25190,"__hash__":25196},"posts\u002F2026-05-25-FA-013-九缺陷叠加P0.md","一天，247 次 OOM",{"type":8,"value":24652,"toc":25175},[24653,24656,24659,24662,24666,24669,24698,24701,24707,24710,24713,24719,24722,24728,24797,24808,24811,24814,24817,24820,24879,24882,24885,24888,24891,24906,24912,24932,24935,24938,24944,24950,24959,24962,24965,24968,25131,25134,25137,25140,25143,25146,25149,25169,25172],[11,24654,24655],{},"247 次。",[11,24657,24658],{},"一个容器一天被 cgroup OOM 杀了 247 次。每次 kill 加 supervisord 拉起新进程，22 秒的启动窗口里所有 API 调用都在超时。累计掉线时间超过 1.5 小时。而这只是冰山一角——整个故障链条里还有八个独立的缺陷在同步放大。",[11,24660,24661],{},"2026-05-24 下午 17:30 起，大量用户反馈掉线。表面上是三种完全不同的症状：网关无响应、插件拿不到 token、WebSocket 频繁断线重连。如果顺着单一假设深挖——比如\"是不是 token 服务故障\"——永远也理不清楚。症状看起来毫无关联，但其实源于同一条触发链的不同环节失效。",[26,24663,24665],{"id":24664},"为什么症状分散根因却统一","为什么症状分散，根因却统一",[11,24667,24668],{},"SSH 进服务器第一件事：",[399,24670,24672],{"className":11754,"code":24671,"language":11756,"meta":183,"style":183},"sudo dmesg -T | grep \"Killed process.*openclaw\" | head -20\n",[15,24673,24674],{"__ignoreMap":183},[407,24675,24676,24678,24681,24684,24686,24688,24691,24693,24695],{"class":409,"line":410},[407,24677,11763],{"class":554},[407,24679,24680],{"class":429}," dmesg",[407,24682,24683],{"class":476}," -T",[407,24685,3482],{"class":417},[407,24687,23525],{"class":554},[407,24689,24690],{"class":429}," \"Killed process.*openclaw\"",[407,24692,3482],{"class":417},[407,24694,23464],{"class":554},[407,24696,24697],{"class":476}," -20\n",[11,24699,24700],{},"输出：",[399,24702,24705],{"className":24703,"code":24704,"language":1327},[1325],"[21:35:14] Killed process 1409836 (openclaw) total-vm:1570176kB, anon-rss:512000kB\n[21:35:15] Killed process 1409924 (openclaw)\n[21:35:52] Killed process 1413381 (openclaw)\n",[15,24706,24704],{"__ignoreMap":183},[11,24708,24709],{},"2 分钟 15 次 kill。内存限制 500MB 的容器在高负载下不够用，系统开始清理。gateway 进程死后，supervisord 重启它，22 秒内启动完成才能服务请求。这个窗口里 panel API 全部超时。现象一：网关掉线。",[11,24711,24712],{},"但这还解释不了\"token 过期\"。进容器看日志，大量这样的：",[399,24714,24717],{"className":24715,"code":24716,"language":1327},[1325],"[21:39:39] [reload] config watcher error: EMFILE: too many open files\n[21:44:10] [skills] watcher error: EMFILE: too many open files\n",[15,24718,24716],{"__ignoreMap":183},[11,24720,24721],{},"同时 registerCpaConnectorMcp 失败。查 DB 对比，容器的 openclaw.json 里的 token 与数据库的 active token 不一致。这说明配置写入本身出了问题。",[11,24723,24724,24725,1244],{},"检查代码 ",[15,24726,24727],{},"apps\u002Fapi\u002Fsrc\u002Fconnector\u002Fmcp-register.ts",[399,24729,24731],{"className":21005,"code":24730,"language":21007,"meta":183,"style":183},"if (r.exitCode !== 0) {\n  logger.warn({...}, 'mcp register exited non-zero');\n  return { status: 'error', reason: `exit=${r.exitCode}` };\n}\n",[15,24732,24733,24746,24767,24793],{"__ignoreMap":183},[407,24734,24735,24737,24740,24742,24744],{"class":409,"line":410},[407,24736,875],{"class":417},[407,24738,24739],{"class":413}," (r.exitCode ",[407,24741,5159],{"class":417},[407,24743,1118],{"class":476},[407,24745,887],{"class":413},[407,24747,24748,24751,24754,24757,24759,24762,24765],{"class":409,"line":184},[407,24749,24750],{"class":413},"  logger.",[407,24752,24753],{"class":554},"warn",[407,24755,24756],{"class":413},"({",[407,24758,7262],{"class":417},[407,24760,24761],{"class":413},"}, ",[407,24763,24764],{"class":429},"'mcp register exited non-zero'",[407,24766,662],{"class":413},[407,24768,24769,24771,24774,24776,24779,24782,24784,24786,24789,24791],{"class":409,"line":189},[407,24770,2142],{"class":417},[407,24772,24773],{"class":413}," { status: ",[407,24775,18743],{"class":429},[407,24777,24778],{"class":413},", reason: ",[407,24780,24781],{"class":429},"`exit=${",[407,24783,22831],{"class":413},[407,24785,3767],{"class":429},[407,24787,24788],{"class":413},"exitCode",[407,24790,5625],{"class":429},[407,24792,4594],{"class":413},[407,24794,24795],{"class":409,"line":452},[407,24796,483],{"class":413},[11,24798,24799,24800,24803,24804,24807],{},"openclaw CLI 用 exit code 1 表示\"完成但有警告\"。代码把所有非零当失败。虽然 stderr 里有 ",[15,24801,24802],{},"Config overwrite"," 痕迹（文件其实已写入），但这个误判触发了 ",[15,24805,24806],{},"clearReconcileCooldown","，下次 WebSocket 重连时又重试一遍。每次重试就是一次 SIGTERM。最终统计：1366 次 mcp register 报失败（大部分是假失败），对应 1366 次虚假 SIGTERM。现象二：网关频繁掉线。",[11,24809,24810],{},"现象三：WebSocket 4-9 分钟就断一次。查 CDN 日志，Tencent EdgeOne 对 idle 连接有强制切断策略。客户端自动重连，形成流量风暴。",[11,24812,24813],{},"三个症状，三个独立的源头，但都指向同一条链路的不同节点失效。正因为如此，修复不能是单一 patch——需要同时拆除这条链上的所有地雷。",[26,24815,24816],{"id":24816},"九个缺陷与它们的叠加效应",[11,24818,24819],{},"做完全景取证后，缺陷清单出现了：",[243,24821,24822,24828,24834,24840,24846,24852,24861,24867,24873],{},[126,24823,24824,24827],{},[488,24825,24826],{},"B1"," 容器内存限制 500MB 严重不足 → gateway 进程 OOM kill → 22s 启动窗口 → panel 调用超时",[126,24829,24830,24833],{},[488,24831,24832],{},"B2"," CLI exit code 1 被误判为失败 → 1366 次虚假 SIGTERM → 指数级恶化掉线",[126,24835,24836,24839],{},[488,24837,24838],{},"B3"," ulimit nofile=1024 太低 → EMFILE → file watcher 失败 → 配置无法热重载",[126,24841,24842,24845],{},[488,24843,24844],{},"B4"," mcp reconcile cooldown 在 skip 时也被清空 → 每次 WS 重连重试 → 无效 SIGTERM 更多",[126,24847,24848,24851],{},[488,24849,24850],{},"B5"," registry kick-on-replace → 多客户端互踢 → 工具调用中途断裂",[126,24853,24854,16323,24857,24860],{},[488,24855,24856],{},"B6",[15,24858,24859],{},"\u002Finstance\u002Fprovision"," 不幂等 → 新用户注册失败 → 14 次 AlreadyExists 错误\u002F天",[126,24862,24863,24866],{},[488,24864,24865],{},"B7"," event loop 阻塞 7-18 秒 → tools\u002Fcall 5 分钟超时 → 整机压力指数级增加",[126,24868,24869,24872],{},[488,24870,24871],{},"B8"," CDN 对 WebSocket idle 4-9 分钟强制切断 → 客户端循环重连 → 流量风暴",[126,24874,24875,24878],{},[488,24876,24877],{},"B9"," 7 个用户仍跑老 EXE → 跟 Tauri 1.0.8 共存互踢 → 特定用户反复掉线",[11,24880,24881],{},"单独看，B3（文件描述符限制）只会导致某些 watcher 失败。B4（cooldown 清空）只是重试次数多一些。但当 B1（内存溢出）把整机资源挤到极限时，B2（虚假 SIGTERM）的流量放大、B4 的无限重试、B8 的 CDN 断线形成了一个正反馈循环。12GiB swap 活跃交换，主线程不断触发 page fault，event loop 阻塞最高达 18.8 秒。整个服务器的 system load 从正常的 2-3 飙升到 18.96。",[11,24883,24884],{},"不止受害的那个 247 次 OOM 的容器宕掉了。所有 372 个容器的网关进程都在 event loop 阻塞中挣扎，形成连锁故障。",[26,24886,24887],{"id":24887},"凌晨部署与并行推进",[11,24889,24890],{},"修复分两条线并行。",[11,24892,24893,24896,24897,88,24900,24902,24903,24905],{},[488,24894,24895],{},"基础设施层","（立即上线）：部署 WebSocket 直连入口，绕过 CDN 的 idle timeout。DNS 添加新直连域名的 A 记录，Caddy 仅暴露 ",[15,24898,24899],{},"\u002Fapi\u002Fconnector\u002Fws",[15,24901,18593],{},"。客户端 1.0.9 OTA 时读 ",[15,24904,19986],{}," 字段，动态切换通路。",[11,24907,24908,24911],{},[488,24909,24910],{},"业务代码层","（业务低谷部署）：五个 patch，530 行改动：",[123,24913,24914,24917,24920,24923,24926],{},[126,24915,24916],{},"B1：500MB → 2GB hard limit",[126,24918,24919],{},"B2：exit code 判定加 stderr marker 检查",[126,24921,24922],{},"B3：ulimit nofile 1024 → 65536",[126,24924,24925],{},"B4：cooldown skip 时不清",[126,24927,24928,24929,24931],{},"B6：",[15,24930,24859],{}," 加 AlreadyExists 幂等处理",[11,24933,24934],{},"P1 级（稍后）：B5 改为拒绝新连接而非踢掉旧连接；B9 的老 EXE 用户做 revoke 和自检。",[11,24936,24937],{},"凌晨 02:00 开始。前置备份 30 分钟（PG、repo、Caddyfile、image、swarm spec、用户数据）。",[11,24939,24940,24943],{},[488,24941,24942],{},"阶段 1 代码部署","：pnpm build，rsync dist 到服务器，原子切换（mv），restart cpa-api。全程 5 秒，用户感知的掉线比预估的 30 秒更短。",[11,24945,24946,24949],{},[488,24947,24948],{},"阶段 2 监控验证","：部署后 5 分钟内检查指标。B2 生效（1366 失败 → 9h 内 132）、B4 生效（cooldown 日志出现）、B6 生效（新注册零错误）。全部通过。",[11,24951,24952,24955,24956,24958],{},[488,24953,24954],{},"阶段 3 滚动升级","：优先升级 6 个重灾户（含 247 次那个），5.5 分钟完成。升级后检查 dmesg，无 OOM kill。剩余 ~366 个容器一次 10 个并行，间隔 30 秒。单容器 ",[15,24957,11046],{}," 耗时 60 秒（含 swarm scheduler delay）。总耗时 6h42min（预估 3 小时，实际慢一倍）。但系统压力从升级到 ~80 个容器时就开始回落。",[11,24960,24961],{},"0 failed，0 回滚。",[26,24963,24964],{"id":24964},"数据说话",[11,24966,24967],{},"部署 24 小时后对比：",[1708,24969,24970,24986],{},[1711,24971,24972],{},[1714,24973,24974,24977,24980,24983],{},[1717,24975,24976],{},"指标",[1717,24978,24979],{},"部署前",[1717,24981,24982],{},"部署后 9h",[1717,24984,24985],{},"改善",[1730,24987,24988,25003,25019,25035,25051,25068,25085,25099,25116],{},[1714,24989,24990,24993,24996,25000],{},[1735,24991,24992],{},"容器 OOM kill（重灾户）",[1735,24994,24995],{},"247 次\u002F天",[1735,24997,24998],{},[488,24999,648],{},[1735,25001,25002],{},"100%",[1714,25004,25005,25008,25011,25014],{},[1735,25006,25007],{},"Swap 使用",[1735,25009,25010],{},"12 GiB",[1735,25012,25013],{},"9 MiB",[1735,25015,25016],{},[488,25017,25018],{},"-99.9%",[1714,25020,25021,25024,25027,25030],{},[1735,25022,25023],{},"System load（1min）",[1735,25025,25026],{},"18.96",[1735,25028,25029],{},"8.46",[1735,25031,25032],{},[488,25033,25034],{},"-55%",[1714,25036,25037,25040,25043,25046],{},[1735,25038,25039],{},"可用内存",[1735,25041,25042],{},"58 GiB",[1735,25044,25045],{},"95 GiB",[1735,25047,25048],{},[488,25049,25050],{},"+64%",[1714,25052,25053,25059,25062,25065],{},[1735,25054,25055,25058],{},[15,25056,25057],{},"mcp register"," 真失败",[1735,25060,25061],{},"1366\u002F天（混+假）",[1735,25063,25064],{},"132\u002F9h",[1735,25066,25067],{},"~88% ↓",[1714,25069,25070,25075,25077,25082],{},[1735,25071,25072,25074],{},[15,25073,25057],{}," 假失败救活",[1735,25076,648],{},[1735,25078,25079],{},[488,25080,25081],{},"26 次",[1735,25083,25084],{},"新指标",[1714,25086,25087,25090,25093,25097],{},[1735,25088,25089],{},"AlreadyExists 新用户错误",[1735,25091,25092],{},"14\u002F天",[1735,25094,25095],{},[488,25096,648],{},[1735,25098,25002],{},[1714,25100,25101,25107,25110,25114],{},[1735,25102,25103,25106],{},[15,25104,25105],{},"connection replaced"," 工具中断",[1735,25108,25109],{},"18\u002F天",[1735,25111,25112],{},[488,25113,648],{},[1735,25115,25002],{},[1714,25117,25118,25123,25126,25128],{},[1735,25119,25120],{},[15,25121,25122],{},"tools\u002Fcall exception",[1735,25124,25125],{},"35\u002F天",[1735,25127,6924],{},[1735,25129,25130],{},"~95% ↓",[11,25132,25133],{},"用户反馈中\"网关掉线\"消失，\"插件用不了\"归零。",[26,25135,25136],{"id":25136},"防回归与巡检",[11,25138,25139],{},"五个 patch 配套 19 个单元测试 case（vitest，135ms 跑完），覆盖 B1\u002FB2\u002FB3\u002FB6 的临界条件。新增 PG 自动备份 cron（每日 03:00），swarm spec 全量快照存档用于灾难重建。部署 checklist 和回滚脚本纳入标准流程。",[11,25141,25142],{},"但多缺陷叠加的\"系统级阈值\"仍缺持续巡检——这不能用传统的单点告警解决。需要在后续单独设计。",[26,25144,25145],{"id":25145},"调查多缺陷故障的方法",[11,25147,25148],{},"多缺陷叠加时，症状看起来统一但根因分散。不能顺着单一假设深挖（比如假设\"一定是 token 问题\"），会被带进死胡同。正确的做法：",[243,25150,25151,25157,25163],{},[126,25152,25153,25156],{},[488,25154,25155],{},"全景取证","：把所有异常信号（OOM、EMFILE、token 不一致、event loop 阻塞、WebSocket thrash）列出来，不预判因果关系。",[126,25158,25159,25162],{},[488,25160,25161],{},"分类而非追踪","：不要从某个症状开始追链条，而是把信号按系统层级分组，看哪些是独立根因、哪些是被放大的现象。",[126,25164,25165,25168],{},[488,25166,25167],{},"按影响面排优先级","：单个 bug 的修复收益不一定最大。有时修复跨层级放大的系统级缺陷（比如错误的 exit code 判定 + 无 cooldown 保护）的收益，远超修复看起来最直接的单个 bug（比如内存限制）。",[11,25170,25171],{},"这次故障的真正杀伤力不来自 B1 的内存溢出，而来自 B2（虚假 SIGTERM）+ B4（cooldown 清空）的组合——它们把单个容器的故障放大成了全站连锁反应。",[1267,25173,25174],{},"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}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}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 .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}",{"title":183,"searchDepth":184,"depth":184,"links":25176},[25177,25178,25179,25180,25181,25182],{"id":24664,"depth":184,"text":24665},{"id":24816,"depth":184,"text":24816},{"id":24887,"depth":184,"text":24887},{"id":24964,"depth":184,"text":24964},{"id":25136,"depth":184,"text":25136},{"id":25145,"depth":184,"text":25145},"2026-05-25",{},"\u002F2026-05-25-fa-013-p0",{"title":24650,"description":24655},"FA-013","2026-05-25-FA-013-九缺陷叠加P0","一场波及全站用户掉线的故障根因不是单一 bug，而是九个独立缺陷在内存压力下叠加放大；修复需要基础设施和业务代码两条线并行推进。",[25191,25192,19463,25193,25194,25195],"容器调度","内存管理","分布式系统","故障诊断","cgroup","-HmhblzI7fSTtvtqjX9kzYVJF2SMwjtWQlD5yvkx0O8",{"id":25198,"title":25199,"body":25200,"column":355,"date":26400,"description":25204,"extension":199,"hero_image":200,"meta":26401,"navigation":202,"path":26402,"seo":26403,"series_id":26404,"severity":200,"stem":26405,"summary":26406,"tags":26407,"__hash__":26412},"posts\u002F2026-05-22-FA-012-gwbridge容量上限.md","一个默认配置，决定了我能有多少用户",{"type":8,"value":25201,"toc":26392},[25202,25205,25208,25211,25214,25217,25220,25223,25226,25229,25232,25237,25244,25250,25253,25318,25321,25342,25345,25348,25353,25359,25407,25410,25413,25416,25443,25446,25451,25454,25474,25477,25482,25485,25490,25493,25496,25559,25562,25565,25570,25573,25576,25579,25612,25615,25618,25646,25649,25652,25659,25664,25667,25670,25676,25727,25730,25735,25742,25745,25763,25766,25769,25774,25777,25782,25785,25837,25843,25854,25861,25864,25945,25951,25956,25962,26016,26019,26022,26027,26030,26033,26036,26041,26044,26047,26052,26055,26061,26064,26071,26076,26082,26085,26092,26097,26100,26142,26145,26148,26166,26176,26182,26185,26190,26193,26207,26214,26219,26222,26227,26230,26233,26237,26243,26252,26268,26274,26277,26280,26306,26309,26329,26332,26335,26340,26354,26359,26362,26365,26389],[11,25203,25204],{},"我以为这是十分钟的活：一行 apt 改动，给 runtime 镜像加 ffmpeg，打包、灰度、完。结果花了三小时多。不是因为代码复杂，而是在灰度的那一刻，整个基础设施的隐性容量上限暴露了——Docker Swarm 的 gwbridge 只有 253 个 IP，现在已经用满了。",[26,25206,25207],{"id":25207},"现象",[11,25209,25210],{},"早上 7:30，用户报 AI 容器里没有 ffmpeg，无法剪视频。表面上这是一行 Dockerfile 改动——apt 列表里漏了 ffmpeg。",[11,25212,25213],{},"一小时内排查完毕：容器确实没装这个工具。改 Dockerfile、打包新镜像（1.0.18），准备灰度。",[11,25215,25216],{},"然后，在给一个用户灰度升级时，事情出了岔子。",[11,25218,25219],{},"Swarm 调度了一个新的 task 去 pull 新镜像，但 gwbridge（Docker overlay 网络中负责容器出站的虚拟网桥）拿不到 IP。Task 失败了。灰度用户被卡在 0\u002F1 状态。自动回滚也失败了。",[11,25221,25222],{},"同时看 prod 集群的 service 状态，有 10 个长期处于 cycling 启动失败、重试、又失败的状态——这些现象其实已经存在很久，但因为看起来像是\"偶发起不来\"，一直被当作瞬时故障忽视了。现在它们集中暴露了。",[11,25224,25225],{},"很快发现真问题：生产环境的 gwbridge 是 \u002F24 网段，253 个可用 IP 已经占满。当前有 251 个用户容器 + ingress network 的 1 个 endpoint + 其他系统组件 = 已用 253\u002F253。任何新容器启动时需要分配 endpoint，但池子空了——没有可用 IP，task 调度失败，开始 cycling。",[11,25227,25228],{},"那 10-13 个看起来\"偶发失败\"的 service？它们就是一直在试图找到那不存在的第 254 个 IP。",[26,25230,25231],{"id":25231},"排查过程",[11,25233,25234],{},[488,25235,25236],{},"8:10 — 首先检查 service 状态",[11,25238,25239,25240,25243],{},"连接到生产服务器，运行 ",[15,25241,25242],{},"docker service ls","，发现当前 service 的复制状态：",[399,25245,25248],{"className":25246,"code":25247,"language":1327},[1325],"ID          NAME              MODE        REPLICAS    IMAGE\n...\n251 个 RUNNING 状态的 service，每个 replicas=1\u002F1\n10 个 RUNNING 状态但 replicas=0\u002F1，反复 cycling\n",[15,25249,25247],{"__ignoreMap":183},[11,25251,25252],{},"所有用户的容器中有 251 个正常运行，另外 10 个卡在了反复重试的状态。再深入看 gwbridge 的配置：",[399,25254,25256],{"className":11754,"code":25255,"language":11756,"meta":183,"style":183},"$ docker network inspect gwbridge\n[{\n  \"Name\": \"docker_gwbridge\",\n  \"Subnet\": \"172.30.1.0\u002F24\",\n  \"Gateway\": \"172.30.1.1\"\n}]\n",[15,25257,25258,25274,25279,25291,25303,25313],{"__ignoreMap":183},[407,25259,25260,25263,25265,25268,25271],{"class":409,"line":410},[407,25261,25262],{"class":554},"$",[407,25264,23903],{"class":429},[407,25266,25267],{"class":429}," network",[407,25269,25270],{"class":429}," inspect",[407,25272,25273],{"class":429}," gwbridge\n",[407,25275,25276],{"class":409,"line":184},[407,25277,25278],{"class":413},"[{\n",[407,25280,25281,25284,25286,25289],{"class":409,"line":189},[407,25282,25283],{"class":429},"  \"Name\"",[407,25285,3607],{"class":413},[407,25287,25288],{"class":429},"\"docker_gwbridge\"",[407,25290,3296],{"class":413},[407,25292,25293,25296,25298,25301],{"class":409,"line":452},[407,25294,25295],{"class":429},"  \"Subnet\"",[407,25297,3607],{"class":413},[407,25299,25300],{"class":429},"\"172.30.1.0\u002F24\"",[407,25302,3296],{"class":413},[407,25304,25305,25308,25310],{"class":409,"line":458},[407,25306,25307],{"class":429},"  \"Gateway\"",[407,25309,3607],{"class":413},[407,25311,25312],{"class":429},"\"172.30.1.1\"\n",[407,25314,25315],{"class":409,"line":464},[407,25316,25317],{"class":413},"}]\n",[11,25319,25320],{},"\u002F24 网段分配给了 gwbridge。接下来检查当前分配的 IP 究竟用到了哪里：",[399,25322,25324],{"className":11754,"code":25323,"language":11756,"meta":183,"style":183},"$ docker network inspect gwbridge --verbose\n",[15,25325,25326],{"__ignoreMap":183},[407,25327,25328,25330,25332,25334,25336,25339],{"class":409,"line":410},[407,25329,25262],{"class":554},[407,25331,23903],{"class":429},[407,25333,25267],{"class":429},[407,25335,25270],{"class":429},[407,25337,25338],{"class":429}," gwbridge",[407,25340,25341],{"class":476}," --verbose\n",[11,25343,25344],{},"一遍遍历下来，251 个用户容器占了 endpoint，加上 ingress 及其他系统组件的占位，gwbridge 内 253 个可用 IP 已经无余量。\u002F24 网段理论上是 254 个 IP（.0 到 .255），但除去网络地址 .0 和广播地址 .255，可用的就是 253 个（.1 网关也在其中）。",[11,25346,25347],{},"已用 253\u002F253。没有余量。这就是为什么新容器无法获取 IP，灰度失败了。",[11,25349,25350],{},[488,25351,25352],{},"9:00 — 假设 1：改 daemon.json 配置",[11,25354,25355,25356,1244],{},"想过扩大 gwbridge 的网段。改 ",[15,25357,25358],{},"\u002Fetc\u002Fdocker\u002Fdaemon.json",[399,25360,25362],{"className":3591,"code":25361,"language":3593,"meta":183,"style":183},"{\n  \"default-address-pools\": [{\n    \"base\": \"172.31.0.0\u002F16\",\n    \"size\": 22\n  }]\n}\n",[15,25363,25364,25368,25376,25388,25398,25403],{"__ignoreMap":183},[407,25365,25366],{"class":409,"line":410},[407,25367,421],{"class":413},[407,25369,25370,25373],{"class":409,"line":184},[407,25371,25372],{"class":476},"  \"default-address-pools\"",[407,25374,25375],{"class":413},": [{\n",[407,25377,25378,25381,25383,25386],{"class":409,"line":189},[407,25379,25380],{"class":476},"    \"base\"",[407,25382,3607],{"class":413},[407,25384,25385],{"class":429},"\"172.31.0.0\u002F16\"",[407,25387,3296],{"class":413},[407,25389,25390,25393,25395],{"class":409,"line":452},[407,25391,25392],{"class":476},"    \"size\"",[407,25394,3607],{"class":413},[407,25396,25397],{"class":476},"22\n",[407,25399,25400],{"class":409,"line":458},[407,25401,25402],{"class":413},"  }]\n",[407,25404,25405],{"class":409,"line":464},[407,25406,483],{"class":413},[11,25408,25409],{},"这样，下次创建网络时，Swarm 应该用 \u002F22（1022 个 IP）而不是 \u002F24（253 个）。",[11,25411,25412],{},"重启 docker daemon，让配置生效。等待 60 秒，让集群自愈。",[11,25414,25415],{},"再看 gwbridge：",[399,25417,25419],{"className":11754,"code":25418,"language":11756,"meta":183,"style":183},"$ docker info | grep \"Default Address Pools:\" -A 1\n",[15,25420,25421],{"__ignoreMap":183},[407,25422,25423,25425,25427,25430,25432,25434,25437,25440],{"class":409,"line":410},[407,25424,25262],{"class":554},[407,25426,23903],{"class":429},[407,25428,25429],{"class":429}," info",[407,25431,3482],{"class":417},[407,25433,23525],{"class":554},[407,25435,25436],{"class":429}," \"Default Address Pools:\"",[407,25438,25439],{"class":476}," -A",[407,25441,25442],{"class":476}," 1\n",[11,25444,25445],{},"没有输出。空。配置没读进去。",[11,25447,25448],{},[488,25449,25450],{},"9:15 — 尝试各种做法",[11,25452,25453],{},"想快速解决，试过几个办法：",[243,25455,25456,25463,25466],{},[126,25457,25458,25459,25462],{},"删除 ",[15,25460,25461],{},"\u002Fvar\u002Flib\u002Fdocker\u002Fnetwork\u002Ffiles\u002Flocal-kv.db","（Docker 网络配置持久化库），强制重建。——不行。",[126,25464,25465],{},"确认 daemon.json 语法无误，重新加载 daemon 配置。——不行。",[126,25467,25468,25469,7322,25471,25473],{},"查阅 Docker 文档。发现一句关键的话：",[15,25470,11008],{},[15,25472,11015],{}," 时消费一次。",[11,25475,25476],{},"意思是，这个配置项的生效时机是 Docker Swarm 初始化那一刻——在那时，Docker 会根据这个池子创建初始网络。但对于已经存在的网络，改这个配置不会有任何效果。gwbridge 是 5 天前 swarm init 时创建的。现在改 daemon.json 已经太晚。配置修改完全无法影响到已有网络的设置。",[11,25478,25479],{},[488,25480,25481],{},"9:30 — 改回配置，准备另一条路",[11,25483,25484],{},"把 daemon.json 恢复到原样，重启 docker。集群自愈回到\"252 healthy + 10 cycling\"状态。",[11,25486,25487],{},[488,25488,25489],{},"9:45 — Staging 验证",[11,25491,25492],{},"有个 staging 环境（同样的 Docker Swarm 架构，但用户数少得多）。用它做 dry-run。",[11,25494,25495],{},"方案：手动删除 gwbridge，再用新 subnet 重建。",[399,25497,25499],{"className":11754,"code":25498,"language":11756,"meta":183,"style":183},"docker network rm docker_gwbridge\ndocker network create \\\n  --driver bridge \\\n  --subnet 172.31.0.0\u002F20 \\\n  --gateway 172.31.0.1 \\\n  docker_gwbridge\n",[15,25500,25501,25513,25524,25534,25544,25554],{"__ignoreMap":183},[407,25502,25503,25505,25507,25510],{"class":409,"line":410},[407,25504,22886],{"class":554},[407,25506,25267],{"class":429},[407,25508,25509],{"class":429}," rm",[407,25511,25512],{"class":429}," docker_gwbridge\n",[407,25514,25515,25517,25519,25522],{"class":409,"line":184},[407,25516,22886],{"class":554},[407,25518,25267],{"class":429},[407,25520,25521],{"class":429}," create",[407,25523,11781],{"class":476},[407,25525,25526,25529,25532],{"class":409,"line":189},[407,25527,25528],{"class":476},"  --driver",[407,25530,25531],{"class":429}," bridge",[407,25533,11781],{"class":476},[407,25535,25536,25539,25542],{"class":409,"line":452},[407,25537,25538],{"class":476},"  --subnet",[407,25540,25541],{"class":429}," 172.31.0.0\u002F20",[407,25543,11781],{"class":476},[407,25545,25546,25549,25552],{"class":409,"line":458},[407,25547,25548],{"class":476},"  --gateway",[407,25550,25551],{"class":476}," 172.31.0.1",[407,25553,11781],{"class":476},[407,25555,25556],{"class":409,"line":464},[407,25557,25558],{"class":429},"  docker_gwbridge\n",[11,25560,25561],{},"Staging 上，105 秒内完成整个过程，所有 service 自动重新调度，容器全部拿回 IP，状态恢复。",[11,25563,25564],{},"确认这个方案可行。",[11,25566,25567],{},[488,25568,25569],{},"9:50 — Canary 试错",[11,25571,25572],{},"但真正动手前，想在 prod 上做个\"单用户 canary\"——选一个 idle 用户，试试 scale 0（停容器，释放 endpoint）然后 scale 1（重启容器，重新分配 endpoint）。这样可以在生产环境验证删除重建方案是否安全。",[11,25574,25575],{},"选了用户 B。",[11,25577,25578],{},"先 scale 0：",[399,25580,25582],{"className":11754,"code":25581,"language":11756,"meta":183,"style":183},"docker service scale svc-\u003C用户B>=0\n",[15,25583,25584],{"__ignoreMap":183},[407,25585,25586,25588,25591,25594,25597,25599,25602,25605,25607,25609],{"class":409,"line":410},[407,25587,22886],{"class":554},[407,25589,25590],{"class":429}," service",[407,25592,25593],{"class":429}," scale",[407,25595,25596],{"class":429}," svc-",[407,25598,1073],{"class":417},[407,25600,25601],{"class":429},"用户",[407,25603,25604],{"class":413},"B",[407,25606,3519],{"class":417},[407,25608,418],{"class":429},[407,25610,25611],{"class":476},"0\n",[11,25613,25614],{},"成功。容器停了，gwbridge 上的 endpoint 释放了。一个 IP 应该回归可用。",[11,25616,25617],{},"再 scale 1：",[399,25619,25621],{"className":11754,"code":25620,"language":11756,"meta":183,"style":183},"docker service scale svc-\u003C用户B>=1\n",[15,25622,25623],{"__ignoreMap":183},[407,25624,25625,25627,25629,25631,25633,25635,25637,25639,25641,25643],{"class":409,"line":410},[407,25626,22886],{"class":554},[407,25628,25590],{"class":429},[407,25630,25593],{"class":429},[407,25632,25596],{"class":429},[407,25634,1073],{"class":417},[407,25636,25601],{"class":429},[407,25638,25604],{"class":413},[407,25640,3519],{"class":417},[407,25642,418],{"class":429},[407,25644,25645],{"class":476},"1\n",[11,25647,25648],{},"预期：gwbridge 应该有 1 个空 IP 现在可用，container 会拿到它。",[11,25650,25651],{},"实际：task 又启动失败了。为什么？",[11,25653,25654,25655,25658],{},"看日志发现：刚释放的那个 IP（比如 172.30.1.100），立刻被其他 cycling 的 task 抢走了。Swarm 内部有失联的 service 在不停地尝试获取 endpoint，它们的重试机制比 ",[15,25656,25657],{},"scale 1"," 的新 task 更激进。这种负反馈导致用户 B 的容器仍然拿不到 IP。",[11,25660,25661],{},[488,25662,25663],{},"9:55 — 关键假设验证",[11,25665,25666],{},"这个失败给了一个启发：能否先把那 13 个 cycling service 全部 scale 0，让它们停止抢占 IP？",[11,25668,25669],{},"这样就有 13 个 IP 可用，足够 gwbridge 扩容时临时周转。",[11,25671,25672,25673,1244],{},"给 13 个失联 service 全部执行 ",[15,25674,25675],{},"scale 0",[399,25677,25679],{"className":11754,"code":25678,"language":11756,"meta":183,"style":183},"docker service ls --filter \"label=health=fail\" --format \"{{.Name}}\" | \\\n  xargs -I {} docker service scale {}=0\n",[15,25680,25681,25705],{"__ignoreMap":183},[407,25682,25683,25685,25687,25689,25692,25695,25698,25701,25703],{"class":409,"line":410},[407,25684,22886],{"class":554},[407,25686,25590],{"class":429},[407,25688,23549],{"class":429},[407,25690,25691],{"class":476}," --filter",[407,25693,25694],{"class":429}," \"label=health=fail\"",[407,25696,25697],{"class":476}," --format",[407,25699,25700],{"class":429}," \"{{.Name}}\"",[407,25702,3482],{"class":417},[407,25704,11781],{"class":476},[407,25706,25707,25710,25713,25716,25718,25720,25722,25725],{"class":409,"line":184},[407,25708,25709],{"class":554},"  xargs",[407,25711,25712],{"class":476}," -I",[407,25714,25715],{"class":429}," {}",[407,25717,23903],{"class":429},[407,25719,25590],{"class":429},[407,25721,25593],{"class":429},[407,25723,25724],{"class":429}," {}=",[407,25726,25611],{"class":476},[11,25728,25729],{},"都成功了。集群稳定，回到\"252 healthy + 0 failing\"（那 13 个现在是 0\u002F0）。",[11,25731,25732],{},[488,25733,25734],{},"10:10 — Staging 性能优化验证",[11,25736,25737,25738,25741],{},"刚意识到，扩容过程中，swarm 会试图 pull 新镜像。因为网络不好，pull 可能花 60+ 秒。能否通过修改 ",[15,25739,25740],{},"\u002Fetc\u002Fhosts"," 把 docker.io 黑洞掉，让 Swarm 直接 fallback 到本地镜像？",[11,25743,25744],{},"在 staging 上测试：在容器启动前加一行 hosts 黑洞：",[399,25746,25748],{"className":11754,"code":25747,"language":11756,"meta":183,"style":183},"echo \"127.0.0.1 docker.io\" >> \u002Fetc\u002Fhosts\n",[15,25749,25750],{"__ignoreMap":183},[407,25751,25752,25754,25757,25760],{"class":409,"line":410},[407,25753,14923],{"class":476},[407,25755,25756],{"class":429}," \"127.0.0.1 docker.io\"",[407,25758,25759],{"class":417}," >>",[407,25761,25762],{"class":429}," \u002Fetc\u002Fhosts\n",[11,25764,25765],{},"效果：scale 0 → scale 1 的过程从 63 秒降到 1 秒。",[11,25767,25768],{},"确认这个优化可行。会用到生产环境。",[11,25770,25771],{},[488,25772,25773],{},"10:25 — 完整备份",[11,25775,25776],{},"在真正动手删 gwbridge 前，备份所有 265 个 service 的 spec JSON（2.1MB）。以防万一 raft 数据库损坏，这是唯一的重建 service 的办法。",[11,25778,25779],{},[488,25780,25781],{},"10:42 — 真正扩容，第一次失败",[11,25783,25784],{},"开始执行删除并重建流程。先删掉旧 gwbridge，再用新网段重建：",[399,25786,25787],{"className":11754,"code":25498,"language":11756,"meta":183,"style":183},[15,25788,25789,25799,25809,25817,25825,25833],{"__ignoreMap":183},[407,25790,25791,25793,25795,25797],{"class":409,"line":410},[407,25792,22886],{"class":554},[407,25794,25267],{"class":429},[407,25796,25509],{"class":429},[407,25798,25512],{"class":429},[407,25800,25801,25803,25805,25807],{"class":409,"line":184},[407,25802,22886],{"class":554},[407,25804,25267],{"class":429},[407,25806,25521],{"class":429},[407,25808,11781],{"class":476},[407,25810,25811,25813,25815],{"class":409,"line":189},[407,25812,25528],{"class":476},[407,25814,25531],{"class":429},[407,25816,11781],{"class":476},[407,25818,25819,25821,25823],{"class":409,"line":452},[407,25820,25538],{"class":476},[407,25822,25541],{"class":429},[407,25824,11781],{"class":476},[407,25826,25827,25829,25831],{"class":409,"line":458},[407,25828,25548],{"class":476},[407,25830,25551],{"class":476},[407,25832,11781],{"class":476},[407,25834,25835],{"class":409,"line":464},[407,25836,25558],{"class":429},[11,25838,25839,25840,781],{},"第二条命令报错：",[15,25841,25842],{},"\"Error response from daemon: Pool overlaps with other one on this address space\"",[11,25844,25845,25846,25849,25850,25853],{},"原因很讽刺：在 9:15 分的探索期间，改了 daemon.json 为 ",[15,25847,25848],{},"172.31.0.0\u002F16 size 22","，然后重启了 docker。启动时，docker daemon 会重建系统默认的 bridge 网络。这次它从那个 \u002F16 池子里切了一个 \u002F22 出来——恰好是 ",[15,25851,25852],{},"172.31.0.0\u002F22","。这块子网现在被 bridge 网络占用了。",[11,25855,25856,25857,25860],{},"我想给 gwbridge 分配的 ",[15,25858,25859],{},"172.31.0.0\u002F20"," 直接包含了这个 \u002F22，两个网络的地址空间产生了重叠，所以冲突。必须换一个不冲突的网段。",[11,25862,25863],{},"临时决策：换一个网段。探测几个候选 subnet 看哪个能创建：",[399,25865,25867],{"className":11754,"code":25866,"language":11756,"meta":183,"style":183},"for SUBNET in 172.31.0.0\u002F22 10.30.0.0\u002F20 192.168.32.0\u002F20; do\n  docker network create --subnet \"$SUBNET\" _test 2>&1 | head -1\n  docker network rm _test 2>\u002Fdev\u002Fnull\ndone\n",[15,25868,25869,25894,25924,25940],{"__ignoreMap":183},[407,25870,25871,25874,25877,25880,25883,25886,25889,25891],{"class":409,"line":410},[407,25872,25873],{"class":417},"for",[407,25875,25876],{"class":413}," SUBNET ",[407,25878,25879],{"class":417},"in",[407,25881,25882],{"class":429}," 172.31.0.0\u002F22",[407,25884,25885],{"class":429}," 10.30.0.0\u002F20",[407,25887,25888],{"class":429}," 192.168.32.0\u002F20",[407,25890,1121],{"class":413},[407,25892,25893],{"class":417},"do\n",[407,25895,25896,25899,25901,25903,25906,25908,25911,25913,25916,25918,25920,25922],{"class":409,"line":184},[407,25897,25898],{"class":554},"  docker",[407,25900,25267],{"class":429},[407,25902,25521],{"class":429},[407,25904,25905],{"class":476}," --subnet",[407,25907,14908],{"class":429},[407,25909,25910],{"class":413},"$SUBNET",[407,25912,6142],{"class":429},[407,25914,25915],{"class":429}," _test",[407,25917,23070],{"class":417},[407,25919,3482],{"class":417},[407,25921,23464],{"class":554},[407,25923,23467],{"class":476},[407,25925,25926,25928,25930,25932,25934,25937],{"class":409,"line":189},[407,25927,25898],{"class":554},[407,25929,25267],{"class":429},[407,25931,25509],{"class":429},[407,25933,25915],{"class":429},[407,25935,25936],{"class":417}," 2>",[407,25938,25939],{"class":429},"\u002Fdev\u002Fnull\n",[407,25941,25942],{"class":409,"line":452},[407,25943,25944],{"class":417},"done\n",[11,25946,25947,25950],{},[15,25948,25949],{},"10.30.0.0\u002F20"," 可用。改用这个。",[11,25952,25953],{},[488,25954,25955],{},"10:45 — 恢复并重建",[11,25957,25958,25959,25961],{},"删 gwbridge，用 ",[15,25960,25949],{}," 重建：",[399,25963,25965],{"className":11754,"code":25964,"language":11756,"meta":183,"style":183},"docker network rm docker_gwbridge\ndocker network create \\\n  --driver bridge \\\n  --subnet 10.30.0.0\u002F20 \\\n  --gateway 10.30.0.1 \\\n  docker_gwbridge\n",[15,25966,25967,25977,25987,25995,26003,26012],{"__ignoreMap":183},[407,25968,25969,25971,25973,25975],{"class":409,"line":410},[407,25970,22886],{"class":554},[407,25972,25267],{"class":429},[407,25974,25509],{"class":429},[407,25976,25512],{"class":429},[407,25978,25979,25981,25983,25985],{"class":409,"line":184},[407,25980,22886],{"class":554},[407,25982,25267],{"class":429},[407,25984,25521],{"class":429},[407,25986,11781],{"class":476},[407,25988,25989,25991,25993],{"class":409,"line":189},[407,25990,25528],{"class":476},[407,25992,25531],{"class":429},[407,25994,11781],{"class":476},[407,25996,25997,25999,26001],{"class":409,"line":452},[407,25998,25538],{"class":476},[407,26000,25885],{"class":429},[407,26002,11781],{"class":476},[407,26004,26005,26007,26010],{"class":409,"line":458},[407,26006,25548],{"class":476},[407,26008,26009],{"class":476}," 10.30.0.1",[407,26011,11781],{"class":476},[407,26013,26014],{"class":409,"line":464},[407,26015,25558],{"class":429},[11,26017,26018],{},"成功。",[11,26020,26021],{},"Swarm 立刻开始自动重新调度那 13 个被 scale 0 的 service。在接下来的 1-2 分钟内，所有 270 个 service 都拿回了 IP。gwbridge 当前用量从 0 涨回 267\u002F4094。",[11,26023,26024],{},[488,26025,26026],{},"10:55 — 孤儿清理",[11,26028,26029],{},"有 1 个 service 卡住了，一直 0\u002F1。",[11,26031,26032],{},"检查发现，旧的 docker-proxy 进程还在占着 host port 18183。container 已经不存在了，但进程没清。端口被占，新容器绑定不了。",[11,26034,26035],{},"手动 kill 这个 PID，释放端口。Swarm 重试，成功。",[11,26037,26038],{},[488,26039,26040],{},"11:00 — 数据库对账",[11,26042,26043],{},"DB 里有 13 条 Instance 记录状态仍是 PROVISIONING，实际容器已经在 RUNNING。写个快速脚本，把它们更新到 RUNNING。",[11,26045,26046],{},"同时删了 1 条孤儿 Instance 记录（某用户容器在异常重启时丢了）。",[11,26048,26049],{},[488,26050,26051],{},"11:05 — 第一次 ffmpeg 灰度，失败",[11,26053,26054],{},"现在网络扩容完毕，可以开始灰度 ffmpeg 了。",[11,26056,26057,26058,781],{},"用 digest 形式指定镜像：",[15,26059,26060],{},"cpa\u002Fopenclaw-runtime:1.0.18@sha256:fcebbc...",[11,26062,26063],{},"想法是 digest 是 immutable 的，Swarm 可以直接用，不必重新 pull。",[11,26065,26066,26067,26070],{},"实际：Swarm 看到 ",[15,26068,26069],{},"@sha256:..."," 这种格式后，认为这是\"远程仓库 immutable 引用\"，强行去 docker.io pull。Pull 失败（网络不稳定），没有 fallback 到本地已有的 image（本地有 1.0.18 tag，但 digest 不同）。Task fatal error。Rollback。",[11,26072,26073],{},[488,26074,26075],{},"11:08 — 第二次灰度，成功",[11,26077,26078,26079,781],{},"改用 tag-only 形式：",[15,26080,26081],{},"cpa\u002Fopenclaw-runtime:1.0.18",[11,26083,26084],{},"Swarm 走 pull 流程，失败后自动 fallback 本地同 tag 的 image。成功。",[11,26086,26087,26088,26091],{},"容器启动，",[15,26089,26090],{},"ffmpeg -version"," 输出 5.1.9。实际剪了一个 1 秒黑屏的 mp4 验证。",[11,26093,26094],{},[488,26095,26096],{},"11:18 — v4 全量发布",[11,26098,26099],{},"网络问题解决后，准备一键发布所有用户的容器升级。提交全部 270 个 service 的 update（1.0.17 → 1.0.18）。用 xargs 并发提交：",[399,26101,26103],{"className":11754,"code":26102,"language":11756,"meta":183,"style":183},"cat service-list.txt | xargs -P 10 -I {} docker service update --image cpa\u002Fopenclaw-runtime:1.0.18 {}\n",[15,26104,26105],{"__ignoreMap":183},[407,26106,26107,26110,26113,26115,26117,26120,26122,26124,26126,26128,26130,26133,26136,26139],{"class":409,"line":410},[407,26108,26109],{"class":554},"cat",[407,26111,26112],{"class":429}," service-list.txt",[407,26114,3482],{"class":417},[407,26116,24538],{"class":554},[407,26118,26119],{"class":476}," -P",[407,26121,4195],{"class":476},[407,26123,25712],{"class":476},[407,26125,25715],{"class":429},[407,26127,23903],{"class":429},[407,26129,25590],{"class":429},[407,26131,26132],{"class":429}," update",[407,26134,26135],{"class":476}," --image",[407,26137,26138],{"class":429}," cpa\u002Fopenclaw-runtime:1.0.18",[407,26140,26141],{"class":429}," {}\n",[11,26143,26144],{},"大约 1 秒完成 270 个 update 提交。",[11,26146,26147],{},"然后脚本进入 Phase 4（收敛判断）。检查所有 service 是否都运行了新镜像：",[399,26149,26151],{"className":11754,"code":26150,"language":11756,"meta":183,"style":183},"docker service ls --format \"{{.Image}}\"\n",[15,26152,26153],{"__ignoreMap":183},[407,26154,26155,26157,26159,26161,26163],{"class":409,"line":410},[407,26156,22886],{"class":554},[407,26158,25590],{"class":429},[407,26160,23549],{"class":429},[407,26162,25697],{"class":476},[407,26164,26165],{"class":429}," \"{{.Image}}\"\n",[11,26167,26168,26169,26172,26173,26175],{},"1 秒钟后看到全部输出都变成了 ",[15,26170,26171],{},"1.0.18","，脚本误判完成，把之前加的 ",[15,26174,25740],{}," 黑洞过早还原。",[11,26177,26178,26179,26181],{},"问题来了：Swarm rolling update 是异步的。",[15,26180,25242],{}," 看到的是 spec image（声明），不是 task 实际跑的 image（事实）。Spec 在你 update 那一秒就变了，但容器真正切换需要时间——它要先 stop 旧 task，再 pull（或 fallback）新镜像，再 start 新 task。这个过程通常需要数十秒。",[11,26183,26184],{},"结果，脚本还原了 hosts 黑洞，但大批容器正在切换过程中，需要 pull 镜像。此时网络开始响应慢。30 秒的 pull 超时开始积累，导致部分容器切换失败。",[11,26186,26187],{},[488,26188,26189],{},"11:21 — 紧急救援",[11,26191,26192],{},"检测到 hosts 还原过早，立刻加回黑洞：",[399,26194,26195],{"className":11754,"code":25747,"language":11756,"meta":183,"style":183},[15,26196,26197],{"__ignoreMap":183},[407,26198,26199,26201,26203,26205],{"class":409,"line":410},[407,26200,14923],{"class":476},[407,26202,25756],{"class":429},[407,26204,25759],{"class":417},[407,26206,25762],{"class":429},[11,26208,26209,26210,26213],{},"等真实收敛。看 ",[15,26211,26212],{},"docker ps --filter \"name=svc-\" --format \"{{.Image}}\"","（task 层的实际镜像），不是 service ls 的 spec。",[11,26215,26216],{},[488,26217,26218],{},"11:25 — 真实收敛",[11,26220,26221],{},"270\u002F270 容器全部跑上 1.0.18。",[11,26223,26224],{},[488,26225,26226],{},"11:26 — 最终验证",[11,26228,26229],{},"抽 3 个用户容器实测 ffmpeg，panel\u002Fcanvas health check = 200，gwbridge 用量 270\u002F4094 (~6.6%)。",[11,26231,26232],{},"全部完成。",[26,26234,26236],{"id":26235},"根因三层问题叠加","根因：三层问题叠加",[11,26238,26239,26242],{},[488,26240,26241],{},"表面","：容器没装 ffmpeg。改 Dockerfile 就行。",[11,26244,26245,26248,26249,26251],{},[488,26246,26247],{},"深层 1 — 架构容量瓶颈","：系统设计是\"1 用户 = 1 容器\"，所以用户数上限 = 可用 IP 数。gwbridge 是 5 天前 ",[15,26250,11015],{}," 时以 docker 默认值 \u002F24 创建的，253 个 IP。现在 251 个用户 + ingress 等系统组件已占满。",[11,26253,26254,26257,26258,26261,26262,26264,26265,26267],{},[488,26255,26256],{},"深层 2 — 配置陷阱","：某人改了 ",[15,26259,26260],{},"daemon.json"," 的 ",[15,26263,11008],{},"，本想扩容。但这个配置只在 ",[15,26266,11015],{}," 那一刻被读取一次。已有网络（gwbridge）不会因为修改配置而改变。这是个文档没突出强调的时机陷阱。",[11,26269,26270,26273],{},[488,26271,26272],{},"深层 3 — 无告警机制","：基础设施容量（IP 池、CPU、内存）应该主动巡检。这里 gwbridge 沉默地跑到 100% 满，用户侧只看到\"个别容器偶发起不来\"，很难追溯到网络容量。",[26,26275,26276],{"id":26276},"修复选择与执行",[11,26278,26279],{},"为什么选\"手动删除重建 gwbridge\"而不是其他：",[243,26281,26282,26288,26294,26300],{},[126,26283,26284,26287],{},[488,26285,26286],{},"改 daemon.json 已验证无效","：现有网络不会因配置变化而改变",[126,26289,26290,26293],{},[488,26291,26292],{},"重启 docker 风险太高","：所有 270 个容器停机 10+ 分钟",[126,26295,26296,26299],{},[488,26297,26298],{},"逐个迁移","：工作量巨大，容易出错",[126,26301,26302,26305],{},[488,26303,26304],{},"删除重建","：只涉及网络对象层面，不影响容器生命周期。Swarm 自动重调度，风险可控",[11,26307,26308],{},"验证阶梯：",[243,26310,26311,26317,26323],{},[126,26312,26313,26316],{},[488,26314,26315],{},"Staging dry-run","（105 秒完成）：确认 service 自动重调度、endpoint 重新分配、容器正常启动",[126,26318,26319,26322],{},[488,26320,26321],{},"单用户 canary","（发现\"释放 IP 被抢\"的问题）：改成先 scale 0 那 13 个 cycling service，再做扩容",[126,26324,26325,26328],{},[488,26326,26327],{},"全量执行","（5 分钟）：删 gwbridge，用 10.30.0.0\u002F20 重建，自动调度完成",[11,26330,26331],{},"结果：253 → 4094 IP（16 倍扩容），13 个失联用户恢复，270 个 service 全部正常。",[26,26333,26334],{"id":26334},"影响与防回归",[11,26336,26337,1244],{},[488,26338,26339],{},"停机影响",[123,26341,26342,26345,26348,26351],{},[126,26343,26344],{},"gwbridge 重建期间 5-10 分钟全集群不可用",[126,26346,26347],{},"灰度期间部分用户约 25 分钟间歇掉线",[126,26349,26350],{},"全量发布期间大批 task 切换，约 7 分钟影响",[126,26352,26353],{},"数据零丢失",[11,26355,26356,1244],{},[488,26357,26358],{},"防回归措施",[11,26360,26361],{},"目前无已实施方案。Action Items 中有 P1 的\"监控告警：gwbridge IP 池 > 70%\"，尚未部署。",[26,26363,26364],{"id":26364},"教训",[243,26366,26367,26373,26383],{},[126,26368,26369,26372],{},[488,26370,26371],{},"基础设施容量要主动巡检，不要等爆表","。隐性容量上限通常以\"偶发故障\"的形式暴露，此时已接近 100%。应定期扫描 IP 池用量，设 70% 和 90% 的告警。",[126,26374,26375,781,26378,7322,26380,26382],{},[488,26376,26377],{},"配置生效时机必须验证，不要假设",[15,26379,11008],{},[15,26381,11015],{}," 消费一次。类似的时机陷阱在分布式系统很常见（初始化 vs 运行时 vs 重启）。每项基础设施配置要写脚本验证一次：改配置 → 观察系统反映（docker info \u002F ps \u002F 日志），不要假设。",[126,26384,26385,26388],{},[488,26386,26387],{},"验证阶梯很重要","。Staging dry-run 测试可行性且暴露意外行为（这次的\"释放 IP 被抢\"）。单用户 canary 在生产真实复现，成本低。全量发布时信心最足。",[1267,26390,26391],{},"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}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}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 .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}",{"title":183,"searchDepth":184,"depth":184,"links":26393},[26394,26395,26396,26397,26398,26399],{"id":25207,"depth":184,"text":25207},{"id":25231,"depth":184,"text":25231},{"id":26235,"depth":184,"text":26236},{"id":26276,"depth":184,"text":26276},{"id":26334,"depth":184,"text":26334},{"id":26364,"depth":184,"text":26364},"2026-05-22",{},"\u002F2026-05-22-fa-012-gwbridge",{"title":25199,"description":25204},"FA-012","2026-05-22-FA-012-gwbridge容量上限","一行 apt 改动引出的 16 倍网络扩容：docker daemon.json 的配置陷阱如何偷偷限制了业务增长。",[11245,26408,26409,26410,26411],"网络容量","基础设施","隐性容量上限","配置陷阱","VVmDdWNROPXksGoBzLZgxfDwrGQwJ3EaEOK5I1GncQE",{"id":26414,"title":26415,"body":26416,"column":196,"date":27040,"description":26420,"extension":199,"hero_image":200,"meta":27041,"navigation":202,"path":27042,"seo":27043,"series_id":200,"severity":200,"stem":27044,"summary":27045,"tags":27046,"__hash__":27052},"posts\u002F2026-05-18-SSHTS远程调试客户Windows.md","不装远控软件，从 Mac 进客户的 Windows",{"type":8,"value":26417,"toc":27029},[26418,26421,26425,26432,26438,26441,26484,26488,26496,26503,26514,26517,26521,26524,26541,26544,26558,26568,26572,26579,26585,26588,26591,26595,26598,26601,26616,26619,26634,26637,26652,26655,26666,26669,26729,26738,26742,26749,26775,26778,26785,26792,26796,26802,26835,26841,26889,26892,26898,26915,26918,26926,26929,26935,26955,26974,26981,26984,27020,27023,27026],[11,26419,26420],{},"客户机在 Windows、开发机在 Mac，现场排障需要上机。RDP 和商业远控工具都太重——要客户配合打开、有安装门槛、按并发收费。我用 SSH 隧道解决这个问题，核心思路是客户运行一个通用 BAT（自动获取连接码），通过 VPS 反向隧道连我的 Mac，然后我用 ProxyJump 直连到他的 PowerShell。这套工具支持 10 个客户并发，用一次性 ed25519 证书做身份认证，OpenSSH 缓存把首次安装时间从 30-60 秒压到 5-10 秒。",[26,26422,26424],{"id":26423},"架构vps-中继-反向隧道-proxyjump","架构：VPS 中继 + 反向隧道 + ProxyJump",[11,26426,26427,26428,26431],{},"整个系统分三端。客户 Windows 上运行 BAT，通过 SSH 建立反向隧道到 VPS（隧道用户只有端口转发权限，无 shell 访问）；VPS 负责分配连接码和证书，维护实时会话面板；我的 Mac 上配置好 SSH 别名，一条命令 ",[15,26429,26430],{},"ssh cust-\u003Ccode>"," 就能跳转到客户机。",[399,26433,26436],{"className":26434,"code":26435,"language":1327},[1325],"┌─────────┐              ┌─────────────────────────────────┐              ┌──────────────┐\n│   Mac   │              │       VPS Relay (中转站)        │              │   Customer   │\n│         │              │                                 │              │   Windows    │\n│  ssh    │──ProxyJump──►│ Web Panel  (Node 20 + Vue 3)    │◄── HTTPS ────│  Universal   │\n│cust-XXX │              │   ↓ \u002Fapi\u002Fallocate (cert)        │              │   BAT        │\n│         │              │   ↓ \u002Fapi\u002Fsessions (kill\u002Flist)   │              │              │\n│         │              │   ↓ \u002Fws (realtime push)         │              │              │\n│         │              │ sshd  reverse port pool         │              │              │\n│         │              │   12100~12199 (cert-only auth)  │◄ ssh -R 12X ─│              │\n│         │              │ nft per-port counters → traffic │              │              │\n│         │              │ SQLite (sessions+audit+samples) │              │              │\n└─────────┘              └─────────────────────────────────┘              └──────────────┘\n",[15,26437,26435],{"__ignoreMap":183},[11,26439,26440],{},"流程是这样的：",[243,26442,26443,26449,26455,26458,26464,26470,26473,26478,26481],{},[126,26444,26445,26446],{},"客户双击 ",[15,26447,26448],{},"远程调试-连接.bat",[126,26450,26451,26452],{},"BAT 生成一次性 SSH 密钥对，调用 ",[15,26453,26454],{},"POST https:\u002F\u002F中转站\u002Fapi\u002Fallocate",[126,26456,26457],{},"服务器返回三位连接码（例如 147）、对应端口（12147）、签发的证书、工程师公钥、VPS 主机公钥",[126,26459,26460,26461],{},"BAT 自动安装或启动 OpenSSH Server，把工程师公钥写入 ",[15,26462,26463],{},"authorized_keys",[126,26465,26466,26467],{},"BAT 用证书建立反向隧道：",[15,26468,26469],{},"ssh -i key -o CertificateFile=cert -R 12147:127.0.0.1:22 tunnel@中转站",[126,26471,26472],{},"BAT 显示连接码给客户（\"您的连接码: 147\"）",[126,26474,26475,26476],{},"客户告诉支持这个数字，我在 Mac 上运行 ",[15,26477,26430],{},[126,26479,26480],{},"连接实时出现在 Web 面板上（代码、IP、流量、时长）",[126,26482,26483],{},"客户关闭 BAT 窗口或我在面板点击\"关闭\"，隧道断开",[26,26485,26487],{"id":26486},"设计点一动态连接码-端口池支持-10-并发","设计点一：动态连接码 + 端口池，支持 10 并发",[11,26489,26490,26491,26495],{},"三位数连接码（100",[26492,26493,26494],"del",{},"199）是为了让客户容易记住和口头传达。服务器维护一个端口池 12100","12199，每次客户请求时随机分配，确保 10 个客户可以并发连接而不冲突。",[11,26497,26498,26499,26502],{},"请求 ",[15,26500,26501],{},"\u002Fapi\u002Fallocate"," 时，服务器：",[123,26504,26505,26508,26511],{},[126,26506,26507],{},"检查当前空闲端口",[126,26509,26510],{},"返回 code（100~199 之间随机）和对应的 port（12100+code）",[126,26512,26513],{},"这个分配关系存进 SQLite，关联客户 IP、时间戳、隧道建立状态",[11,26515,26516],{},"如果 10 个端口都满，新客户会得到 HTTP 503 错误和\"pool_full\"错误码，这是硬限制的立即拒绝。",[26,26518,26520],{"id":26519},"设计点二一次性-ed25519-证书5-分钟有效且绑定源-ip","设计点二：一次性 ed25519 证书，5 分钟有效且绑定源 IP",[11,26522,26523],{},"传统做法是给每个客户一个长期 SSH 密钥，密钥泄露风险大。这里改成 OpenSSH 证书认证：",[123,26525,26526,26529,26535,26538],{},[126,26527,26528],{},"客户 BAT 生成临时 ed25519 密钥对（仅在 BAT 运行时内存中存在）",[126,26530,26531,26532,26534],{},"客户把公钥和源 IP 发给服务器的 ",[15,26533,26501],{}," 接口",[126,26536,26537],{},"服务器签发一张证书：标明这个公钥只在这次连接有效、5 分钟过期、源 IP 绑定",[126,26539,26540],{},"BAT 用证书建立隧道",[11,26542,26543],{},"OpenSSH 证书的威胁模型是：",[123,26545,26546,26552],{},[126,26547,26548,26551],{},[488,26549,26550],{},"能保护的","：密钥长期泄露风险消除了（每次用完即弃）；隧道被劫持后，拦截者只能在 5 分钟内、同一源 IP 范围内冒充这个客户，其他时间段无法复用",[126,26553,26554,26557],{},[488,26555,26556],{},"不能保护的","：如果源 IP 被欺骗（ARP 欺骗、BGP 劫持）、或 VPS 时钟不准（偏差超过 5 分钟），证书校验可能失败或被绕过；BAT 文件本身被篡改（在传输或客户机上被修改），工程师公钥被替换，我连接时会连到恶意机器而不是客户机",[11,26559,26560,26561,26564,26565,781],{},"证书的有效期使用 OpenSSH ",[15,26562,26563],{},"ssh-keygen -V +300s"," 格式，依赖客户机和服务器的系统时钟一致。如果两端时钟相差超过几分钟，OpenSSH 会因时间戳不在有效范围内而拒绝证书。系统没有显式的时钟容差机制，NTP 同步精度需要控制在分钟级以内，IPv6 或 VPN 环境下源 IP 绑定的有效性 ",[407,26566,26567],{},"待补：取决于 VPN 方案是否保留客户真实 IP",[26,26569,26571],{"id":26570},"设计点三隧道账户仅端口转发无-shell-权限","设计点三：隧道账户仅端口转发，无 shell 权限",[11,26573,26574,26575,26578],{},"VPS 上创建一个专用账户 ",[15,26576,26577],{},"tunnel","，SSH 配置中限制它：",[399,26580,26583],{"className":26581,"code":26582,"language":1327},[1325],"# sshd_config 关键配置\nMatch User tunnel\n  AllowAgentForwarding no\n  AllowTcpForwarding yes\n  PermitListen 127.0.0.1:12100-12199\n  PermitOpen 127.0.0.1:12100-12199\n  PermitTTY no\n  AllowUsers tunnel\n  ForceCommand \u002Fbin\u002Ffalse\n",[15,26584,26582],{"__ignoreMap":183},[11,26586,26587],{},"这样即使客户的密钥（或我的长期密钥）泄露，攻击者登陆 tunnel 账户最多只能建立反向端口转映射，无法获得 shell、无法读写文件系统、无法执行命令。双重保护：一是账户本身没有 shell，二是 sshd 配置显式禁止了 TTY 和代理转发。",[11,26589,26590],{},"这里的威胁模型假设：VPS 的 sshd 配置被信任，未被篡改；客户 Windows 机器上没有被植入远控木马（BAT 文件下载、执行、隧道运行的全过程都可能被监控）。如果客户机本身被入侵，这套机制无法保护 VPS 或我的 Mac。",[26,26592,26594],{"id":26593},"设计点四openssh-预缓存5-10-秒装好而不是-30-60-秒","设计点四：OpenSSH 预缓存，5-10 秒装好而不是 30-60 秒",[11,26596,26597],{},"Windows 内置的 OpenSSH Server 需要从 Windows Update 下载，通常要 30-60 秒。方案是在 VPS 上预先缓存一份 OpenSSH-Win64.zip（~50MB），BAT 运行时优先从本地 VPS 的 HTTP 服务（18022 端口）下载，失败才降级到 Windows Update。",[11,26599,26600],{},"VPS 侧的一次性准备：",[399,26602,26604],{"className":11754,"code":26603,"language":11756,"meta":183,"style":183},"sudo bash server\u002Fprepare-openssh-cache.sh\n",[15,26605,26606],{"__ignoreMap":183},[407,26607,26608,26610,26613],{"class":409,"line":410},[407,26609,11763],{"class":554},[407,26611,26612],{"class":429}," bash",[407,26614,26615],{"class":429}," server\u002Fprepare-openssh-cache.sh\n",[11,26617,26618],{},"这个脚本：",[123,26620,26621,26624,26627],{},[126,26622,26623],{},"从 GitHub PowerShell\u002FWin32-OpenSSH releases 官方源下载 OpenSSH-Win64.zip",[126,26625,26626],{},"启动 HTTP 服务在 18022 端口，提供这个 ZIP",[126,26628,26629,26630,26633],{},"不做显式的哈希校验，而是依赖 Windows 对 ZIP 文件内签名的验证（Windows 的 ",[15,26631,26632],{},"Add-WindowsCapability"," 内置验证机制）",[11,26635,26636],{},"客户 BAT 的逻辑：",[123,26638,26639,26646,26649],{},[126,26640,26641,26642,26645],{},"尝试从 VPS ",[15,26643,26644],{},"http:\u002F\u002F中转站:18022\u002FOpenSSH-Win64.zip"," 下载（网络好时 5-10 秒）",[126,26647,26648],{},"下载失败或超时则降级到 Windows Update（30-60 秒）",[126,26650,26651],{},"无论哪种方式都自动继续，用户不需要干预",[11,26653,26654],{},"这个优化需要几个前提条件生效：",[123,26656,26657,26660,26663],{},[126,26658,26659],{},"VPS 和客户机的网络连接要稳定（下载中断会回源 Windows Update，反而更慢）",[126,26661,26662],{},"VPS 和客户机在同一地域最佳（跨域 50MB 可能需要 20-30 秒，降级意义不大）",[126,26664,26665],{},"HTTP 18022 端口要对客户机开放（如果防火墙限制只有 22\u002F443，这个缓存无法使用）",[11,26667,26668],{},"素材里给了性能表：",[1708,26670,26671,26687],{},[1711,26672,26673],{},[1714,26674,26675,26678,26681,26684],{},[1717,26676,26677],{},"场景",[1717,26679,26680],{},"时间 (v1.0)",[1717,26682,26683],{},"时间 (v1.1)",[1717,26685,26686],{},"加速",[1730,26688,26689,26703,26716],{},[1714,26690,26691,26694,26697,26700],{},[1735,26692,26693],{},"首次安装（网络好）",[1735,26695,26696],{},"30-60s",[1735,26698,26699],{},"5-10s",[1735,26701,26702],{},"3-10x",[1714,26704,26705,26708,26711,26713],{},[1735,26706,26707],{},"首次安装（网络慢）",[1735,26709,26710],{},"2-3 min",[1735,26712,26696],{},[1735,26714,26715],{},"2-5x",[1714,26717,26718,26721,26724,26726],{},[1735,26719,26720],{},"已安装",[1735,26722,26723],{},"0.5s",[1735,26725,26723],{},[1735,26727,26728],{},"相同",[11,26730,26731,26737],{},[488,26732,26733,26734],{},"测量条件 ",[407,26735,26736],{},"待补：网络\"好\"和\"慢\"的具体定义、客户端机型、测试样本数量","。现有数据支撑的结论是：新客户首次连接，这个优化能缩短 60-75% 的等待时间（从 60s 到 10s 这个最好情况）；但不能保证 100% 稳定达成（取决于网络波动）。",[26,26739,26741],{"id":26740},"web-管理面板实时掌握会话状态","Web 管理面板：实时掌握会话状态",[11,26743,26744,26745,26748],{},"VPS 上跑的 Node 20 + Vue 3 前端，可以访问 ",[15,26746,26747],{},"https:\u002F\u002F中转站","（配置 Let's Encrypt 证书）。面板功能：",[123,26750,26751,26757,26763,26769],{},[126,26752,26753,26756],{},[488,26754,26755],{},"实时会话列表","：显示当前在线客户的代码、IP、连接时长、流量统计",[126,26758,26759,26762],{},[488,26760,26761],{},"强制断开","：点击\"关闭\"按钮立即切断隧道",[126,26764,26765,26768],{},[488,26766,26767],{},"历史记录","：已结束的所有会话，记录开始\u002F结束时间、流量、断开原因（主动关闭 vs 网络中断 vs 空闲超时）",[126,26770,26771,26774],{},[488,26772,26773],{},"审计日志","：Web 面板登陆尝试、谁强制断开了谁、异常操作",[11,26776,26777],{},"数据存储在 VPS 的 SQLite 里，包括 sessions 表（会话元数据：代码、端口、客户 IP、开始\u002F结束时间、流量总计）、audit 表（操作日志）、traffic_samples 表（按时间采样的流量数据：会话 ID、采样时刻、接收字节、发送字节）。",[11,26779,26780,26781,26784],{},"流量统计通过 ",[15,26782,26783],{},"nft","（Linux 内核防火墙规则）的计数器实现，每个端口 12100~12199 对应一个计数规则，实时汇总给 Web 后端。",[11,26786,26787,26788,26791],{},"登陆认证是用户名 + 密码（在 ",[15,26789,26790],{},"install-panel.sh"," 时设置）。",[26,26793,26795],{"id":26794},"部署4-个步骤完成全套安装","部署：4 个步骤完成全套安装",[11,26797,26798,26801],{},[488,26799,26800],{},"第一步","：生成隧道密钥（Mac 侧）",[399,26803,26805],{"className":11754,"code":26804,"language":11756,"meta":183,"style":183},"ssh-keygen -t ed25519 -f keys\u002Fcustomer_tunnel_key -N \"\" -C \"customer-tunnel\"\n",[15,26806,26807],{"__ignoreMap":183},[407,26808,26809,26812,26815,26818,26820,26823,26826,26829,26832],{"class":409,"line":410},[407,26810,26811],{"class":554},"ssh-keygen",[407,26813,26814],{"class":476}," -t",[407,26816,26817],{"class":429}," ed25519",[407,26819,19613],{"class":476},[407,26821,26822],{"class":429}," keys\u002Fcustomer_tunnel_key",[407,26824,26825],{"class":476}," -N",[407,26827,26828],{"class":429}," \"\"",[407,26830,26831],{"class":476}," -C",[407,26833,26834],{"class":429}," \"customer-tunnel\"\n",[11,26836,26837,26840],{},[488,26838,26839],{},"第二步","：配置 VPS 隧道和 sshd",[399,26842,26844],{"className":11754,"code":26843,"language":11756,"meta":183,"style":183},"ssh root@中转站\ngit clone \u003C仓库地址>\ncd sshts\nsudo bash server\u002Fsetup-vps.sh keys\u002Fcustomer_tunnel_key.pub\n",[15,26845,26846,26853,26870,26877],{"__ignoreMap":183},[407,26847,26848,26850],{"class":409,"line":410},[407,26849,19819],{"class":554},[407,26851,26852],{"class":429}," root@中转站\n",[407,26854,26855,26857,26860,26862,26865,26868],{"class":409,"line":184},[407,26856,19546],{"class":554},[407,26858,26859],{"class":429}," clone",[407,26861,19589],{"class":417},[407,26863,26864],{"class":429},"仓库地",[407,26866,26867],{"class":413},"址",[407,26869,13679],{"class":417},[407,26871,26872,26874],{"class":409,"line":189},[407,26873,19538],{"class":476},[407,26875,26876],{"class":429}," sshts\n",[407,26878,26879,26881,26883,26886],{"class":409,"line":452},[407,26880,11763],{"class":554},[407,26882,26612],{"class":429},[407,26884,26885],{"class":429}," server\u002Fsetup-vps.sh",[407,26887,26888],{"class":429}," keys\u002Fcustomer_tunnel_key.pub\n",[11,26890,26891],{},"这会创建 tunnel 账户、配置 sshd、启用反向隧道、写入 authorized_keys。",[11,26893,26894,26897],{},[488,26895,26896],{},"第三步","：安装 Web 面板（v1.2+）",[399,26899,26901],{"className":11754,"code":26900,"language":11756,"meta":183,"style":183},"sudo DOMAIN=中转站 bash server\u002Finstall-panel.sh\n",[15,26902,26903],{"__ignoreMap":183},[407,26904,26905,26907,26910,26912],{"class":409,"line":410},[407,26906,11763],{"class":554},[407,26908,26909],{"class":429}," DOMAIN=中转站",[407,26911,26612],{"class":429},[407,26913,26914],{"class":429}," server\u002Finstall-panel.sh\n",[11,26916,26917],{},"脚本会提示输入：",[123,26919,26920,26923],{},[126,26921,26922],{},"工程师 SSH 公钥（会被嵌入所有 BAT）",[126,26924,26925],{},"Web 面板管理员用户名和密码",[11,26927,26928],{},"然后自动安装 Node 20、编译 Vue 3 前端、配置 systemd 服务、Nginx + Let's Encrypt。",[11,26930,26931,26934],{},[488,26932,26933],{},"第四步","：配置 Mac 的 SSH 别名（每个工程师一次）",[399,26936,26938],{"className":11754,"code":26937,"language":11756,"meta":183,"style":183},"WIN_USER=Administrator bash mac\u002Finstall-ssh-config.sh\n",[15,26939,26940],{"__ignoreMap":183},[407,26941,26942,26945,26947,26950,26952],{"class":409,"line":410},[407,26943,26944],{"class":413},"WIN_USER",[407,26946,418],{"class":417},[407,26948,26949],{"class":429},"Administrator",[407,26951,26612],{"class":554},[407,26953,26954],{"class":429}," mac\u002Finstall-ssh-config.sh\n",[11,26956,26957,26958,26961,26962,26965,26966,26969,26970,26973],{},"这会在 ",[15,26959,26960],{},"~\u002F.ssh\u002Fconfig"," 里添加 100 条记录：",[15,26963,26964],{},"cust-100"," 到 ",[15,26967,26968],{},"cust-199","，每个都用 ",[15,26971,26972],{},"ProxyJump=中转站","，自动登陆指定的 Windows 管理员。",[11,26975,26976,26977,26980],{},"之后，把 ",[15,26978,26979],{},"client\u002F远程调试-连接.bat"," 发给客户（通用的，不需要填任何参数），客户一运行就能连接。",[26,26982,26983],{"id":26983},"限制和取舍",[123,26985,26986,26996,27002,27008,27014],{},[126,26987,26988,26991,26992,26995],{},[488,26989,26990],{},"支持 10 并发","：这是硬限制，对应 VPS 单机性能（",[407,26993,26994],{},"待补：性能上限如何测试、是否会因为某个客户的高流量拖累其他客户",")。",[126,26997,26998,27001],{},[488,26999,27000],{},"仅支持 Windows 10 1809+ 或 Windows 11","：早期 Windows 版本的 OpenSSH 行为不兼容，素材里明确要求 Administrator 账户。",[126,27003,27004,27007],{},[488,27005,27006],{},"需要 Administrator 权限","：BAT 要写 SSH 密钥到系统目录、启动 sshd 服务，非 Admin 用户无法运行。BAT 会自动检测管理员账户，优先当前用户，降级到 Administrator（需要 UAC 弹窗确认）。",[126,27009,27010,27013],{},[488,27011,27012],{},"证书有效期 5 分钟","：这是为了限制泄露风险，代价是客户机上的系统时钟如果跟服务器相差超过几分钟，连接会失败。系统依赖两端时钟同步，无显式容差机制。",[126,27015,27016,27019],{},[488,27017,27018],{},"Web 面板仅支持单一 admin 账户","：系统只有一个管理员用户，通过用户名+密码认证。所有认证用户都拥有完整权限（查看所有会话、断开任何连接、查看审计日志），不支持按角色分配权限或只读账户。",[26,27021,27022],{"id":27022},"关键点",[11,27024,27025],{},"这套工具解决的是客户排障的第一步——快速、轻量地建立远程访问，而不需要客户安装 TeamViewer、配置 RDP、开放额外端口。支持 10 并发、一次性证书，OpenSSH 缓存把安装时间从 30-60 秒压到 5-10 秒，意味着哪怕客户现场网络不稳定、客户 IT 管理严格，也能以最小摩擦快速上机取证。没有这套工具，那些现场的故障根本无法追踪——光是等待客户开 RDP 就要 10 分钟，OpenSSH 缓存的优化才让 5-10 秒的快速连接成为现实。",[1267,27027,27028],{},"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}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 .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}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}",{"title":183,"searchDepth":184,"depth":184,"links":27030},[27031,27032,27033,27034,27035,27036,27037,27038,27039],{"id":26423,"depth":184,"text":26424},{"id":26486,"depth":184,"text":26487},{"id":26519,"depth":184,"text":26520},{"id":26570,"depth":184,"text":26571},{"id":26593,"depth":184,"text":26594},{"id":26740,"depth":184,"text":26741},{"id":26794,"depth":184,"text":26795},{"id":26983,"depth":184,"text":26983},{"id":27022,"depth":184,"text":27022},"2026-05-18",{},"\u002F2026-05-18-sshtswindows",{"title":26415,"description":26420},"2026-05-18-SSHTS远程调试客户Windows","通过 SSH 隧道 + VPS 中继接客户 Windows，支持 10 并发、一次性证书、5-10 秒快速连接。",[27047,27048,27049,27050,27051],"SSH隧道","reverse-tunnel","ProxyJump","ed25519证书","远程调试","ywgG6vps7Ox2LEtWq36_6F-8ee3rIF7WT2dtbCJjpQE",{"id":27054,"title":27055,"body":27056,"column":355,"date":27621,"description":27060,"extension":199,"hero_image":200,"meta":27622,"navigation":202,"path":27623,"seo":27624,"series_id":27625,"severity":200,"stem":27626,"summary":27627,"tags":27628,"__hash__":27633},"posts\u002F2026-05-16-FA-011-隐藏控制台64KB缓冲.md","端口在听。健康检查超时。日志零字节。",{"type":8,"value":27057,"toc":27601},[27058,27061,27064,27067,27069,27072,27104,27107,27110,27112,27115,27118,27135,27145,27160,27163,27166,27169,27184,27187,27190,27199,27202,27211,27214,27217,27220,27235,27238,27242,27245,27248,27259,27270,27276,27306,27319,27322,27329,27343,27354,27357,27363,27378,27385,27408,27411,27418,27421,27471,27478,27482,27497,27500,27511,27513,27516,27538,27541,27557,27560,27570,27582,27585,27588,27599],[11,27059,27060],{},"三个事实同时成立。端口在听。健康检查超时。日志零字节。",[11,27062,27063],{},"一个进程不会同时\"运行中\"和\"死锁\"，但这个 gateway 就是。21 个线程在等待，1 个在运行，却一个字都没写到日志里，对 HTTP 请求也一点不理。内核看得到绑定的 TCP 端口，应用层就是没法响应。",[11,27065,27066],{},"这个故障的反直觉之处在于，这三件事加在一起，指向一个完全不同的方向。不是网络、不是密钥、不是配置。是一个被隐藏的 Windows 控制台窗口，缓冲区满了。",[26,27068,25207],{"id":25207},[11,27070,27071],{},"客户报告无法收到模型回复。SSH 进入客户机后观察到：",[123,27073,27074,27077,27083,27090,27093],{},[126,27075,27076],{},"node gateway (PID 7928) 已运行 12 分钟以上，监听端口 18789。",[126,27078,27079,27080,27082],{},"直接探测网关的 ",[15,27081,18593],{}," 端点，请求在 5 秒超时前没有任何响应。",[126,27084,27085,27086,27089],{},"查看日志文件 ",[15,27087,27088],{},"data\\openclaw\\logs\\gateway.log","：大小 0 字节，自启动起一行内容都没写。",[126,27091,27092],{},"进程线程状态：CPU 时间继续涨（825 秒），21 个线程里 1 个 Running，20 个在 Wait。",[126,27094,27095,27096,27099,27100,27103],{},"上游 provider 服务测试通过：直连 ",[15,27097,27098],{},"https:\u002F\u002F\u003C中转站>\u002Fv1\u002Fmodels"," 返回 200，API key 有效（有效期至 2026-08-08）；",[15,27101,27102],{},"\u002Fv1\u002Fchat\u002Fcompletions"," 直调成功。",[11,27105,27106],{},"最诡异的地方是，guardian 守护进程的日志显示：「检测到 Gateway 进程 (PID 7928)：端口 18789 已监听但 \u002Fhealth 暂未就绪，避免误杀并采纳该进程」。换句话说，操作系统确实看到了监听的端口，但 gateway 内部完全没有响应。",[11,27108,27109],{},"重启后问题立即复现。同一份新 cache 中每次启动都 100% 触发这个故障。",[26,27111,25231],{"id":25231},[31,27113,27114],{"id":27114},"排除上游和配置问题",[11,27116,27117],{},"第一步检查的是上游连接性。使用 PowerShell 直接测试上游服务：",[399,27119,27123],{"className":27120,"code":27121,"language":27122,"meta":183,"style":183},"language-powershell shiki shiki-themes github-light github-dark","Invoke-WebRequest https:\u002F\u002F\u003C中转站>\u002Fv1\u002Fmodels `\n  -Headers @{Authorization='Bearer \u003CAPI key>'}\n","powershell",[15,27124,27125,27130],{"__ignoreMap":183},[407,27126,27127],{"class":409,"line":410},[407,27128,27129],{},"Invoke-WebRequest https:\u002F\u002F\u003C中转站>\u002Fv1\u002Fmodels `\n",[407,27131,27132],{"class":409,"line":184},[407,27133,27134],{},"  -Headers @{Authorization='Bearer \u003CAPI key>'}\n",[11,27136,27137,27138,27141,27142,27144],{},"响应正常，模型列表包含预期的 ",[15,27139,27140],{},"gpt-5.5","。再测 ",[15,27143,27102],{}," 端点：",[399,27146,27148],{"className":27120,"code":27147,"language":27122,"meta":183,"style":183},"Invoke-WebRequest https:\u002F\u002F\u003C中转站>\u002Fv1\u002Fchat\u002Fcompletions -Method POST `\n  -Body '{\"model\":\"gpt-5.5\",\"input\":\"ping\"}' -ContentType 'application\u002Fjson'\n",[15,27149,27150,27155],{"__ignoreMap":183},[407,27151,27152],{"class":409,"line":410},[407,27153,27154],{},"Invoke-WebRequest https:\u002F\u002F\u003C中转站>\u002Fv1\u002Fchat\u002Fcompletions -Method POST `\n",[407,27156,27157],{"class":409,"line":184},[407,27158,27159],{},"  -Body '{\"model\":\"gpt-5.5\",\"input\":\"ping\"}' -ContentType 'application\u002Fjson'\n",[11,27161,27162],{},"返回 200，响应体 \"pong\"。这说明网络、DNS、API key 全部正常。",[31,27164,27165],{"id":27165},"检查网关本身的启动状态",[11,27167,27168],{},"查看进程和线程状态：",[399,27170,27172],{"className":27120,"code":27171,"language":27122,"meta":183,"style":183},"Get-Process -Id 7928 | Format-List CPU,WS,Threads,StartTime\n(Get-Process -Id 7928).Threads | Group-Object ThreadState | ft Count,Name\n",[15,27173,27174,27179],{"__ignoreMap":183},[407,27175,27176],{"class":409,"line":410},[407,27177,27178],{},"Get-Process -Id 7928 | Format-List CPU,WS,Threads,StartTime\n",[407,27180,27181],{"class":409,"line":184},[407,27182,27183],{},"(Get-Process -Id 7928).Threads | Group-Object ThreadState | ft Count,Name\n",[11,27185,27186],{},"输出显示：1 个线程状态为 Running，20 个为 Wait（在等什么？）。CPU 时间还在累积（825 秒），说明进程没有真的挂死，但主线程肯定陷入了某种阻塞。",[11,27188,27189],{},"验证端口监听：",[399,27191,27193],{"className":27120,"code":27192,"language":27122,"meta":183,"style":183},"Get-NetTCPConnection -LocalPort 18789 -State Listen\n",[15,27194,27195],{"__ignoreMap":183},[407,27196,27197],{"class":409,"line":410},[407,27198,27192],{},[11,27200,27201],{},"确认 owner process ID 是 7928，TCP 三次握手成功，连接能建立。但尝试 HTTP 请求：",[399,27203,27205],{"className":27120,"code":27204,"language":27122,"meta":183,"style":183},"Invoke-WebRequest -UseBasicParsing http:\u002F\u002F127.0.0.1:18789\u002Fhealth -TimeoutSec 5\n",[15,27206,27207],{"__ignoreMap":183},[407,27208,27209],{"class":409,"line":410},[407,27210,27204],{},[11,27212,27213],{},"没有任何响应，直到 5 秒超时。",[31,27215,27216],{"id":27216},"查看日志确认启动没完成",[11,27218,27219],{},"检查日志文件大小和修改时间：",[399,27221,27223],{"className":27120,"code":27222,"language":27122,"meta":183,"style":183},"Get-Item \"$env:LOCALAPPDATA\\Microsoft\\WakouAI\\Runtime\\\u003Ccache>\\data\\openclaw\\logs\\gateway.log\" |\n  Select-Object Length,LastWriteTime\n",[15,27224,27225,27230],{"__ignoreMap":183},[407,27226,27227],{"class":409,"line":410},[407,27228,27229],{},"Get-Item \"$env:LOCALAPPDATA\\Microsoft\\WakouAI\\Runtime\\\u003Ccache>\\data\\openclaw\\logs\\gateway.log\" |\n",[407,27231,27232],{"class":409,"line":184},[407,27233,27234],{},"  Select-Object Length,LastWriteTime\n",[11,27236,27237],{},"Length: 0 bytes。这意味着 file logger 从未接管控制台输出。正常启动的 gateway.log 开头应该是一系列启动消息：\"Hidden-start Gateway on Windows\"、\"loading configuration\"、\"force: no listeners on port 18789\"、\"resolving authentication\" 等。这里什么都没有。",[31,27239,27241],{"id":27240},"排查旧版本-cache","排查旧版本 cache",[11,27243,27244],{},"同一客户的旧 cache（OTA 之前 prewarm 出来的那份）能正常启动。查看那次启动的日志时间戳是 20:18，启动后网关响应正常。这说明问题不在配置或上游，而在新版本的某个变更。",[31,27246,27247],{"id":27247},"对比启动脚本的两个版本",[11,27249,27250,27251,27254,27255,27258],{},"比对新旧 ",[15,27252,27253],{},"start-local-cache.ps1"," 的差异。旧版本使用的是 Rust launcher 里的 ",[15,27256,27257],{},"stage4_spawn"," fallback，显式设置：",[399,27260,27264],{"className":27261,"code":27262,"language":27263,"meta":183,"style":183},"language-rust shiki shiki-themes github-light github-dark","command.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());\n","rust",[15,27265,27266],{"__ignoreMap":183},[407,27267,27268],{"class":409,"line":410},[407,27269,27262],{},[11,27271,27272,27273,1244],{},"新版本（v0.14.0）改用 PowerShell 脚本启动 gateway，对应代码在 ",[15,27274,27275],{},"start-local-cache.ps1:1194",[399,27277,27279],{"className":27120,"code":27278,"language":27122,"meta":183,"style":183},"$gatewayStarter = Start-Process -FilePath \"cmd.exe\" `\n    -ArgumentList @(\"\u002Fd\", \"\u002Fc\", \"`\"$gatewayStart`\" gateway start\") `\n    -WorkingDirectory $cacheRoot `\n    -WindowStyle Hidden `\n    -PassThru\n",[15,27280,27281,27286,27291,27296,27301],{"__ignoreMap":183},[407,27282,27283],{"class":409,"line":410},[407,27284,27285],{},"$gatewayStarter = Start-Process -FilePath \"cmd.exe\" `\n",[407,27287,27288],{"class":409,"line":184},[407,27289,27290],{},"    -ArgumentList @(\"\u002Fd\", \"\u002Fc\", \"`\"$gatewayStart`\" gateway start\") `\n",[407,27292,27293],{"class":409,"line":189},[407,27294,27295],{},"    -WorkingDirectory $cacheRoot `\n",[407,27297,27298],{"class":409,"line":452},[407,27299,27300],{},"    -WindowStyle Hidden `\n",[407,27302,27303],{"class":409,"line":458},[407,27304,27305],{},"    -PassThru\n",[11,27307,27308,27309,16323,27312,13067,27315,27318],{},"注意这里",[488,27310,27311],{},"没有",[15,27313,27314],{},"-RedirectStandardOutput",[15,27316,27317],{},"-RedirectStandardError"," 参数。",[26,27320,27321],{"id":27321},"根因",[11,27323,27324,27325,27328],{},"Windows 下 PowerShell 的 ",[15,27326,27327],{},"Start-Process -WindowStyle Hidden -PassThru"," 在省略重定向参数时的行为是这样的：",[243,27330,27331,27334,27337],{},[126,27332,27333],{},"为子进程单独创建一个新的 conhost（控制台宿主）窗口，窗口隐藏。",[126,27335,27336],{},"子进程的 stdout 和 stderr 自动绑定到这个隐藏的 conhost。",[126,27338,27339,27342],{},[488,27340,27341],{},"关键","：没有任何用户态进程去消费这个 conhost 上的输出。窗口隐藏，父 PowerShell 进程也不接管它的句柄。",[11,27344,27345,27346,27349,27350,27353],{},"conhost 的屏幕缓冲（screen buffer）有一个硬限制，约 64 KB。当输出累积满了这个缓冲后，下一次 ",[15,27347,27348],{},"WriteFile"," 系统调用会",[488,27351,27352],{},"同步阻塞","调用线程，直到缓冲被消费——但这永远不会发生。",[11,27355,27356],{},"OpenClaw node gateway 在 file logger 接管之前会同步输出多条启动消息。这些消息包括：",[399,27358,27361],{"className":27359,"code":27360,"language":1327},[1325],"Hidden-start Gateway on Windows\nloading configuration\nforce: no listeners on port 18789\nresolving authentication\nstarting\nstarting HTTP server\ncanvas mounted\nMCP listening\nready\n",[15,27362,27360],{"__ignoreMap":183},[11,27364,27365,27366,27369,27370,27373,27374,27377],{},"这一坨消息加起来超过 64 KB。当 JavaScript 主线程执行第 N 条 ",[15,27367,27368],{},"console.log()"," 调用时，底层的 ",[15,27371,27372],{},"WriteFile()"," 调用卡住了，线程永不返回。主线程死锁，file logger 永远没有机会初始化和接管输出。因此 ",[15,27375,27376],{},"gateway.log"," 永远是 0 字节。",[11,27379,27380,27381,27384],{},"但是 TCP 的 ",[15,27382,27383],{},"bind()"," 是在内核态完成的，不依赖 JavaScript 主线程的运行。所以端口确实被成功监听了——只是后面没人能处理 HTTP 请求。三个矛盾现象由同一个阻塞点产生：",[123,27386,27387,27396,27402],{},[126,27388,27389,27392,27393,27395],{},[488,27390,27391],{},"端口监听成功"," ← 内核 ",[15,27394,27383],{}," 早于主线程阻塞",[126,27397,27398,27401],{},[488,27399,27400],{},"HTTP 请求超时"," ← 主线程卡死，无法处理请求",[126,27403,27404,27407],{},[488,27405,27406],{},"gateway.log 为 0 字节"," ← file logger 没来得及初始化",[26,27409,27410],{"id":27410},"修复",[11,27412,27413,27414,27417],{},"修复方案已在 v0.14.1 落地，改动位置 ",[15,27415,27416],{},"start-local-cache.ps1:1192-1212","。思路是把 stdio 重定向到一个日志文件，而不是让它悬挂在隐藏的 conhost 上。",[11,27419,27420],{},"新的代码：",[399,27422,27424],{"className":27120,"code":27423,"language":27122,"meta":183,"style":183},"$gatewayLogDir = Join-Path $env:OPENCLAW_HOME \"logs\"\nif (-not (Test-Path -LiteralPath $gatewayLogDir)) {\n    New-Item -ItemType Directory -Force -Path $gatewayLogDir | Out-Null\n}\n$gatewayStdioLog = Join-Path $gatewayLogDir \"gateway-stdio.log\"\n$gatewayStarter = Start-Process -FilePath \"cmd.exe\" `\n    -ArgumentList @(\"\u002Fd\", \"\u002Fc\", \"`\"$gatewayStart`\" gateway start > `\"$gatewayStdioLog`\" 2>&1\") `\n    -WorkingDirectory $cacheRoot `\n    -WindowStyle Hidden `\n    -PassThru\n",[15,27425,27426,27431,27436,27441,27445,27450,27454,27459,27463,27467],{"__ignoreMap":183},[407,27427,27428],{"class":409,"line":410},[407,27429,27430],{},"$gatewayLogDir = Join-Path $env:OPENCLAW_HOME \"logs\"\n",[407,27432,27433],{"class":409,"line":184},[407,27434,27435],{},"if (-not (Test-Path -LiteralPath $gatewayLogDir)) {\n",[407,27437,27438],{"class":409,"line":189},[407,27439,27440],{},"    New-Item -ItemType Directory -Force -Path $gatewayLogDir | Out-Null\n",[407,27442,27443],{"class":409,"line":452},[407,27444,483],{},[407,27446,27447],{"class":409,"line":458},[407,27448,27449],{},"$gatewayStdioLog = Join-Path $gatewayLogDir \"gateway-stdio.log\"\n",[407,27451,27452],{"class":409,"line":464},[407,27453,27285],{},[407,27455,27456],{"class":409,"line":470},[407,27457,27458],{},"    -ArgumentList @(\"\u002Fd\", \"\u002Fc\", \"`\"$gatewayStart`\" gateway start > `\"$gatewayStdioLog`\" 2>&1\") `\n",[407,27460,27461],{"class":409,"line":480},[407,27462,27295],{},[407,27464,27465],{"class":409,"line":1477},[407,27466,27300],{},[407,27468,27469],{"class":409,"line":1483},[407,27470,27305],{},[11,27472,27473,27474,27477],{},"关键是在 cmd 的命令行里加上 ",[15,27475,27476],{},"> \"$gatewayStdioLog\" 2>&1","，这会在 cmd 进程内部把所有输出（stdout 和 stderr）重定向到文件。缓冲不再绑到 conhost，写操作也变成非阻塞的文件写入。",[31,27479,27481],{"id":27480},"为什么不用-powershell-的-redirectstandardoutput","为什么不用 PowerShell 的 -RedirectStandardOutput",[11,27483,27484,27485,88,27488,27490,27491,88,27493,27496],{},"PowerShell 5.1 在 ",[15,27486,27487],{},"-WindowStyle Hidden",[15,27489,27314],{}," 同时使用时存在多个已知的边界 bug，表现因 Windows 版本和 PowerShell 版本而异，不可靠。cmd 的 ",[15,27492,3519],{},[15,27494,27495],{},"2>&1"," 是从 DOS 时代就有的内核级文件描述符复制操作，跨所有 Windows 版本绝对稳定。",[31,27498,27499],{"id":27499},"为什么输出到文件而不是丢弃",[11,27501,27502,27503,27506,27507,27510],{},"如果用 ",[15,27504,27505],{},">nul 2>&1"," 丢弃所有输出，启动故障时就看不到任何诊断信息。保留到 ",[15,27508,27509],{},"gateway-stdio.log"," 的好处是下次排障或复现类似问题时，可以直接查看启动阶段的原始输出，加快诊断。特别是在 file logger 接管前的那一段初始化过程。",[26,27512,23827],{"id":23827},[31,27514,27515],{"id":27515},"构建时检查",[11,27517,18621,27518,27521,27522,27524,27525,27528,27529,16323,27531,13067,27534,27537],{},[15,27519,27520],{},"scripts\u002Fcheck-portable-s0.mjs"," 中新增一段正则检查，扫描 ",[15,27523,27253],{}," 里所有形如 ",[15,27526,27527],{},"gateway start"," 的启动命令。如果找到这样的启动而",[488,27530,27311],{},[15,27532,27533],{},"> ... 2>&1",[15,27535,27536],{},"-RedirectStandardOutput\u002F-RedirectStandardError","，构建直接失败并打印错误信息指向本文档。",[11,27539,27540],{},"验证方式：",[123,27542,27543,27550],{},[126,27544,27545,27546,27549],{},"把启动行改回原来的无重定向写法 → ",[15,27547,27548],{},"node scripts\u002Fcheck-portable-s0.mjs"," 返回非零退出码，错误信息准确指向问题行。",[126,27551,27552,27553,27556],{},"改成带 ",[15,27554,27555],{},"> file 2>&1"," → 检查通过，退出码 0。",[31,27558,27559],{"id":27559},"同类隐患排查",[11,27561,27562,27563,27565,27566,27569],{},"在同一文件 ",[15,27564,27253],{}," 里还有另外两个 ",[15,27567,27568],{},"Start-Process"," 调用：",[123,27571,27572,27575],{},[126,27573,27574],{},"第 185 行：启动 guardian PowerShell 脚本。Guardian 有自己的 file logger，stdout 输出很少，64 KB 缓冲在常规 OTA 周期内不会满。当前保留原样。",[126,27576,27577,27578,27581],{},"第 1231 行：启动 ",[15,27579,27580],{},"WakouPanel.exe","（Tauri GUI 进程）。GUI 进程几乎不写 stdout，同样不会触发缓冲满。当前保留原样。",[11,27583,27584],{},"如果未来观察到 guardian 或 panel 出现类似的「端口监听但不响应」型死锁，按同样的套路加上重定向即可。",[31,27586,27587],{"id":27587},"已知关联但独立的问题",[11,27589,27590,27591,27594,27595,27598],{},"同一客户在 OTA 后还报告了无法收到模型回复的问题。那个故障的根因在 WebSocket 握手阶段，与 stdio 缓冲无关——具体是 ",[15,27592,27593],{},"patch-openclaw.ps1"," 里的 pattern matching 因为上游 OpenClaw 内部形状变化而失败。详见 ",[15,27596,27597],{},"docs\u002Fbug\u002F无模型回复-WS握手.md","，两份文档单独保留，不是同一个根因。",[1267,27600,16447],{},{"title":183,"searchDepth":184,"depth":184,"links":27602},[27603,27604,27611,27612,27616],{"id":25207,"depth":184,"text":25207},{"id":25231,"depth":184,"text":25231,"children":27605},[27606,27607,27608,27609,27610],{"id":27114,"depth":189,"text":27114},{"id":27165,"depth":189,"text":27165},{"id":27216,"depth":189,"text":27216},{"id":27240,"depth":189,"text":27241},{"id":27247,"depth":189,"text":27247},{"id":27321,"depth":184,"text":27321},{"id":27410,"depth":184,"text":27410,"children":27613},[27614,27615],{"id":27480,"depth":189,"text":27481},{"id":27499,"depth":189,"text":27499},{"id":23827,"depth":184,"text":23827,"children":27617},[27618,27619,27620],{"id":27515,"depth":189,"text":27515},{"id":27559,"depth":189,"text":27559},{"id":27587,"depth":189,"text":27587},"2026-05-16",{},"\u002F2026-05-16-fa-011-64kb",{"title":27055,"description":27060},"FA-011","2026-05-16-FA-011-隐藏控制台64KB缓冲","隐藏的 conhost 控制台缓冲满后阻塞 JS 主线程，导致网关端口监听但永不响应。",[12681,27629,27630,27631,27632],"stdio缓冲","PowerShell","启动脚本","进程阻塞","ur8B1zVCS9FTcFkuj2pPDEqTNiXUMixqv2BxrDTSEoY",{"id":27635,"title":27636,"body":27637,"column":355,"date":28274,"description":27641,"extension":199,"hero_image":200,"meta":28275,"navigation":202,"path":28276,"seo":28277,"series_id":28278,"severity":200,"stem":28279,"summary":28280,"tags":28281,"__hash__":28286},"posts\u002F2026-05-14-FA-010-robocopy-MIR删掉激活token.md","三个组件都没做错，token 还是被删了",{"type":8,"value":27638,"toc":28255},[27639,27642,27645,27647,27650,27673,27676,27679,27682,27691,27694,27697,27706,27709,27712,27715,27718,27723,27726,27792,27795,27800,27811,27816,27819,27822,27825,27850,27853,27860,27901,27911,27914,27917,27921,27927,27993,28000,28004,28018,28025,28030,28039,28047,28061,28070,28074,28077,28083,28094,28096,28100,28106,28109,28158,28161,28175,28178,28184,28187,28237,28252],[11,27640,27641],{},"00:19:34 激活成功，token 写到 USB。00:26:23 guardian 同步跑了一次。00:26:44 用户重启面板，激活页又出现了。",[11,27643,27644],{},"这不是网络问题、不是激活逻辑问题，也不是 token 文件损坏。这是一个诡异的时序陷阱：激活流程写的位置和同步删除的方向完全相反，导致刚写入的 token 在 30 秒内被镜像同步无声地删掉。",[26,27646,25207],{"id":25207},[11,27648,27649],{},"实际用户操作序列（某客户机，2026-05-09）：",[123,27651,27652,27655,27658,27661,27664,27667,27670],{},[126,27653,27654],{},"00:19:34　激活流程成功，用户看到\"激活成功\"提示",[126,27656,27657],{},"　　　　　进入仪表盘，但侧边栏无内容（无模型、无项目列表）",[126,27659,27660],{},"用户关闭面板，准备重启",[126,27662,27663],{},"00:26:23　重启后打开面板",[126,27665,27666],{},"00:26:44　面板启动，检测激活状态，跳转到激活页",[126,27668,27669],{},"用户再次输入激活码",[126,27671,27672],{},"00:26:54　又一次激活成功提示，循环开始",[11,27674,27675],{},"这 7 分钟内发生了什么。",[11,27677,27678],{},"诊断命令验证出现了矛盾的两个事实：",[11,27680,27681],{},"USB 上的 token 确实存在，每次激活都更新修改时间：",[399,27683,27685],{"className":27120,"code":27684,"language":27122,"meta":183,"style":183},"ls E:\\wakou-portable\\data\\auth\\token.json\n",[15,27686,27687],{"__ignoreMap":183},[407,27688,27689],{"class":409,"line":410},[407,27690,27684],{},[11,27692,27693],{},"结果：312 字节，时间戳不断更新。",[11,27695,27696],{},"但 cache 位置始终空着：",[399,27698,27700],{"className":27120,"code":27699,"language":27122,"meta":183,"style":183},"ls C:\\Users\\*\\AppData\\Local\\*\\Runtime\\*\\data\\auth\\token.json\n",[15,27701,27702],{"__ignoreMap":183},[407,27703,27704],{"class":409,"line":410},[407,27705,27699],{},[11,27707,27708],{},"结果：MISSING。",[11,27710,27711],{},"这是关键矛盾：USB 的文件明明在、明明被激活流程反复写入，panel 怎么还看不到激活状态。",[26,27713,27714],{"id":27714},"排查",[31,27716,27717],{"id":27717},"排除三个假设",[11,27719,27720],{},[488,27721,27722],{},"假设 1：token 文件格式损坏或部分丢失",[11,27724,27725],{},"检查 token.json 内容（脱敏）：",[399,27727,27729],{"className":3591,"code":27728,"language":3593,"meta":183,"style":183},"{\n  \"version\": 1,\n  \"token\": \"[REDACTED_TOKEN]\",\n  \"fingerprint\": \"c0badff089...\",\n  \"expires_at\": \"2026-08-12T...\",\n  ...\n}\n",[15,27730,27731,27735,27746,27758,27770,27782,27788],{"__ignoreMap":183},[407,27732,27733],{"class":409,"line":410},[407,27734,421],{"class":413},[407,27736,27737,27740,27742,27744],{"class":409,"line":184},[407,27738,27739],{"class":476},"  \"version\"",[407,27741,3607],{"class":413},[407,27743,659],{"class":476},[407,27745,3296],{"class":413},[407,27747,27748,27751,27753,27756],{"class":409,"line":189},[407,27749,27750],{"class":476},"  \"token\"",[407,27752,3607],{"class":413},[407,27754,27755],{"class":429},"\"[REDACTED_TOKEN]\"",[407,27757,3296],{"class":413},[407,27759,27760,27763,27765,27768],{"class":409,"line":452},[407,27761,27762],{"class":476},"  \"fingerprint\"",[407,27764,3607],{"class":413},[407,27766,27767],{"class":429},"\"c0badff089...\"",[407,27769,3296],{"class":413},[407,27771,27772,27775,27777,27780],{"class":409,"line":458},[407,27773,27774],{"class":476},"  \"expires_at\"",[407,27776,3607],{"class":413},[407,27778,27779],{"class":429},"\"2026-08-12T...\"",[407,27781,3296],{"class":413},[407,27783,27784],{"class":409,"line":464},[407,27785,27787],{"class":27786},"s7hpK","  ...\n",[407,27789,27790],{"class":409,"line":470},[407,27791,483],{"class":413},[11,27793,27794],{},"大小始终 312 字节，JSON 结构完整，解析无错误。排除。",[11,27796,27797],{},[488,27798,27799],{},"假设 2：panel 启动时读的是 cache 版本而非 USB",[11,27801,27802,27803,27806,27807,27810],{},"检查 ",[15,27804,27805],{},"resolve_portable_root()"," 的 env 优先级：panel 进程的 ",[15,27808,27809],{},"WAKOU_USB_ROOT"," 始终指向 USB 盘符，不会误读 cache。排除。",[11,27812,27813],{},[488,27814,27815],{},"假设 3：激活流程存在间隔性失败，某些次激活没真正写文件",[11,27817,27818],{},"trace 激活命令的日志时间戳与文件修改时间对齐，激活流程本身无异常。排除。",[31,27820,27821],{"id":27821},"定位根因",[11,27823,27824],{},"检查 guardian 进程的日志，看最后一行输出：",[399,27826,27828],{"className":27120,"code":27827,"language":27122,"meta":183,"style":183},"[2026-05-09 00:26:23] Invoke-FinalSync completed\n  Source (cache): C:\\Users\\*\\AppData\\Local\\*\\Runtime\\*\\data\\auth\n  Destination (USB): E:\\wakou-portable\\data\\auth\n  Mode: \u002FMIR\n",[15,27829,27830,27835,27840,27845],{"__ignoreMap":183},[407,27831,27832],{"class":409,"line":410},[407,27833,27834],{},"[2026-05-09 00:26:23] Invoke-FinalSync completed\n",[407,27836,27837],{"class":409,"line":184},[407,27838,27839],{},"  Source (cache): C:\\Users\\*\\AppData\\Local\\*\\Runtime\\*\\data\\auth\n",[407,27841,27842],{"class":409,"line":189},[407,27843,27844],{},"  Destination (USB): E:\\wakou-portable\\data\\auth\n",[407,27846,27847],{"class":409,"line":452},[407,27848,27849],{},"  Mode: \u002FMIR\n",[11,27851,27852],{},"时间戳 00:26:23 与用户重启时间一致。",[11,27854,27855,27856,27859],{},"同步模式是 ",[15,27857,27858],{},"\u002FMIR","（mirror 镜像）。对比同步前后：",[123,27861,27862,27883],{},[126,27863,27864,27867],{},[488,27865,27866],{},"同步前",[123,27868,27869,27876],{},[126,27870,27871,27872,27875],{},"USB: ",[15,27873,27874],{},"data\\auth\\token.json"," (size=312)",[126,27877,27878,27879,27882],{},"cache: ",[15,27880,27881],{},"data\\auth\\"," (目录不存在或为空)",[126,27884,27885,27888],{},[488,27886,27887],{},"同步后",[123,27889,27890,27895],{},[126,27891,27871,27892,27894],{},[15,27893,27881],{}," (目录为空)",[126,27896,27897,27898],{},"token.json ",[488,27899,27900],{},"已删除",[11,27902,27903,27904,27907,27908,781],{},"根本原因找到了：cache 端没有 token 文件，",[15,27905,27906],{},"robocopy \u002FMIR"," 的语义是\"让目标与源完全一致\"，包括",[488,27909,27910],{},"删除目标上源没有的文件",[26,27912,27913],{"id":27913},"根因分析",[11,27915,27916],{},"这个问题看起来是 token 丢失，实际上是三个独立正确的决策互相冲突了。",[31,27918,27920],{"id":27919},"激活流程直接写-usb为了立刻可用","激活流程直接写 USB（为了立刻可用）",[11,27922,27923,27926],{},[15,27924,27925],{},"activation.rs::write_token_json"," 优先级：",[399,27928,27930],{"className":27261,"code":27929,"language":27263,"meta":183,"style":183},"fn resolve_portable_root() -> Result\u003CPathBuf> {\n    \u002F\u002F WAKOU_USB_ROOT 总是优先使用（便携应用特性）\n    if let Ok(usb) = env::var(\"WAKOU_USB_ROOT\") {\n        return Ok(PathBuf::from(usb));\n    }\n    \u002F\u002F fallback 到 cache\n    env::var(\"WAKOU_LOCAL_CACHE_ROOT\").map(PathBuf::from)\n}\n\nfn write_token_json(portable_root: &Path, payload: &Value) {\n    let auth_path = portable_root.join(\"data\u002Fauth\");\n    fs::write(auth_path.join(\"token.json\"), ...)?;\n}\n",[15,27931,27932,27937,27942,27947,27952,27956,27961,27966,27970,27974,27979,27984,27989],{"__ignoreMap":183},[407,27933,27934],{"class":409,"line":410},[407,27935,27936],{},"fn resolve_portable_root() -> Result\u003CPathBuf> {\n",[407,27938,27939],{"class":409,"line":184},[407,27940,27941],{},"    \u002F\u002F WAKOU_USB_ROOT 总是优先使用（便携应用特性）\n",[407,27943,27944],{"class":409,"line":189},[407,27945,27946],{},"    if let Ok(usb) = env::var(\"WAKOU_USB_ROOT\") {\n",[407,27948,27949],{"class":409,"line":452},[407,27950,27951],{},"        return Ok(PathBuf::from(usb));\n",[407,27953,27954],{"class":409,"line":458},[407,27955,18167],{},[407,27957,27958],{"class":409,"line":464},[407,27959,27960],{},"    \u002F\u002F fallback 到 cache\n",[407,27962,27963],{"class":409,"line":470},[407,27964,27965],{},"    env::var(\"WAKOU_LOCAL_CACHE_ROOT\").map(PathBuf::from)\n",[407,27967,27968],{"class":409,"line":480},[407,27969,483],{},[407,27971,27972],{"class":409,"line":1477},[407,27973,1827],{"emptyLinePlaceholder":202},[407,27975,27976],{"class":409,"line":1483},[407,27977,27978],{},"fn write_token_json(portable_root: &Path, payload: &Value) {\n",[407,27980,27981],{"class":409,"line":2139},[407,27982,27983],{},"    let auth_path = portable_root.join(\"data\u002Fauth\");\n",[407,27985,27986],{"class":409,"line":2180},[407,27987,27988],{},"    fs::write(auth_path.join(\"token.json\"), ...)?;\n",[407,27990,27991],{"class":409,"line":7546},[407,27992,483],{},[11,27994,27995,27996,27999],{},"激活流程得到 ",[15,27997,27998],{},"portable_root"," 时，它已经是 USB 根。为什么？为了让用户激活后立刻能用——不必等 guardian 周期同步，直接读 USB 上的 token。这个设计是对的。",[31,28001,28003],{"id":28002},"guardian-把-auth-目录加入同步白名单为了持久化配置变更","Guardian 把 auth 目录加入同步白名单（为了持久化配置变更）",[11,28005,28006,28009,28010,28013,28014,28017],{},[15,28007,28008],{},"wakou-guardian.ps1::Invoke-FinalSync"," 维护的白名单（",[15,28011,28012],{},"Get-WakouSyncWhitelist","）包括 ",[15,28015,28016],{},"data\\auth","，原因是：config 更新、凭据变更等在 cache 本地修改后，需要通过同步把新数据持久化到 USB。这个设计也是对的。",[31,28019,28021,28022,28024],{"id":28020},"sync-使用-mir-镜像模式为了清理用户手动删除的文件","Sync 使用 ",[15,28023,27858],{}," 镜像模式（为了清理用户手动删除的文件）",[11,28026,28027,1059],{},[15,28028,28029],{},"wakou-common.psm1::Get-RobocopyArgs",[399,28031,28033],{"className":27120,"code":28032,"language":27122,"meta":183,"style":183},"\"Final\" { $args += @(\"\u002FMIR\") }   # mirror, includes purge\n",[15,28034,28035],{"__ignoreMap":183},[407,28036,28037],{"class":409,"line":410},[407,28038,28032],{},[11,28040,28041,28043,28044,1244],{},[15,28042,27858],{}," 等价于 ",[15,28045,28046],{},"\u002FE \u002FPURGE",[123,28048,28049,28055],{},[126,28050,28051,28054],{},[15,28052,28053],{},"\u002FE"," 递归复制整个目录树",[126,28056,28057,28060],{},[15,28058,28059],{},"\u002FPURGE"," 删除目标上源没有的文件",[11,28062,28063,28064,28066,28067,28069],{},"为什么用 ",[15,28065,27858],{},"？为了清理用户手动删除的文件。如果用户在 USB 上删了某个 config，不用 ",[15,28068,28059],{}," 的话，cache 可能还保留旧数据，重启后反而恢复了\"幽灵文件\"。这个决定也是对的。",[31,28071,28073],{"id":28072},"三个对的决策组在一起变成了错","三个对的决策，组在一起变成了错",[11,28075,28076],{},"三个决策各自都没错，但组合起来形成了一条\"写入位置与同步方向相反\"的路径：",[399,28078,28081],{"className":28079,"code":28080,"language":1327},[1325],"激活流程：USB ← 写 token\n         ↓\n用户关面板，guardian 最终同步\n         ↓\n同步方向：cache (empty) → USB (via \u002FMIR)\n         ↓\nUSB 的 token 被 \u002FMIR 删掉\n",[15,28082,28080],{"__ignoreMap":183},[11,28084,28085,28086,28089,28090,28093],{},"根本原因是 ",[488,28087,28088],{},"cache 永远 lag 一拍","。USB→cache 的同步只在 boot 期运行（",[15,28091,28092],{},"Sync-UsbToLocal","），激活发生在运行时，cache 里的 auth 目录在下一次 boot 之前始终是旧状态（或空）。而 guardian 的 final-sync 是在用户关面板时立刻跑的，此时 cache 还没有最新 token。",[26,28095,18998],{"id":18998},[31,28097,28099],{"id":28098},"核心改动redeem-同时写-cache-和-usb","核心改动：redeem 同时写 cache 和 USB",[11,28101,28102,28105],{},[15,28103,28104],{},"activation.rs"," 新增 helper 函数（素材里给出了伪代码，实现逻辑）：",[11,28107,28108],{},"token 激活后的写入改为：",[243,28110,28111,28135],{},[126,28112,28113,28116,28117,28120,28121],{},[488,28114,28115],{},"先写 cache","（launcher 已经通过 ",[15,28118,28119],{},"WAKOU_LOCAL_CACHE_ROOT"," env 注入了 cache 路径）",[123,28122,28123,28126,28129],{},[126,28124,28125],{},"这使 cache 成为 source-of-truth",[126,28127,28128],{},"guardian 的 final-sync 读到的 cache 是最新的 token",[126,28130,28131,28132,28134],{},"下一轮 ",[15,28133,27858],{}," 会把新 token 从 cache 同步到 USB，而不是删它",[126,28136,28137,28140,28141],{},[488,28138,28139],{},"再写 USB","（原有逻辑保留）",[123,28142,28143,28146,28155],{},[126,28144,28145],{},"让用户激活后立刻能用",[126,28147,28148,28149,26261,28152,28154],{},"USB 是面板直接读取的位置（via ",[15,28150,28151],{},"resolve_portable_root",[15,28153,27809],{}," 环境变量）",[126,28156,28157],{},"即使 cache 写入间歇性失败，激活也能完成，用户不卡激活页",[31,28159,28160],{"id":28160},"兼容性",[123,28162,28163,28166,28169],{},[126,28164,28165],{},"老用户若 cache 里碰巧有 token（之前某次 boot 从 USB 同步过来）→ 新激活覆盖，无问题",[126,28167,28168],{},"老用户 cache 没 token → 新激活补上 cache 副本，同时 USB 也有 → 自洽",[126,28170,28171,28172,28174],{},"dev 环境没设 ",[15,28173,28119],{}," → 退化成单写 USB（dev 没跑 guardian \u002FMIR，不会误删）",[26,28176,28177],{"id":28177},"防回归与思考",[11,28179,28180,28181],{},"教训很直白：",[488,28182,28183],{},"镜像同步不该作用于两侧都可能独立写入的目录。",[11,28185,28186],{},"具体措施：",[243,28188,28189,28208,28224],{},[126,28190,28191,28194],{},[488,28192,28193],{},"同步前明确数据流向",[123,28195,28196,28199,28205],{},[126,28197,28198],{},"激活写 USB，配置更新写 cache，两侧都有独立修改源",[126,28200,28201,28202,28204],{},"这种情况下 ",[15,28203,27858],{}," 必然是陷阱",[126,28206,28207],{},"要么改成合并模式，要么明确约定唯一的 source-of-truth，不要试图同时满足两侧",[126,28209,28210,28213],{},[488,28211,28212],{},"代码审查检查清单",[123,28214,28215,28218],{},[126,28216,28217],{},"panel 写入 portable_root 的任何文件都要检查 guardian 的同步白名单",[126,28219,28220,28221,28223],{},"如果白名单包含这个目录且用 ",[15,28222,27858],{},"，立刻改法：写 cache 让 guardian 主动同步到 USB，或改合并模式",[126,28225,28226,28229],{},[488,28227,28228],{},"构建期检查",[123,28230,28231,28234],{},[126,28232,28233],{},"扫描 PowerShell 启动命令，检测\"写 USB 但同步源指向 cache\"的组合",[126,28235,28236],{},"检测到就 fail-build",[11,28238,28239,28240,28243,28244,56,28246,56,28249,28251],{},"本质上这是",[488,28241,28242],{},"时序问题","。panel 运行时改动和 boot 期同步是分开的两个阶段，中间的窗口期——从 token 写入到 guardian 同步的这 7 分钟——成了陷阱的发生地。任何使用删除操作的同步（",[15,28245,28059],{},[15,28247,28248],{},"rsync --delete",[15,28250,17634],{},"），都必须确认源端的数据在整个同步周期内是完整的。不确认就用删除，会把最新的写入无声地删掉。",[1267,28253,28254],{},"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 .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}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 pre.shiki code .s7hpK, html code.shiki .s7hpK{--shiki-default:#B31D28;--shiki-default-font-style:italic;--shiki-dark:#FDAEB7;--shiki-dark-font-style:italic}",{"title":183,"searchDepth":184,"depth":184,"links":28256},[28257,28258,28262,28269,28273],{"id":25207,"depth":184,"text":25207},{"id":27714,"depth":184,"text":27714,"children":28259},[28260,28261],{"id":27717,"depth":189,"text":27717},{"id":27821,"depth":189,"text":27821},{"id":27913,"depth":184,"text":27913,"children":28263},[28264,28265,28266,28268],{"id":27919,"depth":189,"text":27920},{"id":28002,"depth":189,"text":28003},{"id":28020,"depth":189,"text":28267},"Sync 使用 \u002FMIR 镜像模式（为了清理用户手动删除的文件）",{"id":28072,"depth":189,"text":28073},{"id":18998,"depth":184,"text":18998,"children":28270},[28271,28272],{"id":28098,"depth":189,"text":28099},{"id":28160,"depth":189,"text":28160},{"id":28177,"depth":184,"text":28177},"2026-05-14",{},"\u002F2026-05-14-fa-010-robocopy-mirtoken",{"title":27636,"description":27641},"FA-010","2026-05-14-FA-010-robocopy-MIR删掉激活token","激活后 token 写到 USB，但 guardian 最终同步用镜像模式把它删了，导致反复激活。",[12681,27630,28282,28283,28284,28285],"文件同步","便携应用","robocopy","状态管理","0zINumdNwj-VDSZYq8VjN17VeZ5FMwDEAA2Dk4alYu8",{"id":28288,"title":28289,"body":28290,"column":1966,"date":28570,"description":28294,"extension":199,"hero_image":200,"meta":28571,"navigation":202,"path":28572,"seo":28573,"series_id":200,"severity":200,"stem":28574,"summary":28575,"tags":28576,"__hash__":28581},"posts\u002F2026-05-13-从桌面端走向云端多租户第一版架构.md","一个用户一个容器——这个决定管了后面两年",{"type":8,"value":28291,"toc":28563},[28292,28295,28301,28305,28312,28323,28326,28329,28336,28340,28343,28375,28378,28381,28384,28389,28392,28418,28421,28426,28433,28440,28454,28469,28472,28477,28483,28486,28489,28494,28497,28508,28511,28525,28530,28533,28536,28539,28542,28545,28548,28551,28554,28557,28560],[11,28293,28294],{},"桌面便携版 OpenClaw 能服务单个客户，但无法规模化——每户都要人工部署，排障要 SSH 上机，更新要走 OTA。转云端多租户是必然的演进。",[11,28296,28297,28298,781],{},"我在 2026 年 5 月启动 CPA（Cloud Platform Architecture）项目，把这个转变落地。整个设计围绕一个核心决策展开：",[488,28299,28300],{},"每个用户一个独立容器",[26,28302,28304],{"id":28303},"为什么容器隔离必须是-11","为什么容器隔离必须是 1:1",[11,28306,28307,28308,28311],{},"OpenClaw 是 AI Agent 运行时。用户在 shell 里跑 ",[15,28309,28310],{},"openclaw chat","，这个进程会：",[123,28313,28314,28317,28320],{},[126,28315,28316],{},"执行用户上传或配置的代码（skill、workflow）",[126,28318,28319],{},"写文件到本地卷（历史记录、缓存、配置）",[126,28321,28322],{},"装依赖（npm install、pip install）",[11,28324,28325],{},"进程级隔离不够。两个用户的代码在同一进程里跑，一个 crash 就拖累另一个；一个恶意脚本可以访问全局状态、篡改其他用户的卷。这是不可接受的。",[11,28327,28328],{},"虚拟机级隔离太重。每个用户启一个 VM，资源成本会上天。",[11,28330,28331,28332,28335],{},"容器是中间地带。OS-level cgroup + namespace + rootfs，隔离干净、开销可控。所以决定是：",[488,28333,28334],{},"用户数 = 容器数","。这个选择是架构的起点。",[26,28337,28339],{"id":28338},"后果资源线性放大","后果：资源线性放大",[11,28341,28342],{},"选定 1:1 容器后，宿主机上每一项资源都随用户增长线性放大：",[123,28344,28345,28351,28357,28363,28369],{},[126,28346,28347,28350],{},[488,28348,28349],{},"网络地址","：Docker overlay network 需要 virtual IP；每容器占一个。从 10.0.0.0\u002F8 分配，上限约 1600 万 —— 看起来充足，实际 Docker 的实现在几千容器时开始出现地址池竞争。",[126,28352,28353,28356],{},[488,28354,28355],{},"内存","：单容器 1 GiB（Node.js 24 + openclaw + adapter + supervisord），100 用户就是 100 GiB。单机 8 核 16G 撑不了多少人。",[126,28358,28359,28362],{},[488,28360,28361],{},"文件描述符","：容器内 openclaw 进程、adapter 进程各占百把个 fd；同时 100 个容器意味着内核全局 fd 表有万级条目。系统默认上限 65536，很容易触线。",[126,28364,28365,28368],{},[488,28366,28367],{},"存储挂载","：每个用户一个持久卷；容器 prepare 时要挂载卷。超过一定数量后，I\u002FO 竞争明显。",[126,28370,28371,28374],{},[488,28372,28373],{},"升级耗时","：滚动升级 100 个容器，每个要拉镜像、启容器、健康检查，总耗时 = 容器数 × 单个启动时间。100 个容器可能要半小时。",[11,28376,28377],{},"这个选择本身没错。错在没有同步建立\"每项资源的容量上限是多少用户\"这张表。",[26,28379,28380],{"id":28380},"五周实施节奏",[11,28382,28383],{},"为了在 5 周内把这个架构从 0 到可演示，我把工作按依赖关系排成五个阶段。",[11,28385,28386],{},[488,28387,28388],{},"Week 1：基础设施与 sub2api 桥接",[11,28390,28391],{},"基础设施决定了后续每周的开发成败。这周的目标是：",[123,28393,28394,28404,28407,28410,28413],{},[126,28395,28396,28397,56,28399,56,28401],{},"搭 monorepo（pnpm + turbo），分离 ",[15,28398,9396],{},[15,28400,9392],{},[15,28402,28403],{},"infra\u002Fdocker",[126,28405,28406],{},"Postgres + Redis 本地开发环境",[126,28408,28409],{},"反向代理 Caddy，配置 self-signed TLS 与路由",[126,28411,28412],{},"容器镜像骨架（node:24 + supervisord + adapter placeholder）",[126,28414,28415],{},[488,28416,28417],{},"关键：真机联调 sub2api 管理员 API",[11,28419,28420],{},"最后一项是风险最高的点。sub2api 的管理员 API 文档不完整，我写了一个早期探针脚本逐个 curl 真机上的端点路径，确认了创建子账号、登录、建 key、查用量这四个核心接口。如果这一周没跑通，整个 Week 2 就会卡壳。实际上这次探针一次成功，没有返工。",[11,28422,28423],{},[488,28424,28425],{},"Week 2：认证、激活码、容器自动供给",[11,28427,28428,28429,28432],{},"注册流程的核心是三段式（create sub2api user → login as that user → create API key as that user），每段用不同的鉴权方式。Week 1 的探针结果直接映射到 ",[15,28430,28431],{},"Sub2ApiClient"," 的四个方法。",[11,28434,28435,28436,28439],{},"这周新增 User + ActivationCode + Instance 三个数据库表，以及认证中间件（基于 session cookie 的用户校验）。最关键的工作是 ",[15,28437,28438],{},"SignupService","，它：",[243,28441,28442,28445,28448,28451],{},[126,28443,28444],{},"校验激活码",[126,28446,28447],{},"调 sub2api 三段式开通子账号 + key",[126,28449,28450],{},"用 libsodium sealed-box 加密 key 和密码",[126,28452,28453],{},"事务内写入 User 表 + Quota 表 + 消激活码",[11,28455,28456,28457,28460,28461,28464,28465,28468],{},"容器编排改用 Docker Swarm overlay network（相比 host 网络，overlay 能自动处理 DNS + virtual IP）。",[15,28458,28459],{},"Instance"," 表存 ",[15,28462,28463],{},"containerId","（swarm service name）、",[15,28466,28467],{},"host","（overlay VIP）。",[11,28470,28471],{},"实现过程中没有太多惊喜，主要是工程量 —— 从密码哈希、session 管理、错误处理到审计日志，都是标准的 SaaS 后端配方。",[11,28473,28474],{},[488,28475,28476],{},"Week 3：聊天与 Skill 管理",[11,28478,28479,28480,28482],{},"这是最接近业务核心的一周。chatProxy 绕过了还没装进镜像的 openclaw daemon，直接把用户消息转给 sub2api 的 OpenAI 兼容端点 ",[15,28481,27102],{},"。流式响应逐个 token 推回浏览器（SSE）。",[11,28484,28485],{},"Skill 管理因为 openclaw 本身的 API 还没定型，只能做到 L1 —— 在 DB 存每个用户的 skill enable\u002Fdisable 状态，用户点按钮立即保存。实际生效要等 Week 5 才行。",[11,28487,28488],{},"这周前端加了 React 聊天页（Vercel AI SDK 处理流式解析）+ Markdown 渲染（remark-gfm 支持表格和任务列表）。",[11,28490,28491],{},[488,28492,28493],{},"Week 4：高级终端与用量看板",[11,28495,28496],{},"用户在浏览器里打开 xterm 网页终端，直接连到容器内的 ttyd（PTY bridge）。Fastify WebSocket 插件处理双向透传。",[11,28498,28499,28500,28503,28504,28507],{},"关键改动是从 overlay VIP 换成 ",[15,28501,28502],{},"--publish mode=host","。宿主 18000+ 端口一对一映射到容器 8080（adapter）和 7681（ttyd）。这样 cpa-api（跑在 systemd，不在 swarm 里）能用 ",[15,28505,28506],{},"127.0.0.1:PORT"," 直接访问容器，不依赖 overlay 网络。",[11,28509,28510],{},"用量看板聚合 sub2api 数据（API 调用次数、token、费用）+ 本地 Message 表的消息分桶。每天一条记录，前端 recharts 折线图展示趋势。",[11,28512,28513,28514,28517,28518,28521,28522,28524],{},"镜像升到 v0.3，真正装了 ",[15,28515,28516],{},"npm install -g openclaw","。supervisord 配 ",[15,28519,28520],{},"autostart=false","，避免 daemon 启动失败拖累整个容器。用户在 ttyd shell 里手动 ",[15,28523,28310],{}," 按需启动。",[11,28526,28527],{},[488,28528,28529],{},"Week 5：生产加固",[11,28531,28532],{},"三个合规页面（用户协议、隐私、违法举报）+ 注册强制勾选。Postgres 每日自动备份（pg_dump + 30 天保留）+ Docker volume 每周快照。Uptime Kuma 监控 4 个关键 endpoint。admin 后台一键给用户充值 sub2api 余额（前提是上游支持 admin recharge API；如不支持则通知手动充值）。",[11,28534,28535],{},"Vite manualChunks 拆分 bundle —— 把 markdown、charts、xterm 库分离成独立 chunk，首屏只加载核心 main.js，减少初始 load 时间。",[26,28537,28538],{"id":28538},"预留的隐性成本",[11,28540,28541],{},"这个设计交付时看起来很完整，但后续运维中，瓶颈会逐个浮现。",[11,28543,28544],{},"用户数从 0 增到几百时，每项资源都还有富余，看不出问题。但到一定规模，某一个资源先耗尽 —— 比如 overlay network 的 fd 表、或者宿主文件系统的 inode、或者滚动升级时的累积启动时间。现象不是\"平台容量满了\"，而是\"个别用户偶发异常\"\"某个用户的容器起不来\"\"部分 ws 连接超时\"。",[11,28546,28547],{},"排查这类问题很痛苦，因为单看一个用户的日志是完全正常的，必须从全局资源池的角度看。比如文件描述符耗尽时，表现是某个容器内的 adapter 进程打不开 socket 连接，但这个进程本身的 CPU 和内存都在正常范围。",[11,28549,28550],{},"好消息是，这个 1:1 容器的决策本身没有反悔的必要。隔离需求是真实的，成本是值得的。错的只是在架构敲定时，没有同步做\"容量规划表\"—— 明确每 100 用户会消耗多少网络地址、文件描述符、内存碎片、存储 IOPS，以及对应的监控告警阈值。",[11,28552,28553],{},"这些细节会在后续的故障复盘中逐一显露。",[28555,28556],"hr",{},[26,28558,28559],{"id":28559},"后续演进",[11,28561,28562],{},"这个五周MVP是 Agent 平台因果链的起点。后续 05-22、05-25、06-01、06-03、07-26 的六篇文章，逐段展开宿主资源如何一个接一个触线，以及每次的应对方案。从网络隔离、文件描述符争抢、存储竞争，到最终的多区域容灾，整条线索都是这个 1:1 容器决策的直接后果。",{"title":183,"searchDepth":184,"depth":184,"links":28564},[28565,28566,28567,28568,28569],{"id":28303,"depth":184,"text":28304},{"id":28338,"depth":184,"text":28339},{"id":28380,"depth":184,"text":28380},{"id":28538,"depth":184,"text":28538},{"id":28559,"depth":184,"text":28559},"2026-05-13",{},"\u002F2026-05-13",{"title":28289,"description":28294},"2026-05-13-从桌面端走向云端多租户第一版架构","桌面便携方案无法规模化后，转向多租户云端是必然。核心决策：每用户独享容器。这个选择为隔离但代价是资源线性放大。",[28577,28578,28579,28580,11245],"多租户架构","容器隔离","OpenClaw","云平台","Aww4dvgUOQKmX-cHXRzpCGdZ88FzBVbJFAZx97nNsYI",{"id":28583,"title":28584,"body":28585,"column":355,"date":29004,"description":28589,"extension":199,"hero_image":200,"meta":29005,"navigation":202,"path":29006,"seo":29007,"series_id":29008,"severity":200,"stem":29009,"summary":29010,"tags":29011,"__hash__":29014},"posts\u002F2026-05-12-FA-009-强推撤回版本升降级循环.md","点更新是降级，不点就动不了",{"type":8,"value":28586,"toc":28993},[28587,28590,28593,28596,28599,28605,28608,28660,28675,28677,28680,28690,28738,28741,28770,28785,28788,28795,28798,28801,28804,28806,28809,28812,28823,28920,28927,28930,28938,28941,28948,28951,28969,28971,28974,28977,28980,28983,28990],[11,28588,28589],{},"客户端激活成功，第一件事弹了个 modal：\"为了安全和稳定，本版本必须升级后才能继续使用\"。提示版本 0.14.5。当前客户端版本是 0.14.6，比 0.14.5 还新。",[11,28591,28592],{},"不点更新，modal 阻塞所有交互。点\"立即更新\"，客户端被推成 0.14.5——一个已经撤回、有已知 bug 的旧版本。装上 0.14.5 重启，launcher 又推了 0.14.6。再装上，再重启。然后又是 0.14.5。升降级循环。",[26,28594,28595],{"id":28595},"现象确认",[11,28597,28598],{},"触发点是激活成功后的 OTA 检查。用排查命令读取本地缓存的 manifest：",[399,28600,28603],{"className":28601,"code":28602,"language":1327},[1325],"$env:LOCALAPPDATA\\Microsoft\\WakouAI\\Update\\\u003Cfingerprint>\\last-known-signed-manifest.json\n",[15,28604,28602],{"__ignoreMap":183},[11,28606,28607],{},"内容显示：",[399,28609,28611],{"className":3591,"code":28610,"language":3593,"meta":183,"style":183},"{\n  \"version\": \"0.14.5\",\n  \"minClientVersion\": \"0.14.3\",\n  \"forceUpdate\": true,\n  ...\n}\n",[15,28612,28613,28617,28628,28640,28652,28656],{"__ignoreMap":183},[407,28614,28615],{"class":409,"line":410},[407,28616,421],{"class":413},[407,28618,28619,28621,28623,28626],{"class":409,"line":184},[407,28620,27739],{"class":476},[407,28622,3607],{"class":413},[407,28624,28625],{"class":429},"\"0.14.5\"",[407,28627,3296],{"class":413},[407,28629,28630,28633,28635,28638],{"class":409,"line":189},[407,28631,28632],{"class":476},"  \"minClientVersion\"",[407,28634,3607],{"class":413},[407,28636,28637],{"class":429},"\"0.14.3\"",[407,28639,3296],{"class":413},[407,28641,28642,28645,28647,28650],{"class":409,"line":452},[407,28643,28644],{"class":476},"  \"forceUpdate\"",[407,28646,3607],{"class":413},[407,28648,28649],{"class":476},"true",[407,28651,3296],{"class":413},[407,28653,28654],{"class":409,"line":458},[407,28655,27787],{"class":27786},[407,28657,28658],{"class":409,"line":464},[407,28659,483],{"class":413},[11,28661,28662,28663,28666,28667,28670,28671,28674],{},"客户端当前版本 0.14.6（",[15,28664,28665],{},"CARGO_PKG_VERSION","），version compare 结果是 ",[15,28668,28669],{},"semver_gt(\"0.14.5\", \"0.14.6\") = false","。按常理应该无更新。但 launcher 仍然返回 ",[15,28672,28673],{},"ForceUpdate","，触发降级。这里有个隐藏的誤导性：弹窗文案提示\"发现新版本\"，但实际推的是旧版本。",[26,28676,25231],{"id":25231},[11,28678,28679],{},"看到 modal 文案直接走强制更新流程，自然怀疑是 launcher 侧的决策。先确认 cached manifest 的内容，确实 version 是 0.14.5、forceUpdate 标记为 true。",[11,28681,28682,28683,26261,28686,28689],{},"接下来的问题是：launcher 在什么条件下会返 ForceUpdate？阅读决策代码 ",[15,28684,28685],{},"wakou-full\u002Flauncher\u002Fsrc\u002Fota_check.rs",[15,28687,28688],{},"map_outcome_to_result"," 函数（修复前）：",[399,28691,28693],{"className":27261,"code":28692,"language":27263,"meta":183,"style":183},"Verified { manifest } => {\n    if manifest.force_update || semver_gt(&manifest.min_client_version, current_version) {\n        OtaCheckResult::ForceUpdate { version: manifest.version, ... }\n    } else if semver_gt(&manifest.version, current_version) {\n        OtaCheckResult::OptionalUpdate { ... }\n    } else {\n        OtaCheckResult::NoUpdate\n    }\n}\n",[15,28694,28695,28700,28705,28710,28715,28720,28725,28730,28734],{"__ignoreMap":183},[407,28696,28697],{"class":409,"line":410},[407,28698,28699],{},"Verified { manifest } => {\n",[407,28701,28702],{"class":409,"line":184},[407,28703,28704],{},"    if manifest.force_update || semver_gt(&manifest.min_client_version, current_version) {\n",[407,28706,28707],{"class":409,"line":189},[407,28708,28709],{},"        OtaCheckResult::ForceUpdate { version: manifest.version, ... }\n",[407,28711,28712],{"class":409,"line":452},[407,28713,28714],{},"    } else if semver_gt(&manifest.version, current_version) {\n",[407,28716,28717],{"class":409,"line":458},[407,28718,28719],{},"        OtaCheckResult::OptionalUpdate { ... }\n",[407,28721,28722],{"class":409,"line":464},[407,28723,28724],{},"    } else {\n",[407,28726,28727],{"class":409,"line":470},[407,28728,28729],{},"        OtaCheckResult::NoUpdate\n",[407,28731,28732],{"class":409,"line":480},[407,28733,18167],{},[407,28735,28736],{"class":409,"line":1477},[407,28737,483],{},[11,28739,28740],{},"逻辑分支的顺序是：",[243,28742,28743,28755,28764],{},[126,28744,28745,1244,28748,13067,28751,28754],{},[488,28746,28747],{},"第一判断",[15,28749,28750],{},"force_update=true",[15,28752,28753],{},"min_client_version > current_version","，直接返 ForceUpdate",[126,28756,28757,1244,28760,28763],{},[488,28758,28759],{},"第二判断",[15,28761,28762],{},"version > current_version","，返 OptionalUpdate",[126,28765,28766,28769],{},[488,28767,28768],{},"第三判断","：都不满足，返 NoUpdate",[11,28771,28772,28773,28776,28777,28780,28781,28784],{},"问题就在第一判断：没有任何检查确保 ",[15,28774,28775],{},"manifest.version"," 真的比 ",[15,28778,28779],{},"current_version"," 新。",[15,28782,28783],{},"force_update"," 标记被当作绝对权威，即使指向的版本是旧的。",[26,28786,28787],{"id":28787},"根因链路",[11,28789,28790,28791,28794],{},"server 端撤回 0.14.5 时，管理后台做了软删除，但 wrapper API 仍然在某些 channel\u002Fclient query 条件下返回 0.14.5 的 manifest 作为 latest，且签名仍然有效（这里假设是服务端撤回流程不完整）。launcher 拿到这个 manifest，看到 ",[15,28792,28793],{},"forceUpdate=true","，就直接判定为强制更新，完全不检查目标版本号。",[11,28796,28797],{},"客户端此时已经是 0.14.6，一个更高的版本。但因为比对逻辑的分支顺序，force_update 优先于版本号比对，所以被推成了\"必须降级\"。这就是设计缺陷之处：没有版本单调性的护栏。",[11,28799,28800],{},"同样的问题也存在于 OfflineFresh 分支——当本地缓存 manifest 的 force_update 被污染后，即使无网络，后续仍然会按陈旧的 force_update 标记推版本。OfflineFresh 是为了离线场景的降级缓存机制，但它的 force_update 标记一旦错误，就会持续误导客户端。",[11,28802,28803],{},"一旦陷入循环，每次启动都会 query 新 manifest，新 manifest 可能已修复（不再返 0.14.5），但本地 cache 还在，OfflineFresh 分支会继续用污染的 cache。这样就形成了死循环：降级 → 重启 → 升级 → 重启 → 再降级。",[26,28805,18998],{"id":18998},[11,28807,28808],{},"修复分为客户端和服务端两层。",[31,28810,28811],{"id":28811},"客户端侧",[11,28813,28814,28815,28818,28819,28822],{},"核心是把版本单调性检查",[488,28816,28817],{},"前置为独立决策层","，在任何 force_update 判断之前。提取统一决策函数 ",[15,28820,28821],{},"decide_from_manifest","，其中把\"当前版本已满足 min_client_version 且 server 版本不高于当前版本\"作为 NoUpdate 的充要条件：",[399,28824,28826],{"className":27261,"code":28825,"language":27263,"meta":183,"style":183},"fn decide_from_manifest(server_version, server_min_client, force_update, current_version, ...) {\n    let server_newer = semver_gt(server_version, current_version);\n    let client_obsolete = semver_gt(server_min_client, current_version);\n\n    \u002F\u002F 客户端满足 min_client AND server 版本不比我新 → NoUpdate\n    \u002F\u002F 即使 force_update=true，也要遵守单调性\n    if !server_newer && !client_obsolete {\n        return NoUpdate;\n    }\n\n    if client_obsolete || (server_newer && force_update) {\n        return ForceUpdate { ... };\n    }\n\n    if server_newer {\n        return OptionalUpdate { ... };\n    }\n\n    NoUpdate\n}\n",[15,28827,28828,28833,28838,28843,28847,28852,28857,28862,28867,28871,28875,28880,28885,28889,28893,28898,28903,28907,28911,28916],{"__ignoreMap":183},[407,28829,28830],{"class":409,"line":410},[407,28831,28832],{},"fn decide_from_manifest(server_version, server_min_client, force_update, current_version, ...) {\n",[407,28834,28835],{"class":409,"line":184},[407,28836,28837],{},"    let server_newer = semver_gt(server_version, current_version);\n",[407,28839,28840],{"class":409,"line":189},[407,28841,28842],{},"    let client_obsolete = semver_gt(server_min_client, current_version);\n",[407,28844,28845],{"class":409,"line":452},[407,28846,1827],{"emptyLinePlaceholder":202},[407,28848,28849],{"class":409,"line":458},[407,28850,28851],{},"    \u002F\u002F 客户端满足 min_client AND server 版本不比我新 → NoUpdate\n",[407,28853,28854],{"class":409,"line":464},[407,28855,28856],{},"    \u002F\u002F 即使 force_update=true，也要遵守单调性\n",[407,28858,28859],{"class":409,"line":470},[407,28860,28861],{},"    if !server_newer && !client_obsolete {\n",[407,28863,28864],{"class":409,"line":480},[407,28865,28866],{},"        return NoUpdate;\n",[407,28868,28869],{"class":409,"line":1477},[407,28870,18167],{},[407,28872,28873],{"class":409,"line":1483},[407,28874,1827],{"emptyLinePlaceholder":202},[407,28876,28877],{"class":409,"line":2139},[407,28878,28879],{},"    if client_obsolete || (server_newer && force_update) {\n",[407,28881,28882],{"class":409,"line":2180},[407,28883,28884],{},"        return ForceUpdate { ... };\n",[407,28886,28887],{"class":409,"line":7546},[407,28888,18167],{},[407,28890,28891],{"class":409,"line":7564},[407,28892,1827],{"emptyLinePlaceholder":202},[407,28894,28895],{"class":409,"line":17267},[407,28896,28897],{},"    if server_newer {\n",[407,28899,28900],{"class":409,"line":17279},[407,28901,28902],{},"        return OptionalUpdate { ... };\n",[407,28904,28905],{"class":409,"line":18010},[407,28906,18167],{},[407,28908,28909],{"class":409,"line":18057},[407,28910,1827],{"emptyLinePlaceholder":202},[407,28912,28913],{"class":409,"line":18062},[407,28914,28915],{},"    NoUpdate\n",[407,28917,28918],{"class":409,"line":18067},[407,28919,483],{},[11,28921,28922,28923,28926],{},"关键改动是第一道防线：",[15,28924,28925],{},"!server_newer && !client_obsolete"," 时直接返 NoUpdate，这个条件前置在任何 force_update 判断之前。两条路径（Verified 和 OfflineFresh）都走这个函数。",[11,28928,28929],{},"新增单测覆盖缺失的场景：",[123,28931,28932,28935],{},[126,28933,28934],{},"server 0.14.5 + force_update=true + current 0.14.6 → NoUpdate",[126,28936,28937],{},"server 0.14.6 + force_update=true + current 0.14.6 → NoUpdate",[31,28939,28940],{"id":28940},"服务端侧",[11,28942,28943,28944,28947],{},"当版本被撤回时，不仅要删除 manifest 本身，还要同时清理客户端本地缓存里的 ",[15,28945,28946],{},"last-known-signed-manifest.json","。这个文件是离线场景的降级缓存（OfflineFresh 分支会用它），如果不删除，即使 server 已修复，已下发过的陈旧 manifest 仍然会继续被本地 cache 推用。",[11,28949,28950],{},"部署步骤（本次 hotfix）包括：",[243,28952,28953,28956,28959,28962],{},[126,28954,28955],{},"编译修复后的 launcher",[126,28957,28958],{},"清除当前 launcher 进程释放文件锁",[126,28960,28961],{},"替换 launcher.exe 及两份 LocalAppData 缓存里的 launcher.exe",[126,28963,28964,28968],{},[488,28965,25458,28966],{},[15,28967,28946],{},"——这是关键，不删的话 OfflineFresh 路径在下一次离线启动时仍会推旧 manifest",[31,28970,23827],{"id":23827},[11,28972,28973],{},"在构建脚本里增加正则检查：launcher 启动命令必须有 force_update 相关的单调性防御，否则 fail-build。这是构建期的静态检查，防止后续版本回退。",[11,28975,28976],{},"panel 端的展示逻辑（force_update_modal.js）也应加 sanity check：如果展示的版本号 ≤ 当前版本，不显示 modal。这是第二层防御，即使 launcher 修复被回滚，panel 也不会显示误导性的升级弹窗。",[11,28978,28979],{},"服务端撤回 API 的 e2e 测试要补充：测试覆盖撤回一个版本后，该版本在任何 query 条件下都不会被 wrapper 返回，且相关缓存已清理。这是关键的质量关卡，防止类似的撤回不彻底问题再次发生。",[26,28981,28982],{"id":28982},"可迁移的判断",[11,28984,28985,28986,28989],{},"版本单调性不能只靠服务端签名和包体完整性保证——签名可能合法，包体可能完整，唯一的问题是方向错了。版本比对逻辑本身必须作为",[488,28987,28988],{},"独立的一层防御","，在所有其他条件（force_update 标记、min_client_version 约束）之前执行。强制更新的语义是\"必须升级\"，而不是\"必须更新到服务端指定的版本\"，这两者在版本管理上有本质的区别。这一原则对所有支持版本管理的系统都适用。",[1267,28991,28992],{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}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 pre.shiki code .s7hpK, html code.shiki .s7hpK{--shiki-default:#B31D28;--shiki-default-font-style:italic;--shiki-dark:#FDAEB7;--shiki-dark-font-style:italic}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":183,"searchDepth":184,"depth":184,"links":28994},[28995,28996,28997,28998,29003],{"id":28595,"depth":184,"text":28595},{"id":25231,"depth":184,"text":25231},{"id":28787,"depth":184,"text":28787},{"id":18998,"depth":184,"text":18998,"children":28999},[29000,29001,29002],{"id":28811,"depth":189,"text":28811},{"id":28940,"depth":189,"text":28940},{"id":23827,"depth":189,"text":23827},{"id":28982,"depth":184,"text":28982},"2026-05-12",{},"\u002F2026-05-12-fa-009",{"title":28584,"description":28589},"FA-009","2026-05-12-FA-009-强推撤回版本升降级循环","客户端版本比对逻辑缺陷导致强推已撤回的旧版本，陷入升降级循环；两层修复确保版本单调性是独立防御。",[15069,15072,29012,20302,29013],"客户端升级","缓存策略","cCNdBiT9lakAEzsLPzRJ2I23nx8iVvdZONxA2X3z-9k",{"id":29016,"title":29017,"body":29018,"column":355,"date":29326,"description":29327,"extension":199,"hero_image":200,"meta":29328,"navigation":202,"path":29329,"seo":29330,"series_id":29331,"severity":200,"stem":29332,"summary":29333,"tags":29334,"__hash__":29337},"posts\u002F2026-05-11-FA-008-stale-minisig验签失败.md","签名文件比这次更新，早了三小时",{"type":8,"value":29019,"toc":29319},[29020,29026,29029,29076,29080,29089,29099,29158,29161,29164,29175,29177,29195,29208,29210,29213,29231,29237,29285,29296,29299,29302,29311,29314,29317],[11,29021,29022,29023,29025],{},"状态目录里有个文件时间戳不对劲。",[15,29024,19717],{}," 的修改时间是 20:19:35，而本次 OTA 启动于 23:25，相差三小时。验签应该是在 OTA 最后一步才做的事，为什么这个文件早了三小时？",[11,29027,29028],{},"这是 Windows build host 上 1.0.1 升级到 1.0.2 的 OTA 流程。实测现场的完整时序如下：",[123,29030,29031,29038,29052,29055,29073],{},[126,29032,29033,29034,29037],{},"下载阶段正常完成：",[15,29035,29036],{},"download.zip"," 体积 24.4 MB，hash 校验通过",[126,29039,29040,29041,29044,29045,16200,29048,29051],{},"状态文件卡死在 ",[15,29042,29043],{},"state.phase = \"verifying\"","，且 ",[15,29046,29047],{},"state.json",[15,29049,29050],{},"download.bytes_downloaded = 0","（worker 进入 verify 后被杀，没有机会更新这个字段）",[126,29053,29054],{},"后台 worker 进程已退出，没有活进程推进 OTA 链路",[126,29056,29057,29058,29061,29062,29064,29065,29068,29069,29072],{},"状态目录（",[15,29059,29060],{},"Update\\\u003Cserial-hash>\\","）里存放签名文件 ",[15,29063,19717],{},"，修改时间戳是 ",[488,29066,29067],{},"20:19:35","，而本次 OTA 启动于 ",[488,29070,29071],{},"23:25","，相差三小时",[126,29074,29075],{},"launcher 和面板端陷入重试循环：launcher 看到有效状态标记，调用 chain 重新运行 → worker verify 失败 → worker 进程被杀 → launcher 重启 → 再次重新运行同一条 chain",[26,29077,29079],{"id":29078},"根因旧签名文件的复用","根因：旧签名文件的复用",[11,29081,29082,29083,29085,29086,29088],{},"状态目录里的 ",[15,29084,19717],{}," 来自上一轮失败的 OTA。某个时刻的升级（比如从 1.0.0）在 verify 之前或之中中断，签名文件就留在了磁盘上。当前 OTA（1.0.2）下完包后进入验签，代码发现磁盘上已存在 ",[15,29087,19717],{},"，就直接读取，而不下载新版本对应的签名。",[11,29090,29091,29092,88,29095,29098],{},"根本原因在 ",[15,29093,29094],{},"ota_worker.rs",[15,29096,29097],{},"ota_main_chain.rs"," 两处的同一个逻辑块：",[399,29100,29102],{"className":27261,"code":29101,"language":27263,"meta":183,"style":183},"\u002F\u002F ota_worker.rs:240\nlet minisig_path = state_base.join(\"download.zip.minisig\");\nlet sig_text = if minisig_path.exists() {\n    std::fs::read_to_string(&minisig_path)?  \u002F\u002F 文件存在 → 直接读磁盘\n} else {\n    let sig_url = &manifest.download.signature.url;\n    let text = reqwest::blocking::get(sig_url)?.text()?;\n    std::fs::write(&minisig_path, &text)?;\n    text\n};\nverify_zip::verify_zip(&zip_path, &sig_text, ...)?;\n",[15,29103,29104,29109,29114,29119,29124,29129,29134,29139,29144,29149,29153],{"__ignoreMap":183},[407,29105,29106],{"class":409,"line":410},[407,29107,29108],{},"\u002F\u002F ota_worker.rs:240\n",[407,29110,29111],{"class":409,"line":184},[407,29112,29113],{},"let minisig_path = state_base.join(\"download.zip.minisig\");\n",[407,29115,29116],{"class":409,"line":189},[407,29117,29118],{},"let sig_text = if minisig_path.exists() {\n",[407,29120,29121],{"class":409,"line":452},[407,29122,29123],{},"    std::fs::read_to_string(&minisig_path)?  \u002F\u002F 文件存在 → 直接读磁盘\n",[407,29125,29126],{"class":409,"line":458},[407,29127,29128],{},"} else {\n",[407,29130,29131],{"class":409,"line":464},[407,29132,29133],{},"    let sig_url = &manifest.download.signature.url;\n",[407,29135,29136],{"class":409,"line":470},[407,29137,29138],{},"    let text = reqwest::blocking::get(sig_url)?.text()?;\n",[407,29140,29141],{"class":409,"line":480},[407,29142,29143],{},"    std::fs::write(&minisig_path, &text)?;\n",[407,29145,29146],{"class":409,"line":1477},[407,29147,29148],{},"    text\n",[407,29150,29151],{"class":409,"line":1483},[407,29152,8870],{},[407,29154,29155],{"class":409,"line":2139},[407,29156,29157],{},"verify_zip::verify_zip(&zip_path, &sig_text, ...)?;\n",[11,29159,29160],{},"代码本意是支持 resume（同一轮 OTA 中途退出后不重复下载），但\"存在就复用\"太脆弱。",[11,29162,29163],{},"触发链路简述：前一轮 OTA（1.0.0）中断 → minisig 残留 → 当前 OTA（1.0.2）看到文件存在 → 读取老签名 → 用 1.0.0 签名验 1.0.2 zip → SHA256 不匹配 → verify 失败 → worker 进程异常退出 → launcher 误认为可恢复 → 触发 RunChain → 同一条路径再次失败 → 死循环。",[11,29165,29166,29167,29170,29171,29174],{},"这次故障与之前的 FA-005~FA-007 形成递进关系。前面三篇处理的是 OTA ",[488,29168,29169],{},"流程级残留","（marker 文件格式、state.phase 卡死、worker 僵尸进程）；这一篇处理 ",[488,29172,29173],{},"artifact 级残留","——中间产物的生命周期跨越了多轮 OTA。",[26,29176,25231],{"id":25231},[11,29178,29179,29180,29183,29184,29186,29187,29190,29191,29194],{},"发现 stale minisig 的关键线索是",[488,29181,29182],{},"文件时间戳对比","。当 worker 反复失败时，查看 ",[15,29185,29047],{}," 的 phase 和 ",[15,29188,29189],{},"ota-events.jsonl"," 的最后一条事件：phase 卡在 ",[15,29192,29193],{},"verifying","，但 events 里没有 verify 失败的日志，说明 worker 在 verify 时崩溃了。",[11,29196,29197,29198,29200,29201,29203,29204,29207],{},"接下来检查状态目录：",[15,29199,29036],{}," 的时间戳是当前 OTA 启动后的（体积、hash 都对），但 ",[15,29202,19717],{}," 的时间戳远早三小时。minisig 在 verify 前才下载，时间相隔三小时就是上一轮 OTA 残留的文件。用磁盘上的旧 minisig 对当前 zip 进行 ",[15,29205,29206],{},"minisign -Vm"," 验证会看到 SHA256 mismatch，印证了根因。",[26,29209,18998],{"id":18998},[11,29211,29212],{},"修复分为两处，逻辑完全相同：",[243,29214,29215,29223],{},[126,29216,29217],{},[488,29218,29219,29222],{},[15,29220,29221],{},"ota_worker.rs::phase1_download_and_verify"," 第 240 行",[126,29224,29225],{},[488,29226,29227,29230],{},[15,29228,29229],{},"ota_main_chain.rs::run_full_apply_chain"," Stage B verify 段第 302 行",[11,29232,29233,29234,1244],{},"改动是",[488,29235,29236],{},"移除文件存在性判断，永远 fresh fetch",[399,29238,29240],{"className":27261,"code":29239,"language":27263,"meta":183,"style":183},"\u002F\u002F 改前：存在就读，不存在才下载\n\u002F\u002F let sig_text = if minisig_path.exists() { ... } else { ... };\n\n\u002F\u002F 改后：永远删除后重新下载\nlet minisig_path = state_base.join(\"download.zip.minisig\");\nlet _ = std::fs::remove_file(&minisig_path);  \u002F\u002F 强制删除旧文件\nlet sig_url = &manifest.download.signature.url;\nlet sig_text = reqwest::blocking::get(sig_url)?.text()?;\nstd::fs::write(&minisig_path, &sig_text)?;\n",[15,29241,29242,29247,29252,29256,29261,29265,29270,29275,29280],{"__ignoreMap":183},[407,29243,29244],{"class":409,"line":410},[407,29245,29246],{},"\u002F\u002F 改前：存在就读，不存在才下载\n",[407,29248,29249],{"class":409,"line":184},[407,29250,29251],{},"\u002F\u002F let sig_text = if minisig_path.exists() { ... } else { ... };\n",[407,29253,29254],{"class":409,"line":189},[407,29255,1827],{"emptyLinePlaceholder":202},[407,29257,29258],{"class":409,"line":452},[407,29259,29260],{},"\u002F\u002F 改后：永远删除后重新下载\n",[407,29262,29263],{"class":409,"line":458},[407,29264,29113],{},[407,29266,29267],{"class":409,"line":464},[407,29268,29269],{},"let _ = std::fs::remove_file(&minisig_path);  \u002F\u002F 强制删除旧文件\n",[407,29271,29272],{"class":409,"line":470},[407,29273,29274],{},"let sig_url = &manifest.download.signature.url;\n",[407,29276,29277],{"class":409,"line":480},[407,29278,29279],{},"let sig_text = reqwest::blocking::get(sig_url)?.text()?;\n",[407,29281,29282],{"class":409,"line":1477},[407,29283,29284],{},"std::fs::write(&minisig_path, &sig_text)?;\n",[11,29286,29287,29288,29291,29292,29295],{},"代价极小：minisig 约 400 字节，网络耗时几十毫秒，在整个 OTA 流程中可忽略。这个改动体现了核心原则——",[488,29289,29290],{},"中间文件的生命周期必须绑定到任务\u002F版本，而不是绑定到文件是否存在","。未来可以让文件名包含版本号（如 ",[15,29293,29294],{},"download-1.0.2.zip.minisig","），但这次为了最小变动只删除了文件存在性缓存。",[26,29297,29298],{"id":29298},"验证",[11,29300,29301],{},"实测在状态目录放置老版本 minisig，触发 1.0.2 OTA，worker 自动覆盖后 verify 通过，整条 chain 完成。修复后实测 1.0.2 OTA 全链路 22 秒完成（download 8s + verify 5s + ready→applied 2s + USB sync 5s + done），不再出现 retry loop。",[11,29303,29304,29306,29307,29310],{},[488,29305,23827],{},"：这是设计问题而非代码 bug。核心原则是",[488,29308,29309],{},"任何中间文件的有效性不能单靠存在判断，必须包含版本号或 hash 标识","。同理，chain 入口读取 minisig 和 download.zip 时都应该先校验与 manifest 的一致性，不只依赖后续的 verify_zip。",[26,29312,29313],{"id":29313},"系列位置",[11,29315,29316],{},"这是 OTA 死循环系列（FA-005~FA-008）的最后一环。前三篇处理流程级残留（marker、state.phase、worker 进程）；这一篇处理 artifact 级残留（中间文件生命周期）。症状相同（verify 失败 → 重试循环），但根因逐层深入。每一层修复都必要——只解决流程层而不解决 artifact 层，下次跨版本 OTA 中断还会踩坑。",[1267,29318,16447],{},{"title":183,"searchDepth":184,"depth":184,"links":29320},[29321,29322,29323,29324,29325],{"id":29078,"depth":184,"text":29079},{"id":25231,"depth":184,"text":25231},{"id":18998,"depth":184,"text":18998},{"id":29298,"depth":184,"text":29298},{"id":29313,"depth":184,"text":29313},"2026-05-11","状态目录里有个文件时间戳不对劲。download.zip.minisig 的修改时间是 20:19:35，而本次 OTA 启动于 23:25，相差三小时。验签应该是在 OTA 最后一步才做的事，为什么这个文件早了三小时？",{},"\u002F2026-05-11-fa-008-stale-minisig",{"title":29017,"description":29327},"FA-008","2026-05-11-FA-008-stale-minisig验签失败","跨版本 OTA 时复用了上一轮失败留下的旧签名文件，导致所有后续升级验签必然失败。问题根源在文件存在性判断替代了版本校验。",[15069,29335,12681,29336,28285],"文件缓存","签名验证","taq7T9Q_otx3dgdBoDl8gs7c3YFVd-tuHTjQdKJp5Mk",{"id":29339,"title":29340,"body":29341,"column":355,"date":30377,"description":29345,"extension":199,"hero_image":200,"meta":30378,"navigation":202,"path":30379,"seo":30380,"series_id":30211,"severity":200,"stem":30381,"summary":30382,"tags":30383,"__hash__":30385},"posts\u002F2026-05-10-FA-007-phase-Done假报成功.md","同一个弹窗，我修了三次",{"type":8,"value":29342,"toc":30361},[29343,29346,29349,29352,29356,29359,29474,29477,29512,29515,29522,29544,29547,29549,29559,29644,29647,29650,29688,29699,29731,29749,29760,29808,29811,29814,29847,29850,29853,29859,29932,29943,29950,30026,30029,30032,30037,30091,30096,30137,30140,30144,30148,30151,30227,30230,30241,30248,30251,30257,30260,30262,30265,30271,30278,30281,30284,30287,30303,30306,30309,30326,30329,30332,30335,30349,30352,30355,30358],[11,29344,29345],{},"同一个弹窗，我已经修过三次了。",[11,29347,29348],{},"0.14.x 到 1.0.0，双机测试中看到的症状是一个反复循环的升级 modal：\"升级到 1.0.0\"，点更新 panel 关闭，然后立刻被拉起，modal 又来。每 15~20 秒一个 \"OTA applied to 1.0.0\" 的成功记录堆进 events log，但 cache 版本从来没动过，还是 0.14.18。",[11,29350,29351],{},"前面 FA-005 和 FA-006 各修了一处。FA-005 是 admin 端的版本优先级，FA-006 是 panel 进程的 onFinished 回调。两处都修对了，dead loop 依然死循环，说明不是单一根因。",[26,29353,29355],{"id":29354},"现象三个不一致的版本号","现象：三个不一致的版本号",[11,29357,29358],{},"state.json（位于 launcher 工作目录）里写着三个版本号：",[399,29360,29362],{"className":3591,"code":29361,"language":3593,"meta":183,"style":183},"{\n  \"phase\": \"done\",\n  \"version\": \"0.14.15\",\n  \"previous_version\": \"0.14.14\",\n  \"local_apply\": {\n    \"applied_version\": \"0.14.15\",\n    \"completed_dirs\": [\"version.json\", \"WakouPanel.exe\"]\n  },\n  \"signed_manifest\": { \"version\": \"1.0.0\", \"force_update\": true }\n}\n",[15,29363,29364,29368,29380,29391,29403,29411,29422,29439,29444,29470],{"__ignoreMap":183},[407,29365,29366],{"class":409,"line":410},[407,29367,421],{"class":413},[407,29369,29370,29373,29375,29378],{"class":409,"line":184},[407,29371,29372],{"class":476},"  \"phase\"",[407,29374,3607],{"class":413},[407,29376,29377],{"class":429},"\"done\"",[407,29379,3296],{"class":413},[407,29381,29382,29384,29386,29389],{"class":409,"line":189},[407,29383,27739],{"class":476},[407,29385,3607],{"class":413},[407,29387,29388],{"class":429},"\"0.14.15\"",[407,29390,3296],{"class":413},[407,29392,29393,29396,29398,29401],{"class":409,"line":452},[407,29394,29395],{"class":476},"  \"previous_version\"",[407,29397,3607],{"class":413},[407,29399,29400],{"class":429},"\"0.14.14\"",[407,29402,3296],{"class":413},[407,29404,29405,29408],{"class":409,"line":458},[407,29406,29407],{"class":476},"  \"local_apply\"",[407,29409,29410],{"class":413},": {\n",[407,29412,29413,29416,29418,29420],{"class":409,"line":464},[407,29414,29415],{"class":476},"    \"applied_version\"",[407,29417,3607],{"class":413},[407,29419,29388],{"class":429},[407,29421,3296],{"class":413},[407,29423,29424,29427,29429,29432,29434,29437],{"class":409,"line":470},[407,29425,29426],{"class":476},"    \"completed_dirs\"",[407,29428,8767],{"class":413},[407,29430,29431],{"class":429},"\"version.json\"",[407,29433,433],{"class":413},[407,29435,29436],{"class":429},"\"WakouPanel.exe\"",[407,29438,20631],{"class":413},[407,29440,29441],{"class":409,"line":480},[407,29442,29443],{"class":413},"  },\n",[407,29445,29446,29449,29451,29454,29456,29459,29461,29464,29466,29468],{"class":409,"line":1477},[407,29447,29448],{"class":476},"  \"signed_manifest\"",[407,29450,3631],{"class":413},[407,29452,29453],{"class":476},"\"version\"",[407,29455,3607],{"class":413},[407,29457,29458],{"class":429},"\"1.0.0\"",[407,29460,433],{"class":413},[407,29462,29463],{"class":476},"\"force_update\"",[407,29465,3607],{"class":413},[407,29467,28649],{"class":476},[407,29469,3641],{"class":413},[407,29471,29472],{"class":409,"line":1483},[407,29473,483],{"class":413},[11,29475,29476],{},"state.json 中记录了三个不同的版本号：",[123,29478,29479,29485,29491,29498,29504,29509],{},[126,29480,29481,29484],{},[15,29482,29483],{},"local_apply.applied_version = 0.14.15","（上一轮 OTA 标记为已安装的版本）",[126,29486,29487,29490],{},[15,29488,29489],{},"signed_manifest.version = 1.0.0","（本轮要安装的目标版本）",[126,29492,29493,29494,29497],{},"cache 实际的 ",[15,29495,29496],{},"version.json = 0.14.18","（当前运行的实际版本，通过热部署写过）",[126,29499,29500,29503],{},[15,29501,29502],{},"applied_version = 0.14.15","（上一轮 OTA 标记装好的）",[126,29505,29506,29508],{},[15,29507,29489],{},"（这一轮要装的目标）",[126,29510,29511],{},"cache 实际版本 = 0.14.18（热部署后的真实版本）",[11,29513,29514],{},"三个数不一样。phase 显示 done，应该表示\"安装完成\"，但完成状态对应 0.14.15，既不是当前版本 0.14.18，也不是目标版本 1.0.0。",[11,29516,29517,29518,29521],{},"events log 里是这样的（路径 ",[15,29519,29520],{},"%LOCALAPPDATA%\\Microsoft\\WakouAI\\Update\\\u003Cserial-hash>\\ota-events.jsonl","）：",[399,29523,29527],{"className":29524,"code":29525,"language":29526,"meta":183,"style":183},"language-jsonl shiki shiki-themes github-light github-dark","{\"ts\":\"...14:55:10\",\"source\":\"restart_loop\",\"outcome\":\"succeeded\",\"message\":\"OTA applied to 1.0.0\",\"version_target\":\"1.0.0\"}\n{\"ts\":\"...14:55:30\",\"source\":\"restart_loop\",\"outcome\":\"succeeded\",\"message\":\"OTA applied to 1.0.0\",\"version_target\":\"1.0.0\"}\n{\"ts\":\"...14:55:45\",\"source\":\"restart_loop\",\"outcome\":\"succeeded\",\"message\":\"OTA applied to 1.0.0\",\"version_target\":\"1.0.0\"}\n","jsonl",[15,29528,29529,29534,29539],{"__ignoreMap":183},[407,29530,29531],{"class":409,"line":410},[407,29532,29533],{},"{\"ts\":\"...14:55:10\",\"source\":\"restart_loop\",\"outcome\":\"succeeded\",\"message\":\"OTA applied to 1.0.0\",\"version_target\":\"1.0.0\"}\n",[407,29535,29536],{"class":409,"line":184},[407,29537,29538],{},"{\"ts\":\"...14:55:30\",\"source\":\"restart_loop\",\"outcome\":\"succeeded\",\"message\":\"OTA applied to 1.0.0\",\"version_target\":\"1.0.0\"}\n",[407,29540,29541],{"class":409,"line":189},[407,29542,29543],{},"{\"ts\":\"...14:55:45\",\"source\":\"restart_loop\",\"outcome\":\"succeeded\",\"message\":\"OTA applied to 1.0.0\",\"version_target\":\"1.0.0\"}\n",[11,29545,29546],{},"35 秒三次\"成功\"，一条路径，同一目标版本。正常 OTA 应该装一遍就停，重复成功是链路在假报。",[26,29548,27913],{"id":27913},[11,29550,29551,29552,26261,29555,29558],{},"代码在 ",[15,29553,29554],{},"launcher\u002Fsrc\u002Fota_main_chain.rs",[15,29556,29557],{},"run_full_apply_chain"," 函数（550+ 行）。这个函数按顺序执行 Download、Verify、Apply、Finalization 四个 stage。每个 stage 前都有 phase 条件判断：",[399,29560,29562],{"className":27261,"code":29561,"language":27263,"meta":183,"style":183},"\u002F\u002F Stage A: Download\nif matches!(state.phase, Phase::Idle | Phase::Downloading) { \n    \u002F\u002F 执行下载\n    state.phase = Phase::Verifying;\n}\n\n\u002F\u002F Stage B: Verify + Extract\nif matches!(state.phase, Phase::Downloading | Phase::Verifying) { \n    \u002F\u002F 执行验证和解包\n    state.phase = Phase::ReadyToApply;\n}\n\n\u002F\u002F Stage C: Apply\nif matches!(state.phase, Phase::ReadyToApply | Phase::LocalApplying) { \n    \u002F\u002F 执行部署\n    state.phase = Phase::Done;\n}\n",[15,29563,29564,29569,29574,29579,29584,29588,29592,29597,29602,29607,29612,29616,29620,29625,29630,29635,29640],{"__ignoreMap":183},[407,29565,29566],{"class":409,"line":410},[407,29567,29568],{},"\u002F\u002F Stage A: Download\n",[407,29570,29571],{"class":409,"line":184},[407,29572,29573],{},"if matches!(state.phase, Phase::Idle | Phase::Downloading) { \n",[407,29575,29576],{"class":409,"line":189},[407,29577,29578],{},"    \u002F\u002F 执行下载\n",[407,29580,29581],{"class":409,"line":452},[407,29582,29583],{},"    state.phase = Phase::Verifying;\n",[407,29585,29586],{"class":409,"line":458},[407,29587,483],{},[407,29589,29590],{"class":409,"line":464},[407,29591,1827],{"emptyLinePlaceholder":202},[407,29593,29594],{"class":409,"line":470},[407,29595,29596],{},"\u002F\u002F Stage B: Verify + Extract\n",[407,29598,29599],{"class":409,"line":480},[407,29600,29601],{},"if matches!(state.phase, Phase::Downloading | Phase::Verifying) { \n",[407,29603,29604],{"class":409,"line":1477},[407,29605,29606],{},"    \u002F\u002F 执行验证和解包\n",[407,29608,29609],{"class":409,"line":1483},[407,29610,29611],{},"    state.phase = Phase::ReadyToApply;\n",[407,29613,29614],{"class":409,"line":2139},[407,29615,483],{},[407,29617,29618],{"class":409,"line":2180},[407,29619,1827],{"emptyLinePlaceholder":202},[407,29621,29622],{"class":409,"line":7546},[407,29623,29624],{},"\u002F\u002F Stage C: Apply\n",[407,29626,29627],{"class":409,"line":7564},[407,29628,29629],{},"if matches!(state.phase, Phase::ReadyToApply | Phase::LocalApplying) { \n",[407,29631,29632],{"class":409,"line":17267},[407,29633,29634],{},"    \u002F\u002F 执行部署\n",[407,29636,29637],{"class":409,"line":17279},[407,29638,29639],{},"    state.phase = Phase::Done;\n",[407,29641,29642],{"class":409,"line":18010},[407,29643,483],{},[11,29645,29646],{},"这个设计支持 resume：网络中断或进程崩溃后下次启动可以从 phase 决定从哪一步恢复。合理的思路。",[11,29648,29649],{},"问题出在 caller 对 Done 状态的处理：",[399,29651,29653],{"className":27261,"code":29652,"language":27263,"meta":183,"style":183},"run_full_apply_chain(state_base, &manifest, ...)?;\nlet final_state = state_machine::load(state_base)?;\nif final_state.phase == Phase::Done {\n    Ok(RunOutcome::UpdatedToVersion(manifest.version))   \u002F\u002F ← 假成功，只看 phase\n} else {\n    Ok(RunOutcome::Resumed(final_state.phase))\n}\n",[15,29654,29655,29660,29665,29670,29675,29679,29684],{"__ignoreMap":183},[407,29656,29657],{"class":409,"line":410},[407,29658,29659],{},"run_full_apply_chain(state_base, &manifest, ...)?;\n",[407,29661,29662],{"class":409,"line":184},[407,29663,29664],{},"let final_state = state_machine::load(state_base)?;\n",[407,29666,29667],{"class":409,"line":189},[407,29668,29669],{},"if final_state.phase == Phase::Done {\n",[407,29671,29672],{"class":409,"line":452},[407,29673,29674],{},"    Ok(RunOutcome::UpdatedToVersion(manifest.version))   \u002F\u002F ← 假成功，只看 phase\n",[407,29676,29677],{"class":409,"line":458},[407,29678,29128],{},[407,29680,29681],{"class":409,"line":464},[407,29682,29683],{},"    Ok(RunOutcome::Resumed(final_state.phase))\n",[407,29685,29686],{"class":409,"line":470},[407,29687,483],{},[11,29689,29690,29691,29694,29695,29698],{},"当进入 ",[15,29692,29693],{},"Phase::Done"," 后，链路到达末尾，清掉标记文件就返回。关键问题在于调用方 ",[15,29696,29697],{},"run_update_only_chain_inner"," 对 Done 状态的处理：",[399,29700,29701],{"className":27261,"code":29652,"language":27263,"meta":183,"style":183},[15,29702,29703,29707,29711,29715,29719,29723,29727],{"__ignoreMap":183},[407,29704,29705],{"class":409,"line":410},[407,29706,29659],{},[407,29708,29709],{"class":409,"line":184},[407,29710,29664],{},[407,29712,29713],{"class":409,"line":189},[407,29714,29669],{},[407,29716,29717],{"class":409,"line":452},[407,29718,29674],{},[407,29720,29721],{"class":409,"line":458},[407,29722,29128],{},[407,29724,29725],{"class":409,"line":464},[407,29726,29683],{},[407,29728,29729],{"class":409,"line":470},[407,29730,483],{},[11,29732,29733,29734,29737,29738,29741,29742,29745,29746,781],{},"这段代码的逻辑是：如果 ",[15,29735,29736],{},"phase==Done","，就返回 ",[15,29739,29740],{},"UpdatedToVersion","（表示\"成功升级到目标版本\"）。它",[488,29743,29744],{},"完全依赖 phase 字段","来判断成功，",[488,29747,29748],{},"不校验实际产物",[11,29750,29751,29752,29755,29756,29759],{},"现场状态是 ",[15,29753,29754],{},"phase=Done"," 但 ",[15,29757,29758],{},"applied_version=0.14.15 != target=1.0.0","。当 OTA chain 重新运行时：",[243,29761,29762,29768,29778,29786,29794,29797],{},[126,29763,29764,29765,29767],{},"进入 ",[15,29766,29557],{},"，加载 state，phase 已经是 Done",[126,29769,29770,29771,29774,29775],{},"Download stage 检查 ",[15,29772,29773],{},"if matches!(phase, Idle | Downloading)"," → ",[488,29776,29777],{},"不匹配，跳过",[126,29779,29780,29781,29774,29784],{},"Verify stage 检查 ",[15,29782,29783],{},"if matches!(phase, Downloading | Verifying)",[488,29785,29777],{},[126,29787,29788,29789,29774,29792],{},"Apply stage 检查 ",[15,29790,29791],{},"if matches!(phase, ReadyToApply | LocalApplying)",[488,29793,29777],{},[126,29795,29796],{},"所有 stage 都被跳过，函数走到末尾清 marker 返回",[126,29798,29799,29800,29774,29803],{},"caller 看到 ",[15,29801,29802],{},"final_state.phase == Done",[488,29804,7236,29805],{},[15,29806,29807],{},"UpdatedToVersion(1.0.0)",[11,29809,29810],{},"没有任何实际的 download、verify、apply 操作发生。cache 仍是 0.14.18。applied_version 仍是 0.14.15。但链路声称\"已经升级到 1.0.0\"。",[11,29812,29813],{},"这个循环完全在 OTA chain 内部转圈：",[243,29815,29816,29822,29825,29832,29835,29838,29841,29844],{},[126,29817,29818,29819,29821],{},"restart-loop 收到 ",[15,29820,29807],{},"，记进 events log \"OTA applied to 1.0.0\"，认为任务完成",[126,29823,29824],{},"restart-loop 重启 panel",[126,29826,29827,29828,29831],{},"panel 启动执行 ",[15,29829,29830],{},"ota_check_update","，通过 wrapper 询问后端",[126,29833,29834],{},"后端告诉 panel：ga 版本是 1.0.0",[126,29836,29837],{},"panel 本地 cache 还是 0.14.18（因为 apply 根本没执行），对比 1.0.0 ≠ 0.14.18",[126,29839,29840],{},"panel 决策 ForceUpdate，弹出\"升级到 1.0.0\"modal",[126,29842,29843],{},"用户看到 modal，再点一次\"立即更新\"",[126,29845,29846],{},"回到步骤 1，循环",[11,29848,29849],{},"launcher 的 boot 自愈路径（FA-006 修的 G15\u002FG16）也救不了，因为每一轮都是从 phase=Done 这个\"合法\"状态出发，boot 根本不会被触发。",[26,29851,29852],{"id":29852},"修复与验证",[11,29854,29855,29856,29858],{},"chain 入口新增版本校验（",[15,29857,29557],{}," 开头）：",[399,29860,29862],{"className":27261,"code":29861,"language":27263,"meta":183,"style":183},"let stale_done = state.phase == Phase::Done\n    && state\n        .local_apply\n        .as_ref()\n        .and_then(|la| la.applied_version.as_deref())\n        .map(|applied| applied != manifest.version)\n        .unwrap_or(false);\nif stale_done {\n    eprintln!(\n        \"[ota_main_chain] G18: state.phase=Done but applied_version={} != target={}; resetting to Idle\",\n        applied, manifest.version\n    );\n    state_machine::transition(state_base, &mut state, Phase::Idle)?;\n}\n",[15,29863,29864,29869,29874,29879,29884,29889,29894,29899,29904,29909,29914,29919,29923,29928],{"__ignoreMap":183},[407,29865,29866],{"class":409,"line":410},[407,29867,29868],{},"let stale_done = state.phase == Phase::Done\n",[407,29870,29871],{"class":409,"line":184},[407,29872,29873],{},"    && state\n",[407,29875,29876],{"class":409,"line":189},[407,29877,29878],{},"        .local_apply\n",[407,29880,29881],{"class":409,"line":452},[407,29882,29883],{},"        .as_ref()\n",[407,29885,29886],{"class":409,"line":458},[407,29887,29888],{},"        .and_then(|la| la.applied_version.as_deref())\n",[407,29890,29891],{"class":409,"line":464},[407,29892,29893],{},"        .map(|applied| applied != manifest.version)\n",[407,29895,29896],{"class":409,"line":470},[407,29897,29898],{},"        .unwrap_or(false);\n",[407,29900,29901],{"class":409,"line":480},[407,29902,29903],{},"if stale_done {\n",[407,29905,29906],{"class":409,"line":1477},[407,29907,29908],{},"    eprintln!(\n",[407,29910,29911],{"class":409,"line":1483},[407,29912,29913],{},"        \"[ota_main_chain] G18: state.phase=Done but applied_version={} != target={}; resetting to Idle\",\n",[407,29915,29916],{"class":409,"line":2139},[407,29917,29918],{},"        applied, manifest.version\n",[407,29920,29921],{"class":409,"line":2180},[407,29922,7527],{},[407,29924,29925],{"class":409,"line":7546},[407,29926,29927],{},"    state_machine::transition(state_base, &mut state, Phase::Idle)?;\n",[407,29929,29930],{"class":409,"line":7564},[407,29931,483],{},[11,29933,29934,29935,29938,29939,29942],{},"如果 phase 是 Done 但 applied_version 与目标版本不一致，强制调 ",[15,29936,29937],{},"transition(_, _, Phase::Idle)","。这个转移函数会自动清空 download、local_apply、usb_sync、signed_manifest 这些子结构（已有单测 ",[15,29940,29941],{},"transition_done_to_idle_clears_substructures"," 验证），然后 chain 后续所有 stage 都能从干净的 Idle 状态开始正常执行。",[11,29944,29945,29946,29949],{},"同时在 ",[15,29947,29948],{},"main.rs::boot_self_heal_ota_state"," 里也加这条检查（这个函数在 boot 启动时 Stage 5 和 Stage 6 之间调用）：",[399,29951,29953],{"className":27261,"code":29952,"language":27263,"meta":183,"style":183},"let cache_ver = version_file::read(cache_root.join(\"version.json\")).unwrap_or_default();\nlet stale_done = state.phase == Phase::Done\n    && state.local_apply.as_ref()\n        .and_then(|la| la.applied_version.as_deref())\n        .map(|av| !cache_ver.is_empty() && av != cache_ver.as_str())\n        .unwrap_or(false);\nif stuck || stale_done {\n    state.phase = Phase::Idle;\n    state.cancellable = true;\n    state.download = None;\n    state.local_apply = None;\n    state.usb_sync = None;\n    state.signed_manifest = None;\n    state_machine::save(...)?;\n}\n",[15,29954,29955,29960,29964,29969,29973,29978,29982,29987,29992,29997,30002,30007,30012,30017,30022],{"__ignoreMap":183},[407,29956,29957],{"class":409,"line":410},[407,29958,29959],{},"let cache_ver = version_file::read(cache_root.join(\"version.json\")).unwrap_or_default();\n",[407,29961,29962],{"class":409,"line":184},[407,29963,29868],{},[407,29965,29966],{"class":409,"line":189},[407,29967,29968],{},"    && state.local_apply.as_ref()\n",[407,29970,29971],{"class":409,"line":452},[407,29972,29888],{},[407,29974,29975],{"class":409,"line":458},[407,29976,29977],{},"        .map(|av| !cache_ver.is_empty() && av != cache_ver.as_str())\n",[407,29979,29980],{"class":409,"line":464},[407,29981,29898],{},[407,29983,29984],{"class":409,"line":470},[407,29985,29986],{},"if stuck || stale_done {\n",[407,29988,29989],{"class":409,"line":480},[407,29990,29991],{},"    state.phase = Phase::Idle;\n",[407,29993,29994],{"class":409,"line":1477},[407,29995,29996],{},"    state.cancellable = true;\n",[407,29998,29999],{"class":409,"line":1483},[407,30000,30001],{},"    state.download = None;\n",[407,30003,30004],{"class":409,"line":2139},[407,30005,30006],{},"    state.local_apply = None;\n",[407,30008,30009],{"class":409,"line":2180},[407,30010,30011],{},"    state.usb_sync = None;\n",[407,30013,30014],{"class":409,"line":7546},[407,30015,30016],{},"    state.signed_manifest = None;\n",[407,30018,30019],{"class":409,"line":7564},[407,30020,30021],{},"    state_machine::save(...)?;\n",[407,30023,30024],{"class":409,"line":17267},[407,30025,483],{},[11,30027,30028],{},"这样即使没经过 chain 直接启动系统，boot 早期也能检测出\"applied_version 跟 cache 不一致\"的脏 Done 状态并重置。",[11,30030,30031],{},"复测设计了两条路径，对应 G18（chain 入口）和 G16（boot 启动）：",[11,30033,30034],{},[488,30035,30036],{},"路径 A：通过 OTA chain 验证 G18",[243,30038,30039,30045,30048,30051,30057,30063,30070,30073,30076,30079,30082,30088],{},[126,30040,30041,30042],{},"客户机上手动编辑 state.json：设置 ",[15,30043,30044],{},"phase=done + applied_version=0.14.15",[126,30046,30047],{},"cache 保持 0.14.18（这时应该已经是通过热部署或之前的 OTA 装的）",[126,30049,30050],{},"启动正常的 OTA 流程（比如通过 admin 端推送新版本 1.0.0）",[126,30052,30053,30054,30056],{},"chain 执行到 ",[15,30055,29557],{}," 开头，触发 G18 检查",[126,30058,30059,30060],{},"代码检测 ",[15,30061,30062],{},"state.phase==Done && applied_version=0.14.15 != manifest.version=1.0.0",[126,30064,30065,30066,30069],{},"G18 强制调 ",[15,30067,30068],{},"transition(..., Phase::Idle)"," 清掉残留状态",[126,30071,30072],{},"后续 stage 从干净的 Idle 开始执行",[126,30074,30075],{},"Download → Verify → Apply 三个阶段真正跑完",[126,30077,30078],{},"cache version.json 真的变成 1.0.0",[126,30080,30081],{},"local_apply.applied_version 更新为 1.0.0",[126,30083,30084,30085],{},"panel 重启，ota_check 对比 cache(1.0.0) == ga(1.0.0) → NoUpdate → ",[488,30086,30087],{},"不弹 modal",[126,30089,30090],{},"events log 中这一轮只有一条 \"OTA applied to 1.0.0\" 成功记录，没有重复",[11,30092,30093],{},[488,30094,30095],{},"路径 B：通过 boot 启动验证 G16 扩展",[243,30097,30098,30101,30104,30107,30110,30113,30119,30122,30125,30128,30131,30134],{},[126,30099,30100],{},"手动编写 state.json 再次制造脏状态：phase=done，applied_version=0.14.15",[126,30102,30103],{},"cache 故意设为 0.14.18（模拟热部署后的状态）",[126,30105,30106],{},"关闭 panel，让 launcher 完全退出",[126,30108,30109],{},"重新启动系统（VBS → launcher → Stage 1-5 初始化）",[126,30111,30112],{},"在 Stage 5 和 Stage 6 之间，boot_self_heal_ota_state 被调用",[126,30114,30115,30116],{},"G16 检测到 stale_done：",[15,30117,30118],{},"phase==Done && applied_version=0.14.15 != cache_version=0.14.18",[126,30120,30121],{},"G16 强制清掉这个脏状态：设置 phase 为 Idle，清空所有子字段",[126,30123,30124],{},"进入 Stage 6 的 OTA check",[126,30126,30127],{},"read_app_version 读到 cache 的 0.14.18，与 ga 版本 1.0.0 比对不一致",[126,30129,30130],{},"ota_check 决策 ForceUpdate(1.0.0)，写 marker，panel 弹 modal",[126,30132,30133],{},"用户点\"立即更新\"，走正常的 OTA 流程，最终装成 1.0.0",[126,30135,30136],{},"再次重启后不再弹 modal",[11,30138,30139],{},"两条路径都验证了修复的有效性：G18 拦截了 chain 内的假成功，G16 则保证了即使跳过 chain 直接启动系统，脏状态也会被清掉。",[26,30141,30143],{"id":30142},"为什么修了这么多次这一次改了什么","为什么修了这么多次，这一次改了什么",[31,30145,30147],{"id":30146},"同一症状三处已知根因","同一症状，三处已知根因",[11,30149,30150],{},"回顾整个 OTA 死循环系列，这是一个典型的\"多根因叠加\"的故障：",[1708,30152,30153,30171],{},[1711,30154,30155],{},[1714,30156,30157,30160,30163,30165,30168],{},[1717,30158,30159],{},"编号",[1717,30161,30162],{},"故障名",[1717,30164,27321],{},[1717,30166,30167],{},"修复位置",[1717,30169,30170],{},"防护作用",[1730,30172,30173,30190,30207],{},[1714,30174,30175,30178,30181,30184,30187],{},[1735,30176,30177],{},"FA-005",[1735,30179,30180],{},"版本号优先级",[1735,30182,30183],{},"admin 端推送的 ga 版本没有考虑热部署导致 cache 版本提前的情况",[1735,30185,30186],{},"admin wrapper 的版本比对逻辑",[1735,30188,30189],{},"防止\"本地已新，后端说旧\"导致的错误降级",[1714,30191,30192,30195,30198,30201,30204],{},[1735,30193,30194],{},"FA-006",[1735,30196,30197],{},"panel 不退出",[1735,30199,30200],{},"panel 在 onFinished 回调用 reload() 而非 exit()，launcher worker 进程继续运行导致重复 spawn",[1735,30202,30203],{},"panel 端的 onFinished 回调",[1735,30205,30206],{},"防止多个 worker 进程叠加争夺 state.lock",[1714,30208,30209,30212,30215,30218,30224],{},[1735,30210,30211],{},"FA-007",[1735,30213,30214],{},"本篇，phase 假终态",[1735,30216,30217],{},"OTA chain 把终态只用 phase=Done 表示，不校验 applied_version 是否匹配目标版本",[1735,30219,30220,30221,30223],{},"chain 入口 ",[15,30222,29557],{}," 的版本校验",[1735,30225,30226],{},"防止残留的\"上一轮成功\"状态在目标版本不同时假报成功",[11,30228,30229],{},"单独看每一个根因的影响：",[123,30231,30232,30235,30238],{},[126,30233,30234],{},"FA-005 单独发生：panel 看到\"版本已是 ga，不用升\"，用户见不到 modal，系统无害",[126,30236,30237],{},"FA-006 单独发生：worker 进程可能叠加，但第一个 worker 拿到 state.lock，后续的失败重试，最终还能装上新版本，不会死循环",[126,30239,30240],{},"FA-007 单独发生：如果每次启动 state 都是干净的 Idle，这个 bug 也不会触发，系统照常运行",[11,30242,30243,30244,30247],{},"但当这几个条件",[488,30245,30246],{},"同时出现","时，它们形成了一个完整的死循环链。FA-006 修完后，0.14.18 的 OTA 看起来可以跑通。然而升级到 1.0.0 时，由于某些原因（可能是 0.14.17 到 1.0.0 的架构变更），state.json 中残留了一个\"applied_version=0.14.15\"的 Done 状态。这时 FA-007 就被触发了：chain 看到 Done，所有 stage 跳过，假报成功，触发 FA-005 的对比逻辑，最终弹 modal，形成死循环。",[31,30249,30250],{"id":30250},"这一次改了什么",[11,30252,30253,30254],{},"FA-007 要修的是：",[488,30255,30256],{},"任何时候进入 chain，都必须先校验 applied_version 与目标版本是否匹配。如果不匹配，不能相信之前的 Done 状态，必须强制重置到 Idle 重新执行。",[11,30258,30259],{},"这个修复摧毁了死循环链中的\"假成功报告\"这一环。即使状态机上还有其他缺陷（如 FA-005，或尚未发现的残留问题），由于 chain 不再假报成功，它们也更难找到触发的机会。修完这一层也不能断言链路上不存在第四个根因——只能说已知的三个都各自被堵上了。每一处修复都切断了死循环的一条传播路径，修完 FA-005 + FA-006 + FA-007 之后，形成完整死循环的条件才真正被破坏。",[26,30261,23827],{"id":23827},[31,30263,30264],{"id":30264},"代码层面",[11,30266,30267,30268,30270],{},"单元测试 ",[15,30269,29941],{}," 已覆盖 Done → Idle 的转移路径，验证状态重置后所有相关字段都被清空。",[11,30272,30273,30274,30277],{},"schema 兼容性：applied_version 字段在 v4 版本的 review 阶段引入，旧版本 launcher 写的 state.json 中可能缺失这个字段。G18 的代码使用了 Rust 的 ",[15,30275,30276],{},"and_then().map().unwrap_or()"," 链，确保字段缺失时返回默认值，不触发假检测。",[11,30279,30280],{},"worker 路径兼容：ota_worker 不经过 run_full_apply_chain，而是走独立的 phase1_download_and_verify 函数。但因为 G16 在 boot 阶段早期就会重置脏状态，worker 进来时的 state 已经干净，无需额外改动。",[31,30282,30283],{"id":30283},"运维观察",[11,30285,30286],{},"events log 是诊断 FA-007 的重要信号。具体特征：",[123,30288,30289,30294,30297,30300],{},[126,30290,2977,30291,30293],{},[15,30292,16153],{},"（如 restart_loop）",[126,30295,30296],{},"在短时间内（\u003C 1 分钟）",[126,30298,30299],{},"多次（≥ 3 次）出现 \"outcome\":\"succeeded\"",[126,30301,30302],{},"但实际 cache\u002Fversion.json 没有变化",[11,30304,30305],{},"这个组合高度可疑，可以作为自动化巡检的告警触发条件。正常的 OTA 应该一次装完（除非是主动的 resume 路径），重复成功通常意味着链路在假报。",[31,30307,30308],{"id":30308},"兼容性总结",[123,30310,30311,30314,30317,30320,30323],{},[126,30312,30313],{},"旧版本 launcher 的 state.json 兼容：缺失的 applied_version 字段不会误触 G18 检测",[126,30315,30316],{},"G15\u002FG16\u002FG17（来自 FA-006 的 launcher 防御）全部保留，不冲突",[126,30318,30319],{},"panel 端无变更，G13\u002FG14（FA-006 修的 onFinished 退出逻辑）仍在",[126,30321,30322],{},"worker 进程的 phase1_download_and_verify 逻辑不变，不经过 run_full_apply_chain，但因为 G16 在 boot 阶段早期就会重置脏状态，worker 进来时的 state 已经干净，无需额外改动",[126,30324,30325],{},"构建脚本、CI 流程无需改动",[26,30327,30328],{"id":30328},"关键设计原则",[11,30330,30331],{},"状态机的终态判定不能只依赖枚举值，必须验证实际产物。没有这层校验，\"上一轮残留\"的终态会让整个执行链路跳过，转化成假成功报告，最终驱动上层重复触发同一个已经\"完成\"的操作。",[11,30333,30334],{},"对于 phase-conditional 执行链：",[243,30336,30337,30340,30343,30346],{},[126,30338,30339],{},"链的入口做合法性检查：当前 phase 是否与执行目标兼容",[126,30341,30342],{},"残留终态（如 phase=Done）不能盲目信任，必须校验是否指向正确版本",[126,30344,30345],{},"校验失败强制重置，不假设\"上一轮可能对的\"",[126,30347,30348],{},"任何状态转移都应该有明确的幂等性保证——重复执行同一状态转移应该得到相同结果",[11,30350,30351],{},"后续扩展 OTA 协议时，新增 phase 和 RunOutcome 都必须纳入 chain entrypoint 的合法性检查框架内。这是分布式系统设计中的普遍问题，不仅限于 OTA 场景。",[26,30353,30354],{"id":30354},"总结",[11,30356,30357],{},"状态机的终态不能只由阶段枚举表示，必须同时校验实际产物。没有这层校验，\"上一轮残留\"的终态会让整个执行链路跳过，转化成假成功报告，最终驱动上层重复触发同一个已经\"完成\"的操作。这也说明了为什么同一个死循环症状需要改多处——每一处修复都在阻止不同的\"跨越\"路径达到假终态，改完 FA-005 + FA-006 + FA-007 之后才能真正阻断死循环的形成。",[1267,30359,30360],{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}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":183,"searchDepth":184,"depth":184,"links":30362},[30363,30364,30365,30366,30370,30375,30376],{"id":29354,"depth":184,"text":29355},{"id":27913,"depth":184,"text":27913},{"id":29852,"depth":184,"text":29852},{"id":30142,"depth":184,"text":30143,"children":30367},[30368,30369],{"id":30146,"depth":189,"text":30147},{"id":30250,"depth":189,"text":30250},{"id":23827,"depth":184,"text":23827,"children":30371},[30372,30373,30374],{"id":30264,"depth":189,"text":30264},{"id":30283,"depth":189,"text":30283},{"id":30308,"depth":189,"text":30308},{"id":30328,"depth":184,"text":30328},{"id":30354,"depth":184,"text":30354},"2026-05-10",{},"\u002F2026-05-10-fa-007-phase-done",{"title":29340,"description":29345},"2026-05-10-FA-007-phase-Done假报成功","状态机的终态只由 phase 表示，不校验实际产物导致假报成功。修复是入口处检测 applied_version 与目标版本不一致时强制重置。",[15069,8701,20302,12681,30384,15072],"死循环","sovovJBp96cwFJqXF6aE7Ou9pUEpg-z1Fegj6dl8ABM",{"id":30387,"title":30388,"body":30389,"column":355,"date":31205,"description":183,"extension":199,"hero_image":200,"meta":31206,"navigation":202,"path":31207,"seo":31208,"series_id":30194,"severity":200,"stem":31209,"summary":31210,"tags":31211,"__hash__":31213},"posts\u002F2026-05-09-FA-006-panel不退出12个worker.md","12 个卡死的进程，全是用户点出来的",{"type":8,"value":30390,"toc":31196},[30391,30394,30401,30471,30474,30477,30480,30483,30493,30497,30511,30518,30528,30617,30627,30636,30639,30661,30664,30667,30670,30688,30698,30705,30717,30720,30723,30728,30744,30826,30835,30844,30849,30854,31068,31079,31085,31087,31090,31117,31120,31123,31132,31135,31140,31150,31156,31161,31183,31186,31193],[26,30392,30393],{"id":30393},"时间戳指控",[11,30395,30396,30397,30400],{},"构建机上发现 12 个卡死的 ",[15,30398,30399],{},"node.exe"," 进程。看一下 PID：",[1708,30402,30403,30416],{},[1711,30404,30405],{},[1714,30406,30407,30410,30413],{},[1717,30408,30409],{},"PID",[1717,30411,30412],{},"启动时间",[1717,30414,30415],{},"间隔",[1730,30417,30418,30429,30440,30451,30460],{},[1714,30419,30420,30423,30426],{},[1735,30421,30422],{},"17312",[1735,30424,30425],{},"21:30:53",[1735,30427,30428],{},"—",[1714,30430,30431,30434,30437],{},[1735,30432,30433],{},"18456",[1735,30435,30436],{},"21:31:01",[1735,30438,30439],{},"8s",[1714,30441,30442,30445,30448],{},[1735,30443,30444],{},"19340",[1735,30446,30447],{},"21:31:08",[1735,30449,30450],{},"7s",[1714,30452,30453,30455,30457],{},[1735,30454,7262],{},[1735,30456,7262],{},[1735,30458,30459],{},"1~3s",[1714,30461,30462,30465,30468],{},[1735,30463,30464],{},"27140",[1735,30466,30467],{},"21:33:11",[1735,30469,30470],{},"1s",[11,30472,30473],{},"12 个进程连续分布、间隔 1~3 秒。每一个都对应用户点一次\"立即更新\"。",[11,30475,30476],{},"用户机器 A 的 modal 卡在屏幕上显示\"升级成功，面板将自动重启\"。但 version.json 仍是 0.14.15，modal 本身还活着。三个信号同时出现（modal 活跃、版本号未变、UI 说成功）——按设计这不可能发生。如果真成功了，panel 该已退出。",[26,30478,30479],{"id":30479},"现象与诊断",[11,30481,30482],{},"0.14.13~0.14.16 版本的用户在弹窗中点\"立即更新\"后，看到\"升级成功，面板将自动重启\"的提示，但 panel 进程并未退出。launcher worker 被设计为必须等待 panel 完全退出才能进入 Phase 2（文件替换阶段）；由于 panel 苟活，worker 永远卡住。用户看弹窗几秒没反应，以为系统出问题，再点一次\"立即更新\"。又 spawn 了一个新的 worker。反复几次，队伍长到 12 个。",[11,30484,30485,30486,30488,30489,30492],{},"构建机上的现场证据非常直观：12 个名为 ",[15,30487,30399],{}," 的卡死进程，PID 连续分布在 17312",[26492,30490,30491],{},"27140 之间，启动时间从 21:30:53 到 21:33:11，时间间隔正好 1","3 秒。这些时间点与用户每次点击\"立即更新\"的间隔一致，说明每次点击都精确地 spawn 了一个新 worker。",[26,30494,30496],{"id":30495},"为什么-panel-不退出","为什么 panel 不退出",[11,30498,30499,30500,30503,30504,13067,30507,30510],{},"OTA 设计要求 panel 在 ",[15,30501,30502],{},"ready_to_apply"," 阶段调用 ",[15,30505,30506],{},"requestSelfExit()",[15,30508,30509],{},"app.exit(0)"," 杀死自己。launcher worker 等待这个退出，然后进入 Phase 2。",[11,30512,30513,30514,30517],{},"modal 说\"升级成功\"意味着下载没失败。问题是：",[488,30515,30516],{},"panel 为什么还活着","？",[11,30519,30520,30521,26261,30524,30527],{},"前端代码 ",[15,30522,30523],{},"ota-force-modal.js",[15,30525,30526],{},"onFinished"," 回调（第 204~222 行）：",[399,30529,30531],{"className":14691,"code":30530,"language":14693,"meta":183,"style":183},"ota.onFinished(payload => {\n  if (payload && payload.outcome === 'succeeded') {\n    msgEl.textContent = '升级成功，面板将自动重启';\n    setTimeout(() => window.location.reload(), 1200);\n  }\n  ...\n})\n",[15,30532,30533,30550,30569,30581,30604,30608,30612],{"__ignoreMap":183},[407,30534,30535,30538,30540,30542,30545,30548],{"class":409,"line":410},[407,30536,30537],{"class":413},"ota.",[407,30539,30526],{"class":554},[407,30541,586],{"class":413},[407,30543,30544],{"class":718},"payload",[407,30546,30547],{"class":417}," =>",[407,30549,523],{"class":413},[407,30551,30552,30554,30557,30559,30562,30564,30567],{"class":409,"line":184},[407,30553,3721],{"class":417},[407,30555,30556],{"class":413}," (payload ",[407,30558,5203],{"class":417},[407,30560,30561],{"class":413}," payload.outcome ",[407,30563,881],{"class":417},[407,30565,30566],{"class":429}," 'succeeded'",[407,30568,887],{"class":413},[407,30570,30571,30574,30576,30579],{"class":409,"line":189},[407,30572,30573],{"class":413},"    msgEl.textContent ",[407,30575,418],{"class":417},[407,30577,30578],{"class":429}," '升级成功，面板将自动重启'",[407,30580,918],{"class":413},[407,30582,30583,30586,30588,30590,30593,30596,30599,30602],{"class":409,"line":452},[407,30584,30585],{"class":554},"    setTimeout",[407,30587,5531],{"class":413},[407,30589,520],{"class":417},[407,30591,30592],{"class":413}," window.location.",[407,30594,30595],{"class":554},"reload",[407,30597,30598],{"class":413},"(), ",[407,30600,30601],{"class":476},"1200",[407,30603,662],{"class":413},[407,30605,30606],{"class":409,"line":458},[407,30607,563],{"class":413},[407,30609,30610],{"class":409,"line":464},[407,30611,27787],{"class":417},[407,30613,30614],{"class":409,"line":470},[407,30615,30616],{"class":413},"})\n",[11,30618,30619,30622,30623,30626],{},[15,30620,30621],{},"window.location.reload()"," 是页面刷新，",[488,30624,30625],{},"不是进程退出","。这是致命的意图偏差。前端代码作者可能认为\"重启面板\"等于\"刷新页面\"。但 OS 层面，刷新页面 ≠ 杀进程。",[11,30628,30629,30632,30633,30635],{},[15,30630,30631],{},"reload()"," 执行时，panel 进程活得好好的。modal 还在显示。state.json 的 phase 没改到 ",[15,30634,17342],{},"。launcher worker 永远卡在\"等待进程退出\"的系统调用里，永远进不了 Phase 2。",[11,30637,30638],{},"OTA 整体设计流程是：",[243,30640,30641,30651,30658],{},[126,30642,30643,30644,30503,30646,29774,30648,30650],{},"Panel 在 ",[15,30645,30502],{},[15,30647,30506],{},[15,30649,30509],{}," → panel 进程退出",[126,30652,30653,30654,30657],{},"Launcher worker 通过 ",[15,30655,30656],{},"OpenProcess wait"," 或类似机制检测到 panel 已退出 → 进入 Phase 2 安装阶段",[126,30659,30660],{},"Phase 2 完成后，spawn 新的 launcher → 新 launcher 启动新 panel",[11,30662,30663],{},"如果 step 1 没有真正退出，整个链路就断掉了。",[26,30665,30666],{"id":30666},"更深的问题",[11,30668,30669],{},"直接原因是前端用了错误的操作。但背后有更深的设计脆弱点。",[11,30671,30672,30674,30675,30677,30678,30681,30682,30684,30685,781],{},[15,30673,30526],{}," 被设计为异步事件回调，在 OTA 下载完成后由 Rust 后端触发。理想情况下，它应该到达刚刚完成下载的那个 panel 实例，告诉它\"去退出吧，新版本已经准备好了\"。但如果 panel 的 ",[15,30676,30502],{}," 阶段因为某些原因（Tauri 运行时被 ",[15,30679,30680],{},"prevent_exit"," 阻断、tokio 任务阻塞、异步调度延迟）没有成功退出，那么 ",[15,30683,30526],{}," 回调实际上会被",[488,30686,30687],{},"同一个老 panel 实例再次接收",[11,30689,30690,30691,30693,30694,30697],{},"这时候前端没有能力区分\"这是针对我的通知还是针对下一个 panel 的通知\"——它只是机械地执行了 ",[15,30692,30631],{},"，结果是：老 panel 的页面刷了一次，但进程照样活着；state.json 里的 ",[15,30695,30696],{},"selfExitRequested"," 标志被设置为 true，panel 主循环再也不会尝试重新进入 OTA 流程；modal 卡死在那里，等待一个永远不会来的进程退出。",[11,30699,30700,30701,30704],{},"用户看着 modal 卡了几秒钟没反应，以为系统出了问题，再点一下\"立即更新\"按钮。这一次，launcher 从头开始 OTA 流程，",[15,30702,30703],{},"ota.startDownload()"," 再跑一遍，又 spawn 了一个新的 worker。老 worker 还在卡着，新 worker 又加入排队。每次用户重复点击，队伍就长一个。",[11,30706,30707,30709,30710,30713,30714,30716],{},[15,30708,30502],{}," 路径的代码（第 143~149 行）也存在 UX 问题：1500 毫秒的 ",[15,30711,30712],{},"setTimeout"," 后直接调 ",[15,30715,30506],{},"，中间没有任何倒计时或视觉反馈。用户不知道系统在做什么，可能在这 1.5 秒的空窗期内通过其他途径（比如 VBS 脚本）重新拉起 launcher，造成更糟的并发状态。",[26,30718,30719],{"id":30719},"修复策略",[11,30721,30722],{},"修复包括两个层面。",[11,30724,30725],{},[488,30726,30727],{},"第一层：前端必须调进程退出命令而非页面刷新",[11,30729,30730,30732,30733,30736,30737,30740,30741,1244],{},[15,30731,30526],{}," 回调里，不管是 ",[15,30734,30735],{},"succeeded"," 还是 ",[15,30738,30739],{},"no-outcome"," 的兼容路径，都改为调用 ",[15,30742,30743],{},"exitApp()",[399,30745,30747],{"className":14691,"code":30746,"language":14693,"meta":183,"style":183},"ota.onFinished(payload => {\n  if (payload && payload.outcome === 'succeeded') {\n    msgEl.textContent = '升级成功，面板将自动重启';\n    setTimeout(() => exitApp(), 1200);\n  }\n  \u002F\u002F 或 payload 为 undefined 时\n  exitApp();\n})\n",[15,30748,30749,30763,30779,30789,30806,30810,30815,30822],{"__ignoreMap":183},[407,30750,30751,30753,30755,30757,30759,30761],{"class":409,"line":410},[407,30752,30537],{"class":413},[407,30754,30526],{"class":554},[407,30756,586],{"class":413},[407,30758,30544],{"class":718},[407,30760,30547],{"class":417},[407,30762,523],{"class":413},[407,30764,30765,30767,30769,30771,30773,30775,30777],{"class":409,"line":184},[407,30766,3721],{"class":417},[407,30768,30556],{"class":413},[407,30770,5203],{"class":417},[407,30772,30561],{"class":413},[407,30774,881],{"class":417},[407,30776,30566],{"class":429},[407,30778,887],{"class":413},[407,30780,30781,30783,30785,30787],{"class":409,"line":189},[407,30782,30573],{"class":413},[407,30784,418],{"class":417},[407,30786,30578],{"class":429},[407,30788,918],{"class":413},[407,30790,30791,30793,30795,30797,30800,30802,30804],{"class":409,"line":452},[407,30792,30585],{"class":554},[407,30794,5531],{"class":413},[407,30796,520],{"class":417},[407,30798,30799],{"class":554}," exitApp",[407,30801,30598],{"class":413},[407,30803,30601],{"class":476},[407,30805,662],{"class":413},[407,30807,30808],{"class":409,"line":458},[407,30809,563],{"class":413},[407,30811,30812],{"class":409,"line":464},[407,30813,30814],{"class":528},"  \u002F\u002F 或 payload 为 undefined 时\n",[407,30816,30817,30820],{"class":409,"line":470},[407,30818,30819],{"class":554},"  exitApp",[407,30821,3822],{"class":413},[407,30823,30824],{"class":409,"line":480},[407,30825,30616],{"class":413},[11,30827,30828,30830,30831,30834],{},[15,30829,30743],{}," 走的是 ",[15,30832,30833],{},"invoke('plugin:process|exit', {code: 0})"," → Tauri 运行时 → 强制退出进程。这个调用几乎不会失败——即便前端 WebView 处于任意状态，Tauri runtime 都能保证进程被杀掉。",[11,30836,30837,30838,30840,30841,30843],{},"为什么选这个方案而不是等待 ",[15,30839,30506],{}," 的结果？关键在于信号的清晰性。",[15,30842,30526],{}," 这个事件本身就表示\"OTA 流程已完毕，不管什么结果，panel 现在应该滚开\"。老 panel 收到这个消息时，正确的状态是它早就该死了——如果还活着，说明 self_exit 链路出了问题，最稳妥的做法是再死一次，而不是等待一个可能永远不会成功的后端调用。",[11,30845,30846],{},[488,30847,30848],{},"第二层：前端 UX + 防重入",[11,30850,30851,30853],{},[15,30852,30502],{}," 阶段改为 3 秒倒计时，同时禁用所有交互按钮。这不仅是 UX 反馈，更重要的是给用户一个明确的预期：\"现在不要再点\"，以及防止多个并发的退出请求造成的竞态条件：",[399,30855,30857],{"className":14691,"code":30856,"language":14693,"meta":183,"style":183},"if (phase === 'ready_to_apply' && !selfExitRequested) {\n  selfExitRequested = true;\n  updateBtn.disabled = true;\n  quitBtn.disabled = true;\n  let countdown = 3;\n  msgEl.textContent = `面板将在 ${countdown} 秒后自动关闭并重启，请勿操作...`;\n  const ticker = setInterval(() => {\n    countdown -= 1;\n    if (countdown \u003C= 0) {\n      clearInterval(ticker);\n      msgEl.textContent = '面板正在关闭...';\n      ota.requestSelfExit().catch(() => exitApp());\n    } else {\n      msgEl.textContent = `面板将在 ${countdown} 秒后自动关闭并重启，请勿操作...`;\n    }\n  }, 1000);\n}\n",[15,30858,30859,30878,30889,30900,30911,30926,30944,30962,30974,30987,30995,31007,31028,31036,31050,31054,31064],{"__ignoreMap":183},[407,30860,30861,30863,30866,30868,30871,30873,30875],{"class":409,"line":410},[407,30862,875],{"class":417},[407,30864,30865],{"class":413}," (phase ",[407,30867,881],{"class":417},[407,30869,30870],{"class":429}," 'ready_to_apply'",[407,30872,2171],{"class":417},[407,30874,9305],{"class":417},[407,30876,30877],{"class":413},"selfExitRequested) {\n",[407,30879,30880,30883,30885,30887],{"class":409,"line":184},[407,30881,30882],{"class":413},"  selfExitRequested ",[407,30884,418],{"class":417},[407,30886,18873],{"class":476},[407,30888,918],{"class":413},[407,30890,30891,30894,30896,30898],{"class":409,"line":189},[407,30892,30893],{"class":413},"  updateBtn.disabled ",[407,30895,418],{"class":417},[407,30897,18873],{"class":476},[407,30899,918],{"class":413},[407,30901,30902,30905,30907,30909],{"class":409,"line":452},[407,30903,30904],{"class":413},"  quitBtn.disabled ",[407,30906,418],{"class":417},[407,30908,18873],{"class":476},[407,30910,918],{"class":413},[407,30912,30913,30916,30919,30921,30924],{"class":409,"line":458},[407,30914,30915],{"class":417},"  let",[407,30917,30918],{"class":413}," countdown ",[407,30920,418],{"class":417},[407,30922,30923],{"class":476}," 3",[407,30925,918],{"class":413},[407,30927,30928,30931,30933,30936,30939,30942],{"class":409,"line":464},[407,30929,30930],{"class":413},"  msgEl.textContent ",[407,30932,418],{"class":417},[407,30934,30935],{"class":429}," `面板将在 ${",[407,30937,30938],{"class":413},"countdown",[407,30940,30941],{"class":429},"} 秒后自动关闭并重启，请勿操作...`",[407,30943,918],{"class":413},[407,30945,30946,30948,30951,30953,30956,30958,30960],{"class":409,"line":470},[407,30947,892],{"class":417},[407,30949,30950],{"class":476}," ticker",[407,30952,706],{"class":417},[407,30954,30955],{"class":554}," setInterval",[407,30957,5531],{"class":413},[407,30959,520],{"class":417},[407,30961,523],{"class":413},[407,30963,30964,30967,30970,30972],{"class":409,"line":480},[407,30965,30966],{"class":413},"    countdown ",[407,30968,30969],{"class":417},"-=",[407,30971,915],{"class":476},[407,30973,918],{"class":413},[407,30975,30976,30978,30981,30983,30985],{"class":409,"line":1477},[407,30977,19167],{"class":417},[407,30979,30980],{"class":413}," (countdown ",[407,30982,9322],{"class":417},[407,30984,1118],{"class":476},[407,30986,887],{"class":413},[407,30988,30989,30992],{"class":409,"line":1483},[407,30990,30991],{"class":554},"      clearInterval",[407,30993,30994],{"class":413},"(ticker);\n",[407,30996,30997,31000,31002,31005],{"class":409,"line":2139},[407,30998,30999],{"class":413},"      msgEl.textContent ",[407,31001,418],{"class":417},[407,31003,31004],{"class":429}," '面板正在关闭...'",[407,31006,918],{"class":413},[407,31008,31009,31012,31015,31017,31019,31021,31023,31025],{"class":409,"line":2180},[407,31010,31011],{"class":413},"      ota.",[407,31013,31014],{"class":554},"requestSelfExit",[407,31016,984],{"class":413},[407,31018,3830],{"class":554},[407,31020,5531],{"class":413},[407,31022,520],{"class":417},[407,31024,30799],{"class":554},[407,31026,31027],{"class":413},"());\n",[407,31029,31030,31032,31034],{"class":409,"line":7546},[407,31031,19219],{"class":413},[407,31033,19222],{"class":417},[407,31035,523],{"class":413},[407,31037,31038,31040,31042,31044,31046,31048],{"class":409,"line":7564},[407,31039,30999],{"class":413},[407,31041,418],{"class":417},[407,31043,30935],{"class":429},[407,31045,30938],{"class":413},[407,31047,30941],{"class":429},[407,31049,918],{"class":413},[407,31051,31052],{"class":409,"line":17267},[407,31053,18167],{"class":413},[407,31055,31056,31059,31062],{"class":409,"line":17279},[407,31057,31058],{"class":413},"  }, ",[407,31060,31061],{"class":476},"1000",[407,31063,662],{"class":413},[407,31065,31066],{"class":409,"line":18010},[407,31067,483],{"class":413},[11,31069,31070,31071,31074,31075,31078],{},"倒计时的目的不仅是 UX 反馈，更重要的是",[488,31072,31073],{},"给用户一个明确的预期","：\"你现在点了按钮，系统要在 3 秒内关闭，在这期间不要重复点击\"。禁用按钮则是硬性防止。兜底的 ",[15,31076,31077],{},"requestSelfExit().catch(() => exitApp())"," 处理的是\"后端自杀失败就用前端硬杀\"的极端情况。",[11,31080,31081,31084],{},[488,31082,31083],{},"防止 worker 堆积本身需要 launcher 端的支持","，但那超出这篇的范围（属于 worker 并发管理，是独立的改进项）。这篇修复的是\"单个 panel 必须可靠退出\"这个前置条件。",[26,31086,23827],{"id":23827},[11,31088,31089],{},"测试（ota-force-modal.test.js）检验两个场景：",[243,31091,31092,31108],{},[126,31093,31094,31097,31098,31100,31101,31103,31104,31107],{},[15,31095,31096],{},"outcome=succeeded"," 时触发 ",[15,31099,30743],{}," 而非 ",[15,31102,30631],{}," → 断言 ",[15,31105,31106],{},"plugin:process|exit"," 被 invoke",[126,31109,31110,31113,31114,31116],{},[15,31111,31112],{},"outcome"," 缺失时也调 ",[15,31115,30743],{}," → 同上",[11,31118,31119],{},"实机验证：0.14.17 版本 hot-deploy 到构建机和用户 A 的 USB。当 USB 版本等于当前版本时不弹 modal；后续任何 force-update OTA 都能完整跑完，panel 正常退出，worker 正常进 Phase 2。",[11,31121,31122],{},"已堆积的卡死 worker 需手工清：",[399,31124,31126],{"className":27120,"code":31125,"language":27122,"meta":183,"style":183},"Stop-Process -Name node,WakouPanel,launcher -Force\n",[15,31127,31128],{"__ignoreMap":183},[407,31129,31130],{"class":409,"line":410},[407,31131,31125],{},[11,31133,31134],{},"下次正常启动后系统会恢复。Phase 2 已写入缓存的 binary 是有效的（worker 是在 ready_to_apply 之后才卡死的），所以缓存里的 panel\u002Flauncher 实际上已经是新版本，只是没替换到 USB 而已。这个清理步骤是必要的，因为卡死的 worker 进程会阻止 launcher 继续执行，导致系统始终无法进入正常的 panel 运行状态。",[11,31136,31137,1244],{},[488,31138,31139],{},"测试（ota-force-modal.test.js）",[123,31141,31142,31147],{},[126,31143,31144,31145,31107],{},"用例：\"outcome=succeeded 时触发 exitApp 而非 reload\" → 断言 ",[15,31146,31106],{},[126,31148,31149],{},"用例：\"无 outcome 时触发 exitApp\" → 同上",[11,31151,31152,31155],{},[488,31153,31154],{},"实机验证","：\n0.14.17 版本 hot-deploy 到构建机和用户 A 的 USB。当 USB 版本等于当前版本时，不弹 modal；后续任何 force-update OTA 都能跑完完整的链路，panel 正常退出，worker 正常进入 Phase 2。下一个 launcher 启动时，panel 被新版本替换，用户进入正常的应用界面，不再看到循环弹窗。",[11,31157,31158,1244],{},[488,31159,31160],{},"预防规则",[123,31162,31163,31177,31180],{},[126,31164,31165,31166,31168,31169,14442,31171,16323,31174,31176],{},"任何\"panel 自杀让 launcher 接管\"的代码路径都必须走 ",[15,31167,30743],{}," 或 Rust 侧的 ",[15,31170,30509],{},[488,31172,31173],{},"禁止",[15,31175,30621],{},"。这个规则应该在代码审查和静态检查中强制。",[126,31178,31179],{},"倒计时与进度反馈是这种自杀类操作的强制 UX 标准，不让用户处于\"什么都没发生\"的盲区。",[126,31181,31182],{},"后续任何 OTA 功能扩展（比如 hot-reload、partial update）都必须在 panel 端有明确的进程退出路径，不能借 reload 暗渡陈仓。",[26,31184,31185],{"id":31185},"链路脆弱点",[11,31187,31188,31189,31192],{},"这个故障与另一个 OTA 故障（launcher 读 USB 版本号导致无限弹 modal）是",[488,31190,31191],{},"独立根因","——那是 launcher 端问题，这是前端问题。同一用户身上可能两个同时出现，但修复的层级完全不同。这次修复对了\"panel 退出\"这一层，但还需要 launcher 侧的 worker 并发管理来完全避免堆积。比起\"下一个 panel 退不出来怎么办\"，更脆弱的其实是\"已经有一个 worker 在跑，用户又点更新该怎么办\"——这个隐式契约（\"等待外部进程退出\"）在整个链路里是最松散的一环。",[1267,31194,31195],{},"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 .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}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}",{"title":183,"searchDepth":184,"depth":184,"links":31197},[31198,31199,31200,31201,31202,31203,31204],{"id":30393,"depth":184,"text":30393},{"id":30479,"depth":184,"text":30479},{"id":30495,"depth":184,"text":30496},{"id":30666,"depth":184,"text":30666},{"id":30719,"depth":184,"text":30719},{"id":23827,"depth":184,"text":23827},{"id":31185,"depth":184,"text":31185},"2026-05-09",{},"\u002F2026-05-09-fa-006-panel12worker",{"title":30388,"description":183},"2026-05-09-FA-006-panel不退出12个worker","前端 OTA 回调用页面刷新而非进程退出，导致 launcher worker 堆积。",[15069,19464,31212,19953,8701],"进程管理","wdIb79UdbwojoID2WMxXQsmN_HhbpVdQgUbf0qk3XmE",{"id":31215,"title":31216,"body":31217,"column":355,"date":31594,"description":183,"extension":199,"hero_image":200,"meta":31595,"navigation":202,"path":31596,"seo":31597,"series_id":30177,"severity":200,"stem":31598,"summary":31599,"tags":31600,"__hash__":31602},"posts\u002F2026-05-08-FA-005-版本号读取优先级错配.md","「升级成功」，但它觉得自己没升级",{"type":8,"value":31218,"toc":31587},[31219,31223,31230,31251,31258,31264,31267,31270,31273,31309,31318,31321,31338,31344,31347,31353,31356,31359,31390,31393,31402,31431,31437,31440,31442,31448,31475,31485,31488,31491,31540,31542,31548,31558,31571,31574,31577,31585],[26,31220,31222],{"id":31221},"根因读写位置错配","根因：读写位置错配",[11,31224,31225,31226,31229],{},"launcher 的 ",[15,31227,31228],{},"read_app_version"," 函数采用了三级 fallback：",[243,31231,31232,31239,31246],{},[126,31233,31234,31235,31238],{},"优先读 ",[15,31236,31237],{},"portable_root\u002Fversion.json","（USB 上的文件）",[126,31240,31241,31242,31245],{},"其次读 ",[15,31243,31244],{},"WAKOU_LOCAL_CACHE_ROOT\u002Fversion.json","（缓存中的文件）",[126,31247,31248,31249],{},"最后回落到 ",[15,31250,28665],{},[11,31252,31253,31254,31257],{},"但 OTA 的 Phase 2 只更新 cache 里的 ",[15,31255,31256],{},"version.json","，不更新 USB。这个顺序反了。",[11,31259,31260,31261,781],{},"USB 上的版本号代表\"上次制作镜像时是什么版本\"。Cache 代表\"当前系统已安装什么版本\"。读取时应该读当前状态，而非历史记录。关键原则：",[488,31262,31263],{},"读优先级必须与写位置一致",[26,31265,31266],{"id":31266},"怎么形成的循环",[11,31268,31269],{},"0.14.3 用户接到 0.14.4 强制升级。OTA 链路完整跑完，但新版 launcher 启动后仍然弹升级弹窗。用户点更新，下载安装，重启。然后又弹窗。无限循环，直到 0.14.4 被撤回。",[11,31271,31272],{},"Windows 便携版 0.14.3，某个 F 盘 USB 上的流程是：",[243,31274,31275,31278,31281,31284,31291,31294,31297,31300,31303,31306],{},[126,31276,31277],{},"用户双击 Start-Wakou.cmd，0.14.3 launcher 正常启动",[126,31279,31280],{},"Stage 5 完成 cache 准备（mirror 文件来自 USB，所有文件都是 0.14.3 era）",[126,31282,31283],{},"Stage 6 OTA 检查调用远程 wrapper，返回 0.14.4 强制更新指令",[126,31285,31286,31287,31290],{},"写入 ",[15,31288,31289],{},"force-update-pending.json"," marker，Stage 7 启动 panel",[126,31292,31293],{},"Panel 读取 marker，弹出「升级到 0.14.4」对话框",[126,31295,31296],{},"用户点击「立即更新」，OTA worker 开始下载、验证、准备安装",[126,31298,31299],{},"Phase 2 trash_then_install 完成，杀掉 panel，启动新版 launcher",[126,31301,31302],{},"新 launcher 再次执行 OTA 检查，wrapper 仍然返回 0.14.4 强制更新",[126,31304,31305],{},"重新写入 marker，Panel 重启后再次弹窗",[126,31307,31308],{},"循环往复",[11,31310,31311,31312,31314,31315,781],{},"用户无法正常进入新版本的面板。OTA 事件日志显示 \"OTA applied to 0.14.4\"，但 USB 中的 ",[15,31313,31256],{}," 仍然记录 ",[15,31316,31317],{},"0.14.3",[11,31319,31320],{},"观察点是这个矛盾：OTA 事件日志显示已成功安装到 0.14.4，但重启后又触发同样的升级流程。这意味着版本号的读取出了问题。",[11,31322,31323,31324,31326,31327,31329,31330,31333,31334,31337],{},"新版 launcher 启动后，调用 ",[15,31325,31228],{},"。由于优先级错配，它读到的是 USB 上的旧版本号——",[15,31328,31317],{},"。向 wrapper 传 ",[15,31331,31332],{},"currentVersion=0.14.3","，wrapper 返回 ",[15,31335,31336],{},"0.14.4"," 强制更新。marker 重新写入，panel 重启后再次弹窗。",[11,31339,31340,31341,31343],{},"用户点更新，OTA Phase 2 完成，cache 中的 ",[15,31342,31256],{}," 写入 0.14.4。但 USB 同步有 120 秒延迟，甚至更久（guardian 的定时间隔）。下一次 launcher 启动，又读到 USB 的 0.14.3。",[11,31345,31346],{},"循环永不停止——除非 guardian 的同步周期刚好赶上 launcher 的下一次启动。时间窗口里，任何人为原因的重启都能触发循环。",[11,31348,31349,31350,31352],{},"关键是 OTA 的写入和读取位置不一致。OTA 的 Phase 2（trash_then_install）更新的是 ",[15,31351,28119],{},"（本地缓存目录）下的文件。Cache 是操作系统启动后立刻可用的工作目录，USB 上的文件是长期存储。",[11,31354,31355],{},"但新 launcher 启动后，由于优先级设置，仍然首先读取 USB 上那份——而 USB 中的版本号还没有被同步到 0.14.4，因为 guardian 进程定时同步 USB 的间隔是 120 秒。",[11,31357,31358],{},"时间线还原：",[123,31360,31361,31364,31370,31373,31379,31384,31387],{},[126,31362,31363],{},"T0：用户点\"立即更新\"",[126,31365,31366,31367,31369],{},"T0+20s：OTA Phase 2 完成，cache 中 ",[15,31368,31256],{}," 已写入 0.14.4",[126,31371,31372],{},"T0+21s：新 launcher 启动",[126,31374,31375,31376,31378],{},"T0+22s：",[15,31377,31228],{}," 读 USB，得 0.14.3",[126,31380,31381,31382],{},"T0+23s：launcher 向 wrapper 传 ",[15,31383,31332],{},[126,31385,31386],{},"T0+24s：wrapper 返回 0.14.4 强制更新指令，marker 再次写入",[126,31388,31389],{},"T0+120s：guardian 才同步 USB 到 0.14.4",[11,31391,31392],{},"这 120 秒窗口内任何一次 launcher 启动都会重复。系统重启后 guardian 周期重新开始计时，问题仍然会重现。",[11,31394,31395,31398,31399,31401],{},[15,31396,31397],{},"launcher\u002Fsrc\u002Fmain.rs"," 中 ",[15,31400,31228],{}," 的实现问题：",[399,31403,31405],{"className":27261,"code":31404,"language":27263,"meta":183,"style":183},"fn read_app_version(portable_root: &Path) -> String {\n    \u002F\u002F 1. portable_root\u002Fversion.json (USB)   ← 错误优先级\n    \u002F\u002F 2. WAKOU_LOCAL_CACHE_ROOT\u002Fversion.json (cache)\n    \u002F\u002F 3. CARGO_PKG_VERSION (last resort)\n}\n",[15,31406,31407,31412,31417,31422,31427],{"__ignoreMap":183},[407,31408,31409],{"class":409,"line":410},[407,31410,31411],{},"fn read_app_version(portable_root: &Path) -> String {\n",[407,31413,31414],{"class":409,"line":184},[407,31415,31416],{},"    \u002F\u002F 1. portable_root\u002Fversion.json (USB)   ← 错误优先级\n",[407,31418,31419],{"class":409,"line":189},[407,31420,31421],{},"    \u002F\u002F 2. WAKOU_LOCAL_CACHE_ROOT\u002Fversion.json (cache)\n",[407,31423,31424],{"class":409,"line":452},[407,31425,31426],{},"    \u002F\u002F 3. CARGO_PKG_VERSION (last resort)\n",[407,31428,31429],{"class":409,"line":458},[407,31430,483],{},[11,31432,31433,31434,31436],{},"这个优先级顺序违反了核心原则：",[488,31435,31263],{},"。USB 版本号代表\"上次制作镜像时是什么版本\"。Cache 代表\"当前系统已安装什么版本\"。读取时应该读当前状态，而非历史记录。",[11,31438,31439],{},"新 launcher 需要知道\"我现在是什么版本\"，这个\"我\"指的是内存中正在运行的程序，其代码来自 cache（如果有最新版本），而不是来自 USB。读 USB 版本号等于问\"USB 镜像上次更新是几天前\"，这不是正确的问题。",[26,31441,30719],{"id":30719},[11,31443,31444,31445,31447],{},"反转 ",[15,31446,31228],{}," 的优先级：",[399,31449,31451],{"className":27261,"code":31450,"language":27263,"meta":183,"style":183},"fn read_app_version(portable_root: &Path) -> String {\n    \u002F\u002F 1. WAKOU_LOCAL_CACHE_ROOT\u002Fversion.json (cache — 当前已安装)\n    \u002F\u002F 2. portable_root\u002Fversion.json (USB — 兜底）\n    \u002F\u002F 3. CARGO_PKG_VERSION (last resort)\n}\n",[15,31452,31453,31457,31462,31467,31471],{"__ignoreMap":183},[407,31454,31455],{"class":409,"line":410},[407,31456,31411],{},[407,31458,31459],{"class":409,"line":184},[407,31460,31461],{},"    \u002F\u002F 1. WAKOU_LOCAL_CACHE_ROOT\u002Fversion.json (cache — 当前已安装)\n",[407,31463,31464],{"class":409,"line":189},[407,31465,31466],{},"    \u002F\u002F 2. portable_root\u002Fversion.json (USB — 兜底）\n",[407,31468,31469],{"class":409,"line":452},[407,31470,31426],{},[407,31472,31473],{"class":409,"line":458},[407,31474,483],{},[11,31476,31477,31478,31480,31481,31484],{},"修复后，cache 中的 ",[15,31479,31256],{}," 一旦更新为 0.14.4，新 launcher 立刻读到。wrapper 返回 ",[15,31482,31483],{},"NoUpdate","，循环断掉。USB 同步滞后已不关键——cache 就是当前事实。",[11,31486,31487],{},"读取的是\"此时此刻运行的是什么版本\"，而不是\"最后一次有人更新 USB 镜像是什么时候\"。",[11,31489,31490],{},"验证边界场景：",[1708,31492,31493,31505],{},[1711,31494,31495],{},[1714,31496,31497,31499,31502],{},[1717,31498,26677],{},[1717,31500,31501],{},"旧优先级",[1717,31503,31504],{},"新优先级",[1730,31506,31507,31518,31529],{},[1714,31508,31509,31512,31515],{},[1735,31510,31511],{},"全新 USB 首次启动（无 cache）",[1735,31513,31514],{},"读 USB ✓",[1735,31516,31517],{},"cache 不存在，fallback USB ✓",[1714,31519,31520,31523,31526],{},[1735,31521,31522],{},"OTA 完成立刻重启",[1735,31524,31525],{},"USB 旧版 → 循环 ❌",[1735,31527,31528],{},"cache 已更新 ✓",[1714,31530,31531,31534,31537],{},[1735,31532,31533],{},"dev 环境（无文件）",[1735,31535,31536],{},"fallback 常数 ✓",[1735,31538,31539],{},"同上 ✓",[26,31541,23827],{"id":23827},[11,31543,31544,31545,31547],{},"加入集成测试，验证当 cache 和 USB 版本号不一致时的行为：mock cache 为 0.14.5，USB 为 0.14.4，调用 ",[15,31546,31228],{}," 必须返回 0.14.5（而非 0.14.4）。",[11,31549,31550,31552,31553,13067,31555,31557],{},[15,31551,31228],{}," 是 launcher boot Stage 6 的唯一版本来源。任何后续改动涉及 ",[15,31554,27998],{},[15,31556,31256],{}," 的代码都应该 grep 一次这个函数，确保没有再把优先级搞反。",[11,31559,31560,31563,31564,31566,31567,31570],{},[488,31561,31562],{},"附注","：同时期还有两个相关问题。一是 version.json 的 schema 分裂（schema-1 和 schema-2 并存），可能导致版本号回落到默认值 0.0.1，作为 OTA 循环的隐藏放大器——但这是独立的 schema 编码问题，不是优先级问题。二是 launcher 的两个不同代码路径（Stage 6 boot 的 ",[15,31565,31228],{}," 与 OTA check 的 ",[15,31568,31569],{},"current_version_from_local_cache","）的 fallback 链路设计不一致，这也是独立根因，表现为版本号又退化到 0.0.0，同样触发 OTA 循环。当前这篇解决的只是第一个（优先级错配），其他两个有各自的修复方案。",[26,31572,31573],{"id":31573},"更宽的教训",[11,31575,31576],{},"分布式系统里同一份数据有多个副本时，读取顺序必须遵循写入顺序。OTA 系统中，cache 是主，USB 是从；应该读主再读从。任何\"双位置存储\"都有这个风险——没有绝对的\"真理源\"，只有\"最近写入的位置\"。",[11,31578,31579,31580,31566,31582,31584],{},"同时期还有两个相关但独立的 bug。一是 version.json 的 schema 分裂（schema-1 和 schema-2 并存），可能导致版本号回落到 0.0.1，作为 OTA 循环的隐藏放大器。这是 schema 编码问题，不是优先级。二是 launcher 的两个代码路径（Stage 6 boot 的 ",[15,31581,31228],{},[15,31583,31569],{},"）的 fallback 链路不一致，版本号退化到 0.0.0，同样触发循环。这篇只解决了优先级错配，其他两个各有各的修复。",[1267,31586,16447],{},{"title":183,"searchDepth":184,"depth":184,"links":31588},[31589,31590,31591,31592,31593],{"id":31221,"depth":184,"text":31222},{"id":31266,"depth":184,"text":31266},{"id":30719,"depth":184,"text":30719},{"id":23827,"depth":184,"text":23827},{"id":31573,"depth":184,"text":31573},"2026-05-08",{},"\u002F2026-05-08-fa-005",{"title":31216,"description":183},"2026-05-08-FA-005-版本号读取优先级错配","portable 客户升级后版本判定用错了数据源，launcher 不断读旧版本号导致无限循环更新。",[15069,20302,31601,15072,12681],"portable","fR36O4-ZpcDYmp4-jfPWmgpJ8gZznIiLt4fyhRGs-3s",{"id":31604,"title":31605,"body":31606,"column":6815,"date":31939,"description":31610,"extension":199,"hero_image":200,"meta":31940,"navigation":202,"path":31941,"seo":31942,"series_id":200,"severity":200,"stem":31943,"summary":31944,"tags":31945,"__hash__":31949},"posts\u002F2026-05-06-更新包的信任链.md","更新包，能往客户机写任何文件",{"type":8,"value":31607,"toc":31928},[31608,31611,31615,31618,31621,31627,31634,31637,31643,31666,31669,31673,31676,31679,31682,31685,31692,31695,31702,31709,31737,31740,31743,31764,31767,31770,31774,31777,31792,31855,31858,31861,31909,31912,31923,31926],[11,31609,31610],{},"OTA 在客户机上解包并替换文件，这是一条高权限路径。如果源被投毒或包被构造，后果是任意文件写入或目录穿越。我采用三层独立的防御机制，每一层都针对不同的威胁模型，且它们互相不可替代。",[26,31612,31614],{"id":31613},"第一层签名验证链","第一层：签名验证链",[11,31616,31617],{},"每次 OTA 的起点是 release manifest（描述什么版本、文件列表、发布时间）和一个 zip 包体。两者都用 minisign 签名，公钥内嵌在客户端里。验签失败立即终止，后续步骤都走不到。",[11,31619,31620],{},"这一层的目的是确保来源可信。即使后续步骤再严密，如果源头被投毒了，也挡不住。",[11,31622,31623,31626],{},[488,31624,31625],{},"信任链的完整性","。签名验证看似简单，但细节决定能否拦住真实的攻击。曾在 dress rehearsal 时遇到这样的故障：",[11,31628,31629,31630,31633],{},"release manifest 的签名文件（release-manifest.sig）生成时缺少 ",[15,31631,31632],{},"publishedAt"," 字段。launcher 的验证代码强制检查这个字段——它不仅验证签名本身有效，还校验签名的 trusted comment 包含 publishedAt 且与 manifest 内容一致。这样设计是为了确保签名的上下文完整：不只验证\"这个文件确实由 A 签的\"，还验证\"这个文件在时间 T 由 A 签的\"。",[11,31635,31636],{},"缺少 publishedAt 导致验签必失败：",[399,31638,31641],{"className":31639,"code":31640,"language":1327},[1325],"[launcher] Stage 6 OTA check error: trusted_comment missing publishedAt\n",[15,31642,31640],{"__ignoreMap":183},[11,31644,31645,31646,31649,31650,31653,31654,31657,31658,31661,31662,31665],{},"排查过程：看 sig 文件实际内容，trusted comment 只有 ",[15,31647,31648],{},"kind=release_manifest version=0.14.0 signedAt=...","，缺少 ",[15,31651,31652],{},"publishedAt=...","。跟踪 sign CLI 的代码，",[15,31655,31656],{},"release-manifest"," 子命令调用 ",[15,31659,31660],{},"sign::sign_file"," 时 extra_fields 传空数组，没有 publishedAt。修复是给 sign 命令加 ",[15,31663,31664],{},"--published-at \u003CRFC3339>"," 参数，从 manifest.json 的 publishedAt 字段抠出来。",[11,31667,31668],{},"这个故障说明了签名链的脆弱面：签名生成端和验证端的合约必须完全一致，否则合法的包也会被拒。反过来说，这个严格的检查正是设计想要的效果——它提高了被攻击的成本。如果攻击者改了包的内容，要伪造一个新签名，不仅要破解私钥，还要用 launcher 能接受的格式生成 trusted comment。",[26,31670,31672],{"id":31671},"第二层解压边界","第二层：解压边界",[11,31674,31675],{},"即使签名合法，包的内容也可能被精心构造来攻击解压逻辑。这一层用四个独立的检查阻止两类常见攻击：zip bomb 和目录穿越。",[31,31677,31678],{"id":31678},"解压大小限制",[11,31680,31681],{},"单个文件大小上限已经存在：4 GiB。但攻击者可以用多个小文件堆积，总解压体积溢出磁盘。N 个 sub-4GiB 的文件累积可以填满任意容量。这是 CVE-2003-1564 级别的威胁。",[11,31683,31684],{},"防御方法是加总解压上限：10 GiB。这个阈值的选择基于实际情况——完整的 release 包括 panel 应用、多个引擎、二进制工具、数据文件，当前实际体积远低于 10 GiB，留足了安全裕度。",[11,31686,31687,31688,31691],{},"大小检查的时机很关键：在解压前累加 entry 的 uncompressed size（从 zip header 读取），一旦超限立即 bail。这样不需要真正解压 10 GiB 的数据就能拦住攻击。当然，zip header 的 size 字段本身可能被伪造——攻击者可以声明 size=0 但实际写入数据。这靠第二个检查兜底：",[15,31689,31690],{},"io::copy"," 的实际写入字节数仍受 zip stream 的物理限制，不会让你写出超过压缩数据本身的内容。",[31,31693,31694],{"id":31694},"拒绝符号链接",[11,31696,31697,31698,31701],{},"zip 格式可以记录条目的 unix 权限模式，其中包括 mode bit 0o120000 表示符号链接。当前代码会调用 ",[15,31699,31700],{},"File::create(&dest)"," 写入条目，对于 symlink-mode 条目，zip 库不会真创建符号链接，而是当作普通文件写出——这是\"意外安全\"。",[11,31703,31704,31705,31708],{},"问题在于这种安全不是明确的。zip 库升级或 API 改变都可能破坏这个假设。防御方法是显式 reject：检查 ",[15,31706,31707],{},"entry.unix_mode() & 0o170000 == 0o120000","，如果是 symlink mode 直接 bail。",[399,31710,31712],{"className":27261,"code":31711,"language":27263,"meta":183,"style":183},"if let Some(mode) = entry.unix_mode() {\n    if mode & 0o170000 == 0o120000 {\n        bail!(\"OTA_ZIP_SYMLINK_REJECTED: entry `{}` has symlink mode bit\", entry_name);\n    }\n}\n",[15,31713,31714,31719,31724,31729,31733],{"__ignoreMap":183},[407,31715,31716],{"class":409,"line":410},[407,31717,31718],{},"if let Some(mode) = entry.unix_mode() {\n",[407,31720,31721],{"class":409,"line":184},[407,31722,31723],{},"    if mode & 0o170000 == 0o120000 {\n",[407,31725,31726],{"class":409,"line":189},[407,31727,31728],{},"        bail!(\"OTA_ZIP_SYMLINK_REJECTED: entry `{}` has symlink mode bit\", entry_name);\n",[407,31730,31731],{"class":409,"line":452},[407,31732,18167],{},[407,31734,31735],{"class":409,"line":458},[407,31736,483],{},[11,31738,31739],{},"这样即使 zip 库在未来支持真正创建符号链接，launcher 也会拒绝。当前生产的 OTA 包不应该包含 symlink，如果未来需要支持，那是 spec 变更后的事，明确决策即可。",[31,31741,31742],{"id":31742},"路径越界检查",[11,31744,31745,31746,31749,31750,4375,31753,4375,31756,31759,31760,31763],{},"这个已经存在——",[15,31747,31748],{},"safe_join"," 函数拒绝 ",[15,31751,31752],{},"Component::ParentDir",[15,31754,31755],{},"Component::Prefix",[15,31757,31758],{},"Component::RootDir","，防止路径遍历攻击。zip 的 entry name 比如 ",[15,31761,31762],{},"..\u002F..\u002Fetc\u002Fpasswd"," 会被拒绝。",[31,31765,31766],{"id":31766},"这些检查为什么互补",[11,31768,31769],{},"大小检查防止资源耗尽（存储满、内存爆炸）；symlink 拒绝防止权限提升（比如创建指向系统敏感文件的链接）；路径越界检查防止文件覆盖（比如解出文件到安装目录外）。单有其中一个都不够——大小检查无法阻止精心放置的 symlink 攻击，路径检查也无法阻止大量小文件堆积。",[26,31771,31773],{"id":31772},"第三层版本单调性","第三层：版本单调性",[11,31775,31776],{},"已安装的版本号不能倒退。这防止被强推旧的、已知有漏洞的版本。",[11,31778,31779,31780,31783,31784,31787,31788,31791],{},"检查逻辑在 ",[15,31781,31782],{},"version_ge"," 函数里：当前版本 >= 要求版本才允许降级逻辑执行。最初的实现用简单的数字拆分比较，后来改用 ",[15,31785,31786],{},"semver::Version::parse"," 以支持正式的语义版本号，包括预发布版本的顺序（",[15,31789,31790],{},"1.0.0-rc.2 > 1.0.0-rc.1","）和预发布 \u003C 正式版的规则。",[399,31793,31795],{"className":27261,"code":31794,"language":27263,"meta":183,"style":183},"fn version_ge(current: &str, required: &str) -> bool {\n    let parse = |s: &str| {\n        semver::Version::parse(s.trim_start_matches('v'))\n    };\n    match (parse(current), parse(required)) {\n        (Ok(c), Ok(r)) => c >= r,\n        _ => {\n            eprintln!(\"[launcher] WARN: version_ge parse failed\");\n            false  \u002F\u002F 解析失败保守判定为 false，走 RunChain 重新 OTA\n        }\n    }\n}\n",[15,31796,31797,31802,31807,31812,31817,31822,31827,31832,31837,31842,31847,31851],{"__ignoreMap":183},[407,31798,31799],{"class":409,"line":410},[407,31800,31801],{},"fn version_ge(current: &str, required: &str) -> bool {\n",[407,31803,31804],{"class":409,"line":184},[407,31805,31806],{},"    let parse = |s: &str| {\n",[407,31808,31809],{"class":409,"line":189},[407,31810,31811],{},"        semver::Version::parse(s.trim_start_matches('v'))\n",[407,31813,31814],{"class":409,"line":452},[407,31815,31816],{},"    };\n",[407,31818,31819],{"class":409,"line":458},[407,31820,31821],{},"    match (parse(current), parse(required)) {\n",[407,31823,31824],{"class":409,"line":464},[407,31825,31826],{},"        (Ok(c), Ok(r)) => c >= r,\n",[407,31828,31829],{"class":409,"line":470},[407,31830,31831],{},"        _ => {\n",[407,31833,31834],{"class":409,"line":480},[407,31835,31836],{},"            eprintln!(\"[launcher] WARN: version_ge parse failed\");\n",[407,31838,31839],{"class":409,"line":1477},[407,31840,31841],{},"            false  \u002F\u002F 解析失败保守判定为 false，走 RunChain 重新 OTA\n",[407,31843,31844],{"class":409,"line":1483},[407,31845,31846],{},"        }\n",[407,31848,31849],{"class":409,"line":2139},[407,31850,18167],{},[407,31852,31853],{"class":409,"line":2180},[407,31854,483],{},[11,31856,31857],{},"这一层防的是一种很具体的操作事故：手误把已撤回的旧版本重新发布。如果没这一层，客户机会被自动推回旧版本；有了这一层，旧包虽然验签合法、解压也通过了，但版本号检查会拒绝它。",[26,31859,31860],{"id":31860},"为什么三层都必要",[1708,31862,31863,31876],{},[1711,31864,31865],{},[1714,31866,31867,31870,31873],{},[1717,31868,31869],{},"防御",[1717,31871,31872],{},"什么时候失效",[1717,31874,31875],{},"后果",[1730,31877,31878,31888,31899],{},[1714,31879,31880,31882,31885],{},[1735,31881,29336],{},[1735,31883,31884],{},"私钥泄露",[1735,31886,31887],{},"任何内容都能合法签名，后续检查都白搭",[1714,31889,31890,31893,31896],{},[1735,31891,31892],{},"解压边界",[1735,31894,31895],{},"被绕过",[1735,31897,31898],{},"zip bomb 填满磁盘、symlink 提权、文件覆盖",[1714,31900,31901,31904,31906],{},[1735,31902,31903],{},"版本单调",[1735,31905,31895],{},[1735,31907,31908],{},"被推回已知有漏洞的旧版本",[11,31910,31911],{},"签名验证保证来源可信——这是前提。但\"来自信任方\"的包仍可能是：",[123,31913,31914,31917,31920],{},[126,31915,31916],{},"意外生成的恶意内容（比如临时文件被打进 zip）",[126,31918,31919],{},"被中间人修改过（虽然验签会失败，但某些场景下可能有变量窗口）",[126,31921,31922],{},"通过合法渠道发布的有漏洞的旧版本（这时签名完全合法）",[11,31924,31925],{},"解压边界独立处理包内容的风险，不依赖于包的签名状态。版本单调性独立处理版本回退风险。三个不同的威胁模型需要三层不同的防御。",[1267,31927,16447],{},{"title":183,"searchDepth":184,"depth":184,"links":31929},[31930,31931,31937,31938],{"id":31613,"depth":184,"text":31614},{"id":31671,"depth":184,"text":31672,"children":31932},[31933,31934,31935,31936],{"id":31678,"depth":189,"text":31678},{"id":31694,"depth":189,"text":31694},{"id":31742,"depth":189,"text":31742},{"id":31766,"depth":189,"text":31766},{"id":31772,"depth":184,"text":31773},{"id":31860,"depth":184,"text":31860},"2026-05-06",{},"\u002F2026-05-06",{"title":31605,"description":31610},"2026-05-06-更新包的信任链","构建三层防御阻止 OTA 恶意包注入：签名验证、解压边界、版本单调性。",[15069,31946,29336,31947,31948,15072],"minisign","zip bomb","解压安全","NGp6JVDpg3UAbc1LQKtBhL5j8LjKoVKpxrzTxJEqd-8",{"id":31951,"title":31952,"body":31953,"column":6815,"date":32236,"description":31957,"extension":199,"hero_image":200,"meta":32237,"navigation":202,"path":32238,"seo":32239,"series_id":200,"severity":200,"stem":32240,"summary":32241,"tags":32242,"__hash__":32247},"posts\u002F2026-05-05-OTA从plan1到plan11十一版设计改了什么.md","同一个更新链路，我改了十一版设计",{"type":8,"value":31954,"toc":32229},[31955,31958,31962,31972,31990,32007,32021,32031,32037,32041,32047,32054,32069,32080,32087,32091,32094,32119,32122,32158,32165,32168,32177,32188,32191,32194,32204,32207,32217,32220,32223,32226],[11,31956,31957],{},"2026-04-30 到 05-07，OTA 链路从 plan1 走到 plan11，每个版本都在刮之前的设计债。这条线的有意思的地方，不在单个方案本身，而在整体的两个转折：从\"能更新\"转向\"更新失败也能恢复\"，再从\"人工验证\"转向\"故障注入\"。",[26,31959,31961],{"id":31960},"起点plan1-5-搭主干","起点：plan1-5 搭主干",[11,31963,31964,31967,31968,31971],{},[488,31965,31966],{},"Plan 1"," 是契约的交割。admin-panel 停止持有 OTA 私钥，改为接收 launcher 上报的\"要更新哪个版本\"后，运维丢一个 release-manifest 的 URL 过来，backend 返回 RolloutWrapper（不签名，只是策略提示）。真正的验证权移到客户端：launcher 拿到 unsigned wrapper 后，通过 trust waterfall 去验证 release-manifest 的签名。同时出炉离线签名 CLI ",[15,31969,31970],{},"wakou-ota-sign","，私钥永不上线。",[11,31973,31974,31977,31978,31981,31982,31985,31986,31989],{},[488,31975,31976],{},"Plan 2"," 把 launcher 拔到启动链的信任锚点位置。之前 ",[15,31979,31980],{},"Start-Wakou.cmd"," 直接调 PowerShell 脚本启动 cache，launcher 是被 spawn 的后台进程。现在反过来：",[15,31983,31984],{},"launcher.exe boot"," 成为启动链主角，它依次调 ps1 prepare cache、spawn panel、启动 guardian。同时集中化 ",[15,31987,31988],{},"WAKOU_UPDATE_STATE_BASE"," 环境变量，所有涉及 OTA 状态的地方都从这里取路径。",[11,31991,31992,31995,31996,31999,32000,88,32003,32006],{},[488,31993,31994],{},"Plan 3"," 锁死 skills 的物理布局。把用户装的各种 skill 统一归到 ",[15,31997,31998],{},"\u003Croot>\u002Fskills\u002F\u003Cengine>\u002F"," 下，用 NTFS junction 桥接到引擎的默认扫描路径。guardian 同步白名单从 5 条扩到 7 条，加上 ",[15,32001,32002],{},"skills\\openclaw",[15,32004,32005],{},"skills\\hermes","。这看似是小改动，但为后续 OTA 主链\"不动 skills\"的假设打好地基。",[11,32008,32009,32012,32013,32016,32017,32020],{},[488,32010,32011],{},"Plan 4"," 把 USB 准入从 stub 变成真实。stage 0 启动时，launcher 通过 Win32 IOCTL 读当前 USB 硬件 serial，用编译期固化的 USB allowlist 公钥验签 USB 上的 ",[15,32014,32015],{},".config\u002Fwakou-allowlist.json","。新建离线签名 CLI ",[15,32018,32019],{},"wakou-usb-allowlist-sign","，出厂烧录用。admission reject 直接 exit，stage 0 就杀掉整个启动链。",[11,32022,32023,32026,32027,32030],{},[488,32024,32025],{},"Plan 5"," 落地完整 OTA 主链。stage 6 所有者的 ota_check 从 stub 变成真实：调 trust waterfall 拿 release-manifest、HTTP Range resume 下载 zip、sha256+minisign 验证、extract 到本地、robocopy 到 USB、写 version.json 作为提交点。state_machine 从 Idle 推进到 Done，每一步都 fsync state.json。panel 读 env 判断有没有更新，用户点击后 launcher 拉起 ",[15,32028,32029],{},"--update-only"," 子命令。",[11,32032,32033,32034,781],{},"这五个版本把基础设施搭好了：后端不签名、前端信任瀑布、launcher 当主角、skills 物理拓扑、USB 准入、主链路。",[488,32035,32036],{},"能跑了",[26,32038,32040],{"id":32039},"第一个转折plan6-之后全是容错","第一个转折：plan6 之后全是容错",[11,32042,32043,32046],{},[488,32044,32045],{},"Plan 6"," 是转折点。标题写的是\"容错 saga + 测试补全\"，但实质是：Plan 5 主链有个致命漏洞——只写了 happy path。如果 download 失败、verify 失败、local apply 一半死、USB 拔掉，state_machine 就卡在中间状态。下次启动时，launcher 不知道这个中间态，stage 1-5 直接跑完，stage 6 又开始 OTA，结果两个 OTA 流同时写 state.json，状态爆炸。",[11,32048,32049,32050,32053],{},"Plan 6 把这条断裂线缝好：新增 7 个 resume cell，分别对应 Idle\u002FDownloading\u002FVerifying\u002FLocalApplying\u002FLocalApplied\u002FUsbStaging\u002FUsbSwapping\u002FUsbCommitted\u002FCleanup 这 9 个 phase 中的各个断点。launcher 启动时先读 state.json，看 phase 是啥，直接跳到对应的 resume 分支。比如上次卡在 LocalApplying，这次来了直接从 ",[15,32051,32052],{},"resume_local_applying"," 开始，继续 extract、trash、install。",[11,32055,32056,32057,32060,32061,32064,32065,32068],{},"同时引入 trash corrupt 兜底：如果 trash manifest 校验失败（文件被删了或损坏），OTA 拒绝继续，返回 ",[15,32058,32059],{},"OtaStateCorrupt"," 退出码，让 boot 流程拒绝启动。需要修复的话，跑 ",[15,32062,32063],{},"launcher.exe --ota-repair"," 收尾。USB 拔盘也错误化了：设个 ",[15,32066,32067],{},"pending_replug"," flag，记录当时的 USB serial，等用户插回原 USB，resume 检查 serial 一致再继续；serial 不对直接 reject。",[11,32070,32071,32072,32075,32076,32079],{},"从 Plan 6 开始，OTA 设计的所有新增都围绕\"能恢复\"这个主题展开。Plan 7 加后台轮询和设置页，但核心还是——轮询过程中如果出错，不杀进程，error event 写进 ota-events.jsonl，等下次自动重试。Plan 8 加 zip bomb 防护和 trash v2，但归根结底是——compressed 能搞出多大炸弹？单 entry 4GB，多 entry 呢？trash 从 ",[15,32073,32074],{},"{ paths: Vec }"," 升到 ",[15,32077,32078],{},"{ entries: Vec\u003C{ path, kind }> }","，让后续 verify 时能准确判断该删啥。",[11,32081,32082,32083,32086],{},"这一段的逻辑是",[488,32084,32085],{},"层层堵漏","：Plan 6 堵 state machine 中间态、trash 损坏、USB 拔盘；Plan 7 堵轮询失败、panel crash；Plan 8 堵 zip bomb、trash 格式模糊。",[26,32088,32090],{"id":32089},"第二个转折plan11-从快照到注入","第二个转折：plan11 从快照到注入",[11,32092,32093],{},"Plan 6 写了 8 个 cell 测试来覆盖 7 个 resume 分支（还有个 happy path）。但这些 cell 怎么测的？用的是 fixture snapshot——往测试里丢一个特定的 state.json，再丢一个特定的文件树，然后跑 resume 流程，看结果对不对。好处是快，坏处是假——fixture 是死的，真实场景里 download 一半网络断、verify 的 zip 损坏、local apply sync 时 USB I\u002FO error，这些真实的 panic 和 error 在快照里体现不出来。",[11,32095,32096,32099,32100,32103,32104,32107,32108,32111,32112,32115,32116,32118],{},[488,32097,32098],{},"Plan 11"," 把这个反过来：不是\"先造故障状态再恢复\"，而是\"正常跑链路，中途注入故障\"。新建 ",[15,32101,32102],{},"test_inject"," 模块，一个全局 ",[15,32105,32106],{},"Mutex\u003COption\u003C(Stage, Fault)>>","。测试侧 ",[15,32109,32110],{},"arm(Stage::Download, Fault::Transient {…})","，然后跑链路，链路在 download 过程中调 ",[15,32113,32114],{},"take_for(Stage::Download)","，取出注入的 fault，转成 anyhow error，",[15,32117,514],{}," 上抛，整个链继续处理这个错误——resume state、retry、最后要么成功要么进 error 分支。",[11,32120,32121],{},"这样做的收获是什么？真实的错误路径。Plan 6 的 cell 1（download 失败）现在不是\"丢个 stale state.json\"，而是\"真的跑 http client，中途注入 connection reset，看 resume path 怎么处理\"。io::ErrorKind 对应 Transient（可重试），业务逻辑错误对应 Fatal（不重试）。这两条路在链路里分得清清楚楚。",[11,32123,32124,32125,32128,32129,4375,32132,4375,32135,32138,32139,32142,32143,32146,32147,32150,32151,32154,32155,32157],{},"而且这套注入框架为 ",[488,32126,32127],{},"Plan 10"," 的类型化错误做好铺垫。Plan 10 说的是把全链 43 处 ",[15,32130,32131],{},"anyhow!",[15,32133,32134],{},"bail!",[15,32136,32137],{},".context()"," 统一替换成 typed ",[15,32140,32141],{},"OtaChainError { Fatal, Transient }","，这样 error event log 就能从一律记\"fatal\"升级成\"fatal 还是 transient\"。Plan 11 的 ",[15,32144,32145],{},"Fault"," enum 早就是二分的了，Plan 10 只需给它加个 ",[15,32148,32149],{},".to_chain_error()"," 方法，然后 6 处注入点从 ",[15,32152,32153],{},".to_anyhow()"," 改成 ",[15,32156,32149],{}," 即可。",[11,32159,32160,32161,32164],{},"整个演变的思路是",[488,32162,32163],{},"可观测→可测试→可恢复→可调试","。快照测试只能验证\"结果对不对\"；注入测试能验证\"错误路径走得对不对\"；类型化错误让事后分析能从日志直接看出是暂时故障还是永久故障。",[26,32166,32167],{"id":32167},"中间夹的一些扎实细节",[11,32169,32170,32171,18211,32174,781],{},"plan7 之前的 plan1-6 是基建和容错的两层楼；plan7-11 的后半段在补",[488,32172,32173],{},"可观测",[488,32175,32176],{},"细节鲁棒性",[11,32178,32179,32180,32183,32184,32187],{},"Plan 7 加的 ",[15,32181,32182],{},"ota.lock"," 三模式协议（Exclusive \u002F MirrorLease \u002F PendingIntent）是为了防 OTA 和 guardian 同时写 state.json。guardian 跑 robocopy 前先 ",[15,32185,32186],{},"try_acquire(MirrorLease, timeout=3s)","，改死锁等待为超时快速失败。",[11,32189,32190],{},"Plan 8 不光加了 zip bomb 防护的 10GB 上限，还把 symlink reject 从隐式（File::create 默认不创建 symlink）变成显式（检查 unix_permissions bit 0o120000，命中就 bail OTA_ZIP_SYMLINK_REJECTED）。",[11,32192,32193],{},"Plan 9 的 poll-error 缓冲是从 events log 里抽出错误，放进 localStorage 的 50 条 FIFO，让用户在设置页看最近错了什么。从工程角度这是\"让故障可见\"；从产品角度这是\"用户能自救的信息\"。",[11,32195,32196,32197,88,32200,32203],{},"Plan 10 的 error catalog 用 25 个 const code 给每种错误分类。不是所有 context string 都一样对等，",[15,32198,32199],{},"SHA256_MISMATCH",[15,32201,32202],{},"CONNECTION_TIMEOUT"," 是两个完全不同的恢复策略。Plan 10 显式化了这种区分。",[26,32205,32206],{"id":32206},"版本演进的两条线",[11,32208,32209,32210,18211,32213,32216],{},"贯穿始终的是",[488,32211,32212],{},"架构演进",[488,32214,32215],{},"鲁棒性演进","两条线。",[11,32218,32219],{},"架构线：后端契约（plan1）→ launcher 启动链（plan2）→ skills 布局（plan3）→ USB 准入（plan4）→ 主链路（plan5）。这五步是\"能做什么\"。",[11,32221,32222],{},"鲁棒性线：happy path（plan5）→ resume 主链（plan6）→ 后台轮询+观测（plan7）→ 防护+分类（plan8）→ 错误可见（plan9）→ 错误类型（plan10）→ 故障注入（plan11）。这七步是\"当出错时，系统怎么活下来\"。",[11,32224,32225],{},"两条线的交点在 Plan 6。plan1-5 是\"system is go\"；plan6-11 是\"system is broken，now what\"。",[11,32227,32228],{},"这十一个版本压缩到一周多的时间里完成，version bump 这么快的原因就是——每一版都在修上一版的漏。不是说上一版设计有缺陷，而是真实场景的复杂度在逐版暴露出来。plan5 写完以为能跑了，结果 plan6 一加 resume 就发现\"噢，原来还要处理中间态\"。plan6 一写测试就发现\"我的 fixture 不够真实，需要注入\"。这种自洽的迭代，在设计冻结、被迫后测的项目里是看不到的。",{"title":183,"searchDepth":184,"depth":184,"links":32230},[32231,32232,32233,32234,32235],{"id":31960,"depth":184,"text":31961},{"id":32039,"depth":184,"text":32040},{"id":32089,"depth":184,"text":32090},{"id":32167,"depth":184,"text":32167},{"id":32206,"depth":184,"text":32206},"2026-05-05",{},"\u002F2026-05-05-otaplan1plan11",{"title":31952,"description":31957},"2026-05-05-OTA从plan1到plan11十一版设计改了什么","OTA 链路 11 个设计版本的演进轨迹：从后端签名→前端下载验证→容错恢复→故障注入，两个关键转折点。",[15069,32243,32244,32245,367,32246],"launcher","分布式","容错","设计演进","zTJIPC0TMep52bNowC2oy3-v3lxnYBzz8NnXLpcWcjw",{"id":32249,"title":32250,"body":32251,"column":355,"date":33061,"description":183,"extension":199,"hero_image":200,"meta":33062,"navigation":202,"path":33063,"seo":33064,"series_id":33065,"severity":200,"stem":33066,"summary":33067,"tags":33068,"__hash__":33073},"posts\u002F2026-05-04-FA-004-300ms防抖切页丢配置.md","配置填好了，切个页面——没了",{"type":8,"value":32252,"toc":33050},[32253,32256,32259,32262,32266,32269,32275,32280,32283,32289,32296,32299,32312,32315,32318,32324,32327,32348,32351,32353,32361,32394,32401,32429,32443,32452,32476,32482,32491,32512,32517,32555,32560,32567,32575,32595,32600,32609,32650,32653,32655,32663,32670,32776,32795,32802,32834,32840,32847,32918,32935,32941,32960,32965,32984,32987,32990,32993,33001,33004,33006,33009,33023,33030,33033,33039,33042,33045,33048],[26,32254,32255],{"id":32255},"矛盾的事实",[11,32257,32258],{},"用户在面板里填好 OpenAI 模型配置，等了一下，看起来 UI 响应了。关窗。重启面板。模型列表为空。配置消失得无声无息——没有错误提示、没有\"保存失败\"的警告、没有任何异常迹象。这个沉默正是最危险的地方。",[11,32260,32261],{},"让我查了客户的数据目录。",[26,32263,32265],{"id":32264},"现象从三个矛盾开始","现象：从三个矛盾开始",[11,32267,32268],{},"SSH 进去查数据目录：",[399,32270,32273],{"className":32271,"code":32272,"language":1327},[1325],"data\\openclaw\\openclaw.json                  1687B  mtime 2026\u002F5\u002F4 0:17:00\n  顶层字段: gateway, plugins, tools          ← 缺 models \u002F agents \u002F skills\n\ndata\\openclaw\\openclaw.json.bak              无 models 字段\ndata\\openclaw\\agents\\                        目录完全不存在\n",[15,32274,32272],{"__ignoreMap":183},[11,32276,32277,32279],{},[15,32278,20245],{}," 的修改时间停在 2026-05-01 20:33:42——面板首次启动时 calibration baseline 的时刻。之后三天，面板被打开过好多次，但这个文件的 mtime 一动不动。",[11,32281,32282],{},"gateway.log 里什么都有：",[399,32284,32287],{"className":32285,"code":32286,"language":1327},[1325],"2026-05-03 23:56:51  [reload] config change detected (models.providers.openai.models)\n2026-05-03 23:56:51  [reload] config hot reload applied (models.providers.openai.models)\n2026-05-04 00:16:40  [reload] config change detected (models)\n2026-05-04 00:16:40  [reload] config hot reload applied (models)\n",[15,32288,32286],{"__ignoreMap":183},[11,32290,32291,32292,32295],{},"日志明确说配置被加载过。那为什么备份文件从不更新？只有一个解释：",[15,32293,32294],{},"write_openclaw_config"," 几乎没被成功调用过。不是\"保存成功了又被擦除\"，而是\"压根没写盘\"。",[26,32297,32298],{"id":32298},"排查路线",[11,32300,32301,32302,56,32304,32307,32308,32311],{},"对比所有备份版本（",[15,32303,20245],{},[15,32305,32306],{},".pre-portable-repair.bak","）与 USB 缓存，全部缺 ",[15,32309,32310],{},"models"," 字段。如果真是\"曾保存过被清掉\"，至少某个备份该有完整字段。结论：从未成功写入过。",[11,32313,32314],{},"这排除了\"后端某个清理逻辑删了配置\"的假设。",[11,32316,32317],{},"让客户在面板重新配置一个 OpenAI 模型，明确点击保存。立即看磁盘：",[399,32319,32322],{"className":32320,"code":32321,"language":1327},[1325],"data\\openclaw\\openclaw.json  ← 立即写入，mtime 更新\nagents\\main\\agent\\          ← 目录被创建\ngateway.log:  [reload] config hot reload applied (models)  ← 即时加载\n",[15,32323,32321],{"__ignoreMap":183},[11,32325,32326],{},"写盘链路本身完全正常。问题不在落盘机制，在触发时机。",[11,32328,32329,32330,32333,32334,32337,32338,4375,32341,32344,32345,781],{},"grep ",[15,32331,32332],{},"writeOpenclawConfig","，找到 ",[15,32335,32336],{},"src\u002Fpages\u002Fmodels.js","。整个页面没有显式\"保存\"按钮。用户所有的配置变化都依赖 ",[15,32339,32340],{},"onChange",[15,32342,32343],{},"onInput"," 的防抖自动保存 ",[15,32346,32347],{},"autoSave",[11,32349,32350],{},"打开 router 导航逻辑，看 cleanup 怎么被调用。这才是关键。",[26,32352,28787],{"id":28787},[11,32354,32355,3724,32358,32360],{},[488,32356,32357],{},"前端 autoSave 的实现",[15,32359,32336],{}," 第 634-637 行)：",[399,32362,32364],{"className":18628,"code":32363,"language":18630,"meta":183,"style":183},"let _saveTimer = null\n\nfunction autoSave(state) {\n  clearTimeout(_saveTimer)\n  _saveTimer = setTimeout(() => doAutoSave(state), 300)  \u002F\u002F 防抖 300ms\n}\n",[15,32365,32366,32371,32375,32380,32385,32390],{"__ignoreMap":183},[407,32367,32368],{"class":409,"line":410},[407,32369,32370],{},"let _saveTimer = null\n",[407,32372,32373],{"class":409,"line":184},[407,32374,1827],{"emptyLinePlaceholder":202},[407,32376,32377],{"class":409,"line":189},[407,32378,32379],{},"function autoSave(state) {\n",[407,32381,32382],{"class":409,"line":452},[407,32383,32384],{},"  clearTimeout(_saveTimer)\n",[407,32386,32387],{"class":409,"line":458},[407,32388,32389],{},"  _saveTimer = setTimeout(() => doAutoSave(state), 300)  \u002F\u002F 防抖 300ms\n",[407,32391,32392],{"class":409,"line":464},[407,32393,483],{},[11,32395,32396,32397,32400],{},"每次字段变化都重置计时器，300ms 无新变化时才执行 ",[15,32398,32399],{},"doAutoSave","。这个设计本身无问题，问题在 cleanup：",[399,32402,32404],{"className":18628,"code":32403,"language":18630,"meta":183,"style":183},"export function cleanup() {\n  clearTimeout(_saveTimer)                    \u002F\u002F ← 直接丢弃，无 flush\n  _saveTimer = null\n  \u002F\u002F ...\n}\n",[15,32405,32406,32411,32416,32421,32425],{"__ignoreMap":183},[407,32407,32408],{"class":409,"line":410},[407,32409,32410],{},"export function cleanup() {\n",[407,32412,32413],{"class":409,"line":184},[407,32414,32415],{},"  clearTimeout(_saveTimer)                    \u002F\u002F ← 直接丢弃，无 flush\n",[407,32417,32418],{"class":409,"line":189},[407,32419,32420],{},"  _saveTimer = null\n",[407,32422,32423],{"class":409,"line":452},[407,32424,761],{},[407,32426,32427],{"class":409,"line":458},[407,32428,483],{},[11,32430,32431,32432,32435,32436,32439,32440,32442],{},"当用户导航离开本页面时，",[15,32433,32434],{},"cleanup()"," 被同步调用，计时器被 ",[15,32437,32438],{},"clearTimeout","。如果此时 pending 的 ",[15,32441,30712],{}," 还没来得及触发（即用户改字段后不足 300ms），那个写盘操作就永远不会发生。",[11,32444,32445,3724,32448,32451],{},[488,32446,32447],{},"路由切换时的调度",[15,32449,32450],{},"src\u002Frouter.js"," 第 49-52 行)：",[399,32453,32455],{"className":18628,"code":32454,"language":18630,"meta":183,"style":183},"if (_currentCleanup) {\n  try { _currentCleanup() } catch (_) {}     \u002F\u002F 同步调用，无 await 语义\n  _currentCleanup = null\n}\n",[15,32456,32457,32462,32467,32472],{"__ignoreMap":183},[407,32458,32459],{"class":409,"line":410},[407,32460,32461],{},"if (_currentCleanup) {\n",[407,32463,32464],{"class":409,"line":184},[407,32465,32466],{},"  try { _currentCleanup() } catch (_) {}     \u002F\u002F 同步调用，无 await 语义\n",[407,32468,32469],{"class":409,"line":189},[407,32470,32471],{},"  _currentCleanup = null\n",[407,32473,32474],{"class":409,"line":452},[407,32475,483],{},[11,32477,32478,32481],{},[15,32479,32480],{},"navigate()"," 本身是 async，但这里对 cleanup 的调用是同步的。cleanup 返回 Promise 也没人 await，pending 的写盘任务就被留在半空。",[11,32483,32484,3724,32487,32490],{},[488,32485,32486],{},"Tauri 侧的应用关闭",[15,32488,32489],{},"src-tauri\u002Fsrc\u002Flib.rs"," 第 281-298 行)：",[123,32492,32493,32503],{},[126,32494,32495,32498,32499,32502],{},[488,32496,32497],{},"便携版","：直接 ",[15,32500,32501],{},"std::process::exit(0)","，无任何 flush 通知前端",[126,32504,32505,1244,32508,32511],{},[488,32506,32507],{},"标准版",[15,32509,32510],{},"window.hide()"," 隐藏到托盘，计时器理论上还在，但用户已认为应用关闭",[11,32513,32514,1244],{},[488,32515,32516],{},"三种典型触发场景",[1708,32518,32519,32529],{},[1711,32520,32521],{},[1714,32522,32523,32526],{},[1717,32524,32525],{},"动作",[1717,32527,32528],{},"结果",[1730,32530,32531,32539,32547],{},[1714,32532,32533,32536],{},[1735,32534,32535],{},"填完字段后 \u003C 300ms 切到其他页面",[1735,32537,32538],{},"router cleanup → clearTimeout → setTimeout 永不执行",[1714,32540,32541,32544],{},[1735,32542,32543],{},"填完字段后 \u003C 300ms 关闭面板（便携版）",[1735,32545,32546],{},"进程立即退出，setTimeout 无机会运行",[1714,32548,32549,32552],{},[1735,32550,32551],{},"填完字段后 \u003C 300ms 切其他模型 provider 再切回",[1735,32553,32554],{},"同一计时器可能被新 state 覆盖",[11,32556,32557,1244],{},[488,32558,32559],{},"UX 层的雪上加霜",[11,32561,32562,32563,32566],{},"页面无显式保存按钮、无\"已保存\"角标、无 dirty 指示、关窗不弹确认对话。用户填完字段看到 UI 响应，主观认为\"配置已生效\"。等到进程重启读 ",[15,32564,32565],{},"openclaw.json"," 才发现一切归零——这时已太晚。",[11,32568,32569,1244],{},[488,32570,32571,32572,32574],{},"为什么 ",[15,32573,20245],{}," 的 mtime 停在初始化时刻",[11,32576,32577,32578,32581,32582,32584,32585,32587,32588,32590,32591,32594],{},"calibration baseline 的同步代码走的是 ",[15,32579,32580],{},"fs::write"," 直接写入路径，UI 阻塞，必然落盘成功。之后每次用户修改配置全走 ",[15,32583,32347],{}," 防抖。只要 300ms 内导航走，",[15,32586,32399],{}," 的调用链路就不会触发，",[15,32589,32294],{}," 从不被调用，",[15,32592,32593],{},"fs::copy(&path, &bak)"," 也不会执行。备份文件 mtime 永久停留在 5\u002F1 20:33:42，反向锁死了根因。",[11,32596,32597,1244],{},[488,32598,32599],{},"扩散范围",[11,32601,32602,32603,40,32605,32608],{},"grep 扫描所有页面的 ",[15,32604,32332],{},[15,32606,32607],{},"_saveTimer"," 模式，确认下列页面均受同一缺陷影响：",[123,32610,32611,32616,32622,32628,32634,32640],{},[126,32612,32613,32615],{},[15,32614,32336],{}," — 现场客户触发的主页面",[126,32617,32618,32621],{},[15,32619,32620],{},"src\u002Fpages\u002Fgateway.js"," 第 296 行",[126,32623,32624,32627],{},[15,32625,32626],{},"src\u002Fpages\u002Fcron.js"," 第 91 行",[126,32629,32630,32633],{},[15,32631,32632],{},"src\u002Fpages\u002Fcommunication.js"," 第 71 行",[126,32635,32636,32639],{},[15,32637,32638],{},"src\u002Fpages\u002Fservices.js"," 第 871 行",[126,32641,32642,32645,32646,32649],{},[15,32643,32644],{},"src\u002Fpages\u002Fassistant.js"," — 多处 ",[15,32647,32648],{},"saveConfig()"," 调用",[11,32651,32652],{},"至少 5 个以上的配置页面都有相同的 300ms 防抖 + 无 flush cleanup 问题。",[26,32654,18998],{"id":18998},[11,32656,32657,3724,32660,32662],{},[488,32658,32659],{},"P0 — cleanup 改造成 async + flush 待命",[15,32661,32336],{}," 第 628 行)：",[11,32664,32665,32666,32669],{},"需要先把 ",[15,32667,32668],{},"state"," 提升到模块级变量，使 cleanup 能访问：",[399,32671,32673],{"className":18628,"code":32672,"language":18630,"meta":183,"style":183},"let _lastState = null\nlet _saveTimer = null\n\nfunction autoSave(state) {\n  _lastState = state                                    \u002F\u002F 保存当前 state\n  clearTimeout(_saveTimer)\n  _saveTimer = setTimeout(() => doAutoSave(state), 300)\n}\n\nexport async function cleanup() {\n  if (_saveTimer) {\n    clearTimeout(_saveTimer)\n    _saveTimer = null\n    try {\n      await doAutoSave(_lastState)                      \u002F\u002F flush pending 写盘\n    } catch (e) {\n      console.error('[models] flush on cleanup failed:', e)\n    }\n  }\n  if (_batchTestAbort) { _batchTestAbort.abort = true; _batchTestAbort = null }\n  cancelPendingRestart()\n}\n",[15,32674,32675,32680,32684,32688,32692,32697,32701,32706,32710,32714,32719,32724,32729,32734,32739,32744,32749,32754,32758,32762,32767,32772],{"__ignoreMap":183},[407,32676,32677],{"class":409,"line":410},[407,32678,32679],{},"let _lastState = null\n",[407,32681,32682],{"class":409,"line":184},[407,32683,32370],{},[407,32685,32686],{"class":409,"line":189},[407,32687,1827],{"emptyLinePlaceholder":202},[407,32689,32690],{"class":409,"line":452},[407,32691,32379],{},[407,32693,32694],{"class":409,"line":458},[407,32695,32696],{},"  _lastState = state                                    \u002F\u002F 保存当前 state\n",[407,32698,32699],{"class":409,"line":464},[407,32700,32384],{},[407,32702,32703],{"class":409,"line":470},[407,32704,32705],{},"  _saveTimer = setTimeout(() => doAutoSave(state), 300)\n",[407,32707,32708],{"class":409,"line":480},[407,32709,483],{},[407,32711,32712],{"class":409,"line":1477},[407,32713,1827],{"emptyLinePlaceholder":202},[407,32715,32716],{"class":409,"line":1483},[407,32717,32718],{},"export async function cleanup() {\n",[407,32720,32721],{"class":409,"line":2139},[407,32722,32723],{},"  if (_saveTimer) {\n",[407,32725,32726],{"class":409,"line":2180},[407,32727,32728],{},"    clearTimeout(_saveTimer)\n",[407,32730,32731],{"class":409,"line":7546},[407,32732,32733],{},"    _saveTimer = null\n",[407,32735,32736],{"class":409,"line":7564},[407,32737,32738],{},"    try {\n",[407,32740,32741],{"class":409,"line":17267},[407,32742,32743],{},"      await doAutoSave(_lastState)                      \u002F\u002F flush pending 写盘\n",[407,32745,32746],{"class":409,"line":17279},[407,32747,32748],{},"    } catch (e) {\n",[407,32750,32751],{"class":409,"line":18010},[407,32752,32753],{},"      console.error('[models] flush on cleanup failed:', e)\n",[407,32755,32756],{"class":409,"line":18057},[407,32757,18167],{},[407,32759,32760],{"class":409,"line":18062},[407,32761,563],{},[407,32763,32764],{"class":409,"line":18067},[407,32765,32766],{},"  if (_batchTestAbort) { _batchTestAbort.abort = true; _batchTestAbort = null }\n",[407,32768,32769],{"class":409,"line":18072},[407,32770,32771],{},"  cancelPendingRestart()\n",[407,32773,32774],{"class":409,"line":18084},[407,32775,483],{},[11,32777,32778,32779,4375,32782,4375,32785,4375,32788,4375,32791,32794],{},"其他 5 个页面（",[15,32780,32781],{},"gateway.js",[15,32783,32784],{},"cron.js",[15,32786,32787],{},"communication.js",[15,32789,32790],{},"services.js",[15,32792,32793],{},"assistant.js","）同样改造。",[11,32796,32797,3724,32800,32451],{},[488,32798,32799],{},"P0 — router 导航改成 await cleanup",[15,32801,32450],{},[399,32803,32805],{"className":18628,"code":32804,"language":18630,"meta":183,"style":183},"if (_currentCleanup) {\n  try {\n    await _currentCleanup()                             \u002F\u002F await 而非直接调\n  } catch (_) {}\n  _currentCleanup = null\n}\n",[15,32806,32807,32811,32816,32821,32826,32830],{"__ignoreMap":183},[407,32808,32809],{"class":409,"line":410},[407,32810,32461],{},[407,32812,32813],{"class":409,"line":184},[407,32814,32815],{},"  try {\n",[407,32817,32818],{"class":409,"line":189},[407,32819,32820],{},"    await _currentCleanup()                             \u002F\u002F await 而非直接调\n",[407,32822,32823],{"class":409,"line":452},[407,32824,32825],{},"  } catch (_) {}\n",[407,32827,32828],{"class":409,"line":458},[407,32829,32471],{},[407,32831,32832],{"class":409,"line":464},[407,32833,483],{},[11,32835,32836,32839],{},[15,32837,32838],{},"navigate"," 函数本身已是 async，加 await 不影响外部 API 的同步表现。",[11,32841,32842,3724,32845,32490],{},[488,32843,32844],{},"P0 — Tauri CloseRequested 拦截 flush",[15,32846,32489],{},[399,32848,32850],{"className":27261,"code":32849,"language":27263,"meta":183,"style":183},"CloseRequested => {\n  match config.mode {\n    \"portable\" => {\n      window.emit(\"app:will-close\", ()).ok();          \u002F\u002F 通知前端开始 flush\n      \u002F\u002F 前端监听此事件，同步执行 doAutoSave，完成后调用 confirm_close\n      \u002F\u002F Rust 侧等待 confirm_close invoke 或 5 秒超时后 exit\n    }\n    \"standard\" => {\n      \u002F\u002F 同样 flush，再 hide 到托盘\n      window.emit(\"app:will-close\", ()).ok();\n      window.hide().ok();\n    }\n  }\n}\n",[15,32851,32852,32857,32862,32867,32872,32877,32882,32886,32891,32896,32901,32906,32910,32914],{"__ignoreMap":183},[407,32853,32854],{"class":409,"line":410},[407,32855,32856],{},"CloseRequested => {\n",[407,32858,32859],{"class":409,"line":184},[407,32860,32861],{},"  match config.mode {\n",[407,32863,32864],{"class":409,"line":189},[407,32865,32866],{},"    \"portable\" => {\n",[407,32868,32869],{"class":409,"line":452},[407,32870,32871],{},"      window.emit(\"app:will-close\", ()).ok();          \u002F\u002F 通知前端开始 flush\n",[407,32873,32874],{"class":409,"line":458},[407,32875,32876],{},"      \u002F\u002F 前端监听此事件，同步执行 doAutoSave，完成后调用 confirm_close\n",[407,32878,32879],{"class":409,"line":464},[407,32880,32881],{},"      \u002F\u002F Rust 侧等待 confirm_close invoke 或 5 秒超时后 exit\n",[407,32883,32884],{"class":409,"line":470},[407,32885,18167],{},[407,32887,32888],{"class":409,"line":480},[407,32889,32890],{},"    \"standard\" => {\n",[407,32892,32893],{"class":409,"line":1477},[407,32894,32895],{},"      \u002F\u002F 同样 flush，再 hide 到托盘\n",[407,32897,32898],{"class":409,"line":1483},[407,32899,32900],{},"      window.emit(\"app:will-close\", ()).ok();\n",[407,32902,32903],{"class":409,"line":2139},[407,32904,32905],{},"      window.hide().ok();\n",[407,32907,32908],{"class":409,"line":2180},[407,32909,18167],{},[407,32911,32912],{"class":409,"line":7546},[407,32913,563],{},[407,32915,32916],{"class":409,"line":7564},[407,32917,483],{},[11,32919,32920,32921,32924,32925,32928,32929,32932,32933,781],{},"前端需要监听 ",[15,32922,32923],{},"app:will-close"," 事件，若有 pending autoSave 立即同步执行 ",[15,32926,32927],{},"doAutoSave()","，完成或超时后调用 ",[15,32930,32931],{},"invoke('confirm_close')","。Rust 侧收到确认或等待超时（5 秒兜底）后才执行 ",[15,32934,14944],{},[11,32936,32937,32940],{},[488,32938,32939],{},"P1 — UI 反馈层面","（不紧急但重要）：",[123,32942,32943,32952,32955],{},[126,32944,32945,32946,40,32949,32951],{},"模型页右上角加状态角标：\"已保存\" \u002F \"保存中\" \u002F \"未保存\"，来源于 ",[15,32947,32948],{},"_saveTimer != null",[15,32950,32399],{}," 的 Promise 状态",[126,32953,32954],{},"模型页加显式\"保存\"按钮（虽然 autoSave 已覆盖大多数场景，但用户心理预期需要这个 button）",[126,32956,32957,32959],{},[15,32958,32399],{}," 写盘成功立即吐 toast \"配置已保存\"（无需等 gateway 重启），gateway 重启后再吐 \"配置已应用\"",[11,32961,32962,1244],{},[488,32963,32964],{},"P2 — 全局 beforeunload 兜底",[399,32966,32968],{"className":18628,"code":32967,"language":18630,"meta":183,"style":183},"window.addEventListener('beforeunload', () => {\n  if (_saveTimer) doAutoSave(_lastState)                \u002F\u002F 同步 flush\n})\n",[15,32969,32970,32975,32980],{"__ignoreMap":183},[407,32971,32972],{"class":409,"line":410},[407,32973,32974],{},"window.addEventListener('beforeunload', () => {\n",[407,32976,32977],{"class":409,"line":184},[407,32978,32979],{},"  if (_saveTimer) doAutoSave(_lastState)                \u002F\u002F 同步 flush\n",[407,32981,32982],{"class":409,"line":189},[407,32983,30616],{},[11,32985,32986],{},"浏览器\u002FWebView 异常关闭或系统强制杀进程时的最后防线。",[26,32988,32989],{"id":32989},"复现与缓解",[11,32991,32992],{},"修复发布前，客户想保护数据可以这样做：",[243,32994,32995,32998],{},[126,32996,32997],{},"改完字段后等待 1 秒，看右下角是否吐出 toast",[126,32999,33000],{},"或填完后点页面空白处，再等 1 秒再切走\u002F关闭",[11,33002,33003],{},"便携版用户一个额外细节：即便这个 bug 修好了，关闭 panel 后仍需等 2 分钟让 guardian 完成到 USB 的反向同步，才能安心重启。",[26,33005,26358],{"id":26358},[11,33007,33008],{},"当前素材里尚无已实施的防回归。需要的包括：",[123,33010,33011,33014,33017,33020],{},[126,33012,33013],{},"单元测试：快速导航场景，验证 cleanup 能 flush 待命任务",[126,33015,33016],{},"E2E 测试：填字段立即切页\u002F关窗，确保落盘",[126,33018,33019],{},"构建检查：扫描所有 cleanup，确保返回 Promise 且被正确 await",[126,33021,33022],{},"CI 巡检：定期测试便携版快速改配置并重启",[11,33024,33025,33026,33029],{},"修复前复现（任意模式）：启动 panel → 进模型页 → 添加 provider → 填 baseURL + apiKey + 选模型 → ",[488,33027,33028],{},"最后一字段后 100ms 内立即切侧边栏到\"对话\""," → 关闭面板 → 重启。修复前模型列表空，修复后保留。",[26,33031,33032],{"id":33032},"关联问题",[11,33034,33035,33036,33038],{},"素材中记录了另一个独立根因：便携启动序列 ",[15,33037,28092],{}," 时把 cache 新版覆盖回 USB 旧版。那个 bug（FA-003）的症状也是\"重启配置消失\"，但根因完全不同——启动脚本的同步顺序。",[11,33040,33041],{},"本 bug（FA-004）也导致配置消失，但根因是前端写盘没被触发。两个独立问题，在便携模式下症状叠加：修好这个 bug 的 P0（flush 成功）后，用户还需等 guardian 完成 120 秒间隔的反向同步到 USB，否则 2 分钟内重启 panel 仍可能被 USB 旧版本回滚。便携版用户两个修复都需要。",[26,33043,33044],{"id":33044},"深层教训",[11,33046,33047],{},"静默失败比报错更危险——用户会继续信任 UI，不知道磁盘没落盘。防抖保存必须满足三个条件：cleanup 必须 flush（不能丢待命任务）、应用关闭必须被拦截并等待 flush、保存状态必须对用户可见。缺一，就出现\"一切看起来都对，但数据其实没保存\"的局面。",[1267,33049,16447],{},{"title":183,"searchDepth":184,"depth":184,"links":33051},[33052,33053,33054,33055,33056,33057,33058,33059,33060],{"id":32255,"depth":184,"text":32255},{"id":32264,"depth":184,"text":32265},{"id":32298,"depth":184,"text":32298},{"id":28787,"depth":184,"text":28787},{"id":18998,"depth":184,"text":18998},{"id":32989,"depth":184,"text":32989},{"id":26358,"depth":184,"text":26358},{"id":33032,"depth":184,"text":33032},{"id":33044,"depth":184,"text":33044},"2026-05-04",{},"\u002F2026-05-04-fa-004-300ms",{"title":32250,"description":183},"FA-004","2026-05-04-FA-004-300ms防抖切页丢配置","防抖保存依赖 300ms 计时器，cleanup 直接丢弃 pending 任务，导致页面切换或应用关闭时配置静默丢失。",[33069,33070,28285,33071,33072,19953],"JavaScript","防抖","前端架构","数据持久化","Q78CGs3bePtx-vqY_kiWp1q055NpVRSu-zdTH2zX_bM",{"id":33075,"title":33076,"body":33077,"column":355,"date":33617,"description":33618,"extension":199,"hero_image":200,"meta":33619,"navigation":202,"path":33620,"seo":33621,"series_id":33622,"severity":200,"stem":33623,"summary":33624,"tags":33625,"__hash__":33630},"posts\u002F2026-05-02-FA-003-90秒watchdog吞掉真实错误.md","「输出超时」——可文件已经装好了",{"type":8,"value":33078,"toc":33605},[33079,33089,33091,33094,33097,33105,33108,33110,33115,33126,33131,33146,33149,33247,33257,33262,33269,33275,33282,33285,33290,33297,33303,33310,33317,33319,33322,33327,33333,33364,33371,33376,33383,33385,33390,33393,33397,33400,33405,33420,33425,33428,33435,33438,33518,33527,33530,33541,33544,33588,33594,33596,33599,33602],[11,33080,33081,33082,88,33085,33088],{},"「输出超时，已自动结束」。用户看到这个提示，肯定以为失败了。但检查客户机的文件系统发现：",[15,33083,33084],{},"~\u002F.skillhub\u002Fconfig.json",[15,33086,33087],{},"~\u002F.local\u002Fbin\u002Fskillhub.cmd"," 都已落地，时间戳是 17:51:16 和 17:51:19。文件装好了。错位了。",[26,33090,25207],{"id":25207},[11,33092,33093],{},"某客户机（Windows），用户让 deepseek-v4-flash 帮忙手动安装 SkillHub CLI，在无 bash 环境下。AI 开始顺利执行：先 web_fetch 拉取安装文档，提示「先检查是否已安装 SkillHub CLI」，随后进入连续工具循环——exec \u002F read \u002F write 轮流调用，手工解包 tarball、复制 .py 文件、写 skillhub.cmd、修改 PATH。",[11,33095,33096],{},"约 90 秒后，聊天框直接弹出系统消息：「输出超时，已自动结束」。AI 的输出被截断，会话强制结束。",[11,33098,33099,33100,88,33102,33104],{},"看起来失败了。但检查客户机发现：",[15,33101,33084],{},[15,33103,33087],{}," 都已落地，时间戳分别是 17:51:16 和 17:51:19。文件装好了。",[11,33106,33107],{},"这就是那个典型的\"前端保护机制掩盖后端真实状态\"的现象——超时提示完全错误，但用户根本看不到真相。",[26,33109,25231],{"id":25231},[11,33111,33112],{},[488,33113,33114],{},"第一步：找到超时提示的源头",[11,33116,33117,33118,33121,33122,33125],{},"grep 整个 wakou-full 仓库搜「输出超时，已自动结束」，定位到两个位置：",[15,33119,33120],{},"src\u002Flocales\u002Fzh-CN.json:1655"," 的多语言文本，以及 ",[15,33123,33124],{},"src\u002Fpages\u002Fchat.js:1909"," 的调用点。",[11,33127,33128],{},[488,33129,33130],{},"第二步：看计时器的逻辑",[11,33132,33133,33134,33137,33138,33141,33142,33145],{},"打开 ",[15,33135,33136],{},"chat.js:1907-1917","，这是一个 90 秒的安全计时器。每次收到 text delta 事件就调用 ",[15,33139,33140],{},"clearTimeout()"," 重置，超时后就执行 ",[15,33143,33144],{},"appendSystemMessage(t('chat.streamTimeout'))"," 加上强制 reset 流式状态。",[11,33147,33148],{},"逻辑看起来合理，但 reset 的触发条件很关键：",[399,33150,33152],{"className":14691,"code":33151,"language":14693,"meta":183,"style":183},"if (state === 'delta') {\n  ...\n  if (c?.text && c.text.length > _currentAiText.length) {\n    ...\n    clearTimeout(_streamSafetyTimer)\n    _streamSafetyTimer = setTimeout(() => { ... 90s 后强切 ... }, 90000)\n  }\n}\n",[15,33153,33154,33168,33172,33196,33201,33209,33239,33243],{"__ignoreMap":183},[407,33155,33156,33158,33161,33163,33166],{"class":409,"line":410},[407,33157,875],{"class":417},[407,33159,33160],{"class":413}," (state ",[407,33162,881],{"class":417},[407,33164,33165],{"class":429}," 'delta'",[407,33167,887],{"class":413},[407,33169,33170],{"class":409,"line":184},[407,33171,27787],{"class":417},[407,33173,33174,33176,33179,33181,33184,33187,33189,33192,33194],{"class":409,"line":189},[407,33175,3721],{"class":417},[407,33177,33178],{"class":413}," (c?.text ",[407,33180,5203],{"class":417},[407,33182,33183],{"class":413}," c.text.",[407,33185,33186],{"class":476},"length",[407,33188,23064],{"class":417},[407,33190,33191],{"class":413}," _currentAiText.",[407,33193,33186],{"class":476},[407,33195,887],{"class":413},[407,33197,33198],{"class":409,"line":452},[407,33199,33200],{"class":417},"    ...\n",[407,33202,33203,33206],{"class":409,"line":458},[407,33204,33205],{"class":554},"    clearTimeout",[407,33207,33208],{"class":413},"(_streamSafetyTimer)\n",[407,33210,33211,33214,33216,33218,33220,33222,33224,33226,33229,33231,33234,33237],{"class":409,"line":464},[407,33212,33213],{"class":413},"    _streamSafetyTimer ",[407,33215,418],{"class":417},[407,33217,7476],{"class":554},[407,33219,5531],{"class":413},[407,33221,520],{"class":417},[407,33223,7652],{"class":413},[407,33225,7262],{"class":417},[407,33227,33228],{"class":413}," 90s 后强切 ",[407,33230,7262],{"class":417},[407,33232,33233],{"class":413}," }, ",[407,33235,33236],{"class":476},"90000",[407,33238,3970],{"class":413},[407,33240,33241],{"class":409,"line":470},[407,33242,563],{"class":413},[407,33244,33245],{"class":409,"line":480},[407,33246,483],{"class":413},[11,33248,33249,33250,19424,33253,33256],{},"只有在 ",[15,33251,33252],{},"state === 'delta'",[15,33254,33255],{},"text 长度增长"," 时才会 reset。这是关键漏洞。",[11,33258,33259],{},[488,33260,33261],{},"第三步：找会话记录验证",[11,33263,33264,33265,33268],{},"进客户机的本地数据目录，找到 ",[15,33266,33267],{},"data\u002Fopenclaw\u002Fagents\u002Fmain\u002Fsessions\u002F"," 下的会话日志文件（JSONL 格式），时间范围 17:50:32 → 17:51:22，约 50 秒跨度。逐行解析事件流：",[399,33270,33273],{"className":33271,"code":33272,"language":1327},[1325],"17:50:32 - state: 'delta', text: '先检查是否已安装 SkillHub CLI。'\n17:50:35 - state: 'delta', text: '正在检查...' （这是最后一个 text 输出）\n17:50:37 - state: 'thinking', reasoning_content: [...]\n17:50:43 - state: 'toolCall', tool: 'exec', args: {...}\n17:50:45 - state: 'toolResult', result: {...}\n17:50:46 - state: 'thinking', reasoning_content: [...]\n... （重复的 thinking + toolCall + toolResult 循环）\n17:51:15 - state: 'toolResult', result: '文件已写入 ~\u002F.local\u002Fbin\u002Fskillhub.cmd'\n",[15,33274,33272],{"__ignoreMap":183},[11,33276,33277,33278,33281],{},"共 10+ 轮 thinking + toolCall 事件。",[488,33279,33280],{},"期间没有任何新的 text 块","。最后一次 text 输出发生在 17:50:35，然后就是工具循环。",[11,33283,33284],{},"90 秒的计时器从 17:50:35 开始计数（此时最后一次 reset），约 17:52:05 时触发（距离最后的活动已经 90+ 秒）。",[11,33286,33287],{},[488,33288,33289],{},"第四步：发现被吞掉的真实错误",[11,33291,33292,33293,33296],{},"同时期的后端日志 ",[15,33294,33295],{},"profile\u002FTemp\u002Fopenclaw\u002Fopenclaw-2026-05-02.log","，17:51:41 记录了一条 deepseek API 返回 400：",[399,33298,33301],{"className":33299,"code":33300,"language":1327},[1325],"error: The reasoning_content in the thinking mode must be passed back to the API.\ncode: 400\n",[15,33302,33300],{"__ignoreMap":183},[11,33304,33305,33306,33309],{},"这是 deepseek 的 thinking 模式协议要求——当模型产出 ",[15,33307,33308],{},"reasoning_content"," 时，agent runtime 必须在下一轮请求里回传这段内容，否则 API 会拒绝。wakou 的 agent runtime 在多轮对话中没有正确回传，导致了这个 400 错误。",[11,33311,33312,33313,33316],{},"但这条错误",[488,33314,33315],{},"被前端 watchdog 抢先吞掉了","。用户只看到「输出超时」，工程师也看不到真实的 API 错误。排障线索完全丢失。",[26,33318,27321],{"id":27321},[11,33320,33321],{},"两层问题叠加：",[11,33323,33324],{},[488,33325,33326],{},"一级问题：超时判据不完整",[11,33328,33329,33330,1244],{},"计时器只把 text delta 当作\"模型仍在工作\"的信号。但当下 agent 的常态是",[488,33331,33332],{},"长工具链和长思考链",[123,33334,33335,33345,33361],{},[126,33336,33337,33340,33341,33344],{},[15,33338,33339],{},"thinking"," 事件只是模型的 reasoning_content，不增加 ",[15,33342,33343],{},"c.text"," 长度，不会 reset 计时器；",[126,33346,33347,4375,33350,33353,33354,33357,33358,33360],{},[15,33348,33349],{},"toolCall",[15,33351,33352],{},"toolResult"," 事件走的是另一条消息路径（",[15,33355,33356],{},"payload.message.tools","），同样不触发 ",[15,33359,33252],{}," 的分支；",[126,33362,33363],{},"实际的工具执行（比如 web_fetch、exec 在 Windows 上跑 Invoke-WebRequest 拉 tarball）可能耗时 30s+。",[11,33365,33366,33367,33370],{},"只要\"两次连续 text 输出之间的间隔 > 90s\"，watchdog 就会误杀。而这种间隔在「先报告思路 → 进入长工具循环 → 工具执行完成后汇报结果」的对话模式里是",[488,33368,33369],{},"完全正常的","，不是异常。",[11,33372,33373],{},[488,33374,33375],{},"二级问题：错误被覆盖",[11,33377,33378,33379,33382],{},"watchdog 误杀后，前端把 ",[15,33380,33381],{},"state:'error'"," 的事件处理路径也覆盖掉了。后端返回的真实错误（比如这次的 deepseek 400）无法被用户和工程师看到。排障难度上升，因为现象看起来就是\"超时\"，导致团队去排查网络、延迟、模型响应，而不是去看 API 协议是否实现正确。",[26,33384,27410],{"id":27410},[11,33386,33387],{},[488,33388,33389],{},"根治方案：让超时判据覆盖所有\"活着\"的信号",[11,33391,33392],{},"需要前后端配合。前端不能只看 text delta，而是只要后端还在工作就持续 reset；超时只在\"真的什么都没发生\"时才触发。",[31,33394,33396],{"id":33395},"后端侧修改gateway-agent-runtime","后端侧修改（gateway \u002F agent runtime）",[11,33398,33399],{},"在 SSE\u002F事件发送处，在工具调用、reasoning、思考阶段持续推送 keepalive 事件。两个等价做法：",[11,33401,33402],{},[488,33403,33404],{},"推荐方案：新增独立事件",[11,33406,33407,33408,33411,33412,33415,33416,33419],{},"每隔约 10 秒推一个 ",[15,33409,33410],{},"state:'progress'"," 事件，payload 带当前阶段标签和进度信息。这样前端在收到任意非 ",[15,33413,33414],{},"final|error"," 事件时都能 reset 计时器。事件语义清晰，后续 UI 也能基于 ",[15,33417,33418],{},"progress"," 显示\"正在执行 xxx 工具\"。",[11,33421,33422],{},[488,33423,33424],{},"备选方案：复用 delta 承载",[11,33426,33427],{},"在 thinking \u002F tool 阶段把当前阶段名、工具名、进度作为非空 payload 的 delta 推出来（不一定要写到 text 字段），前端在收到任何 delta（含 thinking、tools 字段更新）时统一 reset。这个方案改动小但语义不如第一种清晰。",[31,33429,33431,33432,13615],{"id":33430},"前端侧修改srcpageschatjs","前端侧修改（",[15,33433,33434],{},"src\u002Fpages\u002Fchat.js",[11,33436,33437],{},"把 reset 触发条件从\"只看 text 长度增长\"放宽到\"收到任何 state ≠ final|error 的事件\"：",[399,33439,33441],{"className":14691,"code":33440,"language":14693,"meta":183,"style":183},"if (state === 'delta' || state === 'progress' || state === 'tool_running' || state === 'thinking') {\n  clearTimeout(_streamSafetyTimer)\n  _streamSafetyTimer = setTimeout(() => { ... }, STREAM_TIMEOUT_MS)\n}\n",[15,33442,33443,33483,33490,33514],{"__ignoreMap":183},[407,33444,33445,33447,33449,33451,33453,33455,33458,33460,33463,33465,33467,33469,33472,33474,33476,33478,33481],{"class":409,"line":410},[407,33446,875],{"class":417},[407,33448,33160],{"class":413},[407,33450,881],{"class":417},[407,33452,33165],{"class":429},[407,33454,5238],{"class":417},[407,33456,33457],{"class":413}," state ",[407,33459,881],{"class":417},[407,33461,33462],{"class":429}," 'progress'",[407,33464,5238],{"class":417},[407,33466,33457],{"class":413},[407,33468,881],{"class":417},[407,33470,33471],{"class":429}," 'tool_running'",[407,33473,5238],{"class":417},[407,33475,33457],{"class":413},[407,33477,881],{"class":417},[407,33479,33480],{"class":429}," 'thinking'",[407,33482,887],{"class":413},[407,33484,33485,33488],{"class":409,"line":184},[407,33486,33487],{"class":554},"  clearTimeout",[407,33489,33208],{"class":413},[407,33491,33492,33495,33497,33499,33501,33503,33505,33507,33509,33512],{"class":409,"line":189},[407,33493,33494],{"class":413},"  _streamSafetyTimer ",[407,33496,418],{"class":417},[407,33498,7476],{"class":554},[407,33500,5531],{"class":413},[407,33502,520],{"class":417},[407,33504,7652],{"class":413},[407,33506,7262],{"class":417},[407,33508,33233],{"class":413},[407,33510,33511],{"class":476},"STREAM_TIMEOUT_MS",[407,33513,3970],{"class":413},[407,33515,33516],{"class":409,"line":452},[407,33517,483],{"class":413},[11,33519,33520,33521,33523,33524,33526],{},"同时把 ",[15,33522,33511],{}," 从 90 秒调整到 180 秒作为兜底。即便 keepalive 事件因为网络或其他原因没按时来，也给后端、网络、模型多留一点容错空间。建议把 ",[15,33525,33511],{}," 常量提到文件顶部，方便后续按不同客户场景调整。",[31,33528,33529],{"id":33529},"顺手修的二级问题",[11,33531,33532,33533,33536,33537,33540],{},"watchdog 触发后不要清空 ",[15,33534,33535],{},"runId"," 状态，给后端一个继续把 ",[15,33538,33539],{},"final \u002F error"," 事件推上来的窗口。这样真实错误（包括 deepseek 的 API 400、超时、业务逻辑错误）仍能展示给用户和工程师，便于排障。",[11,33542,33543],{},"修改建议：",[399,33545,33547],{"className":14691,"code":33546,"language":14693,"meta":183,"style":183},"if (timeout triggered) {\n  appendSystemMessage(t('chat.streamTimeout'))\n  \u002F\u002F 不调用 resetStreamState()，保留 runId\n  \u002F\u002F 让后端继续推送 final \u002F error 事件\n}\n",[15,33548,33549,33556,33574,33579,33584],{"__ignoreMap":183},[407,33550,33551,33553],{"class":409,"line":410},[407,33552,875],{"class":417},[407,33554,33555],{"class":413}," (timeout triggered) {\n",[407,33557,33558,33561,33563,33566,33568,33571],{"class":409,"line":184},[407,33559,33560],{"class":554},"  appendSystemMessage",[407,33562,586],{"class":413},[407,33564,33565],{"class":554},"t",[407,33567,586],{"class":413},[407,33569,33570],{"class":429},"'chat.streamTimeout'",[407,33572,33573],{"class":413},"))\n",[407,33575,33576],{"class":409,"line":189},[407,33577,33578],{"class":528},"  \u002F\u002F 不调用 resetStreamState()，保留 runId\n",[407,33580,33581],{"class":409,"line":452},[407,33582,33583],{"class":528},"  \u002F\u002F 让后端继续推送 final \u002F error 事件\n",[407,33585,33586],{"class":409,"line":458},[407,33587,483],{"class":413},[11,33589,33590,33591,33593],{},"deepseek thinking 模式 ",[15,33592,33308],{}," 必须回传是另一个独立 bug（已单独跟进），不在本次范围内。",[26,33595,23827],{"id":23827},[11,33597,33598],{},"任何\"前端拿超时 watchdog 守后端流\"的场景都要明确\"在工作\"的最小信号集。只盯 text delta 是天然脆弱的，因为 LLM 工具循环里 text 本来就会出现长间隔。需要在 SSE\u002FWS 事件规范中明确约定：后端在长任务（tool \u002F 网络拉取 \u002F reasoning）阶段必须有 ≤ 30 秒心跳。",[11,33600,33601],{},"测试层应构造一个\"AI 执行 10+ 轮工具调用但中间没有 text 输出\"的测试用例，验证 watchdog 不会误杀。监控层则需要关联前端 watchdog 触发频率与后端 error 日志，如果超时提示频繁出现但没有对应的真实错误，说明 watchdog 在误杀。",[1267,33603,33604],{},"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 .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}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 .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}",{"title":183,"searchDepth":184,"depth":184,"links":33606},[33607,33608,33609,33610,33616],{"id":25207,"depth":184,"text":25207},{"id":25231,"depth":184,"text":25231},{"id":27321,"depth":184,"text":27321},{"id":27410,"depth":184,"text":27410,"children":33611},[33612,33613,33615],{"id":33395,"depth":189,"text":33396},{"id":33430,"depth":189,"text":33614},"前端侧修改（src\u002Fpages\u002Fchat.js）",{"id":33529,"depth":189,"text":33529},{"id":23827,"depth":184,"text":23827},"2026-05-02","「输出超时，已自动结束」。用户看到这个提示，肯定以为失败了。但检查客户机的文件系统发现：~\u002F.skillhub\u002Fconfig.json 和 ~\u002F.local\u002Fbin\u002Fskillhub.cmd 都已落地，时间戳是 17:51:16 和 17:51:19。文件装好了。错位了。",{},"\u002F2026-05-02-fa-003-90watchdog",{"title":33076,"description":33618},"FA-003","2026-05-02-FA-003-90秒watchdog吞掉真实错误","前端超时 watchdog 误杀长工具链，同时掩盖后端真实错误，导致排障困难。",[33626,33627,33628,17682,33629],"流式超时","前端保护机制","工具循环","错误掩盖","gCppWxNJ16GX1IU2TcDNmp80dcyNAo5sVYr4kV5WdOc",{"id":33632,"title":33633,"body":33634,"column":355,"date":33828,"description":33638,"extension":199,"hero_image":200,"meta":33829,"navigation":202,"path":33830,"seo":33831,"series_id":33832,"severity":200,"stem":33833,"summary":33834,"tags":33835,"__hash__":33840},"posts\u002F2026-05-01-FA-002-事件循环阻塞22秒.md","一次 sessions.list，23 秒",{"type":8,"value":33635,"toc":33820},[33636,33639,33641,33644,33650,33653,33659,33662,33668,33671,33673,33676,33682,33685,33688,33691,33708,33711,33713,33719,33733,33736,33738,33744,33750,33755,33765,33770,33773,33784,33787,33806,33809,33811,33814,33817],[11,33637,33638],{},"sessions.list 23293 毫秒。单次 tick 延迟 21944.6 毫秒。eventLoopUtilization 是 1。这三个数字同时出现，说明的不是网络问题，是主线程被完全占满。",[26,33640,25207],{"id":25207},[11,33642,33643],{},"liveness 告警显示 event loop 延迟峰值达到 22 秒，单核占用率 0.93+，event loop 实际利用率接近 1（无空闲时间）：",[399,33645,33648],{"className":33646,"code":33647,"language":1327},[1325],"[diagnostic] liveness warning: reasons=event_loop_delay,event_loop_utilization,cpu interval=34s\n  eventLoopDelayP99Ms=14822.7 eventLoopDelayMaxMs=14822.7 eventLoopUtilization=0.994 cpuCoreRatio=0.977\n  active=1 waiting=0 queued=1\n[diagnostic] liveness warning: reasons=event_loop_delay,event_loop_utilization,cpu interval=38s\n  eventLoopDelayP99Ms=21944.6 eventLoopDelayMaxMs=21944.6 eventLoopUtilization=1 cpuCoreRatio=0.969\n  active=2 waiting=0 queued=5\n[diagnostic] liveness warning: reasons=event_loop_delay,cpu interval=36s\n  eventLoopDelayP99Ms=12977.2 eventLoopDelayMaxMs=19394.5 eventLoopUtilization=0.943 cpuCoreRatio=0.929\n",[15,33649,33647],{"__ignoreMap":183},[11,33651,33652],{},"并发处理能力完全坍塌：",[399,33654,33657],{"className":33655,"code":33656,"language":1327},[1325],"[ws] ←res ↓sessions.list 23293ms\n[ws] ←res ↓sessions.list 13868ms\n",[15,33658,33656],{"__ignoreMap":183},[11,33660,33661],{},"正常情况 sessions.list 响应在几十毫秒，这里到了 23 秒。同一时间窗口里 embedded agent 首次启动的阶段拆解显示：",[399,33663,33666],{"className":33664,"code":33665,"language":1327},[1325],"[agent\u002Fembedded] [trace:embedded-run] startup stages:\n  runId=...\n  phase=attempt-dispatch totalMs=32433\n  stages=workspace:1ms@1ms,\n         runtime-plugins:5ms@6ms,\n         hooks:0ms@6ms,\n         model-resolution:2974ms@2980ms,\n         auth:14045ms@17025ms,\n         context-engine:1ms@17026ms,\n         attempt-dispatch:15407ms@32433ms\n",[15,33667,33665],{"__ignoreMap":183},[11,33669,33670],{},"auth 阶段独占 14 秒，attempt-dispatch 又花 15 秒，鉴权和派发两步合计 29 秒。用户的核心感受就是\"打开面板就卡，发消息也卡\"。",[26,33672,25231],{"id":25231},[11,33674,33675],{},"从 liveness 告警时间戳倒推启动序列。Gateway 起来后立刻开始 plugin staging——这个阶段在 err.log 里有清晰的 trace：",[399,33677,33680],{"className":33678,"code":33679,"language":1327},[1325],"[plugins] file-transfer staging bundled runtime deps\n[plugins] runway staging bundled runtime deps\n",[15,33681,33679],{"__ignoreMap":183},[11,33683,33684],{},"plugin staging 的工作是从 npm 仓库抽取 plugin 的 runtime dependencies，解包、提取，是典型的 CPU 密集 + IO 密集操作。同时前端 ws 连接可以立刻建立（端口已监听），一旦有调用（例如 sessions.list）就会被 event loop delay 阻塞。",[11,33686,33687],{},"embedded agent 首次启动时 auth 阶段 14 秒是突出的瓶颈。这一段按 stage 拆分来看，从 model-resolution（2974ms）到 auth 开始还要再花 45ms，auth 本身占了 14045ms。auth 阶段的常见操作是 token 校验或 OAuth 刷新，如果这一步走了同步 HTTP 路径（而非异步），就会把整个 event loop 卡住。",[11,33689,33690],{},"时序完全吻合：",[123,33692,33693,33696,33699,33702,33705],{},[126,33694,33695],{},"重启 Gateway → launcher 拉起 Gateway 进程",[126,33697,33698],{},"Gateway 进入 plugin staging（npm 包解压，CPU 拉满）",[126,33700,33701],{},"同时前端 ws 连接进来，调 sessions.list（被 event loop delay 阻塞 → 耗时 23 秒）",[126,33703,33704],{},"用户在面板上 send 消息 → embedded agent 触发 auth（卡 14 秒）+ dispatch（卡 15 秒）",[126,33706,33707],{},"用户感觉\"系统完全冻住了\"",[11,33709,33710],{},"第二次启动明显变快的原因是 plugin staging 的结果有缓存（不需要重新拉 npm 包），token 也有缓存（不需要重新走 OAuth 流程）。所以这个问题严格限定在\"首次启动 \u002F 升级后第一次启动\"这个窗口。",[26,33712,27321],{"id":27321},[11,33714,33715,33718],{},[488,33716,33717],{},"未完全定位","，但根据阶段耗时和 CPU 占用率，两个怀疑点都指向同一时间窗口：",[243,33720,33721,33727],{},[126,33722,33723,33726],{},[488,33724,33725],{},"plugin staging 跑在主线程","：npm 包的抽取是 CPU + IO 密集操作，如果不 fork 子进程而是在主线程处理，会直接阻塞 event loop。从 cpuCoreRatio 0.93+ 看，至少有一个核心被打得很满。",[126,33728,33729,33732],{},[488,33730,33731],{},"embedded agent auth 阶段是同步阻塞","：14 秒的 auth 很可能是某个云端 token 校验或 OAuth 刷新走了同步 HTTP 路径，socket 读写未超时的情况下会永远等下去，此间 event loop 毫无机会让出来。",[11,33734,33735],{},"两个问题叠加的后果是客户感受到的 22 秒冻结：plugin staging 把 CPU 占满，auth 同步等待把 event loop 卡死，任何其他操作都排队等待。",[26,33737,18998],{"id":18998},[11,33739,33740,33743],{},[488,33741,33742],{},"应急处理","：客户首次打开面板时等待 2 分钟再使用，或重启 Gateway 后手动刷新两次。问题会逐渐缓解，因为 plugin 缓存和 token 都会落地。这不是长期方案，但能缓解首次体验。",[11,33745,33746,33749],{},[488,33747,33748],{},"工程改进","（三个方向）：",[11,33751,33752],{},[488,33753,33754],{},"1. plugin staging 从主线程移出",[11,33756,33757,33758,13067,33761,33764],{},"改造 staging 过程使用 ",[15,33759,33760],{},"worker_threads",[15,33762,33763],{},"child_process"," 运行，主线程只等结果信号。参考 npm\u002Fyarn 的做法：安装依赖时 fork 出一个子进程跑 install，主进程保持响应。对应修改点是 plugin 管理器的 staging 入口，把同步的文件操作改成非阻塞的 spawn\u002Ffork 调用。",[11,33766,33767],{},[488,33768,33769],{},"2. embedded agent auth 加超时并预热",[11,33771,33772],{},"OAuth 刷新或 token 校验加超时，超时走降级路径。更激进的做法是启动时就发起 auth 预热，不等用户第一次 send 才触发，这样用户真正交互时 token 已经热了。",[11,33774,33775],{},[488,33776,33777,33778,88,33780,33783],{},"3. 分离 ",[15,33779,18593],{},[15,33781,33782],{},"\u002Fready"," 端点",[11,33785,33786],{},"现在 Gateway 只要端口能监听就被认为\"健康\"，但实际工作状态是\"正在初始化\"。建议：",[123,33788,33789,33794,33799],{},[126,33790,33791,33793],{},[15,33792,18593],{}," 保持轻量级，返回存活状态",[126,33795,33796,33798],{},[15,33797,33782],{}," 检查真实就绪条件（plugin staging 完成、token 预热完成）",[126,33800,33801,33802,33805],{},"ws 连接在收到 401 或 503 时附带 ",[15,33803,33804],{},"Retry-After"," header 让前端等待",[11,33807,33808],{},"这比让用户等一秒感受到的卡顿好得多。\"假就绪 + 真卡顿\"的体验等于欺骗，\"真就绪 + 明确告诉你还要等\"的体验是可控的。",[26,33810,23827],{"id":23827},[11,33812,33813],{},"构建期应增加代码静态检查，确保 plugin staging 的实现确实 fork 了子进程而不是在主线程同步运行。liveness 告警的精细度也要提升，eventLoopDelayMaxMs 达到一定阈值时向前端报警，而非仅作后台日志。冷启动时的性能验证目前还没有明确目标，需要补充。",[26,33815,33816],{"id":33816},"结论",[11,33818,33819],{},"单一的性能问题往往由多个原因叠加触发。这里是 plugin staging 的 CPU 密集操作 + auth 同步等待 + 缺乏就绪状态隔离，三层一起压向用户。解决任何一层都能改善，但完整修复需要同时改三个地方：把 CPU 工作从主线程移走，把网络等待加超时，把\"我能接请求\"和\"我真的准备好\"分开。",{"title":183,"searchDepth":184,"depth":184,"links":33821},[33822,33823,33824,33825,33826,33827],{"id":25207,"depth":184,"text":25207},{"id":25231,"depth":184,"text":25231},{"id":27321,"depth":184,"text":27321},{"id":18998,"depth":184,"text":18998},{"id":23827,"depth":184,"text":23827},{"id":33816,"depth":184,"text":33816},"2026-05-01",{},"\u002F2026-05-01-fa-002-22",{"title":33633,"description":33638},"FA-002","2026-05-01-FA-002-事件循环阻塞22秒","Gateway 启动首阶段 event loop 长期阻塞，sessions.list 慢到 23 秒；根因是 plugin staging 跑主线程，结合 auth 同步等待被卡 22 秒。",[19464,33836,33837,33838,33839],"event loop","plugin staging","concurrent","gateway","3WDjfTt61Op_m8jF-QXsl-HMQqxBzcGc0ss811YakMk",{"id":33842,"title":33843,"body":33844,"column":355,"date":34380,"description":33848,"extension":199,"hero_image":200,"meta":34381,"navigation":202,"path":34382,"seo":34383,"series_id":34384,"severity":200,"stem":34385,"summary":34386,"tags":34387,"__hash__":34390},"posts\u002F2026-04-30-FA-001-便携包截断网关起不来.md","什么日志都没有——因为进程没活到能写日志",{"type":8,"value":33845,"toc":34370},[33846,33849,33851,33854,33900,33903,33909,33912,33914,33919,33924,33929,33941,33948,33959,33965,33970,33973,33982,33992,33997,34000,34006,34009,34088,34093,34114,34116,34131,34144,34157,34173,34188,34191,34194,34198,34256,34260,34342,34344,34352,34355,34368],[11,33847,33848],{},"Guardian 日志只有 3 行。cache 目录的 logs 下面就一个 config-health.json。没有 gateway 业务日志。端口 18789 空闲。这三个事实同时出现时说明一件事：进程根本没活到能写日志的阶段。",[26,33850,25207],{"id":25207},[11,33852,33853],{},"SSH 连接客户机（Windows 环境，便携包版本 0.0.1-s0）查看：",[123,33855,33856,33862,33869,33882,33885,33888],{},[126,33857,33858,33861],{},[15,33859,33860],{},"tasklist"," 无任何 panel \u002F gateway \u002F claw 进程",[126,33863,33864,33865,33868],{},"Guardian 日志仅有 3 行：",[15,33866,33867],{},"[启动循环] → [暂停升级] → [恢复升级]","，之后无任何输出",[126,33870,33871,33872,33875,33876,14442,33879],{},"本地缓存目录 ",[15,33873,33874],{},"%LOCALAPPDATA%\\Microsoft\\WakouAI\\Runtime\\\u003CcacheId>\\data\\openclaw\\logs\\"," 只有一个 ",[15,33877,33878],{},"config-health.json",[488,33880,33881],{},"完全没有 gateway 业务日志",[126,33883,33884],{},"Guardian 调试日志显示 panel 19:35 启动、19:37 主动关闭，Guardian 正常 final sync 退出，不是崩溃",[126,33886,33887],{},"端口 18789 空闲",[126,33889,33890,33892,33893,56,33896,33899],{},[15,33891,32565],{}," 配置正常：",[15,33894,33895],{},"mode=local",[15,33897,33898],{},"port=18789","、token 有、DeepSeek 模型已配",[11,33901,33902],{},"手动在命令行前台运行启动脚本，立即退出，唯一的输出是：",[399,33904,33907],{"className":33905,"code":33906,"language":1327},[1325],"The system cannot find the path specified.\n",[15,33908,33906],{"__ignoreMap":183},[11,33910,33911],{},"这条错误文本很模糊，没有指明哪个路径，凭这条消息无法直接定位。但关键信息是\"立即退出\"和\"错误一闪而过\"——这通常意味着启动脚本本身执行失败，而不是网关进程启动后才出问题。",[26,33913,25231],{"id":25231},[11,33915,33916],{},[488,33917,33918],{},"第一步：确认是没启动还是启动后崩溃",[11,33920,33921,33923],{},[15,33922,33860],{}," 找不到任何相关进程，说明进程根本没有起来。这排除了\"启动后立即崩溃\"的假设。",[11,33925,33926],{},[488,33927,33928],{},"第二步：查启动链路",[11,33930,33931,33932,29774,33935,29774,33938],{},"便携包有一条启动链路：",[15,33933,33934],{},"运行龙虾.cmd",[15,33936,33937],{},"bin\\start-local-cache.ps1",[15,33939,33940],{},"bin\\gateway-start.cmd",[11,33942,33943,33944,33947],{},"其中 ",[15,33945,33946],{},"gateway-start.cmd"," 的核心命令是：",[399,33949,33953],{"className":33950,"code":33951,"language":33952,"meta":183,"style":183},"language-cmd shiki shiki-themes github-light github-dark","\"%ROOT%\\runtime\\nodejs\\node.exe\" \"%ROOT%\\engines\\openclaw\\node_modules\\openclaw\\openclaw.mjs\" gateway run --port 18789 --force\n","cmd",[15,33954,33955],{"__ignoreMap":183},[407,33956,33957],{"class":409,"line":410},[407,33958,33951],{},[11,33960,33961,33962,781],{},"这是一条标准的 Node.js 应用启动命令：先调用 node.exe，再加载入口文件 ",[15,33963,33964],{},"openclaw.mjs",[11,33966,33967],{},[488,33968,33969],{},"第三步：手动运行启动脚本，捕获完整输出",[11,33971,33972],{},"在便携包根目录下运行：",[399,33974,33976],{"className":27120,"code":33975,"language":27122,"meta":183,"style":183},"& \".\\bin\\gateway-start.cmd\" 2>&1\n",[15,33977,33978],{"__ignoreMap":183},[407,33979,33980],{"class":409,"line":410},[407,33981,33975],{},[11,33983,33984,33985,33988,33989,781],{},"出错退出，输出就是那条含糊的\"找不到路径\"。",[15,33986,33987],{},"cmd.exe"," 在尝试运行第一行命令时找不到 ",[15,33990,33991],{},"%ROOT%\\runtime\\nodejs\\node.exe",[11,33993,33994],{},[488,33995,33996],{},"第四步：验证关键文件",[11,33998,33999],{},"直接查看便携包目录结构：",[399,34001,34004],{"className":34002,"code":34003,"language":1327},[1325],"便携包根\u002F\n├── bin\u002F\n├── data\u002F\n├── engines\u002F\n│   └── openclaw\u002F\n│       └── node_modules\u002F       ← 子目录数只有 6 个\n├── launcher.exe\n├── WakouPanel.exe\n└── （没有 runtime 目录）\n",[15,34005,34003],{"__ignoreMap":183},[11,34007,34008],{},"对标源端机器的同一版本便携包：",[1708,34010,34011,34024],{},[1711,34012,34013],{},[1714,34014,34015,34018,34021],{},[1717,34016,34017],{},"关键路径",[1717,34019,34020],{},"应有",[1717,34022,34023],{},"客户 U 盘实际",[1730,34025,34026,34043,34056],{},[1714,34027,34028,34033,34036],{},[1735,34029,34030],{},[15,34031,34032],{},"runtime\\nodejs\\node.exe",[1735,34034,34035],{},"~80 MB",[1735,34037,34038,34039,34042],{},"整个 ",[15,34040,34041],{},"runtime\\"," 目录不存在",[1714,34044,34045,34050,34053],{},[1735,34046,34047],{},[15,34048,34049],{},"engines\\openclaw\\node_modules\\openclaw\\openclaw.mjs",[1735,34051,34052],{},"网关入口文件",[1735,34054,34055],{},"缺失",[1714,34057,34058,34063,34066],{},[1735,34059,34060],{},[15,34061,34062],{},"engines\\openclaw\\node_modules\\",[1735,34064,34065],{},"数百个依赖包",[1735,34067,34068,34069,4375,34072,4375,34075,4375,34078,4375,34081,4375,34084,34087],{},"只剩 ",[15,34070,34071],{},".bin",[15,34073,34074],{},"@agentclientprotocol",[15,34076,34077],{},"@anthropic-ai",[15,34079,34080],{},"@aws",[15,34082,34083],{},"@aws-crypto",[15,34085,34086],{},"@aws-sdk"," 共 6 个",[11,34089,34090],{},[488,34091,34092],{},"第五步：排除其他假设",[123,34094,34095,34102,34108,34111],{},[126,34096,34097,34098,34101],{},"端口冲突？",[15,34099,34100],{},"netstat -ano | findstr :18789","，端口空闲。",[126,34103,34104,34105,34107],{},"配置问题？",[15,34106,32565],{}," 存在且有效。",[126,34109,34110],{},"Node.js 不兼容？node.exe 本来就不存在，不是版本问题。",[126,34112,34113],{},"网络问题？整个拷贝都完不成，网络不是瓶颈。",[26,34115,27321],{"id":27321},[11,34117,34118,34119,34122,34123,34126,34127,34130],{},"便携包从源端机器拷到客户 U 盘时",[488,34120,34121],{},"不完整","。这个不完整不是物理损伤或网络中断，而是 ",[488,34124,34125],{},"NTFS 长路径限制","与",[488,34128,34129],{},"拷贝工具默认行为","的组合：",[11,34132,34133,34136,34137,34139,34140,34143],{},[488,34134,34135],{},"NTFS MAX_PATH 限制","：Windows 默认限制单个路径不超过 260 字符（包括目录和文件名）。便携包中 ",[15,34138,34062],{}," 的嵌套深度很深，加上包名很长（如 ",[15,34141,34142],{},"@anthropic-ai\u002Fsdk","），容易超过 260 字符。",[11,34145,34146,34149,34150,34152,34153,34156],{},[488,34147,34148],{},"拷贝工具的静默跳过","：当用资源管理器或 ",[15,34151,28284],{}," 等默认设置拷贝超长路径的文件时，如果 Windows 未启用长路径支持（LongPathsEnabled），",[488,34154,34155],{},"拷贝工具不会报错，而是静默跳过这些文件","。用户看不到任何警告，拷贝过程显示\"成功\"，但目录树已经被截断。",[11,34158,34159,34160,34163,34164,34166,34167,34169,34170,34172],{},"具体表现：拷贝 ",[15,34161,34162],{},"node_modules"," 时，前面的几层依赖（如 ",[15,34165,34086],{},"）拷进去了，但更深层的包或更长名字的包在超过 260 字符时被跳过，最终 ",[15,34168,34162],{}," 从数百个包被截断到个位数。而最关键的 ",[15,34171,84],{}," 包由于嵌套深度加包名长度恰好超过限制，整个被跳过。",[11,34174,34175,34178,34179,34181,34182,34184,34185,781],{},[488,34176,34177],{},"为什么网关启动时没有日志","：网关启动脚本没有重定向 stdout\u002Fstderr，启动信息输出到控制台。但 U 盘上根本没有 ",[15,34180,30399],{},"，所以 cmd.exe 在尝试执行 ",[15,34183,30399],{}," 这一步就报错退出了。错误消息（\"找不到路径\"）一闪而过，进程根本没活到能初始化日志系统的阶段。这是缺文件导致启动失败的典型症状：",[488,34186,34187],{},"看起来\"什么都没有\"，其实是进程死在了能写日志之前",[11,34189,34190],{},"对比 Guardian 日志的\"三行后无输出\"：Guardian 本身能启动，它检测到 panel 启动失败（网关无法启动），于是做了升级恢复，但由于网关依然起不来，Guardian 进入循环等待，不再输出日志。这不是 Guardian 的问题，是它上游依赖的网关起不来。",[26,34192,34193],{"id":34193},"修复方法",[31,34195,34197],{"id":34196},"短期方案让客户能跑起来","短期方案（让客户能跑起来）",[243,34199,34200,34218],{},[126,34201,34202,34205,34206,34215,34217],{},[488,34203,34204],{},"客户端启用 NTFS 长路径支持","（关键步骤）：",[399,34207,34209],{"className":27120,"code":34208,"language":27122,"meta":183,"style":183},"reg add HKLM\\SYSTEM\\CurrentControlSet\\Control\\FileSystem \u002Fv LongPathsEnabled \u002Ft REG_DWORD \u002Fd 1 \u002Ff\n",[15,34210,34211],{"__ignoreMap":183},[407,34212,34213],{"class":409,"line":410},[407,34214,34208],{},[23764,34216],{},"修改后需重启或注销用户，才能让 Windows API 生效。",[126,34219,34220,34223,34224,34227,34228,34248,34250,34251,88,34253,34255],{},[488,34221,34222],{},"重新拷贝便携包","：用 7-Zip 或 PowerShell 的 ",[15,34225,34226],{},"Expand-Archive"," 解压一份完整的便携包到 U 盘。解压工具通常比拷贝工具对长路径的处理更完善。",[399,34229,34231],{"className":27120,"code":34230,"language":27122,"meta":183,"style":183},"Compress-Archive -Path 便携包源目录 -DestinationPath wakou-portable.zip -CompressionLevel Fastest\n# 或\n7z a -mx=1 wakou-portable.7z 便携包源目录\n",[15,34232,34233,34238,34243],{"__ignoreMap":183},[407,34234,34235],{"class":409,"line":410},[407,34236,34237],{},"Compress-Archive -Path 便携包源目录 -DestinationPath wakou-portable.zip -CompressionLevel Fastest\n",[407,34239,34240],{"class":409,"line":184},[407,34241,34242],{},"# 或\n",[407,34244,34245],{"class":409,"line":189},[407,34246,34247],{},"7z a -mx=1 wakou-portable.7z 便携包源目录\n",[23764,34249],{},"不可行方案：只补传 ",[15,34252,34041],{},[15,34254,84],{}," 两个目录——这样的补丁仍然会因为路径超长而被静默跳过，治标不治本。",[31,34257,34259],{"id":34258},"长期方案产品工程层预防","长期方案（产品\u002F工程层预防）",[243,34261,34262,34282,34323,34336],{},[126,34263,34264,1244,34267,34269,34270,34279,34281],{},[488,34265,34266],{},"启动脚本增加 stderr 重定向",[15,34268,33946],{}," 当前不记录启动错误，导致诊断困难。改成：",[399,34271,34273],{"className":33950,"code":34272,"language":33952,"meta":183,"style":183},"start \"OpenClaw Gateway\" \u002Fmin cmd \u002Fc \"\"%ROOT%\\runtime\\nodejs\\node.exe\" \"%ROOT%\\engines\\openclaw\\node_modules\\openclaw\\openclaw.mjs\" gateway run --port 18789 --force >> \"%OPENCLAW_HOME%\\logs\\gateway-stdout.log\" 2>> \"%OPENCLAW_HOME%\\logs\\gateway-stderr.log\"\"\n",[15,34274,34275],{"__ignoreMap":183},[407,34276,34277],{"class":409,"line":410},[407,34278,34272],{},[23764,34280],{},"这样无论 node.exe 找不到还是启动报错，都会写入日志文件，大大加快诊断速度。",[126,34283,34284,34287,34288,34290,34291,34293,34294,34317,34319,34320,781],{},[488,34285,34286],{},"首启自检：部署包完整性校验","。在 ",[15,34289,27253],{}," 或 launcher 的初始化阶段增加关键路径检查，拒绝启动不完整的包：",[23764,34292],{},"关键路径清单：",[123,34295,34296,34301,34306,34311],{},[126,34297,34298,34300],{},[15,34299,34032],{}," 存在且文件大小 > 30 MB",[126,34302,34303,34305],{},[15,34304,34049],{}," 存在",[126,34307,34308,34310],{},[15,34309,34062],{}," 一级子目录数 > 200（截断时通常只剩个位数，这个阈值用来快速检测）",[126,34312,34313,34316],{},[15,34314,34315],{},"runtime\\python\\python.exe"," 存在且文件大小 > 20 MB",[23764,34318],{},"任一不通过，弹窗中文提示\"安装包不完整：检测到 XXX 缺失，请重新解压完整的安装包\"，",[488,34321,34322],{},"拒绝继续启动",[126,34324,34325,34328,34329,4375,34332,34335],{},[488,34326,34327],{},"分发流程改进","：官方只发送 ",[15,34330,34331],{},".zip",[15,34333,34334],{},".7z"," 归档文件，不发送解压后的文件夹。在 README 或启动引导中明确写出\"请启用 NTFS 长路径支持\"的前置条件。",[126,34337,34338,34341],{},[488,34339,34340],{},"Panel UI 错误展示","：当前 gateway 启动失败时，Panel 没有显式提示。建议把 stderr 内容展示给用户，而不是默默失败。",[26,34343,23827],{"id":23827},[11,34345,34346,34347,56,34349,34351],{},"构建期检查是关键。发版前在构建机运行完整性校验脚本，遍历关键路径清单（",[15,34348,34032],{},[15,34350,34049],{},"、一级子目录数量等），缺任何一项直接 fail 禁止上传。",[11,34353,34354],{},"便携包生成后用自检脚本验证所有关键文件存在、文件大小合理、依赖包数量达标。与此同时，客户交付文档里新增\"NTFS 长路径支持\"为前置条件，并提供一键启用的 PowerShell 脚本。",[11,34356,34357,34358,34360,34361,34364,34365,34367],{},"缺文件导致的启动失败有个共同特征：进程没启动，日志空白，症状表现为\"什么都没有\"。下次遇到这种现象，排查应该是：先用 ",[15,34359,33860],{}," 确认进程不存在，再用 ",[15,34362,34363],{},"Test-Path"," 逐个验证启动脚本的依赖文件，最后手动前台运行脚本并用 ",[15,34366,27495],{}," 捕获完整的 stderr。不要先查配置——配置问题通常是进程起来后才报错。",[1267,34369,16447],{},{"title":183,"searchDepth":184,"depth":184,"links":34371},[34372,34373,34374,34375,34379],{"id":25207,"depth":184,"text":25207},{"id":25231,"depth":184,"text":25231},{"id":27321,"depth":184,"text":27321},{"id":34193,"depth":184,"text":34193,"children":34376},[34377,34378],{"id":34196,"depth":189,"text":34197},{"id":34258,"depth":189,"text":34259},{"id":23827,"depth":184,"text":23827},"2026-04-30",{},"\u002F2026-04-30-fa-001",{"title":33843,"description":33848},"FA-001","2026-04-30-FA-001-便携包截断网关起不来","NTFS长路径限制导致便携包拷贝时文件截断，网关入口缺失无进程。缺文件症状表现为无日志，排查应优先验证交付物完整性。",[12681,19464,19467,34388,34389],"文件拷贝","NTFS","tsMqL7PY8Yw-Bt-N22avotg4U6S9xbJeG8GjhWbeVp4",{"id":34392,"title":34393,"body":34394,"column":196,"date":34826,"description":34398,"extension":199,"hero_image":200,"meta":34827,"navigation":202,"path":34828,"seo":34829,"series_id":200,"severity":200,"stem":34830,"summary":34831,"tags":34832,"__hash__":34837},"posts\u002F2026-04-28-helpplay-27个模块设计文档两天产出.md","两天，27 个模块的设计文档",{"type":8,"value":34395,"toc":34813},[34396,34399,34406,34409,34412,34462,34465,34468,34475,34479,34482,34515,34518,34522,34525,34557,34560,34580,34583,34589,34602,34608,34619,34623,34626,34653,34656,34660,34663,34669,34672,34675,34678,34682,34689,34692,34695,34698,34760,34763,34766,34772,34789,34795,34801,34803,34810],[11,34397,34398],{},"去年 4 月底，地陪服务平台脚手架期启动一周后，需要在两天内产出 27 个业务模块的设计文档。10 项细分服务、多角色分账模型（平台\u002F商家\u002F代理\u002F分销\u002F地陪）、完整后台，每个模块一个独立设计文档——如果逐个手写，进度撑不住。",[11,34400,34401,34402,34405],{},"这篇记录批量产出这类文档的方法论。核心思路是",[488,34403,34404],{},"先定统一的文档模板与字段规范，再定跨模块共享的核心模型，最后按模板填充各模块","。要点在于：共享模型必须先定死，因为改一次就要改所有相关文档。",[26,34407,34408],{"id":34408},"先定文档模板骨架",[11,34410,34411],{},"27 个模块的设计文档虽然内容不同，但结构可以统一。我设定的骨架如下：",[123,34413,34414,34420,34426,34432,34438,34444,34450,34456],{},[126,34415,34416,34419],{},[488,34417,34418],{},"1. Goal","：这个模块解决什么",[126,34421,34422,34425],{},[488,34423,34424],{},"2. Recommended Approach","：为什么采用这个方案而不是其他（这很关键，很多团队会漏掉）",[126,34427,34428,34431],{},[488,34429,34430],{},"3. Scope","：包含什么、不包含什么（防止后续蠕虫式功能膨胀）",[126,34433,34434,34437],{},[488,34435,34436],{},"4. Information Architecture","：如果涉及后台，菜单信息架构怎么组织",[126,34439,34440,34443],{},[488,34441,34442],{},"5. Page Design","：UI 分区、筛选项、表格列、统计卡片等",[126,34445,34446,34449],{},[488,34447,34448],{},"6. Data Model","：数据库表结构、字段、枚举、索引",[126,34451,34452,34455],{},[488,34453,34454],{},"7. API Design","：接口地址、参数、响应格式",[126,34457,34458,34461],{},[488,34459,34460],{},"8. Testing Strategy","：前后端各自怎么测",[11,34463,34464],{},"这个骨架对所有 27 个模块都适用。没有骨架时，每个模块文档的结构都不一样，审查和理解成本翻倍；有骨架后，新增模块只需要按位置填空。",[26,34466,34467],{"id":34467},"定死五个跨模块共享的核心模型",[11,34469,34470,34471,34474],{},"更关键的是",[488,34472,34473],{},"定死核心数据模型","，特别是账本和订单。一旦这些模型后改，27 个文档都要返工。",[31,34476,34478],{"id":34477},"_1-订单模型","1. 订单模型",[11,34480,34481],{},"订单需要覆盖所有场景：用户下单、服务提供者接单、支付、取消、改期、超时计费、位置追踪、评价。这 10 项服务都公用一套订单模型，字段只增不减。",[11,34483,34484,34485,56,34488,56,34490,34493,34494,56,34497,34499,34500,56,34503,56,34505,56,34508,56,34511,34514],{},"核心字段：",[15,34486,34487],{},"orderId",[15,34489,1199],{},[15,34491,34492],{},"escortId","（服务提供者）、",[15,34495,34496],{},"serviceId",[15,34498,3655],{},"（状态机）、",[15,34501,34502],{},"amount",[15,34504,8288],{},[15,34506,34507],{},"startTime",[15,34509,34510],{},"endTime",[15,34512,34513],{},"cancelledAt"," 等。",[11,34516,34517],{},"最容易踩的坑：不要为了\"简洁\"就给每种服务拆一个订单表。一开始拆，后面统计、对账、消息推送都是灾难。",[31,34519,34521],{"id":34520},"_2-账本模型最复杂","2. 账本模型（最复杂）",[11,34523,34524],{},"这是文章的重点。27 个模块里，账本类模块有 5 个：",[243,34526,34527,34533,34539,34545,34551],{},[126,34528,34529,34532],{},[488,34530,34531],{},"Platform Ledger","（平台收支）",[126,34534,34535,34538],{},[488,34536,34537],{},"Merchant Ledger","（商家明细）",[126,34540,34541,34544],{},[488,34542,34543],{},"Agency Ledger","（代理商明细）",[126,34546,34547,34550],{},[488,34548,34549],{},"Distributor Ledger","（分销商明细）",[126,34552,34553,34556],{},[488,34554,34555],{},"Escort Ledger","（地陪\u002F达人明细）",[11,34558,34559],{},"再加上 3 个交易流水类：",[243,34561,34562,34568,34574],{"start":464},[126,34563,34564,34567],{},[488,34565,34566],{},"Recharge Records","（充值）",[126,34569,34570,34573],{},[488,34571,34572],{},"Refund Records","（退款）",[126,34575,34576,34579],{},[488,34577,34578],{},"Withdrawal Accounts","（提现账户）",[11,34581,34582],{},"这 8 个都遵循同一套流水结构，共通字段有：",[399,34584,34587],{"className":34585,"code":34586,"language":1327},[1325],"{\n  ledgerRecordId,      \u002F\u002F 唯一标识\n  role,                \u002F\u002F 账本角色 (user\u002Fescort\u002Fmerchant\u002Fdistributor)\n  direction,           \u002F\u002F 方向 (income\u002Fexpense)\n  amount,              \u002F\u002F 金额\n  remark,              \u002F\u002F 备注\n  orderNo,             \u002F\u002F 源单号（可追溯）\n  recordedAt,          \u002F\u002F 记账时间\n  createdAt,\n  updatedAt\n}\n",[15,34588,34586],{"__ignoreMap":183},[11,34590,34591,34592,56,34595,56,34598,34601],{},"角色特定字段会有差异（比如 Merchant Ledger 多了 ",[15,34593,34594],{},"paymentMethod",[15,34596,34597],{},"balanceAfter",[15,34599,34600],{},"sceneType"," 等），但这套基础字段集合对所有 8 个账本都适用。",[11,34603,34604,34607],{},[488,34605,34606],{},"最关键的决策：每个角色独立一张表，不要合并。"," 很多人会想\"都是账本，能不能用一个大表加 role 字段区分？\"不行。原因有三：",[123,34609,34610,34613,34616],{},[126,34611,34612],{},"查询效率：同一个角色的流水集中在一张表，索引策略清晰；混在一张表里，where 条件变复杂",[126,34614,34615],{},"扩展性：未来某个角色的流水逻辑变复杂，新加字段只影响这张表",[126,34617,34618],{},"权限隔离：后台按角色展示不同的账本页面，数据表和权限边界对齐，防止权限越界的 Bug",[31,34620,34622],{"id":34621},"_3-单笔订单如何拆成多条流水多角色分账","3. 单笔订单如何拆成多条流水（多角色分账）",[11,34624,34625],{},"这是设计中最容易出错的地方。用户下单一笔，金额是 100 元。这 100 元要在 5 个账本里都体现：",[123,34627,34628,34633,34638,34643,34648],{},[126,34629,34630,34632],{},[488,34631,34531],{},"：+100（收入）",[126,34634,34635,34637],{},[488,34636,34537],{},"：-70（服务提供者从平台结账）",[126,34639,34640,34642],{},[488,34641,34555],{},"：+60（地陪佣金）",[126,34644,34645,34647],{},[488,34646,34543],{},"：+5（所属代理分成）",[126,34649,34650,34652],{},[488,34651,34549],{},"：+5（所属分销分成）",[11,34654,34655],{},"一笔订单，拆成 5 条流水，分别计入 5 个账本。这不能在前端聚合，必须在订单产生时就由 order-service 通过 MQ 广播给各个 ledger 消费者异步处理。",[31,34657,34659],{"id":34658},"_4-幂等键设计","4. 幂等键设计",[11,34661,34662],{},"MQ 消息可能被重复消费。幂等键设计决定了\"重复消费时是否产生重复的流水记录\"。",[11,34664,34665,34666],{},"常见的幂等键结构：",[15,34667,34668],{},"{orderId}-{ledgerRecordType}-{direction}-{roleId}",[11,34670,34671],{},"比如：\"order-12345-commission-income-escort-789\"",[11,34673,34674],{},"这个 key 存到 Redis，TTL 设为 24 小时。消费者拿到消息后，先检查这个 key 是否存在。如果存在，说明之前处理过，直接返回；如果不存在，才生成一条新的 ledger_record。",[11,34676,34677],{},"不这样做的后果：用户投诉\"我下单了，怎么平台账户进账两次\"，查日志发现订单服务发了两条相同的 MQ 消息，或者消费者重启期间消息重新投递了。",[31,34679,34681],{"id":34680},"_5-对账口径","5. 对账口径",[11,34683,34684,34685,34688],{},"\"对账\"是财务部门关心的。账本数据必须能对应到源单据。每条流水都要有 ",[15,34686,34687],{},"orderNo"," 字段（或其他源单号），这样财务人员可以随时拿着订单号查到对应的账本记录。",[11,34690,34691],{},"后台的\"平台收支\"页面，最关键的操作是能按订单号检索流水。如果没有这个，财务无法核账。",[26,34693,34694],{"id":34694},"按模板批量填充",[11,34696,34697],{},"定好模板和核心模型后，填充就快了。以 Merchant Ledger 为例，从模板复制到文档，然后：",[243,34699,34700,34706,34712,34718,34727,34733,34743,34754],{},[126,34701,34702,34705],{},[488,34703,34704],{},"Goal"," 部分：改成\"查看商家账户余额流水\"",[126,34707,34708,34711],{},[488,34709,34710],{},"Recommended Approach","：解释为什么独立一张表而不是从商家余额字段反推",[126,34713,34714,34717],{},[488,34715,34716],{},"Scope","：列出这个模块包括查询、删除，不包括批量删除、导出",[126,34719,34720,34723,34724],{},[488,34721,34722],{},"Information Architecture","：菜单路由 ",[15,34725,34726],{},"\u002Ffinance\u002Fmerchant-ledger",[126,34728,34729,34732],{},[488,34730,34731],{},"Page Design","：筛选项（商家、支付方式、时间）、表格列（商户名、手机号、金额、余额、时间等）",[126,34734,34735,34738,34739,34742],{},[488,34736,34737],{},"Data Model","：表名 ",[15,34740,34741],{},"merchant_ledger_records","，字段集合",[126,34744,34745,1244,34748,56,34751],{},[488,34746,34747],{},"API Design",[15,34749,34750],{},"GET \u002Fadmin\u002Ffinance\u002Fmerchant-ledger",[15,34752,34753],{},"DELETE \u002Fadmin\u002Ffinance\u002Fmerchant-ledger\u002F:id",[126,34755,34756,34759],{},[488,34757,34758],{},"Testing Strategy","：参数转发、筛选正确、金额计算正确",[11,34761,34762],{},"从模板到完成，一个模块 20-30 分钟。27 个模块，按这个节奏，两天内完全可以产出。",[26,34764,34765],{"id":34765},"避免的陷阱",[11,34767,34768,34771],{},[488,34769,34770],{},"陷阱 1：过度个性化","。如果每个模块都要求\"独特的设计\"，模板就没用了。要求统一：所有后台列表页遵循同一个 UI 结构，只改列和筛选项。",[11,34773,34774,34777,34778,34781,34782,34785,34786,34788],{},[488,34775,34776],{},"陷阱 2：字段命名不一致","。A 账本叫 ",[15,34779,34780],{},"ledgerRecordId","，B 账本叫 ",[15,34783,34784],{},"recordId","，C 账本叫 ",[15,34787,13266],{},"。查询时容易出错。从一开始就定好规范，全部遵循。",[11,34790,34791,34794],{},[488,34792,34793],{},"陷阱 3：索引规划滞后","。数据模型写完了，才想起来没有索引。到了后期\"查询慢\"才加索引，可能已经影响了代码逻辑。索引要在设计文档里就列出来。",[11,34796,34797,34800],{},[488,34798,34799],{},"陷阱 4：忽略 Not Included","。设计文档里不写\"不包含什么\"，后续就有人问\"为什么没有批量删除\"\"为什么没有导出\"。明确列出 Scope 的边界，能省很多周期。",[26,34802,30354],{"id":30354},[11,34804,34805,34806,34809],{},"批量产出设计文档的要诀是",[488,34807,34808],{},"复用而非重复编写","。文档模板保证了结构统一，核心数据模型保证了逻辑一致，两者结合，27 个模块的设计工作量从\"无法估算\"变成\"可预测\"。",[11,34811,34812],{},"最后的提醒：这套方法对脚手架期这样\"框架先行、业务后补\"的场景特别有效。但如果业务需求本身就不清楚，文档多了也没用——反而会变成\"写了一堆高保真但实际没用的文档\"。务必确保需求先冻结，再启动文档产出。",{"title":183,"searchDepth":184,"depth":184,"links":34814},[34815,34816,34823,34824,34825],{"id":34408,"depth":184,"text":34408},{"id":34467,"depth":184,"text":34467,"children":34817},[34818,34819,34820,34821,34822],{"id":34477,"depth":189,"text":34478},{"id":34520,"depth":189,"text":34521},{"id":34621,"depth":189,"text":34622},{"id":34658,"depth":189,"text":34659},{"id":34680,"depth":189,"text":34681},{"id":34694,"depth":184,"text":34694},{"id":34765,"depth":184,"text":34765},{"id":30354,"depth":184,"text":30354},"2026-04-28",{},"\u002F2026-04-28-helpplay-27",{"title":34393,"description":34398},"2026-04-28-helpplay-27个模块设计文档两天产出","批量产出设计文档的方法——统一模板、共享核心模型、按模板填充。27 个业务模块若逐个手写撑不住进度，通过先定死文档骨架和跨模块通用数据结构，可以降低重复劳动。",[34833,34834,34835,34836],"文档工程","设计模板","模块化设计","分账系统","EmM4jsQgKaOyTtIZh6WmNxTg1Gre2siFrTEdsAHNqKI",{"id":34839,"title":34840,"body":34841,"column":6815,"date":35099,"description":34845,"extension":199,"hero_image":200,"meta":35100,"navigation":202,"path":35101,"seo":35102,"series_id":200,"severity":200,"stem":35103,"summary":35104,"tags":35105,"__hash__":35109},"posts\u002F2026-04-26-开栏便携版交付难在哪.md","把文件拷到 U 盘——整条链路最脆的一环",{"type":8,"value":34842,"toc":35092},[34843,34846,34850,34853,34859,34862,34865,34871,34874,34880,34883,34886,34890,34893,34907,34910,34913,34919,34922,34925,34936,34939,34943,34946,34949,34952,34955,34958,34961,34964,34968,34971,35022,35025,35031,35034,35037,35041,35044,35050,35057,35060,35083,35086,35089],[11,34844,34845],{},"便携版（portable）看起来是最简单的交付形式：解压 zip 到 U 盘，双击启动，无需安装、无注册表污染。现实中这个形式却是整个交付链路里最脆弱的一环。四个潜伏的难点会在不同场景下反复触发，造成的故障难以排查、易于遗漏。这一栏专门记录便携版交付在设计与运维中踩过的坑。",[26,34847,34849],{"id":34848},"难点-1完整性-几百个文件缺一不可","难点 1：完整性 —— 几百个文件，缺一不可",[11,34851,34852],{},"便携版的 zip 解压后包含的文件数量出人意料地多。以 Windows 为例，一个完整的便携目录结构大约是这样：",[399,34854,34857],{"className":34855,"code":34856,"language":1327},[1325],"portable\u002F\n├── WakouPanel.exe                    (~30 MB)\n├── launcher.exe                      (~7 MB)\n├── runtime\u002F\n│   ├── python\u002F                       (~25 MB)\n│   ├── nodejs\u002F                       (~40 MB)\n│   └── webview2\u002F                     (bootstrapper)\n├── engines\u002F\n│   ├── openclaw\u002F                     (~150 MB，含 npm node_modules)\n│   └── hermes\u002F                       (~250 MB，含 Python site-packages)\n├── data\u002F                             (用户数据)\n└── ...\n",[15,34858,34856],{"__ignoreMap":183},[11,34860,34861],{},"python 目录里是 embeddable Python，完整的 Lib 结构；nodejs 目录含 node.exe 和整个 node_modules\u002Fnpm；openclaw 是 npm 本地安装的结果，带上百个 transitive 依赖包；hermes 是 Python 环装后的 site-packages，几千个 Python 文件。任何一个文件损坏或缺失，应用启动时都可能失败——但错误信息通常指向别处。",[11,34863,34864],{},"启动时最先执行的是一个检查阶段，逐项验证关键文件完整性：",[399,34866,34869],{"className":34867,"code":34868,"language":1327},[1325],"launcher.exe 启动\n  ├─ 检查 runtime\u002Fpython\u002Fpython.exe 存在且可执行\n  ├─ 检查 runtime\u002Fnodejs\u002Fnode.exe 存在且可执行\n  ├─ 检查 engines\u002Fopenclaw\u002F 目录结构\n  ├─ 检查 engines\u002Fhermes\u002F 目录结构\n  └─ 任一失败 → 显示\"U 盘损坏，请联系运营\"并退出\n",[15,34870,34868],{"__ignoreMap":183},[11,34872,34873],{},"但这只是皮毛检查。真正的依赖关系隐藏在运行时：OpenClaw 启动时需要一个特定版本的 npm package，Hermes 需要 Python 3.11 及特定的 pip 包。这些 transitive 依赖包本身又依赖其他包。一个文件被病毒软件误删、或从网络同步源拉取出错、或用户错误覆盖，都可能导致启动时的链路中断。而错误日志往往是这样的：",[399,34875,34878],{"className":34876,"code":34877,"language":1327},[1325],"[ERROR] Cannot find module 'xxxx'\n",[15,34879,34877],{"__ignoreMap":183},[11,34881,34882],{},"指向的是深层依赖包，用户根本不知道这个包应该来自 U 盘的哪个位置。或者更糟的情况，应用在读取某个配置文件时卡死，日志文件根本没有被写入（因为日志初始化本身依赖这些文件）。",[11,34884,34885],{},"这就是我后来在架构中加入\"启动期环境扫描\"的原因：不只检查文件存在，还要检查版本兼容、引入时序、必要的环保变量设置。但即使这样，Z 盘上某个角落的小文件丢失，依然会造成诡异的故障。",[26,34887,34889],{"id":34888},"难点-2双位置状态-哪个才是真相","难点 2：双位置状态 —— 哪个才是真相",[11,34891,34892],{},"便携版为了加速和降低 USB I\u002FO 压力，启动时采用了这样的策略：",[243,34894,34895,34901,34904],{},[126,34896,34897,34898,13615],{},"程序启动后，把 U 盘内容 robocopy（增量复制）到本地磁盘一个临时目录（",[15,34899,34900],{},"%LOCALAPPDATA%\\Microsoft\\WakouAI\\Runtime\\\u003C缓存ID>\\",[126,34902,34903],{},"所有后续运行都在本地缓存目录进行，直接读写本地 SSD",[126,34905,34906],{},"后台守护进程（guardian）每 60 秒把本地缓存的数据同步回 U 盘",[11,34908,34909],{},"这样做的好处是显而易见的：本地 SSD 读写速度是 USB 的 10 倍以上，用户体验流畅。但代价是引入了两份状态，同步的方向和时机就成了生死攸关的问题。",[11,34911,34912],{},"最危险的场景是这样的：",[399,34914,34917],{"className":34915,"code":34916,"language":1327},[1325],"T0: 用户启动应用 → 本地 cache 已准备好\nT1: 用户在应用里操作 → 数据写入本地 cache\nT60: guardian 开始同步 → 本地 cache 文件 → U 盘\nT55-T60 之间: 用户突然拔 U 盘\n→ 同步中断，本地 cache 中的数据丢失\n→ 用户下次在别的电脑插 U 盘，看到的是上次完整同步的状态，这次的操作凭空消失\n",[15,34918,34916],{"__ignoreMap":183},[11,34920,34921],{},"为了降低这个风险，我在设计中采用了两层防护：",[11,34923,34924],{},"第一，缩短同步间隔。最初考虑的是 2 分钟一次，后来改为 60 秒。理由是 USB 场景拔盘风险高，同步频率更高能减少未同步数据窗口。robocopy 的 copy-only 增量同步在百 MB 数据量下耗时不超过 5 秒，性能可接受。",[11,34926,34927,34928,34931,34932,34935],{},"第二，双轨写入策略。当 U 盘可写时，更新内容同时写到 U 盘的 ",[15,34929,34930],{},"update\u002Fstaging\u002F"," 目录；当 U 盘只读时（或无法写入），改为写到本地 ",[15,34933,34934],{},"%LOCALAPPDATA%","。两种情况下，用户都会得到明确的提示，知道这次变更是\"永久保存在 U 盘\"还是\"仅在当前电脑临时有效\"。",[11,34937,34938],{},"但即便如此，这个难点仍然是后续故障的根源。用户在问\"为什么我上次保存的聊天记录不见了\"时，十有八九就是在这个同步窗口被中断了。",[26,34940,34942],{"id":34941},"难点-3就地更新-边跑边修","难点 3：就地更新 —— 边跑边修",[11,34944,34945],{},"传统的桌面应用更新流程很简单：停止应用 → 覆盖文件 → 重启。便携版不行。",[11,34947,34948],{},"为什么？因为便携版的目标用户是在客户机上工作的人。他们双击启动应用，正在和 AI 对话、生成代码、调试工具。你不能告诉他\"系统要更新，所有东西停止 30 秒\"。这不是理想情况下的交付，这是在实际场景中的交付。",[11,34950,34951],{},"更严格的限制还有两个：",[11,34953,34954],{},"第一，不能装程序。应用进程本身是可执行的，但更新时不能覆盖当前运行的 exe。Windows 会锁定正在执行中的文件，强制覆盖会报错。解决方案是引入一个独立的启动器进程（launcher.exe），它的职责很简单：等主应用退出 → 备份旧版本 → 覆盖新文件 → 启动新版本 → 监控启动失败 → 自动回滚到旧版。主应用本身不需要感知更新逻辑。",[11,34956,34957],{},"第二，不能污染客户机。更新的新文件、临时文件、备份文件，都必须存储在 U 盘或本地缓存目录里，绝对不能写入 Windows 系统目录或用户的 Program Files。这意味着更新后的文件验证、备份清理，都要自己处理。",[11,34959,34960],{},"还有一个隐形的复杂性：灰度发布。为了降低全量更新的风险，后台会根据用户 ID 哈希决定是否推送新版本——10% 用户先升级，没问题再 50%、再 100%。这意味着后台需要维护版本清单、灰度配置，客户端需要能解析和判断自己是否应该升级。如果某个版本被发现有严重 bug，还要能强制弹窗\"立即升级\"或\"退出软件\"，用户无法选择\"先不升\"。",[11,34962,34963],{},"这四个细节加在一起，就是为什么 OTA（Over-The-Air）更新被独立列为一个难点——更新失败的最坏结果是应用无法启动，而它发生在没有人在场的客户机上。",[26,34965,34967],{"id":34966},"难点-4客户机环境不可控-变量太多","难点 4：客户机环境不可控 —— 变量太多",[11,34969,34970],{},"便携版理论上可以工作在任何 Windows 10 \u002F 11 机器上。但\"任何\"意味着什么？意味着要兼容：",[123,34972,34973,34979,34984,34990,34996,35001,35007,35016],{},[126,34974,34975,34978],{},[488,34976,34977],{},"OS 版本","：Windows 10 1809（微软已停止支持但仍有用户用）到 Windows 11 24H2（最新版本）",[126,34980,34981,34983],{},[488,34982,15667],{},"：只考虑 x64，32 位与 ARM64 暂不支持",[126,34985,34986,34989],{},[488,34987,34988],{},"运行库","：WebView2 Runtime 可能未预装；应用需要能检测并引导用户安装",[126,34991,34992,34995],{},[488,34993,34994],{},"Python","：客户机可能已有 Python 环境，版本混乱（2.7、3.7、3.9、3.11、3.12 混用）。应用内置了 Python 3.11，但如果客户机已有兼容版本，优先使用以加速启动；如果版本不兼容，自动降级到内置版本",[126,34997,34998,35000],{},[488,34999,19464],{},"：同样的问题，但内置版本与系统版本冲突的情况较少",[126,35002,35003,35006],{},[488,35004,35005],{},"防杀软件","：启动期会扫描常见杀毒软件（360、腾讯电脑管家、Windows Defender 等）是否在运行，这会影响 I\u002FO 速度和权限检查",[126,35008,35009,35012,35013,35015],{},[488,35010,35011],{},"磁盘空间","：OTA 升级需要临时空间；本地缓存也需要空间。如果 U 盘剩余 \u003C 500 MB 或目标机 ",[15,35014,34934],{}," 所在盘 \u003C 500 MB，升级会失败",[126,35017,35018,35021],{},[488,35019,35020],{},"网络","：启动期会触发多个网络请求（检查更新、拉公告、同步用户配置）。不稳定的网络、防火墙阻止、代理认证失败，都要优雅降级而不是卡死",[11,35023,35024],{},"为了应对这些变量，启动器在启动主程序之前做了一整套\"环境扫描\"：",[399,35026,35029],{"className":35027,"code":35028,"language":1327},[1325],"launcher.exe 启动\n  ├─ 检查 Windows 版本 → 太旧则拒绝启动\n  ├─ 检查 WebView2 Runtime → 缺失则触发 Bootstrapper 安装\n  ├─ 扫描目标机 Python → 找兼容版本记录来源\n  ├─ 探测防杀软件进程 → 记录可能影响性能的软件\n  ├─ 检查磁盘空间 → 空间不足则警告\n  ├─ 检查 U 盘接入方式 → 检测是否被识别为网络驱动器（某些网络盘可能导致 WebView2 异常）\n  └─ 收集诊断信息 → 写本地文件并异步上报后台\n",[15,35030,35028],{"__ignoreMap":183},[11,35032,35033],{},"这个扫描过程通常耗时 1-3 秒。收集到的诊断信息会异步发送到后台，但不阻塞应用启动——即使网络不通，应用仍然能启动。",[11,35035,35036],{},"客户机环境的复杂性最容易导致的问题就是\"我这台电脑能用，为什么别的电脑不行\"。同样一个 v0.14.0 版本的便携包，在配置 A（Win11 24H2、Python 3.11 官方版、SSD、无防火墙）上跑得飞快，在配置 B（Win10 1809、PyCharm 装的 Python 3.8、机械硬盘、严格防火墙）上可能卡死。排查的线索分散在多个地方：系统日志、应用日志、诊断数据、用户描述。",[26,35038,35040],{"id":35039},"u-盘本地缓存ota-的关系模型","U 盘、本地缓存、OTA 的关系模型",[11,35042,35043],{},"讲完了这四个难点，需要把它们串联起来，看清整个便携版的信息流动图：",[399,35045,35048],{"className":35046,"code":35047,"language":1327},[1325],"启动时序：\n  用户双击 Start.cmd\n    ↓\n  launcher.exe stage 0-7（环境检查 + 诊断收集）\n    ↓\n  robocopy U 盘 → 本地缓存\n    ↓\n  设置环境变量（HOME \u002F USERPROFILE \u002F PATH 等）指向缓存路径\n    ↓\n  启动 WakouPanel.exe（运行在缓存中）\n    ↓\n  启动 guardian.exe（后台守护）\n\n运行时流程：\n  应用在缓存中读写数据（快速，本地 I\u002FO）\n    ↓\n  每 60 秒 guardian 触发一次 robocopy\n    ↓\n  缓存中的变更同步回 U 盘\n    ↓\n  如果同步中断（拔盘 \u002F USB 断电），下次插上时重新同步\n    ↓\n  应用中的\"换机无感切换\"依赖这个同步\n\nOTA 升级流程：\n  launcher.exe 检测到新版本\n    ↓\n  下载新版本到 update\u002Fstaging\u002F\n    ↓\n  等待主应用退出\n    ↓\n  备份当前 engines\u002F 为 *.old\n    ↓\n  覆盖 U 盘上的 WakouPanel.exe \u002F engines\u002F\n    ↓\n  启动新版本\n    ↓\n  如果新版本连续启动失败，自动回滚到 *.old\n",[15,35049,35047],{"__ignoreMap":183},[11,35051,35052,35053,35056],{},"这个模型的核心是",[488,35054,35055],{},"分层","：U 盘是\"源\"（truth source），缓存是\"镜像\"（working copy），OTA 触发时更新源，然后下一次启动时缓存自动同步最新的源。",[11,35058,35059],{},"但这个分层在以下场景下会产生矛盾：",[123,35061,35062,35068,35074],{},[126,35063,35064,35067],{},[488,35065,35066],{},"更新与拔盘同时发生","：用户正在下载新版本，突然拔盘。缓存中的 staging\u002F 目录消失，但 U 盘上的 staging\u002F 可能不完整。重新插盘启动时会尝试恢复，但流程复杂容易出错。",[126,35069,35070,35073],{},[488,35071,35072],{},"多个缓存实例竞争","：同一台机器上多个用户账户，或用户在不同时间以不同身份登录，都会产生不同的缓存目录。如果 U 盘在两个缓存间不一致地同步，可能导致\"缓存 A 看到的是旧数据，缓存 B 看到的是新数据\"。",[126,35075,35076,35079,35080,35082],{},[488,35077,35078],{},"本地缓存被意外清理","：Windows 的磁盘清理工具或杀毒软件的隔离功能，可能会删除 ",[15,35081,34934],{}," 下的陈旧目录。如果缓存被清理而 U 盘还活着，下次启动会重新同步，但如果同步过程出错就会卡死。",[11,35084,35085],{},"这些矛盾大多数时候能正常处理（因为设计中有检测和回滚机制），但在网络不稳定、用户异常操作的时候，就会逐个暴露出来。",[11,35087,35088],{},"四个难点——完整性、双位置状态、就地更新、环境检测——不是孤立的。它们交互作用，放大彼此的风险。一个完整的文件列表，如果同步中断就变得不完整；一个完成了 50% 的 OTA 更新，如果被中断的网络打乱时序，就会导致两个版本的文件混在一起；一个环境扫描失败的启动，往往不是单一原因，而是几个小问题的叠加。",[11,35090,35091],{},"这就是为什么这一栏叫\"交付与更新\"。便携版交付看起来简单，实际却是最复杂的交付链路。后续每一篇故障档案，都可以溯源到这四个难点中的某一个，或它们的组合。",{"title":183,"searchDepth":184,"depth":184,"links":35093},[35094,35095,35096,35097,35098],{"id":34848,"depth":184,"text":34849},{"id":34888,"depth":184,"text":34889},{"id":34941,"depth":184,"text":34942},{"id":34966,"depth":184,"text":34967},{"id":35039,"depth":184,"text":35040},"2026-04-26",{},"\u002F2026-04-26",{"title":34840,"description":34845},"2026-04-26-开栏便携版交付难在哪","便携版看起来只是\"把文件拷到 U 盘\"，实际涉及完整性、状态同步、就地更新、环境检测四大难点——这些问题构成了后续大量交付故障的根源。",[32497,35106,15069,35107,35108],"交付","数据同步","系统兼容","McTP6fieUwOYhuyzm_KDg3QKEuZwnza2EBE__MSYYPc",{"id":35111,"title":35112,"body":35113,"column":1966,"date":35461,"description":35117,"extension":199,"hero_image":200,"meta":35462,"navigation":202,"path":35463,"seo":35464,"series_id":200,"severity":200,"stem":35465,"summary":35466,"tags":35467,"__hash__":35472},"posts\u002F2026-04-25-Provider-registry多家模型接入的抽象层.md","接口能统一，能力差异不能",{"type":8,"value":35114,"toc":35455},[35115,35118,35125,35129,35132,35135,35149,35159,35173,35182,35193,35200,35206,35210,35213,35223,35233,35242,35256,35259,35276,35283,35368,35375,35379,35382,35392,35407,35414,35421,35424,35427,35433,35443,35449],[11,35116,35117],{},"在多模型时代，一个AI产品不太可能只绑定一家供应商。客户会问\"能不能接DeepSeek\"，你说\"可以，稍等\"，然后发现每家的鉴权、请求格式、流式协议、错误码都不一样。这就是Provider Registry要解决的问题。",[11,35119,35120,35121,35124],{},"关键在于",[488,35122,35123],{},"什么该统一，什么该暴露","。统一过头会碰到差异的墙，暴露太多会让上游调用方的逻辑爆炸。我在这个项目里碰到的取舍决策，可能值得记录下来。",[26,35126,35128],{"id":35127},"三层统一一层保留差异","三层统一，一层保留差异",[11,35130,35131],{},"最初的想法是\"搞一个Provider Registry，把所有差异封装起来，上游拿到一个统一的接口，根本不用关心用的是哪家\"。这个想法在配置层确实能做到，但在能力层做不到。",[11,35133,35134],{},"我最终把Registry的职责分成四块：",[11,35136,35137,35140,35141,35144,35145,35148],{},[488,35138,35139],{},"第一块：统一鉴权与配置存储","。所有Provider的API key、baseUrl、模型列表都写到一份配置文件（openclaw.json）里，用键值对结构 ",[15,35142,35143],{},"models.providers.\u003Cprovider_id>"," 组织。用户在desktop-app里粘minimax的key，点保存，底层走CLI命令 ",[15,35146,35147],{},"openclaw config set --batch-file"," 写入这份文件。无论是minimax还是deepseek，写的流程完全一致——就是一个json patch操作。",[11,35150,35151,35154,35155,35158],{},[488,35152,35153],{},"第二块：统一请求\u002F响应形状","。这里的统一指的是\"同一个Provider的多个模型，请求格式保持一致\"。比如minimax用openai-compatible协议，所以不管你用MiniMax-Text-01还是MiniMax-M2.7，都走同一套 ",[15,35156,35157],{},"openai-completions"," adapter。当然，不同Provider可能走不同协议（minimax是openai-compatible，google用google-generative-ai，bedrock用bedrock-converse-stream），但这个差异在Provider层已经明确了，不会到上游去。",[11,35160,35161,35164,35165,35168,35169,35172],{},[488,35162,35163],{},"第三块：统一错误分类","。vendor CLI 会把所有可能的执行错误映射到几个明确的category：schema validation failed、size-drop protection triggered、tamper detection、gateway unreachable、auth failed、subprocess timeout。desktop-app调",[15,35166,35167],{},"openclaw_cli_run","时，返回值里有一个",[15,35170,35171],{},"error_kind","字段，直接是枚举值，不用自己去解析stderr。",[11,35174,35175,35178,35179,35181],{},[488,35176,35177],{},"第四块：不统一能力差异。"," 这是关键。以推理（thinking）为例，claude有 ",[15,35180,33339],{}," mode，但openai的o1\u002Fo3才有对应概念。我最初想的是\"在ProviderModel的schema里加一个通用的reasoning字段\"，让上游统一判断\"这个模型支持推理吗\"。结果发现：",[123,35183,35184,35187,35190],{},[126,35185,35186],{},"deepseek的thinking_content有返回值要求：如果开启thinking，服务端一定要把thinking_content写回来，desktop-app才能正常继续（不回传时上游直接拒绝请求）",[126,35188,35189],{},"但openai的thinking_process在某些情况下服务端可能不返回",[126,35191,35192],{},"anthropic的thinking则是完全不同的结构",[11,35194,35195,35196,35199],{},"最后的结果是：我在ProviderModel里只留了一个",[15,35197,35198],{},"reasoning?: boolean","标记（这个模型支持这类能力吗），真正的细节——怎么请求、返回值里怎么提取、出错怎么处理——统统暴露给上游。上游（比如agent runtime）要根据具体的Provider来适配这些差异，不指望Registry替它们处理。",[11,35201,35202,35203,781],{},"这看起来像是\"没把问题解决好\"，但实际上它是正确的边界划分：",[488,35204,35205],{},"Registry的职责是\"让你有办法表达和存储配置\"，不是\"替你处理所有差异\"",[26,35207,35209],{"id":35208},"为什么是cli-subprocess而不是其他方案","为什么是CLI Subprocess而不是其他方案",[11,35211,35212],{},"这个决策过程本身也说明了架构边界的问题。我考虑过三个方案：",[11,35214,35215,35218,35219,35222],{},[488,35216,35217],{},"方案一：磁盘直写","。desktop-app直接写openclaw.json文件。看起来最简单。真机烟测发现行不通——vendor设计了tamper protection机制，直写会被clobber到",[15,35220,35221],{},".clobbered.{timestamp}","文件里，文件恢复到上一次known-good的快照。这是vendor的安全设计，我不应该绕过它，也不可能绕过（因为我没有权限改vendor的防护逻辑）。",[11,35224,35225,35228,35229,35232],{},[488,35226,35227],{},"方案二：Gateway WebSocket RPC直调","。vendor gateway进程暴露了一个WebSocket接口，理论上可以直接调 ",[15,35230,35231],{},"gateway.secrets.reload"," RPC来热更新配置。这需要我逆向gateway的RPC schema、管理auth token、处理网络错误和重试——这些工作量加起来大概要10多天。而且每次vendor升级，RPC schema要是变了，我的代码就得跟着改。",[11,35234,35235,35238,35239,35241],{},[488,35236,35237],{},"方案三：CLI Subprocess","。vendor本身提供了 ",[15,35240,35147],{}," 命令。我只需要：",[243,35243,35244,35247,35250,35253],{},[126,35245,35246],{},"调这个命令（通过Tauri的subprocess接口）",[126,35248,35249],{},"解析返回的error code和stderr",[126,35251,35252],{},"根据特定的error pattern分类（schema validation \u002F size-drop \u002F tamper \u002F timeout）",[126,35254,35255],{},"返回给上游一个枚举值",[11,35257,35258],{},"vendor CLI是vendor自己维护的接口，意味着：",[123,35260,35261,35264,35267,35270,35273],{},[126,35262,35263],{},"vendor会负责tamper protection检测",[126,35265,35266],{},"vendor会负责schema校验",[126,35268,35269],{},"vendor会负责atomic写盘",[126,35271,35272],{},"如果vendor升级，CLI接口保持稳定（vendor有兼容性承诺）",[126,35274,35275],{},"desktop-app需要的所有信息都通过exit code和stderr传出来，我不需要逆向内部RPC",[11,35277,35278,35279,35282],{},"选CLI Subprocess最关键的原因是",[488,35280,35281],{},"职责清晰","。desktop-app的职责就是\"构造一个正确的batch JSON，传给vendor CLI，等它返回结果，根据error kind决定怎么展示给用户\"。vendor CLI的职责是\"完成真正的写盘和保护\"。两者分工明确，即使vendor升级也不容易破。",[1708,35284,35285,35301],{},[1711,35286,35287],{},[1714,35288,35289,35292,35295,35298],{},[1717,35290,35291],{},"维度",[1717,35293,35294],{},"磁盘直写",[1717,35296,35297],{},"WebSocket RPC",[1717,35299,35300],{},"CLI Subprocess",[1730,35302,35303,35315,35326,35340,35354],{},[1714,35304,35305,35308,35311,35313],{},[1735,35306,35307],{},"是否绕过vendor的tamper protection",[1735,35309,35310],{},"✓（无法)",[1735,35312,21724],{},[1735,35314,21727],{},[1714,35316,35317,35320,35322,35324],{},[1735,35318,35319],{},"需要逆向vendor内部结构",[1735,35321,21727],{},[1735,35323,21724],{},[1735,35325,21727],{},[1714,35327,35328,35331,35334,35337],{},[1735,35329,35330],{},"对vendor升级的脆弱性",[1735,35332,35333],{},"高（配置格式可能改)",[1735,35335,35336],{},"高（RPC schema可能改)",[1735,35338,35339],{},"低（CLI稳定）",[1714,35341,35342,35345,35348,35351],{},[1735,35343,35344],{},"工时",[1735,35346,35347],{},"N\u002FA（已证明不可行）",[1735,35349,35350],{},"10+ 天",[1735,35352,35353],{},"6 天",[1714,35355,35356,35359,35362,35365],{},[1735,35357,35358],{},"错误诊断难度",[1735,35360,35361],{},"低（没有vendor反馈）",[1735,35363,35364],{},"中等（需逆向RPC)",[1735,35366,35367],{},"低（stderr明确）",[11,35369,35370,35371,35374],{},"这三个方案的比较说明的是：当你需要跟另一个系统（vendor）交互时，",[488,35372,35373],{},"优先选择对方主动暴露的官方接口","，其次才考虑逆向内部结构或绕过防护。官方接口代表了对方的兼容性承诺。",[26,35376,35378],{"id":35377},"数据模型咬字清楚很重要","数据模型：咬字清楚很重要",[11,35380,35381],{},"实现Provider Registry时，我犯过一个细节错误，后来成了教训：",[11,35383,35384,35385,4375,35388,35391],{},"素材里的第一版Plan曾假设vendor的openclaw.json schema是snake_case的（比如 ",[15,35386,35387],{},"base_url",[15,35389,35390],{},"api_key","）。这是因为user.yml（user.yml是用户手写的YAML，确实用snake_case）里确实是这样写的。我原以为\"既然user.yml这样写，vendor读的时候应该也认snake_case\"。",[11,35393,35394,35395,35398,35399,35402,35403,35406],{},"真机跑命令 ",[15,35396,35397],{},"openclaw config set models.providers.minimax.apiKey sk-xxx"," 时发现失败——报错说 ",[15,35400,35401],{},"baseUrl: expected string, received undefined","。这才明白vendor的正式schema是",[488,35404,35405],{},"camelCase","的。user.yml里的snake_case是vendor CLI在\"模式1：读user.yml时\"自己做的转换，但当你用\"模式2：config set命令\"时，必须用camelCase。",[11,35408,35409,35410,35413],{},"这个细节影响了整个batch JSON的生成逻辑。我不得不在",[15,35411,35412],{},"toOpenclawConfigBatch","函数里明确做一遍 snake_case → camelCase的转换，确保生成的batch JSON符合vendor的正式schema。",[11,35415,35416,35417,35420],{},"这个教训是：",[488,35418,35419],{},"当一个系统同时支持多种输入格式（user.yml用snake_case，REST API用camelCase），一定要明确弄清楚\"最终落盘的schema是什么\"","。不能假设格式一致。",[26,35422,35423],{"id":35423},"限制与未来的债项",[11,35425,35426],{},"这个Registry设计有明确的能力边界：",[11,35428,35429,35432],{},[488,35430,35431],{},"第一，Phase 1不做SecretRef","。目前所有API key都是明文存在openclaw.json里。完整的方案应该是API key存在Windows Credential Manager里，openclaw.json里只记录一个指针（SecretRef）指向凭据存储。这个完整方案留给Phase 2（标记为debt-04-Z）。风险是什么？主要是\"U盘被别人插上，openclaw.json会暴露API key\"，但这在便携设备上是已知的风险。",[11,35434,35435,35438,35439,35442],{},[488,35436,35437],{},"第二，Provider删除有限制","。vendor的size-drop protection机制会拒绝配置大幅缩减的操作（比如从569 bytes缩到275 bytes）。这是vendor的安全设计，防止误删。结果是desktop-app里\"删除某个provider\"这个操作目前没有好办法。暂时的workaround是\"留着条目，清空apiKey\"，后续可能需要vendor补一个",[15,35440,35441],{},"--force-shrink","flag。",[11,35444,35445,35448],{},[488,35446,35447],{},"第三，能力差异持续外溢","。即使Registry层正式了，上游（比如agent runtime）处理不同Provider的差异的代码也在增多。reasoning支持、token计数、流式响应格式……每一项都需要上游适配。这不是Registry的问题（Registry本来就不该包揽这些），而是多模型系统的固有复杂度。",[11,35450,35451,35452],{},"如果我要总结一句可迁移的判断，那就是：",[488,35453,35454],{},"把供应商的差异当作一等公民，而不是\"需要封装的细节\"。Registry的价值不在统一差异，而在让差异可以有序地表达和消费。",{"title":183,"searchDepth":184,"depth":184,"links":35456},[35457,35458,35459,35460],{"id":35127,"depth":184,"text":35128},{"id":35208,"depth":184,"text":35209},{"id":35377,"depth":184,"text":35378},{"id":35423,"depth":184,"text":35423},"2026-04-25",{},"\u002F2026-04-25-provider-registry",{"title":35112,"description":35117},"2026-04-25-Provider-registry多家模型接入的抽象层","如何设计一个Provider Registry来统一多家模型供应商接入，同时暴露关键能力差异。",[35468,35469,35470,35471,4318],"Provider Registry","架构设计","配置管理","多模型接入","2-3EItI0gS_7qAKqXVR2M5y05LlpseXpXfnMgjkS4Eo",{"id":35474,"title":35475,"body":35476,"column":1966,"date":35728,"description":35480,"extension":199,"hero_image":200,"meta":35729,"navigation":202,"path":35730,"seo":35731,"series_id":200,"severity":200,"stem":35732,"summary":35733,"tags":35734,"__hash__":35738},"posts\u002F2026-04-23-离线环境预置把依赖下沉进交付物.md","客户机上什么都没有，连运行库都没有",{"type":8,"value":35477,"toc":35718},[35478,35481,35485,35488,35495,35506,35516,35522,35536,35539,35542,35545,35548,35562,35565,35568,35572,35575,35578,35585,35589,35592,35595,35606,35612,35619,35623,35626,35629,35642,35648,35651,35654,35658,35666,35677,35680,35687,35690,35693,35700,35703,35706,35709,35712,35715],[11,35479,35480],{},"客户拿到 U 盘启动时，不能假设其机器已装好任何运行时。纯净 Win10 上双击启动不能直接闪退，这意味着 Node、Python、FFmpeg、VC++ Redistributable 这些依赖全都要打进交付物里。问题不止于此——这个自包含包要在任何盘符运行、处理可选模块的平台差异、防止构建漂移、应对 Windows 的路径长度陷阱。",[26,35482,35484],{"id":35483},"分层打包系统层-vs-应用层","分层打包：系统层 vs 应用层",[11,35486,35487],{},"离线环保需要预置的东西分成两个层级，放在 U 盘的不同位置。",[11,35489,35490,35491,35494],{},"**系统层（Windows 运行库）**在 ",[15,35492,35493],{},"prereq\u002F"," 目录：",[123,35496,35497,35500,35503],{},[126,35498,35499],{},"WebView2 Evergreen Standalone Installer（约 170 MB）",[126,35501,35502],{},"VC++ Redistributable 2015-2022 x64 installer（约 25 MB）",[126,35504,35505],{},"vcruntime 四个 DLL 备份（vcruntime140.dll、vcruntime140_1.dll、msvcp140.dll、concrt140.dll，共约 3 MB）",[11,35507,35508,35509,40,35512,35515],{},"为什么要预置这些？Node 24 依赖 ",[15,35510,35511],{},"vcruntime140.dll",[15,35513,35514],{},"msvcp140.dll","；Python build-standalone 同样依赖；FFmpeg 也需要。纯净 Win10 不保证这些 DLL 存在——试图运行缺依赖的 exe 会直接闪退，没有清晰的错误提示。WebView2 在 Win11 预装，但 Win10 某些纯净版没有，而 Tauri desktop 应用的 GUI 层必须依赖它。",[11,35517,35518,35519,35494],{},"**应用层（运行时）**在 ",[15,35520,35521],{},"runtime-extra\u002F",[123,35523,35524,35527,35530,35533],{},[126,35525,35526],{},"Python 3.11（python-build-standalone，约 50 MB）",[126,35528,35529],{},"FFmpeg 7.x static build（约 100 MB）",[126,35531,35532],{},"Git 2.46+（MinGit，约 50 MB，可选）",[126,35534,35535],{},"Node 24.2.0 保留在 tar.zst 内，不在 runtime-extra（这是打包阶段的历史决策）",[11,35537,35538],{},"应用层为什么单独打包？第一，Node 已经在冷缓存 tar.zst 里；第二，Python 和 FFmpeg 这种大运行时（50-100 MB 级别）分开打包能让冷缓存保持小巧，也便于未来独立更新某个运行时；第三，Git 标记为可选，允许某些配置完全不需要版本控制。",[26,35540,35541],{"id":35541},"启动序列的检查与回退",[11,35543,35544],{},"launcher.exe 启动时的第一件事是预检——在任何主业务代码执行前，就要确认依赖齐全。",[11,35546,35547],{},"对于 WebView2 和 VC++，检查逻辑走这条路：",[243,35549,35550,35553,35556,35559],{},[126,35551,35552],{},"注册表查询是否已装（HKLM 的三处位置）",[126,35554,35555],{},"若已装，版本号对比（WebView2 要 ≥ 90；VC++ 要满足 manifest 里写的最低版本）",[126,35557,35558],{},"若版本不足或缺失，尝试系统级安装（需要管理员权限）",[126,35560,35561],{},"若系统级安装失败（用户拒绝 UAC、或权限不足），回退到 per-user 安装（WebView2）或 app-local DLL 旁置（VC++）",[11,35563,35564],{},"这个分支设计背后的约束是客户的权限矩阵不可控。家长控制的账户可能完全无管理员权限，硬要等系统级安装会卡住。允许 per-user 和 app-local 回退意味着代码路径多一倍，但这是在异构客户环境下的必要权衡。",[11,35566,35567],{},"对于应用层运行时（Python\u002FFFmpeg\u002FGit），检查轻得多：每个可执行文件存在否、sha256 是否匹配、版本命令输出是否符合预期。缺失 required 的运行时是致命错误；缺失 optional 的（如 Git）则记录降级状态，允许启动继续，只是后续依赖 Git 的 skill 会被标红。",[26,35569,35571],{"id":35570},"检测-vc-运行库的程序自己不能依赖-vc-运行库","检测 VC++ 运行库的程序，自己不能依赖 VC++ 运行库",[11,35573,35574],{},"launcher.exe 的职责之一是检测并安装 VC++ 运行库。但如果它自己用默认方式编译，二进制就动态链接了那套运行库——在一台从没装过 VC++ 的机器上，launcher 连启动都做不到，更谈不上去装。",[11,35576,35577],{},"这是个自举悖论：负责解决依赖缺失的程序，自己不能有那个依赖。",[11,35579,35580,35581,35584],{},"解法是 launcher 用 ",[15,35582,35583],{},"+crt-static"," 编译，把 C 运行时静态链进二进制。代价是体积变大，换来的是它在任何一台干净的 Windows 上都能起来。这个决定必须在项目最早期做——等到发现问题时再改编译方式，前面所有的构建产物都要重来。",[26,35586,35588],{"id":35587},"退出码-0-不等于装上了","退出码 0 不等于装上了",[11,35590,35591],{},"安装器返回 0，通常意味着装好了。这套流程里不能这么认。",[11,35593,35594],{},"装完必须重读注册表确认。原因是杀毒软件可能拦掉了注册表写入，而安装器进程自己仍然正常退出、返回 0。只信退出码，就会得到一个\"装成功了但检测不到\"的状态，然后下一个 stage 在莫名其妙的地方失败。",[11,35596,35597,35598,35601,35602,35605],{},"还有一个更细的坑：这里说的退出码，必须是通过 ",[15,35599,35600],{},"GetExitCodeProcess"," 取到的真正 Win32 子进程退出码，不是 ",[15,35603,35604],{},"ShellExecuteW"," 那个 HINSTANCE-like 的返回值。后者只反映\"这个进程能不能被启动起来\"，不反映它干成了什么。把这两个混为一谈，会得到一个永远成功的安装流程——所有安装都\"成功\"，因为进程确实都启动了。",[11,35607,35608,35609,781],{},"第三条规则和版本有关：如果检测到已装的 WebView2 版本过低，",[488,35610,35611],{},"不重装",[11,35613,35614,35615,35618],{},"装新版有覆盖客户机上其他应用所依赖的 WebView2 引用的风险。那不是我的软件该替客户承担的副作用——为了让自己能跑，去动客户机上别的软件的运行时，越界了。这种情况直接走降级路径：禁用 GUI、保留 CLI 可用、记一条 ",[15,35616,35617],{},"version_too_old"," 事件，把决定权交回给客户。",[26,35620,35622],{"id":35621},"每次启动都全量校验-170mb是不行的","每次启动都全量校验 170MB，是不行的",[11,35624,35625],{},"U 盘会经手客户，预置资产必须防篡改，手段是 sha256 加 ed25519 签名链。但资产有 170MB 以上，而预检阶段的验收要求是 100ms 以内——每次启动全量 hash 一遍，这个指标直接就废了。",[11,35627,35628],{},"所以校验分成两层，按\"什么时候真的需要这个保证\"来切：",[11,35630,35631,35634,35635,35637,35638,35641],{},[488,35632,35633],{},"Layer 1，每次启动必做，只做毫秒级的事。"," 定位 ",[15,35636,35493],{}," 目录（取 ",[15,35639,35640],{},"current_exe()"," 的同级）、验 manifest 的 ed25519 签名、然后按 manifest 里的 size 字段把文件 stat 一遍。签名保证清单本身没被改过，size 对比能抓出明显的损坏和替换。",[11,35643,35644,35647],{},[488,35645,35646],{},"Layer 2，只在真要用某个资产之前做。"," 比如已经确定要装 WebView2 了，才对那个安装器文件算完整 hash。单次几秒，但一次启动里最多发生一两回。",[11,35649,35650],{},"这个划分的前提是信任链的方向：Layer 1 验的是\"清单可信\"，Layer 2 才验\"文件与清单一致\"。顺序反过来就没意义了——拿一份可能已被篡改的清单去校验文件，校验通过也说明不了任何事。",[11,35652,35653],{},"同理，注册表检测也是每次启动都查，不写 sentinel 文件缓存结果。查一次注册表 50ms 以内，而 sentinel 会在客户手动卸载了运行库之后继续谎报\"已装\"。用便宜的实时检测换掉一个会说谎的缓存，这笔交易划得来。",[26,35655,35657],{"id":35656},"manifest-与版本管理","manifest 与版本管理",[11,35659,35660,35661,88,35663,35665],{},"U 盘根的 ",[15,35662,35493],{},[15,35664,35521],{}," 各自配套一个 manifest 文件（JSON 格式），记录：",[123,35667,35668,35671,35674],{},[126,35669,35670],{},"每个组件的版本号",[126,35672,35673],{},"sha256 校验值（抽样，不是全文件 hash）",[126,35675,35676],{},"最低版本要求（用于判断已装的版本是否满足）",[11,35678,35679],{},"为什么版本号要写进 manifest 而不是硬编码在代码里？因为这样可以让 U 盘中的系统层或应用层独立升级。比如 Microsoft 每季度推一个新的 WebView2 stable，打包脚本只需重新跑 download-webview2-installer.ps1，更新 manifest，重新签名，就能用新版本。不用重新编译 launcher.exe。",[11,35681,35682,35683,35686],{},"VC++ Redistributable 的版本策略更细：manifest 里不只记录当前版本，还记录 ",[15,35684,35685],{},"min_runtime_minor","。因为 VC++ 14.x 系列内部版本（minor）是向后兼容的，14.44 的 DLL 可以被 14.50 替代。所以检测逻辑是\"(major > min_major) OR (major == min_major AND minor >= min_minor)\"。这避免了非必要的重装。但同时也意味着不能随意支持太老的版本——14.40 已于 2026-01-13 结束支持，manifest 里的 min_runtime_minor 需要设到 44 或更高，主动排除 EOL 版本的客户机。",[11,35688,35689],{},"Python 和 FFmpeg 不需要这么细的版本对标。launcher 启动时只用 version 命令验证前缀（\"Python 3.11.\" 或 \"ffmpeg version 7.\"），足以说明大版本号匹配。",[26,35691,35692],{"id":35692},"网络视角的隔离",[11,35694,35695,35696,35699],{},"这套方案的一个隐含的架构优势是",[488,35697,35698],{},"运行时完全网络隔离","。打包阶段在某个受控环境里把所有东西下下来、验证、签名；之后 U 盘交付出去，启动时完全不需要访问外网。这对政企客户特别有价值——某些机房里网络受严格管制，根本出不了外网。传统的\"运行时现场下载\"方案在这种环境里就彻底废了。",[11,35701,35702],{},"当然代价是 U 盘 size 不能太小。系统层 + 应用层合计约 200+ MB（不含 Node 的 tar.zst）。这对 U 盘不是问题，但对某些极老的机器（USB 3.0 普及前）读取速度会有感知。",[26,35704,35705],{"id":35705},"自检与降级",[11,35707,35708],{},"launcher 做完预检后，启动过程还没有真正开始。此时会把检查结果写入 runtime-info.json，供后续 desktop-app 查询。如果某个 required 的运行时缺失或损坏，launcher 弹错误对话框直接退出。如果是 optional 的（目前只有 Git），就标记为 degraded，继续启动，然后 desktop-app 的技能管理页会给依赖该运行时的 skill 加个\"环境受限\"的灰色标记。",[11,35710,35711],{},"这个分层的降级策略源于一个现实：VC++ 缺失是致命的（几乎所有运行时都要它），但 Git 就不是——大多数 skill 完全不需要版本控制。对这两类依赖差别对待，能在保证基本可用性的前提下，最大化客户的体验。",[11,35713,35714],{},"离线预置的本质是把一个通常由宿主操作系统承担的职责下推到交付物本身。代价不只是包体积——更麻烦的是，操作系统原本替你处理的那些边界情况，现在全都归你了：权限不够怎么办、安装器撒谎怎么办、客户机上已有的版本比你需要的旧怎么办、而它又被别的软件依赖着。",[11,35716,35717],{},"这些分支里没有一个是\"技术难题\"，它们只是琐碎、且必须逐个想清楚。写下这套设计的时候客户机还没到手，上面每一条规则都是预设的失败模式，还不是踩过的坑。",{"title":183,"searchDepth":184,"depth":184,"links":35719},[35720,35721,35722,35723,35724,35725,35726,35727],{"id":35483,"depth":184,"text":35484},{"id":35541,"depth":184,"text":35541},{"id":35570,"depth":184,"text":35571},{"id":35587,"depth":184,"text":35588},{"id":35621,"depth":184,"text":35622},{"id":35656,"depth":184,"text":35657},{"id":35692,"depth":184,"text":35692},{"id":35705,"depth":184,"text":35705},"2026-04-23",{},"\u002F2026-04-23",{"title":35475,"description":35480},"2026-04-23-离线环境预置把依赖下沉进交付物","客户机器不一定有 Node、Python、VC++ 运行库，交付物必须自带整个运行环境。解决方案的分层设计与三个隐藏的坑。",[12681,35735,15072,35736,35737],"依赖打包","运行时","离线交付","AD_7Fh9uF3QDz6_BMPN8bTZhOJHBlwen5OHROStUwY4",{"id":35740,"title":35741,"body":35742,"column":1966,"date":38877,"description":35746,"extension":199,"hero_image":200,"meta":38878,"navigation":202,"path":38879,"seo":38880,"series_id":200,"severity":200,"stem":38881,"summary":38882,"tags":38883,"__hash__":38885},"posts\u002F2026-04-21-Tauri2而不是Electron四个理由.md","Electron 要 60MB——在 U 盘上这不成比例",{"type":8,"value":35743,"toc":38845},[35744,35747,35750,35753,35756,35760,35764,35767,35770,35784,35787,35790,35793,35797,35800,35803,35899,35902,35974,35977,35981,35984,35987,35993,35996,35999,36003,36007,36010,36015,36018,36023,36026,36031,36037,36042,36045,36048,36051,36054,36057,36060,36066,36148,36152,36155,36161,36164,36170,36184,36187,36201,36207,36218,36224,36239,36242,36246,36249,36255,36260,36263,36374,36377,36382,36385,36424,36427,36469,36472,36507,36510,36514,36517,36520,36525,36528,36539,36544,36547,36558,36561,36566,36627,36630,36633,36636,36641,36644,36833,36838,36841,36978,36983,36986,36994,36997,37000,37011,37014,37017,37110,37113,37119,37128,37132,37135,37149,37153,37157,37160,37165,37168,37273,37276,37291,37294,37299,37302,37490,37493,37510,37513,37518,37521,37649,37652,37657,37660,37717,37720,37731,37735,37738,37792,37795,37798,37801,37804,37810,37837,37843,37866,37869,37872,37875,37879,37882,37887,37913,37916,37922,37925,37939,37942,37953,37957,37965,37969,37972,37977,37988,37993,37996,38079,38082,38093,38098,38112,38117,38301,38304,38318,38322,38325,38328,38395,38398,38403,38408,38411,38425,38428,38433,38436,38441,38444,38449,38452,38455,38466,38469,38629,38635,38737,38740,38744,38747,38752,38766,38771,38797,38800,38805,38816,38821,38829,38834,38842],[11,35745,35746],{},"便携 AI 助手需要一个配置中心——这是常见的桌面应用需求。选型时 Electron 是行业默认答案，却因为四个约束选了 Tauri 2。这篇文章把决策过程和权衡写清楚。",[26,35748,35749],{"id":35749},"约束背景",[11,35751,35752],{},"整个产品装在 U 盘里交付。U 盘容量有限，内置常驻启动器（Launcher）已占据部分空间；配置中心是按需启动的配套工具，不能无限膨胀。同时，Launcher 用 Rust 写的，已有完整的进程管理、文件 I\u002FO、Windows API 调用能力——如果前端框架能复用这套基础设施，可以省掉大量重复实现。",[11,35754,35755],{},"这两个条件合在一起，决定了选型的方向。不是\"选最新的框架\"，而是\"在约束下找最优解\"。",[26,35757,35759],{"id":35758},"理由一体积约束10-倍差距的现实","理由一：体积约束——10 倍差距的现实",[31,35761,35763],{"id":35762},"electron-的包体积现实","Electron 的包体积现实",[11,35765,35766],{},"Electron 自带完整的 Chromium 浏览器。这听起来很方便（开箱即用的跨平台 WebView），但成本是无法回避的。一个最小化的 Electron 应用，构建后体积在 150MB 左右；经过压缩和精心优化后，可以缩小到 60-80MB。但这已经是压缩限制，再往下很难。",[11,35768,35769],{},"为什么这么大？核心原因是 Chromium 本身就是一个 100MB+ 的浏览器引擎。Chromium 包含：",[123,35771,35772,35775,35778,35781],{},[126,35773,35774],{},"渲染引擎（Blink）：处理 HTML\u002FCSS 的布局和绘制，约 30MB",[126,35776,35777],{},"JavaScript 引擎（V8）：JIT 编译、垃圾回收等，约 10MB",[126,35779,35780],{},"网络栈、媒体解码、GPU 命令处理：约 50MB",[126,35782,35783],{},"第三方库和字体：约 10MB",[11,35785,35786],{},"即使不加任何应用代码，空的 Electron 壳都要这个量级。Slack、VS Code、Figma 等大型应用都是 100MB+，人们习以为常。但这个数字对 U 盘便携应用是灾难性的。",[11,35788,35789],{},"问题不在于 60MB 本身大不大，而在于它要和什么东西抢空间。U 盘上还得放捆绑的 Node.js 运行时、Python、工具链、openclaw 本体和它的依赖树，往后还有会逐月增长的模型本地缓存。在这个清单里，一个配置界面占掉 60MB 是不成比例的——它是整套东西里最不该占地方的那部分。",[11,35791,35792],{},"更关键的是分发成本。如果用户想把产品拷到多个 U 盘，或者网络分发时需要压缩传输，每个版本都是 60MB 的包。假设产品月更新一次，一年就是 720MB 的总流量（对个人开发者或初创团队来说是明显的成本）。",[31,35794,35796],{"id":35795},"tauri-的体积目标与实施","Tauri 的体积目标与实施",[11,35798,35799],{},"Tauri 不内置浏览器。它调用操作系统已有的 WebView 实现：Windows 上是 WebView2，macOS 上是 WKWebView，Linux 上是 GTK WebKit。这意味着应用本身只需打包前端资源和 Rust 后端，不需要背负一个完整浏览器引擎。",[11,35801,35802],{},"这个项目的 Tauri 构建目标是：",[1708,35804,35805,35818],{},[1711,35806,35807],{},[1714,35808,35809,35812,35815],{},[1717,35810,35811],{},"组件",[1717,35813,35814],{},"大小限制",[1717,35816,35817],{},"说明",[1730,35819,35820,35831,35842,35853,35864,35875,35886],{},[1714,35821,35822,35825,35828],{},[1735,35823,35824],{},"YourBrand.exe（Tauri Rust 二进制）",[1735,35826,35827],{},"≤ 18 MB",[1735,35829,35830],{},"包含 tokio、serde、windows-rs、tauri-runtime 等依赖",[1714,35832,35833,35836,35839],{},[1735,35834,35835],{},"Vue.js 打包输出（JS）",[1735,35837,35838],{},"≤ 250 KB gzip",[1735,35840,35841],{},"Vite 构建 + Tree shaking + 代码分割",[1714,35843,35844,35847,35850],{},[1735,35845,35846],{},"Tailwind CSS 样式表",[1735,35848,35849],{},"≤ 80 KB gzip",[1735,35851,35852],{},"JIT 编译后去除未使用的 utility class",[1714,35854,35855,35858,35861],{},[1735,35856,35857],{},"Inter 字体（4 字重）",[1735,35859,35860],{},"≤ 60 KB × 4",[1735,35862,35863],{},"woff2 格式本地嵌入，按需加载",[1714,35865,35866,35869,35872],{},[1735,35867,35868],{},"Material Symbols 图标集",[1735,35870,35871],{},"≤ 80 KB",[1735,35873,35874],{},"按页面功能子集，避免加载整个 45MB icon library",[1714,35876,35877,35880,35883],{},[1735,35878,35879],{},"业务 SVG 和静态资源",[1735,35881,35882],{},"≤ 50 KB",[1735,35884,35885],{},"本地 logo、icon、占位图等",[1714,35887,35888,35891,35896],{},[1735,35889,35890],{},"总计",[1735,35892,35893],{},[488,35894,35895],{},"≤ 20 MB",[1735,35897,35898],{},"包括所有资源和二进制",[11,35900,35901],{},"对比分析：",[1708,35903,35904,35917],{},[1711,35905,35906],{},[1714,35907,35908,35910,35912,35914],{},[1717,35909,26677],{},[1717,35911,15068],{},[1717,35913,19953],{},[1717,35915,35916],{},"差异",[1730,35918,35919,35933,35946,35960],{},[1714,35920,35921,35924,35927,35930],{},[1735,35922,35923],{},"初次下载",[1735,35925,35926],{},"60 MB",[1735,35928,35929],{},"20 MB",[1735,35931,35932],{},"节省 40 MB（-67%）",[1714,35934,35935,35938,35941,35943],{},[1735,35936,35937],{},"装 3 个版本",[1735,35939,35940],{},"180 MB",[1735,35942,35926],{},[1735,35944,35945],{},"节省 120 MB",[1714,35947,35948,35951,35954,35957],{},[1735,35949,35950],{},"3 年（36 个版本）",[1735,35952,35953],{},"2.16 GB",[1735,35955,35956],{},"0.72 GB",[1735,35958,35959],{},"节省 1.44 GB",[1714,35961,35962,35965,35968,35971],{},[1735,35963,35964],{},"同时运行 10 个进程",[1735,35966,35967],{},"600 MB 内存",[1735,35969,35970],{},"50 MB 内存*",[1735,35972,35973],{},"节省 550 MB",[11,35975,35976],{},"*Tauri 的内存占用是 Electron 的 1\u002F10 级别，因为没有每个进程独立的 Chromium 引擎。这在多窗口场景下优势明显。",[31,35978,35980],{"id":35979},"webview2-的前置条件","WebView2 的前置条件",[11,35982,35983],{},"当然，代价是 WebView2 不一定预装。新安装的 Windows 系统或者长期未更新的老机器上，可能没有 WebView2。这时需要用户下载，大约 100MB 左右（一次性）。",[11,35985,35986],{},"项目中的应对是在 Launcher 侧做检测（注册表查询），如果缺失则提示用户：",[399,35988,35991],{"className":35989,"code":35990,"language":1327},[1325],"检测流程：\n  1. Launcher 启动时查询注册表\n     HKEY_LOCAL_MACHINE\\Software\\WOW6432Node\\Microsoft\\EdgeUpdate\\ClientState\\{F3017226...}\n  2. 读取版本号 pv 字段（如 \"123.0.0.0\"）\n  3. 若版本 >= 90，直接启动 Tauri\n  4. 若版本 \u003C 90 或不存在，弹 Win32 对话框\n     \"配置中心首次使用需下载 WebView2（约 100MB）\"\n  5. 用户点击\"立即下载\"\n     执行 webview2-bootstrap.exe（MS 官方下载器）\n  6. 下载完成自动重新启动配置中心\n",[15,35992,35990],{"__ignoreMap":183},[11,35994,35995],{},"这是折衷方案：常见场景（WebView2 已装）下体积优势完全发挥；少数场景（首次装 WebView2）下推给用户一次性下载。",[11,35997,35998],{},"对于 U 盘产品而言，这个折衷是合理的。因为 WebView2 作为系统组件，用户装过一次后就永久有效，下一次拷贝不同版本的 openclaw 到另一个 U 盘时就不需要再装了。而 Electron 的 60MB 体积却是每个应用版本都绕不过的。",[26,36000,36002],{"id":36001},"理由二rust-启动器代码复用","理由二：Rust 启动器代码复用",[31,36004,36006],{"id":36005},"现有-launcher-的能力清单","现有 Launcher 的能力清单",[11,36008,36009],{},"这个产品的 Launcher（启动.exe）是用 Rust 从零写的，已有完整的五层堆栈，每层职责明确：",[11,36011,36012],{},[488,36013,36014],{},"Layer 1-2：环境自检与缓存初始化",[11,36016,36017],{},"启动时查注册表确认 WebView2 是否可用、检查 VC++ 运行库版本、探测捆绑的 Node.js，然后做 U 盘挂载点检测、建缓存目录、拉起日志系统。这些工作一次性完成，为后面几层打好基础。",[11,36019,36020],{},[488,36021,36022],{},"Layer 3：请求路由与状态维护",[11,36024,36025],{},"来自 WebUI、CLI 工具、其他组件的请求需要被正确路由到 Gateway。同时维护 Gateway 的状态机（启动中、运行中、故障、重启中）。这层处理的是应用的控制流。",[11,36027,36028],{},[488,36029,36030],{},"Layer 4：守护与恢复",[11,36032,36033,36034,36036],{},"定时检测 Gateway health（每 5 秒轮询 ",[15,36035,18593],{}," 端点），若发现故障则自动重启 Gateway 子进程。故障恢复后记录详细日志。这层是系统可靠性的保证。",[11,36038,36039],{},[488,36040,36041],{},"Layer 5：系统集成",[11,36043,36044],{},"Win32 托盘菜单（Shell_NotifyIcon）、文件原子操作（atomic rename）、进程管理（CreateProcess、互斥量、事件信号）。这层与操作系统底层交互。",[31,36046,36047],{"id":36047},"为何代码复用很关键",[11,36049,36050],{},"这五层的代码都是可以被配置中心复用的。具体例子：",[11,36052,36053],{},"配置中心需要向 Gateway 发送请求（修改配置、查询状态），可以直接导入 Launcher 的 HTTP 代理层。不需要在前端再实现一套网络栈。",[11,36055,36056],{},"配置中心需要原子写入 user.yml 到 U 盘（确保部分写入不会导致配置文件损坏），可以复用 Launcher 的原子文件操作库。",[11,36058,36059],{},"配置中心需要通知 Launcher 某个事件已完成（比如首启引导完成），可以复用 Launcher 的进程通信机制（命名管道）。",[11,36061,36062,36065],{},[488,36063,36064],{},"具体的 invoke 命令清单","（所有后端实现都可导入 Launcher 代码）：",[1708,36067,36068,36081],{},[1711,36069,36070],{},[1714,36071,36072,36075,36078],{},[1717,36073,36074],{},"命令",[1717,36076,36077],{},"来源 Launcher 模块",[1717,36079,36080],{},"复用度",[1730,36082,36083,36096,36109,36122,36135],{},[1714,36084,36085,36090,36093],{},[1735,36086,36087],{},[15,36088,36089],{},"gateway_request",[1735,36091,36092],{},"launcher\u002Fsrc\u002Fgateway.rs 的 HTTP proxy",[1735,36094,36095],{},"直接复用 reqwest client + error handling",[1714,36097,36098,36103,36106],{},[1735,36099,36100],{},[15,36101,36102],{},"user_yml_write",[1735,36104,36105],{},"launcher\u002Fsrc\u002Ffs\u002Fatomic.rs + serde",[1735,36107,36108],{},"导入 atomic_write 函数，套上 YAML schema",[1714,36110,36111,36116,36119],{},[1735,36112,36113],{},[15,36114,36115],{},"single_instance",[1735,36117,36118],{},"launcher\u002Fsrc\u002Fipc\u002Fnamed_pipe.rs + launcher\u002Fsrc\u002Fwin32\u002Fmutex.rs",[1735,36120,36121],{},"直接复用 CreateMutexW 和 pipe 逻辑",[1714,36123,36124,36129,36132],{},[1735,36125,36126],{},[15,36127,36128],{},"get_runtime_info",[1735,36130,36131],{},"launcher\u002Fsrc\u002Fbootstrap.rs",[1735,36133,36134],{},"读取 Launcher 写入的 runtime-info.json",[1714,36136,36137,36142,36145],{},[1735,36138,36139],{},[15,36140,36141],{},"get_brand",[1735,36143,36144],{},"launcher\u002Fsrc\u002Fbrand.rs",[1735,36146,36147],{},"直接导入常量定义",[31,36149,36151],{"id":36150},"electron-vs-tauri-的架构对比","Electron vs Tauri 的架构对比",[11,36153,36154],{},"如果用 Electron，前端和后端分属两个不同的技术栈：",[399,36156,36159],{"className":36157,"code":36158,"language":1327},[1325],"Launcher (Rust 生态)\n├─ tokio async runtime\n├─ reqwest HTTP client\n├─ serde JSON parsing\n├─ fs2 file locking\n└─ windows-rs Win32 API\n\n↓ (跨语言通信，需要新的 IPC 和 native binding 层)\n\nElectron (Node.js 生态)\n├─ express 或 koa 本地 HTTP server（暴露 REST API 给前端）\n├─ node-ffi 或 N-API native module（调用 Launcher 的 Rust DLL）\n├─ node-gyp 编译脚本（为不同 Node 版本编译 native extension）\n└─ npm dependencies（400+ 个包）\n",[15,36160,36158],{"__ignoreMap":183},[11,36162,36163],{},"这个架构有多个问题：",[11,36165,36166,36169],{},[488,36167,36168],{},"编译复杂性","：为了让 Electron 调用 Launcher 的 Rust 库，需要：",[243,36171,36172,36175,36178,36181],{},[126,36173,36174],{},"把 Launcher 代码编译为 CDyLib（动态库）",[126,36176,36177],{},"用 N-API 包装暴露给 Node.js",[126,36179,36180],{},"用 node-gyp 配置编译脚本",[126,36182,36183],{},"在 CI 中为 Windows + Node 14\u002F16\u002F18\u002F20 等版本交叉编译",[11,36185,36186],{},"这意味着每个操作系统\u002FNode 版本组合都需要重新编译。Node.js 主版本升级时，整个构建流程都要调整。维护成本非常高。",[11,36188,36189,36192,36193,36196,36197,36200],{},[488,36190,36191],{},"类型一致性","：一个数据结构（比如 Gateway HTTP 响应的 HealthStatus）在 Launcher 中定义为 Rust struct，在 native module 中序列化为 JSON，再在 Electron 中重新定义为 TypeScript interface。三层映射，容易出现脱同步。某个字段从 ",[15,36194,36195],{},"u64"," 改为 ",[15,36198,36199],{},"i32","，native module 中要改，TypeScript 中也要改。改漏了就是 runtime error。",[11,36202,36203,36206],{},[488,36204,36205],{},"调试困难","：错误发生在 native module 与 Node.js 边界时，堆栈跟踪跨越两种语言，调试工具和资料都很少。比如 Launcher 的 HTTP 代理层在 Rust 侧发生了某个网络超时，这个异常需要：",[243,36208,36209,36212,36215],{},[126,36210,36211],{},"从 Rust panic 转换为 Node.js exception",[126,36213,36214],{},"通过 N-API 传递",[126,36216,36217],{},"在 Electron 前端被 catch\n这个过程中很容易丢失上下文信息。",[11,36219,36220,36223],{},[488,36221,36222],{},"维护债务","：任何底层库升级都会触发重新编译。比如：",[123,36225,36226,36233,36236],{},[126,36227,36228,36229,36232],{},"更新 tokio 版本（从 1.x 升 1.y）：Rust side 简单 ",[15,36230,36231],{},"cargo update","，但 native module 需要重新编译和测试",[126,36234,36235],{},"升级 Node.js（18 → 20）：N-API 的行为改变，可能需要调整绑定代码",[126,36237,36238],{},"升级 Electron（25 → 26）：可能有 V8 引擎版本变化，影响 native module 兼容性",[11,36240,36241],{},"总之，跨语言的架构引入了显著的复杂性。",[31,36243,36245],{"id":36244},"tauri-方案的优势","Tauri 方案的优势",[11,36247,36248],{},"选 Tauri 后，整个后端都是 Rust：",[399,36250,36253],{"className":36251,"code":36252,"language":1327},[1325],"Launcher (Rust)\n├─ tokio async runtime\n├─ reqwest HTTP client  ← 可以引入这个库\n├─ serde JSON parsing   ← 可以引入这个库\n├─ fs2 file locking     ← 可以引入这个库\n└─ windows-rs Win32 API ← 可以引入这个库\n\nTauri 桌面应用 (Rust + Vue 3)\n├─ src-tauri\u002F（Rust 后端）\n│  ├─ Cargo.toml: 引入 launcher 相关 crate\n│  │  reqwest = \"0.11\"  \u002F\u002F 与 Launcher 共用版本\n│  │  serde = { version = \"1.0\", features = [\"derive\"] }\n│  │  windows = { version = \"0.52\", features = [...] }\n│  ├─ commands\u002F\n│  │  ├─ gateway.rs     : 封装 reqwest client 为 invoke 命令\n│  │  ├─ user_config.rs : 使用 fs2 做原子写\n│  │  └─ runtime.rs     : 读取 runtime-info.json\n│  └─ src\u002Fmain.rs       : Tauri builder 入口\n├─ src\u002F（Vue 3 前端）\n│  ├─ stores\u002F\n│  │  ├─ gateway.ts\n│  │  ├─ userConfig.ts\n│  │  └─ runtime.ts\n│  └─ services\u002F\n│     └─ tauri.ts       : 包装 invoke() 调用\n",[15,36254,36252],{"__ignoreMap":183},[11,36256,36257,1244],{},[488,36258,36259],{},"代码复用清单",[11,36261,36262],{},"实际计算可复用的代码行数：",[1708,36264,36265,36281],{},[1711,36266,36267],{},[1714,36268,36269,36272,36275,36278],{},[1717,36270,36271],{},"模块",[1717,36273,36274],{},"Launcher 行数",[1717,36276,36277],{},"复用方式",[1717,36279,36280],{},"节省",[1730,36282,36283,36297,36311,36325,36339,36352],{},[1714,36284,36285,36288,36291,36294],{},[1735,36286,36287],{},"HTTP gateway proxy",[1735,36289,36290],{},"500 行",[1735,36292,36293],{},"直接导入 reqwest client，wrapper 只需 50 行",[1735,36295,36296],{},"-90%",[1714,36298,36299,36302,36305,36308],{},[1735,36300,36301],{},"原子文件写",[1735,36303,36304],{},"200 行",[1735,36306,36307],{},"导入 fs2，wrapper 只需 30 行",[1735,36309,36310],{},"-85%",[1714,36312,36313,36316,36319,36322],{},[1735,36314,36315],{},"命名管道 IPC",[1735,36317,36318],{},"300 行",[1735,36320,36321],{},"导入 windows-rs，wrapper 只需 50 行",[1735,36323,36324],{},"-83%",[1714,36326,36327,36330,36333,36336],{},[1735,36328,36329],{},"错误类型",[1735,36331,36332],{},"150 行",[1735,36334,36335],{},"直接导入 errors module",[1735,36337,36338],{},"-100%",[1714,36340,36341,36344,36347,36350],{},[1735,36342,36343],{},"品牌常量",[1735,36345,36346],{},"100 行",[1735,36348,36349],{},"直接导入 brand module",[1735,36351,36338],{},[1714,36353,36354,36359,36364,36369],{},[1735,36355,36356],{},[488,36357,36358],{},"小计",[1735,36360,36361],{},[488,36362,36363],{},"1250 行",[1735,36365,36366],{},[488,36367,36368],{},"总计复用 ~900 行",[1735,36370,36371],{},[488,36372,36373],{},"-72%",[11,36375,36376],{},"这意味着配置中心的 Rust 后端大约可以节省 900 行代码。900 行按每小时 50 行的编码速度计算，节省了 18 小时的开发时间。而且这些是业务关键的代码——用现成的、已测试过的实现，质量有保证。",[11,36378,36379],{},[488,36380,36381],{},"被放弃的选项",[11,36383,36384],{},"也考虑过 Electron + native module 的混合方案。伪代码：",[399,36386,36388],{"className":27261,"code":36387,"language":27263,"meta":183,"style":183},"\u002F\u002F 作为 native module 暴露给 Electron\n#[napi]\npub fn gateway_request(url: String, opts: String) -> napi::Result\u003CString> {\n    let client = reqwest::Client::new();\n    \u002F\u002F 实现 HTTP 请求\n    Ok(response_json)\n}\n",[15,36389,36390,36395,36400,36405,36410,36415,36420],{"__ignoreMap":183},[407,36391,36392],{"class":409,"line":410},[407,36393,36394],{},"\u002F\u002F 作为 native module 暴露给 Electron\n",[407,36396,36397],{"class":409,"line":184},[407,36398,36399],{},"#[napi]\n",[407,36401,36402],{"class":409,"line":189},[407,36403,36404],{},"pub fn gateway_request(url: String, opts: String) -> napi::Result\u003CString> {\n",[407,36406,36407],{"class":409,"line":452},[407,36408,36409],{},"    let client = reqwest::Client::new();\n",[407,36411,36412],{"class":409,"line":458},[407,36413,36414],{},"    \u002F\u002F 实现 HTTP 请求\n",[407,36416,36417],{"class":409,"line":464},[407,36418,36419],{},"    Ok(response_json)\n",[407,36421,36422],{"class":409,"line":470},[407,36423,483],{},[11,36425,36426],{},"然后在 Electron 中：",[399,36428,36430],{"className":18628,"code":36429,"language":18630,"meta":183,"style":183},"const { gateway_request } = require('.\u002Fnative');\nconst response = gateway_request(url, opts);\n",[15,36431,36432,36455],{"__ignoreMap":183},[407,36433,36434,36436,36438,36440,36443,36445,36448,36450,36453],{"class":409,"line":410},[407,36435,700],{"class":417},[407,36437,7652],{"class":413},[407,36439,36089],{"class":476},[407,36441,36442],{"class":413}," } ",[407,36444,418],{"class":417},[407,36446,36447],{"class":554}," require",[407,36449,586],{"class":413},[407,36451,36452],{"class":429},"'.\u002Fnative'",[407,36454,662],{"class":413},[407,36456,36457,36459,36461,36463,36466],{"class":409,"line":184},[407,36458,700],{"class":417},[407,36460,21039],{"class":476},[407,36462,706],{"class":417},[407,36464,36465],{"class":554}," gateway_request",[407,36467,36468],{"class":413},"(url, opts);\n",[11,36470,36471],{},"这样可以在 Electron 中调用 Rust 代码。但成本包括：",[123,36473,36474,36480,36486,36496,36502],{},[126,36475,36476,36479],{},[488,36477,36478],{},"构建脚本复杂度提升 3 倍","：需要 node-gyp、MSVC 工具链配置、目标 triple 管理",[126,36481,36482,36485],{},[488,36483,36484],{},"分发体积增加","：预编译的 .node 文件（Node Native Extension）体积大（通常 5-10MB），且不同 Node 版本不兼容，需要发布多个版本到 npm",[126,36487,36488,36491,36492,36495],{},[488,36489,36490],{},"开发反馈慢","：改一行 Rust 代码都要重新 ",[15,36493,36494],{},"npm run build:native","，速度慢",[126,36497,36498,36501],{},[488,36499,36500],{},"错误报告不清楚","：JSON 序列化\u002F反序列化过程中容易丢失类型信息或堆栈跟踪",[126,36503,36504,36506],{},[488,36505,36222],{},"：任何 Node 版本升级都需要重新编译",[11,36508,36509],{},"Tauri 的 invoke 机制绕过了所有这些问题。WebView JS 与 Rust 的通道由 Tauri 框架管理，类型通过 serde derive 自动序列化，错误处理一致。",[26,36511,36513],{"id":36512},"理由三更新包体积更小","理由三：更新包体积更小",[31,36515,36516],{"id":36516},"更新流程中的成本",[11,36518,36519],{},"便携应用的更新路径有两种：",[11,36521,36522],{},[488,36523,36524],{},"路径 A：物理 U 盘",[11,36526,36527],{},"用户从官网下载新版本到 U 盘，拷贝覆盖旧版本。成本是：",[123,36529,36530,36533,36536],{},[126,36531,36532],{},"用户的网络带宽（下载新版本）",[126,36534,36535],{},"用户的等待时间",[126,36537,36538],{},"官网的服务器流量",[11,36540,36541],{},[488,36542,36543],{},"路径 B：应用内网络更新",[11,36545,36546],{},"应用内检测更新，自动下载最新版本。成本是：",[123,36548,36549,36552,36555],{},[126,36550,36551],{},"用户的网络带宽（同上）",[126,36553,36554],{},"开发者的 CDN 费用（流量计费）",[126,36556,36557],{},"服务器存储成本（保存多个历史版本）",[11,36559,36560],{},"无论哪种路径，包体积都直接影响成本。60MB vs 20MB，相差整整 3 倍。",[11,36562,36563,1244],{},[488,36564,36565],{},"成本计算（假设 1000 活跃用户，月更新一次）",[1708,36567,36568,36581],{},[1711,36569,36570],{},[1714,36571,36572,36574,36576,36578],{},[1717,36573,26677],{},[1717,36575,15068],{},[1717,36577,19953],{},[1717,36579,36580],{},"成本差",[1730,36582,36583,36597,36611],{},[1714,36584,36585,36588,36591,36594],{},[1735,36586,36587],{},"用户网络（下载 1000 次 60MB）",[1735,36589,36590],{},"60 GB 月",[1735,36592,36593],{},"20 GB 月",[1735,36595,36596],{},"省 40 GB",[1714,36598,36599,36602,36605,36608],{},[1735,36600,36601],{},"CDN 流量费（$0.1\u002FGB）",[1735,36603,36604],{},"$600\u002F月",[1735,36606,36607],{},"$200\u002F月",[1735,36609,36610],{},"省 $400\u002F月",[1714,36612,36613,36616,36619,36622],{},[1735,36614,36615],{},"年度成本",[1735,36617,36618],{},"$7200",[1735,36620,36621],{},"$2400",[1735,36623,36624],{},[488,36625,36626],{},"省 $4800\u002F年",[11,36628,36629],{},"对于商业应用，省钱就是收益。对于个人开发者，更新不了的快速迭代也是成本。",[31,36631,36632],{"id":36632},"分层配置的增量优势",[11,36634,36635],{},"进一步优化的策略是配置文件分层。这个项目将配置拆成两个 YAML：",[11,36637,36638],{},[488,36639,36640],{},"user.yml（本体配置，改动触发 Gateway reload）",[11,36642,36643],{},"这个文件存储用户真正配置的内容：API Key、模型选择、渠道连接状态等。改动时需要通知 Gateway 重新加载。",[399,36645,36647],{"className":8608,"code":36646,"language":8610,"meta":183,"style":183},"schema_version: 1\nbrand:\n  name: \"openclaw\"\n\nmodels:\n  providers:\n    deepseek:\n      base_url: \"https:\u002F\u002Fapi.deepseek.com\u002Fv1\"\n      api_key: \"sk-xxx...\"\n      models:\n        - id: \"deepseek-chat\"\n          name: \"DeepSeek Chat\"\n          context_window: 64000\n          max_tokens: 8192\n\nchannels:\n  feishu: { enabled: false }\n  webchat: { enabled: true }\n\nplugins:\n  allow: [\"skill-basic-chat\"]\n",[15,36648,36649,36658,36666,36676,36680,36686,36693,36700,36710,36720,36727,36739,36749,36759,36769,36773,36780,36795,36810,36814,36821],{"__ignoreMap":183},[407,36650,36651,36654,36656],{"class":409,"line":410},[407,36652,36653],{"class":8622},"schema_version",[407,36655,3607],{"class":413},[407,36657,25645],{"class":476},[407,36659,36660,36663],{"class":409,"line":184},[407,36661,36662],{"class":8622},"brand",[407,36664,36665],{"class":413},":\n",[407,36667,36668,36671,36673],{"class":409,"line":189},[407,36669,36670],{"class":8622},"  name",[407,36672,3607],{"class":413},[407,36674,36675],{"class":429},"\"openclaw\"\n",[407,36677,36678],{"class":409,"line":452},[407,36679,1827],{"emptyLinePlaceholder":202},[407,36681,36682,36684],{"class":409,"line":458},[407,36683,32310],{"class":8622},[407,36685,36665],{"class":413},[407,36687,36688,36691],{"class":409,"line":464},[407,36689,36690],{"class":8622},"  providers",[407,36692,36665],{"class":413},[407,36694,36695,36698],{"class":409,"line":470},[407,36696,36697],{"class":8622},"    deepseek",[407,36699,36665],{"class":413},[407,36701,36702,36705,36707],{"class":409,"line":480},[407,36703,36704],{"class":8622},"      base_url",[407,36706,3607],{"class":413},[407,36708,36709],{"class":429},"\"https:\u002F\u002Fapi.deepseek.com\u002Fv1\"\n",[407,36711,36712,36715,36717],{"class":409,"line":1477},[407,36713,36714],{"class":8622},"      api_key",[407,36716,3607],{"class":413},[407,36718,36719],{"class":429},"\"sk-xxx...\"\n",[407,36721,36722,36725],{"class":409,"line":1483},[407,36723,36724],{"class":8622},"      models",[407,36726,36665],{"class":413},[407,36728,36729,36732,36734,36736],{"class":409,"line":2139},[407,36730,36731],{"class":413},"        - ",[407,36733,13266],{"class":8622},[407,36735,3607],{"class":413},[407,36737,36738],{"class":429},"\"deepseek-chat\"\n",[407,36740,36741,36744,36746],{"class":409,"line":2180},[407,36742,36743],{"class":8622},"          name",[407,36745,3607],{"class":413},[407,36747,36748],{"class":429},"\"DeepSeek Chat\"\n",[407,36750,36751,36754,36756],{"class":409,"line":7546},[407,36752,36753],{"class":8622},"          context_window",[407,36755,3607],{"class":413},[407,36757,36758],{"class":476},"64000\n",[407,36760,36761,36764,36766],{"class":409,"line":7564},[407,36762,36763],{"class":8622},"          max_tokens",[407,36765,3607],{"class":413},[407,36767,36768],{"class":476},"8192\n",[407,36770,36771],{"class":409,"line":17267},[407,36772,1827],{"emptyLinePlaceholder":202},[407,36774,36775,36778],{"class":409,"line":17279},[407,36776,36777],{"class":8622},"channels",[407,36779,36665],{"class":413},[407,36781,36782,36785,36787,36789,36791,36793],{"class":409,"line":18010},[407,36783,36784],{"class":8622},"  feishu",[407,36786,3631],{"class":413},[407,36788,13975],{"class":8622},[407,36790,3607],{"class":413},[407,36792,18738],{"class":476},[407,36794,3641],{"class":413},[407,36796,36797,36800,36802,36804,36806,36808],{"class":409,"line":18057},[407,36798,36799],{"class":8622},"  webchat",[407,36801,3631],{"class":413},[407,36803,13975],{"class":8622},[407,36805,3607],{"class":413},[407,36807,28649],{"class":476},[407,36809,3641],{"class":413},[407,36811,36812],{"class":409,"line":18062},[407,36813,1827],{"emptyLinePlaceholder":202},[407,36815,36816,36819],{"class":409,"line":18067},[407,36817,36818],{"class":8622},"plugins",[407,36820,36665],{"class":413},[407,36822,36823,36826,36828,36831],{"class":409,"line":18072},[407,36824,36825],{"class":8622},"  allow",[407,36827,8767],{"class":413},[407,36829,36830],{"class":429},"\"skill-basic-chat\"",[407,36832,20631],{"class":413},[11,36834,36835],{},[488,36836,36837],{},"app-prefs.yml（桌面偏好，改动不触发 reload）",[11,36839,36840],{},"这个文件存储用户界面的临时状态：主题、窗口大小、上次访问的页面等。改动完全是本地的，不影响 Gateway。",[399,36842,36844],{"className":8608,"code":36843,"language":8610,"meta":183,"style":183},"schema_version: 1\n\nui:\n  theme: dark\n  language: zh-CN\n  \nwindow:\n  x: 120\n  y: 80\n  w: 1280\n  h: 800\n  maximized: false\n\nlast_selected:\n  model: \"deepseek\u002Fdeepseek-chat\"\n  page: \"\u002Fhome\"\n",[15,36845,36846,36854,36858,36865,36875,36885,36890,36897,36907,36917,36927,36937,36947,36951,36958,36968],{"__ignoreMap":183},[407,36847,36848,36850,36852],{"class":409,"line":410},[407,36849,36653],{"class":8622},[407,36851,3607],{"class":413},[407,36853,25645],{"class":476},[407,36855,36856],{"class":409,"line":184},[407,36857,1827],{"emptyLinePlaceholder":202},[407,36859,36860,36863],{"class":409,"line":189},[407,36861,36862],{"class":8622},"ui",[407,36864,36665],{"class":413},[407,36866,36867,36870,36872],{"class":409,"line":452},[407,36868,36869],{"class":8622},"  theme",[407,36871,3607],{"class":413},[407,36873,36874],{"class":429},"dark\n",[407,36876,36877,36880,36882],{"class":409,"line":458},[407,36878,36879],{"class":8622},"  language",[407,36881,3607],{"class":413},[407,36883,36884],{"class":429},"zh-CN\n",[407,36886,36887],{"class":409,"line":464},[407,36888,36889],{"class":413},"  \n",[407,36891,36892,36895],{"class":409,"line":470},[407,36893,36894],{"class":8622},"window",[407,36896,36665],{"class":413},[407,36898,36899,36902,36904],{"class":409,"line":480},[407,36900,36901],{"class":8622},"  x",[407,36903,3607],{"class":413},[407,36905,36906],{"class":476},"120\n",[407,36908,36909,36912,36914],{"class":409,"line":1477},[407,36910,36911],{"class":476},"  y",[407,36913,3607],{"class":413},[407,36915,36916],{"class":476},"80\n",[407,36918,36919,36922,36924],{"class":409,"line":1483},[407,36920,36921],{"class":8622},"  w",[407,36923,3607],{"class":413},[407,36925,36926],{"class":476},"1280\n",[407,36928,36929,36932,36934],{"class":409,"line":2139},[407,36930,36931],{"class":8622},"  h",[407,36933,3607],{"class":413},[407,36935,36936],{"class":476},"800\n",[407,36938,36939,36942,36944],{"class":409,"line":2180},[407,36940,36941],{"class":8622},"  maximized",[407,36943,3607],{"class":413},[407,36945,36946],{"class":476},"false\n",[407,36948,36949],{"class":409,"line":7546},[407,36950,1827],{"emptyLinePlaceholder":202},[407,36952,36953,36956],{"class":409,"line":7564},[407,36954,36955],{"class":8622},"last_selected",[407,36957,36665],{"class":413},[407,36959,36960,36963,36965],{"class":409,"line":17267},[407,36961,36962],{"class":8622},"  model",[407,36964,3607],{"class":413},[407,36966,36967],{"class":429},"\"deepseek\u002Fdeepseek-chat\"\n",[407,36969,36970,36973,36975],{"class":409,"line":17279},[407,36971,36972],{"class":8622},"  page",[407,36974,3607],{"class":413},[407,36976,36977],{"class":429},"\"\u002Fhome\"\n",[11,36979,36980],{},[488,36981,36982],{},"为什么要分离？",[11,36984,36985],{},"这两类数据的更新频率和影响范围完全不同：",[123,36987,36988,36991],{},[126,36989,36990],{},"user.yml 改动很少见（用户配置 API Key 时、添加新模型时），但一旦改动需要通知 Gateway 重新加载配置。用户可能几个月都不碰这个文件。",[126,36992,36993],{},"app-prefs.yml 改动很频繁（每次调整窗口大小、切换页面、改主题），但完全是本地状态，不影响 Gateway。",[11,36995,36996],{},"如果没有这个分层，每次用户调整窗口大小（1280×800 → 1920×1080）都会被记录到 user.yml，然后在下一次产品更新时被打包进去。这意味着文件内容频繁变化，增量更新时无法有效压缩（diff 算法看到的是文件内容变化，难以识别哪些是有意义的改动）。",[11,36998,36999],{},"分层后，只有 user.yml 的变化才需要在更新包中传输。假设统计 10000 个用户：",[123,37001,37002,37005,37008],{},[126,37003,37004],{},"其中 9500 个用户的 user.yml 一个月都没改（API Key 没变、模型配置没变）",[126,37006,37007],{},"只有 500 个用户改过配置",[126,37009,37010],{},"在这 500 个人中，改动大小平均只有 2-3KB（比如加一个新模型、禁用一个渠道）",[11,37012,37013],{},"增量更新时，这 9500 个用户的包甚至可以是 0 字节（user.yml 部分完全无变化）。前端资源（Vue.js + CSS）倒是会经常改动，但总量只有 250-80 = 330KB，即使完整传输也很小。",[31,37015,37016],{"id":37016},"分发成本的数字对比",[1708,37018,37019,37033],{},[1711,37020,37021],{},[1714,37022,37023,37025,37028,37031],{},[1717,37024,26677],{},[1717,37026,37027],{},"Electron 策略",[1717,37029,37030],{},"Tauri 策略",[1717,37032,36280],{},[1730,37034,37035,37051,37067,37083,37097],{},[1714,37036,37037,37043,37046,37049],{},[1735,37038,37039,37042],{},[488,37040,37041],{},"场景 1：前端 UI 改动","（改首页样式、加按钮）",[1735,37044,37045],{},"完整 60 MB",[1735,37047,37048],{},"前端资源 Δ ≈ 50 KB",[1735,37050,25018],{},[1714,37052,37053,37059,37061,37064],{},[1735,37054,37055,37058],{},[488,37056,37057],{},"场景 2：模型列表更新","（加新 provider）",[1735,37060,37045],{},[1735,37062,37063],{},"Rust Δ ≈ 0-2 MB",[1735,37065,37066],{},"-96%",[1714,37068,37069,37075,37077,37080],{},[1735,37070,37071,37074],{},[488,37072,37073],{},"场景 3：Bug 修复","（修复一个 config 读写 bug）",[1735,37076,37045],{},[1735,37078,37079],{},"Rust Δ ≈ 100 KB",[1735,37081,37082],{},"-99.8%",[1714,37084,37085,37090,37092,37095],{},[1735,37086,37087],{},[488,37088,37089],{},"场景 4：前端 + 后端同时改",[1735,37091,37045],{},[1735,37093,37094],{},"50 KB + 2 MB = 2 MB",[1735,37096,37066],{},[1714,37098,37099,37102,37104,37107],{},[1735,37100,37101],{},"用户装 3 个版本（总成本）",[1735,37103,35940],{},[1735,37105,37106],{},"20 + 0.05 + 2 = 22 MB",[1735,37108,37109],{},"-87%",[11,37111,37112],{},"这里的 Tauri 增量假设采用了 differential patching（只传输二进制的变化，而非完整重新下载）。这个项目在 Plan 03（MVP）阶段还没有实装自动增量（技术债务在 Plan 04 的 debt-03-F 中记录），但架构上完全支持这种优化。",[11,37114,37115,37118],{},[488,37116,37117],{},"实装策略","（后续）：",[11,37120,8285,37121,13067,37124,37127],{},[15,37122,37123],{},"diffpatch",[15,37125,37126],{},"rsync"," 算法生成二进制 delta 文件。即使 18MB 的 Rust binary 只改了 2MB 的代码，可以只传输那 2MB 的差异。对前端资源采用 Vite 的资源 hash 策略，只重新下载改动过的 JS\u002FCSS 文件。",[11,37129,37130],{},[488,37131,36381],{},[11,37133,37134],{},"考虑过为 Electron 实装类似的增量更新（electron-updater 支持 delta 更新），但有两个问题：",[243,37136,37137,37143],{},[126,37138,37139,37142],{},[488,37140,37141],{},"基数过大","：即使差异只有 5-10%，增量也是 3-6MB。维护增量分发流程（计算 patch、上传到 CDN、验证签名）的工程成本相对收益不大。而 Tauri 的增量通常在 50KB-2MB 之间，工程复杂度一样，收益却 10 倍。",[126,37144,37145,37148],{},[488,37146,37147],{},"更新可靠性","：增量更新依赖于用户已有的旧版本文件完整无损。一旦用户的本地文件被损坏（磁盘错误、杀毒软件误删、手动修改等），增量更新就会失败，还得回退到完整下载。Tauri + 分层配置的方案更简单：完整下载一次 18MB（秒级），之后 95% 的更新都是资源级别的小补丁。",[26,37150,37152],{"id":37151},"理由四windows-系统集成更直接","理由四：Windows 系统集成更直接",[31,37154,37156],{"id":37155},"需要的-windows-能力清单","需要的 Windows 能力清单",[11,37158,37159],{},"配置中心需要与 Windows 操作系统进行多种交互，每一个都涉及底层 API：",[11,37161,37162],{},[488,37163,37164],{},"1. 进程管理与单实例约束",[11,37166,37167],{},"用户不应该能同时打开两个配置中心窗口。实现这个需要创建一个全局互斥量（named mutex），在进程启动时尝试获取，失败说明已有实例在运行：",[399,37169,37171],{"className":27261,"code":37170,"language":27263,"meta":183,"style":183},"use windows::Win32::System::Threading::CreateMutexW;\nuse windows::core::HSTRING;\n\nlet mutex_name = format!(\"Global\\\\openclaw-tauri-singleton-{}\", &usb_hash[..8]);\nlet mutex = CreateMutexW(\n    None,                          \u002F\u002F lpMutexAttributes\n    false,                         \u002F\u002F bInitialOwner (not initially owned)\n    &HSTRING::from(mutex_name)\n)?;\n\nmatch GetLastError() {\n    0 => {\n        \u002F\u002F 互斥量创建成功，说明这是第一个实例\n        \u002F\u002F 创建命名管道监听其他实例的信号\n    }\n    ERROR_ALREADY_EXISTS => {\n        \u002F\u002F 互斥量已存在，说明有实例在运行\n        \u002F\u002F 连接管道，发送 \"RAISE\" 信号给已有实例\n        \u002F\u002F 然后退出\n    }\n}\n",[15,37172,37173,37178,37183,37187,37192,37197,37202,37207,37212,37217,37221,37226,37231,37236,37241,37245,37250,37255,37260,37265,37269],{"__ignoreMap":183},[407,37174,37175],{"class":409,"line":410},[407,37176,37177],{},"use windows::Win32::System::Threading::CreateMutexW;\n",[407,37179,37180],{"class":409,"line":184},[407,37181,37182],{},"use windows::core::HSTRING;\n",[407,37184,37185],{"class":409,"line":189},[407,37186,1827],{"emptyLinePlaceholder":202},[407,37188,37189],{"class":409,"line":452},[407,37190,37191],{},"let mutex_name = format!(\"Global\\\\openclaw-tauri-singleton-{}\", &usb_hash[..8]);\n",[407,37193,37194],{"class":409,"line":458},[407,37195,37196],{},"let mutex = CreateMutexW(\n",[407,37198,37199],{"class":409,"line":464},[407,37200,37201],{},"    None,                          \u002F\u002F lpMutexAttributes\n",[407,37203,37204],{"class":409,"line":470},[407,37205,37206],{},"    false,                         \u002F\u002F bInitialOwner (not initially owned)\n",[407,37208,37209],{"class":409,"line":480},[407,37210,37211],{},"    &HSTRING::from(mutex_name)\n",[407,37213,37214],{"class":409,"line":1477},[407,37215,37216],{},")?;\n",[407,37218,37219],{"class":409,"line":1483},[407,37220,1827],{"emptyLinePlaceholder":202},[407,37222,37223],{"class":409,"line":2139},[407,37224,37225],{},"match GetLastError() {\n",[407,37227,37228],{"class":409,"line":2180},[407,37229,37230],{},"    0 => {\n",[407,37232,37233],{"class":409,"line":7546},[407,37234,37235],{},"        \u002F\u002F 互斥量创建成功，说明这是第一个实例\n",[407,37237,37238],{"class":409,"line":7564},[407,37239,37240],{},"        \u002F\u002F 创建命名管道监听其他实例的信号\n",[407,37242,37243],{"class":409,"line":17267},[407,37244,18167],{},[407,37246,37247],{"class":409,"line":17279},[407,37248,37249],{},"    ERROR_ALREADY_EXISTS => {\n",[407,37251,37252],{"class":409,"line":18010},[407,37253,37254],{},"        \u002F\u002F 互斥量已存在，说明有实例在运行\n",[407,37256,37257],{"class":409,"line":18057},[407,37258,37259],{},"        \u002F\u002F 连接管道，发送 \"RAISE\" 信号给已有实例\n",[407,37261,37262],{"class":409,"line":18062},[407,37263,37264],{},"        \u002F\u002F 然后退出\n",[407,37266,37267],{"class":409,"line":18067},[407,37268,18167],{},[407,37270,37271],{"class":409,"line":18072},[407,37272,483],{},[11,37274,37275],{},"如果用 Electron 做这个，需要一个原生模块。Node.js 本身没有直接的 CreateMutexW 绑定。有几种选择：",[123,37277,37278,37285,37288],{},[126,37279,37280,37281,37284],{},"npm 包 ",[15,37282,37283],{},"windows-mutex","（社区维护，成熟度不明）",[126,37286,37287],{},"node-gyp + C++ 自己写（编译复杂，维护成本高）",[126,37289,37290],{},"node-ffi（动态调用 DLL，运行时性能损失）",[11,37292,37293],{},"都不如直接 Rust 调用来得清爽。",[11,37295,37296],{},[488,37297,37298],{},"2. 进程间通信（IPC）",[11,37300,37301],{},"当用户第二次启动配置中心时（实例已存在），新进程需要通知旧进程：\"嘿，用户又点了一次，请把窗口拉到前台\"。实现这个用 Windows 命名管道（Named Pipe）：",[399,37303,37305],{"className":27261,"code":37304,"language":27263,"meta":183,"style":183},"use windows::Win32::Storage::FileSystem::{CreateNamedPipeW, ConnectNamedPipe};\nuse windows::core::HSTRING;\n\nlet pipe_name = format!(\"\\\\\\\\?\\\\pipe\\\\openclaw-tauri-{}\", &usb_hash[..8]);\n\n\u002F\u002F 主实例：创建管道并监听\nlet pipe = CreateNamedPipeW(\n    &HSTRING::from(&pipe_name),\n    PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED,  \u002F\u002F 双向 + 异步\n    PIPE_TYPE_BYTE,\n    4,                                           \u002F\u002F maxInstances\n    0, 0, None\n)?;\n\n\u002F\u002F 在 tokio task 中持续监听\ntokio::spawn(async move {\n    loop {\n        if ConnectNamedPipe(pipe, None).is_ok() {\n            \u002F\u002F 有其他实例连接了，读取它的消息\n            \u002F\u002F 消息是 \"RAISE\"，表示要求拉起窗口\n            \n            \u002F\u002F 通过 Tauri AppHandle 调用前端事件\n            app_handle.emit_all(\"window-raise-requested\", ())?;\n            \n            \u002F\u002F 前端收到事件后调用 webview.set_focus() 和 webview.show()\n        }\n    }\n});\n\n\u002F\u002F 二次实例：连接管道并发送信号\nConnectNamedPipeW(\n    &HSTRING::from(&pipe_name),\n    GENERIC_WRITE,\n    None\n)?;\nWriteFile(pipe, b\"RAISE\", None)?;\n\u002F\u002F 然后退出进程\n",[15,37306,37307,37312,37316,37320,37325,37329,37334,37339,37344,37349,37354,37359,37364,37368,37372,37377,37382,37387,37392,37397,37402,37407,37412,37417,37421,37426,37430,37434,37439,37444,37450,37456,37461,37467,37473,37478,37484],{"__ignoreMap":183},[407,37308,37309],{"class":409,"line":410},[407,37310,37311],{},"use windows::Win32::Storage::FileSystem::{CreateNamedPipeW, ConnectNamedPipe};\n",[407,37313,37314],{"class":409,"line":184},[407,37315,37182],{},[407,37317,37318],{"class":409,"line":189},[407,37319,1827],{"emptyLinePlaceholder":202},[407,37321,37322],{"class":409,"line":452},[407,37323,37324],{},"let pipe_name = format!(\"\\\\\\\\?\\\\pipe\\\\openclaw-tauri-{}\", &usb_hash[..8]);\n",[407,37326,37327],{"class":409,"line":458},[407,37328,1827],{"emptyLinePlaceholder":202},[407,37330,37331],{"class":409,"line":464},[407,37332,37333],{},"\u002F\u002F 主实例：创建管道并监听\n",[407,37335,37336],{"class":409,"line":470},[407,37337,37338],{},"let pipe = CreateNamedPipeW(\n",[407,37340,37341],{"class":409,"line":480},[407,37342,37343],{},"    &HSTRING::from(&pipe_name),\n",[407,37345,37346],{"class":409,"line":1477},[407,37347,37348],{},"    PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED,  \u002F\u002F 双向 + 异步\n",[407,37350,37351],{"class":409,"line":1483},[407,37352,37353],{},"    PIPE_TYPE_BYTE,\n",[407,37355,37356],{"class":409,"line":2139},[407,37357,37358],{},"    4,                                           \u002F\u002F maxInstances\n",[407,37360,37361],{"class":409,"line":2180},[407,37362,37363],{},"    0, 0, None\n",[407,37365,37366],{"class":409,"line":7546},[407,37367,37216],{},[407,37369,37370],{"class":409,"line":7564},[407,37371,1827],{"emptyLinePlaceholder":202},[407,37373,37374],{"class":409,"line":17267},[407,37375,37376],{},"\u002F\u002F 在 tokio task 中持续监听\n",[407,37378,37379],{"class":409,"line":17279},[407,37380,37381],{},"tokio::spawn(async move {\n",[407,37383,37384],{"class":409,"line":18010},[407,37385,37386],{},"    loop {\n",[407,37388,37389],{"class":409,"line":18057},[407,37390,37391],{},"        if ConnectNamedPipe(pipe, None).is_ok() {\n",[407,37393,37394],{"class":409,"line":18062},[407,37395,37396],{},"            \u002F\u002F 有其他实例连接了，读取它的消息\n",[407,37398,37399],{"class":409,"line":18067},[407,37400,37401],{},"            \u002F\u002F 消息是 \"RAISE\"，表示要求拉起窗口\n",[407,37403,37404],{"class":409,"line":18072},[407,37405,37406],{},"            \n",[407,37408,37409],{"class":409,"line":18084},[407,37410,37411],{},"            \u002F\u002F 通过 Tauri AppHandle 调用前端事件\n",[407,37413,37414],{"class":409,"line":18091},[407,37415,37416],{},"            app_handle.emit_all(\"window-raise-requested\", ())?;\n",[407,37418,37419],{"class":409,"line":18159},[407,37420,37406],{},[407,37422,37423],{"class":409,"line":18164},[407,37424,37425],{},"            \u002F\u002F 前端收到事件后调用 webview.set_focus() 和 webview.show()\n",[407,37427,37428],{"class":409,"line":18170},[407,37429,31846],{},[407,37431,37432],{"class":409,"line":18176},[407,37433,18167],{},[407,37435,37437],{"class":409,"line":37436},28,[407,37438,667],{},[407,37440,37442],{"class":409,"line":37441},29,[407,37443,1827],{"emptyLinePlaceholder":202},[407,37445,37447],{"class":409,"line":37446},30,[407,37448,37449],{},"\u002F\u002F 二次实例：连接管道并发送信号\n",[407,37451,37453],{"class":409,"line":37452},31,[407,37454,37455],{},"ConnectNamedPipeW(\n",[407,37457,37459],{"class":409,"line":37458},32,[407,37460,37343],{},[407,37462,37464],{"class":409,"line":37463},33,[407,37465,37466],{},"    GENERIC_WRITE,\n",[407,37468,37470],{"class":409,"line":37469},34,[407,37471,37472],{},"    None\n",[407,37474,37476],{"class":409,"line":37475},35,[407,37477,37216],{},[407,37479,37481],{"class":409,"line":37480},36,[407,37482,37483],{},"WriteFile(pipe, b\"RAISE\", None)?;\n",[407,37485,37487],{"class":409,"line":37486},37,[407,37488,37489],{},"\u002F\u002F 然后退出进程\n",[11,37491,37492],{},"Electron 中要实现同样的效果，又要用到原生模块。Windows Named Pipe 不在 Node.js 的标准库中。通常的做法是：",[123,37494,37495,37501,37507],{},[126,37496,37280,37497,37500],{},[15,37498,37499],{},"node-ipc","（跨平台，但主要用 TCP socket，不用 Named Pipe）",[126,37502,37280,37503,37506],{},[15,37504,37505],{},"named-pipe","（仅 Windows，但星数少，维护度不明）",[126,37508,37509],{},"node-gyp 自己写（复杂）",[11,37511,37512],{},"而且很难保证这些包与 Tauri 的兼容性和性能。Tauri 用 Rust 后端的好处就是不用担心这些。",[11,37514,37515],{},[488,37516,37517],{},"3. WebView2 运行时检测与版本管理",[11,37519,37520],{},"在启动前检查系统是否安装了 WebView2，以及版本号是否足够新（≥ v90）。这需要查询 Windows 注册表：",[399,37522,37524],{"className":27261,"code":37523,"language":27263,"meta":183,"style":183},"use windows::Win32::System::Registry::*;\nuse windows::core::*;\n\n\u002F\u002F 打开注册表键\nlet hkey_local_machine = HKEY_LOCAL_MACHINE;\nlet subkey = w!(\"Software\\\\WOW6432Node\\\\Microsoft\\\\EdgeUpdate\\\\ClientState\\\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}\");\n\nlet mut key: HKEY = Default::default();\nRegOpenKeyExW(hkey_local_machine, subkey, 0, KEY_QUERY_VALUE, &mut key)?;\n\n\u002F\u002F 读取版本号\nlet mut version = [0u16; 256];\nlet mut size = std::mem::size_of_val(&version) as u32;\nRegQueryValueExW(key, w!(\"pv\"), None, None, Some(version.as_mut_ptr() as *mut _), Some(&mut size))?;\n\n\u002F\u002F 将 UTF-16 转换为字符串，解析版本号\nlet version_str = String::from_utf16(&version[..size as usize \u002F 2])\n    .unwrap_or_default();\n\nlet major_version = version_str.split('.').next()\n    .and_then(|s| s.parse::\u003Ci32>().ok())\n    .unwrap_or(0);\n\nif major_version \u003C 90 {\n    \u002F\u002F 版本太旧，提示用户下载\n}\n",[15,37525,37526,37531,37536,37540,37545,37550,37555,37559,37564,37569,37573,37578,37583,37588,37593,37597,37602,37607,37612,37616,37621,37626,37631,37635,37640,37645],{"__ignoreMap":183},[407,37527,37528],{"class":409,"line":410},[407,37529,37530],{},"use windows::Win32::System::Registry::*;\n",[407,37532,37533],{"class":409,"line":184},[407,37534,37535],{},"use windows::core::*;\n",[407,37537,37538],{"class":409,"line":189},[407,37539,1827],{"emptyLinePlaceholder":202},[407,37541,37542],{"class":409,"line":452},[407,37543,37544],{},"\u002F\u002F 打开注册表键\n",[407,37546,37547],{"class":409,"line":458},[407,37548,37549],{},"let hkey_local_machine = HKEY_LOCAL_MACHINE;\n",[407,37551,37552],{"class":409,"line":464},[407,37553,37554],{},"let subkey = w!(\"Software\\\\WOW6432Node\\\\Microsoft\\\\EdgeUpdate\\\\ClientState\\\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}\");\n",[407,37556,37557],{"class":409,"line":470},[407,37558,1827],{"emptyLinePlaceholder":202},[407,37560,37561],{"class":409,"line":480},[407,37562,37563],{},"let mut key: HKEY = Default::default();\n",[407,37565,37566],{"class":409,"line":1477},[407,37567,37568],{},"RegOpenKeyExW(hkey_local_machine, subkey, 0, KEY_QUERY_VALUE, &mut key)?;\n",[407,37570,37571],{"class":409,"line":1483},[407,37572,1827],{"emptyLinePlaceholder":202},[407,37574,37575],{"class":409,"line":2139},[407,37576,37577],{},"\u002F\u002F 读取版本号\n",[407,37579,37580],{"class":409,"line":2180},[407,37581,37582],{},"let mut version = [0u16; 256];\n",[407,37584,37585],{"class":409,"line":7546},[407,37586,37587],{},"let mut size = std::mem::size_of_val(&version) as u32;\n",[407,37589,37590],{"class":409,"line":7564},[407,37591,37592],{},"RegQueryValueExW(key, w!(\"pv\"), None, None, Some(version.as_mut_ptr() as *mut _), Some(&mut size))?;\n",[407,37594,37595],{"class":409,"line":17267},[407,37596,1827],{"emptyLinePlaceholder":202},[407,37598,37599],{"class":409,"line":17279},[407,37600,37601],{},"\u002F\u002F 将 UTF-16 转换为字符串，解析版本号\n",[407,37603,37604],{"class":409,"line":18010},[407,37605,37606],{},"let version_str = String::from_utf16(&version[..size as usize \u002F 2])\n",[407,37608,37609],{"class":409,"line":18057},[407,37610,37611],{},"    .unwrap_or_default();\n",[407,37613,37614],{"class":409,"line":18062},[407,37615,1827],{"emptyLinePlaceholder":202},[407,37617,37618],{"class":409,"line":18067},[407,37619,37620],{},"let major_version = version_str.split('.').next()\n",[407,37622,37623],{"class":409,"line":18072},[407,37624,37625],{},"    .and_then(|s| s.parse::\u003Ci32>().ok())\n",[407,37627,37628],{"class":409,"line":18084},[407,37629,37630],{},"    .unwrap_or(0);\n",[407,37632,37633],{"class":409,"line":18091},[407,37634,1827],{"emptyLinePlaceholder":202},[407,37636,37637],{"class":409,"line":18159},[407,37638,37639],{},"if major_version \u003C 90 {\n",[407,37641,37642],{"class":409,"line":18164},[407,37643,37644],{},"    \u002F\u002F 版本太旧，提示用户下载\n",[407,37646,37647],{"class":409,"line":18170},[407,37648,483],{},[11,37650,37651],{},"Electron 中查注册表也要用原生模块或 node-gyp 脚本。而且 Node.js 访问注册表的方式都不够优雅。",[11,37653,37654],{},[488,37655,37656],{},"4. 外部程序启动（浏览器打开）",[11,37658,37659],{},"用户点\"打开 WebUI\"按钮，应该用系统默认浏览器打开 Gateway 的地址。在 Windows 上用 ShellExecuteW：",[399,37661,37663],{"className":27261,"code":37662,"language":27263,"meta":183,"style":183},"use windows::Win32::System::Com::ShellExecuteW;\nuse windows::core::*;\n\nShellExecuteW(\n    None,                                           \u002F\u002F hwnd\n    w!(\"open\"),                                     \u002F\u002F lpOperation\n    w!(\"http:\u002F\u002F127.0.0.1:53721\"),                  \u002F\u002F lpFile (URL)\n    None,                                           \u002F\u002F lpParameters\n    None,                                           \u002F\u002F lpDirectory\n    windows::Win32::System::Com::SW_SHOW\n)?;\n",[15,37664,37665,37670,37674,37678,37683,37688,37693,37698,37703,37708,37713],{"__ignoreMap":183},[407,37666,37667],{"class":409,"line":410},[407,37668,37669],{},"use windows::Win32::System::Com::ShellExecuteW;\n",[407,37671,37672],{"class":409,"line":184},[407,37673,37535],{},[407,37675,37676],{"class":409,"line":189},[407,37677,1827],{"emptyLinePlaceholder":202},[407,37679,37680],{"class":409,"line":452},[407,37681,37682],{},"ShellExecuteW(\n",[407,37684,37685],{"class":409,"line":458},[407,37686,37687],{},"    None,                                           \u002F\u002F hwnd\n",[407,37689,37690],{"class":409,"line":464},[407,37691,37692],{},"    w!(\"open\"),                                     \u002F\u002F lpOperation\n",[407,37694,37695],{"class":409,"line":470},[407,37696,37697],{},"    w!(\"http:\u002F\u002F127.0.0.1:53721\"),                  \u002F\u002F lpFile (URL)\n",[407,37699,37700],{"class":409,"line":480},[407,37701,37702],{},"    None,                                           \u002F\u002F lpParameters\n",[407,37704,37705],{"class":409,"line":1477},[407,37706,37707],{},"    None,                                           \u002F\u002F lpDirectory\n",[407,37709,37710],{"class":409,"line":1483},[407,37711,37712],{},"    windows::Win32::System::Com::SW_SHOW\n",[407,37714,37715],{"class":409,"line":2139},[407,37716,37216],{},[11,37718,37719],{},"这会调用系统的 URL 处理器，用用户设置的默认浏览器打开链接。非常可靠。",[11,37721,37722,37723,37726,37727,37730],{},"Electron 中可以用 ",[15,37724,37725],{},"child_process.exec('start http:\u002F\u002F...')","，但这是调用系统 shell，不如直接 ShellExecuteW 可靠。更好的方案是 npm 包 ",[15,37728,37729],{},"open","，但又多了一个依赖，还要担心它的维护状态。",[31,37732,37734],{"id":37733},"tauri-的系统集成优势","Tauri 的系统集成优势",[11,37736,37737],{},"用 Tauri + Rust 后端的好处是：所有这些 Windows API 都直接可用，通过 windows-rs crate。无需额外的原生模块编译、无需第三方包的兼容性顾虑。",[399,37739,37743],{"className":37740,"code":37741,"language":37742,"meta":183,"style":183},"language-toml shiki shiki-themes github-light github-dark","[dependencies]\nwindows = { version = \"0.52\", features = [\n    \"Win32_System_Threading\",      \u002F\u002F CreateMutexW\n    \"Win32_Storage_FileSystem\",    \u002F\u002F CreateNamedPipeW\n    \"Win32_System_Registry\",       \u002F\u002F RegOpenKeyExW\n    \"Win32_System_Com\"             \u002F\u002F ShellExecuteW\n] }\n","toml",[15,37744,37745,37750,37755,37763,37771,37779,37787],{"__ignoreMap":183},[407,37746,37747],{"class":409,"line":410},[407,37748,37749],{},"[dependencies]\n",[407,37751,37752],{"class":409,"line":184},[407,37753,37754],{},"windows = { version = \"0.52\", features = [\n",[407,37756,37757,37760],{"class":409,"line":189},[407,37758,37759],{},"    \"Win32_System_Threading\",",[407,37761,37762],{},"      \u002F\u002F CreateMutexW\n",[407,37764,37765,37768],{"class":409,"line":452},[407,37766,37767],{},"    \"Win32_Storage_FileSystem\",",[407,37769,37770],{},"    \u002F\u002F CreateNamedPipeW\n",[407,37772,37773,37776],{"class":409,"line":458},[407,37774,37775],{},"    \"Win32_System_Registry\",",[407,37777,37778],{},"       \u002F\u002F RegOpenKeyExW\n",[407,37780,37781,37784],{"class":409,"line":464},[407,37782,37783],{},"    \"Win32_System_Com\"",[407,37785,37786],{},"             \u002F\u002F ShellExecuteW\n",[407,37788,37789],{"class":409,"line":470},[407,37790,37791],{},"] }\n",[11,37793,37794],{},"一个 Cargo.toml 的配置，所有 Win32 API 都在手边。调用时就是普通的 Rust 代码，IDE 有完整的类型检查和代码补全。性能上也没有任何中间层开销——直接调用系统 API。",[11,37796,37797],{},"windows-rs 是 Microsoft 官方维护的库，API coverage 和更新速度都很快。比依赖社区维护的小 npm 包可靠得多。",[31,37799,37800],{"id":37800},"调试和维护的成本差异",[11,37802,37803],{},"遇到 Windows 系统级的 Bug 时，两种方案的成本差异很大：",[11,37805,37806,37809],{},[488,37807,37808],{},"Tauri 方案","（问题快速修复）：",[243,37811,37812,37821,37824,37827,37834],{},[126,37813,18621,37814,13067,37817,37820],{},[15,37815,37816],{},"single_instance.rs",[15,37818,37819],{},"gateway_proxy.rs"," 中加 debug 日志",[126,37822,37823],{},"用 eprintln! 或 log crate 打印详细信息",[126,37825,37826],{},"本地测试或连接远程 Windows 机器调试（RDP）",[126,37828,37829,37830,37833],{},"修改 Rust 代码，",[15,37831,37832],{},"cargo build"," 重新编译（秒级）",[126,37835,37836],{},"0 额外依赖，0 兼容性问题",[11,37838,37839,37842],{},[488,37840,37841],{},"Electron + native module 方案","（修复慢且容易出问题）：",[243,37844,37845,37848,37851,37854,37857,37860,37863],{},[126,37846,37847],{},"在 C++\u002FRust 代码中加日志",[126,37849,37850],{},"用 node-gyp rebuild 重新编译（分钟级）",[126,37852,37853],{},"检查 npm 版本、Node 版本是否匹配",[126,37855,37856],{},"本地测试通过后，上传新的 prebuilt binary 到 npm registry",[126,37858,37859],{},"通知用户升级 npm 依赖，等待 npm install 下载新版本",[126,37861,37862],{},"可能需要等待用户反馈是否真的修复了",[126,37864,37865],{},"如果问题涉及 Node 或 Electron 版本，可能需要多个版本的 binary",[11,37867,37868],{},"调试一个互斥量或管道相关的 Windows Bug，Tauri 方案可能 1-2 小时搞定，Electron 方案可能要 1-2 天（包括编译、上传、用户反馈周期）。",[26,37870,37871],{"id":37871},"代价与现实",[11,37873,37874],{},"选择 Tauri 不是银弹。三个代价必须正视、明确记录：",[31,37876,37878],{"id":37877},"代价一前端生态成熟度低于-electron社区库工具文档","代价一：前端生态成熟度低于 Electron（社区库、工具、文档）",[11,37880,37881],{},"Electron 有 10+ 年的积累，社区工具、UI 库、启动模板、学习资料极其丰富。Tauri 是后来者，虽然发展快速（v2.0 在 2024 年发布），但生态仍在追赶阶段。",[11,37883,37884,1244],{},[488,37885,37886],{},"具体体现",[123,37888,37889,37895,37901,37907],{},[126,37890,37891,37894],{},[488,37892,37893],{},"UI 组件库","：Electron 生态有 electron-react-boilerplate、electron-vue 等现成启动项目，开箱即用。Tauri 则需要自己选择前端框架（React \u002F Vue \u002F Svelte），然后手工集成 Tauri 框架。",[126,37896,37897,37900],{},[488,37898,37899],{},"设计系统","：基于 Electron 的大型应用（VS Code、Slack、Figma）都有完整的设计系统文档公开。Tauri 应用还相对少。",[126,37902,37903,37906],{},[488,37904,37905],{},"问题解答","：Stack Overflow 搜 Electron 问题，通常有现成解答。搜 Tauri 问题时经常找不到人遇过同样的坑。",[126,37908,37909,37912],{},[488,37910,37911],{},"第三方集成","：很多 SaaS 工具（Sentry、Segment、Logrocket）都有 Electron 集成，Tauri 支持相对较弱。",[11,37914,37915],{},"这个项目的应对策略是自建组件库，不依赖第三方 UI 框架。具体规划是：",[399,37917,37920],{"className":37918,"code":37919,"language":1327},[1325],"自建 16 个组件库\n├─ 基础控件（AppButton、AppInput、AppToggle、AppRadio）\n├─ 反馈组件（AppStatusBadge、AppStatusDot、AppToast、AppModal）\n├─ 容器组件（AppCard、AppGlassPanel、AppPageHeader）\n└─ 布局组件（AppBentoGrid、AppSectionGroup）\n\n工作量估算：\n├─ 设计 token 体系（Tailwind + Material Design 3）：2 天\n├─ 16 个组件实装 + 单测：6 天\n├─ 5 个业务页面集成这些组件：3 天\n└─ 总计：11 天（占 Plan 03 总工期 25 天的 44%）\n",[15,37921,37919],{"__ignoreMap":183},[11,37923,37924],{},"优点：",[123,37926,37927,37930,37933,37936],{},[126,37928,37929],{},"完全掌控 UI 外观和行为",[126,37931,37932],{},"与 Tauri 的集成 100% 可靠（没有第三方包的兼容性问题）",[126,37934,37935],{},"日后维护和修改只需改自己的代码",[126,37937,37938],{},"可以为特定功能优化（比如 Material Design 3 的暗黑主题优化）",[11,37940,37941],{},"缺点：",[123,37943,37944,37947,37950],{},[126,37945,37946],{},"工作量大（44% 的工期用在组件库）",[126,37948,37949],{},"不能复用成熟的设计系统（比如 Ant Design、shadcn\u002Fui）",[126,37951,37952],{},"团队成员需要熟悉 Vue 3 + Tailwind，学习曲线更陡",[11,37954,37955,1244],{},[488,37956,24254],{},[123,37958,37959,37962],{},[126,37960,37961],{},"小到中等规模应用（5-20 个页面）：自建组件库是合理的投资",[126,37963,37964],{},"大型应用（50+ 页面、复杂交互）：可能需要重新评估，找现成的 Tauri-compatible UI 库或投入更多资源",[31,37966,37968],{"id":37967},"代价二webview2-版本差异带来的兼容性问题","代价二：WebView2 版本差异带来的兼容性问题",[11,37970,37971],{},"Electron 内置特定版本的 Chromium，所有用户体验完全一致。WebView2 则取决于用户机器上安装的版本。这引入了环境差异：",[11,37973,37974,1244],{},[488,37975,37976],{},"版本时间线与分布",[123,37978,37979,37982,37985],{},[126,37980,37981],{},"Windows 11：内置 WebView2（版本通常是最新的或接近最新）",[126,37983,37984],{},"Windows 10：需要从 Microsoft 下载（版本取决于用户是否启用 auto-update）",[126,37986,37987],{},"非常旧的 Windows 版本（Win7、Win8）：不支持 WebView2",[11,37989,37990,1244],{},[488,37991,37992],{},"兼容性风险",[11,37994,37995],{},"不同版本的 WebView2 对新 CSS 特性、JavaScript API、性能特征的支持差异很大。例如：",[1708,37997,37998,38011],{},[1711,37999,38000],{},[1714,38001,38002,38005,38008],{},[1717,38003,38004],{},"特性",[1717,38006,38007],{},"最低版本",[1717,38009,38010],{},"常见问题",[1730,38012,38013,38027,38038,38052,38068],{},[1714,38014,38015,38021,38024],{},[1735,38016,38017,38018,12414],{},"CSS Grid ",[15,38019,38020],{},"gap",[1735,38022,38023],{},"v87",[1735,38025,38026],{},"v86 及以下需要用 margin\u002Fpadding",[1714,38028,38029,38032,38035],{},[1735,38030,38031],{},"CSS Subgrid",[1735,38033,38034],{},"v101",[1735,38036,38037],{},"对于复杂布局有 workaround",[1714,38039,38040,38046,38049],{},[1735,38041,38042,38043],{},"JS ",[15,38044,38045],{},"Promise.allSettled()",[1735,38047,38048],{},"v85",[1735,38050,38051],{},"老版本需要 polyfill",[1714,38053,38054,38059,38062],{},[1735,38055,38042,38056],{},[15,38057,38058],{},"globalThis",[1735,38060,38061],{},"v83",[1735,38063,38064,38065,38067],{},"v82 及以下用 ",[15,38066,36894],{}," 代替",[1714,38069,38070,38073,38076],{},[1735,38071,38072],{},"SharedArrayBuffer",[1735,38074,38075],{},"v91",[1735,38077,38078],{},"Web Worker 中的高性能共享内存",[11,38080,38081],{},"这个项目设置的最低版本是 v90，意味着：",[123,38083,38084,38087,38090],{},[126,38085,38086],{},"可以用绝大多数现代 CSS 特性",[126,38088,38089],{},"可以用 ES2021 特性",[126,38091,38092],{},"但要避免最新的 v120+ 才有的特性",[11,38094,38095,1244],{},[488,38096,38097],{},"对 CSS\u002FJS 开发的影响",[243,38099,38100,38103,38106,38109],{},[126,38101,38102],{},"需要在构建流程中加 polyfill 和 transpile 步骤",[126,38104,38105],{},"某些新 CSS 特性需要 fallback（比如 CSS Anchor Positioning 在 v125+ 才支持，需要降级方案）",[126,38107,38108],{},"测试矩阵需要覆盖多个 WebView2 版本",[126,38110,38111],{},"用户首次启动时可能遇到环境问题，需要友好的错误提示",[11,38113,38114],{},[488,38115,38116],{},"示例：最小兼容性处理",[399,38118,38120],{"className":21005,"code":38119,"language":21007,"meta":183,"style":183},"\u002F\u002F services\u002Fcompatibility.ts\nexport const features = {\n  cssGap: () => {\n    \u002F\u002F 检测浏览器是否支持 CSS Gap\n    const el = document.createElement('div');\n    el.style.gap = '1px';\n    return el.style.gap !== '';\n  },\n  \n  promiseAllSettled: () => {\n    return typeof Promise.allSettled === 'function';\n  }\n};\n\n\u002F\u002F 在应用启动时检查\nif (!features.cssGap()) {\n  \u002F\u002F 加载 polyfill 或使用 margin-based layout\n  document.body.classList.add('no-css-gap');\n}\n",[15,38121,38122,38127,38140,38152,38157,38180,38192,38206,38210,38214,38225,38244,38248,38252,38256,38261,38278,38283,38297],{"__ignoreMap":183},[407,38123,38124],{"class":409,"line":410},[407,38125,38126],{"class":528},"\u002F\u002F services\u002Fcompatibility.ts\n",[407,38128,38129,38131,38133,38136,38138],{"class":409,"line":184},[407,38130,1045],{"class":417},[407,38132,3166],{"class":417},[407,38134,38135],{"class":476}," features",[407,38137,706],{"class":417},[407,38139,523],{"class":413},[407,38141,38142,38145,38148,38150],{"class":409,"line":189},[407,38143,38144],{"class":554},"  cssGap",[407,38146,38147],{"class":413},": () ",[407,38149,520],{"class":417},[407,38151,523],{"class":413},[407,38153,38154],{"class":409,"line":452},[407,38155,38156],{"class":528},"    \u002F\u002F 检测浏览器是否支持 CSS Gap\n",[407,38158,38159,38162,38165,38167,38170,38173,38175,38178],{"class":409,"line":458},[407,38160,38161],{"class":417},"    const",[407,38163,38164],{"class":476}," el",[407,38166,706],{"class":417},[407,38168,38169],{"class":413}," document.",[407,38171,38172],{"class":554},"createElement",[407,38174,586],{"class":413},[407,38176,38177],{"class":429},"'div'",[407,38179,662],{"class":413},[407,38181,38182,38185,38187,38190],{"class":409,"line":464},[407,38183,38184],{"class":413},"    el.style.gap ",[407,38186,418],{"class":417},[407,38188,38189],{"class":429}," '1px'",[407,38191,918],{"class":413},[407,38193,38194,38196,38199,38201,38204],{"class":409,"line":470},[407,38195,3807],{"class":417},[407,38197,38198],{"class":413}," el.style.gap ",[407,38200,5159],{"class":417},[407,38202,38203],{"class":429}," ''",[407,38205,918],{"class":413},[407,38207,38208],{"class":409,"line":480},[407,38209,29443],{"class":413},[407,38211,38212],{"class":409,"line":1477},[407,38213,36889],{"class":413},[407,38215,38216,38219,38221,38223],{"class":409,"line":1483},[407,38217,38218],{"class":554},"  promiseAllSettled",[407,38220,38147],{"class":413},[407,38222,520],{"class":417},[407,38224,523],{"class":413},[407,38226,38227,38229,38232,38234,38237,38239,38242],{"class":409,"line":2139},[407,38228,3807],{"class":417},[407,38230,38231],{"class":417}," typeof",[407,38233,7377],{"class":476},[407,38235,38236],{"class":413},".allSettled ",[407,38238,881],{"class":417},[407,38240,38241],{"class":429}," 'function'",[407,38243,918],{"class":413},[407,38245,38246],{"class":409,"line":2180},[407,38247,563],{"class":413},[407,38249,38250],{"class":409,"line":7546},[407,38251,8870],{"class":413},[407,38253,38254],{"class":409,"line":7564},[407,38255,1827],{"emptyLinePlaceholder":202},[407,38257,38258],{"class":409,"line":17267},[407,38259,38260],{"class":528},"\u002F\u002F 在应用启动时检查\n",[407,38262,38263,38265,38267,38269,38272,38275],{"class":409,"line":17279},[407,38264,875],{"class":417},[407,38266,3724],{"class":413},[407,38268,3727],{"class":417},[407,38270,38271],{"class":413},"features.",[407,38273,38274],{"class":554},"cssGap",[407,38276,38277],{"class":413},"()) {\n",[407,38279,38280],{"class":409,"line":18010},[407,38281,38282],{"class":528},"  \u002F\u002F 加载 polyfill 或使用 margin-based layout\n",[407,38284,38285,38288,38290,38292,38295],{"class":409,"line":18057},[407,38286,38287],{"class":413},"  document.body.classList.",[407,38289,7335],{"class":554},[407,38291,586],{"class":413},[407,38293,38294],{"class":429},"'no-css-gap'",[407,38296,662],{"class":413},[407,38298,38299],{"class":409,"line":18062},[407,38300,483],{"class":413},[11,38302,38303],{},"这个项目中的策略是：",[123,38305,38306,38309,38312,38315],{},[126,38307,38308],{},"用 Vite + TypeScript 做编译时检查",[126,38310,38311],{},"用 Tailwind CSS 的兼容性模式",[126,38313,38314],{},"用 @vitejs\u002Fplugin-legacy 处理 ES 新特性",[126,38316,38317],{},"在运行时检查某些关键特性，不支持时给出明确提示",[31,38319,38321],{"id":38320},"代价三社区方案远少于-electron依赖库第三方集成","代价三：社区方案远少于 Electron（依赖库、第三方集成）",[11,38323,38324],{},"Electron 生态中有大量现成的工具和库。需要用户认证？有 electron-oauth2。需要自动更新？有 electron-updater。需要加密存储敏感信息？有 keytar。",[11,38326,38327],{},"Tauri 也有 plugin 系统和逐渐增长的生态，但数量和成熟度都差一截。官方维护的 plugin 包括：",[1708,38329,38330,38343],{},[1711,38331,38332],{},[1714,38333,38334,38337,38340],{},[1717,38335,38336],{},"Plugin",[1717,38338,38339],{},"功能",[1717,38341,38342],{},"成熟度",[1730,38344,38345,38355,38365,38375,38385],{},[1714,38346,38347,38350,38353],{},[1735,38348,38349],{},"tauri-plugin-updater",[1735,38351,38352],{},"应用自动更新",[1735,38354,21753],{},[1714,38356,38357,38360,38363],{},[1735,38358,38359],{},"tauri-plugin-global-shortcut",[1735,38361,38362],{},"全局快捷键",[1735,38364,21765],{},[1714,38366,38367,38370,38373],{},[1735,38368,38369],{},"tauri-plugin-shell",[1735,38371,38372],{},"执行系统命令",[1735,38374,21753],{},[1714,38376,38377,38380,38383],{},[1735,38378,38379],{},"tauri-plugin-clipboard",[1735,38381,38382],{},"剪贴板访问",[1735,38384,21765],{},[1714,38386,38387,38390,38393],{},[1735,38388,38389],{},"tauri-plugin-http",[1735,38391,38392],{},"HTTP 请求",[1735,38394,21753],{},[11,38396,38397],{},"与 Electron 的生态相比，覆盖面积大约是 40-50%。某些需求可能需要自己实现。",[11,38399,38400,1244],{},[488,38401,38402],{},"这个项目中的例子",[11,38404,38405],{},[488,38406,38407],{},"自更新（tauri-plugin-updater）",[11,38409,38410],{},"tauri-plugin-updater 支持检查更新、下载、验证签名、安装，整个流程都有。但文档相对基础，没有像 Electron Updater 那样完整的例子。第一次使用时需要研究源码才能理解：",[123,38412,38413,38416,38419,38422],{},[126,38414,38415],{},"manifest 文件的确切格式",[126,38417,38418],{},"签名验证的密钥配置",[126,38420,38421],{},"不同 platform 的版本管理",[126,38423,38424],{},"回滚和降级的策略",[11,38426,38427],{},"Electron Updater 在这些方面文档更详尽。",[11,38429,38430],{},[488,38431,38432],{},"关键词搜索的缺失",[11,38434,38435],{},"想做个全局快捷键（比如 Ctrl+Shift+A 快速打开配置中心）？Electron 有 electron-shortcut。Tauri 有 tauri-plugin-global-shortcut，功能也不差。但如果想要更复杂的行为（快捷键冲突检测、快捷键绑定记录、动态解绑等），生态里没有现成的轮子。需要自己在插件基础上二次开发。",[11,38437,38438],{},[488,38439,38440],{},"后台进程管理",[11,38442,38443],{},"想让配置中心在后台运行时保持某些定时任务（比如每分钟检查一次 Gateway 健康状态）？Electron 可以用 ipcMain 和 Node.js 的 setInterval。Tauri 中这些能力也有（tokio task、async\u002Fawait），但需要对 Rust async 编程有一定理解。如果前端开发者不熟悉 Rust，这会成为障碍。",[11,38445,38446,1244],{},[488,38447,38448],{},"影响评估",[11,38450,38451],{},"这个项目的范围相对有限（5 个主页面 + 首启引导 + 托盘菜单），所以社区方案少带来的影响有限。",[11,38453,38454],{},"若规模变大：",[123,38456,38457,38460,38463],{},[126,38458,38459],{},"50+ 页面的应用：社区工具的缺失会成为显著的痛点",[126,38461,38462],{},"需要复杂的权限系统、日志收集、错误追踪的应用：生态支持不足会推高开发成本",[126,38464,38465],{},"需要跨多个平台的应用（Windows\u002FMac\u002FLinux）：Tauri 的优势最大（WebView 差异多）",[26,38467,38468],{"id":38468},"数字汇总与成本效益",[1708,38470,38471,38485],{},[1711,38472,38473],{},[1714,38474,38475,38477,38480,38482],{},[1717,38476,24976],{},[1717,38478,38479],{},"Electron 方案",[1717,38481,37808],{},[1717,38483,38484],{},"收益",[1730,38486,38487,38504,38522,38540,38557,38575,38593,38611],{},[1714,38488,38489,38494,38497,38499],{},[1735,38490,38491],{},[488,38492,38493],{},"应用体积",[1735,38495,38496],{},"60-80 MB",[1735,38498,35827],{},[1735,38500,38501],{},[488,38502,38503],{},"-75% 体积",[1714,38505,38506,38511,38514,38517],{},[1735,38507,38508],{},[488,38509,38510],{},"首次启动时间",[1735,38512,38513],{},"~2-3 秒",[1735,38515,38516],{},"≤ 1 秒",[1735,38518,38519],{},[488,38520,38521],{},"-67% 启动",[1714,38523,38524,38529,38532,38535],{},[1735,38525,38526],{},[488,38527,38528],{},"运行时内存",[1735,38530,38531],{},"300-500 MB（单实例）",[1735,38533,38534],{},"30-50 MB",[1735,38536,38537],{},[488,38538,38539],{},"-90% 内存",[1714,38541,38542,38547,38549,38552],{},[1735,38543,38544],{},[488,38545,38546],{},"版本更新包（前端改动）",[1735,38548,35926],{},[1735,38550,38551],{},"50-300 KB",[1735,38553,38554],{},[488,38555,38556],{},"-99% 增量",[1714,38558,38559,38564,38567,38570],{},[1735,38560,38561],{},[488,38562,38563],{},"代码复用率（与 Launcher）",[1735,38565,38566],{},"~15%（需要 native module）",[1735,38568,38569],{},"~60%（直接导入）",[1735,38571,38572],{},[488,38573,38574],{},"+45% 复用",[1714,38576,38577,38582,38585,38588],{},[1735,38578,38579],{},[488,38580,38581],{},"前端编译时间",[1735,38583,38584],{},"~60s",[1735,38586,38587],{},"~10s",[1735,38589,38590],{},[488,38591,38592],{},"-83% 编译",[1714,38594,38595,38600,38603,38606],{},[1735,38596,38597],{},[488,38598,38599],{},"开发依赖数",[1735,38601,38602],{},"400+ npm packages",[1735,38604,38605],{},"180+ npm + 50 cargo",[1735,38607,38608],{},[488,38609,38610],{},"-40% 总依赖",[1714,38612,38613,38618,38621,38624],{},[1735,38614,38615],{},[488,38616,38617],{},"系统 API 调用",[1735,38619,38620],{},"需要原生模块 + npm 包",[1735,38622,38623],{},"直接 windows-rs",[1735,38625,38626],{},[488,38627,38628],{},"0 中间层",[11,38630,38631,38634],{},[488,38632,38633],{},"成本效益计算","（针对 1000 活跃用户的产品）：",[1708,38636,38637,38651],{},[1711,38638,38639],{},[1714,38640,38641,38643,38646,38649],{},[1717,38642,35291],{},[1717,38644,38645],{},"Electron 年成本",[1717,38647,38648],{},"Tauri 年成本",[1717,38650,36280],{},[1730,38652,38653,38669,38685,38701,38717],{},[1714,38654,38655,38658,38661,38664],{},[1735,38656,38657],{},"网络流量（CDN）",[1735,38659,38660],{},"$7,200",[1735,38662,38663],{},"$2,400",[1735,38665,38666],{},[488,38667,38668],{},"$4,800",[1714,38670,38671,38674,38677,38680],{},[1735,38672,38673],{},"服务器存储（版本历史）",[1735,38675,38676],{},"$1,200",[1735,38678,38679],{},"$400",[1735,38681,38682],{},[488,38683,38684],{},"$800",[1714,38686,38687,38690,38693,38696],{},[1735,38688,38689],{},"开发维护（系统集成 Bug）",[1735,38691,38692],{},"$6,000（3 人月）",[1735,38694,38695],{},"$2,000（1 人月）",[1735,38697,38698],{},[488,38699,38700],{},"$4,000",[1714,38702,38703,38706,38709,38712],{},[1735,38704,38705],{},"编译 + 构建时间成本",[1735,38707,38708],{},"$3,000（150 小时）",[1735,38710,38711],{},"$500（25 小时）",[1735,38713,38714],{},[488,38715,38716],{},"$2,500",[1714,38718,38719,38722,38727,38732],{},[1735,38720,38721],{},"总成本",[1735,38723,38724],{},[488,38725,38726],{},"$17,400",[1735,38728,38729],{},[488,38730,38731],{},"$5,300",[1735,38733,38734],{},[488,38735,38736],{},"$12,100（69% 节省）",[11,38738,38739],{},"假设开发人员时薪 $100\u002F小时，这个节省对小团队来说很显著。",[26,38741,38743],{"id":38742},"决策总结约束驱动的选择","决策总结：约束驱动的选择",[11,38745,38746],{},"这不是\"Tauri 比 Electron 更好\"的宣言，而是\"在这个具体约束集合下，Tauri 的权衡更优\"的决策记录。",[11,38748,38749,1244],{},[488,38750,38751],{},"约束集合",[243,38753,38754,38757,38760,38763],{},[126,38755,38756],{},"便携产品，U 盘交付，体积敏感（约束 A）",[126,38758,38759],{},"已有 Rust 后端（Launcher），代码复用机会大（约束 B）",[126,38761,38762],{},"Windows 系统集成需求明确（约束 C）",[126,38764,38765],{},"团队熟悉 Rust，学习新框架成本可控（约束 D）",[11,38767,38768,1244],{},[488,38769,38770],{},"若约束变化，决策也会变化",[123,38772,38773,38779,38785,38791],{},[126,38774,38775,38778],{},[488,38776,38777],{},"约束 A 不再存在","（比如改为云应用，不再需要便携）：Electron 的价值中立化（不需要考虑体积）。此时代价一（前端生态）会变成主导因素，Electron 重新成为首选。",[126,38780,38781,38784],{},[488,38782,38783],{},"约束 B 变弱","（比如后端改用 Node.js）：代码复用的优势消失。此时需要重新评估 Electron 的前端生态优势 vs Tauri 的内存\u002F性能优势。",[126,38786,38787,38790],{},[488,38788,38789],{},"约束 C 减弱","（比如 Linux 支持变重要）：Windows API 的优势边际化。Tauri 的跨平台 WebView 差异会变成劣势（需要在三个平台上调试兼容性）。Electron 自带 Chromium 的\"一致性\"优势会相对凸显。",[126,38792,38793,38796],{},[488,38794,38795],{},"约束 D 变化","（比如加入前端工程师，Rust 不熟悉）：自建组件库的成本会上升，Electron 的成熟生态会更有吸引力。",[26,38798,38799],{"id":38799},"后续债务与演进规划",[11,38801,38802,1244],{},[488,38803,38804],{},"Plan 03（当前）实装",[123,38806,38807,38810,38813],{},[126,38808,38809],{},"Tauri 2 MVP（5 主页 + 首启引导）",[126,38811,38812],{},"基础系统集成（托盘、WebView2 检测、单实例）",[126,38814,38815],{},"自建 16 组件库",[11,38817,38818,1244],{},[488,38819,38820],{},"Plan 04（后续债务）",[123,38822,38823,38826],{},[126,38824,38825],{},"应用自更新真实集成（tauri-plugin-updater + KMS 签名，debt-03-F）",[126,38827,38828],{},"Gateway 状态事件化（Launcher NamedPipe 推送 vs 轮询，debt-03-E）",[11,38830,38831,1244],{},[488,38832,38833],{},"Plan 08（更远期）",[123,38835,38836,38839],{},[126,38837,38838],{},"Gateway 重启能力（GatewayController 状态机，debt-08-G）",[126,38840,38841],{},"更多 Windows 系统集成（文件关联、快捷方式等）",[1267,38843,38844],{},"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 .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 .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":183,"searchDepth":184,"depth":184,"links":38846},[38847,38848,38853,38859,38864,38869,38874,38875,38876],{"id":35749,"depth":184,"text":35749},{"id":35758,"depth":184,"text":35759,"children":38849},[38850,38851,38852],{"id":35762,"depth":189,"text":35763},{"id":35795,"depth":189,"text":35796},{"id":35979,"depth":189,"text":35980},{"id":36001,"depth":184,"text":36002,"children":38854},[38855,38856,38857,38858],{"id":36005,"depth":189,"text":36006},{"id":36047,"depth":189,"text":36047},{"id":36150,"depth":189,"text":36151},{"id":36244,"depth":189,"text":36245},{"id":36512,"depth":184,"text":36513,"children":38860},[38861,38862,38863],{"id":36516,"depth":189,"text":36516},{"id":36632,"depth":189,"text":36632},{"id":37016,"depth":189,"text":37016},{"id":37151,"depth":184,"text":37152,"children":38865},[38866,38867,38868],{"id":37155,"depth":189,"text":37156},{"id":37733,"depth":189,"text":37734},{"id":37800,"depth":189,"text":37800},{"id":37871,"depth":184,"text":37871,"children":38870},[38871,38872,38873],{"id":37877,"depth":189,"text":37878},{"id":37967,"depth":189,"text":37968},{"id":38320,"depth":189,"text":38321},{"id":38468,"depth":184,"text":38468},{"id":38742,"depth":184,"text":38743},{"id":38799,"depth":184,"text":38799},"2026-04-21",{},"\u002F2026-04-21-tauri2electron",{"title":35741,"description":35746},"2026-04-21-Tauri2而不是Electron四个理由","便携 AI 助手的配置中心为什么选 Tauri 2 而不是默认答案 Electron，涉及包体积、工程复用、更新成本、系统集成四个决策点与代价分析。",[19953,15068,38884,20302,12681,35469],"桌面应用","G5mnvD-EG-eLRR2FJTVU-hlbFbJuzRNxZK8sF78qxfM",{"id":38887,"title":38888,"body":38889,"column":1966,"date":38987,"description":38893,"extension":199,"hero_image":200,"meta":38988,"navigation":202,"path":38989,"seo":38990,"series_id":200,"severity":200,"stem":38991,"summary":38992,"tags":38993,"__hash__":38996},"posts\u002F2026-04-19-开栏从一个USB启动器开始.md","一切，从一个 U 盘启动器开始",{"type":8,"value":38890,"toc":38981},[38891,38894,38897,38900,38903,38907,38910,38913,38916,38919,38923,38926,38929,38932,38935,38938,38941,38945,38948,38951,38954,38957,38960,38963,38966,38969,38972,38975,38978],[11,38892,38893],{},"客户的机器不允许装软件。这个简单的限制决定了整个产品的前两年。",[11,38895,38896],{},"背景是这样的：我需要把 openclaw（一个开源 AI 助手框架）打成一个可以在 Windows 上开箱即用的产品。客户是完全不懂技术的计算机新手，他们不知道什么是\"端口\"、\"环境变量\"、\"管理员权限\"，不看得懂英文错误码，害怕 SmartScreen 和 Defender 的弹窗，会在插着的状态下直接拔 U 盘。更关键的是，他们的机器常年被企业 IT 部门的安全策略锁定——既无法装 WSL2，也无法获得管理员权限运行任何安装程序。",[11,38898,38899],{},"这个约束排除了所有常见的分发方式。传统的 Windows 应用安装程序需要管理员权限或系统权限。虚拟机方案需要巨大的镜像和首次解压时间。云端方案对网络有依赖，在公司代理、DNS 污染的环境下会宕掉。唯一可行的路径是：把整个运行环境打进一个 USB 设备里，用户只需要插入、等待、使用。",[11,38901,38902],{},"选 USB 便携方案本身还不够，它带来了三个硬约束，每一个都对应方案里的一条技术线。",[26,38904,38906],{"id":38905},"第一个约束运行时必须自包含","第一个约束：运行时必须自包含",[11,38908,38909],{},"USB 设备不能依赖宿主系统已经装了什么。一个 Windows 用户可能根本没有 Node.js、Python 或任何开发工具。Defender 可能会误删或隔离系统 DLL。PowerShell 版本可能太老。这意味着 openclaw 需要的所有东西都要打进去——Node 24、Python 3.11、ffmpeg、7zip、imagemagick、curl。",[11,38911,38912],{},"自包含运行时的代价是体积。便携 Node 约 70 MB，Python 约 120 MB，工具链 200 MB，openclaw 本体 1.5 GB，Python 依赖 1.8 GB，15-20 个内置 skill 各自的依赖又是一层。最后 U 盘占用达到约 3.8 GB。32 GB 的 U 盘只剩 28 GB 给用户数据——对消费级应用这不算离谱，但对工具链意味着没有多余空间放实验性模块。",[11,38914,38915],{},"被放弃的选项是\"精简运行时\"——比如只装 Node，让 Python 用户自己装。这个想法在企业环境里立刻失效。被放弃的另一个是\"延迟加载\"——首启时解压热缓存，冷依赖等用户第一次调用时再下载。这要求联网，违反了\"完全离线可用\"的设计目标。",[11,38917,38918],{},"自包含运行时的好处是，USB 从 A 机器拔出、插到 B 机器，配置完全一致。用户关机不退出、下次插进去期望一切还在——这个看似理所当然的用户心智其实很难满足，除非整个依赖树都在本地盘符上。",[26,38920,38922],{"id":38921},"第二个约束状态必须能在-u-盘与本地缓存之间同步","第二个约束：状态必须能在 U 盘与本地缓存之间同步",[11,38924,38925],{},"首启性能是第二个杀手约束。主流用户期望\"从插入 U 盘到能用\"不超过 10 秒。但 3.8 GB 的解压按 USB 2.0 速度需要几十秒。",[11,38927,38928],{},"解决方案是热\u002F冷缓存分离。180 MB 的热缓存包含 Gateway 核心、基础 skill、一个默认的 AI provider——这部分在启动器的 5 秒内就解压完了，托盘就能亮起来，用户可以开始聊天。剩下的 1.3 GB 冷缓存（所有 IM 渠道插件、所有备选 provider、60+ 个 bundled skill）在后台悄悄解压，进度条显示在托盘气泡里，用户感受不到阻塞。",[11,38930,38931],{},"这个设计带来了状态一致性的问题。进程在解压中途可能崩溃、电源可能掉、用户可能直接拔 U 盘。启动器必须能识别\"哪些文件已经写好、哪些只写了一半\"。采用的方案是原子 rename——解压到临时目录，全部成功后一次性 rename 到目标位置，这样就天然避免了半写状态。",[11,38933,38934],{},"但这还不够。U 盘本身可能是 FAT32（有 4 GB 单文件限制），可能只有 USB 2.0 接口，可能被系统弄脏（比如在双系统里被 Linux 写过）。本地缓存目录可能不可写（企业 Defender 的 Controlled Folder Access）。启动器需要一个兜底方案——如果本地 %LOCALAPPDATA% 无法写入，就把缓存降级回 U 盘的 cache-fallback\u002F 目录，虽然性能差点但总能跑起来。",[11,38936,38937],{},"被放弃的方案是\"只在 U 盘上缓存\"——这样省了跨盘符的状态管理，但牺牲了启动速度（USB 2.0 全程卡）。被放弃的另一个是\"每次启动都完整重解压\"——这样没有任何状态一致性问题，但两分钟的首启时间不可接受。",[11,38939,38940],{},"状态同步的好处是，插到企业机器上，Defender 可能在后台扫描，杀软可能隔离了某个 .node 文件，重启一次启动器就能自动从备份重解压修复。缓存机制同时是防护层。",[26,38942,38944],{"id":38943},"第三个约束更新不能依赖安装程序","第三个约束：更新不能依赖安装程序",[11,38946,38947],{},"传统 Windows 应用的升级走安装程序，需要管理员权限。便携方案无法这样做。",[11,38949,38950],{},"变成了 OTA（Over-The-Air）——启动器后台每 4 小时检查一次更新，有新版本就下载到 updates\u002Fstaging\u002F 目录。下载可能中断（网络抖动）、磁盘空间可能满、校验可能失败。下一次启动时，启动器用双槽 A\u002FB 的策略：校验完整性后原子切换，current\u002F 变成 rollback\u002F，staging\u002F 变成 current\u002F。如果新版本连续崩溃 3 次，自动回滚到 rollback\u002F 的旧版。",[11,38952,38953],{},"被放弃的是\"差分更新\"——用 bsdiff 只传输变化部分。这能省流量，但增加了失败模式的复杂性：差分包损坏、老版本号错误、合并失败都无法自愈。Phase 1 就用整包替换，虽然 150-300 MB 的新版本下载可能要几分钟，但完全幂等、完全可回滚。",[11,38955,38956],{},"被放弃的另一个是\"依赖云端激活服务\"——每次更新都校验签名、校验设备有没有授权。这样 Phase 2 才行，当前还没有激活系统。Phase 1 的 OTA 使用本地签名校验，master public key 内置在启动器，本地一样能验——即使完全离线也能回滚。",[11,38958,38959],{},"OTA 机制的约束是，一旦发布错误版本，马上就会在数千台设备扩散。所以后台需要发布管理系统：单管理员账号、版本列表、三通道（canary \u002F beta \u002F stable）切换、一键回滚。",[26,38961,38962],{"id":38962},"架构沿着这三条线演进",[11,38964,38965],{},"这三个约束会在接下来的两年里演化成整个架构的基石。",[11,38967,38968],{},"自包含运行时的约束延伸出来是 Launcher + Tauri 桌面软件的两层壳。Launcher 是纯 Rust 静态编译的 8 MB 小程序，处理 U 盘识别、缓存管理、五层状态机、68 项兜底自愈。Tauri 桌面软件是配置中心，用户改 AI provider key、配置 IM 渠道、管理 skill 都在这里。openclaw 原生 WebUI 保持零改动，只负责聊天。三层进程分离，各自独立，启动器可以在不启动 Tauri 的情况下让 Gateway 跑起来。",[11,38970,38971],{},"状态同步的约束逐步演化成缓存一致性机制。V8 启动快照能压缩 Node 初始化时间，从 2-4 秒压到 0.3-0.8 秒。并行 zstd 多线程解压能把冷缓存解压从 10 秒压到 3-4 秒。但更根本的是，缓存分层之后，系统具有\"部分就绪\"的能力——即使某个可选模块损坏，基础聊天功能照样可用。同样的理念也用在了设计中 skill 的三层存放上（bundled \u002F 预装 \u002F workspace）。",[11,38973,38974],{},"OTA 机制到 Phase 2 时扩展成激活系统。用户输入激活码，后台签发 license（30 天刷新一次），每台机器的 license 通过 AES-256-GCM 用 VolumeSerialNumber 派生密钥加密，丢失也无法读。这样 license 可以明文放在 U 盘 data\u002F 目录，不用担心被盗。激活系统同时引入了 policy.yml 的远程策略下发，设计上预留了强制切自建 AI 网关的能力。",[11,38976,38977],{},"如果单机便携方案验证成功、用户数量持续增长，自然会遇到容量瓶颈。到那时单机迟早要让位于某种集中部署方案——当时并没有具体方案。USB 启动器定下的三个约束——自包含、状态可靠、OTA 更新——从一开始就会成为产品基因，很难改掉。",[11,38979,38980],{},"所有这些约束换来的设计目标是什么。首启 5 秒托盘亮、10 秒全功能就绪、完全离线可用、热拔无损、自动自愈。用户只需要会\"插 U 盘、等待、用\"。从这个意义上，设计不是为了展示技术，而是为了消除用户需要知道的东西。",{"title":183,"searchDepth":184,"depth":184,"links":38982},[38983,38984,38985,38986],{"id":38905,"depth":184,"text":38906},{"id":38921,"depth":184,"text":38922},{"id":38943,"depth":184,"text":38944},{"id":38962,"depth":184,"text":38962},"2026-04-19",{},"\u002F2026-04-19-usb",{"title":38888,"description":38893},"2026-04-19-开栏从一个USB启动器开始","为什么从 USB 便携方案起步，带来的三个硬约束如何演化成后续架构的基石。",[38994,12681,32212,28283,38995],"USB","性能优化","pK1Z63zH7yB54Li8nurLbO3l4ByS7-IJ8_umbW3UcmM",{"id":38998,"title":38999,"body":39000,"column":196,"date":39402,"description":39004,"extension":199,"hero_image":200,"meta":39403,"navigation":202,"path":39404,"seo":39405,"series_id":200,"severity":200,"stem":39406,"summary":39407,"tags":39408,"__hash__":39412},"posts\u002F2026-04-17-30个自定义agent什么时候值得单独造.md","造到第 30 个 agent，我开始怀疑该不该造",{"type":8,"value":39001,"toc":39392},[39002,39005,39011,39014,39018,39025,39031,39034,39060,39063,39077,39080,39084,39090,39097,39116,39123,39126,39130,39133,39138,39158,39163,39188,39194,39220,39226,39264,39271,39274,39277,39280,39314,39317,39320,39331,39334,39337,39340,39366,39369,39372,39386,39389],[11,39003,39004],{},"我的 Claude agent 定义库里现在有 30 份。reviewer 配对给了每种编程语言；流程 agent 分了规划、设计、测试、审查五个阶段；还有 5 个领域特化（数据库、医疗、性能等）。问题是：多少才够？什么时候不该造新的？",[11,39006,39007,39008,781],{},"答案不是\"能不能造\"，而是",[488,39009,39010],{},"约束密度",[26,39012,39013],{"id":39013},"三类成立条件",[31,39015,39017],{"id":39016},"_1-语言成对的-reviewer-和-build-resolver最明显成立","1. 语言成对的 reviewer 和 build-resolver——最明显成立",[11,39019,39020,39021,39024],{},"TypeScript、Rust、Go、Java、Python、C++、Kotlin……每种语言都配一对。typescript-reviewer 关注类型安全、异步正确、XSS 防护；rust-build-resolver 关注借用检查器、生命周期、",[15,39022,39023],{},"cargo"," 依赖。",[11,39026,39027,39028,781],{},"为什么分离？因为 ",[488,39029,39030],{},"error pattern 真的不一样",[11,39032,39033],{},"typescript-reviewer 的 HIGH 检查点包括：",[123,39035,39036,39043,39048,39057],{},[126,39037,39038,39039,39042],{},"非空断言滥用（",[15,39040,39041],{},"value!"," 后没有前置守卫）",[126,39044,39045,39047],{},[15,39046,903],{}," 类型转换绕过检查",[126,39049,39050,39053,39054,39056],{},[15,39051,39052],{},"forEach"," 里用 ",[15,39055,7981],{},"（不会等待）",[126,39058,39059],{},"宽松的 TypeScript 编译器设置",[11,39061,39062],{},"这些对 Rust 没有意义。Rust 则需要：",[123,39064,39065,39068,39071,39074],{},[126,39066,39067],{},"借用冲突（immutable 活跃时不能 mutable）",[126,39069,39070],{},"生命周期边界太短",[126,39072,39073],{},"从引用后移出所有权",[126,39075,39076],{},"trait 实现不匹配",[11,39078,39079],{},"混在一个通用 reviewer 里会产生两个后果：要么给出泛泛而谈的\"检查类型\"之类的意见，要么为了照顾所有语言而膨胀到无用。所以这类成立。",[31,39081,39083],{"id":39082},"_2-流程-agent思考方式必须隔离","2. 流程 agent——思考方式必须隔离",[11,39085,39086,39087,781],{},"planner、architect、tdd-guide、code-reviewer、security-reviewer 分别对应的是不同的",[488,39088,39089],{},"思维模式",[11,39091,39092,39093,39096],{},"planner 的工作是",[488,39094,39095],{},"规模化","。它把一个模糊的需求分解成有依赖关系的、分阶段的步骤。格式包括：",[123,39098,39099,39102,39105,39108,39111,39113],{},[126,39100,39101],{},"Requirements 列表",[126,39103,39104],{},"Architecture Changes 清单",[126,39106,39107],{},"多个 Phase，每个 Phase 有多个步骤",[126,39109,39110],{},"每步都标注 Action、Why、Dependencies、Risk",[126,39112,34758],{},[126,39114,39115],{},"Success Criteria",[11,39117,39118,39119,39122],{},"code-reviewer 的工作是",[488,39120,39121],{},"细节校验","。它逐行读代码，按优先级分类问题（CRITICAL \u002F HIGH \u002F MEDIUM \u002F LOW），给出代码片段和修复建议。",[11,39124,39125],{},"如果混在一个 agent 里，它们会互相干扰：规划时陷入代码细节，审查时又开始重新规划。分离后，planner 可以大局思考而不被小语法问题打断，reviewer 可以专注代码质量而不被\"这个步骤设计对吗\"的大问题分神。",[31,39127,39129],{"id":39128},"_3-领域-agent条件性成立必须约束足够密","3. 领域 agent——条件性成立，必须约束足够密",[11,39131,39132],{},"database-reviewer 是这类的好例子。它不只是\"你是 PostgreSQL 专家\"，而是定义了：",[11,39134,39135,1244],{},[488,39136,39137],{},"核心职责",[123,39139,39140,39143,39146,39149,39152,39155],{},[126,39141,39142],{},"Query Performance",[126,39144,39145],{},"Schema Design",[126,39147,39148],{},"Security & RLS",[126,39150,39151],{},"Connection Management",[126,39153,39154],{},"Concurrency",[126,39156,39157],{},"Monitoring",[11,39159,39160,1244],{},[488,39161,39162],{},"诊断命令",[399,39164,39166],{"className":11754,"code":39165,"language":11756,"meta":183,"style":183},"psql -c \"SELECT query, mean_exec_time, calls FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 10;\"\npsql -c \"SELECT relname, pg_size_pretty(...) FROM pg_stat_user_tables ORDER BY ...;\"\n",[15,39167,39168,39179],{"__ignoreMap":183},[407,39169,39170,39173,39176],{"class":409,"line":410},[407,39171,39172],{"class":554},"psql",[407,39174,39175],{"class":476}," -c",[407,39177,39178],{"class":429}," \"SELECT query, mean_exec_time, calls FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 10;\"\n",[407,39180,39181,39183,39185],{"class":409,"line":184},[407,39182,39172],{"class":554},[407,39184,39175],{"class":476},[407,39186,39187],{"class":429}," \"SELECT relname, pg_size_pretty(...) FROM pg_stat_user_tables ORDER BY ...;\"\n",[11,39189,39190,39193],{},[488,39191,39192],{},"关键原则","（不只是建议，是规则）：",[123,39195,39196,39199,39202,39205,39208,39211,39214,39217],{},[126,39197,39198],{},"索引外键——没有例外",[126,39200,39201],{},"部分索引用于软删除",[126,39203,39204],{},"覆盖索引避免表查",[126,39206,39207],{},"队列用 SKIP LOCKED",[126,39209,39210],{},"游标分页替代 OFFSET",[126,39212,39213],{},"批量插入，禁止循环内逐行插入",[126,39215,39216],{},"事务保持短",[126,39218,39219],{},"锁顺序一致",[11,39221,39222,39225],{},[488,39223,39224],{},"反模式检查清单","（10+ 条）：",[123,39227,39228,39234,39243,39249,39252,39255,39261],{},[126,39229,39230,39233],{},[15,39231,39232],{},"SELECT *"," 在生产代码",[126,39235,39236,39237,39239,39240,13615],{},"ID 用 ",[15,39238,987],{},"（应该 ",[15,39241,39242],{},"bigint",[126,39244,39245,39248],{},[15,39246,39247],{},"timestamp"," 无时区",[126,39250,39251],{},"随机 UUID 做主键",[126,39253,39254],{},"未参数化查询",[126,39256,39257,39260],{},[15,39258,39259],{},"GRANT ALL"," 给应用用户",[126,39262,39263],{},"RLS 策略每行调函数",[11,39265,39266,39267,39270],{},"这些",[488,39268,39269],{},"不是建议","，而是具体的、可验证的、在 PostgreSQL 世界里遵循的法则。database-reviewer 存在是因为它编码了这个领域的专有知识。",[11,39272,39273],{},"对比一个不该独立存在的例子：假设有个 \"api-design-reviewer\" agent，系统提示只有\"检查 API 设计\"加几条通用的\"RESTful 最佳实践\"。没有针对这个项目的路由规范、没有特定的错误响应格式检查、没有该业务领域的约束……那个 agent 其实不该独立——它只是拿 Claude 通用能力换了个称呼，增加了调度成本却没有添加任何专有约束。",[26,39275,39276],{"id":39276},"过度设计的信号",[11,39278,39279],{},"一个 agent 定义如果满足以下条件，很可能不该独立存在：",[243,39281,39282,39288,39294,39300],{},[126,39283,39284,39287],{},[488,39285,39286],{},"系统提示是\"你是 X 专家\"加 2-3 条泛泛要求","。真正有价值的 agent 定义会包含诊断步骤、命令、检查清单。",[126,39289,39290,39293],{},[488,39291,39292],{},"没有输出格式或流程","。强 agent 会规定\"输出必须包含这些节，用这个表格格式\"之类的约束。没有就说明它没有对问题空间的深入理解。",[126,39295,39296,39299],{},[488,39297,39298],{},"check list 是通用的，不是该领域特有的","。code-reviewer 的\"检查大函数\"对所有语言都有意义，但 typescript-reviewer 还会检查特定的 async 陷阱。如果一个 agent 的检查清单完全是通用的，它不该独立。",[126,39301,39302,39305,39306,39309,39310,39313],{},[488,39303,39304],{},"没有诊断命令","。database-reviewer 给出了 ",[15,39307,39308],{},"pg_stat_statements"," 查询、",[15,39311,39312],{},"EXPLAIN ANALYZE"," 步骤。如果 agent 定义里全是文字建议没有可执行的步骤，它没有足够的专有约束。",[26,39315,39316],{"id":39316},"调度的代价",[11,39318,39319],{},"多 agent 不是免费的。每调用一个 agent 意味着：",[123,39321,39322,39325,39328],{},[126,39323,39324],{},"上下文切换",[126,39326,39327],{},"一次新的 LLM 推理（可能是更贵的模型）",[126,39329,39330],{},"并行时的并发管理",[11,39332,39333],{},"所以不值得的 agent 必须合并。比如\"README-updater\"和\"changelog-updater\"如果约束差不多，就应该合并成单一的 doc-updater。实际上 doc-updater 确实存在，而且它的约束包括：输出结构（docs\u002FCODEMAPS\u002F 的特定文件组织）、代码地图格式、验证步骤。这足以支撑独立存在。",[26,39335,39336],{"id":39336},"实际计数",[11,39338,39339],{},"30 份 agent 的分布：",[123,39341,39342,39348,39354,39360],{},[126,39343,39344,39347],{},[488,39345,39346],{},"语言 reviewer\u002Fbuild-resolver 对","：8 对（TypeScript\u002FRust\u002FGo\u002FJava\u002FPython\u002FC++\u002FKotlin\u002FPyTorch），共 16 份",[126,39349,39350,39353],{},[488,39351,39352],{},"流程 agent","：planner、architect、tdd-guide、code-reviewer、security-reviewer，共 5 份",[126,39355,39356,39359],{},[488,39357,39358],{},"领域特化","：database-reviewer、healthcare-reviewer、performance-optimizer，共 3 份",[126,39361,39362,39365],{},[488,39363,39364],{},"功能专用","：doc-updater、e2e-runner、refactor-cleaner、chief-of-staff、loop-operator、harness-optimizer、docs-lookup，共 7 份",[11,39367,39368],{},"前三类的成立都有明确的约束理由。第四类是工具性的，每个都解决具体问题（文档更新、E2E 测试、死代码清理）。",[11,39370,39371],{},"反过来想，如果要造第 31 个 agent，需要问：",[123,39373,39374,39377,39380,39383],{},[126,39375,39376],{},"它处理的问题空间有专有模式吗？",[126,39378,39379],{},"能列出 5 条以上该领域特有的规则或检查点吗？",[126,39381,39382],{},"有具体的诊断或验证步骤吗？",[126,39384,39385],{},"还是只是\"我是 X 专家\"加通用建议？",[11,39387,39388],{},"只要有一条答\"不\"，那就应该把这个职责并入现有 agent，而不是新造。",[1267,39390,39391],{},"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 .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":183,"searchDepth":184,"depth":184,"links":39393},[39394,39399,39400,39401],{"id":39013,"depth":184,"text":39013,"children":39395},[39396,39397,39398],{"id":39016,"depth":189,"text":39017},{"id":39082,"depth":189,"text":39083},{"id":39128,"depth":189,"text":39129},{"id":39276,"depth":184,"text":39276},{"id":39316,"depth":184,"text":39316},{"id":39336,"depth":184,"text":39336},"2026-04-17",{},"\u002F2026-04-17-30agent",{"title":38999,"description":39004},"2026-04-17-30个自定义agent什么时候值得单独造","独立 agent 的价值来自它携带的专有约束量，不是职责名称听起来有多不同。",[39409,10930,39410,39411],"agent","系统设计","编排","KvP3OfLg88tMxT95J-2wcPa_uPo7FY7YDnwFt34AsuM",{"id":39414,"title":39415,"body":39416,"column":196,"date":39622,"description":39420,"extension":199,"hero_image":200,"meta":39623,"navigation":202,"path":39624,"seo":39625,"series_id":200,"severity":200,"stem":39626,"summary":39627,"tags":39628,"__hash__":39634},"posts\u002F2026-04-15-一周四个演示项目最小可信实现.md","一周四个演示项目，什么可以是假的？",{"type":8,"value":39417,"toc":39615},[39418,39421,39424,39427,39430,39433,39436,39439,39445,39448,39451,39454,39457,39460,39467,39472,39475,39478,39481,39488,39505,39508,39511,39515,39518,39521,39524,39529,39532,39546,39549,39560,39563,39577,39580,39612],[11,39419,39420],{},"同期接到四个演示项目：智慧园区停车系统（SmartPark）、校园班车调度（campus-bus）、通用 IoT 平台（iot-platform）、餐盘识别系统（clean-plate）。限期一周内\"看起来能用\"。这类项目最容易踩的坑是：在非关键细节上过度完备，结果关键路径没时间打磨。",[26,39422,39423],{"id":39423},"什么必须真",[11,39425,39426],{},"数据流通和关键交互是演示的生命线。用户点一下按钮，必须有实时反馈。用户看一张报表，数字必须算对。",[11,39428,39429],{},"SmartPark 的核心是可视化——地图展示、停车位实时状态、收费计算。这三个环节缺一不可。地图集成（高德 API）、数据绑定、前端计算逻辑都必须真实运转。相反，权限管理的细分（运营、财务、巡查员各自的权限边界）在演示阶段可以完全省掉，所有用户走同一条权限路径就够了。",[11,39431,39432],{},"campus-bus 的核心是班车轨迹和到达预测。班车坐标实时更新、用户能看到倒计时，这才能说\"班车调度系统\"。与其花时间做用户认证和学工处接口对接，不如用 mock 数据把轨迹动画做流畅。MSW 库（SmartPark 里用了）可以在开发阶段拦截 HTTP 请求，返回写好的假数据，前端感知不到后端是否存在。",[11,39434,39435],{},"iot-platform 设计文档里提到多租户、RBAC、分布式锁、Kafka 消费端幂等等，这些都是生产级别才需要的。演示阶段关键路径是：设备能连上 → 上报数据 → 看板展示。单租户模式启动（环境变量控制，文档里明确支持），所有权限检查用一个假 admin 令牌搞定，Kafka 改成单 broker 部署，Redis 用单机版。核心的时序数据写入（Micro-Batch 到 MongoDB Time Series Collection）是真的（因为这是展示数据的基础），但\"百万设备并发时的背压机制\"完全可以不实现。",[11,39437,39438],{},"clean-plate（餐盘识别）的瓶颈在 AI 模型推理。假设已经有了一个能用的识别模型，演示重点是前端能拍照 → 调推理服务 → 展示识别结果。模型的精度、冷启动优化、批推理这些可以后来再优。",[11,39440,39441,39442,781],{},"共同的一条线是：",[488,39443,39444],{},"数据可以是假的（用 mock 返回），但数据流通的路径必须是真的",[26,39446,39447],{"id":39447},"什么可以假",[11,39449,39450],{},"并发和容错是演示的盲点。开发环境单机跑，谁会同时登 50 个用户？",[11,39452,39453],{},"SmartPark 不需要考虑\"10 万辆车同时更新位置\"——演示用几十辆数据刷屏就很炫了。不需要分布式锁保证停车位售出不超卖，单机内存里用个计数器就够。不需要支付系统的幂等设计和对账，演示时支付直接假装成功。",[11,39455,39456],{},"campus-bus 不需要处理\"100 班班车同时调度冲突\"。没有并发冲突检测，也不需要队列和任务调度系统。轨迹上报和用户查询都走单个实例，简单粗暴。",[11,39458,39459],{},"iot-platform 设计文档里关于\"设备状态一致性\"的三层保障（心跳 TTL、断连事件、状态修复巡检）在演示版完全可以忽略。直接写 Redis，没有巡检任务，设备掉线了刷新页面重新查就行。Kafka 消费端幂等（messageId 防重）也不需要，反正数据量小。",[11,39461,39462,39463,39466],{},"异常分支更不用说——登录失败、网络超时、服务宕机这些错误处理在演示里往往是 ",[15,39464,39465],{},"console.error"," 一行了事，或者弹个提示框就过去了。没人会用 Sentry 做错误追踪，没人会写降级方案。",[11,39468,39469],{},[488,39470,39471],{},"演示项目的数据一致性、高可用、容错，这些生产级的需求全部可以零实现。",[26,39473,39474],{"id":39474},"复用的界限",[11,39476,39477],{},"四个项目用的技术栈不完全一样：SmartPark 和 campus-bus 都是 Vue 3，iot-platform 是 React，clean-plate 是什么具体没看。完全共用一套脚手架不现实。",[11,39479,39480],{},"但能复用的是设计令牌和组件库选型。SmartPark 用 Ant Design Vue 4，campus-bus 虽然更简洁但也能接入 Ant Design。iot-platform React 用的是 Ant Design 5。共用 Ant Design 意味着色板、字体、组件 API 是一致的，用户看起来像是\"一家人\"。",[11,39482,39483,39484,39487],{},"关键是",[488,39485,39486],{},"把差异收在业务页面","。共同的部分是：",[123,39489,39490,39493,39496,39499,39502],{},[126,39491,39492],{},"登录\u002F权限框架（虽然演示时全部跳过，但代码结构保留）",[126,39494,39495],{},"路由和菜单结构",[126,39497,39498],{},"数据 API 调用层（axios 或 fetch 封装）",[126,39500,39501],{},"表单、表格、弹框这些通用组件",[126,39503,39504],{},"色彩、间距、阴影这些设计系统",[11,39506,39507],{},"业务差异放在各自的模块里：地图组件（SmartPark 专属）、时序图表（iot-platform 专属）、图像识别 UI（clean-plate 专属）。",[11,39509,39510],{},"实际效果是：前端架构像一个\"半骨架\"，部分通用，部分预留接口。新项目接入时快速填充业务逻辑即可。",[26,39512,39514],{"id":39513},"反思时间分配的陷阱","反思：时间分配的陷阱",[11,39516,39517],{},"一周四个项目，最容易掉的坑：在非关键处投入时间。",[11,39519,39520],{},"比如某个项目花了两天做完整的权限管理系统——细粒度权限点、角色继承、数据权限范围等等。结果发现核心数据展示还没做完。演示时别人只看到\"权限菜单很齐全但数据看不到\"。这就本末倒置了。",[11,39522,39523],{},"又比如某个项目花时间做\"错误处理和重试逻辑\"，包括网络超时、服务异常、降级方案。实际演示时网络稳定，这些代码完全用不到，反而增加了调试难度。",[11,39525,39526],{},[488,39527,39528],{},"正确的姿势是：先把关键路径跑通（最短的从输入到输出的数据链），再看有没有时间加糖。",[11,39530,39531],{},"SmartPark 的时间分配应该是：",[243,39533,39534,39537,39540,39543],{},[126,39535,39536],{},"高德地图集成 + mock 停车位数据（2 天）",[126,39538,39539],{},"停车位状态展示和实时更新（1 天）",[126,39541,39542],{},"收费计算逻辑（0.5 天）",[126,39544,39545],{},"UI 打磨和动画效果（1.5 天）\n省掉权限、支付对接、后台管理这些，演示版根本用不到。",[11,39547,39548],{},"campus-bus：",[243,39550,39551,39554,39557],{},[126,39552,39553],{},"Mock 班车轨迹数据 + 地图展示（1.5 天）",[126,39555,39556],{},"轨迹动画和到达倒计时（1.5 天）",[126,39558,39559],{},"UI 美化和粒子效果（1 天）\n省掉用户认证、学工处接口、调度算法优化。",[11,39561,39562],{},"iot-platform：",[243,39564,39565,39568,39571,39574],{},[126,39566,39567],{},"设备 mock 数据和 EMQX 单机部署（1 天）",[126,39569,39570],{},"设备列表、状态展示、时序图表（2 天）",[126,39572,39573],{},"简单的指令下发模拟（0.5 天）",[126,39575,39576],{},"看板美化（0.5 天）\n省掉多租户隔离、复杂的 Kafka 消费逻辑、灰度发布、99.9% 可用性这些架构细节。",[26,39578,39579],{"id":39579},"关键实践",[243,39581,39582,39588,39594,39600,39606],{},[126,39583,39584,39587],{},[488,39585,39586],{},"明确最少可行演示","：在写代码前，列出\"演示时必须工作的 3-5 个功能\"。其他一切都是可选的。",[126,39589,39590,39593],{},[488,39591,39592],{},"默认 mock 所有后端接口","：除非后端已经完成且稳定，否则前端用 mock 数据。这样前后端可以完全解耦开发，演示时换成真实 API 即可。",[126,39595,39596,39599],{},[488,39597,39598],{},"用环境变量控制功能开关","：生产级的功能（权限校验、幂等检查、限流）用 feature flag 包裹，演示环境全部关闭。不是删代码，而是不执行。",[126,39601,39602,39605],{},[488,39603,39604],{},"复用而不共享","：建立一套通用的组件库和设计系统，但不强制每个项目都用。允许项目选择最适合的技术栈，只要最后看起来协调就行。",[126,39607,39608,39611],{},[488,39609,39610],{},"最后一天留给抛光","：前六天完成功能，最后一天专注 UI 细节、动画、错误提示文案。演示的第一印象很重要。",[11,39613,39614],{},"演示项目成功的标志不是架构有多完善、功能有多全面，而是用户第一眼看到\"这东西能用\"。所有投入都应该指向这一点。",{"title":183,"searchDepth":184,"depth":184,"links":39616},[39617,39618,39619,39620,39621],{"id":39423,"depth":184,"text":39423},{"id":39447,"depth":184,"text":39447},{"id":39474,"depth":184,"text":39474},{"id":39513,"depth":184,"text":39514},{"id":39579,"depth":184,"text":39579},"2026-04-15",{},"\u002F2026-04-15",{"title":39415,"description":39420},"2026-04-15-一周四个演示项目最小可信实现","演示项目的核心是取舍：什么路径必须打磨到真实，什么细节可以简化甚至假装。",[39629,39630,39631,39632,39633],"Vue","React","项目交付","快速演示","技术取舍","L9hcuUGczTuAZDIGq7VEKYJsTPmuTpYF4U8JBh4hKgM",{"id":39636,"title":39637,"body":39638,"column":196,"date":40385,"description":39642,"extension":199,"hero_image":200,"meta":40386,"navigation":202,"path":40387,"seo":40388,"series_id":200,"severity":200,"stem":40389,"summary":40390,"tags":40391,"__hash__":40397},"posts\u002F2026-04-12-三端联调的顺序问题.md","三端并行开发，谁先接谁？",{"type":8,"value":39639,"toc":40373},[39640,39643,39646,39649,39652,39667,39670,39695,39706,39709,39713,39719,39725,39730,39744,39749,39755,39758,39762,39767,39772,39777,39788,39792,39821,39826,39858,39869,39964,39967,39971,39976,39981,39985,39993,39997,40008,40012,40052,40063,40073,40093,40096,40100,40105,40110,40114,40129,40133,40144,40148,40187,40190,40204,40207,40210,40242,40247,40250,40343,40346,40349,40363,40370],[11,39641,39642],{},"三端（后台、小程序、设备）并行开发时，联调顺序直接决定返工量。这里讲的是 ToyNet 项目的实践：为什么不能先接设备再接小程序，以及各阶段的桩数据策略。",[26,39644,39645],{"id":39645},"为什么后台必须先行",[11,39647,39648],{},"后台 API 是唯一的事实源。小程序和设备都依赖后台数据，这种依赖关系不可逆转。",[11,39650,39651],{},"ToyNet 的架构很清楚：",[123,39653,39654,39657,39660],{},[126,39655,39656],{},"后台提供 REST API（用户、商品、订单、设备管理）和 MQTT 消息队列（设备指令下发）",[126,39658,39659],{},"小程序是 API 消费者（调用 REST 接口）",[126,39661,39662,39663,39666],{},"设备是 MQTT 消费者（订阅 ",[15,39664,39665],{},"toynet\u002Fdevice\u002F{deviceSn}\u002Fcommand"," 主题、执行命令、上报状态）",[11,39668,39669],{},"这意味着小程序开发的第一天就需要后台 API 存在。不是非得完全实现，但至少得有契约——定义好接口名、参数、返回值格式。设备也是一样，需要后台先把 MQTT 消息格式定下来。",[11,39671,39672,39675,39676,39679,39680,39683,39684,39687,39688,88,39691,39694],{},[488,39673,39674],{},"反序的代价","。如果先做设备再做小程序，会发生什么？小程序开发期间没有可读的数据。具体例子：小程序要显示\"设备列表\"和\"设备状态\"，调的是 ",[15,39677,39678],{},"\u002Fapi\u002Fv1\u002Fdevices\u002Flist","。如果后台这个接口还不存在，小程序工程师得猜测设备状态的格式——也许是 ",[15,39681,39682],{},"{ status: 'online' }","，也许是 ",[15,39685,39686],{},"{ online: true }","，也许还要加 ",[15,39689,39690],{},"battery",[15,39692,39693],{},"lastSeenAt","。用错了格式渲染页面，即使设备硬件工作正常，小程序也没法验证这个流程。几周后后台实现了，返回格式和小程序猜的不一样，小程序要改、测试、再验证设备——这一圈返工没有价值，本可以避免。",[11,39696,39697,39698,39701,39702,39705],{},"类似的问题还会出现在 MQTT 指令格式上。先做设备时，硬件工程师定义了命令格式，比如 ",[15,39699,39700],{},"{ action: 'play', audioId: 123 }","。后台做到这一步时，根据产品需求改成了 ",[15,39703,39704],{},"{ cmd: 'play_audio', params: { id: 123 } }","。小程序要跟着改，设备固件也要改。如果顺序对了，后台先定好格式，这些改动就不会出现。",[26,39707,39708],{"id":39708},"可行的分阶段顺序",[31,39710,39712],{"id":39711},"第1阶段后台自测","第1阶段：后台自测",[11,39714,39715,39718],{},[488,39716,39717],{},"目标","：后台 6 个核心模块全部能跑，包括鉴权、设备管理、商品、订单、AI、MQTT。",[11,39720,39721,39724],{},[488,39722,39723],{},"在 ToyNet 里的对应","：Plan 1–6（2026-04-09 到 2026-04-10）。",[11,39726,39727,1244],{},[488,39728,39729],{},"桩数据策略",[123,39731,39732,39735,39738,39741],{},[126,39733,39734],{},"用 Jest 单测 + 集成测试验证每个 service",[126,39736,39737],{},"MongoDB 用 mongodb-memory-server 在内存中启动，每次测试自动清理",[126,39739,39740],{},"不需要真实设备，MQTT broker 可以离线或用 mock 实现",[126,39742,39743],{},"test fixtures 里写好标准数据：用户对象、设备对象、订单对象，每个测试复用",[11,39745,39746,1244],{},[488,39747,39748],{},"具体做法",[399,39750,39753],{"className":39751,"code":39752,"language":1327},[1325],"server\u002Ftests\u002Funit\u002Fmodules\u002Fdevices\u002Fdevices.service.test.js\nserver\u002Ftests\u002Fintegration\u002Fdevices.test.js\nnpm test -- --coverage\n",[15,39754,39752],{"__ignoreMap":183},[11,39756,39757],{},"验收标准是覆盖率 ≥80%，所有 API 路由至少走过一遍。这时候后台是独立的黑盒，没有外部依赖。",[31,39759,39761],{"id":39760},"第2阶段后台小程序","第2阶段：后台+小程序",[11,39763,39764,39766],{},[488,39765,39717],{},"：小程序能从后台读数据、写数据，覆盖游客浏览和登录用户操作。",[11,39768,39769,39771],{},[488,39770,39723],{},"：Phase 1–3（2026-04-12 到 2026-04-13 期间）。",[11,39773,39774,1244],{},[488,39775,39776],{},"依赖关系",[123,39778,39779,39782,39785],{},[126,39780,39781],{},"Phase 1（骨架）需要后台 Plan 1（基础：auth、middleware、request client）",[126,39783,39784],{},"Phase 2（首页+商品）需要后台 Plan 1 + Plan 4（products API）",[126,39786,39787],{},"Phase 3（下单+支付）需要后台 Plan 1 + Plan 4（orders\u002Fpayments API）",[11,39789,39790,1244],{},[488,39791,39729],{},[123,39793,39794,39797,39800,39807,39818],{},[126,39795,39796],{},"后台完全真实：真实 MongoDB，真实 API 服务",[126,39798,39799],{},"小程序在微信开发者工具中：用假 WeChat openid（例如 test-user-001）写死在开发配置里，调用真实后台 API",[126,39801,39802,39803,39806],{},"支付模块：后台在测试环境下返回 mock 支付结果（检测 ",[15,39804,39805],{},"NODE_ENV=development"," 时跳过真实微信支付 SDK，直接返回 success）",[126,39808,39809,39810,39813,39814,39817],{},"认证用工具函数 ",[15,39811,39812],{},"requireLogin()","，小程序 service 的 ",[15,39815,39816],{},"request()"," 自动读 localStorage 里的 fake token，后台鉴权中间件识别这个 test token 就返回授权",[126,39819,39820],{},"不真实的数据（设备状态、MQTT 消息）用固定 fixture 返回",[11,39822,39823,1244],{},[488,39824,39825],{},"具体流程",[243,39827,39828,39835,39841,39848,39855],{},[126,39829,39830,39831,39834],{},"启动后台 dev 服务器 ",[15,39832,39833],{},"npm run dev","，默认连接本地 MongoDB",[126,39836,39837,39838],{},"打开微信开发者工具，小程序项目配置指向 ",[15,39839,39840],{},"http:\u002F\u002Flocalhost:3000",[126,39842,39843,39844,39847],{},"小程序登录页点\"授权\"，调后台 ",[15,39845,39846],{},"\u002Fauth\u002Flogin","，后台返回 token，小程序存入 localStorage",[126,39849,39850,39851,39854],{},"跳转首页，调 ",[15,39852,39853],{},"\u002Fproducts?limit=10","，后台返回商品列表，小程序渲染轮播图",[126,39856,39857],{},"找到接口缺失或返回格式不对，后台补齐、更新 Swagger 文档，小程序跟着改",[11,39859,39860,39861,39864,39865,39868],{},"这个阶段没有真实设备，完全是前后端的对接。设备相关的代码（比如 Phase 2 里的 ",[15,39862,39863],{},"services\u002Fdevices.ts"," 里的 ",[15,39866,39867],{},"getDeviceList()","）是空实现或返回 mock 数据，例如：",[399,39870,39872],{"className":21005,"code":39871,"language":21007,"meta":183,"style":183},"export async function getDeviceList() {\n  if (process.env.NODE_ENV === 'test') {\n    return { list: [{ id: 'mock-dev-1', name: '玩具1', status: 'online' }], total: 1 }\n  }\n  return request({ url: '\u002Fdevices', skipAuth: false })\n}\n",[15,39873,39874,39888,39906,39935,39939,39960],{"__ignoreMap":183},[407,39875,39876,39878,39880,39882,39885],{"class":409,"line":410},[407,39877,1045],{"class":417},[407,39879,7354],{"class":417},[407,39881,1048],{"class":417},[407,39883,39884],{"class":554}," getDeviceList",[407,39886,39887],{"class":413},"() {\n",[407,39889,39890,39892,39895,39898,39901,39904],{"class":409,"line":184},[407,39891,3721],{"class":417},[407,39893,39894],{"class":413}," (process.env.",[407,39896,39897],{"class":476},"NODE_ENV",[407,39899,39900],{"class":417}," ===",[407,39902,39903],{"class":429}," 'test'",[407,39905,887],{"class":413},[407,39907,39908,39910,39913,39916,39919,39922,39925,39928,39931,39933],{"class":409,"line":189},[407,39909,3807],{"class":417},[407,39911,39912],{"class":413}," { list: [{ id: ",[407,39914,39915],{"class":429},"'mock-dev-1'",[407,39917,39918],{"class":413},", name: ",[407,39920,39921],{"class":429},"'玩具1'",[407,39923,39924],{"class":413},", status: ",[407,39926,39927],{"class":429},"'online'",[407,39929,39930],{"class":413}," }], total: ",[407,39932,659],{"class":476},[407,39934,3641],{"class":413},[407,39936,39937],{"class":409,"line":452},[407,39938,563],{"class":413},[407,39940,39941,39943,39946,39949,39952,39955,39957],{"class":409,"line":458},[407,39942,2142],{"class":417},[407,39944,39945],{"class":554}," request",[407,39947,39948],{"class":413},"({ url: ",[407,39950,39951],{"class":429},"'\u002Fdevices'",[407,39953,39954],{"class":413},", skipAuth: ",[407,39956,18738],{"class":476},[407,39958,39959],{"class":413}," })\n",[407,39961,39962],{"class":409,"line":464},[407,39963,483],{"class":413},[11,39965,39966],{},"这样既能让页面渲染通过，又不依赖真实设备存在。",[31,39968,39970],{"id":39969},"第3阶段后台设备","第3阶段：后台+设备",[11,39972,39973,39975],{},[488,39974,39717],{},"：设备能接收后台指令、上报状态。小程序和设备可以不通信，但后台要能驱动设备。",[11,39977,39978,39980],{},[488,39979,39723],{},"：Phase 4（设备详情、扫码绑定）。",[11,39982,39983,1244],{},[488,39984,39776],{},[123,39986,39987,39990],{},[126,39988,39989],{},"Phase 4 需要后台 Plan 3（devices\u002FMQTT 模块）",[126,39991,39992],{},"Phase 4 不需要 Phase 2\u002F3 完全完成，但需要后台 device 模型已定义",[11,39994,39995,1244],{},[488,39996,39729],{},[123,39998,39999,40002,40005],{},[126,40000,40001],{},"后台：真实 MQTT broker（EMQX 或阿里云\u002F腾讯云 MQTT 服务），真实设备在线或可控上线",[126,40003,40004],{},"小程序：可以继续用假数据调试 UI，设备相关的 API 调用和页面暂时 mock 返回",[126,40006,40007],{},"关键路径是后台 → MQTT broker → 设备的指令下发和状态回复，这一段必须真实",[11,40009,40010,1244],{},[488,40011,39825],{},[243,40013,40014,40017,40023,40029],{},[126,40015,40016],{},"启动后台 MQTT 服务（连接真实 broker）",[126,40018,40019,40020,40022],{},"连接真实设备到 broker（设备订阅 ",[15,40021,39665],{}," 主题）",[126,40024,40025,40026],{},"后台代码调用 ",[15,40027,40028],{},"publishDeviceCommand(deviceSn, { cmd: 'set_volume', params: { volume: 50 } })",[126,40030,40031,40032],{},"验证：\n",[123,40033,40034,40037,40040,40043,40049],{},[126,40035,40036],{},"MQTT broker 收到消息",[126,40038,40039],{},"设备接收到消息",[126,40041,40042],{},"设备执行（音量调到 50）",[126,40044,40045,40046],{},"设备上报反馈到后台 ",[15,40047,40048],{},"toynet\u002Fdevice\u002F{deviceSn}\u002Fstatus",[126,40050,40051],{},"后台记录状态变化",[11,40053,40054,40055,40058,40059,40062],{},"这个阶段会发现的问题通常是：MQTT topic 路径理解偏差（后台发到 ",[15,40056,40057],{},"device\u002F123\u002Fcmd","，设备监听的是 ",[15,40060,40061],{},"device\u002F123\u002Fcommand","）、命令参数类型错误（发的是 string 但设备期望 number）、设备离线处理逻辑不对、消息丢失重试机制缺失。这些问题不会影响后台+小程序的对接（那是上一阶段已验证的），只能来自设备硬件的理解差异。",[11,40064,40065,40068,40069,40072],{},[488,40066,40067],{},"离线设备的处理","。Plan 3 设计了一个细节：设备离线时，",[15,40070,40071],{},"POST \u002Fdevices\u002F:id\u002Fcommand"," 仍然接受请求，返回 422 错误码 42240，告诉客户端\"设备离线，指令已入队但不保证送达\"。这样做是为了解耦小程序和设备的强依赖关系——小程序不用关心设备是否在线，后台负责队列和重试。真实测试时：",[243,40074,40075,40078,40081,40084,40087,40090],{},[126,40076,40077],{},"准备一台设备，上线后发一条指令，确认执行成功",[126,40079,40080],{},"拔掉设备电源，让它离线",[126,40082,40083],{},"再发一条指令，应该收到 42240 错误，指令被入队",[126,40085,40086],{},"给设备重新通电，它上线",[126,40088,40089],{},"确认设备优先处理队列里的待发指令",[126,40091,40092],{},"验证指令执行顺序和完整性",[11,40094,40095],{},"这个测试不复杂，但很关键。它验证了后台的异步处理能力，这对小程序的用户体验很重要——用户不会在\"点了按钮设备没反应\"时尴尬，后台会自动重试。",[31,40097,40099],{"id":40098},"第4阶段三端","第4阶段：三端",[11,40101,40102,40104],{},[488,40103,39717],{},"：小程序操作 → 后台处理 → 设备执行 → 实时反馈给小程序，完整闭环。",[11,40106,40107,40109],{},[488,40108,39723],{},"：Phase 5（录音上传、MQTT 推送到设备）。",[11,40111,40112,1244],{},[488,40113,39776],{},[123,40115,40116,40119,40122],{},[126,40117,40118],{},"Phase 5 需要 Phase 4 的设备模块已稳定",[126,40120,40121],{},"需要后台 Plan 5（AI 对话）+ Plan 3（MQTT 服务）已完成",[126,40123,40124,40125,40128],{},"需要设备硬件支持 ",[15,40126,40127],{},"play_custom_audio"," 命令（可能需要固件升级，这是前期沟通清楚的）",[11,40130,40131,1244],{},[488,40132,39729],{},[123,40134,40135,40138,40141],{},[126,40136,40137],{},"全真实：真实小程序用户、真实后台、真实 MQTT broker、真实设备",[126,40139,40140],{},"不用 mock，不用假数据",[126,40142,40143],{},"唯一的前置条件是：设备硬件能力清单已确认（支持哪些命令、指令超时时间、失败重试策略）",[11,40145,40146,1244],{},[488,40147,39825],{},[243,40149,40150,40153,40159,40162,40172,40175,40181,40184],{},[126,40151,40152],{},"小程序用户点\"录制\"按钮，录音 5 秒钟",[126,40154,40155,40156],{},"上传音频文件到后台 ",[15,40157,40158],{},"POST \u002Fapi\u002Fv1\u002Frecordings\u002Fupload",[126,40160,40161],{},"后台接收、持久化音频（存 COS 或本地），返回音频 URL",[126,40163,40164,40165,40168,40169],{},"后台业务逻辑获取该用户绑定的所有设备，对每一台设备发 MQTT 指令：",[15,40166,40167],{},"device\u002F{sn}\u002Fcommand"," 消息内容 ",[15,40170,40171],{},"{ cmd: 'play_custom_audio', params: { url: 'https:\u002F\u002F...', duration: 5 } }",[126,40173,40174],{},"设备收到消息，下载音频，开始播放",[126,40176,40177,40178],{},"设备每秒上报播放状态（进度、音量、是否完成）到后台 MQTT 主题 ",[15,40179,40180],{},"device\u002F{sn}\u002Fstatus",[126,40182,40183],{},"后台通过 WebSocket 或 Server-Sent Event 推送状态更新给小程序",[126,40185,40186],{},"小程序实时显示设备播放进度条、播放完成提示",[11,40188,40189],{},"这个阶段是三端各司其职的验证。一旦前三个阶段都通过了，这里通常没有架构问题——问题可能来自：",[123,40191,40192,40195,40198,40201],{},[126,40193,40194],{},"硬件支持度（设备固件不支持某个命令）",[126,40196,40197],{},"网络延迟（MQTT 消息延迟超过 1 秒）",[126,40199,40200],{},"音频格式不兼容（设备不支持某种编码）",[126,40202,40203],{},"存储容量（音频文件过大）",[11,40205,40206],{},"但这些都不是联调顺序的问题，都是已知的工程约束。",[26,40208,40209],{"id":40209},"为什么这个顺序正确",[243,40211,40212,40218,40224,40230,40236],{},[126,40213,40214,40217],{},[488,40215,40216],{},"依赖关系清晰","：每一步都在前一步的基础上加新功能，不会无限反复。后台是基座，小程序依赖后台 API，设备依赖后台 MQTT，没有循环依赖。",[126,40219,40220,40223],{},[488,40221,40222],{},"快速反馈","：每个阶段都能独立验证，不用等所有功能都做完。第 1 阶段后端工程师就能 100% 确认\"后台自己能跑\"。第 2 阶段小程序工程师就能 100% 确认\"小程序能调后台 API\"。没有\"可能、应该、估计\"。",[126,40225,40226,40229],{},[488,40227,40228],{},"问题隔离","：如果第 N 阶段失败，问题一定在这一步加的新东西里，前面的层都已经验证过。例如第 3 阶段如果设备收不到指令，不用怀疑后台 API（已在第 2 阶段通过了），直接看 MQTT 通信。这样定位问题的时间从\"无限\"缩短到\"可控\"。",[126,40231,40232,40235],{},[488,40233,40234],{},"资源利用","：设备成本高（采购、维护、通电、运输、故障修理）。如果先做设备再做小程序，小程序开发期间所有设备都得 24\u002F7 开着，浪费电，加速磨损。如果后台+小程序联调期间设备全关着，开发速度反而快。",[126,40237,40238,40241],{},[488,40239,40240],{},"并行开发","：三个角色（后端、小程序、硬件）可以真正并行。后端做 Plan 1-2 时，小程序工程师看 Swagger 文档设计页面框架；硬件工程师看 MQTT topic 设计固件消息处理。等后端出了初版 API，小程序才开始集成；硬件到了 Plan 3 才开始真实测试。如果反序，硬件工程师得等小程序做完才能知道\"产品需要什么\"，这是典型的串行瓶颈。",[11,40243,40244,40246],{},[488,40245,39674],{},"。如果先做设备再做小程序，流程变成：设备完成 → 小程序根据设备现状开发 → 发现产品需求与设备能力不匹配 → 改硬件或改产品需求 → 小程序重做。这一圈返工，设备的每次改动都要测试，成本指数级上升。而且到了后期，后台 API 还没对接上来，小程序用 mock 数据做了一堆假验证，等真接口来了又全得改。",[26,40248,40249],{"id":40249},"每个阶段的验收输出物",[123,40251,40252,40283,40305,40324],{},[126,40253,40254,1244,40257],{},[488,40255,40256],{},"第1阶段",[123,40258,40259,40262,40265,40268,40274],{},[126,40260,40261],{},"后台源码（完整目录结构）",[126,40263,40264],{},"Jest 覆盖率报告（≥80%）",[126,40266,40267],{},"Swagger API 文档（自动生成，所有路由都有示例）",[126,40269,40270,40273],{},[15,40271,40272],{},".env.example"," 和数据库初始化脚本",[126,40275,40276,40277,40279,40280,40282],{},"通过标准：",[15,40278,10841],{}," 全部 PASS，",[15,40281,10828],{}," 能启动",[126,40284,40285,1244,40288],{},[488,40286,40287],{},"第2阶段",[123,40289,40290,40293,40296,40299,40302],{},[126,40291,40292],{},"小程序源码（所有 Phase 1-3 功能代码）",[126,40294,40295],{},"Jest 测试报告（services 层覆盖率 ≥80%）",[126,40297,40298],{},"真机测试截图和视频（登录、首页、搜索、下单、支付成功页）",[126,40300,40301],{},"小程序 Swagger 文档（调用了哪些后台接口）",[126,40303,40304],{},"通过标准：真机打开小程序能正常浏览商品、能登录、能加购",[126,40306,40307,1244,40310],{},[488,40308,40309],{},"第3阶段",[123,40311,40312,40315,40318,40321],{},[126,40313,40314],{},"设备 MQTT 通信日志（broker 收到的所有消息 + 时间戳）",[126,40316,40317],{},"设备指令执行记录（对于每条指令记录：发出时刻、设备接收时刻、执行结果、完成时刻）",[126,40319,40320],{},"离线重试测试报告（设备离线时发指令、再上线的执行顺序）",[126,40322,40323],{},"通过标准：指令下发成功率 100%，设备离线重试有效",[126,40325,40326,1244,40329],{},[488,40327,40328],{},"第4阶段",[123,40330,40331,40334,40337,40340],{},[126,40332,40333],{},"端到端完整流程录屏 3 分钟（从小程序用户点录制，到设备播放完成，中间显示所有 MQTT 消息）",[126,40335,40336],{},"性能指标（从小程序点击到设备反馈的延迟、音频传输成功率）",[126,40338,40339],{},"边界情况测试报告（设备离线、网络断线、文件过大、格式不支持等）",[126,40341,40342],{},"通过标准：真机录音并推送到设备，设备成功播放，小程序显示进度",[26,40344,40345],{"id":40345},"何时切换到真实数据",[11,40347,40348],{},"每个阶段 mock 和真实的分界线很明确，不要超前也不要延后：",[123,40350,40351,40357],{},[126,40352,40353,40356],{},[488,40354,40355],{},"不要延后","：后台 API 已写好了还继续用 mock，小程序就永远发现不了接口格式不匹配的问题。",[126,40358,40359,40362],{},[488,40360,40361],{},"不要超前","：设备硬件还没到，不用强行对接真实 MQTT broker，浪费时间调试网络问题。",[11,40364,40365,40366,40369],{},"经验是：",[488,40367,40368],{},"看依赖关系","。小程序需要后台 API 才能测，但小程序的 UI 测试不需要真设备；设备需要后台 MQTT 才能下指令，但这时候小程序可以继续 mock。按这个粒度切分，三个角色都不会被卡住。",[1267,40371,40372],{},"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 .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":183,"searchDepth":184,"depth":184,"links":40374},[40375,40376,40382,40383,40384],{"id":39645,"depth":184,"text":39645},{"id":39708,"depth":184,"text":39708,"children":40377},[40378,40379,40380,40381],{"id":39711,"depth":189,"text":39712},{"id":39760,"depth":189,"text":39761},{"id":39969,"depth":189,"text":39970},{"id":40098,"depth":189,"text":40099},{"id":40209,"depth":184,"text":40209},{"id":40249,"depth":184,"text":40249},{"id":40345,"depth":184,"text":40345},"2026-04-12",{},"\u002F2026-04-12",{"title":39637,"description":39642},"2026-04-12-三端联调的顺序问题","三端并行开发时顺序错了导致大量返工；可行顺序是后台自测→后台+小程序→后台+设备→三端，理由是后台是唯一事实源。",[40392,40393,40394,40395,40396],"联调","项目管理","微信小程序","物联网","MQTT","K1xidurgY1T0nHQz_Hdm21bPL984lc8_rPJD3qsgp80",{"id":40399,"title":40400,"body":40401,"column":196,"date":40507,"description":40405,"extension":199,"hero_image":200,"meta":40508,"navigation":202,"path":40509,"seo":40510,"series_id":200,"severity":200,"stem":40511,"summary":40512,"tags":40513,"__hash__":40517},"posts\u002F2026-04-08-设备在线状态为什么会飘.md","设备明明在线，状态却一直跳",{"type":8,"value":40402,"toc":40502},[40403,40406,40409,40412,40418,40421,40427,40430,40439,40442,40448,40451,40469,40472,40475,40484,40487,40490,40493,40496,40499],[11,40404,40405],{},"MQTT 的连接状态和\"设备在线\"这个业务概念不是一回事。直接用连接\u002F断开事件驱动设备状态会导致频繁抖动——用户界面上设备一会儿在线一会儿离线，或者后台任务反复重试，都源于这个混淆。",[11,40407,40408],{},"问题的根源在于混淆了网络协议层和业务层的两个不同概念。MQTT broker 记录的\"连接\"是 TCP 会话的建立，而业务上的\"设备在线\"应该表达\"设备功能可用\"。两者之间隔着网络传输、消息队列、客户端重连等多个可能出错的环节。",[26,40410,40411],{"id":40411},"三个源头",[11,40413,40414,40417],{},[488,40415,40416],{},"第一个是网络抖动。"," 网络瞬时中断会触发MQTT连接断开，但通常在秒级内重新连上。MQTT客户端库一般都有自动重连机制，收到断开通知后立即尝试重连。如果业务逻辑依赖连接事件，这短暂的断开就被当作\"离线\"上报，导致状态反复切换。例如设备在某个Wi-Fi信号不稳定的位置，可能每分钟产生3-5次连接\u002F断开事件。用户看到的就是设备状态在一分钟内闪烁十几次。",[11,40419,40420],{},"这种抖动在移动设备上特别明显。设备从一个Wi-Fi热点移动到另一个、或从Wi-Fi切到蜂窝网络、再切回Wi-Fi，每一次切换都会产生一次短暂的网络中断。后台的指令重试、日志上报等任务也会反复启动和取消，浪费电量和网络带宽。",[11,40422,40423,40426],{},[488,40424,40425],{},"第二个是遗嘱消息的延迟。"," MQTT 的 Last Will and Testament（LWT）机制是这样的：客户端连接时向服务端注册一条遗嘱消息，如果客户端异常断开（而不是正常关闭连接），服务端会在一个keep-alive超时周期后代为发布这条消息。超时时间由客户端的 keep-alive 参数决定，通常是60秒。即使是10秒的超时，对于用户感知来说也是明显的延迟——设备下线了10秒后才被标记离线。",[11,40428,40429],{},"更糟的是消息到达顺序问题。设备先由于网络抖动而异常掉线，服务端记录下这个事件但还在等待超时才能发 LWT。此时设备已经重新连上，上报了新的连接事件和\"我在线\"的心跳。但随后 LWT 消息才被投递，这条迟到的遗嘱消息被用来更新状态，刚刚上线的设备又被判成离线。这就是\"死而复生\"的情况。要修复它，需要确保消息的因果顺序被正确处理——新连接的消息必须总是覆盖旧连接的 LWT，而不是相反。",[11,40431,40432,16323,40435,40438],{},[488,40433,40434],{},"第三个是重连时的会话交叠。",[407,40436,40437],{},"待补：会话标识的具体场景描述","。简单说，客户端断开后重新连接的过程中，旧连接和新连接在短时间内可能同时存活在服务端。MQTT 服务端通常用客户端 ID（client ID）来唯一标识一个逻辑客户端，但不区分\"同一个 ID 的第N次连接\"是旧连接还是新连接。如果处理不当，新连接的消息和旧连接的清理事件可能乱序处理：新连接发来\"我在线\"，但旧连接的\"断开\"事件此时才被处理，状态又变成离线。或者旧连接还没来得及发送 LWT，新连接就已经改变了状态，但后续 LWT 消息被错误地应用到了新连接之上。这会导致整个状态机陷入不一致。",[26,40440,40441],{"id":40441},"三层防护",[11,40443,40444,40447],{},[488,40445,40446],{},"第一层：去抖窗口。"," 不要在收到单个连接\u002F断开事件时立即改变设备状态。改为记录事件的时间戳，再用一个去抖窗口来过滤（典型值：5-15秒）。只有状态在窗口期内保持一致，才最终确认这个状态变更。这样可以吸收网络毛刺和短暂的异常断开。例如设备在5秒内出现\"连->断->连\"的抖动，去抖窗口可以忽略这个抖动，最终确认设备仍然在线。",[11,40449,40450],{},"但这只是表面文章。去抖窗口治标不治本——它隐藏了问题，但没有从根本上解决状态不一致的根源。如果关键业务逻辑（如指令下发）直接依赖这个抖动的状态，去抖5秒和去抖15秒的区别就可能是收到指令和没收到的区别。真正的防护靠后面两层。",[11,40452,40453,40456,40457,40460,40461,40464,40465,40468],{},[488,40454,40455],{},"第二层：心跳而非连接事件。"," 这是关键转换。设备不依赖 MQTT 连接本身来表示\"在线\"，而是定期上报心跳消息。例如设备每30秒上报一条心跳消息到专用 topic（如 ",[15,40458,40459],{},"toynet\u002Fdevice\u002F{deviceSn}\u002Fheartbeat","），服务端通过消费这个 topic 来更新设备的 ",[15,40462,40463],{},"lastHeartbeat"," 时间戳。设备在线的判据变成：",[15,40466,40467],{},"lastHeartbeat \u003C 现在 - 90秒"," 则离线，否则在线。",[11,40470,40471],{},"90秒是容错窗口的设计。设备每30秒上报一次，两个周期加上网络延迟最多60秒，保留30秒的余量。这意味着即便连接反复震荡，甚至一次心跳消息丢失，只要没有连续90秒没有心跳，设备就保持在线。这样既吸收了网络抖动，也给了消息传输足够的容错空间。",[11,40473,40474],{},"心跳本身是一条普通的业务消息，不绑定 TCP 连接的生命周期。即使 MQTT 连接因为网络抖动断开又重连了，只要心跳消息能被投递到，服务端就能正确判断设备的实际状态。这打破了\"连接 = 在线\"的一对一映射，引入了一个独立的信号源。",[11,40476,40477,16323,40480,40483],{},[488,40478,40479],{},"第三层：会话标识隔离旧连接。",[407,40481,40482],{},"待补：具体的会话标识方案、实现细节","。原理是：每次连接时生成一个会话标识符（可以是 UUID、或连接时间戳+随机数），客户端在上报心跳和其他消息时携带这个会话 ID。服务端收到消息时记录当前连接的会话 ID，收到断开事件也记录对应的会话 ID。关键是：只有来自\"当前会话\"的消息和事件才被用来更新设备状态，来自旧会话的事件直接丢弃。",[11,40485,40486],{},"这防止了会话交叠期间的状态混乱。例如旧连接的 LWT 消息迟到时，服务端检查发现它对应的会话 ID 是\"session-old-123\"，而当前连接的会话 ID 是\"session-new-456\"，就忽略这条 LWT，不用它来改变设备状态。新连接发来的\"我在线\"（会话 ID：\"session-new-456\"）保持生效。这样即使 LWT 消息交错到达，也不会倒流状态。",[26,40488,40489],{"id":40489},"落地要点",[11,40491,40492],{},"心跳超时的90秒数字不是随意设置。设备每30秒上报一次心跳，这意味着理想情况下服务端最多30秒收到一条。但网络不总是理想的——消息可能因为网络延迟延后10秒、或因为服务端消费队列繁忙而被延后处理15秒。设置90秒意味着：即便两个心跳周期（60秒）都加上网络和处理延迟的上界（30秒），还有30秒的最后安全边界。超出这个范围再判离线，误触发率控制在可接受的范围。",[11,40494,40495],{},"如果将超时设为30秒（仅一个周期），单次心跳延迟就可能触发误判。设为60秒可能看起来\"更稳妥\"，但代价是真实离线的设备需要等60秒才被发现，影响业务响应速度。90秒是在响应速度和稳定性之间的平衡。",[11,40497,40498],{},"指令下发时，如果设备在线（基于心跳判断）则立即尝试下发。如果设备离线，需要根据业务需求决定是否入队。如果选择入队（例如对关键指令），要清楚地告知前端\"设备离线，指令已记录但不保证立即送达\"。设备重新在线后，优先处理队列中的待发指令。这避免了用户\"我下发指令了为什么没反应\"的困惑。",[11,40500,40501],{},"实装时还有一个易忽视的坑：MQTT broker 和客户端的 keep-alive 参数要协调。MQTT keep-alive 是 TCP 连接保活的参数，客户端每隔 keep-alive 时间必须向 broker 发送某条消息（包括 PING），否则 broker 可能会关闭连接。如果 broker 的 keep-alive 是60秒，但业务层的心跳周期是30秒，这两者之间没有冲突。但如果心跳周期是90秒，而 keep-alive 是60秒，TCP 连接可能在接收心跳之前就被 broker 主动断开。通常的做法是让客户端 keep-alive 小于业务心跳周期的一半，或者心跳消息本身也被用作 keep-alive 信号。",{"title":183,"searchDepth":184,"depth":184,"links":40503},[40504,40505,40506],{"id":40411,"depth":184,"text":40411},{"id":40441,"depth":184,"text":40441},{"id":40489,"depth":184,"text":40489},"2026-04-08",{},"\u002F2026-04-08",{"title":40400,"description":40405},"2026-04-08-设备在线状态为什么会飘","MQTT连接状态与业务在线状态不同步导致频繁抖动的原因分析，以及心跳机制、去抖、会话识别三层防护方案。",[40396,40514,28285,40515,40516],"设备在线","心跳机制","IoT","RVsK4w0Hz-2_5oGMtxmT5-12lbO4BjITTpcG-XxE91A",{"id":40519,"title":40520,"body":40521,"column":196,"date":40879,"description":40525,"extension":199,"hero_image":200,"meta":40880,"navigation":202,"path":40881,"seo":40882,"series_id":200,"severity":200,"stem":40883,"summary":40884,"tags":40885,"__hash__":40888},"posts\u002F2026-04-05-toynet-IoT后端从零到MQTT接入.md","设备还没接上，后端先做什么？",{"type":8,"value":40522,"toc":40872},[40523,40526,40529,40532,40535,40538,40557,40560,40563,40566,40571,40585,40588,40593,40604,40607,40612,40623,40626,40629,40643,40649,40653,40656,40659,40665,40675,40697,40700,40714,40717,40743,40746,40752,40816,40823,40826,40829,40832,40835,40867,40870],[11,40524,40525],{},"玩具联网项目需要同时支撑设备接入、厂商管理、订单与分销、小程序。搭建后端时，如果简单按\"设备\"→\"产品\"→\"订单\"的业务流程顺序实现，很快会碰到一个问题：设备连接上来上报数据，但不知道这个设备属于谁、属于哪个厂商，调试无从下手。",[11,40527,40528],{},"这不是代码问题，是架构依赖顺序的问题。",[26,40530,40531],{"id":40531},"为什么顺序错了调试会卡住",[11,40533,40534],{},"设备上线后会通过 MQTT 上报心跳、状态、日志。这些数据要写入数据库，必须记录设备序列号和设备 ID。而设备 ID 是 MongoDB ObjectId，它存在于 Device 模型里。",[11,40536,40537],{},"Device 模型包含三个关键外键：",[123,40539,40540,40546,40552],{},[126,40541,40542,40545],{},[15,40543,40544],{},"manufacturerId","（该设备属于哪个厂商）",[126,40547,40548,40551],{},[15,40549,40550],{},"productId","（该设备属于哪个商品型号）",[126,40553,40554,40556],{},[15,40555,1199],{},"（当前绑定的用户，null 表示未绑定）",[11,40558,40559],{},"当你没有先定义 Manufacturer 和 User 数据结构时，Device 记录就悬空了。设备连接时，你能看到日志来了，但无法回答\"这是谁的设备\"。更重要的是，Device 模型的权限检查逻辑会失效——后续要实现\"用户只能查看自己的设备\"，需要依赖 userId 字段；要实现\"厂商只能看自己的设备\"，需要 manufacturerId。没有这两个模型，权限层就没法建。",[26,40561,40562],{"id":40562},"正确的搭建顺序",[11,40564,40565],{},"从下往上分三层：",[11,40567,40568],{},[488,40569,40570],{},"第一层：所有制基础（必须最先）",[123,40572,40573,40576,40579,40582],{},[126,40574,40575],{},"User 模型（小程序用户）",[126,40577,40578],{},"Admin 模型（后台管理员）",[126,40580,40581],{},"Manufacturer 模型（厂商入驻）",[126,40583,40584],{},"Role 模型（角色权限）",[11,40586,40587],{},"这一层定义了\"谁是谁\"。后续所有数据都需要通过 userId、adminId 或 manufacturerId 来标记所有权和权限。",[11,40589,40590],{},[488,40591,40592],{},"第二层：设备与商品（依赖第一层）",[123,40594,40595,40598,40601],{},[126,40596,40597],{},"Product 模型（商品\u002F型号定义）",[126,40599,40600],{},"Device 模型（具体设备实例，绑定到 User）",[126,40602,40603],{},"MQTT 服务（设备通信协议）",[11,40605,40606],{},"Device 需要 manufacturerId 来记录厂商关系，这样权限检查才有依据。Product 同样需要 manufacturerId。",[11,40608,40609],{},[488,40610,40611],{},"第三层：业务流程（依赖前两层）",[123,40613,40614,40617,40620],{},[126,40615,40616],{},"Order 模型（订单）",[126,40618,40619],{},"FinanceRecord 模型（财务结算）",[126,40621,40622],{},"Distribution 模型逻辑（二级分销）",[11,40624,40625],{},"订单需要引用 User、Product、Device，财务结算需要引用 Manufacturer。没有前两层，这一层无法运转。",[11,40627,40628],{},"为什么不能倒过来？如果先建 Order 和 Device，再补 User 和 Manufacturer，你会发现：",[243,40630,40631,40634,40637,40640],{},[126,40632,40633],{},"Device 里的 manufacturerId 和 userId 无法被正确校验",[126,40635,40636],{},"MQTT 设备上线后，无法判断数据属于哪个 User 或 Manufacturer",[126,40638,40639],{},"设备日志（DeviceLog）要记录归属关系，但缺少参考数据",[126,40641,40642],{},"后续添加用户和厂商时，已有的设备记录需要大规模回填或迁移",[11,40644,40645,40646,781],{},"总结一句：",[488,40647,40648],{},"数据的所有权明确之前，不要让设备开始说话",[26,40650,40652],{"id":40651},"mqtt-主题设计三层隔离","MQTT 主题设计：三层隔离",[11,40654,40655],{},"一旦用户、厂商、设备三个模型都存在了，MQTT 通信才真正能跑起来。",[11,40657,40658],{},"设计主题结构时，我采用三级层次：",[399,40660,40663],{"className":40661,"code":40662,"language":1327},[1325],"toynet\u002Fdevice\u002F{deviceSn}\u002Fstatus       # 设备上报状态\ntoynet\u002Fdevice\u002F{deviceSn}\u002Fheartbeat    # 心跳\ntoynet\u002Fdevice\u002F{deviceSn}\u002Fcommand      # 下发指令\ntoynet\u002Fdevice\u002F{deviceSn}\u002Fresponse     # 指令响应\ntoynet\u002Fdevice\u002F{deviceSn}\u002Fai\u002Faudio     # 语音数据流\n",[15,40664,40662],{"__ignoreMap":183},[11,40666,40667,40668,40671,40672,40674],{},"为什么是 ",[15,40669,40670],{},"deviceSn"," 而不是 ",[15,40673,21113],{},"？两个考虑：",[243,40676,40677,40683],{},[126,40678,40679,40682],{},[488,40680,40681],{},"硬件视角","：SN（序列号）是设备的全球唯一标识，出厂时就确定。即使设备还没入库、还没绑定用户，SN 也是不变的。这样 MQTT broker 可以在设备连接时就验证 SN。",[126,40684,40685,40688,40689,40692,40693,40696],{},[488,40686,40687],{},"权限隔离","：MQTT ACL（Access Control List）规则可以基于 SN 前缀做批量配置。比如\"厂商 A 的所有设备 SN 以 ",[15,40690,40691],{},"TOY-A-"," 开头\"，就可以一条规则 ",[15,40694,40695],{},"toynet\u002Fdevice\u002FTOY-A-+\u002F+"," 授予厂商 A 的连接账号。",[11,40698,40699],{},"再往上一层想，虽然主题里没有显式写 manufacturerId 或 userId，但通过 Device 模型的正向查询，MQTT 服务收到消息后可以：",[243,40701,40702,40705,40708,40711],{},[126,40703,40704],{},"解析 deviceSn",[126,40706,40707],{},"查库找到 Device 记录",[126,40709,40710],{},"获取 manufacturerId、userId、productId",[126,40712,40713],{},"校验消息来源（设备证书或 token）是否有权限修改这个设备",[11,40715,40716],{},"这样做的好处：",[123,40718,40719,40725,40731,40737],{},[126,40720,40721,40724],{},[488,40722,40723],{},"设备独立性","：设备只需要知道自己的 SN，不需要关心自己属于哪个用户",[126,40726,40727,40730],{},[488,40728,40729],{},"厂商隔离","：后台在 MQTT broker 层可以为每个厂商的账号设置主题权限",[126,40732,40733,40736],{},[488,40734,40735],{},"灵活迁移","：设备转移给另一个用户时，只需更新 Device.userId，MQTT 主题不变",[126,40738,40739,40742],{},[488,40740,40741],{},"调试友好","：指定 deviceSn 就能看完整的上报链路",[26,40744,40745],{"id":40745},"指令下发与离线队列",[11,40747,40748,40749,40751],{},"设备接收指令通过 ",[15,40750,39665],{}," 这个 topic。指令格式：",[399,40753,40755],{"className":3591,"code":40754,"language":3593,"meta":183,"style":183},"{\n  \"cmdId\": \"a1b2c3d4-e5f6-...\",\n  \"cmd\": \"play_story\",\n  \"params\": { \"storyId\": \"001\" },\n  \"timestamp\": 1712600000\n}\n",[15,40756,40757,40761,40773,40785,40802,40812],{"__ignoreMap":183},[407,40758,40759],{"class":409,"line":410},[407,40760,421],{"class":413},[407,40762,40763,40766,40768,40771],{"class":409,"line":184},[407,40764,40765],{"class":476},"  \"cmdId\"",[407,40767,3607],{"class":413},[407,40769,40770],{"class":429},"\"a1b2c3d4-e5f6-...\"",[407,40772,3296],{"class":413},[407,40774,40775,40778,40780,40783],{"class":409,"line":189},[407,40776,40777],{"class":476},"  \"cmd\"",[407,40779,3607],{"class":413},[407,40781,40782],{"class":429},"\"play_story\"",[407,40784,3296],{"class":413},[407,40786,40787,40790,40792,40795,40797,40800],{"class":409,"line":452},[407,40788,40789],{"class":476},"  \"params\"",[407,40791,3631],{"class":413},[407,40793,40794],{"class":476},"\"storyId\"",[407,40796,3607],{"class":413},[407,40798,40799],{"class":429},"\"001\"",[407,40801,21188],{"class":413},[407,40803,40804,40807,40809],{"class":409,"line":458},[407,40805,40806],{"class":476},"  \"timestamp\"",[407,40808,3607],{"class":413},[407,40810,40811],{"class":476},"1712600000\n",[407,40813,40814],{"class":409,"line":464},[407,40815,483],{"class":413},[11,40817,40818,40819,40822],{},"每条指令都有唯一的 ",[15,40820,40821],{},"cmdId","（UUID），用于追踪执行状态。指令类型包括播放故事、播放音乐、设置音量、运动控制等。",[11,40824,40825],{},"当设备离线时，server 端收不到 MQTT 连接，无法直接下发。但不能简单丢弃指令——应该先入库，标记为 pending，等设备上线后重新发送。这就需要一个 pending command queue，可以存在 Device.pendingCommands 字段或单独的 CommandQueue 表。",[11,40827,40828],{},"心跳机制是定位离线状态的基础：设备每 30 秒上报一次心跳，server 收到心跳就更新 Device.lastHeartbeat 和 status='online'。90 秒没收到心跳，自动标记 status='offline'。这样即使没有正式的连接状态通知，也能通过心跳超时推断设备状态。",[26,40830,40831],{"id":40831},"从设计到运行",[11,40833,40834],{},"总结一下这个架构的关键约束：",[243,40836,40837,40843,40849,40855,40861],{},[126,40838,40839,40842],{},[488,40840,40841],{},"所有权层必须先建","：User、Manufacturer 决定后续数据的访问边界。",[126,40844,40845,40848],{},[488,40846,40847],{},"设备必须有归属","：Device 必须绑定 manufacturerId（出厂时），后续可选绑定 userId（用户扫码时）。",[126,40850,40851,40854],{},[488,40852,40853],{},"MQTT 主题用 deviceSn","：保持设备的全局唯一性和权限隔离。",[126,40856,40857,40860],{},[488,40858,40859],{},"心跳定期刷新状态","：不依赖复杂的连接管理，简单可靠。",[126,40862,40863,40866],{},[488,40864,40865],{},"指令带 ID 追踪","：cmdId 让后续的 AI 对话模块（Plan 5）可以与指令执行绑定。",[11,40868,40869],{},"这个顺序不是教科书上的\"标准\"，而是在这个具体项目里，设备数据要有明确的所有权和权限边界这个需求推导出来的。如果只有单一厂商、单一用户类型，顺序可以更灵活。但一旦要支撑多厂商入驻和用户私密性，底层模型的依赖关系就会很快锁死搭建顺序。",[1267,40871,30360],{},{"title":183,"searchDepth":184,"depth":184,"links":40873},[40874,40875,40876,40877,40878],{"id":40531,"depth":184,"text":40531},{"id":40562,"depth":184,"text":40562},{"id":40651,"depth":184,"text":40652},{"id":40745,"depth":184,"text":40745},{"id":40831,"depth":184,"text":40831},"2026-04-05",{},"\u002F2026-04-05-toynet-iotmqtt",{"title":40520,"description":40525},"2026-04-05-toynet-IoT后端从零到MQTT接入","设备接入系统如何确保数据有归属，为什么用户和厂商模块必须先建，MQTT 主题设计如何支撑权限隔离。",[40516,40396,19464,40886,40887,39410],"Koa","MongoDB","n-oGSEyhQWTIXH_mJqBzF0pnjPGc-GqJi62Zu0HiHcY",{"id":40890,"title":40891,"body":40892,"column":196,"date":41174,"description":40896,"extension":199,"hero_image":200,"meta":41175,"navigation":202,"path":41176,"seo":41177,"series_id":200,"severity":200,"stem":41178,"summary":41179,"tags":41180,"__hash__":41184},"posts\u002F2026-03-30-77个规则文件为什么一层通用规则不够用.md","77 个规则文件——一层通用规则不够用",{"type":8,"value":40893,"toc":41165},[40894,40897,40900,40904,40907,40910,40913,40916,40920,40923,40929,40935,40941,40944,40948,40955,40958,40961,40964,40969,40972,40976,40979,40982,41000,41011,41014,41074,41077,41080,41083,41089,41092,41100,41103,41106,41111,41118,41122,41125,41131,41134,41137,41140,41142,41145,41148,41159,41162],[11,40895,40896],{},"建立规则系统时面临一个基础问题：通用规范和语言专用规范如何共存。",[11,40898,40899],{},"我采用的方案是分层存储：一份通用规则作为基础，11 个语言特定目录各自扩展，再加一份中文翻译层。77 个文件分布在 13 个目录里，但不是简单地重复 13 遍相同内容——每一层有明确的职责和覆盖关系。",[26,40901,40903],{"id":40902},"问题单层平铺的陷阱","问题：单层平铺的陷阱",[11,40905,40906],{},"最直观的做法是把所有规则写进一个大文件。但这样做有三个问题。",[11,40908,40909],{},"首先是冗余。通用的编码原则（比如测试覆盖率 80%、禁止硬编码密钥）对所有语言都适用，不需要重复写 11 遍。",[11,40911,40912],{},"其次是维护成本。一处修改要同步到 11 个地方，漏掉一个就产生不一致。",[11,40914,40915],{},"第三是可迁移性差。选择采用 TypeScript 时不需要关心 Perl 的规则，把无关的内容混在一起反而增加认知负担。",[26,40917,40919],{"id":40918},"方案三层分组","方案：三层分组",[11,40921,40922],{},"我把 77 个文件按职责分成三层。",[11,40924,40925,40928],{},[488,40926,40927],{},"第一层是通用规则（common 目录，10 个文件）","。这里放所有语言都适用的原则：不可变性、文件大小上限（800 行）、错误处理策略、测试覆盖率（80%）、代码审查标准。这些是基线，整个体系的基座。",[11,40930,40931,40934],{},[488,40932,40933],{},"第二层是语言特定规则（11 个语言目录，55 个文件）","。每个目录对应一门语言或语言族群（cpp 处理 C\u002FC++，typescript 覆盖 TypeScript 和 JavaScript）。语言特定目录里的 5 个文件（coding-style、testing、patterns、hooks、security）与通用层同名，但内容是该语言的方言。",[11,40936,40937,40940],{},[488,40938,40939],{},"第三层是翻译层（zh 目录，11 个文件）","。这是通用规则的中文版本，与 common 目录的内容完全对应（多一份 README），满足中文环境的表述习惯。",[11,40942,40943],{},"数字加起来：10（common）+ 55（11×5）+ 11（zh）+ 1（rules\u002FREADME.md）= 77 个文件。",[26,40945,40947],{"id":40946},"关键设计特定覆盖通用","关键设计：特定覆盖通用",[11,40949,40950,40951,40954],{},"分层的核心机制是",[488,40952,40953],{},"优先级约束","。语言特定的规则可以覆盖通用规则。",[11,40956,40957],{},"这个模型来自 CSS 特异性和 .gitignore 的优先级机制。不是\"多份规则随意叠加\"，而是\"特定优先，无特定则用通用默认\"。",[11,40959,40960],{},"具体例子：common\u002Fcoding-style.md 有一条铁律——\"始终返回新对象，禁止原地修改\"。这对 TypeScript、Python、Rust 等语言是对的，因为不可变数据模式防止隐藏的副作用。",[11,40962,40963],{},"但 Go 不同。惯用的 Go 代码使用指针接收器进行结构体变更——这是语言习惯，也是性能优化的标准实践。所以 golang\u002Fcoding-style.md 在文件开头就声明：",[1205,40965,40966],{},[11,40967,40968],{},"This file extends common\u002Fcoding-style.md with Go specific content.",[11,40970,40971],{},"然后针对可变性做出例外说明。规则读者看 Go 项目时会先读通用层，再读语言层，遇到冲突时语言层赢。",[26,40973,40975],{"id":40974},"陷阱安装时的覆盖风险","陷阱：安装时的覆盖风险",[11,40977,40978],{},"分层的好处显而易见，但也有一个隐藏的陷阱。",[11,40980,40981],{},"假设用这种方式安装规则：",[399,40983,40985],{"className":11754,"code":40984,"language":11756,"meta":183,"style":183},"cp rules\u002F* ~\u002F.claude\u002Frules\u002F\n",[15,40986,40987],{"__ignoreMap":183},[407,40988,40989,40992,40995,40997],{"class":409,"line":410},[407,40990,40991],{"class":554},"cp",[407,40993,40994],{"class":429}," rules\u002F",[407,40996,1540],{"class":476},[407,40998,40999],{"class":429}," ~\u002F.claude\u002Frules\u002F\n",[11,41001,41002,41003,41006,41007,41010],{},"看似简洁，实际破坏了整个结构。因为通用目录和语言目录里",[488,41004,41005],{},"存在同名文件","（都有 coding-style.md、testing.md 等），展开通配符时语言特定文件会直接覆盖通用规则。README.md 里的 ",[15,41008,41009],{},"..\u002Fcommon\u002F"," 相对引用也会断掉。",[11,41012,41013],{},"README 的安装指南明确了正确做法：",[399,41015,41017],{"className":11754,"code":41016,"language":11756,"meta":183,"style":183},"# 安装通用规则（所有项目必需）\ncp -r rules\u002Fcommon ~\u002F.claude\u002Frules\u002Fcommon\n\n# 安装语言特定规则\ncp -r rules\u002Ftypescript ~\u002F.claude\u002Frules\u002Ftypescript\ncp -r rules\u002Fpython ~\u002F.claude\u002Frules\u002Fpython\n# ...其他语言\n",[15,41018,41019,41024,41036,41040,41045,41057,41069],{"__ignoreMap":183},[407,41020,41021],{"class":409,"line":410},[407,41022,41023],{"class":528},"# 安装通用规则（所有项目必需）\n",[407,41025,41026,41028,41030,41033],{"class":409,"line":184},[407,41027,40991],{"class":554},[407,41029,24541],{"class":476},[407,41031,41032],{"class":429}," rules\u002Fcommon",[407,41034,41035],{"class":429}," ~\u002F.claude\u002Frules\u002Fcommon\n",[407,41037,41038],{"class":409,"line":189},[407,41039,1827],{"emptyLinePlaceholder":202},[407,41041,41042],{"class":409,"line":452},[407,41043,41044],{"class":528},"# 安装语言特定规则\n",[407,41046,41047,41049,41051,41054],{"class":409,"line":458},[407,41048,40991],{"class":554},[407,41050,24541],{"class":476},[407,41052,41053],{"class":429}," rules\u002Ftypescript",[407,41055,41056],{"class":429}," ~\u002F.claude\u002Frules\u002Ftypescript\n",[407,41058,41059,41061,41063,41066],{"class":409,"line":464},[407,41060,40991],{"class":554},[407,41062,24541],{"class":476},[407,41064,41065],{"class":429}," rules\u002Fpython",[407,41067,41068],{"class":429}," ~\u002F.claude\u002Frules\u002Fpython\n",[407,41070,41071],{"class":409,"line":470},[407,41072,41073],{"class":528},"# ...其他语言\n",[11,41075,41076],{},"不用通配符，逐个目录复制。这样保留了目录结构，相对引用才能生效。这是\"踩过才知道\"的约束——第一次装时很容易掉进去。",[26,41078,41079],{"id":41079},"显式标注可覆盖项",[11,41081,41082],{},"但通用规则和语言规则之间哪些会冲突、哪些是兼容的，仅靠约定不够。",[11,41084,41085,41086,781],{},"我在 README 里加了一个机制：",[488,41087,41088],{},"Language note 标记",[11,41090,41091],{},"在 common 目录里，可能被语言层覆盖的条目会标注一句：",[1205,41093,41094],{},[11,41095,41096,41099],{},[488,41097,41098],{},"Language note",": This rule may be overridden by language-specific rules for languages where this pattern is not idiomatic.",[11,41101,41102],{},"比如 common\u002Fcoding-style.md 的不可变性原则就带了这个标记。读规则的人一看到它，就知道\"这条原则大多数语言都遵守，但可能有例外\"。对应的语言目录里如果有覆盖，就形成了一个清晰的对话。",[11,41104,41105],{},"反过来，语言特定文件在覆盖时也要声明来源：",[1205,41107,41108],{},[11,41109,41110],{},"Idiomatic Go uses pointer receivers for struct mutation — see common\u002Fcoding-style.md for the general principle, but Go-idiomatic mutation is preferred here.",[11,41112,41113,41114,41117],{},"这样做的好处是",[488,41115,41116],{},"可搜索","。grep 一下 \"Language note\"，所有潜在冲突点一目了然。新增语言时也知道哪些条目是\"容易冲突的\"。",[26,41119,41121],{"id":41120},"规则与-skill-的分工","规则与 Skill 的分工",[11,41123,41124],{},"整套体系里还有一个角色是 skills——这个 2026 年 03 月的时间点还没有具体内容。",[11,41126,41127,41128,781],{},"但分工边界已经定好了：",[488,41129,41130],{},"规则定标准，Skill 定做法",[11,41132,41133],{},"规则说\"测试覆盖率必须 80%\"是标准。Skill 说\"怎样用 pytest 构造 fixtures，怎样用 mock 隔离依赖\"是方法。规则说\"禁止硬编码密钥\"是底线。Skill 说\"把密钥存进 .env，用 dotenv 库加载\"是工具链实现。",[11,41135,41136],{},"规则通常是范围更广的原则和检查清单——应用到多个技术栈，形成同一个文化基准线。Skill 是特定任务的深入指南，可能只给某一个语言或框架用。",[11,41138,41139],{},"规则层的文件相对稳定（改一次要同步到 13 个目录有成本，所以需要重大理由）。Skill 可以快速迭代（技术动作变了，Skill 立即补）。",[26,41141,15303],{"id":15303},[11,41143,41144],{},"77 个文件分成通用层、11 个语言目录和一套中文译本，看似复杂，实际是在规模和一致性之间的权衡。",[11,41146,41147],{},"关键的三个设计决策是：",[243,41149,41150,41153,41156],{},[126,41151,41152],{},"特定优先——语言规则可以覆盖通用规则，类似 CSS 特异性的模型",[126,41154,41155],{},"结构保护——禁用通配符安装，逐目录复制，保留相对引用的完整性",[126,41157,41158],{},"冲突可见——用 Language note 标记潜在的覆盖点，避免隐藏的假设",[11,41160,41161],{},"这套结构一旦理解清楚，就成为一个快速导航点：看 common 知道所有项目的基线，看具体语言目录知道专项微调，看标记知道哪里有例外。",[1267,41163,41164],{},"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}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}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}",{"title":183,"searchDepth":184,"depth":184,"links":41166},[41167,41168,41169,41170,41171,41172,41173],{"id":40902,"depth":184,"text":40903},{"id":40918,"depth":184,"text":40919},{"id":40946,"depth":184,"text":40947},{"id":40974,"depth":184,"text":40975},{"id":41079,"depth":184,"text":41079},{"id":41120,"depth":184,"text":41121},{"id":15303,"depth":184,"text":15303},"2026-03-30",{},"\u002F2026-03-30-77",{"title":40891,"description":40896},"2026-03-30-77个规则文件为什么一层通用规则不够用","通用规则会自相矛盾——Go 的指针接收器和\"始终返回新对象\"直接冲突，而后者对 TypeScript 是对的。这是分层的起因。",[41181,35470,41182,41183],"规则系统","代码标准","分层架构","VOb36qwNs4_qMXjkEcyX7qIRCQQvJMzYeTO_J4Duusw",{"id":41186,"title":41187,"body":41188,"column":2727,"date":41506,"description":41192,"extension":199,"hero_image":200,"meta":41507,"navigation":202,"path":41508,"seo":41509,"series_id":200,"severity":200,"stem":41510,"summary":41511,"tags":41512,"__hash__":41516},"posts\u002F2026-03-29-长文生成的上下文调度.md","写第 40 章，该喂哪些前文？",{"type":8,"value":41189,"toc":41491},[41190,41193,41196,41200,41203,41206,41209,41220,41223,41227,41230,41247,41250,41253,41264,41267,41271,41274,41277,41280,41284,41287,41290,41296,41299,41324,41327,41331,41334,41342,41345,41348,41352,41355,41359,41362,41365,41369,41372,41377,41391,41396,41410,41415,41426,41433,41436,41439,41453,41456,41470,41473,41476,41479,41482,41485,41488],[11,41191,41192],{},"每次生成小说的一章，调度器要做三个决策：注入哪些世界设定、哪些前文摘要、哪些结构约束。这不是一个简单的\"贪心召回\"问题，而是要在有限的上下文窗口内，权衡相关性、预算和一致性的三角形约束。",[26,41194,41195],{"id":41195},"调度问题的三个维度",[31,41197,41199],{"id":41198},"相关性召回真正需要的设定与前文","相关性：召回真正需要的设定与前文",[11,41201,41202],{},"生成第 10 章时，模型需要知道的背景信息远超过上下文容量。世界观有多条规则线，前文有 9 章的事件积累，人物有 10 多个角色的状态变化，每一项都可能与当前章节相关。",[11,41204,41205],{},"相关性检索的核心是\"这些东西与当前章节生成任务有多大的影响\"。这不只是 Qdrant 的向量相似度匹配。一条\"主角接到任务时必须穿过北城门\"的世界规则，与其他几条规则的组合方式，决定了它对第 10 章是否真正有约束力。",[11,41207,41208],{},"系统里的做法是把世界观资源分层：",[123,41210,41211,41214,41217],{},[126,41212,41213],{},"硬约束层：世界核心规则、人物基础设定、已确定的事实",[126,41215,41216],{},"软约束层：前文原文、场景描写、对话",[126,41218,41219],{},"可选层：已排除的假设、备选方向",[11,41221,41222],{},"向量检索时，应该先检索硬约束层，再按优先级检索软约束层，最后考虑可选层。这个顺序的反转会导致\"生成偏离世界规则但文采很好\"的常见问题。",[31,41224,41226],{"id":41225},"预算在窗口限额内排序","预算：在窗口限额内排序",[11,41228,41229],{},"假设模型上下文窗口是 8K tokens，去掉系统 prompt、生成目标、章节大纲，实际可用空间约 6K。这 6K 要装下：",[123,41231,41232,41235,41238,41241,41244],{},[126,41233,41234],{},"世界观摘要（500-800 tokens）",[126,41236,41237],{},"相关人物设定（300-500 tokens）",[126,41239,41240],{},"前 N 章摘要（1000-2000 tokens）",[126,41242,41243],{},"本章拆细（200-300 tokens）",[126,41245,41246],{},"写法约束（200-300 tokens）",[11,41248,41249],{},"就算每项都精简，也很容易超出。这时候需要动态预算：系统估算本章的生成复杂度（新人物出现？大事件转折？），动态调整各部分的分配。",[11,41251,41252],{},"简单的做法是按字数均分，结果通常是浪费。好的做法是：",[243,41254,41255,41258,41261],{},[126,41256,41257],{},"把硬约束固定分配（不能省）",[126,41259,41260],{},"把相关性最高的前文摘要（不是原文）作为主体",[126,41262,41263],{},"其余空间留给可选背景",[11,41265,41266],{},"前文不用原文，用摘要。这是关键优化。一章正文 2000 字，用 200 字的摘要（关键事件 + 角色状态变化 + 留下的悬念）可以承载 80% 的必要信息，剩下 20% 的细节（某个对话的原词、场景的视觉细节）在绝大多数情况下不影响后续章节的生成一致性。",[31,41268,41270],{"id":41269},"一致性跨章节事实的映射","一致性：跨章节事实的映射",[11,41272,41273],{},"第 10 章里模型可能生成\"某交易的金额是 5000 元\"，第 11 章的调度器需要知道这个信息。但如果第 10 章生成了两个版本（一个 5000 元，一个 6000 元），调度器用了不同的版本，第 11 章可能会自相矛盾。",[11,41275,41276],{},"这是故事\"越写越散\"的根本原因。传统做法是写完全文后回头检查一致性，那时候改得很贵。好的做法是在每章生成后，立即抽取\"硬事实\"（人物位置、已完成交易、确定的时间戳、确认过的背景细节），写入一个\"事实账本\"，下一章生成时优先查这个账本。",[11,41278,41279],{},"这不是自然语言的摘要，而是结构化的事实提取：交易金额、时间点、位置坐标、角色关系状态。第 11 章的模型看到的前文摘要里，这些数字必须与事实账本对齐。",[26,41281,41283],{"id":41282},"langgraph-的角色流程与状态","LangGraph 的角色：流程与状态",[11,41285,41286],{},"LangGraph 的工作是管理整个调度流程和状态转移。它不生成小说，而是组织生成的前置条件。",[11,41288,41289],{},"一个最小的调度 DAG 是这样的：",[399,41291,41294],{"className":41292,"code":41293,"language":1327},[1325],"检索相关世界规则 → 检索前文摘要 → 检索事实账本\n            ↓\n        动态预算分配\n            ↓\n        确定最终上下文\n            ↓\n        调用生成模型\n            ↓\n        检测一致性问题\n            ↓\n    是否需要修复 → 是 → 重新调度 + 重新生成\n            ↓ 否\n        提取并记录新事实\n            ↓\n        状态转移到下一章\n",[15,41295,41293],{"__ignoreMap":183},[11,41297,41298],{},"LangGraph 在这个流程中的价值是：",[243,41300,41301,41307,41313,41319],{},[126,41302,41303,41306],{},[488,41304,41305],{},"可观察性","：每一步的输入输出都记录，可以回放整个调度决策过程",[126,41308,41309,41312],{},[488,41310,41311],{},"可恢复性","：如果中间某一步失败（比如生成模型超时），可以从失败点恢复，不用重新调度前面所有步骤",[126,41314,41315,41318],{},[488,41316,41317],{},"条件分支","：一致性检测失败时，可以触发自动修复流程，而不是简单地重试整个生成",[126,41320,41321,41323],{},[488,41322,8701],{},"：跨多章的生成是有顺序的，章 N 的状态会影响章 N+1 的调度。LangGraph 管理这个状态链",[11,41325,41326],{},"代码层面，这意味着每个节点是一个独立的可调用单元，节点间通过状态对象通信。比如\"动态预算分配\"这个节点，它的输入是\"已检索的资源列表 + 本章复杂度评分\"，输出是\"每个资源的分配 token 数\"，这个输出成为下一个节点的输入。",[26,41328,41330],{"id":41329},"qdrant-的角色相关性与召回","Qdrant 的角色：相关性与召回",[11,41332,41333],{},"Qdrant 存储两类向量：",[243,41335,41336,41339],{},[126,41337,41338],{},"世界观资源的向量化（规则、势力、地点、角色设定）",[126,41340,41341],{},"历史章节的向量化（章节摘要、关键事件、角色状态快照）",[11,41343,41344],{},"调度时，系统用当前章节的\"生成任务\"（比如\"主角在封闭病区内发现病历记录不一致\"）作为查询向量，去 Qdrant 检索。",[11,41346,41347],{},"检索策略分两层：",[31,41349,41351],{"id":41350},"第一层硬约束检索","第一层：硬约束检索",[11,41353,41354],{},"用专有字段（not 向量相似度）精确匹配。比如\"这章涉及的人物有：角色 A、角色 B\"，那么系统直接从数据库里取这两个角色的设定，不走向量检索。硬约束必须 100% 准确。",[31,41356,41358],{"id":41357},"第二层软上下文检索","第二层：软上下文检索",[11,41360,41361],{},"用向量相似度检索前文摘要、相关规则、可用场景要素。设置相似度阈值（比如 0.7），只返回真正相关的资源。",[11,41363,41364],{},"一个常见的优化是使用混合检索：用关键词和向量相似度结合。比如检索\"与角色 A 相关的前文事件\"，既用 BM25 匹配\"角色 A\"这个关键词，又用向量相似度找相似的事件类型。这能减少\"语义相关但实际无关\"的召回。",[26,41366,41368],{"id":41367},"优先级策略硬约束优先","优先级策略：硬约束优先",[11,41370,41371],{},"一个实际的优先级排序是这样的：",[11,41373,41374],{},[488,41375,41376],{},"第一梯队（固定，必须纳入）",[123,41378,41379,41382,41385,41388],{},[126,41380,41381],{},"当前章节涉及的所有人物的最新设定",[126,41383,41384],{},"上一章的事实账本",[126,41386,41387],{},"本章任务单里的硬约束（\"主角必须提到过的某个承诺\"）",[126,41389,41390],{},"世界核心规则中与本章有直接冲突关系的部分",[11,41392,41393],{},[488,41394,41395],{},"第二梯队（按相关性，逐个加入直到预算用尽）",[123,41397,41398,41401,41404,41407],{},[126,41399,41400],{},"历史章节中提到过的相关地点描写",[126,41402,41403],{},"相关势力或人物的行动线",[126,41405,41406],{},"前 3 章的事件时间线",[126,41408,41409],{},"可能在本章触发的伏笔提示",[11,41411,41412],{},[488,41413,41414],{},"第三梯队（可选，仅在预算充足时加入）",[123,41416,41417,41420,41423],{},[126,41418,41419],{},"其他角色的背景故事",[126,41421,41422],{},"世界观的附加说明",[126,41424,41425],{},"参考范本",[11,41427,41428,41429,41432],{},"这个优先级的核心原则是：",[488,41430,41431],{},"生成一致性 > 生成创意","。第二梯队和第三梯队是为了提升文章质量，但如果加入它们会导致硬约束无法完整表达，就必须删掉。",[26,41434,41435],{"id":41435},"一致性检测与修复流程",[11,41437,41438],{},"生成完成后，系统会用一个检测模块检查：",[243,41440,41441,41444,41447,41450],{},[126,41442,41443],{},"本章提到的人物位置与上章是否矛盾",[126,41445,41446],{},"本章提到的时间戳是否与事实账本对齐",[126,41448,41449],{},"本章是否违反了硬约束规则",[126,41451,41452],{},"本章是否引入了未设定过的背景信息",[11,41454,41455],{},"如果检测到矛盾，调度器不是简单地重新生成，而是定向修复：",[123,41457,41458,41461,41464,41467],{},[126,41459,41460],{},"位置矛盾：部分改写那个段落，保持其他内容",[126,41462,41463],{},"时间矛盾：调整时间戳，保持事件顺序",[126,41465,41466],{},"规则违反：提示模型重新考虑那个场景",[126,41468,41469],{},"未设定背景：或纳入世界设定，或要求模型移除",[11,41471,41472],{},"这需要生成模块支持\"部分修复\"而不是\"全盘重试\"。全盘重试成本高且容易导致连锁问题（修好了位置矛盾，却引入了新的对话矛盾）。",[26,41474,41475],{"id":41475},"现有系统的实现参考",[11,41477,41478],{},"项目里已经实现了的相关部分：",[11,41480,41481],{},"\"本书世界、角色、拆书、知识库联动\"模块说明系统已经在做这些事：世界观能生成世界骨架和世界手册，角色准备会结合势力倾向和世界规则，知识库文档能回灌到规划和正文生成。这是调度器的底层数据来源。",[11,41483,41484],{},"\"章节定稿时自动抽取正文中的关键硬事实并写入事实账本，下一章生成时即可读到真实前文\"这一项正是上面说的事实账本机制，系统已经开始做了。",[11,41486,41487],{},"\"修复懒规划（JIT）模式下结构化大纲步骤被误报\"未产出\"的问题\"说明系统在处理的一个具体问题是：生成任务和实际输出的不对齐。调度器需要理解\"这一步预期输出什么\"和\"实际输出了什么\"之间的差异。",[11,41489,41490],{},"一致性和失败处理这两个难点，已经不是理论讨论，而是项目里正在打磨的工程问题。调度器的价值体现在能多快地从这些失败中恢复。",{"title":183,"searchDepth":184,"depth":184,"links":41492},[41493,41498,41499,41503,41504,41505],{"id":41195,"depth":184,"text":41195,"children":41494},[41495,41496,41497],{"id":41198,"depth":189,"text":41199},{"id":41225,"depth":189,"text":41226},{"id":41269,"depth":189,"text":41270},{"id":41282,"depth":184,"text":41283},{"id":41329,"depth":184,"text":41330,"children":41500},[41501,41502],{"id":41350,"depth":189,"text":41351},{"id":41357,"depth":189,"text":41358},{"id":41367,"depth":184,"text":41368},{"id":41435,"depth":184,"text":41435},{"id":41475,"depth":184,"text":41475},"2026-03-29",{},"\u002F2026-03-29",{"title":41187,"description":41192},"2026-03-29-长文生成的上下文调度","长篇章节生成的关键难点不是单章质量，而是如何在上下文窗口限额内，调度出真正有用的设定与前文，同时保证事实一致性。",[41513,41514,41515,16799,28285],"LangGraph","Qdrant","长文生成","YRn6EjAjuSaVKBUxU-M6NdHxM64vn65qHHjuLA97xdA",{"id":41518,"title":41519,"body":41520,"column":2727,"date":41871,"description":41524,"extension":199,"hero_image":200,"meta":41872,"navigation":202,"path":41873,"seo":41874,"series_id":200,"severity":200,"stem":41875,"summary":41876,"tags":41877,"__hash__":41883},"posts\u002F2026-03-22-写法引擎把文风编译成Prompt.md","把「写得像谁」变成机器能执行的参数",{"type":8,"value":41521,"toc":41857},[41522,41525,41528,41532,41535,41542,41549,41569,41572,41576,41579,41582,41585,41588,41592,41595,41598,41605,41622,41625,41629,41632,41650,41653,41660,41666,41673,41679,41685,41688,41692,41695,41698,41704,41707,41710,41713,41717,41720,41723,41730,41741,41745,41748,41751,41754,41757,41761,41764,41770,41773,41780,41783,41789,41792,41799,41803,41806,41812,41818,41825,41832,41839,41841,41844,41847],[11,41523,41524],{},"\"写得像某种风格\"是一个模糊需求。如果直接把这句话塞进 Prompt，效果不稳定——有时模型听，有时回到八股腔。问题不在于模型，而在于没有把文风\"编译\"成机器能执行的指令。",[11,41526,41527],{},"写法引擎的核心思路就是把这个环节工程化：文风分析 → 结构化规则 → 规则编译 → Prompt 注入 → 输出检测 → 修正重写，形成一条稳定的流水线。",[26,41529,41531],{"id":41530},"为什么文风不能直接写进-prompt","为什么文风不能直接写进 Prompt",[11,41533,41534],{},"问题出在两个地方。",[11,41536,41537,41538,41541],{},"一是",[488,41539,41540],{},"粒度过粗","。\"这本书读起来要冷一点、现实一点\"，听起来很清楚，但模型理解\"冷\"有十种方式——删除心理描写、压低情绪、用短句、用第三人称、减少对话。到底要哪几种的什么强度组合，模型不知道。",[11,41543,41544,41545,41548],{},"二是",[488,41546,41547],{},"可复用性差","。每次生成都要用自然语言重新描述一遍文风，费时，还容易描述不一致。A 次说\"口语化\"\"有脏话\"，B 次说\"粗糙\"\"不文艺\"，两个描述指的是同一个东西，但模型当成两套规则执行，结果就漂移。",[11,41550,41551,41552,18211,41555,41558,41559,433,41562,433,41565,41568],{},"解决方案是把文风从",[488,41553,41554],{},"描述层",[488,41556,41557],{},"执行层","分开：用户面对的是自然语言的描述（\"我想要底层循环现实流的感觉\"），系统底层处理的是结构化规则（",[15,41560,41561],{},"register: colloquial",[15,41563,41564],{},"allow_self_reflection: false",[15,41566,41567],{},"emotion_expression: behavior_only","），两者通过编译器连接。",[26,41570,41571],{"id":41571},"流水线的六个环节",[31,41573,41575],{"id":41574},"环节一写法资产化","环节一：写法资产化",[11,41577,41578],{},"输入：拆书分析、参考文本、手工描述、内置模板。",[11,41580,41581],{},"输出：一份写法资产，包含叙事规则、人物表达规则、语言规则、节奏规则。",[11,41583,41584],{},"这一环的职责是\"发现写法\"。可以从已有拆书的\"文风与技法\"章节一键生成，也可以粘贴喜欢的文本让系统反推，或者直接从模板（底层循环现实流、悬疑压迫递增流、冷峻专业流）起步。",[11,41586,41587],{},"关键是让结果都进入同一套数据结构，而不是让每种来源产生格式各异的描述。",[31,41589,41591],{"id":41590},"环节二规则标准化","环节二：规则标准化",[11,41593,41594],{},"输入：来自上一环的原始规则描述。",[11,41596,41597],{},"输出：统一格式的字段集，每个字段有明确的取值范围。",[11,41599,41600,41601,41604],{},"这一环要解决的是",[488,41602,41603],{},"去歧义","。拆书可能写\"语言兼具哲理与口语\"，用户手工写的可能是\"别太文青\"，这两句指的可能是同一个规则，也可能指三个不同的规则。标准化器负责把这些模糊描述转成内部字段：",[123,41606,41607,41612,41617],{},[126,41608,41609],{},[15,41610,41611],{},"language.register = mixed",[126,41613,41614],{},[15,41615,41616],{},"allow_philosophy = true",[126,41618,41619],{},[15,41620,41621],{},"philosophy_embedding = dialogue_or_action_only",[11,41623,41624],{},"规则标准化也负责补全缺失项。如果用户没说对话风格，系统给一个默认值而不是留空，保证后续编译环节永远有完整输入。",[31,41626,41628],{"id":41627},"环节三规则编译","环节三：规则编译",[11,41630,41631],{},"输入：标准化后的规则字段 + 任务类型 + 绑定权重。",[11,41633,41634,41635,433,41638,433,41641,433,41644,433,41647,14432],{},"输出：分块的 Prompt 片段（",[15,41636,41637],{},"context_block",[15,41639,41640],{},"style_rule_block",[15,41642,41643],{},"anti_ai_block",[15,41645,41646],{},"output_block",[15,41648,41649],{},"self_check_block",[11,41651,41652],{},"这是流水线的核心。编译器不是直接把 JSON 塞给模型，而是把每一块规则翻译成模型最容易执行的自然语言形式。",[11,41654,41655,41656,41659],{},"例如，对于禁止型规则（",[15,41657,41658],{},"forbid_explicit_psychology: true","），编译器生成：",[399,41661,41664],{"className":41662,"code":41663,"language":1327},[1325],"禁止直接解释人物心理，不得使用\"他感到\"\"他意识到\"等句式。人物状态必须通过动作、对话、环境反应体现。\n",[15,41665,41663],{"__ignoreMap":183},[11,41667,41668,41669,41672],{},"对于软规则（",[15,41670,41671],{},"allow_useless_details: true","），编译器用不同的措辞：",[399,41674,41677],{"className":41675,"code":41676,"language":1327},[1325],"优先加入无意义但真实的小动作，以增强生活感。\n",[15,41678,41676],{"__ignoreMap":183},[11,41680,41681,41682,41684],{},"一个关键的设计选择是",[488,41683,35055],{},"。不是把所有规则平铺在一个 Prompt 里，而是按层次组织：基础上下文层（说清现象）→ 写法规则层（怎么写）→ 角色校正层（角色怎么说话）→ 反 AI 约束层（不要这样写）→ 输出格式层（生成什么样的结果）→ 自检指令层（写完了检查一遍）。",[11,41686,41687],{},"这样做的好处是，不同任务可以选择性使用这些块。大纲生成阶段可能只要轻量提示，章节正文生成需要完整约束，修正任务需要强化反 AI 块——同一套规则，根据任务自适应编译。",[31,41689,41691],{"id":41690},"环节四生成执行","环节四：生成执行",[11,41693,41694],{},"输入：编译后的 Prompt 块 + 用户输入 + 任务参数。",[11,41696,41697],{},"输出：模型生成的文本。",[11,41699,41700,41701,30517],{},"这个环节看起来最简单（就是调用 API），但涉及一个工程决策：",[488,41702,41703],{},"单轮还是双轮",[11,41705,41706],{},"单轮模式就是直接生成。快，成本低，适合尝试性写作。",[11,41708,41709],{},"双轮模式是先生成、再检测、若违规则进入修正生成。慢一点，成本高一倍，但稳定性显著更好，适合关键章节或风格敏感任务。",[11,41711,41712],{},"大多数情况下的选择是：正文生成默认单轮（靠 Prompt 约束压住），AI 味明显的情况再自动进入双轮修正。",[31,41714,41716],{"id":41715},"环节五输出检测","环节五：输出检测",[11,41718,41719],{},"输入：模型生成的文本 + 当前绑定的反 AI 规则。",[11,41721,41722],{},"输出：检测报告，标记出每条违规规则、违规位置、建议修正。",[11,41724,41725,41726,41729],{},"检测的难点不在于关键词匹配——检测\"他感到\"很简单。难的是",[488,41727,41728],{},"全局维度","的检测：段落长度是否过于整齐、句式重复率是否过高、连续几段是否都是解释没有动作、对话是否纯功能性。这些需要统计分析，不是正则。",[11,41731,41732,41733,41736,41737,41740],{},"更难的是",[488,41734,41735],{},"准度与召回的平衡","。严太了，文章被误报，用户体验差；松了，真正的 AI 味漏过去。实践中的做法是用风险评分代替二值判决：",[15,41738,41739],{},"risk_score = 76","，超过阈值才自动触发修正建议。",[31,41742,41744],{"id":41743},"环节六修正重写","环节六：修正重写",[11,41746,41747],{},"输入：原文 + 检测报告 + 当前写法规则。",[11,41749,41750],{},"输出：修正后的文本。",[11,41752,41753],{},"这一环的目标是\"只修违规点，不伤原文信息\"。所以 Prompt 需要精确指定问题位置，而不是丢给模型一个\"去掉 AI 味\"的模糊指令。",[11,41755,41756],{},"检测报告会说：第三段的\"他感到一阵烦躁\"属于直接心理解释，建议改为行为化表达。修正 Prompt 就根据这个报告逐条生成修正指令，而不是重新生成整段。",[26,41758,41760],{"id":41759},"核心难点一致性与失败处理","核心难点：一致性与失败处理",[11,41762,41763],{},"写法引擎最容易翻车的地方有两个。",[11,41765,41766,41769],{},[488,41767,41768],{},"一致性问题","：写法可以绑定在多个层级——整本书、某一卷、某一章、某一角色视角、当前任务。这些绑定叠加时怎么合并？哪个优先级更高？",[11,41771,41772],{},"例如，书级绑定说\"口语化\"，章节绑定说\"压抑、慢节奏\"，角色绑定说\"主角嘴硬、不做自省\"。三套规则进入编译器时，应该是累加还是覆盖？如果都累加，会不会互相抵消？",[11,41774,41775,41776,41779],{},"实践中的答案是有",[488,41777,41778],{},"明确的优先级","：本次任务 > 角色视角 > 章节 > 卷 > 全书模板。同一类型的规则冲突时，高优先级覆盖低优先级；不冲突的规则进行累加。反 AI 规则通常只增不减。",[11,41781,41782],{},"但这个规则本身就是一个精细的平衡——优先级太严格会压死灵活性，太松散会导致混乱。这个值需要在实战中反复调整。",[11,41784,41785,41788],{},[488,41786,41787],{},"失败处理问题","：检测不准、修正过度。",[11,41790,41791],{},"检测报告说第三段存在\"段尾升华\"，建议删除。但什么是升华？\"生活终究教会了他……\"显然是。\"他停顿了一下\"就不是。模型看到检测报告后，有时会删过头——把所有总结性的句子都删掉，反而破坏了叙事完整性。",[11,41793,41794,41795,41798],{},"对策是",[488,41796,41797],{},"限制修正幅度","。修正 Prompt 里明确写：\"不改变事件事实，不新增核心剧情，仅修正表达方式\"。并且在修正后做差异对比——如果删除幅度超过某个阈值（比如删了 20% 的内容），就警告用户而不是直接替换。",[26,41800,41802],{"id":41801},"边界设计哪些参数化有效哪些反而失控","边界设计：哪些参数化有效，哪些反而失控",[11,41804,41805],{},"能参数化的，不是所有纬度都值得。",[11,41807,41808,41811],{},[488,41809,41810],{},"有效的参数","：语言风格（口语化程度、粗粝度）、情绪表达方式（行为化还是直说）、对话风格、节奏密度。这些参数改变后，Prompt 的改变直接对应输出的改变。",[11,41813,41814,41817],{},[488,41815,41816],{},"失控的参数","：故事推进单元、视角制度、收尾方式。看起来文风相关，但改了这些参数，实际上是在改故事结构。参数化它只会导致写法引擎越界，跟故事规划模块打架。",[11,41819,41820,41821,41824],{},"更微妙的是",[488,41822,41823],{},"中间地带","：POV 距离（文字层的叙事距离，比如第三人称与第一人称的心理距离）值得参数化，但 POV 制度（这本书是单视角还是多视角）不应该。语言节奏密度（段落长、句子长短）值得参数化，但故事节奏（高潮应该在哪一章）不应该。",[11,41826,41827,41828,41831],{},"这条线的判断标准是：",[488,41829,41830],{},"这个参数改了，是改表达形式，还是改故事结构？"," 前者属于写法引擎，后者属于规划模块。",[11,41833,41834,41835,41838],{},"实际执行中，写法引擎确实有不少字段越界了——例如 ",[15,41836,41837],{},"narrativeRules.progressionMode","，这明显是结构职责，不是表达职责。长期的做法是把这些字段降权或迁出，但短期为了兼容旧资产，可能还要保留。这时就需要在编译阶段明确标记：这个字段的权重很低，主要用于兼容，不作为新增规则源。",[26,41840,15303],{"id":15303},[11,41842,41843],{},"写法引擎把文风从\"模糊描述\"转成\"可执行约束\"的关键，不是更多的规则字段，而是一条完整的、有检测反馈的流水线。",[11,41845,41846],{},"每个环节都有明确的输入输出合同：资产化产生结构化规则，标准化统一字段，编译器生成 Prompt 块，执行生成文本，检测标记问题，修正针对性重写。",[11,41848,41849,41850,41853,41854,41856],{},"核心难点不是单点的生成质量，而是",[488,41851,41852],{},"一致性","（多层规则怎么协调）与",[488,41855,18577],{},"（检测与修正的准度边界）。边界设计的平衡点在于：不是所有看起来相关的维度都值得参数化——参数化要为了改表达，不是用来绕过结构约束。",{"title":183,"searchDepth":184,"depth":184,"links":41858},[41859,41860,41868,41869,41870],{"id":41530,"depth":184,"text":41531},{"id":41571,"depth":184,"text":41571,"children":41861},[41862,41863,41864,41865,41866,41867],{"id":41574,"depth":189,"text":41575},{"id":41590,"depth":189,"text":41591},{"id":41627,"depth":189,"text":41628},{"id":41690,"depth":189,"text":41691},{"id":41715,"depth":189,"text":41716},{"id":41743,"depth":189,"text":41744},{"id":41759,"depth":184,"text":41760},{"id":41801,"depth":184,"text":41802},{"id":15303,"depth":184,"text":15303},"2026-03-22",{},"\u002F2026-03-22-prompt",{"title":41519,"description":41524},"2026-03-22-写法引擎把文风编译成Prompt","如何把模糊的文风描述结构化成可执行的 Prompt 约束，并通过检测与修正形成闭环。",[41878,41879,41880,41881,41882],"提示词工程","文风控制","AI生成","Prompt编译","反AI特征","MMuYUQN9ntUtAGoy0F9Nmms6XKsvsUmbNmt9bhRbpC0",{"id":41885,"title":41886,"body":41887,"column":2727,"date":42124,"description":41891,"extension":199,"hero_image":200,"meta":42125,"navigation":202,"path":42126,"seo":42127,"series_id":200,"severity":200,"stem":42128,"summary":42129,"tags":42130,"__hash__":42134},"posts\u002F2026-03-15-世界观管理把设定变成可检索知识.md","几十万字之后，设定得能被查出来",{"type":8,"value":41888,"toc":42116},[41889,41892,41895,41898,41901,41904,41907,41910,41913,41916,41923,41929,41946,41949,41956,41982,41985,41992,42003,42006,42013,42017,42020,42023,42029,42035,42041,42047,42053,42059,42065,42068,42071,42074,42077,42080,42083,42086,42089,42092,42098,42101,42104,42107,42110,42113],[11,41890,41891],{},"写长篇小说时，设定会不断膨胀。开篇两个势力、五个地点，写到中期变成十个势力、三十个地点，到高潮期可能累积上百个实体。传统做法是把所有设定平铺在一个大 wiki 或表格里，但这会带来两个致命问题：一是维护成本指数增长（修改一个地点的状态时，要手动检查有多少相关设定受影响），二是写作时精确召回成了噩梦（生成下一章内容时，模型必须在这一大堆设定里检索相关部分，但向量检索容易拉进大量噪音）。",[11,41893,41894],{},"正确的做法是把\"设定\"和\"剧情\"彻底分离成两套存储系统。设定层是稳定的约束与资源库，剧情层是演进的事实序列。两者各自维护，通过明确的接口连接。",[26,41896,41897],{"id":41897},"设定与剧情分离的本质",[11,41899,41900],{},"很多人一开始会混淆这两个概念。设定是\"世界允许什么、有什么资源、运行规则是什么\"，是相对稳定的。剧情是\"当前故事中发生了什么、谁去了哪里、状态如何变化\"，是演进的。",[11,41902,41903],{},"把它们混在一起后会发生什么？假设你在世界设定里写了\"某市立医院存在一个隐秘的观察病区\"。这是世界资源。后来在故事中，主角突然进入了这个病区并发现了一个秘密。这是剧情事实。如果你把两者混在同一个数据层，那么\"观察病区的状态\"就陷入了模糊：它是世界设定的一部分（永远存在），还是这部故事特有的激活（只在这本书的这一刻才被触发）？",[11,41905,41906],{},"这不仅造成维护混乱，更会直接影响检索。当你写第 n 章内容时，AI 需要知道\"当前可见的世界状态\"。如果设定和剧情混在一起，模型很难判断某个势力是\"世界中永远存在的\"还是\"仅在这部故事中被激活的\"。检索就会变成一个低效的向量搜索，大量拉进无关内容。",[11,41908,41909],{},"分离之后，事情就清晰了。世界管理层只负责存储所有可能的资源。故事层维护当前这部故事激活了哪些资源、状态如何演进。检索时，AI 不是在整个世界里搜，而是在当前故事的有效切片里查。这会让信息密度大幅提升，噪音大幅降低。",[26,41911,41912],{"id":41912},"世界管理的数据模型",[11,41914,41915],{},"世界管理层应该被结构化成五个层次，而不是一个混合大表单。",[11,41917,41918,41919,41922],{},"第一层是",[488,41920,41921],{},"世界概要","。这是入口级的元数据：世界名称、题材基底、时代背景、核心主题、整体基调。它的作用不是详细介绍世界，而是定调——让后续的所有资源围绕这个调性来设计。",[11,41924,41925,41926,41928],{},"第二层是",[488,41927,16504],{},"。这是最关键的一层。它定义了世界\"允许什么、不允许什么\"。分五类：",[123,41930,41931,41934,41937,41940,41943],{},[126,41932,41933],{},"现实规则：世界稳定还是脆弱，现实会不会扭曲",[126,41935,41936],{},"超常规则：异能是否存在、是否公开、普通人能否理解、使用是否有代价",[126,41938,41939],{},"生死规则：死亡可逆性如何、复活是否允许、代价是什么",[126,41941,41942],{},"信息规则：真相能否被完整获知、知识是否危险",[126,41944,41945],{},"叙事规则：禁用的剧情方向、不建议的设定组合",[11,41947,41948],{},"这一层是整个系统后续所有生成的上位约束。角色能力不能超过这里定义的 power ceiling，剧情推进方向不能违反这里的禁忌。",[11,41950,41951,41952,41955],{},"第三层是",[488,41953,41954],{},"资源库","。分四类：",[123,41957,41958,41964,41970,41976],{},[126,41959,41960,41963],{},[488,41961,41962],{},"阵营","：抽象的立场与意识形态（守秘派 vs 公开派、扩张派 vs 保守派）",[126,41965,41966,41969],{},[488,41967,41968],{},"势力","：可直接参与剧情的具体组织（医院管理层、地方调查局、地下教团）。势力隶属于阵营，但一个阵营可以有多个势力",[126,41971,41972,41975],{},[488,41973,41974],{},"地点","：能触发事件与限制行动的叙事场景。必须定义\"进入限制\"\"离开代价\"\"常见风险\"，否则就只是摆设",[126,41977,41978,41981],{},[488,41979,41980],{},"特殊要素","：物品、异常现象、禁忌知识、仪式、规则片段等。这些常常是剧情推进的机关和反转的触发器",[11,41983,41984],{},"关键点是，每个资源都必须有叙事价值。不能允许只填\"背景介绍\"。阵营要有\"核心立场和长期目标\"，势力要有\"具体的压迫手段\"（行政压制、舆论操控、暗中渗透、经济挤压、猎杀清洗等），地点要有\"限制性\"，要素要有\"被激活时的风险与收益\"。",[11,41986,41987,41988,41991],{},"第四层是",[488,41989,41990],{},"关系网络","。这是很多系统容易忽视的一层。光有资源还不够，资源之间的关系决定了世界是否\"活\"。包括：",[123,41993,41994,41997,42000],{},[126,41995,41996],{},"势力对立关系：某势力与另一势力的关系是敌对、同盟、渗透还是暂时中立",[126,41998,41999],{},"地点控制关系：哪个地点被哪个势力控制、控制类型是公开、隐蔽还是有争议",[126,42001,42002],{},"要素归属关系：特殊要素掌握在谁手中、是被拥有、封印、研究、崇拜还是压制",[11,42004,42005],{},"有了这层，系统就能自动推导冲突。设定者无需手动说\"这两个势力会打起来\"，系统能从关系图里看出\"这个势力威胁那个势力的核心利益\"。",[11,42007,42008,42009,42012],{},"第五层是",[488,42010,42011],{},"小说绑定接口","。这是最后也是最关键的一层。世界管理的全量资源对于单本小说来说，绝大多数是噪音。一部小说通常只需要激活世界中 10% 到 20% 的资源。小说绑定接口的职责就是：给定当前小说的初始信息（题材、一句话 premise、风格倾向），从世界资源库里筛选出\"本书最适合激活的部分\"，并将其压缩成一个标准输出对象。",[26,42014,42016],{"id":42015},"世界切片从全量设定到有效检索","世界切片：从全量设定到有效检索",[11,42018,42019],{},"这第五层就是我所说的\"世界切片\"（story world slice）。它是一个中间对象，标准结构化，专门为故事生成阶段设计。",[11,42021,42022],{},"一个完整的世界切片应该包含十个部分。",[11,42024,42025,42028],{},[488,42026,42027],{},"第一部分是核心世界框架","。这不是完整的世界观介绍，而是给下游生成系统一个气质底板：体裁、时代、基调、核心主题、针对这部故事的世界总结。比如\"现代都市克苏鲁悬疑背景，现实表层稳定但某些机构内存在异常污染\"。",[11,42030,42031,42034],{},[488,42032,42033],{},"第二部分是应用规则","。把世界规则翻译成\"当前小说必须遵守的规则\"。分三类：绝对不能违背的硬规则（超自然不能被大众公开验证、真相不可一次性完整揭示），建议遵守的软规则（叙事应以压迫感为主），明确禁止的方向（禁止直接写成全民超能力）。",[11,42036,42037,42040],{},[488,42038,42039],{},"第三、四、五部分是激活资源","。不是把世界的所有势力、地点、要素全都列出来，而是输出\"本书当前阶段建议激活的\"。每个资源要做\"叙事化翻译\"。比如，不只说\"市立精神康复中心管理层是一个势力\"，而是说\"这个势力在表面上是主角的工作权威来源，暗地里是压制异常外泄的封锁力量，它的压迫手段是制度压制和信息封锁，它在故事前期应该以制造困局的角色出现，中期暴露遮掩真相的动机\"。",[11,42042,42043,42046],{},[488,42044,42045],{},"第六、七部分是冲突与压力源","。这是故事生成模块最直接需要的东西。冲突候选列出所有可能的冲突轴（外部冲突、内部冲突、关系冲突），每个都说清楚源头、性质、故事价值。压力源列出所有能对主角施加持续压力的元素——势力的压迫、地点的限制、要素的风险。这两个字段会直接用于生成有真实张力的故事。",[11,42048,42049,42052],{},[488,42050,42051],{},"第八部分是谜团来源","。如果没有这个，故事很容易\"冲突有了，但钩子不强\"。你需要明确指出\"这个世界里，哪些东西适合成为核心悬念\"。比如\"为什么某个地方从未出现在正式建筑图纸里\"、\"为什么某份记录会写到主角尚未经历的事件\"。这些几乎可以直接变成故事的主钩子。",[11,42054,42055,42058],{},[488,42056,42057],{},"第九部分是故事轴","。指出这个世界切片最适合沿哪几条轴展开：主线轴（现实秩序对异常真相的压制）、副线轴（主角对自身精神状态的怀疑）、暗线轴（某种更高层存在的渗入）。这会直接帮助故事生成模块理解\"主线写什么、副线写什么、暗线藏什么\"。",[11,42060,42061,42064],{},[488,42062,42063],{},"第十部分是作用域边界","。这是为了防止故事开太大。明确说出\"主舞台限制在医院及周边\"\"前中期异常仅限少数角色察觉\"\"不得在前期扩展到全国级公开灾变\"。这个字段强制了后续所有内容生成的范围。",[26,42066,42067],{"id":42067},"检索策略的转变",[11,42069,42070],{},"有了这个结构，检索策略就从\"全量向量搜索\"彻底转变成了\"定向切片查询\"。",[11,42072,42073],{},"原来的做法是：每次生成一章时，把整个世界设定作为上下文喂给模型，让模型自己做向量检索来找相关信息。这样做的问题是，距离度量本身不够精确。一个关于\"医院走廊的灯光\"的描写，向量上可能和\"病患的精神崩坏\"距离很近（都涉及感知失真），但叙事上它们是完全不同的东西。模型容易过度检索，导致上下文被无关设定噪音填满。",[11,42075,42076],{},"新做法是：系统在故事初始化时就运行一次\"世界切片提取\"，生成 story_world_slice 对象。之后每一章的生成都基于这个切片。这个切片只包含三类信息：",[11,42078,42079],{},"第一类是\"本书激活的资源\"。系统明确知道\"这部故事里只有这些势力会出现、只有这些地点是可达的、只有这些要素可以使用\"。生成时不会去翻其他的。",[11,42081,42082],{},"第二类是\"已建立的规则与关系\"。这些是写到第 n 章时已经暴露给读者的信息，或者系统已经决定在这一章内暴露的信息。比如\"医院管理层正在隐瞒某件事\"这个关系已经确立，那么后续章节的生成就必须保持一致。",[11,42084,42085],{},"第三类是\"未来可用的线索与反转\"。这是还未在故事中激活、但被标记为\"适合在后续章节用作推进机关\"的要素。比如\"某份病历的异常\"这个要素被标记为\"early reveal\"，系统就知道它应该在故事前期被发现，而不是突然蹦出来。",[11,42087,42088],{},"这三类信息加起来，大幅缩小了每一步生成的搜索空间。模型不再是在一个模糊的向量空间里摸索，而是在一个明确的、结构化的、经过筛选的知识空间里工作。信息密度飙升，内容一致性也显著提升。",[26,42090,42091],{"id":42091},"流水线中的位置",[11,42093,42094,42095,781],{},"这个模块在写作流水线中充当的角色很明确：",[488,42096,42097],{},"从全量世界资源，产出单部小说的有效舞台",[11,42099,42100],{},"输入端连接世界管理系统（全量资源库）。输出端连接故事宏观规划系统（使用世界切片来生成故事引擎）。中间的处理包括：一次相关性筛选（根据小说的题材和 premise，判断哪些世界元素相关），一次叙事化翻译（把静态的资源数据转成\"这个势力会怎样压迫主角\"），一次冲突与压力的显式提取（让模型更容易理解这个世界的张力来自哪里）。",[11,42102,42103],{},"输入输出的契约很清晰。只要下游拿到了一个完整的 story_world_slice，它就能独立生成这部故事的框架，而无需再去翻世界库的原始资源。反过来，如果故事中期用户调整了\"要激活某个新势力\"或\"某个地点现在被另一个阵营控制了\"，系统也能增量地更新 story_world_slice，而不需要重新计算整个世界。",[11,42105,42106],{},"失败处理的关键点在两个地方。一是筛选环节，如果小说的 premise 与世界的规则产生硬冲突（比如小说想要\"全民都知道超自然现象\"，但世界规则禁止这个），系统应该明确报错，而不是默默尝试调和。二是叙事化翻译环节，如果某个世界资源无法被有意义地翻译成\"叙事价值\"（比如一个势力在这部故事中根本用不上），系统应该把它标记为\"disabled\"，而不是硬行包含进去。",[11,42108,42109],{},"一致性检查应该在每次更新 story_world_slice 时运行：确保激活的资源之间的关系是一致的（如果 A 势力被标记为\"active\"，而它隶属的阵营被标记为\"inactive\"，就是矛盾），确保应用规则没有被活跃资源违反（如果\"硬规则\"说\"真相不可一次性完整揭示\"，但某个要素被标记为\"complete revelation marker\"，就是矛盾）。",[26,42111,42112],{"id":42112},"最后的判断",[11,42114,42115],{},"建立这样一个系统的真正价值，在于它让\"世界设定\"变成了可机器读、可增量维护、可精确检索的知识。不是说必须从零开始设计这样的结构——许多作者的工作方法天然就接近这样的分离——而是说一旦显式地把它实现出来，维护和生成的效率会有质的跳变。特别是在 AI 辅助下，这种结构化会直接转化成更一致、更有张力的内容输出。",{"title":183,"searchDepth":184,"depth":184,"links":42117},[42118,42119,42120,42121,42122,42123],{"id":41897,"depth":184,"text":41897},{"id":41912,"depth":184,"text":41912},{"id":42015,"depth":184,"text":42016},{"id":42067,"depth":184,"text":42067},{"id":42091,"depth":184,"text":42091},{"id":42112,"depth":184,"text":42112},"2026-03-15",{},"\u002F2026-03-15",{"title":41886,"description":41891},"2026-03-15-世界观管理把设定变成可检索知识","长篇设定膨胀后无法精确召回。通过分层存储和世界切片策略，把稳定约束与演进事实分离，实现定向检索而非全量向量搜索。",[42131,20982,39410,42132,42133],"AI写作","数据模型","信息检索","VOA4YBD6mujfsQ1TEamMMB0wmrI_ZHC67mYhGq6wVVQ",{"id":42136,"title":42137,"body":42138,"column":2727,"date":42290,"description":42142,"extension":199,"hero_image":200,"meta":42291,"navigation":202,"path":42292,"seo":42293,"series_id":200,"severity":200,"stem":42294,"summary":42295,"tags":42296,"__hash__":42302},"posts\u002F2026-03-08-为什么聊天式续写写不出长篇.md","你写一句，AI 补一句——写不出长篇",{"type":8,"value":42139,"toc":42283},[42140,42143,42147,42150,42153,42156,42159,42162,42166,42169,42172,42175,42178,42182,42185,42188,42191,42194,42197,42199,42202,42205,42210,42213,42216,42221,42224,42227,42230,42235,42238,42241,42255,42258,42260,42263,42266,42269,42272,42275,42278,42280],[11,42141,42142],{},"聊天式续写是最直观的 AI 辅写方案：打开对话框，输入一句提示词，模型回你一段正文，满意就保存，不满意就重试。这套流程在短篇创作上表现尚可，但一旦进入长篇领域（十万字以上），就会陷入无法解决的困境。问题不在模型的生成质量本身，而在结构。",[26,42144,42146],{"id":42145},"缺陷一上下文窗口的遗忘曲线","缺陷一：上下文窗口的遗忘曲线",[11,42148,42149],{},"聊天式续写的工作方式很简单：每次写作时，把用户已有的全部上文（或者说上文的最近 N 个 token）和新的提示词一起送给模型。理论上讲，模型看到了完整的前文，应该能保持一致性。",[11,42151,42152],{},"但实际问题在于两个地方。",[11,42154,42155],{},"其一是窗口容量本身。一部长篇小说可能有 30 万字以上的内容。即便用最新一代模型，上下文窗口也往往只有 10 万到 20 万 token 的实用规模（留出空间给生成和提示词本身）。换句话说，越写到后面，模型能看到的\"完整前文\"就越来越残缺——它最多看到最近几十万字，之前的设定、伏笔、角色铺垫就开始消失在视野外。",[11,42157,42158],{},"其二是遗忘的渐进性。即便窗口足够大，模型对位置较远的信息的关注度也会衰减。一句在第 1 章出现的设定，到了第 10 章时，模型的 attention 权重已经显著下降。结果就是：前期精心铺设的世界规则、人物设定、暗线伏笔，在中后期写作时越来越容易被忽略。",[11,42160,42161],{},"因此，聊天式续写只要进入\"中段\"（通常是总体篇幅的 30%-60% 位置），就开始显现一个普遍现象：前面设定的某条规则突然被违背了，之前出现过的配角突然改了性格，世界背景的某个细节被自相矛盾地改写了。用户要么手动修正，要么放弃这个版本重来——但这套流程本质上没有解决这个问题，只是在打补丁。",[26,42163,42165],{"id":42164},"缺陷二缺乏全局结构导致的情节崩坏","缺陷二：缺乏全局结构导致的情节崩坏",[11,42167,42168],{},"更深层的问题在于，聊天式续写是完全\"局部最优\"驱动的。模型看到当前的情节状态和用户的续写提示，就生成看起来最合理的下一段——这个\"最合理\"完全是基于局部上下文的计算，跟整本书的宏观规划无关。",[11,42170,42171],{},"想象一个场景。设定中主角应该在第 10 章经历某个关键的认知转变。但因为聊天式续写没有全局蓝图，模型在写到第 5 章时，可能就把这个转变在某个对话里「提前透支了」——也许是通过某个配角无意中透露的线索，也许是主角在某个场景的突然顿悟。等真的写到第 10 章，用户设想的那个转变场景就显得多余或突兀了。",[11,42173,42174],{},"类似的崩坏层出不穷：主线推进的节奏被打乱（有的卷写得很快，有的卷反复打转），角色的成长弧线被割裂（某些关键的人物转折变成了无根据的 180 度掉头），冲突的堆积与释放失去均衡（后期要么冲突堆积无处释放，要么所有矛盾突然在一两章内全部坍塌）。",[11,42176,42177],{},"更棘手的是，这些问题往往到了中后期才会被察觉——因为短期内看起来每一段都是通顺的，只有当读者或作者从整体视角回顾时，才会发现故事的骨架扭曲了。而这时候修复的成本已经极高：可能需要推翻已写的大量内容，重新规划后续。",[26,42179,42181],{"id":42180},"缺陷三设定无法约束导致的规则漂移","缺陷三：设定无法约束导致的规则漂移",[11,42183,42184],{},"更底层的问题是：聊天式续写没有\"设定约束层\"。模型每次生成时，面对的是一个完全开放的选择空间——用户提交的最新提示词，加上看得到的部分前文，就是全部的约束。",[11,42186,42187],{},"这意味着什么？意味着即便你在开篇时精心设计了世界观（某个势力如何组织、什么力量受法律保护、什么信息不可能被大众所知），到了中期，这些设定在实际写作中就会悄悄漂移。",[11,42189,42190],{},"一个典型的例子：设定里\"超自然现象不可能被公众验证\"，但写到某个场景时，模型生成的内容里，主角突然在众目睽睽之下暴露了异常能力。这看起来是个一次性的错误，但更深层反映的是：模型没有对这条规则的持久的、可检索的认知。它只是在前文里看到了一次\"不可能被验证\"这样的提法，而到了新的场景，这条规则就从 attention 视野里消失了。",[11,42192,42193],{},"人物性格也一样会漂移。一个被设定为\"冷静内向、不会主动表露情感\"的角色，因为连续几章里剧情需要他说一些温情的话，模型就开始习惯性地让他说更多内心独白、做更多主动表白。几十章后，这个角色已经变成了\"偶尔有冷静时刻的热血少年\"——跟原设定判若两人，但这个漂移是不知不觉发生的。",[11,42195,42196],{},"对策是什么？不是让用户在每一次续写时重复提示\"记住这个角色设定\"，那样会导致提示词爆炸。而是需要一个独立的、可持久保存、可实时检索的\"设定库\"——世界观、规则、角色档案都不再嵌在对话文本里，而是作为独立资产存在，每次生成时被动态调用。",[28555,42198],{},[26,42200,42201],{"id":42201},"对应的系统化改造",[11,42203,42204],{},"上面这三个缺陷指向的不是\"用更好的模型\"或\"写更详细的提示词\"，而是结构性的架构问题。改造方向明确：",[11,42206,42207],{},[488,42208,42209],{},"第一层：世界管理与设定库",[11,42211,42212],{},"把世界观、规则、势力、地点、异常要素都独立存储成结构化资产。这不只是\"把设定写成文档\"，而是做成可检索、可版本控制、可进行相关性匹配的知识库。",[11,42214,42215],{},"关键是\"可检索\"。写到某一章时，系统能自动理解当前剧情的背景与参与角色，从全量的世界设定里抽取\"本章需要遵守的那部分规则\"。某个势力的细节不需要每次都喂给模型，但一旦这个势力在当前章节出现，相关的规则和背景就应该被自动调用。",[11,42217,42218],{},[488,42219,42220],{},"第二层：结构先于文本",[11,42222,42223],{},"长篇小说应该先完成宏观规划，再进行逐章生成。这个规划层不是给用户看的装饰性文档，而是真正的结构化输入，用来约束后续写作。",[11,42225,42226],{},"故事宏观规划应该包括：故事的核心冲突是什么，主角的成长路径分几个阶段，每个阶段的关键转折点在哪里，中间该如何铺垫、如何兑现。基于这个规划，再往下拆卷战略（这本书分几卷，每卷的 narrative goal 是什么），再往下拆章节目录（第几到第几章解决什么情节）。",[11,42228,42229],{},"每一层的拆解都是可见的、可调整的，而不是\"先写一阵子再看哪里崩坏了\"。",[11,42231,42232],{},[488,42233,42234],{},"第三层：生成时的双重约束",[11,42236,42237],{},"写作过程不再是\"提示词 + 前文 → 正文\"的单向过程，而是\"设定库 + 结构方案 + 前文 + 提示词 → 正文\"的多源约束模型。",[11,42239,42240],{},"当生成某一章的正文时：",[123,42242,42243,42246,42249,42252],{},[126,42244,42245],{},"世界设定库被查询，相关的规则和背景被检索进来",[126,42247,42248],{},"当前章节在结构中的位置被明确（这是第几章，在第几卷，对应的情节目标是什么）",[126,42250,42251],{},"这一章前面已发生的硬事实（前几章里明确发生过的交易、死亡、承诺）被提取出来，作为不可违背的约束",[126,42253,42254],{},"然后才是生成",[11,42256,42257],{},"这样的架构下，模型就不再是\"凭空补全\"，而是\"在已经被裁剪好的舞台里推进故事\"。",[28555,42259],{},[26,42261,42262],{"id":42262},"从聊天式到生产系统的转变",[11,42264,42265],{},"本质上，这是从\"对话工具\"向\"创作生产系统\"的转变。",[11,42267,42268],{},"聊天式续写的隐含假设是：模型和用户在对话中共同完成创作，每一步都是交互式的决策。这在短篇、或者用户有丰富创作经验时还能接受。但在长篇小说这样\"周期长、决策多、前后牵连复杂\"的场景，这个假设就破裂了。",[11,42270,42271],{},"一个更现实的假设是：创作本身有清晰的阶段和流程。开书定盘（明确想写什么、风格基调、目标读者）→ 世界设定（这个故事的舞台是什么样的）→ 宏观规划（故事从何开始、如何推进、在哪里高潮、怎样结尾）→ 章节拆解（把规划落实成具体章节、每章的目标、每章的主要内容）→ 逐章生成（按照章节目标和前文，生成这一章的内容）→ 质量控制（检查是否有设定冲突、逻辑断裂、风格不一致）。",[11,42273,42274],{},"这个流程中，每一步的输入和输出都是明确的。设定库是独立资产，结构方案也是独立资产，章节执行时既受结构约束，也受设定约束。出现问题时，可以在对应层修复，而不是\"推翻重来\"。",[11,42276,42277],{},"这也解释了为什么\"管理知识与设定（RAG）、控制写作风格与叙事一致性、最终生成完整章节甚至整本小说\"这样的能力，不能用聊天框搭出来——它们需要一个真正的生产系统来协调。",[28555,42279],{},[11,42281,42282],{},"对于新手创作者来说，聊天式续写的诱惑很大：看起来立刻能写，不用先花时间规划。但长篇的现实是残酷的——越是\"先写后想\"，后期的推倒重来成本就越高。而系统化的流程虽然前期看起来更复杂，反而是在为后续的顺畅推进铺路。这不是工程师思维强加给创作的约束，而是长篇故事本身的内在需求。",{"title":183,"searchDepth":184,"depth":184,"links":42284},[42285,42286,42287,42288,42289],{"id":42145,"depth":184,"text":42146},{"id":42164,"depth":184,"text":42165},{"id":42180,"depth":184,"text":42181},{"id":42201,"depth":184,"text":42201},{"id":42262,"depth":184,"text":42262},"2026-03-08",{},"\u002F2026-03-08",{"title":42137,"description":42142},"2026-03-08-为什么聊天式续写写不出长篇","聊天式续写在长篇小说上的三个结构性失效原因，与对应的系统化改造方向。",[42297,42298,42299,42300,20982,42301],"AI 创作","LLM 应用","系统架构","长篇生成","结构化规划","3gqAytNbAf3h3SEeppiy9mJE8Ef_HAeyqESaq_ajUhE",{"id":42304,"title":42305,"body":42306,"column":42410,"date":42411,"description":42310,"extension":199,"hero_image":200,"meta":42412,"navigation":202,"path":42413,"seo":42414,"series_id":200,"severity":200,"stem":42415,"summary":42416,"tags":42417,"__hash__":42422},"posts\u002F2025-09-14-那5%我不交给AI.md","那 5%，我不交给 AI",{"type":8,"value":42307,"toc":42404},[42308,42311,42314,42318,42321,42324,42327,42330,42333,42337,42340,42343,42346,42349,42352,42356,42359,42362,42365,42368,42375,42378,42381,42387,42398,42401],[11,42309,42310],{},"用 AI 写代码一年多，交出去的比例一直在涨。但有三类工作我始终自己做，不是还没轮到它们，是明确不交。",[11,42312,42313],{},"这条线比\"AI 能干什么\"更值得写。能力清单每隔几个月就过期一次，而边界为什么在这里，理由是稳定的。",[26,42315,42317],{"id":42316},"第一类架构设计","第一类：架构设计",[11,42319,42320],{},"不是说 AI 给不出架构方案——它给得出，而且通常看起来很合理：分层清晰、职责分明、扩展点齐全。",[11,42322,42323],{},"问题在于合理的方案可能是错的。",[11,42325,42326],{},"架构决策依赖两样东西：对业务往哪走的判断，以及对历史包袱的了解。前者在我脑子里都未必清楚，更不在上下文里；后者是一堆\"当初为什么这么做\"的历史，散落在几年的提交记录、废弃的分支、和某次线上事故之后加的一个 workaround 里。这些东西没法塞进对话。",[11,42328,42329],{},"于是会得到一个技术上自洽、但在具体处境里错误的方案。它不违反任何原则，只是不适合这个项目。而这类错误的代价是最高的——写错的函数改起来是几十行，选错的架构改起来是几个月。",[11,42331,42332],{},"我的做法是：架构自己定，定完让 AI 挑毛病。让它扮演一个不了解历史的新人来审——它确实就是。它提的问题里，一部分是我已经权衡过的（那说明我的文档没写清），一部分是我真的漏了。这个用法比让它出方案有价值得多。",[26,42334,42336],{"id":42335},"第二类它试了几轮还没解决的问题","第二类：它试了几轮还没解决的问题",[11,42338,42339],{},"这一类不是按任务性质划的，是按过程判定的。",[11,42341,42342],{},"同样是\"修一个 bug\"，有时候 AI 两三轮就搞定，有时候十轮还在原地打转。而事前看不出区别——难点常常不在描述里。所以先验分类没什么用，我改用一个事后判据：给它固定几轮，不收敛就自己接手。",[11,42344,42345],{},"不收敛的样子挺好认：反复改同一处、每轮都给一个新解释但都不对、或者开始建议重写一些明明无关的代码。最后那个信号最明确——它在扩大搜索范围，因为在原范围里已经找不到解了。",[11,42347,42348],{},"这时候接手，第一件事不是自己从头写，而是找它缺的那条信息。多轮不收敛几乎总意味着关键事实不在它能看到的范围内：一个没被提及的环境差异、一份没给它的日志、一个只有我知道的历史约定。补上那一条，往往剩下的它自己就能做完。",[11,42350,42351],{},"真正需要我从头写的情况很少。绝大多数\"AI 搞不定\"其实是\"我没给够\"。",[26,42353,42355],{"id":42354},"第三类极高风险操作","第三类：极高风险操作",[11,42357,42358],{},"发版、删数据、改生产配置。",[11,42360,42361],{},"这三件事的共同点不是难，是不可逆且影响面大。判断的依据不该是 AI 做得对不对——它大概率做得对——而是做错一次的代价。一个函数写错，测试会拦住，最差是回滚一个提交。一次删错数据，回滚的对象是用户的东西。",[11,42363,42364],{},"这里面的不对称很关键：AI 在这类操作上的正确率可能比我高（它不会漏步骤、不会记错顺序、不会因为做过一百遍就跳过检查）。但正确率高不代表可以授权，因为失败的代价不由它承担。",[11,42366,42367],{},"我确实有过一次事故：AI 删掉了数据。恢复它靠的是备份——我一直在做大量备份，那次照常有一份可用的。",[11,42369,42370,42371,42374],{},"事后我想的不是\"以后要更小心\"，而是另一件事：",[488,42372,42373],{},"如果那次没有备份，这个边界就根本不存在","。我之所以敢把大部分工作交出去，前提是错了能回来。没有兜底的授权不是信任，是赌博——而赌赢过几次的人最容易把它误认为信任。",[11,42376,42377],{},"所以这条边界真正的支撑不是我不让 AI 碰这些操作，而是我在它碰得到的地方都留了退路。前者是纪律，后者是基础设施。纪律会松，基础设施不会。",[26,42379,42380],{"id":42380},"三条线的共同点",[11,42382,42383,42384,781],{},"回头看这三类，划线的依据其实是同一个问题：",[488,42385,42386],{},"出错的时候，谁能发现，以及能不能回来",[123,42388,42389,42392,42395],{},[126,42390,42391],{},"架构错误：很晚才能发现，而且几乎回不来",[126,42393,42394],{},"多轮不收敛：立刻能发现（它自己在原地转圈），随时能回来",[126,42396,42397],{},"高风险操作：可能立刻发现，但回不回来取决于有没有备份",[11,42399,42400],{},"中间那一类之所以可以交给 AI 试，正是因为它失败得很明显、很便宜。而另外两类，一个是发现得太晚，一个是回不了头。",[11,42402,42403],{},"这个判据比\"哪些任务适合 AI\"更耐用。任务类型会变，模型能力会涨，但\"错了能不能发现、能不能回来\"这两个问题永远得先答。",{"title":183,"searchDepth":184,"depth":184,"links":42405},[42406,42407,42408,42409],{"id":42316,"depth":184,"text":42317},{"id":42335,"depth":184,"text":42336},{"id":42354,"depth":184,"text":42355},{"id":42380,"depth":184,"text":42380},"工具链观察","2025-09-14",{},"\u002F2025-09-14-5percentai",{"title":42305,"description":42310},"2025-09-14-那5%我不交给AI","绝大部分开发工作可以交给 AI，但有三类明确不交：架构设计、AI 多轮未解决的问题、极高风险操作。边界比能力更能说明判断力。",[42418,42419,42420,42421,11722],"AI 编程","工作边界","风险控制","备份策略","B4TC5bxguSoGpmQZ8NDmriLk4_0Iqgk9TWIJsitPCIQ",{"id":42424,"title":42425,"body":42426,"column":42410,"date":42549,"description":42430,"extension":199,"hero_image":200,"meta":42550,"navigation":202,"path":42551,"seo":42552,"series_id":200,"severity":200,"stem":42553,"summary":42554,"tags":42555,"__hash__":42559},"posts\u002F2025-07-20-五个模型我都熟只用一个.md","五个模型我都熟，只用一个",{"type":8,"value":42427,"toc":42542},[42428,42431,42434,42437,42440,42443,42446,42449,42452,42458,42461,42467,42473,42479,42485,42488,42491,42494,42497,42500,42503,42506,42512,42515,42518,42521,42524,42527,42533,42536,42539],[11,42429,42430],{},"从 o1 发布那会儿开始碰 AI，到现在快一年。Claude、GPT、Gemini、GLM、Kimi 这几家我都实际用过一段时间，不是试两句就走的那种熟。",[11,42432,42433],{},"然后日常固定只用其中一个。",[11,42435,42436],{},"这跟到处能看到的建议是反的——那些建议大意都是\"按任务选模型\"：长文档分析用这家、写代码用那家、要便宜用另一家。听起来很合理，是把每个任务都放到最擅长它的模型上。我试过这么干，后来放弃了。",[26,42438,42439],{"id":42439},"不是因为其他模型不行",[11,42441,42442],{},"得先把这句说清楚，否则下面全是偏见。",[11,42444,42445],{},"这五家在我用过的任务上都能干活，各自也确实有明显更强的地方。如果把单个任务孤立出来比，\"按任务分派\"的结论是对的——某类任务上换一家，产出确实更好一点。",[11,42447,42448],{},"问题是任务并不孤立存在。",[26,42450,42451],{"id":42451},"切换成本不在模型上",[11,42453,42454,42455,781],{},"真正的成本不是学会用一个新模型——那很快，几小时就能上手。成本在于",[488,42456,42457],{},"围绕单一工具沉淀下来的那些东西，换模型就得重建",[11,42459,42460],{},"至少有四样：",[11,42462,42463,42466],{},[488,42464,42465],{},"一套写规则的习惯。"," 用久了就知道这个模型对什么样的指令反应准，哪种表述会被忽略，哪些约束必须重复强调它才当真。这些不是通用的 prompt 技巧，是针对具体模型的。换一家，重新摸。",[11,42468,42469,42472],{},[488,42470,42471],{},"对它失败模式的直觉。"," 这一条最值钱，也最难迁移。用久了会形成一种预感：这个任务它大概会在哪一步跑偏、什么样的回答是\"它其实没懂但在硬答\"、哪种沉默意味着上下文不够。这种直觉让我能在它出错之前就介入。换模型之后，直觉全部失效——而且是无声失效，你不知道自己已经不准了，只会觉得\"最近怎么老出问题\"。",[11,42474,42475,42478],{},[488,42476,42477],{},"上下文的组织方式。"," 给多少、按什么顺序给、什么该放开头、什么放结尾、哪些信息其实是噪音。这些在不同模型上的最优解不一样。",[11,42480,42481,42484],{},[488,42482,42483],{},"命令与工作流的肌肉记忆。"," 这个最琐碎，但每天都在消耗。",[11,42486,42487],{},"四样加起来，换模型的真实代价是把这些重新长一遍。而收益，是某类任务上的边际提升。",[11,42489,42490],{},"这笔账在多数时候是不划算的。",[26,42492,42493],{"id":42493},"分派本身也有成本",[11,42495,42496],{},"还有一层容易被忽略：即使不算沉淀成本，\"按任务分派\"本身也不免费。",[11,42498,42499],{},"每个任务开始前多了一个决策：这个该给谁。这个决策不难，但它是持续的、高频的、而且经常做不准——因为一个任务的难点往往在做的过程中才暴露出来，事前分派依据的是对任务的初始判断，而初始判断经常是错的。",[11,42501,42502],{},"于是会出现更糟的情况：任务开到一半发现选错了，换一家重做。此时前面积累的上下文全部作废，因为它在另一个会话里。",[26,42504,42505],{"id":42505},"什么时候值得切换",[11,42507,42508,42509],{},"不是永远不换。判据是：",[488,42510,42511],{},"它在我的主力场景上有代际差距，而不是某个单点更强。",[11,42513,42514],{},"单点更强不值得动——比如某家的长文档处理明显好，但我一周只做两次长文档分析，为这个把主力工作流迁走，账算不平。",[11,42516,42517],{},"代际差距值得动——如果某家在\"理解一个中等复杂度的代码库并做出正确修改\"这件事上明显进了一档，那该换，因为这就是我 80% 的时间花的地方，沉淀成本会被摊平。",[11,42519,42520],{},"区别在于那个能力是不是压在主路径上。",[26,42522,42523],{"id":42523},"一个更一般的判断",[11,42525,42526],{},"这件事想通之后，我看工具选型的角度变了。",[11,42528,42529,42530],{},"以前会比参数、比榜单、比某几个 case 的产出质量。现在会先问另一个问题：",[488,42531,42532],{},"我能在这个东西上面沉淀多深？",[11,42534,42535],{},"能沉淀得深的工具，即使起点不是最强的那个，一年之后的实际产出也会超过一直在换的那种。因为沉淀是复利，而每次切换都把利息清零。",[11,42537,42538],{},"反过来，如果一个工具让我沉淀不下任何东西——每次用都像第一次用——那它多强都只是个临时的外援，不构成能力。",[11,42540,42541],{},"这也解释了为什么\"用哪个模型\"这个问题被问得太多，而\"你在它上面攒下了什么\"几乎没人问。后者才是差距真正拉开的地方。",{"title":183,"searchDepth":184,"depth":184,"links":42543},[42544,42545,42546,42547,42548],{"id":42439,"depth":184,"text":42439},{"id":42451,"depth":184,"text":42451},{"id":42493,"depth":184,"text":42493},{"id":42505,"depth":184,"text":42505},{"id":42523,"depth":184,"text":42523},"2025-07-20",{},"\u002F2025-07-20",{"title":42425,"description":42430},"2025-07-20-五个模型我都熟只用一个","Claude、GPT、Gemini、GLM、Kimi 都用熟了，日常固定只用一个。切换模型的真实成本不在学习模型本身，而在重建围绕它沉淀的一切。",[42418,42556,42557,10930,42558],"模型选型","工具链","迁移成本","RYfIUu2g7SajwWGxT566_tQuNPrz69n3WeD4dchs_ho",1789212572339]