[{"data":1,"prerenderedAt":1658},["ShallowReactive",2],{"\u002F2026-06-11-llm":3,"\u002F2026-06-11-llm-rel":722},{"id":4,"title":5,"body":6,"column":705,"date":706,"description":12,"extension":707,"hero_image":708,"meta":709,"navigation":710,"path":711,"seo":712,"series_id":708,"severity":708,"stem":713,"summary":714,"tags":715,"__hash__":721},"posts\u002F2026-06-11-LLM网关的账号鉴权与反绕过.md","用量要算钱，那密钥就不能给客户端",{"type":7,"value":8,"toc":692},"minimark",[9,13,17,22,25,119,122,133,170,248,251,255,270,343,348,355,359,362,367,392,396,399,407,415,421,436,440,443,446,491,494,497,510,513,516,519,640,643,646,672,678,681,688],[10,11,12],"p",{},"按用量计费的 LLM 中转服务面临一个根本矛盾：客户端是完全攻击者可控的代码，应用逻辑最终依赖模型 API，但鉴权与计费只能在服务端进行。一旦客户端能绕过网关直连上游，计费和配额就失效了——用户可以白嫖，也可以把你的中转当成代理给别人用。这不是\"更好的用户体验\"问题，而是商业模式破裂。",[14,15,16],"h2",{"id":16},"绕过的三条路径与对应防堵",[18,19,21],"h3",{"id":20},"路径-1客户端持有上游密钥","路径 1：客户端持有上游密钥",[10,23,24],{},"当前常见的方案是：桌面端登录后拿到平台的 API 密钥，本地保存，直接用这个密钥去请求上游模型。客户端代码大致像这样：",[26,27,32],"pre",{"className":28,"code":29,"language":30,"meta":31,"style":31},"language-typescript shiki shiki-themes github-light github-dark","\u002F\u002F 不安全的旧做法\nconst apiKey = readLocallyStoredApiKey(); \u002F\u002F 明文或\"加密\"存储\nconst response = await fetch('https:\u002F\u002Fupstream-api.example\u002Fv1\u002Fmessages', {\n  headers: { 'x-api-key': apiKey },\n  body: messagePayload\n});\n","typescript","",[33,34,35,44,69,95,107,113],"code",{"__ignoreMap":31},[36,37,40],"span",{"class":38,"line":39},"line",1,[36,41,43],{"class":42},"sJ8bj","\u002F\u002F 不安全的旧做法\n",[36,45,47,51,55,58,62,66],{"class":38,"line":46},2,[36,48,50],{"class":49},"szBVR","const",[36,52,54],{"class":53},"sj4cs"," apiKey",[36,56,57],{"class":49}," =",[36,59,61],{"class":60},"sScJk"," readLocallyStoredApiKey",[36,63,65],{"class":64},"sVt8B","(); ",[36,67,68],{"class":42},"\u002F\u002F 明文或\"加密\"存储\n",[36,70,72,74,77,79,82,85,88,92],{"class":38,"line":71},3,[36,73,50],{"class":49},[36,75,76],{"class":53}," response",[36,78,57],{"class":49},[36,80,81],{"class":49}," await",[36,83,84],{"class":60}," fetch",[36,86,87],{"class":64},"(",[36,89,91],{"class":90},"sZZnC","'https:\u002F\u002Fupstream-api.example\u002Fv1\u002Fmessages'",[36,93,94],{"class":64},", {\n",[36,96,98,101,104],{"class":38,"line":97},4,[36,99,100],{"class":64},"  headers: { ",[36,102,103],{"class":90},"'x-api-key'",[36,105,106],{"class":64},": apiKey },\n",[36,108,110],{"class":38,"line":109},5,[36,111,112],{"class":64},"  body: messagePayload\n",[36,114,116],{"class":38,"line":115},6,[36,117,118],{"class":64},"});\n",[10,120,121],{},"这个方案的问题在于：密钥是长期的、可移植的、不可吊销的。即使密钥被加密存储在本地，一个决心充分的用户可以解密、导出，然后用 curl 直连上游——根本不需要走你的客户端。更糟的是，这个密钥可能被多个用户共享（意外泄露或故意倒卖）。",[10,123,124,128,129,132],{},[125,126,127],"strong",{},"防堵方案","：不向客户端下发真实的上游密钥，只下发短期凭证。具体实现是引入一个专用的 ",[33,130,131],{},"llmToken","（不同于普通的 accessToken）：",[134,135,136,147,164],"ul",{},[137,138,139,142,143,146],"li",{},[125,140,141],{},"签发","：用户请求 ",[33,144,145],{},"\u002Fapi\u002Fllm\u002Ftoken"," 端点时，后端校验 accessToken 有效→用户处于激活状态→当前设备的 session 未被撤销，然后生成一个 JWT 格式的 llmToken，TTL 设为 1–2 小时（足以覆盖一次长对话，但不会无限期存活）。",[137,148,149,152,153,156,157,156,160,163],{},[125,150,151],{},"绑定","：llmToken 包含 ",[33,154,155],{},"userId","、",[33,158,159],{},"sessionId",[33,161,162],{},"deviceId"," 三个标识。前两个用于账号级的全局控制（用户被禁用、session 被远程踢下线），最后一个用于防倒卖（即使密钥被导出，也只能在绑定的设备上用）。",[137,165,166,169],{},[125,167,168],{},"校验","：每次 LLM 调用网关时，网关验证签名→拆出 userId\u002FsessionId\u002FdeviceId→实时复校用户状态与 session（不依赖 token 的 TTL）。",[26,171,173],{"className":28,"code":172,"language":30,"meta":31,"style":31},"\u002F\u002F 防堵后的安全做法\nconst llmToken = await fetchLlmToken(accessToken, deviceId); \u002F\u002F 短期 token\nconst response = await fetch('https:\u002F\u002Fgateway.example\u002Fapi\u002Fllm\u002Fv1\u002Fmessages', {\n  headers: { 'authorization': `Bearer ${llmToken}` },\n  body: messagePayload\n});\n",[33,174,175,180,200,219,240,244],{"__ignoreMap":31},[36,176,177],{"class":38,"line":39},[36,178,179],{"class":42},"\u002F\u002F 防堵后的安全做法\n",[36,181,182,184,187,189,191,194,197],{"class":38,"line":46},[36,183,50],{"class":49},[36,185,186],{"class":53}," llmToken",[36,188,57],{"class":49},[36,190,81],{"class":49},[36,192,193],{"class":60}," fetchLlmToken",[36,195,196],{"class":64},"(accessToken, deviceId); ",[36,198,199],{"class":42},"\u002F\u002F 短期 token\n",[36,201,202,204,206,208,210,212,214,217],{"class":38,"line":71},[36,203,50],{"class":49},[36,205,76],{"class":53},[36,207,57],{"class":49},[36,209,81],{"class":49},[36,211,84],{"class":60},[36,213,87],{"class":64},[36,215,216],{"class":90},"'https:\u002F\u002Fgateway.example\u002Fapi\u002Fllm\u002Fv1\u002Fmessages'",[36,218,94],{"class":64},[36,220,221,223,226,229,232,234,237],{"class":38,"line":97},[36,222,100],{"class":64},[36,224,225],{"class":90},"'authorization'",[36,227,228],{"class":64},": ",[36,230,231],{"class":90},"`Bearer ${",[36,233,131],{"class":64},[36,235,236],{"class":90},"}`",[36,238,239],{"class":64}," },\n",[36,241,242],{"class":38,"line":109},[36,243,112],{"class":64},[36,245,246],{"class":38,"line":115},[36,247,118],{"class":64},[10,249,250],{},"相比之下，即使用户拿到了 llmToken，这个 token 也只能在原设备上用 1–2 小时。过期后需要重新签发，而签发必须经过 accessToken 校验（意味着如果账号被禁用或 session 被撤销，签发会立即失败）。不再有永久密钥在客户端浮动。",[18,252,254],{"id":253},"路径-2直接改配置指向上游","路径 2：直接改配置指向上游",[10,256,257,258,261,262,265,266,269],{},"客户端代码中通常有一个 ",[33,259,260],{},"baseUrl"," 配置，指向网关：",[33,263,264],{},"https:\u002F\u002Fgateway.example\u002Fapi\u002Fllm","。攻击者可以修改客户端代码或配置文件，改成直接指向上游：",[33,267,268],{},"https:\u002F\u002Fupstream-api.example","。",[26,271,273],{"className":28,"code":272,"language":30,"meta":31,"style":31},"\u002F\u002F 攻击者修改配置后\nconst baseUrl = 'https:\u002F\u002Fupstream-api.example'; \u002F\u002F 绕过网关\nconst apiKey = storedToken; \u002F\u002F 不管这个 token 从哪来\nconst response = await fetch(`${baseUrl}\u002Fv1\u002Fmessages`, { ... });\n",[33,274,275,280,298,312],{"__ignoreMap":31},[36,276,277],{"class":38,"line":39},[36,278,279],{"class":42},"\u002F\u002F 攻击者修改配置后\n",[36,281,282,284,287,289,292,295],{"class":38,"line":46},[36,283,50],{"class":49},[36,285,286],{"class":53}," baseUrl",[36,288,57],{"class":49},[36,290,291],{"class":90}," 'https:\u002F\u002Fupstream-api.example'",[36,293,294],{"class":64},"; ",[36,296,297],{"class":42},"\u002F\u002F 绕过网关\n",[36,299,300,302,304,306,309],{"class":38,"line":71},[36,301,50],{"class":49},[36,303,54],{"class":53},[36,305,57],{"class":49},[36,307,308],{"class":64}," storedToken; ",[36,310,311],{"class":42},"\u002F\u002F 不管这个 token 从哪来\n",[36,313,314,316,318,320,322,324,326,329,331,334,337,340],{"class":38,"line":97},[36,315,50],{"class":49},[36,317,76],{"class":53},[36,319,57],{"class":49},[36,321,81],{"class":49},[36,323,84],{"class":60},[36,325,87],{"class":64},[36,327,328],{"class":90},"`${",[36,330,260],{"class":64},[36,332,333],{"class":90},"}\u002Fv1\u002Fmessages`",[36,335,336],{"class":64},", { ",[36,338,339],{"class":49},"...",[36,341,342],{"class":64}," });\n",[10,344,345,347],{},[125,346,127],{},"：配置不要让客户端掌握。关键的网关地址与上游地址都应该由服务端下发或在部署时写死，而不是嵌在客户端配置文件里。同时，客户端应该有一个\"配置验证\"环节——比如在启动时校验 baseUrl 是否和预期值匹配，或者网关做请求签名验证。",[10,349,350,351,354],{},"但更根本的防堵来自",[125,352,353],{},"路径 1 的解决方案","：即使改了 baseUrl，也改不了 llmToken 的签发机制。直连上游时，你没有有效的、绑定了 deviceId 的短期 token，请求会直接失败。上游服务也不认识你的 llmToken（它只认 sub2api 的密钥），所以伪造一个不可能成功。",[18,356,358],{"id":357},"路径-3复用他人凭据","路径 3：复用他人凭据",[10,360,361],{},"如果 llmToken 没有绑定，或绑定得太松散，用户 A 可以把自己的 token 分享给用户 B 用。这样用户 B 就能免费消耗用户 A 的额度，甚至整个平台的额度都被少数用户共享。",[10,363,364,366],{},[125,365,127],{},"：",[368,369,370,376,386],"ol",{},[137,371,372,375],{},[125,373,374],{},"deviceId 绑定","：llmToken 中包含发起签发请求的设备 ID。网关在验证 token 时，检查当前请求的设备 ID 是否和 token 中的 deviceId 一致。设备 ID 可以基于硬件特征（MAC 地址、CPU 序列号）或操作系统本地生成的 UUID。设备难以伪造（虽然 Electron 应用中可以被修改，但需要重新编译客户端）。",[137,377,378,381,382,385],{},[125,379,380],{},"sessionId 绑定","：llmToken 还绑定了当前登录的 session ID。如果用户 A 在设备 D1 登录，生成的 token 包含 ",[33,383,384],{},"sessionId='sess-abc'","。用户 B 不可能有相同的 sessionId（除非他也登录用户 A 的账号，但那时就真的是同一个账号了）。",[137,387,388,391],{},[125,389,390],{},"即时吊销","：单设备互踢（用户 A 在另一台设备登录时，D1 的 session 被撤销）或远程封禁都会立即生效。网关不仅校验 token 的签名和 TTL，还会查一遍数据库确认 session 未被撤销。所以即使倒卖者拿到了别人的 token，一旦源用户被禁或 session 被踢，这个 token 就废了。",[14,393,395],{"id":394},"网关的计费保障金额一致性的困境","网关的计费保障：金额一致性的困境",[10,397,398],{},"防住绕过只是第一步，还需要保证计费的完整性：请求被处理，就必须被正确计费；计费成功了，请求才能真正完成。否则会出现两类灾难：",[368,400,401,404],{},[137,402,403],{},"请求已发出但计费失败 → 白嫖了额度",[137,405,406],{},"计费成功但请求被中断 → 用户被重复扣费",[10,408,409,414],{},[125,410,411],{},[36,412,413],{},"待补：事务一致性"," 这涉及数据库事务、网关层幂等键设计等细节。当前素材中关于\"请求处理与计费如何保证在同一事务边界内\"的设计不足。",[10,416,417,418,366],{},"实践中采用的策略是 ",[125,419,420],{},"fail-closed",[134,422,423,426,433],{},[137,424,425],{},"余额查询走缓存（Redis 或进程内 LRU），TTL 设为 30–60 秒。命中且大于 0 就放行；命中且小于等于 0 就直接拒绝（HTTP 402）；未命中则同步查一次上游服务。",[137,427,428,429,432],{},"上游服务失败时（网络问题、超时）",[125,430,431],{},"拒绝该请求","（不放行，也不允许用户花钱）。这比\"放行再补扣\"更宁可一时用户不可用，也不容忍\"余额未知却已消费\"的场景。",[137,434,435],{},"缓存 TTL 窗口内（30–60 秒），同一用户的余额不会从正变负而立即被拦截。这个窗口内的透支是允许的代价，换来的是减少与上游服务的往返压力。缓存失败后有限重试（通常 1 次），仍失败就拒绝。",[14,437,439],{"id":438},"网关的实时复校不相信-token-的-ttl","网关的实时复校：不相信 Token 的 TTL",[10,441,442],{},"即使 llmToken 的 TTL 设成 2 小时，不能依赖这个 2 小时直到 token 过期。如果用户账号在 1 小时后被禁用或 session 被远程踢下线，第二小时的请求不应该还能用旧 token 成功发出。",[10,444,445],{},"网关的每一次请求处理链都包括：",[368,447,448,459,462,465,475,482,485,488],{},[137,449,450,451,454,455,458],{},"提取请求头中的 token（可能来自 ",[33,452,453],{},"x-api-key"," 或 ",[33,456,457],{},"authorization: Bearer","）",[137,460,461],{},"验证签名与 TTL 有效",[137,463,464],{},"从 token 中拆出 userId、sessionId、deviceId",[137,466,467,468,471,472],{},"实时查数据库：用户的 ",[33,469,470],{},"status"," 字段是否仍为 ",[33,473,474],{},"active",[137,476,477,478,481],{},"实时查数据库：该 sessionId 对应的 ",[33,479,480],{},"revokedAt"," 是否为空",[137,483,484],{},"余额门控：该用户的余额是否 > 0",[137,486,487],{},"限流：该用户的请求速率是否超过限额",[137,489,490],{},"通过全部检查后，用服务端保管的上游密钥代替 token，转发请求",[10,492,493],{},"前三步是 token 的格式与密码学验证，不需要 I\u002FO。后四步都走数据库查询，引入延迟但保证了最新状态。这样即使 token 本身还有 1 小时有效期，如果用户状态变了，下一次请求立即失败。",[14,495,496],{"id":496},"限流与防刷",[10,498,499,500,502,503,505,506,509],{},"同时还需要防止单个用户刷爆系统。",[33,501,131],{}," 签发端点（",[33,504,145],{},"）本身需要限流，防止用户持续刷 token。",[33,507,508],{},"\u002Fapi\u002Fllm\u002Fv1\u002F*"," 代理端点需要按用户限流，比如限制每分钟最多 60 个请求。如果有用户超过限额，返回 HTTP 429 并告知。",[10,511,512],{},"限流计数在多实例部署时走 Redis 保证一致，单机时可用进程内 LRU 缓存（精度够用）。",[14,514,515],{"id":515},"错误分类与用户体验",[10,517,518],{},"网关需要明确地区分不同的失败原因，这样客户端才能正确响应：",[520,521,522,541],"table",{},[523,524,525],"thead",{},[526,527,528,532,535,538],"tr",{},[529,530,531],"th",{},"错误",[529,533,534],{},"HTTP 状态",[529,536,537],{},"原因",[529,539,540],{},"客户端处理",[542,543,544,561,577,592,608,624],"tbody",{},[526,545,546,552,555,558],{},[547,548,549],"td",{},[33,550,551],{},"llm_token_invalid",[547,553,554],{},"401",[547,556,557],{},"token 签名失败、过期或格式错",[547,559,560],{},"重新签发，签发失败则回登录页",[526,562,563,568,571,574],{},[547,564,565],{},[33,566,567],{},"user_disabled",[547,569,570],{},"403",[547,572,573],{},"账号被禁用",[547,575,576],{},"提示用户账号已禁用，登出",[526,578,579,584,586,589],{},[547,580,581],{},[33,582,583],{},"session_revoked",[547,585,554],{},[547,587,588],{},"该 session 被撤销（异地登录踢下线）",[547,590,591],{},"提示\"已在其他设备登录\"，回登录页",[526,593,594,599,602,605],{},[547,595,596],{},[33,597,598],{},"insufficient_balance",[547,600,601],{},"402",[547,603,604],{},"余额不足",[547,606,607],{},"提示充值，不做重试",[526,609,610,615,618,621],{},[547,611,612],{},[33,613,614],{},"rate_limited",[547,616,617],{},"429",[547,619,620],{},"请求过于频繁",[547,622,623],{},"指数退避重试",[526,625,626,631,634,637],{},[547,627,628],{},[33,629,630],{},"upstream_error",[547,632,633],{},"502",[547,635,636],{},"上游服务错误或超时",[547,638,639],{},"提示\"服务暂时不可用，请稍后重试\"",[14,641,642],{"id":642},"设计权衡与代价",[10,644,645],{},"这套防绕过方案的代价是什么？",[368,647,648,654,660,666],{},[137,649,650,653],{},[125,651,652],{},"网关成为热路径","：每次 LLM 调用都必须经过网关，包括数据库查询。高并发时网关可能成为瓶颈。缓解方法是无状态设计（水平扩展）+ 缓存（减少数据库压力）+ 上游超时控制（防止卡住）。",[137,655,656,659],{},[125,657,658],{},"可用性与安全的权衡","：fail-closed 策略意味着当上游服务的余额查询接口不可用时，所有用户都无法发起 LLM 调用。这是一个 all-or-nothing 的决策——宁可整个平台短时间无法用，也不允许\"余额未知但已消费\"的场景。代价是需要对上游服务的可用性做严格监控与告警，并预留人工切换开关（紧急放行）。",[137,661,662,665],{},[125,663,664],{},"长会话的 Token 重取","：1–2 小时的 TTL 意味着长对话可能中途需要重取 token。由于 Electron 应用运行中无法热更环境变量，需要在应用层实现\"token 即将过期时自动重取\"的逻辑。如果 token 过期后仍在续取，accessToken 本身可能也已过期，这时需要用 refreshToken 刷新。处理不当会导致长对话中途断连。",[137,667,668,671],{},[125,669,670],{},"设备 ID 的可信度","：设备 ID 基于硬件或本地 UUID，Electron 应用中理论上可以被修改（重新编译、patch 二进制）。这个防护针对的是\"非专业用户倒卖 token\"的场景，不能防住\"决心充分的开发者自己修改客户端\"。但那类用户通常会干脆把客户端改成绕过整个 login 流程，直接注入自己的上游 key，不会去倒卖 token。",[10,673,674,677],{},[125,675,676],{},"这些代价都是可接受的","，因为目标不是\"让绕过物理上不可能\"（这在客户端代码的场景下确实不可能），而是\"让绕过没有动机\"：白嫖你中转的人通常不是付费客户，如果他们能自己持有密钥，还会用你的中转吗？关键是挡住\"账号体系坍塌、流量无法计费\"的最坏情形。",[14,679,680],{"id":680},"小结",[10,682,683,684,687],{},"LLM 网关的账号鉴权不只是验证身份，而是在客户端完全可控的前提下，通过",[125,685,686],{},"短期凭证 + 多维绑定 + 实时复校 + fail-closed 缓存","这四层防线，让绕过失去意义。每一层都有明确的威胁模型：路径 1 针对\"密钥倒卖\"，路径 2 针对\"配置改写\"，路径 3 针对\"凭据复用\"，底层的计费保障针对\"透支风险\"。没有一层是万能的，但叠加起来足以在商业上可接受的代价范围内保护好平台的计费完整性。",[689,690,691],"style",{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":31,"searchDepth":46,"depth":46,"links":693},[694,699,700,701,702,703,704],{"id":16,"depth":46,"text":16,"children":695},[696,697,698],{"id":20,"depth":71,"text":21},{"id":253,"depth":71,"text":254},{"id":357,"depth":71,"text":358},{"id":394,"depth":46,"text":395},{"id":438,"depth":46,"text":439},{"id":496,"depth":46,"text":496},{"id":515,"depth":46,"text":515},{"id":642,"depth":46,"text":642},{"id":680,"depth":46,"text":680},"Agent 平台","2026-06-11","md",null,{},true,"\u002F2026-06-11-llm",{"title":5,"description":12},"2026-06-11-LLM网关的账号鉴权与反绕过","通过短期 Token、设备绑定、实时复校与 fail-closed 策略，阻断客户端绕过网关直连上游的三类路径。",[716,717,718,719,720],"API 网关","账号安全","反绕过","凭证管理","计费安全","F2dkAU0BUcvwJelANHxJOIMEAdx3M6OvH4MSwRi-J6w",[723,1041,1338],{"id":724,"title":725,"body":726,"column":705,"date":1027,"description":730,"extension":707,"hero_image":708,"meta":1028,"navigation":710,"path":1029,"seo":1030,"series_id":708,"severity":708,"stem":1031,"summary":1032,"tags":1033,"__hash__":1040},"posts\u002F2026-07-26-峰值1987一个人维护AI平台的边界.md","一个人，能维护到多大规模？",{"type":7,"value":727,"toc":1012},[728,731,734,737,740,772,775,778,782,785,792,807,810,817,821,824,827,830,837,844,848,851,858,862,865,868,878,889,892,896,899,902,909,913,916,919,922,926,929,932,958,961,964,967,973,987,992,1006],[10,729,730],{},"从 2026-05-22 的 270 个用户到 07 下旬的峰值 1987 日活，这个平台的增长不是线性的。中间隔着四次明确的容量撞墙，每一次都留下了可复查的根因记录。这篇文章讲的是，什么条件下一个人能维护一个到达四位数日活规模的多租户平台。",[14,732,733],{"id":733},"成长曲线与撞墙的四个节点",[10,735,736],{},"架构的设计前提是\"1 用户 = 1 容器\"。这个决策确定了，用户数与容器数就是同一个口径。没有\"日活 X 万、同时在线 Y 万\"这种双指标的混淆。",[10,738,739],{},"实际的规模序列是这样的：",[134,741,742,748,754,760,766],{},[137,743,744,747],{},[125,745,746],{},"270 个 service","（05-22）：刚完成 ffmpeg 升级后的测量",[137,749,750,753],{},[125,751,752],{},"372 RUNNING","（05-24）：滚动升级前的容器计数",[137,755,756,759],{},[125,757,758],{},"379","（05-25）：五个 P0 patch 部署后稳定",[137,761,762,765],{},[125,763,764],{},"574 用户","（06-03）：迁移到 K8s 时的基线，539 个 RUNNING",[137,767,768,771],{},[125,769,770],{},"1987","（07 下旬）：最近测得的峰值",[10,773,774],{},"跨度是两个月，增速从 Swarm 期间的两周内 270→379（40% 增长）、到迁 K8s 后约八周 574→1987（约 3.5 倍）。这个加速度不是\"计划好的伸缩\"，而是每一次解决了瓶颈后，下一个瓶颈暴露出来。",[14,776,777],{"id":777},"四堵撞过的墙",[18,779,781],{"id":780},"第一堵容器网络-ip-池05-22fa-012","第一堵：容器网络 IP 池（05-22，FA-012）",[10,783,784],{},"用户报告\"AI 容器里没 ffmpeg\"。这本来是个 Dockerfile 一行 apt 的事。但打算上线这个修改时，意外发现 gwbridge（Docker Swarm 默认的容器网络）IP 地址段是 \u002F24，总共 253 个 IP，已经被 270 个用户的容器占满。13 个用户容器长期处于启动失败的循环中。",[10,786,787,788,791],{},"排查逻辑是这样的：一行 Dockerfile 改动不至于触发灰度风险，但灰度一个用户时，Swarm 需要给新容器分配 gwbridge 的 IP。如果池子满了，IP 分配失败，新容器启动失败，再加上老容器还没完全释放（endpoint 还在占位），就陷入了\"申请 → 失败 → retry\"的循环。这种症状看起来随机，用户看到的是\"我的容器启不来\"，系统看到的是\"又一个容器 cycling\"。直到看 ",[33,789,790],{},"docker info"," 的输出，才发现 gwbridge 已经 100% 满用。",[10,793,794,795,798,799,806],{},"根本原因在于一个不易察觉的配置陷阱：Docker Daemon 的配置文件里改了 ",[33,796,797],{},"default-address-pools","，但这个配置",[125,800,801,802,805],{},"只在 ",[33,803,804],{},"swarm init"," 那一刻消费一次","。已经创建的网络不会动。之前改过这个配置的人可能不知道这个行为，改了等于没改。",[10,808,809],{},"修复不能简单地改配置重启。我试过在 staging 环境用 dry-run 验证，写脚本探测不冲突的子网段，然后在 production 的 canary 验证阶段发现\"释放的 IP 立刻被其它 cycling 任务抢走\"这样的负反馈。所以流程变成：先 scale 0 所有失联的服务（停止它们 cycling，释放的位置不会被抢），然后手动删除旧的 gwbridge、用新 subnet 重建。最终把容量从 253 扩到了 4094，增长了 16 倍。13 个失联用户全部恢复。",[10,811,812,813,816],{},"这次的启示是",[125,814,815],{},"配置陷阱往往比代码 bug 更隐蔽","。因为配置改了看不出效果，维护者会觉得没改上去、会反复尝试，但每次尝试的假设都错了。",[18,818,820],{"id":819},"第二堵单容器资源限制与内核参数05-25fa-013","第二堵：单容器资源限制与内核参数（05-25，FA-013）",[10,822,823],{},"五天后，部署了五个 P0 patch：B1 容器内存 burst factor 调整（硬限制改为内存 × 4）、B2 mcp register 的假失败救活逻辑、B3 ulimit nofile 扩大到 65536、B4 mcp cooldown 清理、B6 provision 接口幂等性。这一次的滚动升级从 05-25 凌晨 0:41 一直跑到 07:34，实际耗时 6 小时 42 分钟，原本预估只要 3 小时。",[10,825,826],{},"但更关键的数据是这个：升级前，平台里有一个重灾户容器一天被 OOM kill 了 247 次。这不是偶发故障——是每一天都在重复。升级完成一小时后，这个数字变成了 0。",[10,828,829],{},"这次的根因是九个缺陷的叠加。B1 的根因是硬限制设得太低（原来 93 个用户只有 500MB 硬限，远低于实际需要），B2 是 mcp register exit code 的错误判断（非零 exit 自动当做失败，但有时是因为配置覆盖了），B3 是文件描述符不足导致新连接打开失败。单个缺陷可能不致命，但聚在一起就是 OOM 风暴。更严重的是，一个用户的 OOM 不只影响那个用户——每次 OOM 都会触发内核的 swap 操作，拖累整机的 I\u002FO，让 cpa-api 主进程的事件循环卡顿 18 秒。前端看到的是全站变慢，实际根因可能是某个用户容器在反复 OOM。",[10,831,832,833,836],{},"部署前做了充分的 staging 验证和 production canary，分阶段升级了重灾户、然后全量升级、最后等完全收敛。期间遇到的问题是单容器的 ",[33,834,835],{},"docker service update"," 实际耗时约 60 秒（含 Swarm scheduler 延迟），并发调度起来比预期慢 2 倍。",[10,838,839,840,843],{},"这次的启示是：",[125,841,842],{},"当多个独立缺陷同时叠加时，表象看起来是单一故障（OOM 风暴），但根因分散在配置、参数、逻辑判断的不同层","。修复必须从全景取证开始，先理清每个环节的缺陷，再按优先级有序修复。一次部署才能彻底收敛，局部修复反而会留下隐患。",[18,845,847],{"id":846},"第三堵编排层与自愈能力06-03迁-k8s","第三堵：编排层与自愈能力（06-03，迁 K8s）",[10,849,850],{},"到 06-03，用户已经涨到 574 个。继续打补丁的成本已经高于\"一次性迁移编排平台\"。Swarm 的问题不只是容量——还有调度自愈的缺失。任何故障都依赖人工介入，一个人无法 24 小时在线。决策很简单：从 Docker Swarm 迁到 Kubernetes。",[10,852,853,854,857],{},"这次迁移的复杂性不在于技术实现本身（用 COS 中转数据、转换 sub2api 密钥、把 124G 数据分批导入），而在于",[125,855,856],{},"理解新平台引入的新故障域","。K8s 有更细的控制粒度和自动调度，但同时也暴露了之前 Swarm 隐藏的问题。比如共享存储（NFS\u002FSFS）的客户端卡死，在 Swarm 时期可能因为容器分散在不同节点而被掩盖；但在 K8s 这样的细粒度编排下，如果一个节点的 NFS 客户端出问题，节点上的所有 pod 都会受影响。所以迁移不是终点，而是暴露新问题的起点。",[18,859,861],{"id":860},"第四堵节点级存储与可观测性06-05fa-015","第四堵：节点级存储与可观测性（06-05，FA-015）",[10,863,864],{},"迁移两天后，某个节点上的 NFS 客户端在某个时刻 hang 住了。Kubernetes 不知道发生了什么，kubelet 仍然报告节点 Ready（因为 kubelet 本身没卡），但这个节点上 52 个实例的网关全部 DOWN 或 HUNG。这 52 个实例对应的是本该分散部署的用户容器，因为某种原因堆在了同一个节点上。",[10,866,867],{},"故障的症状可以分成几类。单个实例网关的 DOWN（容器无法启动、schema 非法）、HUNG（反复重启导致 adapter 504）、节点级的卡死（整节点上的容器创建\u002F删除都阻塞）、数据库与实际状态割裂（DB 里是 ERROR、但 pod 健康了）。每种症状对应不同的根因，需要不同的检测和自愈手段。",[10,869,870,871,874,875,269],{},"最严重的是，",[125,872,873],{},"这个故障没有任何告警","。平台的监控指标全部正常，绿灯一片。直到用户反馈\"连不上面板\"，才被发现。这已经是故障发生几小时以后的事。故障本身是可逆的——节点冻死就人工 cordon、删除卡住的 pod、让其它节点重新调度。但",[125,876,877],{},"不可见的故障比故障本身更致命",[10,879,880,881,884,885,888],{},"这次事故直接导出了四层兜底的设计：L0 从配置和资源限制层面降低故障触发（比如 NFS 改 ",[33,882,883],{},"soft"," 参数而不是 ",[33,886,887],{},"hard","，这样网络抖动时容器报错退出而不是整个节点冻）；L1 加检测让故障可见（每 60 秒检测一遍\"节点上是否有 pod 卡 ContainerCreating、网关探测失败率多少\"）；L2 对低风险故障自动自愈（网关单次 DOWN 就重建 pod、DB 对账失败就修复状态）；L3 对高风险动作告警优先（节点冻死先推送告警、等人工确认再 drain）。",[14,890,891],{"id":891},"三件真正决定可行性的事",[18,893,895],{"id":894},"_1-文档即基础设施","1. 文档即基础设施",[10,897,898],{},"这个平台现在有 200+ 份设计文档与 80 份故障档案。这些不是为了\"好看\"存在的。",[10,900,901],{},"一个人无法对整个系统保持完整的心智模型。200+ 文档是唯一能让一个人记住系统全貌的方式。每当遇到新故障，能快速检索以前遇过的类似问题。每当需要做架构决策，能回溯当初为什么这样设计、放弃过哪些选项。",[10,903,904,905,908],{},"同时，",[125,906,907],{},"这些文档也是 AI 能有效介入的前提","。我可以把这些故障档案喂给模型，让它帮助排查新问题、验证修复方案、甚至生成监控规则。但前提是要把故障记录得清楚。空洞的\"修好了\"没有任何价值。",[18,910,912],{"id":911},"_2-故障必须被归档","2. 故障必须被归档",[10,914,915],{},"同一个症状反复出现，每次修复可能只治了表象的一个侧面，真正的收敛需要理解全部的根因。这只有在每一次都写清档案的情况下才可能。",[10,917,918],{},"如果没有归档制度，第二次遇到类似症状时，维护者根本不知道第一次修复做过什么、为什么还没有彻底解决。\"又来了\"和\"这个问题还有遗留\"的反应完全不同。前者是被动应对、逐次救火，后者是主动追踪、系统解决。",[10,920,921],{},"实际上，平台里有不少故障走过了四到五次的修复周期。每一次修复时，回看之前的档案，能快速理清\"这一层已经改过，那一层还没触及\"。这种\"有案可查、有据可循\"的状态，把修复从赌博变成了可重复的流程——每一次问题复发时，不是从零开始排查，而是从已知的检查点继续。",[18,923,925],{"id":924},"_3-自愈优先于告警告警优先于人工","3. 自愈优先于告警，告警优先于人工",[10,927,928],{},"在 FA-015 之前，平台大量依赖人工值守。任何问题都需要运维看到日志、理解现象、手动操作。一个人无法 24 小时在线。",[10,930,931],{},"FA-015 的教训直接导出了四层兜底的设计：",[134,933,934,940,946,952],{},[137,935,936,939],{},[125,937,938],{},"L0 预防","：从配置和资源限制的层面降低故障触发的概率。",[137,941,942,945],{},[125,943,944],{},"L1 检测","：让不可见的故障变成可见——节点卡死、实例网关异常、数据库与实际状态割裂，全部要有独立的检测逻辑。",[137,947,948,951],{},[125,949,950],{},"L2 自愈","：对于低风险的故障（单实例网关重启、DB 状态对账），直接自动修复。高风险的动作（节点 drain）先告警、等人工确认。",[137,953,954,957],{},[125,955,956],{},"L3 告警","：自愈失败时、检测到新的异常时，推送给人。",[10,959,960],{},"这样的设计下，一个人维护平台的上限大幅抬高。不是因为个人能力变强了，而是系统能自动处理大多数故障，只把人类的决策能力用在最关键的地方。",[14,962,963],{"id":963},"诚实的边界在哪",[10,965,966],{},"但这个设计也有明显的天花板：",[10,968,969,972],{},[125,970,971],{},"真正撑不住的","（需要六小时以上连续操作、需要跨时区响应、需要多人交叉验证）：",[134,974,975,978,981,984],{},[137,976,977],{},"数据库或存储层的重大故障。恢复涉及数据一致性检查，无法完全自动化。",[137,979,980],{},"涉及业务逻辑的错误。修复需要理解用户意图，不只是系统恢复。",[137,982,983],{},"密钥泄露或安全事件。需要立刻通知客户、协调应急处置、事后全面审计。",[137,985,986],{},"多个独立故障同时发生、相互放大的情况。需要多个人在不同维度分别操作。",[10,988,989,366],{},[125,990,991],{},"如果重来一次，优先级这样排",[368,993,994,997,1000,1003],{},[137,995,996],{},"最先做的是 L1 检测——让故障可见。这是一切自动化的前提。宁可产生虚报，也不能漏掉真实故障。",[137,998,999],{},"其次是 L0 预防——从配置、资源限制、网络参数这些基础设施层降低故障率。这些改动成本低、收益高。",[137,1001,1002],{},"然后才是 L2 自愈——只对低风险的故障做自动恢复。对于高风险操作，即使多花一个人工确认的时间，也要确保不会进一步破坏系统。",[137,1004,1005],{},"最后是文档和监控。这些不是\"最后的事情\"，而是贯穿全过程的——每个改动都要同步更新文档、每个故障都要写进档案。",[10,1007,1008,1009,1011],{},"那些回避的成本很高。曾经因为 NFS 挂载的 ",[33,1010,887],{}," 参数导致节点冻死，这个参数的改动只需要改一行配置文件、然后滚动重启一次实例。但因为这个调整一直没做，就承受了几小时的无声故障。反过来说，那些看起来\"小\"的改动——改配置参数、改资源限制、加一个监控规则——才是最划算的投资。",{"title":31,"searchDepth":46,"depth":46,"links":1013},[1014,1015,1021,1026],{"id":733,"depth":46,"text":733},{"id":777,"depth":46,"text":777,"children":1016},[1017,1018,1019,1020],{"id":780,"depth":71,"text":781},{"id":819,"depth":71,"text":820},{"id":846,"depth":71,"text":847},{"id":860,"depth":71,"text":861},{"id":891,"depth":46,"text":891,"children":1022},[1023,1024,1025],{"id":894,"depth":71,"text":895},{"id":911,"depth":71,"text":912},{"id":924,"depth":71,"text":925},{"id":963,"depth":46,"text":963},"2026-07-26",{},"\u002F2026-07-26-1987ai",{"title":725,"description":730},"2026-07-26-峰值1987一个人维护AI平台的边界","从 270 到 1987 日活，每一次规模跃升前都先撞了一次墙。这条增长曲线记录的不是预设设计，而是每次故障都被完整归档后逐步演进出来的可行性边界。",[1034,1035,1036,1037,1038,1039],"Docker Swarm","Kubernetes","容器编排","运维自动化","故障自愈","规模扩展","D-mYcaOLOBT0PhwjmrqVVEwCE7hl8Tqi9I57HrRlt5U",{"id":1042,"title":1043,"body":1044,"column":705,"date":1324,"description":1325,"extension":707,"hero_image":708,"meta":1326,"navigation":710,"path":1327,"seo":1328,"series_id":708,"severity":708,"stem":1329,"summary":1330,"tags":1331,"__hash__":1337},"posts\u002F2026-07-14-分销体系的账本设计.md","一笔充值，要拆成几条流水？",{"type":7,"value":1045,"toc":1314},[1046,1053,1056,1060,1063,1066,1073,1084,1091,1095,1098,1104,1107,1110,1113,1124,1127,1135,1142,1146,1149,1160,1163,1174,1181,1185,1188,1194,1197,1208,1211,1215,1218,1221,1224,1235,1238,1246,1249,1260,1263,1266,1271,1278,1283,1293,1296,1299,1302,1308,1311],[10,1047,1048,1049,1052],{},"单笔用户充值，背后是一次复杂的资金拆分：用户充值 100 元，既是平台的收入，也是分销代理的佣金来源，可能还有上级代理的层级提成。这些数字必须同时记录、互相平衡、永不重复。这不是数据流通的问题，是",[125,1050,1051],{},"现金流的问题","——差一分钱就是漏账，重复一次就是挪用。",[10,1054,1055],{},"分销账本设计的核心就四条铁律和一个恒等式。遵循它们，系统能撑到任何规模；跳过其中任何一条，早晚会在对账时翻车。",[14,1057,1059],{"id":1058},"规则一佣金计算基数要先定死","规则一：佣金计算基数要先定死",[10,1061,1062],{},"从什么数字出发算佣金？这个问题比看起来复杂。",[10,1064,1065],{},"通常的选项有三个：订单总金额、用户实付金额、或者订单到账净额。乍看没区别，一旦遇上退款就完全不同。",[10,1067,1068,1069,1072],{},"采用的方案是",[125,1070,1071],{},"用户充值净额","（billing 系统中已确认到账的实付分）。理由很直白：",[368,1074,1075,1078,1081],{},[137,1076,1077],{},"退款处理天然免疫。用户充值后退款，billing 的该用户账户余额已经扣掉，充值净额自动反映了这笔冲销。分成计算只需聚合这个净额乘以比例，不用单独写退款冲正逻辑。",[137,1079,1080],{},"避免应收账款。如果以订单金额算，还没到账时代理已经看得到分成，这在 reporting-only 设计下容易造成认知错位（代理以为钱已经是他的，实际还在支付处理中）。",[137,1082,1083],{},"同源唯一。billing 是平台的权威账本，分成的基数来自这里，对账时只需验证\"代理分成之和 + 平台收入 = billing 总充值\"，一个公式搞定。",[10,1085,1086,1087,1090],{},"反过来说，如果公司后续引入退款主动冲补（而非被动扣减），这个基数设定会变得复杂。但在初期，",[125,1088,1089],{},"基数 = billing 已确认充值"," 是最简洁的切口。",[14,1092,1094],{"id":1093},"规则二结算时点决定了数据流向","规则二：结算时点决定了数据流向",[10,1096,1097],{},"到底是在订单成交时计提佣金，还是账期结束时一次性结算？",[10,1099,1100,1101,269],{},"这里的选择是 ",[125,1102,1103],{},"reporting-only 只读聚合，实时查询，不计提、不累积",[10,1105,1106],{},"具体含义是：代理看到的\"我的分成\"不是一条条流水记录，而是每次查询时现场计算出来的聚合数字。算法是\"我名下所有用户的充值净额总和 × 我的佣金比例\"。没有单独的\"分成计提\"操作，没有一条条的\"分成到账\"记录。",[10,1108,1109],{},"好处和代价是对偶的。",[10,1111,1112],{},"好处：",[134,1114,1115,1118,1121],{},[137,1116,1117],{},"免除计提时点的争议。不用决定在订单成交时、支付完成时、还是 T+1 时计提，因为根本不计提。",[137,1119,1120],{},"天然避免双扣。既然分成不落库不累积，就不存在\"发放一次、又重复发放一次\"的并发风险。同一笔充值无论被查询多少次，贡献的佣金永远相同。",[137,1122,1123],{},"简化对账。代理的分成数字永远等于\"最新充值净额 × 比例\"，无需追溯历史。",[10,1125,1126],{},"代价：",[134,1128,1129,1132],{},[137,1130,1131],{},"代理无法看到\"分成流水\"。有些运营场景下，需要展示\"哪笔订单产生了多少佣金\"这样的明细，reporting-only 做不了（可以通过关联用户的充值明细变通，但那是用户维度的流水，不是分成维度的）。",[137,1133,1134],{},"退款时必须同步。如果用户退了 50 块钱，billing 系统立刻反映这笔扣减，代理的分成下一秒查询就会跌下来。这对代理来说是透明的（分成就是动态的），但运营沟通时需要提前说清楚。",[10,1136,1137,1138,1141],{},"选择 reporting-only 的核心原因是：",[125,1139,1140],{},"初期不出金、无提现","。既然分成只是一个数字展示、不涉及真金白银的打款，那就不用建立复杂的流水账体系。等到未来做提现时，可以在 reporting-only 的基础上加一层\"快照 + 冻结\"机制（即每个提现周期开始时拍一个快照，这个快照才是可提的分成额）。",[14,1143,1145],{"id":1144},"规则三层级上限和循环检测","规则三：层级上限和循环检测",[10,1147,1148],{},"分销是分多少层级？",[10,1150,1151,1152,1155,1156,1159],{},"首期方案是",[125,1153,1154],{},"单层","。用户通过一个特定的渠道码注册（如 ",[33,1157,1158],{},"AB-48210377","），永久绑定到某个代理。代理无法有\"上级代理\"，也就无法有\"上级佣金\"这样的递归结构。",[10,1161,1162],{},"这一约束看起来很强，但在无提现的 reporting-only 下，是合理的。理由是：",[368,1164,1165,1168,1171],{},[137,1166,1167],{},"简化代理运维。总台只需管理一套代理的佣金比例（per-代理），不用维护代理之间的树形关系。",[137,1169,1170],{},"避免环形链。单层天然杜绝了\"A 的上级是 B，B 的上级是 A\"这类配置错误。多层结构下，环形检测本身又是一个故障点。",[137,1172,1173],{},"初期够用。大多数分销场景早期就是\"直销商（代理）→ 用户\"的二元关系，不需要分级。",[10,1175,1176,1177,1180],{},"但要注意，这个约束是",[125,1178,1179],{},"数据模型层的","，不是业务规则层的。如果未来需要升级到多层结构，数据模型需要改（Channel 表可能要加 parentResellerId 等），但已经发出去的单层记录无需回溯改造——它们天然是单层的。",[14,1182,1184],{"id":1183},"规则四幂等键设计防重复计提","规则四：幂等键设计（防重复计提）",[10,1186,1187],{},"同一笔充值可能被多个系统调用、被回调多次。分成必须严格幂等：无论这笔充值被聚合几次，贡献给代理的佣金永远是\"充值额 × 比例\"这一个数字，不能是两倍、三倍。",[10,1189,1190,1191,269],{},"因为采用 reporting-only + 只读聚合的设计，幂等性",[125,1192,1193],{},"自动满足",[10,1195,1196],{},"推理如下：",[368,1198,1199,1202,1205],{},[137,1200,1201],{},"billing 侧的充值流水本身是幂等的。同一个订单号的充值，billing 确保只入账一次（通过订单号的唯一性约束）。",[137,1203,1204],{},"分成聚合是无状态的。每次查询时，服务端都是\"遍历该代理名下的用户 → 调用 billing 的 summaryByUsers 接口 → 汇总充值净额 → 乘以比例\"。这个聚合过程不依赖任何之前的计提记录。",[137,1206,1207],{},"结论：即使 billing 错误地返回了同一笔充值两次，聚合结果也只会包含一次（因为底层是\"用户ID → 总充值净额\"的映射，不是\"订单 → 充值\"的流水列表）。",[10,1209,1210],{},"相反，如果设计成\"订单成交时计提一条分成记录\"的模式，就需要在分成记录上加幂等键（如 orderId），确保同一订单的分成只计提一次。这个幂等键检查本身就是一个额外的故障点。",[14,1212,1214],{"id":1213},"一个恒等式对账的唯一标准","一个恒等式：对账的唯一标准",[10,1216,1217],{},"前面四条规则规范了流程，但最后的验证还是要靠一个简单的数学公式。",[10,1219,1220],{},"$$\n\\sum_^{n} \\text{Commission}_i + \\text{PlatformNetIncome} = \\text{TotalTopup}\n$$",[10,1222,1223],{},"其中：",[134,1225,1226,1229,1232],{},[137,1227,1228],{},"$\\text{Commission}_i$ 是第 $i$ 个代理的分成（所有名下用户的充值净额 × 佣金比例）",[137,1230,1231],{},"$\\text{PlatformNetIncome}$ 是平台的净收入（总充值 - 所有代理的分成总额）",[137,1233,1234],{},"$\\text{TotalTopup}$ 是 billing 系统的总充值额（所有用户的到账充值之和）",[10,1236,1237],{},"这个等式是唯一可靠的对账标尺。任何时刻，只要这个等式不成立，就说明某个环节出了问题：",[134,1239,1240,1243],{},[137,1241,1242],{},"等式左侧大于右侧 → 某个代理的分成算重了，或者平台收入算多了",[137,1244,1245],{},"等式左侧小于右侧 → 某个代理的分成算少了，或者某笔收入漏了",[10,1247,1248],{},"而且这个等式不需要建立任何新表。完全可以通过查询三个现成的数据源验证：",[368,1250,1251,1254,1257],{},[137,1252,1253],{},"billing 的 SummaryByUsers（每个用户的充值净额）",[137,1255,1256],{},"代理表的 commissionRate（每个代理的佣金比例）",[137,1258,1259],{},"一行 SQL 的聚合（sum 和乘法）",[14,1261,1262],{"id":1262},"技术实现的两个关键点",[10,1264,1265],{},"光有规则还不够，实现层要支撑这些规则。素材中的设计有两个细节值得指出。",[10,1267,1268,269],{},[125,1269,1270],{},"其一，billing 要暴露 summaryByUsers 接口",[10,1272,1273,1274,1277],{},"分成聚合依赖\"按用户ID汇总充值净额和消耗\"这个操作。如果 billing 侧没有这个批量接口，代理端就得自己拼接多个单用户查询，性能和一致性都会打折扣。实现计划里新增的 ",[33,1275,1276],{},"POST \u002Fanalytics\u002Fsummary-by-users"," 就是为了这个。",[10,1279,1280,269],{},[125,1281,1282],{},"其二，reseller 侧的所有查询要服务端注入 channelId",[10,1284,1285,1286,454,1289,1292],{},"代理登录后调用 ",[33,1287,1288],{},"\u002Fapi\u002Freseller\u002Fsummary",[33,1290,1291],{},"\u002Fapi\u002Freseller\u002Fusers","，后端不能信任请求体里的 channelId 参数。而是从 token 解析出代理身份 → 查表得出该代理对应的 channelId → 强制注入到查询条件里。这是防代理 A 越权查看代理 B 渠道的唯一有效方式。",[10,1294,1295],{},"代码里体现为：从 Admin token 反查 Channel 表的 resellerId 字段，确保该 Admin 只能看自己那行 Channel。",[14,1297,1298],{"id":1298},"与既有分账设计的呼应",[10,1300,1301],{},"这套账本设计不是凭空造出来的。平台此前在另一个系统里实现过五类角色的分账体系（Platform、Agency、Escort、Distributor、Merchant），每个角色各维护一张 Ledger 流水表。那个设计的核心思想是\"分账入表\"——即每一笔影响各角色收入的交易，都要对应地在各自的 Ledger 表里落一条记录。",[10,1303,1304,1305,269],{},"当前这套分销设计采用了相反的思路：不建 Ledger 表，而是在查询时通过聚合来推导分成。这的背景是 reporting-only 属性（不出金、只展示），使得可以接受动态聚合的方案。但底层的思想是一致的——",[125,1306,1307],{},"通过对账恒等式来保证多角色之间的收支平衡",[14,1309,1310],{"id":1310},"结尾",[10,1312,1313],{},"账本设计的目标不是漂亮的表格或丰富的报表，而是一个简单的数学等式永远成立。一旦等式破裂，对账人员立刻能定位是哪个环节失守。这比事后扑火要高效得多。",{"title":31,"searchDepth":46,"depth":46,"links":1315},[1316,1317,1318,1319,1320,1321,1322,1323],{"id":1058,"depth":46,"text":1059},{"id":1093,"depth":46,"text":1094},{"id":1144,"depth":46,"text":1145},{"id":1183,"depth":46,"text":1184},{"id":1213,"depth":46,"text":1214},{"id":1262,"depth":46,"text":1262},{"id":1298,"depth":46,"text":1298},{"id":1310,"depth":46,"text":1310},"2026-07-14","单笔用户充值，背后是一次复杂的资金拆分：用户充值 100 元，既是平台的收入，也是分销代理的佣金来源，可能还有上级代理的层级提成。这些数字必须同时记录、互相平衡、永不重复。这不是数据流通的问题，是现金流的问题——差一分钱就是漏账，重复一次就是挪用。",{},"\u002F2026-07-14",{"title":1043,"description":1325},"2026-07-14-分销体系的账本设计","分销系统最容易在财务对账出错。单笔充值需同时产生平台收入、代理佣金等多条记录，必须在同一事务内闭合。四条规则与一个恒等式是账本设计的全部。",[1332,1333,1334,1335,1336],"分销","账本设计","对账","幂等性","佣金结算","Q3NC9Z8JBJ7e2MLQFH10q68Igpbp2fMF5gT-do6Rxaw",{"id":1339,"title":1340,"body":1341,"column":705,"date":1645,"description":1345,"extension":707,"hero_image":708,"meta":1646,"navigation":710,"path":1647,"seo":1648,"series_id":708,"severity":708,"stem":1649,"summary":1650,"tags":1651,"__hash__":1657},"posts\u002F2026-06-29-记忆星系长期记忆可视化工作台.md","模型记错了，用户得能删掉",{"type":7,"value":1342,"toc":1635},[1343,1346,1349,1353,1359,1362,1365,1368,1371,1378,1381,1384,1416,1419,1422,1489,1496,1503,1506,1509,1553,1556,1559,1563,1566,1569,1572,1575,1578,1581,1584,1587,1590,1597,1604,1607,1610,1617,1620,1623,1626,1629,1632],[10,1344,1345],{},"长期记忆存起来容易，用户看不见也管不了。记忆一旦不可见，就会累积错误信息并持续污染后续对话——模型出错时没有纠正入口，错误就永久留存。",[10,1347,1348],{},"我在 yun-claude 的记忆设计中遇到的问题正是这个。聊天系统能自动从对话抽取持久要点并入库，但页面还是传统的卡片列表，看不出记忆的类型、重要性、使用频次。更严重的是，用户无法编辑或删除那些被错误标记的记忆。所以这次升级的核心不是加一个炫彩的可视化，而是把记忆管理变成一个真正可用的信息工作台。",[14,1350,1352],{"id":1351},"表格优先而不是星河优先","表格优先，而不是星河优先",[10,1354,1355,1356,269],{},"设计的第一个决策是：",[125,1357,1358],{},"主界面用表格承载记忆列表，不用星河画布作主体交互",[10,1360,1361],{},"这听起来反直觉。当初考虑过让星河画布成为核心——节点按重要性大小分布、按创建时间环形排列、搜索命中时高亮。视觉上是漂亮的，也更有\"知识宇宙沉淀\"的产品感。但约束改变了这个选择：",[10,1363,1364],{},"第一，信息密度。表格一屏可以显示 10-20 条记忆的核心属性（标题、类型、重要性、标签、创建时间），并支持排序和筛选。星河画布要展示相同的信息量，就必须让节点变小、缩放调整、甚至分屏展示——交互成本陡升。而用户——AI agent 的主人——需要快速浏览和定位记忆，不是浏览艺术装置。",[10,1366,1367],{},"第二，编辑成本。星河上的节点编辑通常要额外打开面板或弹窗。如果记忆管理的主要工作是\"检查是否有错记、删除重复、调整分类\"，那星河就不是最优方案。对比之下，表格 + 右侧详情面板的结构让用户可以同时看到列表和正在编辑的项目，没有上下文切换。",[10,1369,1370],{},"第三，用户规模。当前 yun-claude 没有真实用户，推断单个用户的记忆条数会比较少（几十到几百）。在这个规模下，表格就足够快了，不必依赖图形加速或向量索引的复杂优化。如果将来规模增大再考虑换方案。",[10,1372,1373,1374,1377],{},"所以最终设计是：",[125,1375,1376],{},"表格为主工作台，右侧详情面板负责编辑与整理，星系可视化只作为辅助视图（后续可选）","。这不是缺乏视觉想象力，而是对工作流的务实权衡。代价是产品感稍弱，但可用性强得多。",[14,1379,1380],{"id":1380},"五类语义记忆与设计令牌",[10,1382,1383],{},"为了让记忆可见可管，将长期记忆分成五类：",[134,1385,1386,1392,1398,1404,1410],{},[137,1387,1388,1391],{},[125,1389,1390],{},"核心记忆","（CORE）：与用户身份、核心项目、重要约束直接相关。模型在每轮对话发送前都应该检索。",[137,1393,1394,1397],{},[125,1395,1396],{},"常驻记忆","（PERMANENT）：用户的工作背景、技术栈偏好、团队结构等长期背景。",[137,1399,1400,1403],{},[125,1401,1402],{},"临时记忆","（TEMPORARY）：短期的任务进度、当前问题、临时约束。生命周期短。",[137,1405,1406,1409],{},[125,1407,1408],{},"知识星云","（KNOWLEDGE）：用户分享的文档要点、API 文档摘录、最佳实践。主要用于 RAG 增强。",[137,1411,1412,1415],{},[125,1413,1414],{},"其他","（OTHER）：模型无法明确分类或用户手动标记的记忆。",[10,1417,1418],{},"每一类的关键差异是生命周期和召回策略。核心记忆应该高频被注入 prompt，临时记忆应该自动清理，知识类应该被 RAG 系统共同使用。",[10,1420,1421],{},"为了在视觉上强化这个分类，为每类配置了一个独立的语义色：",[520,1423,1424,1437],{},[523,1425,1426],{},[526,1427,1428,1431,1434],{},[529,1429,1430],{},"类型",[529,1432,1433],{},"颜色",[529,1435,1436],{},"用途",[542,1438,1439,1450,1460,1470,1480],{},[526,1440,1441,1444,1447],{},[547,1442,1443],{},"核心",[547,1445,1446],{},"Amber-500",[547,1448,1449],{},"节点、筛选按钮、卡片左边线",[526,1451,1452,1455,1458],{},[547,1453,1454],{},"常驻",[547,1456,1457],{},"Sky-500",[547,1459,1449],{},[526,1461,1462,1465,1468],{},[547,1463,1464],{},"临时",[547,1466,1467],{},"Teal-500",[547,1469,1449],{},[526,1471,1472,1475,1478],{},[547,1473,1474],{},"知识",[547,1476,1477],{},"Violet-500",[547,1479,1449],{},[526,1481,1482,1484,1487],{},[547,1483,1414],{},[547,1485,1486],{},"Slate-500",[547,1488,1449],{},[10,1490,1491,1492,1495],{},"关键的设计决策是：",[125,1493,1494],{},"颜色不只是装饰，而是结构的一部分","。在表格、筛选条、详情面板的边线上，用户都能看到同一个颜色，强化类型认知。这样的一致性是可信的信息工作台的标志——用户一眼知道哪条记忆是核心、哪条是临时。",[10,1497,1498,1499,1502],{},"颜色值不是散落在各个组件里的魔法数字，而是集中在一个 ",[33,1500,1501],{},"memoryStyles.ts"," 文件中管理。任何需要记忆类型颜色的地方都从这里引用。这样改一个颜色时不必跨多个文件搜索替换，也不会出现同类型在不同地方显示不同色的尴尬局面。",[14,1504,1505],{"id":1505},"记忆模型与后端约束",[10,1507,1508],{},"后端为每条记忆增加了结构化字段：",[134,1510,1511,1517,1523,1529,1535,1541,1547],{},[137,1512,1513,1516],{},[33,1514,1515],{},"title","：节点或列表行的标题，从正文前 18 个字符生成",[137,1518,1519,1522],{},[33,1520,1521],{},"type","：五类之一",[137,1524,1525,1528],{},[33,1526,1527],{},"importance","：1-100 的重要性分值，决定节点大小和列表排序",[137,1530,1531,1534],{},[33,1532,1533],{},"tags","：字符串数组，最多 8 个标签",[137,1536,1537,1540],{},[33,1538,1539],{},"lastUsedAt","：最近被召回的时间",[137,1542,1543,1546],{},[33,1544,1545],{},"usedCount","：被召回次数",[137,1548,1549,1552],{},[33,1550,1551],{},"metadata","：扩展字段，保存来源会话、模型、抽取动作等",[10,1554,1555],{},"当模型抽取记忆时，输出包含这些结构化字段。服务端必须做兜底和校验：type 非法时改为 OTHER、importance 超范围时裁剪、tags 去重去空白最多保留 8 个。如果抽取失败，仍然保存正文并用默认字段（importance=50, type=OTHER）。",[10,1557,1558],{},"这些默认值的设计是为了降级优雅。即便结构化抽取失败，记忆也不会丢失，只是分类不精准——这是可以接受的，因为用户随后可以手动调整。",[14,1560,1562],{"id":1561},"搜索筛选与编辑","搜索、筛选与编辑",[10,1564,1565],{},"用户在表格上方有一个搜索框和类型筛选条。搜索时调用后端的语义搜索接口，命中的记忆在表格中高亮，并自动选中最高相关性的一条，打开右侧详情面板。语义搜索基于向量数据库的余弦相似度匹配——记忆文本被嵌入为 4096 维向量，查询时也转换为向量并与库内存储按相似度排序召回，只返回分值达到阈值（≥0.3）的结果。这样的匹配比关键词搜索精准度高，也支持语义近似的记忆关联（比如\"TypeScript 后端\"和\"TS 服务端\"会被认为相关）。搜索失败时保留当前星河状态并 toast 提示，不中断工作流。",[10,1567,1568],{},"筛选可以按五类过滤，清除筛选则回到全量视图。如果当前选中的记忆被筛选隐藏了，系统会自动选中可见记忆中最高重要性的那一条；如果没有可见记忆，则关闭详情面板显示空状态。",[10,1570,1571],{},"详情面板里，用户可以编辑标题、正文、类型、标签和重要性。编辑表单使用产品内设计，不弹浏览器原生弹窗。保存后立即更新列表和节点（如果有星河视图的话）。这样用户修改一条记忆时，不必刷新页面或等待后台同步，改动立刻可见。",[10,1573,1574],{},"删除是另一个关键能力。必须让用户能删除被错误标记的记忆，否则错误就成了永久污染源。删除按钮放在详情面板的危险操作区，点击后需确认。删除成功后，该条记忆从表格和星河中消失，列表自动选中下一条（如果有的话）。",[14,1576,1577],{"id":1577},"星河与移动端降级",[10,1579,1580],{},"虽然主界面是表格，但在设计中保留了星河作为可视化补充。桌面端在表格下方或侧边可以显示一个小星图，让用户看到记忆的空间分布——核心记忆聚在中心，临时记忆分散在外围。星河上的节点与表格关联：点击表格行时，星河也高亮对应节点；点击星河节点时，表格定位到对应行。",[10,1582,1583],{},"但星河不是强制项。第一版的实现可能不包含完整星河，而是先确保表格工作台完整可用。星河可以作为后续的增强——用户如果觉得需要可视化辅助，才加上去。",[10,1585,1586],{},"移动端设计上，星河更是不现实（屏幕太小）。所以移动端完全降级为列表视图，点击列表项后通过底部抽屉显示详情和编辑表单。搜索和类型 tabs 放在顶部，保持核心交互可用。这样既避免了表格在手机上的横向溢出，也保证了可用性。",[14,1588,1589],{"id":1589},"节点布局与稳定性",[10,1591,1592,1593,1596],{},"如果星河要实现，一个重要的设计细节是：",[125,1594,1595],{},"节点位置必须稳定","。即便刷新页面或重新打开应用，同一批记忆应该保持相同的位置，这样用户才能形成空间记忆——\"核心记忆总在中心，临时的在右上角\"。",[10,1598,1599,1600,1603],{},"这意味着节点位置不能是随机的实时布局，也不能用物理模拟（那会每次都算不同的位置）。用 ",[33,1601,1602],{},"createdAt"," 字段参与布局计算，保证确定性：同一条记忆的创建时间固定了，它在环形排列中的角度也就固定了。结合 importance 决定距离中心的远近，位置就完全由数据驱动，刷新后毫厘不差。",[14,1605,1606],{"id":1606},"一致性与可维护性",[10,1608,1609],{},"这个设计的关键约束是一致性。如果记忆在表格中是 Amber-500（核心），那在星河、筛选、详情面板的边线上也必须是 Amber-500。任何拆散这个一致性的修改都会破坏用户的心智模型。",[10,1611,1612,1613,1616],{},"所以在设计系统里明确了这一点：",[125,1614,1615],{},"新增或调整记忆类型视觉时，先更新设计文档里的语义色板和 memoryStyles.ts，再在各组件中引用，不允许组件各自复制色值","。这样的约束看似严苛，但它保证了长期的可维护性——下次要改颜色时，只需改一个文件。",[14,1618,1619],{"id":1619},"代价与权衡",[10,1621,1622],{},"这个设计的代价是什么？",[10,1624,1625],{},"第一，视觉冲击力比不上星河优先。表格是务实的设计，不够\"黑科技\"感。如果产品定位是\"AI 记忆系统\"要卖视觉冲击，这个方案会显得保守。",[10,1627,1628],{},"第二，星河的潜力没有完全释放。放弃了复杂的关系推理、自由漫游、力导向布局这些\"高级\"可视化特性，原因就是表格工作台不需要它们，而加上去反而添加复杂度。",[10,1630,1631],{},"第三，设计系统的维护成本提高了。一致性的要求意味着每次改动都要考虑全局影响。但这其实是长期收益——减少了 bug 和不一致的可能。",[10,1633,1634],{},"这些代价对 yun-claude 是可以接受的，因为当前用户还不多，重点是让产品可用，而不是炫技。如果将来用户规模上升，数百条甚至千条记忆的管理场景出现，表格 + 星河的混合界面可能不够，那时再考虑更激进的可视化方案。现在，信息工作台的优先级明确高于视觉体验。",{"title":31,"searchDepth":46,"depth":46,"links":1636},[1637,1638,1639,1640,1641,1642,1643,1644],{"id":1351,"depth":46,"text":1352},{"id":1380,"depth":46,"text":1380},{"id":1505,"depth":46,"text":1505},{"id":1561,"depth":46,"text":1562},{"id":1577,"depth":46,"text":1577},{"id":1589,"depth":46,"text":1589},{"id":1606,"depth":46,"text":1606},{"id":1619,"depth":46,"text":1619},"2026-06-29",{},"\u002F2026-06-29",{"title":1340,"description":1345},"2026-06-29-记忆星系长期记忆可视化工作台","表格优先的记忆管理：用高信息密度工作台承载持久要点，可视化只作辅助，设计令牌贯穿全局。",[1652,1653,1654,1655,1656],"长期记忆","信息工作台","设计决策","语义搜索","设计令牌","gsJKxlNQ-FiADsw2ca6e5WJo-_uyYDFZ55iuiMr4jQE",1785406912236]