[{"data":1,"prerenderedAt":4243},["ShallowReactive",2],{"home-latest-posts":3},[4,2047,2133,2537,3912],{"id":5,"title":6,"body":7,"cover":2034,"date":2035,"description":54,"draft":2036,"extension":2037,"meta":2038,"navigation":1339,"path":2039,"seo":2040,"stem":2041,"summary":2042,"tags":2043,"__hash__":2046},"posts\u002Fposts\u002F26_06_09\u002Fmain.md","以 QQ 机器人为梳理解异步编程",{"type":8,"value":9,"toc":1996},"minimark",[10,15,22,25,30,33,45,48,78,85,95,98,146,157,159,163,166,175,182,186,252,258,264,294,316,320,361,363,367,378,382,389,424,430,438,462,466,471,500,503,509,512,585,597,599,603,606,610,615,621,627,631,638,658,662,669,673,680,700,710,717,719,723,726,730,741,791,804,819,826,831,835,866,872,879,890,899,944,951,1017,1022,1026,1032,1038,1064,1075,1078,1175,1201,1205,1225,1255,1265,1300,1304,1311,1420,1423,1429,1439,1443,1449,1458,1461,1479,1481,1485,1488,1492,1497,1500,1571,1578,1601,1613,1616,1624,1659,1667,1715,1807,1823,1827,1833,1853,1856,1866,1872,1898,1903,1909,1912,1931,1933,1937,1985,1992],[11,12,14],"h1",{"id":13},"以-qq-机器人为例讲解异步编程","以 QQ 机器人为例讲解异步编程",[16,17,18],"blockquote",{},[19,20,21],"p",{},"从「异步是什么」到「事件循环怎么转」，再到一个真实框架（NcatBot）里的应用，最后用一个真实的定时任务 bug 复盘，把抽象的概念落到能跑的代码上。",[23,24],"hr",{},[26,27,29],"h2",{"id":28},"一为什么需要异步","一、为什么需要异步",[19,31,32],{},"先想一个最朴素的问题：一个 QQ 机器人在干什么？",[19,34,35,36,40,41,44],{},"它绝大部分时间在",[37,38,39],"strong",{},"等","——等 WebSocket 收到下一条消息、等 LLM API 返回、等沙箱里的命令跑完、等一张图片下载完。这些操作的共同点是 ",[37,42,43],{},"I\u002FO 密集","：CPU 几乎不工作，只是干等外部资源。",[19,46,47],{},"如果用同步阻塞的写法：",[49,50,55],"pre",{"className":51,"code":52,"language":53,"meta":54,"style":54},"language-python shiki shiki-themes github-dark github-light","msg = ws.recv()              # 阻塞，等消息（可能等几秒，也可能几分钟）\nreply = llm.chat(msg)        # 阻塞，等 LLM 返回（好几秒）\nws.send(reply)               # 阻塞，等发送完成\n","python","",[56,57,58,66,72],"code",{"__ignoreMap":54},[59,60,63],"span",{"class":61,"line":62},"line",1,[59,64,65],{},"msg = ws.recv()              # 阻塞，等消息（可能等几秒，也可能几分钟）\n",[59,67,69],{"class":61,"line":68},2,[59,70,71],{},"reply = llm.chat(msg)        # 阻塞，等 LLM 返回（好几秒）\n",[59,73,75],{"class":61,"line":74},3,[59,76,77],{},"ws.send(reply)               # 阻塞，等发送完成\n",[19,79,80,81,84],{},"在 ",[56,82,83],{},"ws.recv()"," 等消息的那几分钟里，整个程序什么都做不了——来了第二个群的消息也只能排队干瞪眼。要并发处理多个会话，传统办法是「一个连接一个线程」，但线程有真实的内存和上下文切换开销，几千个会话就会把机器拖垮。",[19,86,87,90,91,94],{},[37,88,89],{},"异步（async）的核心思想","：既然大部分时间都在等 I\u002FO，那就在「等」的时候让出 CPU，去干别的活，等 I\u002FO 好了再回来接着干。这样",[37,92,93],{},"单个线程","就能同时推进成百上千个「等待中」的任务。",[19,96,97],{},"关键区分：",[99,100,101,116],"table",{},[102,103,104],"thead",{},[105,106,107,110,113],"tr",{},[108,109],"th",{},[108,111,112],{},"含义",[108,114,115],{},"适合场景",[117,118,119,133],"tbody",{},[105,120,121,127,130],{},[122,123,124],"td",{},[37,125,126],{},"并发（concurrency）",[122,128,129],{},"多个任务交替推进，宏观上「同时」进行",[122,131,132],{},"I\u002FO 密集（机器人就是典型）",[105,134,135,140,143],{},[122,136,137],{},[37,138,139],{},"并行（parallelism）",[122,141,142],{},"多个任务在多核上真正同时执行",[122,144,145],{},"CPU 密集（大量计算）",[19,147,148,149,152,153,156],{},"Python 的 ",[56,150,151],{},"asyncio"," 提供的是",[37,154,155],{},"单线程并发","：不靠多核，靠在一个线程里飞快地切换任务来「假装同时」。对 I\u002FO 密集的机器人来说，这恰好是最划算的模型。",[23,158],{},[26,160,162],{"id":161},"二事件循环event-loop异步的心脏","二、事件循环（Event Loop）：异步的心脏",[19,164,165],{},"异步代码不会自己跑。背后必须有一个调度器在不停地做这件事：",[16,167,168],{},[19,169,170,171,174],{},"「谁的 I\u002FO 好了 \u002F 谁的定时器到点了 → 让谁继续往下执行；谁遇到 ",[56,172,173],{},"await"," 要等待了 → 把它挂起，切去跑别的。」",[19,176,177,178,181],{},"这个调度器就是 ",[37,179,180],{},"event loop（事件循环）","。",[183,184,185],"h3",{"id":185},"三个核心概念",[99,187,188,201],{},[102,189,190],{},[105,191,192,195,198],{},[108,193,194],{},"概念",[108,196,197],{},"是什么",[108,199,200],{},"类比",[117,202,203,219,239],{},[105,204,205,210,216],{},[122,206,207],{},[37,208,209],{},"coroutine（协程）",[122,211,212,215],{},[56,213,214],{},"async def"," 函数，调用后返回一个「待执行的计划」，本身不会自动运行",[122,217,218],{},"一份写好的菜谱",[105,220,221,226,236],{},[122,222,223],{},[37,224,225],{},"Task（任务）",[122,227,228,231,232,235],{},[56,229,230],{},"asyncio.create_task(coro)"," 把协程",[37,233,234],{},"注册到当前 loop","，loop 才会调度它",[122,237,238],{},"把菜谱交给厨师排进待办单",[105,240,241,246,249],{},[122,242,243],{},[37,244,245],{},"event loop",[122,247,248],{},"真正的调度者，运行期间不断切换、推进所有 Task",[122,250,251],{},"厨房里那个不停切换炉灶的厨师",[183,253,255,257],{"id":254},"await-到底发生了什么",[56,256,173],{}," 到底发生了什么",[19,259,260,263],{},[56,261,262],{},"await some_io()"," 这行代码的语义是：",[265,266,267,271,278,284,287],"ol",{},[268,269,270],"li",{},"「我要等这个 I\u002FO，现在还没好。」",[268,272,273,274,277],{},"把当前协程的执行",[37,275,276],{},"挂起","，记下它停在哪一行。",[268,279,280,281,181],{},"把控制权",[37,282,283],{},"交还给 event loop",[268,285,286],{},"loop 转去推进其他就绪的任务。",[268,288,289,290,293],{},"等这个 I\u002FO 完成，loop 再把这个协程从挂起处",[37,291,292],{},"恢复","执行。",[19,295,296,297,299,300,303,304,307,308,311,312,315],{},"这套「挂起—交还—恢复」就是单线程能并发的全部秘密。注意：",[56,298,173],{}," 让出的前提是它等的东西",[37,301,302],{},"本身是异步的","（如 ",[56,305,306],{},"asyncio.sleep","、异步网络库）。如果你在协程里写了同步阻塞的 ",[56,309,310],{},"time.sleep(5)","，loop 会被这一行",[37,313,314],{},"整个卡死"," 5 秒，所有其他任务一起停摆——这是异步编程最常见的坑。",[183,317,319],{"id":318},"三个必须记住的认知后面-bug-复盘的关键","三个必须记住的认知（后面 bug 复盘的关键）",[265,321,322,335,348],{},[268,323,324,181,327,330,331,334],{},[37,325,326],{},"协程不会自己跑",[56,328,329],{},"f()"," 只是创建协程对象，必须有一个",[37,332,333],{},"正在运行的 loop"," 去驱动。",[268,336,337,343,344,347],{},[37,338,339,342],{},[56,340,341],{},"create_task"," 绑定到「当前正在运行的那个 loop」","——调用那一刻 ",[56,345,346],{},"asyncio.get_running_loop()"," 返回的 loop。",[268,349,350,353,354,357,358,360],{},[37,351,352],{},"loop 一旦停止\u002F关闭，挂在它上面的所有 Task 都不再被调度","。哪怕协程是个 ",[56,355,356],{},"while True","，loop 不转，它就永远卡在某个 ",[56,359,173],{}," 上不动。",[23,362],{},[26,364,366],{"id":365},"三原理对比typescript-浏览器-与-python-的事件循环","三、原理对比：TypeScript \u002F 浏览器 与 Python 的事件循环",[19,368,369,370,373,374,377],{},"很多人是先在前端接触异步的。JS\u002FTS 和 Python 的 ",[56,371,372],{},"async\u002Fawait"," ",[37,375,376],{},"语法几乎一样","，但底层调度模型有一个值得一提的区别。",[183,379,381],{"id":380},"js-浏览器宏任务-微任务两个队列","JS \u002F 浏览器：宏任务 + 微任务两个队列",[19,383,384,385,388],{},"浏览器的事件循环把待执行的回调分成",[37,386,387],{},"两类队列","：",[390,391,392,405],"ul",{},[268,393,394,388,397,400,401,404],{},[37,395,396],{},"宏任务（macrotask）队列",[56,398,399],{},"setTimeout","、",[56,402,403],{},"setInterval","、I\u002FO、UI 渲染事件等。",[268,406,407,388,410,413,414,416,417,400,420,423],{},[37,408,409],{},"微任务（microtask）队列",[56,411,412],{},"Promise.then"," 回调、",[56,415,173],{}," 之后的续体、",[56,418,419],{},"queueMicrotask",[56,421,422],{},"MutationObserver"," 等。",[19,425,426,427],{},"调度规则是：",[37,428,429],{},"每执行完一个宏任务，就把当前微任务队列里的任务全部清空，然后才考虑渲染、再取下一个宏任务。",[49,431,436],{"className":432,"code":434,"language":435,"meta":54},[433],"language-text","取一个宏任务执行\n  └─ 执行过程中产生的微任务进微任务队列\n执行完这个宏任务\n  └─ 把微任务队列「一次性全部排空」（期间新产生的微任务也一起处理掉）\n  └─ （浏览器）此时可能进行一次渲染\n取下一个宏任务……\n","text",[56,437,434],{"__ignoreMap":54},[19,439,440,441,444,445,448,449,451,452,455,456,458,459,461],{},"为什么要分两个队列？很大程度上正是",[37,442,443],{},"为了浏览器渲染","。浏览器希望「一组逻辑上相关的、原子的更新」在同一帧内一起完成，不要被渲染打断到中间状态。微任务队列保证了：一个宏任务触发的所有连锁 Promise 回调，会在",[37,446,447],{},"下一次渲染之前一次性跑完","，从而把这些更新「绑定到一起」原子地呈现，避免画面闪烁或撕裂。",[56,450,173],{}," 的续体走的是微任务，所以它的优先级高于 ",[56,453,454],{},"setTimeout(0)"," 这种宏任务——这就是经典面试题「",[56,457,412],{}," 比 ",[56,460,399],{}," 先执行」的根源。",[183,463,465],{"id":464},"python-asyncio单一就绪队列-定时器堆","Python asyncio：单一就绪队列 + 定时器堆",[19,467,148,468,470],{},[56,469,151],{}," 没有「宏\u002F微任务」这种二分。它的模型更直接：",[390,472,473,480,494],{},[268,474,475,476,479],{},"一个 ",[37,477,478],{},"ready 队列","：所有当前就绪、可以立刻继续跑的回调\u002FTask。",[268,481,482,483,486,487,490,491,493],{},"一个按时间排序的",[37,484,485],{},"定时器结构","（最小堆）：",[56,488,489],{},"call_later"," \u002F ",[56,492,306],{}," 注册的「到点才就绪」的回调。",[268,495,475,496,499],{},[37,497,498],{},"selector","：通过操作系统的 I\u002FO 多路复用（epoll\u002Fkqueue\u002FIOCP）监听 fd 是否就绪。",[19,501,502],{},"每一轮循环（一次 \"tick\"）大致是：",[49,504,507],{"className":505,"code":506,"language":435,"meta":54},[433],"1. 算出最近的定时器还有多久到点 → 作为本轮 select 的超时时间\n2. select(timeout)：等 I\u002FO 事件，或等到最近的定时器到点\n3. 把「I\u002FO 就绪的回调」和「已到点的定时器回调」放进 ready 队列\n4. 把 ready 队列里当前的回调「逐个执行完」\n5. 回到第 1 步\n",[56,508,506],{"__ignoreMap":54},[19,510,511],{},"和 JS 的根本区别：",[99,513,514,527],{},[102,515,516],{},[105,517,518,521,524],{},[108,519,520],{},"维度",[108,522,523],{},"JS \u002F 浏览器",[108,525,526],{},"Python asyncio",[117,528,529,540,555,574],{},[105,530,531,534,537],{},[122,532,533],{},"队列结构",[122,535,536],{},"宏任务队列 + 微任务队列（二分）",[122,538,539],{},"单一 ready 队列 + 定时器堆",[105,541,542,545,552],{},[122,543,544],{},"设计动机",[122,546,547,548,551],{},"与",[37,549,550],{},"渲染","协调，把原子更新绑定在一帧内",[122,553,554],{},"纯后端调度，无渲染概念，模型更扁平",[105,556,557,562,571],{},[122,558,559,561],{},[56,560,173],{}," 续体优先级",[122,563,564,565,373,568,570],{},"走微任务，",[37,566,567],{},"高于",[56,569,399],{}," 等宏任务",[122,572,573],{},"就是普通就绪回调，进同一个 ready 队列",[105,575,576,579,582],{},[122,577,578],{},"「下一个 tick」语义",[122,580,581],{},"微任务排空 → （渲染）→ 下个宏任务",[122,583,584],{},"一轮 select + 执行 ready 回调",[19,586,587,590,591,593,594,596],{},[37,588,589],{},"JS 的两队列模型是被浏览器渲染需求「逼」出来的；Python 没有渲染负担，所以用了一个更扁平的单队列 + 定时器堆模型。"," 但两者对应用层开发者暴露的 ",[56,592,372],{}," 心智模型是高度一致的——遇到 ",[56,595,173],{}," 让出、I\u002FO 好了恢复。",[23,598],{},[26,600,602],{"id":601},"四异步的应用一个真实的-qq-机器人框架","四、异步的应用：一个真实的 QQ 机器人框架",[19,604,605],{},"来看异步在真实项目里怎么用。下面以我正在开发的基于 NcatBot 的 QQ AI Agent 框架（AEsirClaw）为例。",[183,607,609],{"id":608},"_41-整条链路都是异步的","4.1 整条链路都是异步的",[19,611,612,613,388],{},"一条 QQ 消息从进来到回复，几乎每一环都在 ",[56,614,173],{},[49,616,619],{"className":617,"code":618,"language":435,"meta":54},[433],"QQ 消息 ──→ WebSocket 收包（await recv）\n                │\n                ▼\n           事件分发 → 防抖调度（await sleep 等窗口）\n                │\n                ▼\n           组装上下文 → Agent Loop ◄──► LLM API（await chat）\n                │\n                ├── execute_task → Docker 沙箱（await 执行）\n                └── send_group_msg → QQ（await 发送 + await 打字延迟）\n",[56,620,618],{"__ignoreMap":54},[19,622,623,624,626],{},"每个 ",[56,625,173],{}," 都是一次「让出 CPU 去处理别的会话」的机会。正因如此，单线程就能同时服务多个群和私聊：A 群在等 LLM 返回时，loop 顺手就把 B 群刚到的消息推进了。",[183,628,630],{"id":629},"_42-异步让拟人化变得自然","4.2 异步让「拟人化」变得自然",[19,632,633,634,637],{},"机器人为了模拟真人打字节奏，会分段发送、每段之间停顿。同步写法里「停顿」意味着卡死整个程序；异步里它只是一次 ",[56,635,636],{},"await asyncio.sleep","，停顿期间 loop 照样服务别人：",[49,639,641],{"className":51,"code":640,"language":53,"meta":54,"style":54},"for seg in messages:\n    await send(seg)\n    await asyncio.sleep(字数 * delay + 随机波动)   # 停顿，但不阻塞其他会话\n",[56,642,643,648,653],{"__ignoreMap":54},[59,644,645],{"class":61,"line":62},[59,646,647],{},"for seg in messages:\n",[59,649,650],{"class":61,"line":68},[59,651,652],{},"    await send(seg)\n",[59,654,655],{"class":61,"line":74},[59,656,657],{},"    await asyncio.sleep(字数 * delay + 随机波动)   # 停顿，但不阻塞其他会话\n",[183,659,661],{"id":660},"_43-防抖用异步实现听完再说","4.3 防抖：用异步实现「听完再说」",[19,663,664,665,668],{},"人聊天习惯是连发好几条，机器人不该逐条机械回复。框架用一个异步防抖器：首条消息到达后启动一个 5 秒窗口（",[56,666,667],{},"await asyncio.sleep(5)","），窗口期间的新消息只刷新「候选」，窗口结束才取最新候选统一处理。整个等待逻辑就是一个挂在 loop 上的协程，等待期间完全不占用 CPU。",[183,670,672],{"id":671},"_44-框架如何托管-event-loop","4.4 框架如何托管 event loop",[19,674,675,676,679],{},"应用层开发者通常不自己写 ",[56,677,678],{},"asyncio.run","，而是框架替你把 loop 跑起来。NcatBot 的入口极简：",[49,681,683],{"className":51,"code":682,"language":53,"meta":54,"style":54},"from ncatbot.core import BotClient\nbot = BotClient()\nbot.run_frontend()   # 一切从这里开始\n",[56,684,685,690,695],{"__ignoreMap":54},[59,686,687],{"class":61,"line":62},[59,688,689],{},"from ncatbot.core import BotClient\n",[59,691,692],{"class":61,"line":68},[59,693,694],{},"bot = BotClient()\n",[59,696,697],{"class":61,"line":74},[59,698,699],{},"bot.run_frontend()   # 一切从这里开始\n",[19,701,702,705,706,709],{},[56,703,704],{},"run_frontend()"," 内部最终会进入一个长驻的事件循环，由它统一驱动所有异步任务。",[37,707,708],{},"理解「框架在哪里、用哪个 loop 跑你的代码」，是用好异步框架的关键","——下一节的 bug 正是栽在这上面。",[19,711,712,713,716],{},"这里先埋一个伏笔：NcatBot 在启动过程中其实会创建",[37,714,715],{},"不止一个"," event loop，它们各司其职、互相独立。记住这一点，往下看。",[23,718],{},[26,720,722],{"id":721},"五实践定时任务的实现与一次真实-bug-复盘","五、实践：定时任务的实现与一次真实 bug 复盘",[19,724,725],{},"需求很常见：让 QQ 机器人能自主设定定时任务（「每天早上 8 点拉一遍新闻」「每隔一小时报时」「10 分钟后提醒我」），到点后机器人主动在原会话里发言。",[183,727,729],{"id":728},"_51-调度器的设计","5.1 调度器的设计",[19,731,732,733,736,737,740],{},"核心是一个 ",[56,734,735],{},"TaskScheduler","：用 JSON 文件持久化任务，挂一个",[37,738,739],{},"常驻的异步扫描循环","，每隔 N 秒扫一遍，把到期的任务触发掉。扫描循环长这样：",[49,742,744],{"className":51,"code":743,"language":53,"meta":54,"style":54},"async def _scan_loop(self) -> None:\n    \"\"\"常驻循环：周期性扫描到期任务并触发。\"\"\"\n    while True:\n        try:\n            await asyncio.sleep(self.scan_interval)\n            await self._tick()                 # 扫描 + 触发到期任务\n        except asyncio.CancelledError:\n            break\n",[56,745,746,751,756,761,767,773,779,785],{"__ignoreMap":54},[59,747,748],{"class":61,"line":62},[59,749,750],{},"async def _scan_loop(self) -> None:\n",[59,752,753],{"class":61,"line":68},[59,754,755],{},"    \"\"\"常驻循环：周期性扫描到期任务并触发。\"\"\"\n",[59,757,758],{"class":61,"line":74},[59,759,760],{},"    while True:\n",[59,762,764],{"class":61,"line":763},4,[59,765,766],{},"        try:\n",[59,768,770],{"class":61,"line":769},5,[59,771,772],{},"            await asyncio.sleep(self.scan_interval)\n",[59,774,776],{"class":61,"line":775},6,[59,777,778],{},"            await self._tick()                 # 扫描 + 触发到期任务\n",[59,780,782],{"class":61,"line":781},7,[59,783,784],{},"        except asyncio.CancelledError:\n",[59,786,788],{"class":61,"line":787},8,[59,789,790],{},"            break\n",[19,792,793,794,796,797,799,800,803],{},"这是个标准的异步后台循环：",[56,795,356],{}," + ",[56,798,636],{},"。它必须被某个",[37,801,802],{},"持续运行的 loop"," 调度，才能一轮一轮地转下去。启动它的代码也很直觉：",[49,805,807],{"className":51,"code":806,"language":53,"meta":54,"style":54},"def start(self) -> None:\n    self._loop_task = asyncio.create_task(self._scan_loop())\n",[56,808,809,814],{"__ignoreMap":54},[59,810,811],{"class":61,"line":62},[59,812,813],{},"def start(self) -> None:\n",[59,815,816],{"class":61,"line":68},[59,817,818],{},"    self._loop_task = asyncio.create_task(self._scan_loop())\n",[19,820,821,822,825],{},"到点触发时，它调用一个回调，往会话里注入一条「",[59,823,824],{},"定时任务触发","」系统消息，再唤醒一次 Agent Loop，让机器人自己决定怎么发言。整个机制干净利落——逻辑测试全过，任务也能正常写进 JSON 文件。",[19,827,828],{},[37,829,830],{},"然后部署上去，到点了……什么都没发生。",[183,832,834],{"id":833},"_52-现象","5.2 现象",[390,836,837,848,855],{},[268,838,839,840,843,844,847],{},"任务确实被创建了：",[56,841,842],{},"schedules.json"," 里躺着两条 ",[56,845,846],{},"daily"," 任务。",[268,849,850,851,854],{},"时间早就过了，",[56,852,853],{},"next_run"," 时间戳算得也对。",[268,856,857,858,865],{},"但日志里",[37,859,860,861,864],{},"没有任何 ",[56,862,863],{},"[Scheduler] 触发任务"," 的记录","，连「扫描器已启动」之后的活动都看不到。",[19,867,868,869,181],{},"任务能存、时间能算，就是不触发。问题不在逻辑，而在",[37,870,871],{},"那个扫描循环根本没在转",[183,873,875,876,878],{"id":874},"_53-根因create_task-挂错了-loop","5.3 根因：",[56,877,341],{}," 挂错了 loop",[19,880,881,882,885,886,889],{},"我最初把 ",[56,883,884],{},"scheduler.start()"," 放在了插件的 ",[56,887,888],{},"on_load","（插件加载时的初始化钩子）里。看起来天经地义——加载时启动调度器，对吧？",[19,891,892,893,898],{},"错。关键在于框架",[37,894,895,896],{},"怎么调用 ",[56,897,888],{},"。扒开 NcatBot 的插件加载器：",[49,900,902],{"className":51,"code":901,"language":53,"meta":54,"style":54},"# ncatbot\u002Fplugin_system\u002Floader.py\ndef _run_init():\n    loop = asyncio.new_event_loop()              # ← 新建一个临时 loop\n    asyncio.set_event_loop(loop)\n    try:\n        loop.run_until_complete(plugin.__onload__())   # ← 跑你的 on_load\n    finally:\n        loop.close()                             # ← 跑完立刻关闭这个 loop！\n",[56,903,904,909,914,919,924,929,934,939],{"__ignoreMap":54},[59,905,906],{"class":61,"line":62},[59,907,908],{},"# ncatbot\u002Fplugin_system\u002Floader.py\n",[59,910,911],{"class":61,"line":68},[59,912,913],{},"def _run_init():\n",[59,915,916],{"class":61,"line":74},[59,917,918],{},"    loop = asyncio.new_event_loop()              # ← 新建一个临时 loop\n",[59,920,921],{"class":61,"line":763},[59,922,923],{},"    asyncio.set_event_loop(loop)\n",[59,925,926],{"class":61,"line":769},[59,927,928],{},"    try:\n",[59,930,931],{"class":61,"line":775},[59,932,933],{},"        loop.run_until_complete(plugin.__onload__())   # ← 跑你的 on_load\n",[59,935,936],{"class":61,"line":781},[59,937,938],{},"    finally:\n",[59,940,941],{"class":61,"line":787},[59,942,943],{},"        loop.close()                             # ← 跑完立刻关闭这个 loop！\n",[19,945,946,947,950],{},"而且这一切还被丢进",[37,948,949],{},"一个独立线程","里执行。问题链条于是浮现：",[265,952,953,962,976,992,1003],{},[268,954,955,957,958,961],{},[56,956,888],{}," 运行在这个",[37,959,960],{},"临时 loop","（Loop ①）上。",[268,963,964,967,968,971,972,975],{},[56,965,966],{},"start()"," 里的 ",[56,969,970],{},"asyncio.create_task(_scan_loop())"," 把扫描循环",[37,973,974],{},"绑定到了 Loop ①","（回忆第二节的认知 2：create_task 绑定当前正在跑的 loop）。",[268,977,978,980,981,984,985,988,989,181],{},[56,979,888],{}," 一返回，",[56,982,983],{},"run_until_complete"," 结束，紧接着 ",[56,986,987],{},"loop.close()"," —— ",[37,990,991],{},"Loop ① 死亡",[268,993,994,995,998,999,1002],{},"扫描循环卡在第一个 ",[56,996,997],{},"await asyncio.sleep(...)"," 上，",[37,1000,1001],{},"再也没有 loop 去唤醒它","（认知 3）。",[268,1004,1005,1006,1009,1010,1013,1014,181],{},"而真正长驻的业务主 loop（Loop ②，由 ",[56,1007,1008],{},"run_frontend"," 内部的 ",[56,1011,1012],{},"asyncio.run(connect_websocket())"," 驱动）上，",[37,1015,1016],{},"压根没有这个扫描任务",[19,1018,1019],{},[37,1020,1021],{},"任务被挂在了一个用完即弃的 loop 上，那个 loop 关了之后，任务就成了无人调度的僵尸。",[183,1023,1025],{"id":1024},"_54-框架里到底有几个-loop","5.4 框架里到底有几个 loop",[19,1027,1028,1029,1031],{},"把 ",[56,1030,704],{}," 的链路摊开看，就能数清楚：",[49,1033,1036],{"className":1034,"code":1035,"language":435,"meta":54},[433],"run_frontend()\n  └─ start()\n       ├─ 加载插件\n       │    └─ 独立线程\n       │         └─ Loop ① = asyncio.new_event_loop()    ← 临时\n       │              └─ run_until_complete(on_load)       ← ❌ 我最初在这里 create_task\n       │         └─ loop.close()                           ← Loop ① 死亡，扫描循环冻结\n       │\n       └─ asyncio.run(connect_websocket())\n            └─ Loop ② = 长驻主循环                          ← ✅ 应该挂在这里\n                 ├─ while True: recv() → 分发 QQ 消息\n                 └─ 触发 STARTUP 事件 → _on_bot_ready       ← 这里 start() 才对\n",[56,1037,1035],{"__ignoreMap":54},[390,1039,1040,1055],{},[268,1041,1042,1045,1046,1048,1049,1051,1052,181],{},[37,1043,1044],{},"Loop ①（临时）","：只为跑一次性的 ",[56,1047,888],{}," 初始化，",[56,1050,983],{}," 完就 ",[56,1053,1054],{},"close",[268,1056,1057,1060,1061,1063],{},[37,1058,1059],{},"Loop ②（长驻）","：bot 的命脉，跑 ",[56,1062,356],{}," 收消息，所有 QQ 事件、启动回调都在它身上。",[183,1065,1067,1068,1071,1072,1074],{"id":1066},"_55-looprun_until_complete-vs-run_frontend-的本质区别","5.5 ",[56,1069,1070],{},"loop.run_until_complete"," vs ",[56,1073,704],{}," 的本质区别",[19,1076,1077],{},"这两者处在完全不同的抽象层级，正好对照理解：",[99,1079,1080,1095],{},[102,1081,1082],{},[105,1083,1084,1086,1091],{},[108,1085,520],{},[108,1087,1088],{},[56,1089,1090],{},"loop.run_until_complete(coro)",[108,1092,1093],{},[56,1094,704],{},[117,1096,1097,1116,1136,1150,1165],{},[105,1098,1099,1102,1109],{},[122,1100,1101],{},"本质",[122,1103,1104,1105,1108],{},"event loop 的",[37,1106,1107],{},"底层方法","：跑到指定协程完成就停",[122,1110,1111,1112,1115],{},"框架的",[37,1113,1114],{},"顶层入口","：启动整个 bot 并阻塞到退出",[105,1117,1118,1121,1127],{},[122,1119,1120],{},"生命周期",[122,1122,1123,1126],{},[37,1124,1125],{},"短暂","，跑完一个协程就返回（随后被 close）",[122,1128,1129,1132,1133,1135],{},[37,1130,1131],{},"长驻","，内部进 ",[56,1134,356],{}," 收消息，正常永不返回",[105,1137,1138,1141,1144],{},[122,1139,1140],{},"用的 loop",[122,1142,1143],{},"临时新建的 Loop ①，用完即弃",[122,1145,1146,1147,1149],{},"内部 ",[56,1148,678],{}," 独占运行的 Loop ②",[105,1151,1152,1155,1158],{},[122,1153,1154],{},"适合放什么",[122,1156,1157],{},"一次性、跑完就结束的初始化",[122,1159,1160,1161,1164],{},"需要",[37,1162,1163],{},"长期存活","的后台任务应挂在它的 loop 上",[105,1166,1167,1169,1172],{},[122,1168,200],{},[122,1170,1171],{},"临时点火烧开一壶水就熄火",[122,1173,1174],{},"常明的灶火，整晚不灭",[19,1176,1177,1178,1181,1182,1185,1186,1189,1190,1193,1194,1197,1198,1200],{},"补一个常被忽略的关系：",[56,1179,1180],{},"asyncio.run(coro)"," ≈ 「新建 loop + ",[56,1183,1184],{},"run_until_complete(coro)"," + 最后 ",[56,1187,1188],{},"close()","」。所以二者底层是同一类操作，区别只在跑的协程",[37,1191,1192],{},"会不会很快结束","——",[56,1195,1196],{},"connect_websocket"," 是死循环（loop 长驻），",[56,1199,888],{}," 很快结束（loop 短命）。",[183,1202,1204],{"id":1203},"_56-修复","5.6 修复",[19,1206,1028,1207,1209,1210,1212,1213,1216,1217,1220,1221,1224],{},[56,1208,966],{}," 从 ",[56,1211,888],{}," 挪到 ",[56,1214,1215],{},"_on_bot_ready","（",[56,1218,1219],{},"OFFICIAL_STARTUP_EVENT"," 的回调，",[37,1222,1223],{},"运行在长驻主 loop Loop ② 上","）：",[49,1226,1228],{"className":51,"code":1227,"language":53,"meta":54,"style":54},"async def _on_bot_ready(self, event):\n    \"\"\"Bot 连接成功后加载历史消息，并在主 loop 上启动定时任务调度器。\"\"\"\n    await self._load_recent_messages()\n    # 在 bot 主 event loop 上启动调度器（此回调即运行于该 loop）\n    self.scheduler.start()\n",[56,1229,1230,1235,1240,1245,1250],{"__ignoreMap":54},[59,1231,1232],{"class":61,"line":62},[59,1233,1234],{},"async def _on_bot_ready(self, event):\n",[59,1236,1237],{"class":61,"line":68},[59,1238,1239],{},"    \"\"\"Bot 连接成功后加载历史消息，并在主 loop 上启动定时任务调度器。\"\"\"\n",[59,1241,1242],{"class":61,"line":74},[59,1243,1244],{},"    await self._load_recent_messages()\n",[59,1246,1247],{"class":61,"line":763},[59,1248,1249],{},"    # 在 bot 主 event loop 上启动调度器（此回调即运行于该 loop）\n",[59,1251,1252],{"class":61,"line":769},[59,1253,1254],{},"    self.scheduler.start()\n",[19,1256,1257,1258,1260,1261,1264],{},"而 ",[56,1259,888],{}," 里只保留「构造对象、注册回调」这类",[37,1262,1263],{},"不依赖 loop 存活","的同步初始化，并留下注释警示后人：",[49,1266,1268],{"className":51,"code":1267,"language":53,"meta":54,"style":54},"# 注意：on_load 由框架在独立线程的临时 event loop 上执行\n# （loader.py: loop.run_until_complete(...) 后 loop.close()），\n# 因此这里只构造调度器，绝不能在此 start()——否则扫描协程会被挂到\n# 那个用完即弃的 loop 上，永远不会被调度。真正的启动放在 _on_bot_ready 中。\nself.scheduler = TaskScheduler(...)\nself.scheduler.set_callback(self._on_scheduled_trigger)\n",[56,1269,1270,1275,1280,1285,1290,1295],{"__ignoreMap":54},[59,1271,1272],{"class":61,"line":62},[59,1273,1274],{},"# 注意：on_load 由框架在独立线程的临时 event loop 上执行\n",[59,1276,1277],{"class":61,"line":68},[59,1278,1279],{},"# （loader.py: loop.run_until_complete(...) 后 loop.close()），\n",[59,1281,1282],{"class":61,"line":74},[59,1283,1284],{},"# 因此这里只构造调度器，绝不能在此 start()——否则扫描协程会被挂到\n",[59,1286,1287],{"class":61,"line":763},[59,1288,1289],{},"# 那个用完即弃的 loop 上，永远不会被调度。真正的启动放在 _on_bot_ready 中。\n",[59,1291,1292],{"class":61,"line":769},[59,1293,1294],{},"self.scheduler = TaskScheduler(...)\n",[59,1296,1297],{"class":61,"line":775},[59,1298,1299],{},"self.scheduler.set_callback(self._on_scheduled_trigger)\n",[183,1301,1303],{"id":1302},"_57-最小复现一眼看穿","5.7 最小复现：一眼看穿",[19,1305,1306,1307,1310],{},"为了把这个机制钉死，写一个不依赖框架的十几行 demo（项目里 ",[56,1308,1309],{},"test\u002Ftest_async.py","），对比两种 loop：",[49,1312,1314],{"className":51,"code":1313,"language":53,"meta":54,"style":54},"async def background_loop(tag):\n    while True:\n        await asyncio.sleep(0.2)\n        fired.append(tag)\n\n# 场景 A：临时 loop —— 等价于 on_load\ndef scenario_temp_loop():\n    loop = asyncio.new_event_loop()\n    asyncio.set_event_loop(loop)\n    loop.run_until_complete(fake_on_load())   # 内部 create_task(background_loop)\n    loop.close()                              # ← loop 关闭\n    time.sleep(1.0)                           # 等一秒，看后台循环跑没跑\n\n# 场景 B：长驻 loop —— 等价于主 loop\ndef scenario_long_running_loop():\n    async def main():\n        asyncio.create_task(background_loop(\"B\"))\n        await asyncio.sleep(1.0)              # loop 持续运转 1 秒\n    asyncio.run(main())\n",[56,1315,1316,1321,1325,1330,1335,1341,1346,1351,1356,1361,1367,1373,1379,1384,1390,1396,1402,1408,1414],{"__ignoreMap":54},[59,1317,1318],{"class":61,"line":62},[59,1319,1320],{},"async def background_loop(tag):\n",[59,1322,1323],{"class":61,"line":68},[59,1324,760],{},[59,1326,1327],{"class":61,"line":74},[59,1328,1329],{},"        await asyncio.sleep(0.2)\n",[59,1331,1332],{"class":61,"line":763},[59,1333,1334],{},"        fired.append(tag)\n",[59,1336,1337],{"class":61,"line":769},[59,1338,1340],{"emptyLinePlaceholder":1339},true,"\n",[59,1342,1343],{"class":61,"line":775},[59,1344,1345],{},"# 场景 A：临时 loop —— 等价于 on_load\n",[59,1347,1348],{"class":61,"line":781},[59,1349,1350],{},"def scenario_temp_loop():\n",[59,1352,1353],{"class":61,"line":787},[59,1354,1355],{},"    loop = asyncio.new_event_loop()\n",[59,1357,1359],{"class":61,"line":1358},9,[59,1360,923],{},[59,1362,1364],{"class":61,"line":1363},10,[59,1365,1366],{},"    loop.run_until_complete(fake_on_load())   # 内部 create_task(background_loop)\n",[59,1368,1370],{"class":61,"line":1369},11,[59,1371,1372],{},"    loop.close()                              # ← loop 关闭\n",[59,1374,1376],{"class":61,"line":1375},12,[59,1377,1378],{},"    time.sleep(1.0)                           # 等一秒，看后台循环跑没跑\n",[59,1380,1382],{"class":61,"line":1381},13,[59,1383,1340],{"emptyLinePlaceholder":1339},[59,1385,1387],{"class":61,"line":1386},14,[59,1388,1389],{},"# 场景 B：长驻 loop —— 等价于主 loop\n",[59,1391,1393],{"class":61,"line":1392},15,[59,1394,1395],{},"def scenario_long_running_loop():\n",[59,1397,1399],{"class":61,"line":1398},16,[59,1400,1401],{},"    async def main():\n",[59,1403,1405],{"class":61,"line":1404},17,[59,1406,1407],{},"        asyncio.create_task(background_loop(\"B\"))\n",[59,1409,1411],{"class":61,"line":1410},18,[59,1412,1413],{},"        await asyncio.sleep(1.0)              # loop 持续运转 1 秒\n",[59,1415,1417],{"class":61,"line":1416},19,[59,1418,1419],{},"    asyncio.run(main())\n",[19,1421,1422],{},"运行结果：",[49,1424,1427],{"className":1425,"code":1426,"language":435,"meta":54},[433],"【场景 A：临时 loop（用完即弃）】\n  >>> 结果：后台循环执行了 0 次（loop 已关闭，无人调度）\n\n【场景 B：长驻 loop】\n  [B-长驻loop] 后台循环执行了一次 ...   (×4)\n  >>> 结果：后台循环执行了 4 次（loop 持续运转，正常调度）\n",[56,1428,1426],{"__ignoreMap":54},[19,1430,1431,1438],{},[37,1432,1433,1434,1437],{},"完全相同的 ",[56,1435,1436],{},"create_task(background_loop)","，差别只在「它绑定的 loop 是否持续运行」。"," 场景 A 的 0 次，就是线上「定时任务永不触发」的精确缩影；场景 B 的 4 次，就是修复后扫描循环每 20 秒正常扫一遍的样子。",[183,1440,1442],{"id":1441},"_58-经验法则","5.8 经验法则",[19,1444,1445,1446,1448],{},"在「框架托管 event loop」的场景下，判断「后台任务该在哪 ",[56,1447,341],{},"」只需一条：",[16,1450,1451],{},[19,1452,1453],{},[37,1454,1455,1457],{},[56,1456,341],{}," 必须在「那个会长期活着的 loop」正在运行时调用。",[19,1459,1460],{},"具体到这类机器人框架：",[390,1462,1463,1473,1476],{},[268,1464,1465,1466,1468,1469,1472],{},"❌ 不要在 ",[56,1467,888],{},"\u002F",[56,1470,1471],{},"__init__"," 这类「初始化完就结束」的地方起长驻后台任务。",[268,1474,1475],{},"✅ 在「bot 就绪 \u002F 连接成功」的事件回调里起——那才是长驻主 loop。",[268,1477,1478],{},"快速自检：如果你的任务需要「一直跑」，但它所在的函数是「跑完就返回」的，那它大概率挂错了 loop。",[23,1480],{},[26,1482,1484],{"id":1483},"六延伸思考一个程序能有几个-event-loop","六、延伸思考：一个程序能有几个 event loop？",[19,1486,1487],{},"bug 复盘里反复出现「临时 loop」「长驻 loop」，自然引出三个递进的问题。",[183,1489,1491],{"id":1490},"_61-异步只能有一个-event-loop-吗","6.1 异步只能有一个 event loop 吗？",[19,1493,1494],{},[37,1495,1496],{},"不是。但有一条铁律：每个线程在同一时刻，最多只能有一个「正在运行」的 event loop。",[19,1498,1499],{},"「能创建几个」和「能同时运行几个」是两回事：",[99,1501,1502,1511],{},[102,1503,1504],{},[105,1505,1506,1508],{},[108,1507,520],{},[108,1509,1510],{},"答案",[117,1512,1513,1529,1544,1556],{},[105,1514,1515,1522],{},[122,1516,1517,1518,1521],{},"一个程序能",[37,1519,1520],{},"创建","几个 loop？",[122,1523,1524,1525,1528],{},"任意多个，",[56,1526,1527],{},"asyncio.new_event_loop()"," 想建几个建几个",[105,1530,1531,1537],{},[122,1532,1533,1534,1521],{},"一个线程能同时",[37,1535,1536],{},"运行",[122,1538,1539,1540,1543],{},"只能 1 个，loop 是阻塞式运行的，",[56,1541,1542],{},"loop.run_xxx()"," 没返回前线程被它占着",[105,1545,1546,1553],{},[122,1547,1548,1549,1552],{},"多个 loop 能在",[37,1550,1551],{},"不同线程","里同时跑吗？",[122,1554,1555],{},"可以，每个线程跑自己的 loop，互不干扰",[105,1557,1558,1565],{},[122,1559,1560,1561,1564],{},"loop 能",[37,1562,1563],{},"先后","在同一线程跑吗？",[122,1566,1567,1568,1570],{},"可以，跑完一个 ",[56,1569,1054],{}," 掉，再建一个跑——这正是 NcatBot 干的事",[19,1572,1573,1574,1577],{},"关键限制来自一个",[37,1575,1576],{},"线程局部","的概念「当前线程的 event loop」：",[390,1579,1580,1589],{},[268,1581,1582,1584,1585,1588],{},[56,1583,346],{}," 返回",[37,1586,1587],{},"此刻正在跑","的那个 loop。",[268,1590,1591,1593,1594,1597,1598,1600],{},[56,1592,230],{}," 把任务绑定到它返回的那个 loop——",[37,1595,1596],{},"这就是定时任务 bug 的命门","：你在哪个 loop 运行时调 ",[56,1599,341],{},"，任务就绑给哪个 loop。",[183,1602,1604,1605,1608,1609,1612],{"id":1603},"_62-new_event_loop-和-asynciorunmain-的区别","6.2 ",[56,1606,1607],{},"new_event_loop()"," 和 ",[56,1610,1611],{},"asyncio.run(main())"," 的区别",[19,1614,1615],{},"这俩不在一个层级——一个是「底层手动挡」，一个是「高层自动挡」。",[19,1617,1618,1623],{},[37,1619,1620,1622],{},[56,1621,1527],{},"：只「造」一个 loop","，不运行任何东西，生命周期全靠你：",[49,1625,1627],{"className":51,"code":1626,"language":53,"meta":54,"style":54},"loop = asyncio.new_event_loop()        # 1. 造\nasyncio.set_event_loop(loop)           # 2. 设为本线程当前 loop\ntry:\n    loop.run_until_complete(main())    # 3. 手动驱动\nfinally:\n    loop.close()                       # 4. 手动关（不关就泄漏）\n",[56,1628,1629,1634,1639,1644,1649,1654],{"__ignoreMap":54},[59,1630,1631],{"class":61,"line":62},[59,1632,1633],{},"loop = asyncio.new_event_loop()        # 1. 造\n",[59,1635,1636],{"class":61,"line":68},[59,1637,1638],{},"asyncio.set_event_loop(loop)           # 2. 设为本线程当前 loop\n",[59,1640,1641],{"class":61,"line":74},[59,1642,1643],{},"try:\n",[59,1645,1646],{"class":61,"line":763},[59,1647,1648],{},"    loop.run_until_complete(main())    # 3. 手动驱动\n",[59,1650,1651],{"class":61,"line":769},[59,1652,1653],{},"finally:\n",[59,1655,1656],{"class":61,"line":775},[59,1657,1658],{},"    loop.close()                       # 4. 手动关（不关就泄漏）\n",[19,1660,1661,1666],{},[37,1662,1663,1665],{},[56,1664,1611],{},"：造 + 跑 + 清理 + 关，一条龙","。等价于（简化）：",[49,1668,1670],{"className":51,"code":1669,"language":53,"meta":54,"style":54},"def run(main):\n    loop = asyncio.new_event_loop()                    # 造\n    try:\n        asyncio.set_event_loop(loop)\n        return loop.run_until_complete(main)           # 跑到完成\n    finally:\n        loop.run_until_complete(loop.shutdown_asyncgens())  # 清理残留 task \u002F async gen\n        asyncio.set_event_loop(None)\n        loop.close()                                   # 关\n",[56,1671,1672,1677,1682,1686,1691,1696,1700,1705,1710],{"__ignoreMap":54},[59,1673,1674],{"class":61,"line":62},[59,1675,1676],{},"def run(main):\n",[59,1678,1679],{"class":61,"line":68},[59,1680,1681],{},"    loop = asyncio.new_event_loop()                    # 造\n",[59,1683,1684],{"class":61,"line":74},[59,1685,928],{},[59,1687,1688],{"class":61,"line":763},[59,1689,1690],{},"        asyncio.set_event_loop(loop)\n",[59,1692,1693],{"class":61,"line":769},[59,1694,1695],{},"        return loop.run_until_complete(main)           # 跑到完成\n",[59,1697,1698],{"class":61,"line":775},[59,1699,938],{},[59,1701,1702],{"class":61,"line":781},[59,1703,1704],{},"        loop.run_until_complete(loop.shutdown_asyncgens())  # 清理残留 task \u002F async gen\n",[59,1706,1707],{"class":61,"line":787},[59,1708,1709],{},"        asyncio.set_event_loop(None)\n",[59,1711,1712],{"class":61,"line":1358},[59,1713,1714],{},"        loop.close()                                   # 关\n",[99,1716,1717,1731],{},[102,1718,1719],{},[105,1720,1721,1723,1727],{},[108,1722],{},[108,1724,1725],{},[56,1726,1607],{},[108,1728,1729],{},[56,1730,1611],{},[117,1732,1733,1744,1755,1768,1779,1796],{},[105,1734,1735,1738,1741],{},[122,1736,1737],{},"抽象层级",[122,1739,1740],{},"底层，手动挡",[122,1742,1743],{},"高层，自动挡",[105,1745,1746,1749,1752],{},[122,1747,1748],{},"做什么",[122,1750,1751],{},"只创建 loop 对象",[122,1753,1754],{},"创建 + 运行 + 清理 + 关闭",[105,1756,1757,1760,1765],{},[122,1758,1759],{},"谁负责 close",[122,1761,1762],{},[37,1763,1764],{},"你自己",[122,1766,1767],{},"自动",[105,1769,1770,1773,1776],{},[122,1771,1772],{},"能否复用 loop",[122,1774,1775],{},"能（建一次跑多次）",[122,1777,1778],{},"不能（每次新建新关）",[105,1780,1781,1784,1787],{},[122,1782,1783],{},"能否嵌套",[122,1785,1786],{},"—（你自己管）",[122,1788,1789,1792,1793],{},[37,1790,1791],{},"不能","，运行中的 loop 里再调会 ",[56,1794,1795],{},"RuntimeError",[105,1797,1798,1801,1804],{},[122,1799,1800],{},"典型用途",[122,1802,1803],{},"框架\u002F库内部精细控制、子线程建 loop",[122,1805,1806],{},"应用程序入口，跑一个顶层协程",[19,1808,1809,1810],{},"一句话：",[37,1811,1812,1814,1815,796,1818,796,1820,1822],{},[56,1813,678],{}," ≈ ",[56,1816,1817],{},"new_event_loop",[56,1819,983],{},[56,1821,1054],{}," 的安全封装。",[183,1824,1826],{"id":1825},"_63-机器人框架设计两个-loop是否合理","6.3 机器人框架设计「两个 loop」是否合理？",[19,1828,1829,1830,1832],{},"回看 NcatBot 的 ",[56,1831,966],{},"，它确实先后用了两个：",[49,1834,1836],{"className":51,"code":1835,"language":53,"meta":54,"style":54},"run_coroutine(self.plugin_loader.load_plugins)    # 临时 loop ①：插件加载，跑完即弃\n...\nasyncio.run(self.adapter.connect_websocket())     # 长驻 loop ②：业务主循环\n",[56,1837,1838,1843,1848],{"__ignoreMap":54},[59,1839,1840],{"class":61,"line":62},[59,1841,1842],{},"run_coroutine(self.plugin_loader.load_plugins)    # 临时 loop ①：插件加载，跑完即弃\n",[59,1844,1845],{"class":61,"line":68},[59,1846,1847],{},"...\n",[59,1849,1850],{"class":61,"line":74},[59,1851,1852],{},"asyncio.run(self.adapter.connect_websocket())     # 长驻 loop ②：业务主循环\n",[19,1854,1855],{},"我的判断分两层：",[19,1857,1858,1861,1862,1865],{},[37,1859,1860],{},"✅ 合理的部分","：生命周期不同的阶段用不同 loop 是正当的。「一次性初始化」和「长期运行」本就是两个阶段，只要两个 loop ",[37,1863,1864],{},"不同时运行、而是先后接力","，工程上完全站得住脚。",[19,1867,1868,1871],{},[37,1869,1870],{},"⚠️ 不优雅的部分","：NcatBot 这个具体实现是历史包袱，而非精心设计。两个味道：",[265,1873,1874,1887],{},[268,1875,1876,1882,1883,1886],{},[37,1877,1878,1881],{},[56,1879,1880],{},"run_coroutine"," 自己都标了「已废弃」","——其源码注释直言「NcatBot 已重构为纯异步架构，此函数使用 ",[56,1884,1885],{},"asyncio.run()"," 会阻塞线程，违背异步并发原则」。即作者自己都认为「插件加载另起一个线程 + 临时 loop」不该保留。",[268,1888,1889,388,1892,1894,1895,1897],{},[37,1890,1891],{},"正是这个设计埋了定时任务的坑",[56,1893,888],{}," 跑在临时 loop ① 上，",[56,1896,341],{}," 起的扫描循环随 ① 关闭而冻结，而长驻的是 ②。统一一个 loop，这坑根本不存在。",[19,1899,1900,388],{},[37,1901,1902],{},"更优雅的设计是「单一长驻 loop」",[49,1904,1907],{"className":1905,"code":1906,"language":435,"meta":54},[433],"asyncio.run(main())\n  └─ loop（唯一且长驻）\n       ├─ await 初始化（加载插件、连数据库……）   # 同一个 loop\n       ├─ create_task(后台任务们)                  # 绑定到这个长驻 loop ✅\n       └─ await 主循环（while True 收消息）         # 同一个 loop\n",[56,1908,1906],{"__ignoreMap":54},[19,1910,1911],{},"一个 loop 从头跑到尾，所有初始化、后台任务、业务循环都挂在它身上，就不会「挂错 loop」。",[19,1913,1914,1915,1918,1919,1922,1923,1926,1927,1930],{},"那「多 loop」什么时候才是真正的合理设计、而非妥协？——",[37,1916,1917],{},"多线程隔离","（阻塞\u002FCPU 密集活丢子线程跑独立 loop）、",[37,1920,1921],{},"库与宿主集成","（某库内部要跑自己的 loop，靠 ",[56,1924,1925],{},"run_coroutine_threadsafe"," 跨 loop 通信）、",[37,1928,1929],{},"多进程隔离","（每进程一个 loop，靠 IPC）。这些是目的明确的架构选择；而 NcatBot 的两个 loop 更像「重构没做完的残留」。",[23,1932],{},[26,1934,1936],{"id":1935},"七小结","七、小结",[390,1938,1939,1945,1951,1961,1968],{},[268,1940,1941,1944],{},[37,1942,1943],{},"异步","用「等 I\u002FO 时让出 CPU」换来单线程的高并发，天生契合 QQ 机器人这种 I\u002FO 密集场景。",[268,1946,1947,1950],{},[37,1948,1949],{},"事件循环","是异步的心脏；协程靠它驱动，Task 绑定它调度，loop 一停任务即冻结。",[268,1952,1953,1956,1957,1960],{},[37,1954,1955],{},"JS\u002FTS"," 因渲染需求采用宏\u002F微任务双队列，",[37,1958,1959],{},"Python"," 用更扁平的单就绪队列 + 定时器堆，但上层心智一致。",[268,1962,1963,1964,1967],{},"真实框架往往",[37,1965,1966],{},"托管多个 loop","，分清「临时初始化 loop」与「长驻业务 loop」，是把长驻后台任务挂对地方的前提——这次定时任务 bug 就是活生生的教训。",[268,1969,1970,1971,1974,1975,1977,1978,1981,1982,181],{},"一个程序",[37,1972,1973],{},"可以有多个 loop","，但同线程同一刻只跑一个；",[56,1976,678],{}," 是 ",[56,1979,1980],{},"new_event_loop + run + close"," 的安全封装；",[37,1983,1984],{},"「单一长驻 loop」通常是比多 loop 更省心的设计",[19,1986,1987,1988,1991],{},"异步不难，难的是搞清楚「",[37,1989,1990],{},"此刻我的代码，跑在哪个 loop 上，那个 loop 还活着吗","」。",[1993,1994,1995],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}",{"title":54,"searchDepth":68,"depth":68,"links":1997},[1998,1999,2005,2009,2015,2027,2033],{"id":28,"depth":68,"text":29},{"id":161,"depth":68,"text":162,"children":2000},[2001,2002,2004],{"id":185,"depth":74,"text":185},{"id":254,"depth":74,"text":2003},"await 到底发生了什么",{"id":318,"depth":74,"text":319},{"id":365,"depth":68,"text":366,"children":2006},[2007,2008],{"id":380,"depth":74,"text":381},{"id":464,"depth":74,"text":465},{"id":601,"depth":68,"text":602,"children":2010},[2011,2012,2013,2014],{"id":608,"depth":74,"text":609},{"id":629,"depth":74,"text":630},{"id":660,"depth":74,"text":661},{"id":671,"depth":74,"text":672},{"id":721,"depth":68,"text":722,"children":2016},[2017,2018,2019,2021,2022,2024,2025,2026],{"id":728,"depth":74,"text":729},{"id":833,"depth":74,"text":834},{"id":874,"depth":74,"text":2020},"5.3 根因：create_task 挂错了 loop",{"id":1024,"depth":74,"text":1025},{"id":1066,"depth":74,"text":2023},"5.5 loop.run_until_complete vs run_frontend() 的本质区别",{"id":1203,"depth":74,"text":1204},{"id":1302,"depth":74,"text":1303},{"id":1441,"depth":74,"text":1442},{"id":1483,"depth":68,"text":1484,"children":2028},[2029,2030,2032],{"id":1490,"depth":74,"text":1491},{"id":1603,"depth":74,"text":2031},"6.2 new_event_loop() 和 asyncio.run(main()) 的区别",{"id":1825,"depth":74,"text":1826},{"id":1935,"depth":68,"text":1936},"\u002Fimages\u002Fposts\u002F26_06_09\u002Fcover.jpg","2026-06-09",false,"md",{},"\u002Fposts\u002F26_06_09\u002Fmain",{"title":6,"description":54},"posts\u002F26_06_09\u002Fmain","巩固对异步编程的理解，记录在AI Agent QQ机器人中定时任务的实现过程",[2044,2045],"notes","开发","nofOd1E3dwhXmWsl9CQr9zkrEOQWm3tQNJm5SsnS39o",{"id":2048,"title":2049,"body":2050,"cover":2122,"date":2123,"description":2054,"draft":2036,"extension":2037,"meta":2124,"navigation":1339,"path":2125,"seo":2126,"stem":2127,"summary":2128,"tags":2129,"__hash__":2132},"posts\u002Fposts\u002Flearning_with_ai\u002Fmain.md","关于AI时代的学习",{"type":8,"value":2051,"toc":2116},[2052,2055,2058,2061,2064,2067,2070,2073,2076,2082,2085,2088,2091,2094,2097,2100,2103,2106,2113],[19,2053,2054],{},"进入 AI 时代之后，很多原本需要上网狂暴搜索半天才能解决的问题，现在可能只要简单描述一下，十几秒就能得到一个看起来还不错的答案。比如报错信息看不懂、某个 API 不会用、想快速了解一个概念的背景，AI 往往都能给出比搜索引擎更直接的反馈。",[19,2056,2057],{},"但这并不意味着学习效率也会以同样夸张的幅度提升。信息获取变快了，不代表理解、内化和实践也会自动变快。很多时候，AI 能帮我们跨过“查资料”的门槛，却不能替我们完成“建立知识结构”和“形成经验”的过程。实际学习中，我们可能还会遇到下面这些困难。",[26,2059,2060],{"id":2060},"不知道自己不知道什么",[19,2062,2063],{},"学习一个新领域时，最难的往往不是某个具体问题，而是不知道这个领域到底由哪些问题组成。如果对整体体系没有概念，就很难向 AI 提出有效的问题。",[19,2065,2066],{},"例如刚开始学 TypeScript 时，可能只会问“TypeScript 怎么学？”或者“interface 和 type 有什么区别？”。这些问题当然能得到答案，但很容易停留在零散知识点上。真正影响长期理解的内容可能包括类型推导、泛型、结构化类型系统、类型收窄、工程配置、和 JavaScript 运行时之间的边界等。如果一开始完全不知道这些主题的存在，就很难主动问到它们。",[19,2068,2069],{},"所以 AI 更像是一个很强的问答对象，而不是天然可靠的学习地图。我们仍然需要先让它帮忙梳理领域轮廓，比如让它列出学习路线、核心概念、常见误区，再基于这些内容逐步深入。",[26,2071,2072],{"id":2072},"缺少实践",[19,2074,2075],{},"有些技能的掌握离不开实践，尤其是写代码。编程能力并不只是“知道某个语法怎么写”，还包括拆解问题、设计结构、定位 bug、理解报错、权衡取舍等一整套经验。",[19,2077,2078,2079,2081],{},"如果过于追求效率，把实现过程全部交给 AI，短期内确实可以更快得到一个能跑的结果，但自己的经验积累可能会被跳过。比如一个异步请求失败了，AI 可以直接告诉你要加 ",[56,2080,173],{},"、要处理异常、要检查接口返回值；但如果每次都只是复制修复方案，而没有自己观察现象、猜测原因、验证假设，那么下次遇到类似问题时，仍然很难独立判断。",[19,2083,2084],{},"更好的方式也许是把 AI 当成陪练：先自己尝试实现，遇到卡点时让 AI 提示思路；修好 bug 之后，再让它解释为什么这样改有效、还有没有其他写法。这样 AI 提供的是反馈和复盘，而不是直接替代练习。",[26,2086,2087],{"id":2087},"知识的诅咒",[19,2089,2090],{},"AI 也可能会有某种“知识的诅咒”：它默认自己知道的东西你也大概知道，于是解释时跳过了关键前提，或是抛出大量“越级”概念；或者反过来，它不知道你的真实基础，又花很多篇幅讲解你已经熟悉的内容。",[19,2092,2093],{},"比如学习 TypeScript 时，如果我已经有一些 Python 异步开发经验，那么“异步函数会返回 Promise”“await 会等待异步结果”这类解释可能就不需要太多篇幅。真正需要补足的，可能是 TypeScript 如何描述 Promise 的类型、泛型在异步函数中的作用、前端框架里异步状态该如何建模。可是如果没有主动说明这些背景，AI 很可能按通用初学者教程来讲，导致信息密度不合适。",[19,2095,2096],{},"因此，和 AI 学习时，描述自己的背景很重要。比起直接问“给我讲讲 TypeScript”，更好的提问可能是：“我有 Python 基础，了解 async\u002Fawait，但不熟悉前端工程和 TypeScript 类型系统。请跳过基础编程概念，重点讲 TypeScript 相比 Python 类型标注最不一样的地方。”",[26,2098,2099],{"id":2099},"个人尝试",[19,2101,2102],{},"目前我也在尝试探索一些更适合自己的 AI 学习方法。受 AI coding 的启发，我发现开发复杂任务时，通常不会一上来就让 AI 写代码，而是先讨论需求、澄清边界、拆分步骤，再进入具体实现。学习复杂知识时，也许可以采用类似的流程。",[19,2104,2105],{},"比如在开始学习一个新领域前，可以先与 AI 讨论，根据自己的背景制定学习计划：我已经会什么、不熟悉什么、目标是能读懂项目还是能独立开发、每天大概能投入多少时间。计划确定之后，再把学习过程拆成一个个小任务，每完成一部分就让 AI 帮忙检查理解是否准确，或者根据练习结果调整后续安排。",[19,2107,2108,2109,2112],{},"在这个过程中，还可以像维护 ",[56,2110,2111],{},"CLAUDE.md"," 一样维护一份“个人学习画像”：已有基础、常见误区、喜欢的解释方式、正在学习的主题、已经完成的练习等。这样 AI 不需要每次从零理解我是谁，也能更快进入合适的讲解深度。",[19,2114,2115],{},"当前正在尝试用这种方法快速学习网站前后端开发、工业级coding agent搭建，也作为该方法的一种实验看看效果如何（体验好的话感觉可以整个skill🥰）",{"title":54,"searchDepth":68,"depth":68,"links":2117},[2118,2119,2120,2121],{"id":2060,"depth":68,"text":2060},{"id":2072,"depth":68,"text":2072},{"id":2087,"depth":68,"text":2087},{"id":2099,"depth":68,"text":2099},"\u002Fimages\u002Fposts\u002Flearning_with_ai\u002Fcover.png","2026-05-27",{},"\u002Fposts\u002Flearning_with_ai\u002Fmain",{"title":2049,"description":2054},"posts\u002Flearning_with_ai\u002Fmain","AI时代如何更高效地学习？",[2130,2131],"杂谈","AI","JqXiYbhXLQh2poEKGXuVZ8IlImulklqOFSkFfW-bSQ0",{"id":2134,"title":2135,"body":2136,"cover":2527,"date":2528,"description":2140,"draft":2036,"extension":2037,"meta":2529,"navigation":1339,"path":2530,"seo":2531,"stem":2532,"summary":2533,"tags":2534,"__hash__":2536},"posts\u002Fposts\u002FFSDP\u002Fmain.md","FSDP-Zero123分片简介",{"type":8,"value":2137,"toc":2517},[2138,2141,2144,2151,2154,2157,2241,2251,2254,2257,2260,2263,2266,2274,2277,2281,2294,2300,2303,2306,2309,2312,2320,2324,2327,2333,2336,2345,2348,2351,2354,2358,2366,2372,2375,2383,2386,2389,2392,2395,2406,2410,2420,2426,2429,2435,2438,2442,2445,2514],[19,2139,2140],{},"随着模型越来越大，单卡显存已经无法满足训练需求，人们使用多种手段来扩大训练规模，如使用混合精度减少参数内存占用，或是使用多卡通信的方法进行并行训练。ZeRO 是常见的数据并行模型状态分片方案，思路为将模型权重、梯度、优化器状态等训练状态分片到多张卡上。",[19,2142,2143],{},"这里需要先区分 ZeRO 分片和 tensor parallel。ZeRO-3 也会分片模型权重，但它分片的是模型状态的常驻存储：每张卡平时只保存一部分权重，真正计算某一层时再临时 AllGather 出这一层需要的完整参数。tensor parallel 则是把一层里的矩阵乘、attention head、MLP hidden dim 等计算本身切开，多张卡共同完成同一个 batch 的同一层计算。ZeRO 的核心问题是“状态放不下，怎么存”，tensor parallel 的核心问题是“单层算不动，怎么切计算”。",[19,2145,2146],{},[2147,2148],"img",{"alt":2149,"src":2150},"全量微调一个7.5B模型时，显存中的参数组成，以及zero并行方案示意图","\u002Fimages\u002Fposts\u002FFSDP\u002Ffsdp_preview.png",[26,2152,2153],{"id":2153},"参数组成",[19,2155,2156],{},"假设使用AdamW + BF16 进行混合精度训练，则在训练过程中，需要存储的参数组成如下：",[99,2158,2159,2175],{},[102,2160,2161],{},[105,2162,2163,2166,2169,2172],{},[108,2164,2165],{},"组件",[108,2167,2168],{},"精度",[108,2170,2171],{},"每参数字节数",[108,2173,2174],{},"用途",[117,2176,2177,2191,2203,2217,2229],{},[105,2178,2179,2182,2185,2188],{},[122,2180,2181],{},"模型权重（计算用）",[122,2183,2184],{},"BF16",[122,2186,2187],{},"2B",[122,2189,2190],{},"前向\u002F反向计算",[105,2192,2193,2196,2198,2200],{},[122,2194,2195],{},"梯度",[122,2197,2184],{},[122,2199,2187],{},[122,2201,2202],{},"反向传播产出，供优化器更新参数",[105,2204,2205,2208,2211,2214],{},[122,2206,2207],{},"FP32 主权重（优化器）",[122,2209,2210],{},"FP32",[122,2212,2213],{},"4B",[122,2215,2216],{},"保存更高精度的参数副本，用于稳定更新",[105,2218,2219,2222,2224,2226],{},[122,2220,2221],{},"一阶动量 m_t",[122,2223,2210],{},[122,2225,2213],{},[122,2227,2228],{},"AdamW 对梯度均值的指数滑动平均",[105,2230,2231,2234,2236,2238],{},[122,2232,2233],{},"二阶动量 v_t",[122,2235,2210],{},[122,2237,2213],{},[122,2239,2240],{},"AdamW 对梯度平方的指数滑动平均",[19,2242,2243,2244,400,2247,2250],{},"BF16 模型权重主要服务于矩阵乘等实际计算，可以显著降低显存和带宽压力；梯度是反向传播阶段产生的临时训练信号，优化器会读取它来决定参数更新方向；FP32 主权重、",[56,2245,2246],{},"m_t",[56,2248,2249],{},"v_t"," 则属于 AdamW 的优化器状态，通常用更高精度保存，以减少长时间训练中的数值误差。",[19,2252,2253],{},"可见训练情况下，参数占用的大头在优化器状态上",[19,2255,2256],{},"不分片时：每张卡都持有完整的一份，即 N × (2+2+4+4+4) = 16N 字节（不算激活值）。",[26,2258,2259],{"id":2259},"训练流程梳理",[19,2261,2262],{},"先不考虑 ZeRO 分片，只看普通多卡 DDP 的训练流程。DDP 中每张 GPU 都持有一份完整的模型权重和优化器状态，但每张卡读取的数据不同。例如 8 卡训练时，一个 global batch 会被切成 8 份 micro-batch，每张卡只负责其中一份。前向传播时，每张卡用自己本地的完整 BF16 模型权重计算输出，并保存反向传播需要的激活值。",[19,2264,2265],{},"反向传播时，每张卡根据自己的 micro-batch 计算本地梯度。此时不同 GPU 上的梯度并不相同，因为它们看到的数据不同。如果直接用各自的梯度更新参数，8 张卡的模型副本会从这一步开始分叉，后续训练就不再等价于单个大 batch 的训练。因此 DDP 会在反向传播过程中对梯度做 AllReduce，把所有卡的梯度求和或平均，并把同步后的梯度写回每张卡本地。",[19,2267,2268,2269,1608,2271,2273],{},"梯度同步完成后，每张卡都拥有相同的模型权重、相同的平均梯度，以及相同的 AdamW 优化器状态。随后每张卡各自在本地执行 optimizer step，读取 BF16 梯度、FP32 主权重、",[56,2270,2246],{},[56,2272,2249],{},"，计算出完全相同的参数更新。因为更新前的状态相同、梯度也已经同步，所以更新后的模型副本仍然保持一致。",[19,2275,2276],{},"这个流程的好处是实现简单、计算并行度高，每张卡都可以独立完成完整模型的前向和反向。代价是显存冗余很大：模型权重、梯度和优化器状态在每张卡上都完整复制了一份。ZeRO 后续要优化的正是这部分冗余存储。",[26,2278,2280],{"id":2279},"zero-stage-1分片优化器状态","ZeRO Stage 1：分片优化器状态",[19,2282,2283,2284,400,2286,2288,2289,400,2291,2293],{},"ZeRO-1 先处理最大的一块冗余：优化器状态。在普通 DDP 里，每张卡不仅保存完整的 BF16 计算权重和 BF16 梯度，还保存完整的 FP32 主权重、",[56,2285,2246],{},[56,2287,2249],{},"。如果使用 AdamW，这三项合起来是 12N 字节，比 BF16 模型权重本身大很多。ZeRO-1 的做法是：BF16 模型权重和 BF16 梯度仍然完整复制在每张卡上，但把 FP32 主权重、",[56,2290,2246],{},[56,2292,2249],{}," 按参数范围切成 8 份，每张卡只负责其中 1 份。",[49,2295,2298],{"className":2296,"code":2297,"language":435},[433],"┌──────────────────────────────────────────────────┐\n│              8 张 GPU 各自持有                      │\n│                                                    │\n│  完整 BF16 模型权重           N × 2B              │\n│  完整 BF16 梯度               N × 2B              │\n│                                                    │\n│  分片优化器状态                                    │\n│     • 1\u002F8 的 FP32 主权重       N\u002F8 × 4B            │\n│     • 1\u002F8 的 m_t              N\u002F8 × 4B             │\n│     • 1\u002F8 的 v_t              N\u002F8 × 4B             │\n└──────────────────────────────────────────────────┘\n",[56,2299,2297],{"__ignoreMap":54},[19,2301,2302],{},"训练时，ZeRO-1 的前向和反向基本仍然像 DDP 一样执行。每张卡都有完整 BF16 权重，所以可以独立完成自己 micro-batch 的 forward\u002Fbackward；反向传播得到的梯度也会像普通 DDP 一样做 AllReduce，使每张卡都拿到完整的平均梯度。",[19,2304,2305],{},"区别发生在 optimizer step。由于 GPU0 只保存第 0 片参数的 FP32 主权重和 AdamW 动量，GPU1 只保存第 1 片，以此类推，所以每张卡只更新自己负责的那 1\u002F8 参数。更新完成后，各卡会把自己更新好的参数分片同步给其他卡，所有卡再把这些分片拼回本地完整的 BF16 模型权重。这样下一轮 forward 时，每张卡仍然能看到完整且一致的模型。",[19,2307,2308],{},"从通信角度看，ZeRO-1 仍然保留 DDP 的梯度 AllReduce；额外需要在参数更新后同步各自更新的参数分片。它的优点是对计算流程改动较小，因为 forward\u002Fbackward 期间每张卡仍有完整模型；缺点是梯度和 BF16 权重还没有省，显存优化只作用在优化器状态上。",[19,2310,2311],{},"每卡显存：2N + 2N + (4N+4N+4N)\u002F8 = 5.5N 字节（vs 原来 16N）",[19,2313,2314,2315,400,2317,2319],{},"节省的是：FP32 主权重、",[56,2316,2246],{},[56,2318,2249],{}," 这 12N 优化器状态从完整保存变成按卡分片。",[26,2321,2323],{"id":2322},"zero-stage-2分片优化器状态-梯度","ZeRO Stage 2：分片优化器状态 + 梯度",[19,2325,2326],{},"ZeRO-2 在 ZeRO-1 的基础上继续观察一个事实：既然每张卡只负责更新 1\u002F8 的参数，那么它其实也只需要这 1\u002F8 参数对应的平均梯度。Stage 1 中每张卡保留完整梯度，是为了和普通 DDP 的流程保持一致；Stage 2 则把这部分也分片掉。",[49,2328,2331],{"className":2329,"code":2330,"language":435},[433],"┌──────────────────────────────────────────────────┐\n│              8 张 GPU 各自持有                      │\n│                                                    │\n│  完整 BF16 模型权重           N × 2B              │\n│                                                    │\n│  分片梯度                                          │\n│     • 1\u002F8 的 BF16 梯度        N\u002F8 × 2B             │\n│                                                    │\n│  分片优化器状态                                    │\n│     • 1\u002F8 的 FP32 主权重       N\u002F8 × 4B            │\n│     • 1\u002F8 的 m_t              N\u002F8 × 4B             │\n│     • 1\u002F8 的 v_t              N\u002F8 × 4B             │\n└──────────────────────────────────────────────────┘\n",[56,2332,2330],{"__ignoreMap":54},[19,2334,2335],{},"普通 DDP 的梯度同步可以理解为“先把所有卡的梯度求和，再把完整结果发回每张卡”。ZeRO-2 把这个过程改成 Reduce-Scatter：仍然对所有卡的梯度求和或平均，但最终结果不是完整复制给每张卡，而是按参数范围 scatter 出去。GPU0 只拿到第 0 片平均梯度，GPU1 只拿到第 1 片平均梯度，刚好和各自保存的优化器状态对应。",[19,2337,2338,2339,2341,2342,2344],{},"这样做在数学上仍然等价于 DDP 的全局平均梯度更新，只是每张卡不再保存自己不负责的梯度部分。随后 optimizer step 和 ZeRO-1 类似：每张卡读取自己的梯度分片、FP32 主权重分片、",[56,2340,2246],{}," 分片和 ",[56,2343,2249],{}," 分片，更新自己负责的参数。因为 BF16 模型权重在 Stage 2 里仍然完整复制在每张卡上，所以更新后的参数分片仍需要同步给所有卡，让每张卡本地的完整 BF16 权重保持一致。",[19,2346,2347],{},"实际实现通常会按 bucket 或按 layer 边反向、边 Reduce-Scatter、边释放梯度缓存，而不是等整个模型的梯度都算完再统一处理。因此下面的公式更接近稳定状态下的模型状态占用；真实峰值还会受到 bucket 大小、梯度累积、通信重叠策略影响。",[19,2349,2350],{},"每卡显存：2N + (2N+4N+4N+4N)\u002F8 = 3.75N 字节",[19,2352,2353],{},"节省的是：在 Stage 1 基础上，梯度也从 2N → 0.25N。",[26,2355,2357],{"id":2356},"zero-stage-3分片一切优化器状态-梯度-模型权重","ZeRO Stage 3：分片一切（优化器状态 + 梯度 + 模型权重）",[19,2359,2360,2361,2341,2363,2365],{},"ZeRO-3 再进一步，把计算用的 BF16 模型权重也分片保存。到这一步，每张卡常驻的模型状态只剩自己负责的 1\u002F8：BF16 参数分片、BF16 梯度分片、FP32 主权重分片、",[56,2362,2246],{},[56,2364,2249],{}," 分片。它和 tensor parallel 的关键区别是：ZeRO-3 并不是把某个矩阵乘的计算永久切给不同 GPU，而是把参数平时分片存放；真正执行某个 module 的计算前，再临时收集出这个 module 需要的完整参数。",[49,2367,2370],{"className":2368,"code":2369,"language":435},[433],"┌──────────────────────────────────────────────────┐\n│              8 张 GPU 各自持有                      │\n│                                                    │\n│  分片 BF16 模型权重           N\u002F8 × 2B            │\n│  分片 BF16 梯度               N\u002F8 × 2B            │\n│  分片 FP32 主权重             N\u002F8 × 4B            │\n│  分片 m_t                     N\u002F8 × 4B            │\n│  分片 v_t                     N\u002F8 × 4B            │\n└──────────────────────────────────────────────────┘\n",[56,2371,2369],{"__ignoreMap":54},[19,2373,2374],{},"前向传播时，框架会在进入某一层或某个 FSDP unit 前，对这一段参数做 AllGather。AllGather 之后，每张卡临时拥有这一层的完整 BF16 权重，于是可以像普通数据并行一样，用自己的 micro-batch 完成这一层计算。计算结束后，非本卡负责的参数分片可以释放，只保留本卡原本负责的 shard。",[19,2376,2377,2378,400,2380,2382],{},"反向传播时也类似。计算某一层的 backward 需要对应的权重，因此可能再次 AllGather 这一层参数；得到梯度后，再用 Reduce-Scatter 把梯度规约并切回各个 rank。最终每张卡只保留自己负责的梯度分片，并用它更新本地的 FP32 主权重、",[56,2379,2246],{},[56,2381,2249],{}," 和 BF16 参数分片。",[19,2384,2385],{},"和 ZeRO-1\u002F2 不同，ZeRO-3 更新完参数后不需要把完整模型权重常驻同步到每张卡，因为每张卡本来就不保存完整权重。下一次 forward 需要某一层参数时，再从各卡当前持有的参数分片 AllGather 出来即可。这个设计把常驻显存压到最低，但把参数通信放进了 forward\u002Fbackward 的关键路径。",[19,2387,2388],{},"每卡显存：(2N+2N+4N+4N+4N)\u002F8 = 2N 字节",[19,2390,2391],{},"这个 2N 指的是理想情况下每卡常驻的模型状态。真实训练峰值会更高，因为某些时刻会临时持有当前层 AllGather 出来的完整参数、通信 bucket、prefetch 参数以及激活值。ZeRO-3 的核心取舍就是：用更多、更频繁的参数 AllGather，换取模型状态显存近似按数据并行卡数线性下降。",[26,2393,2394],{"id":2394},"通信代价的直觉",[390,2396,2397,2400,2403],{},[268,2398,2399],{},"ZeRO-1：梯度同步仍然接近普通 DDP，额外多了参数更新后的同步，整体通信压力通常可控。",[268,2401,2402],{},"ZeRO-2：用 Reduce-Scatter 替代 AllReduce 梯度同步，通信总量和 DDP 同阶，但每张卡只保留梯度分片。",[268,2404,2405],{},"ZeRO-3：前向和反向会按层 AllGather 参数，反向后再 Reduce-Scatter 梯度。它的显存收益最大，但速度更依赖网络带宽、分片粒度、通信计算重叠和框架实现。",[26,2407,2409],{"id":2408},"zero分片与张量并行组合使用","Zero分片与张量并行组合使用",[19,2411,2412,2413,400,2416,2419],{},"假设有 8 张 GPU，设置 ",[56,2414,2415],{},"tp_size=2",[56,2417,2418],{},"dp_size=4","，那么可以把相邻两张卡组成一个 TP group，共同切分同一个模型副本中的张量计算：",[49,2421,2424],{"className":2422,"code":2423,"language":435,"meta":54},[433],"TP groups:\n  [GPU0, GPU1], [GPU2, GPU3], [GPU4, GPU5], [GPU6, GPU7]\n",[56,2425,2423],{"__ignoreMap":54},[19,2427,2428],{},"这 4 个 TP group 之间再构成数据并行关系，每个 group 处理不同的 mini-batch。为了让同一个 tensor-parallel 分片位置之间同步梯度或分片优化器状态，通常会按 TP rank 位置组成 DP group：",[49,2430,2433],{"className":2431,"code":2432,"language":435,"meta":54},[433],"DP groups:\n  [GPU0, GPU2, GPU4, GPU6]  # 每个模型副本里的第 0 个 TP 分片\n  [GPU1, GPU3, GPU5, GPU7]  # 每个模型副本里的第 1 个 TP 分片\n",[56,2434,2432],{"__ignoreMap":54},[19,2436,2437],{},"这样，一层内部的计算由 TP group 内的两张卡协作完成；不同数据 batch 之间的梯度同步、优化器状态分片，则发生在 DP group 内。ZeRO\u002FFSDP 通常作用在 DP group 维度上，而不是替代 tensor parallel 的算子切分。",[26,2439,2441],{"id":2440},"fsdp-和-zero-的关系","FSDP 和 ZeRO 的关系",[19,2443,2444],{},"PyTorch FSDP 可以理解为 ZeRO 思路在 PyTorch 里的实现之一。常见对应关系是：",[99,2446,2447,2460],{},[102,2448,2449],{},[105,2450,2451,2454,2457],{},[108,2452,2453],{},"FSDP 策略",[108,2455,2456],{},"近似对应",[108,2458,2459],{},"说明",[117,2461,2462,2475,2488,2501],{},[105,2463,2464,2469,2472],{},[122,2465,2466],{},[56,2467,2468],{},"NO_SHARD",[122,2470,2471],{},"DDP",[122,2473,2474],{},"不分片参数、梯度和优化器状态",[105,2476,2477,2482,2485],{},[122,2478,2479],{},[56,2480,2481],{},"SHARD_GRAD_OP",[122,2483,2484],{},"ZeRO-2",[122,2486,2487],{},"分片梯度和优化器状态，参数在计算时保持完整",[105,2489,2490,2495,2498],{},[122,2491,2492],{},[56,2493,2494],{},"FULL_SHARD",[122,2496,2497],{},"ZeRO-3",[122,2499,2500],{},"分片参数、梯度和优化器状态",[105,2502,2503,2508,2511],{},[122,2504,2505],{},[56,2506,2507],{},"HYBRID_SHARD",[122,2509,2510],{},"ZeRO-3 + 层级分组",[122,2512,2513],{},"组内分片，组间复制，常用于多机多卡",[19,2515,2516],{},"实际训练时还需要额外考虑激活值、通信 bucket、临时 AllGather 出来的完整参数、CUDA allocator 碎片以及 checkpoint 保存方式，所以这些公式更适合用来建立显存组成的直觉，而不是直接等同于最终峰值显存。",{"title":54,"searchDepth":68,"depth":68,"links":2518},[2519,2520,2521,2522,2523,2524,2525,2526],{"id":2153,"depth":68,"text":2153},{"id":2259,"depth":68,"text":2259},{"id":2279,"depth":68,"text":2280},{"id":2322,"depth":68,"text":2323},{"id":2356,"depth":68,"text":2357},{"id":2394,"depth":68,"text":2394},{"id":2408,"depth":68,"text":2409},{"id":2440,"depth":68,"text":2441},"\u002Fimages\u002Fposts\u002FFSDP\u002Fcover.png","2026-04-16",{},"\u002Fposts\u002Ffsdp\u002Fmain",{"title":2135,"description":2140},"posts\u002FFSDP\u002Fmain","zero并行训练方案简介",[2044,2535],"后训练","LXnMqZsxJNTbq709BfEfelc-WUxUk4q9kvBXhrGS_GQ",{"id":2538,"title":2539,"body":2540,"cover":3905,"date":2528,"description":2544,"draft":2036,"extension":2037,"meta":3906,"navigation":1339,"path":3907,"seo":3908,"stem":3909,"summary":2539,"tags":3910,"__hash__":3911},"posts\u002Fposts\u002Fmix_precision\u002Fmain.md","混合精度训练简介",{"type":8,"value":2541,"toc":3899},[2542,2545,2548,2551,2843,2846,2849,2856,2859,2866,2876,2879,2882,2888,2891,3280,3283,3434,3437,3441,3587,3590,3698,3712,3715,3729,3733,3736,3739,3883,3886,3889,3896],[19,2543,2544],{},"传统的模型训练一般使用FP32精度进行训练。近年来，由于模型越来越大，我们希望在有限的显存中训练更大的模型，也希望能够提高训练速度，因此，人们开始研究如何在混合使用低精度的参数进行模型训练时，保持模型的性能。",[26,2546,2547],{"id":2547},"浮点数据类型介绍",[19,2549,2550],{},"要理解混合精度训练，最好先把“浮点数”这件事想清楚。计算机里存一个浮点数，并不是直接把十进制小数塞进去，而是把它拆成三块：符号位决定正负，指数位决定这个数大概落在哪个数量级，尾数位决定这个数量级里还能分得多细。粗略写出来就是：",[59,2552,2555,2657],{"className":2553},[2554],"katex",[59,2556,2559],{"className":2557},[2558],"katex-mathml",[2560,2561,2563],"math",{"xmlns":2562},"http:\u002F\u002Fwww.w3.org\u002F1998\u002FMath\u002FMathML",[2564,2565,2566,2652],"semantics",{},[2567,2568,2569,2574,2577,2581,2602,2605,2608,2611,2613,2616,2618,2620,2622,2624,2626],"mrow",{},[2570,2571,2573],"mo",{"stretchy":2572},"false","(",[2570,2575,2576],{},"−",[2578,2579,2580],"mn",{},"1",[2582,2583,2584,2587],"msup",{},[2570,2585,2586],{"stretchy":2572},")",[2567,2588,2589,2593,2596,2599],{},[2590,2591,2592],"mi",{},"s",[2590,2594,2595],{},"i",[2590,2597,2598],{},"g",[2590,2600,2601],{},"n",[2570,2603,2604],{},"×",[2590,2606,2607],{},"m",[2590,2609,2610],{},"a",[2590,2612,2601],{},[2590,2614,2615],{},"t",[2590,2617,2595],{},[2590,2619,2592],{},[2590,2621,2592],{},[2590,2623,2610],{},[2570,2625,2604],{},[2582,2627,2628,2631],{},[2578,2629,2630],{},"2",[2567,2632,2633,2636,2639,2641,2644,2646,2648,2650],{},[2590,2634,2635],{},"e",[2590,2637,2638],{},"x",[2590,2640,19],{},[2590,2642,2643],{},"o",[2590,2645,2601],{},[2590,2647,2635],{},[2590,2649,2601],{},[2590,2651,2615],{},[2653,2654,2656],"annotation",{"encoding":2655},"application\u002Fx-tex","(-1)^{sign} \\times mantissa \\times 2^{exponent}\n",[59,2658,2662,2750,2783],{"className":2659,"ariaHidden":2661},[2660],"katex-html","true",[59,2663,2666,2671,2675,2679,2682,2738,2743,2747],{"className":2664},[2665],"base",[59,2667],{"className":2668,"style":2670},[2669],"strut","height:1.0747em;vertical-align:-0.25em;",[59,2672,2573],{"className":2673},[2674],"mopen",[59,2676,2576],{"className":2677},[2678],"mord",[59,2680,2580],{"className":2681},[2678],[59,2683,2686,2689],{"className":2684},[2685],"mclose",[59,2687,2586],{"className":2688},[2685],[59,2690,2693],{"className":2691},[2692],"msupsub",[59,2694,2697],{"className":2695},[2696],"vlist-t",[59,2698,2701],{"className":2699},[2700],"vlist-r",[59,2702,2706],{"className":2703,"style":2705},[2704],"vlist","height:0.8247em;",[59,2707,2709,2714],{"style":2708},"top:-3.063em;margin-right:0.05em;",[59,2710],{"className":2711,"style":2713},[2712],"pstrut","height:2.7em;",[59,2715,2721],{"className":2716},[2717,2718,2719,2720],"sizing","reset-size6","size3","mtight",[59,2722,2724,2728,2731,2735],{"className":2723},[2678,2720],[59,2725,2592],{"className":2726},[2678,2727,2720],"mathnormal",[59,2729,2595],{"className":2730},[2678,2727,2720],[59,2732,2598],{"className":2733,"style":2734},[2678,2727,2720],"margin-right:0.0359em;",[59,2736,2601],{"className":2737},[2678,2727,2720],[59,2739],{"className":2740,"style":2742},[2741],"mspace","margin-right:0.2222em;",[59,2744,2604],{"className":2745},[2746],"mbin",[59,2748],{"className":2749,"style":2742},[2741],[59,2751,2753,2757,2761,2764,2767,2771,2774,2777,2780],{"className":2752},[2665],[59,2754],{"className":2755,"style":2756},[2669],"height:0.7429em;vertical-align:-0.0833em;",[59,2758,2760],{"className":2759},[2678,2727],"man",[59,2762,2615],{"className":2763},[2678,2727],[59,2765,2595],{"className":2766},[2678,2727],[59,2768,2770],{"className":2769},[2678,2727],"ss",[59,2772,2610],{"className":2773},[2678,2727],[59,2775],{"className":2776,"style":2742},[2741],[59,2778,2604],{"className":2779},[2746],[59,2781],{"className":2782,"style":2742},[2741],[59,2784,2786,2790],{"className":2785},[2665],[59,2787],{"className":2788,"style":2789},[2669],"height:0.7936em;",[59,2791,2793,2796],{"className":2792},[2678],[59,2794,2630],{"className":2795},[2678],[59,2797,2799],{"className":2798},[2692],[59,2800,2802],{"className":2801},[2696],[59,2803,2805],{"className":2804},[2700],[59,2806,2808],{"className":2807,"style":2789},[2704],[59,2809,2810,2813],{"style":2708},[59,2811],{"className":2812,"style":2713},[2712],[59,2814,2816],{"className":2815},[2717,2718,2719,2720],[59,2817,2819,2822,2825,2828,2831,2834,2837,2840],{"className":2818},[2678,2720],[59,2820,2635],{"className":2821},[2678,2727,2720],[59,2823,2638],{"className":2824},[2678,2727,2720],[59,2826,19],{"className":2827},[2678,2727,2720],[59,2829,2643],{"className":2830},[2678,2727,2720],[59,2832,2601],{"className":2833},[2678,2727,2720],[59,2835,2635],{"className":2836},[2678,2727,2720],[59,2838,2601],{"className":2839},[2678,2727,2720],[59,2841,2615],{"className":2842},[2678,2727,2720],[19,2844,2845],{},"所以同样是一个 16 bit 或 32 bit 的数字，指数位多一点，能表示的范围就更大；尾数位多一点，相邻两个可表示数字之间就更密，数值就更精细。混合精度训练里的很多选择，本质上都是在这两个方向之间做取舍：我们到底更怕数值溢出，还是更怕舍入误差。",[19,2847,2848],{},"FP32 是最传统的训练精度，它用 1 bit 存符号、8 bit 存指数、23 bit 存尾数，一共 4 bytes。它的好处是范围够大、精度也够细，所以不管是前向计算、反向梯度，还是优化器里长期累积的小更新，都比较稳。问题也很直接：每个参数 4 bytes，模型一大，权重、梯度、优化器状态很快就把显存吃完；同时 GPU 做矩阵乘时，低精度 Tensor Core 往往能给出更高吞吐，继续全程 FP32 就显得有点浪费。",[19,2850,2851,2852,2855],{},"FP16 看起来像是最自然的压缩版本：它只有 2 bytes，显存直接减半，而且 10 bit 尾数比 BF16 更细。但 FP16 把指数位压到了 5 bit，动态范围小很多；按照 IEEE FP16 的编码规则，它的最大有限值是 65504。训练里一旦激活值、loss scale 或梯度中间值稍微大一点，就容易上溢成 ",[56,2853,2854],{},"inf","；梯度太小的时候又容易下溢成 0。因此早期 FP16 训练通常要配合 loss scaling，把 loss 先放大再反传，尽量让梯度落在 FP16 能表示的区间里。",[19,2857,2858],{},"BF16 的设计思路刚好不一样。它同样是 2 bytes，但保留了和 FP32 一样的 8 bit 指数，只把尾数缩到了 7 bit。也就是说，BF16 的动态范围几乎和 FP32 一样，训练中不太容易因为量级问题直接爆掉；代价是同一个数量级里的刻度更粗，精度比 FP16 还低。这个取舍非常适合深度学习里的矩阵乘：神经网络本身对一点舍入噪声比较耐受，而大范围可以显著减少上溢、下溢问题。所以现在做大模型训练或微调时，BF16 往往比 FP16 更省心。",[19,2860,2861,2862,2865],{},"这也解释了为什么后文会把 BF16 放在前向和反向传播里，却仍然让优化器保存 FP32 主权重。前向\u002F反向主要是大批量矩阵乘和激活函数，低精度误差通常会被模型和 batch 噪声“吃掉”；优化器更新则是在做长期累加，很多时候是把一个很小的 ",[56,2863,2864],{},"lr * grad"," 加到一个已经存在的权重上。如果权重只保存在 BF16 里，这个微小更新可能连最小刻度都碰不到，直接被舍入掉。于是计算可以低精度，状态累积仍然要高精度，这就是混合精度训练里最核心的分工。",[19,2867,2868,2869,1608,2872,2875],{},"再往下压就是 FP8。PyTorch 里常见的两个 FP8 类型是 ",[56,2870,2871],{},"torch.float8_e4m3fn",[56,2873,2874],{},"torch.float8_e5m2","。前者可以理解成 4 bit 指数、3 bit 尾数，精度稍好但范围更小；后者是 5 bit 指数、2 bit 尾数，范围更大但刻度更粗。实际训练里，FP8 通常不会像 BF16 那样直接替换所有计算，而是要配合 scaling，把张量按块或按通道缩放到合适区间，再交给硬件做低精度矩阵乘。直觉上可以把它看成比 BF16 更激进的带宽和算力优化：收益更大，但对数值缩放、算子支持和训练框架的要求也更高。",[26,2877,2878],{"id":2878},"混合精度训练的完整工作流",[19,2880,2881],{},"下面以 AdamW + BF16 计算为例。先说明一下，这里讨论的是大模型训练里很常见的一种实现方式：前向、反向使用 BF16 权重和激活来降低显存与带宽压力，优化器内部仍然维护 FP32 主权重和 FP32 动量状态。不同框架的细节会有差异，比如 PyTorch AMP 可能让参数本体保持 FP32，只在算子执行时临时 autocast，但背后的数值逻辑是一样的：计算可以低精度，长期累积的训练状态最好保留高精度。",[19,2883,2884],{},[2147,2885],{"alt":2886,"src":2887},"混合精度训练流程（BF16计算，FP32优化器）","\u002Fimages\u002Fposts\u002Fmix_precision\u002Fmix_precision.png",[19,2889,2890],{},"如果只看模型状态，不考虑激活值，一个参数在 AdamW + BF16 混合精度全量微调中大致会占：",[59,2892,2894,2989],{"className":2893},[2554],[59,2895,2897],{"className":2896},[2558],[2560,2898,2899],{"xmlns":2562},[2564,2900,2901,2986],{},[2567,2902,2903,2905,2909,2911,2914,2916,2919,2921,2923,2925,2928,2930,2932,2935,2937,2939,2942,2944,2946,2948,2950,2952,2959,2961,2963,2965,2967,2969,2976,2978,2981,2984],{},[2578,2904,2630],{},[2906,2907,2908],"mtext",{},"B",[2570,2910,2573],{"stretchy":2572},[2906,2912,2913],{},"BF16 权重",[2570,2915,2586],{"stretchy":2572},[2570,2917,2918],{},"+",[2578,2920,2630],{},[2906,2922,2908],{},[2570,2924,2573],{"stretchy":2572},[2906,2926,2927],{},"BF16 梯度",[2570,2929,2586],{"stretchy":2572},[2570,2931,2918],{},[2578,2933,2934],{},"4",[2906,2936,2908],{},[2570,2938,2573],{"stretchy":2572},[2906,2940,2941],{},"FP32 主权重",[2570,2943,2586],{"stretchy":2572},[2570,2945,2918],{},[2578,2947,2934],{},[2906,2949,2908],{},[2570,2951,2573],{"stretchy":2572},[2953,2954,2955,2957],"msub",{},[2590,2956,2607],{},[2590,2958,2615],{},[2570,2960,2586],{"stretchy":2572},[2570,2962,2918],{},[2578,2964,2934],{},[2906,2966,2908],{},[2570,2968,2573],{"stretchy":2572},[2953,2970,2971,2974],{},[2590,2972,2973],{},"v",[2590,2975,2615],{},[2570,2977,2586],{"stretchy":2572},[2570,2979,2980],{},"=",[2578,2982,2983],{},"16",[2906,2985,2908],{},[2653,2987,2988],{"encoding":2655},"2\\text{B}(\\text{BF16 权重}) + 2\\text{B}(\\text{BF16 梯度}) + 4\\text{B}(\\text{FP32 主权重}) + 4\\text{B}(m_t) + 4\\text{B}(v_t) = 16\\text{B}\n",[59,2990,2992,3035,3074,3115,3191,3264],{"className":2991,"ariaHidden":2661},[2660],[59,2993,2995,2999,3002,3008,3011,3023,3026,3029,3032],{"className":2994},[2665],[59,2996],{"className":2997,"style":2998},[2669],"height:1em;vertical-align:-0.25em;",[59,3000,2630],{"className":3001},[2678],[59,3003,3005],{"className":3004},[2678,435],[59,3006,2908],{"className":3007},[2678],[59,3009,2573],{"className":3010},[2674],[59,3012,3014,3018],{"className":3013},[2678,435],[59,3015,3017],{"className":3016},[2678],"BF16 ",[59,3019,3022],{"className":3020},[2678,3021],"cjk_fallback","权重",[59,3024,2586],{"className":3025},[2685],[59,3027],{"className":3028,"style":2742},[2741],[59,3030,2918],{"className":3031},[2746],[59,3033],{"className":3034,"style":2742},[2741],[59,3036,3038,3041,3044,3050,3053,3062,3065,3068,3071],{"className":3037},[2665],[59,3039],{"className":3040,"style":2998},[2669],[59,3042,2630],{"className":3043},[2678],[59,3045,3047],{"className":3046},[2678,435],[59,3048,2908],{"className":3049},[2678],[59,3051,2573],{"className":3052},[2674],[59,3054,3056,3059],{"className":3055},[2678,435],[59,3057,3017],{"className":3058},[2678],[59,3060,2195],{"className":3061},[2678,3021],[59,3063,2586],{"className":3064},[2685],[59,3066],{"className":3067,"style":2742},[2741],[59,3069,2918],{"className":3070},[2746],[59,3072],{"className":3073,"style":2742},[2741],[59,3075,3077,3080,3083,3089,3092,3103,3106,3109,3112],{"className":3076},[2665],[59,3078],{"className":3079,"style":2998},[2669],[59,3081,2934],{"className":3082},[2678],[59,3084,3086],{"className":3085},[2678,435],[59,3087,2908],{"className":3088},[2678],[59,3090,2573],{"className":3091},[2674],[59,3093,3095,3099],{"className":3094},[2678,435],[59,3096,3098],{"className":3097},[2678],"FP32 ",[59,3100,3102],{"className":3101},[2678,3021],"主权重",[59,3104,2586],{"className":3105},[2685],[59,3107],{"className":3108,"style":2742},[2741],[59,3110,2918],{"className":3111},[2746],[59,3113],{"className":3114,"style":2742},[2741],[59,3116,3118,3121,3124,3130,3133,3179,3182,3185,3188],{"className":3117},[2665],[59,3119],{"className":3120,"style":2998},[2669],[59,3122,2934],{"className":3123},[2678],[59,3125,3127],{"className":3126},[2678,435],[59,3128,2908],{"className":3129},[2678],[59,3131,2573],{"className":3132},[2674],[59,3134,3136,3139],{"className":3135},[2678],[59,3137,2607],{"className":3138},[2678,2727],[59,3140,3142],{"className":3141},[2692],[59,3143,3146,3170],{"className":3144},[2696,3145],"vlist-t2",[59,3147,3149,3165],{"className":3148},[2700],[59,3150,3153],{"className":3151,"style":3152},[2704],"height:0.2806em;",[59,3154,3156,3159],{"style":3155},"top:-2.55em;margin-left:0em;margin-right:0.05em;",[59,3157],{"className":3158,"style":2713},[2712],[59,3160,3162],{"className":3161},[2717,2718,2719,2720],[59,3163,2615],{"className":3164},[2678,2727,2720],[59,3166,3169],{"className":3167},[3168],"vlist-s","​",[59,3171,3173],{"className":3172},[2700],[59,3174,3177],{"className":3175,"style":3176},[2704],"height:0.15em;",[59,3178],{},[59,3180,2586],{"className":3181},[2685],[59,3183],{"className":3184,"style":2742},[2741],[59,3186,2918],{"className":3187},[2746],[59,3189],{"className":3190,"style":2742},[2741],[59,3192,3194,3197,3200,3206,3209,3250,3253,3257,3261],{"className":3193},[2665],[59,3195],{"className":3196,"style":2998},[2669],[59,3198,2934],{"className":3199},[2678],[59,3201,3203],{"className":3202},[2678,435],[59,3204,2908],{"className":3205},[2678],[59,3207,2573],{"className":3208},[2674],[59,3210,3212,3215],{"className":3211},[2678],[59,3213,2973],{"className":3214,"style":2734},[2678,2727],[59,3216,3218],{"className":3217},[2692],[59,3219,3221,3242],{"className":3220},[2696,3145],[59,3222,3224,3239],{"className":3223},[2700],[59,3225,3227],{"className":3226,"style":3152},[2704],[59,3228,3230,3233],{"style":3229},"top:-2.55em;margin-left:-0.0359em;margin-right:0.05em;",[59,3231],{"className":3232,"style":2713},[2712],[59,3234,3236],{"className":3235},[2717,2718,2719,2720],[59,3237,2615],{"className":3238},[2678,2727,2720],[59,3240,3169],{"className":3241},[3168],[59,3243,3245],{"className":3244},[2700],[59,3246,3248],{"className":3247,"style":3176},[2704],[59,3249],{},[59,3251,2586],{"className":3252},[2685],[59,3254],{"className":3255,"style":3256},[2741],"margin-right:0.2778em;",[59,3258,2980],{"className":3259},[3260],"mrel",[59,3262],{"className":3263,"style":3256},[2741],[59,3265,3267,3271,3274],{"className":3266},[2665],[59,3268],{"className":3269,"style":3270},[2669],"height:0.6833em;",[59,3272,2983],{"className":3273},[2678],[59,3275,3277],{"className":3276},[2678,435],[59,3278,2908],{"className":3279},[2678],[19,3281,3282],{},"所以一个常用的粗估是：",[59,3284,3286,3324],{"className":3285},[2554],[59,3287,3289],{"className":3288},[2558],[2560,3290,3291],{"xmlns":2562},[2564,3292,3293,3321],{},[2567,3294,3295,3298,3301,3304,3306,3308,3311,3313,3316,3318],{},[2906,3296,3297],{},"显存",[2570,3299,3300],{},"≈",[2906,3302,3303],{},"参数量",[2570,3305,2604],{},[2578,3307,2983],{},[2906,3309,3310],{}," bytes",[2570,3312,2918],{},[2906,3314,3315],{},"激活值",[2570,3317,2918],{},[2906,3319,3320],{},"临时 buffer",[2653,3322,3323],{"encoding":2655},"\\text{显存} \\approx \\text{参数量} \\times 16\\text{ bytes} + \\text{激活值} + \\text{临时 buffer}\n",[59,3325,3327,3348,3370,3395,3416],{"className":3326,"ariaHidden":2661},[2660],[59,3328,3330,3333,3339,3342,3345],{"className":3329},[2665],[59,3331],{"className":3332,"style":3270},[2669],[59,3334,3336],{"className":3335},[2678,435],[59,3337,3297],{"className":3338},[2678,3021],[59,3340],{"className":3341,"style":3256},[2741],[59,3343,3300],{"className":3344},[3260],[59,3346],{"className":3347,"style":3256},[2741],[59,3349,3351,3355,3361,3364,3367],{"className":3350},[2665],[59,3352],{"className":3353,"style":3354},[2669],"height:0.7667em;vertical-align:-0.0833em;",[59,3356,3358],{"className":3357},[2678,435],[59,3359,3303],{"className":3360},[2678,3021],[59,3362],{"className":3363,"style":2742},[2741],[59,3365,2604],{"className":3366},[2746],[59,3368],{"className":3369,"style":2742},[2741],[59,3371,3373,3377,3380,3386,3389,3392],{"className":3372},[2665],[59,3374],{"className":3375,"style":3376},[2669],"height:0.8889em;vertical-align:-0.1944em;",[59,3378,2983],{"className":3379},[2678],[59,3381,3383],{"className":3382},[2678,435],[59,3384,3310],{"className":3385},[2678],[59,3387],{"className":3388,"style":2742},[2741],[59,3390,2918],{"className":3391},[2746],[59,3393],{"className":3394,"style":2742},[2741],[59,3396,3398,3401,3407,3410,3413],{"className":3397},[2665],[59,3399],{"className":3400,"style":3354},[2669],[59,3402,3404],{"className":3403},[2678,435],[59,3405,3315],{"className":3406},[2678,3021],[59,3408],{"className":3409,"style":2742},[2741],[59,3411,2918],{"className":3412},[2746],[59,3414],{"className":3415,"style":2742},[2741],[59,3417,3419,3423],{"className":3418},[2665],[59,3420],{"className":3421,"style":3422},[2669],"height:0.6944em;",[59,3424,3426,3430],{"className":3425},[2678,435],[59,3427,3429],{"className":3428},[2678,3021],"临时",[59,3431,3433],{"className":3432},[2678]," buffer",[19,3435,3436],{},"实际估算时我一般会把模型状态按 16 到 18 bytes\u002Fparam 留余量，因为不同实现里还会有参数对齐、通信 bucket、梯度临时副本、fused optimizer workspace 等额外开销。真正容易被低估的往往是激活值，它和 sequence length、micro batch size、是否开启 activation checkpointing 强相关；有时候模型状态算得很准，最后还是爆在激活值上。",[26,3438,3440],{"id":3439},"为什么优化器必须保存-fp32-主权重","为什么优化器必须保存 FP32 主权重？",[19,3442,3443,3444,3515,3516,3586],{},"最核心的原因是权重更新通常非常小，而 BF16 在某个数值附近能分辨的刻度又比较粗。BF16 只有 7 bit 显式尾数，算上隐含的最高位，大约可以理解成 8 bit 有效精度；在 1.0 附近，相邻两个 BF16 数之间的间隔约为 ",[59,3445,3447,3470],{"className":3446},[2554],[59,3448,3450],{"className":3449},[2558],[2560,3451,3452],{"xmlns":2562},[2564,3453,3454,3467],{},[2567,3455,3456],{},[2582,3457,3458,3460],{},[2578,3459,2630],{},[2567,3461,3462,3464],{},[2570,3463,2576],{},[2578,3465,3466],{},"7",[2653,3468,3469],{"encoding":2655},"2^{-7}",[59,3471,3473],{"className":3472,"ariaHidden":2661},[2660],[59,3474,3476,3480],{"className":3475},[2665],[59,3477],{"className":3478,"style":3479},[2669],"height:0.8141em;",[59,3481,3483,3486],{"className":3482},[2678],[59,3484,2630],{"className":3485},[2678],[59,3487,3489],{"className":3488},[2692],[59,3490,3492],{"className":3491},[2696],[59,3493,3495],{"className":3494},[2700],[59,3496,3498],{"className":3497,"style":3479},[2704],[59,3499,3500,3503],{"style":2708},[59,3501],{"className":3502,"style":2713},[2712],[59,3504,3506],{"className":3505},[2717,2718,2719,2720],[59,3507,3509,3512],{"className":3508},[2678,2720],[59,3510,2576],{"className":3511},[2678,2720],[59,3513,3466],{"className":3514},[2678,2720],"，也就是 0.0078125。FP32 的尾数细得多，在 1.0 附近的间隔约为 ",[59,3517,3519,3542],{"className":3518},[2554],[59,3520,3522],{"className":3521},[2558],[2560,3523,3524],{"xmlns":2562},[2564,3525,3526,3539],{},[2567,3527,3528],{},[2582,3529,3530,3532],{},[2578,3531,2630],{},[2567,3533,3534,3536],{},[2570,3535,2576],{},[2578,3537,3538],{},"23",[2653,3540,3541],{"encoding":2655},"2^{-23}",[59,3543,3545],{"className":3544,"ariaHidden":2661},[2660],[59,3546,3548,3551],{"className":3547},[2665],[59,3549],{"className":3550,"style":3479},[2669],[59,3552,3554,3557],{"className":3553},[2678],[59,3555,2630],{"className":3556},[2678],[59,3558,3560],{"className":3559},[2692],[59,3561,3563],{"className":3562},[2696],[59,3564,3566],{"className":3565},[2700],[59,3567,3569],{"className":3568,"style":3479},[2704],[59,3570,3571,3574],{"style":2708},[59,3572],{"className":3573,"style":2713},[2712],[59,3575,3577],{"className":3576},[2717,2718,2719,2720],[59,3578,3580,3583],{"className":3579},[2678,2720],[59,3581,2576],{"className":3582},[2678,2720],[59,3584,3538],{"className":3585},[2678,2720],"，也就是 1.19e-7。",[19,3588,3589],{},"训练里一次参数更新大概长这样：",[59,3591,3593,3631],{"className":3592},[2554],[59,3594,3596],{"className":3595},[2558],[2560,3597,3598],{"xmlns":2562},[2564,3599,3600,3628],{},[2567,3601,3602,3606,3609,3611,3614,3617,3619,3621,3623,3625],{},[2590,3603,3605],{"mathvariant":3604},"normal","Δ",[2590,3607,3608],{},"w",[2570,3610,2980],{},[2590,3612,3613],{},"l",[2590,3615,3616],{},"r",[2570,3618,2604],{},[2590,3620,2598],{},[2590,3622,3616],{},[2590,3624,2610],{},[2590,3626,3627],{},"d",[2653,3629,3630],{"encoding":2655},"\\Delta w = lr \\times grad\n",[59,3632,3634,3656,3680],{"className":3633,"ariaHidden":2661},[2660],[59,3635,3637,3640,3643,3647,3650,3653],{"className":3636},[2665],[59,3638],{"className":3639,"style":3270},[2669],[59,3641,3605],{"className":3642},[2678],[59,3644,3608],{"className":3645,"style":3646},[2678,2727],"margin-right:0.0269em;",[59,3648],{"className":3649,"style":3256},[2741],[59,3651,2980],{"className":3652},[3260],[59,3654],{"className":3655,"style":3256},[2741],[59,3657,3659,3663,3667,3671,3674,3677],{"className":3658},[2665],[59,3660],{"className":3661,"style":3662},[2669],"height:0.7778em;vertical-align:-0.0833em;",[59,3664,3613],{"className":3665,"style":3666},[2678,2727],"margin-right:0.0197em;",[59,3668,3616],{"className":3669,"style":3670},[2678,2727],"margin-right:0.0278em;",[59,3672],{"className":3673,"style":2742},[2741],[59,3675,2604],{"className":3676},[2746],[59,3678],{"className":3679,"style":2742},[2741],[59,3681,3683,3686,3689,3692,3695],{"className":3682},[2665],[59,3684],{"className":3685,"style":3376},[2669],[59,3687,2598],{"className":3688,"style":2734},[2678,2727],[59,3690,3616],{"className":3691,"style":3670},[2678,2727],[59,3693,2610],{"className":3694},[2678,2727],[59,3696,3627],{"className":3697},[2678,2727],[19,3699,3700,3701,3704,3705,3708,3709,181],{},"如果学习率是 1e-5，梯度量级又差不多是 1，那么这一步更新就是 1e-5。对于 FP32 来说，这个变化明显大于 1.0 附近的最小刻度，所以权重会真实发生变化；虽然十进制的 ",[56,3702,3703],{},"1.00001"," 本身不一定能被二进制浮点数精确表示，但这个更新不会被整个抹掉。对于 BF16 来说就不同了，1e-5 远小于 0.0078125，",[56,3706,3707],{},"1.0 + 0.00001"," 四舍五入之后仍然很可能回到 ",[56,3710,3711],{},"1.0",[19,3713,3714],{},"这就是所谓的 swamping problem：大数加小数时，小数被低精度格式的刻度吞掉。它麻烦的地方不在于某一步误差稍微大一点，而在于训练本来就是几千步、几万步的小更新累积。如果每一步都在 BF16 权重上直接更新，大量微小变化会反复消失，模型看起来在反传，实际上参数没有按预期移动。",[19,3716,3717,3718,1608,3720,3722,3723,3725,3726,3728],{},"AdamW 里的 ",[56,3719,2246],{},[56,3721,2249],{}," 也有类似问题。它们是梯度的一阶、二阶指数滑动平均，本质上也是长期状态。如果这些状态本身就用低精度保存，早期的细小变化和后期的微调信号都会更容易被量化误差污染。所以常见做法是：计算权重可以是 BF16，优化器读到梯度后，仍然在 FP32 主权重、FP32 ",[56,3724,2246],{}," 和 FP32 ",[56,3727,2249],{}," 上完成更新，再把新的权重同步回低精度计算副本。",[26,3730,3732],{"id":3731},"为什么前向反向传播可以用-bf16","为什么前向\u002F反向传播可以用 BF16？",[19,3734,3735],{},"看起来这好像有点矛盾：既然优化器更新这么怕 BF16，为什么前向和反向又能用 BF16？关键在于，这两类计算对误差的敏感方式不一样。",[19,3737,3738],{},"前向传播主要是一层层矩阵乘、归一化、激活函数和残差连接：",[59,3740,3742,3794],{"className":3741},[2554],[59,3743,3745],{"className":3744},[2558],[2560,3746,3747],{"xmlns":2562},[2564,3748,3749,3791],{},[2567,3750,3751,3754,3756,3758,3761,3763,3765,3767,3769,3771,3773,3775,3777,3779,3782,3784,3786,3789],{},[2590,3752,3753],{},"y",[2570,3755,2980],{},[2590,3757,2610],{},[2590,3759,3760],{},"c",[2590,3762,2615],{},[2590,3764,2595],{},[2590,3766,2973],{},[2590,3768,2610],{},[2590,3770,2615],{},[2590,3772,2595],{},[2590,3774,2643],{},[2590,3776,2601],{},[2570,3778,2573],{"stretchy":2572},[2590,3780,3781],{},"W",[2590,3783,2638],{},[2570,3785,2918],{},[2590,3787,3788],{},"b",[2570,3790,2586],{"stretchy":2572},[2653,3792,3793],{"encoding":2655},"y = activation(Wx + b)\n",[59,3795,3797,3816,3871],{"className":3796,"ariaHidden":2661},[2660],[59,3798,3800,3804,3807,3810,3813],{"className":3799},[2665],[59,3801],{"className":3802,"style":3803},[2669],"height:0.625em;vertical-align:-0.1944em;",[59,3805,3753],{"className":3806,"style":2734},[2678,2727],[59,3808],{"className":3809,"style":3256},[2741],[59,3811,2980],{"className":3812},[3260],[59,3814],{"className":3815,"style":3256},[2741],[59,3817,3819,3822,3825,3828,3831,3834,3837,3840,3843,3846,3849,3852,3855,3859,3862,3865,3868],{"className":3818},[2665],[59,3820],{"className":3821,"style":2998},[2669],[59,3823,2610],{"className":3824},[2678,2727],[59,3826,3760],{"className":3827},[2678,2727],[59,3829,2615],{"className":3830},[2678,2727],[59,3832,2595],{"className":3833},[2678,2727],[59,3835,2973],{"className":3836,"style":2734},[2678,2727],[59,3838,2610],{"className":3839},[2678,2727],[59,3841,2615],{"className":3842},[2678,2727],[59,3844,2595],{"className":3845},[2678,2727],[59,3847,2643],{"className":3848},[2678,2727],[59,3850,2601],{"className":3851},[2678,2727],[59,3853,2573],{"className":3854},[2674],[59,3856,3781],{"className":3857,"style":3858},[2678,2727],"margin-right:0.1389em;",[59,3860,2638],{"className":3861},[2678,2727],[59,3863],{"className":3864,"style":2742},[2741],[59,3866,2918],{"className":3867},[2746],[59,3869],{"className":3870,"style":2742},[2741],[59,3872,3874,3877,3880],{"className":3873},[2665],[59,3875],{"className":3876,"style":2998},[2669],[59,3878,3788],{"className":3879},[2678,2727],[59,3881,2586],{"className":3882},[2685],[19,3884,3885],{},"BF16 和 FP32 一样有 8 bit exponent，动态范围接近 FP32，因此相比 FP16 更不容易因为量级问题上溢或下溢。这里不能说“不会溢出”，极端输入、异常 loss、错误的初始化照样会把数值打爆；更准确的说法是，BF16 把 FP16 最头疼的动态范围问题缓和了很多。尾数少带来的舍入误差依然存在，但神经网络训练本身就带有 mini-batch 采样噪声、dropout、数据增强等随机性，很多局部舍入误差不会像优化器状态那样长期原封不动地累积。",[19,3887,3888],{},"反向传播也类似。梯度当然不是越粗越好，但它本身就是从一个 mini-batch 估计出来的随机量，我们通常更关心整体方向和统计趋势，而不是每个元素最后几位小数完全准确。更重要的是，现代 GPU 上很多低精度矩阵乘并不是“从头到尾都 BF16”：常见路径是 BF16 输入，内部用更高精度做累加，再输出 BF16 或 FP32 结果。框架也经常会让 softmax、LayerNorm、loss 计算这类敏感算子保留更高精度。",[19,3890,3891,3892,3895],{},"所以这里不能简单概括成“乘法可以低精度，累加必须高精度”。矩阵乘本身就包含大量累加，只是这些累加通常发生在受控的算子内部，并且硬件和框架会用更稳的 accumulate 精度处理。优化器更新的问题更特殊：它是在同一个持久化权重上反复加一个很小的增量，",[56,3893,3894],{},"w = w + lr * grad"," 这种“大数吃小数”的场景正好是低尾数精度最容易翻车的地方。",[19,3897,3898],{},"因此混合精度训练的直觉可以总结成一句话：把吞吐量最大的前向\u002F反向计算交给 BF16，把最怕长期量化误差的优化器状态留在 FP32。这样既吃到低精度硬件的速度和显存收益，又尽量不破坏训练过程中真正需要细粒度累积的部分。",{"title":54,"searchDepth":68,"depth":68,"links":3900},[3901,3902,3903,3904],{"id":2547,"depth":68,"text":2547},{"id":2878,"depth":68,"text":2878},{"id":3439,"depth":68,"text":3440},{"id":3731,"depth":68,"text":3732},"\u002Fimages\u002Fposts\u002Fmix_precision\u002Fcover.png",{},"\u002Fposts\u002Fmix_precision\u002Fmain",{"title":2539,"description":2544},"posts\u002Fmix_precision\u002Fmain",[2044,2535],"S-4aoM7SUoMsqd7d8Gp3rk4W3X4J9vb1xRMS7Nnztfw",{"id":3913,"title":3914,"body":3915,"cover":4234,"date":4235,"description":54,"draft":2036,"extension":2037,"meta":4236,"navigation":1339,"path":4237,"seo":4238,"stem":4239,"summary":4240,"tags":4241,"__hash__":4242},"posts\u002Fposts\u002Fverl_0\u002Fmain.md","verl调参指南",{"type":8,"value":3916,"toc":4221},[3917,3921,3924,3935,3945,3951,3954,3960,3968,3980,3989,3998,4000,4004,4013,4017,4024,4033,4048,4052,4059,4069,4086,4092,4094,4098,4105,4109,4114,4128,4137,4141,4147,4168,4175,4179,4189],[11,3918,3920],{"id":3919},"grpo-训练与调参梳理","GRPO 训练与调参梳理",[26,3922,3923],{"id":3923},"前言",[19,3925,3926,3927,3930,3931,3934],{},"不久前尝试复现了一个 GRPO 训练 Qwen2.5-VL-7B 的工作，当时面对 ",[56,3928,3929],{},"verl"," 繁多的参数，看个文档也是一知半解，简单粗暴地改小了 ",[56,3932,3933],{},"train_batch_size"," 试图增加 logging 频率，结果没练几步就爆了。",[19,3936,3937,3941],{},[2147,3938],{"alt":3939,"src":3940},"github issue 0","\u002Fimages\u002Fposts\u002Fverl_0\u002Fgrpo_0.png",[2147,3942],{"alt":3943,"src":3944},"github issue 1","\u002Fimages\u002Fposts\u002Fverl_0\u002Fgrpo_1.png",[19,3946,3947,3948,3950],{},"这次重回 ",[56,3949,3929],{}," 进行训练，决定先把这些参数背后的逻辑彻底理顺。",[26,3952,3953],{"id":3953},"概念梳理",[19,3955,3956,3957,3959],{},"在聊流程之前，我们得先搞清楚 ",[56,3958,3929],{}," 里这几个容易打架的 Batch Size 概念。这几个参数如果不区分清楚，后面的逻辑很难盘得通：",[19,3961,3962,3963,3967],{},"首先是 ",[37,3964,3965],{},[56,3966,3933],{},"，这是宏观层面的一次“采样大部队”。它决定了在一次采样阶段（Rollout），模型总共要处理多少个不同的 Prompt。",[19,3969,3970,3971,3976,3977,181],{},"其次是 ",[37,3972,3973],{},[56,3974,3975],{},"rollout.n","，也就是 Group Size。GRPO 的特点就是对同一个 Prompt 生成多个回复（Group），然后组内比较优势。所以，实际上模型在采样阶段产生的数据总量是 ",[56,3978,3979],{},"train_batch_size * rollout.n",[19,3981,3982,3983,3988],{},"有了数据就要训练，这就轮到 ",[37,3984,3985],{},[56,3986,3987],{},"ppo_mini_batch_size"," 了。这才是优化器（Optimizer）真正关心的参数——每次更新权重时“吃”多少数据。",[19,3990,3991,3992,3997],{},"至于 ",[37,3993,3994],{},[56,3995,3996],{},"micro_batch_size","，它纯粹是为了照顾显存的。如果显卡塞不下 mini batch，就切得更碎一点用梯度累加（Gradient Accumulation）来凑，它不影响训练逻辑。",[23,3999],{},[26,4001,4003],{"id":4002},"grpo-的两阶段循环","GRPO 的两阶段循环",[19,4005,4006,4007,1608,4010,181],{},"GRPO 的训练本质上就是两个阶段的无限循环：",[37,4008,4009],{},"造数据（Rollout）",[37,4011,4012],{},"吃数据（Update）",[183,4014,4016],{"id":4015},"第一阶段造数据-rollout","第一阶段：造数据 (Rollout)",[19,4018,4019,4020,4023],{},"这个时候模型是冻结的（",[56,4021,4022],{},"eval"," 模式），它的任务就是根据当前的策略去“见世面”。",[19,4025,4026,4027,4029,4030,4032],{},"系统会从数据集中捞出 ",[56,4028,3933],{}," 个 Prompt，然后让模型对着每个 Prompt 生成 ",[56,4031,3975],{}," 个回答。",[19,4034,4035,4036,4039,4040,4043,4044,4047],{},"生成完之后，最关键的一步来了：模型会立刻计算这些回答的 ",[56,4037,4038],{},"old_log_prob","。你可以把这个值看作是一个 ",[37,4041,4042],{},"“锚点”","。在接下来的训练中，无论模型参数怎么变，这个锚点数值是",[37,4045,4046],{},"绝对不会变","的。它就像是一个参照系，用来时刻提醒模型：“你现在的策略和刚开始采样时的策略差了多远”。",[183,4049,4051],{"id":4050},"第二阶段吃数据-ppo-update","第二阶段：吃数据 (PPO Update)",[19,4053,4054,4055,4058],{},"数据造好了，Reward Model 也打完分了，现在模型切换到训练模式（",[56,4056,4057],{},"train","），开始参数更新。",[19,4060,4061,4062,4065,4066,4068],{},"通常我们会把这批造好的数据重复利用几次（",[56,4063,4064],{},"ppo_epochs","），在每一个 Epoch 里，数据被切分成小块（",[56,4067,3987],{},"）喂给优化器。",[19,4070,4071,4074,4075,4078,4079,4082,4083,4085],{},[37,4072,4073],{},"这里有个非常重要的细节：","\n我们在 PPO Update 中会进行多次 ",[56,4076,4077],{},"optimizer.step()","。虽然每一次更新后，模型的参数都变了，新计算出的概率（",[56,4080,4081],{},"log_prob","）也变了，但我们在第一阶段算好的那个“锚点”（",[56,4084,4038],{},"）是定死的。",[19,4087,4088,4089,181],{},"这意味着，",[37,4090,4091],{},"随着更新步数越来越多，现在的模型策略会逐渐偏离采样时的策略",[23,4093],{},[26,4095,4097],{"id":4096},"为什么会练崩调参背后的逻辑","为什么会练崩？调参背后的逻辑",[19,4099,4100,4101,4104],{},"理解了上面的流程，之前的“崩盘”原因就呼之欲出了。调参的核心，其实就是在平衡**“外循环的广度”",[37,4102,4103],{},"和","“内循环的深度”**。",[183,4106,4108],{"id":4107},"_1-外循环为什么-train_batch_size-越大越稳","1. 外循环：为什么 train_batch_size 越大越稳？",[19,4110,4111,4113],{},[56,4112,3933],{}," 决定了模型在改变策略之前，到底看了多少“案例”。",[390,4115,4116,4122],{},[268,4117,4118,4121],{},[37,4119,4120],{},"如果 Batch 很小（比如 256）","：\n这就好比一个学生，做了一道题就急着对答案、改思路。这道题可能比较偏，学生改完思路后，再做下一道题发现又不对，于是又改。模型就会陷入这种“做一题改一次”的震荡中，非常容易过拟合当前的局部数据，导致策略剧烈抖动。",[268,4123,4124,4127],{},[37,4125,4126],{},"如果 Batch 很大（比如 10240）","：\n这就像学生做完了一整套模拟卷（100题），综合了所有题目的得失，才总结出一套改进方案。这种基于大样本量的梯度方向是非常稳健的，因为它看到了数据的“全貌”。",[19,4129,4130,4131],{},"所以结论很简单：",[37,4132,4133,4134,4136],{},"只要显存和时间允许，",[56,4135,3933],{}," 越大越好。",[183,4138,4140],{"id":4139},"_2-内循环ppo_mini_batch_size-的黄金比例","2. 内循环：ppo_mini_batch_size 的黄金比例",[19,4142,4143,4144,4146],{},"确定了要采多少数据（Train Batch），接下来就是决定“怎么吃掉这批数据”。这时候 ",[56,4145,3987],{}," 就很关键了。",[390,4148,4149,4159],{},[268,4150,4151,4154,4155,4158],{},[37,4152,4153],{},"吃得太慢（Mini Batch 太小）","：\n这意味着你需要更新很多次参数才能吃完这批数据。就像上面说的，更新次数越多，模型策略偏离“锚点”就越远。\n这就导致到了后面几步，模型现在的想法和采样时的想法已经天差地别了。此时计算出的 Ratio (",[56,4156,4157],{},"log_prob \u002F old_log_prob",") 会变得极不稳定，PPO 的裁剪机制（Clip）会强制把梯度切零。结果就是：你后面算的这些步数，基本都是无效计算，甚至是有害的。",[268,4160,4161,4164,4165,4167],{},[37,4162,4163],{},"吃得太快（Mini Batch 太大）","：\n假如你直接一口吞（Mini Batch = Train Batch），那这一轮 Rollout 辛辛苦苦生成的几万条数据，只换来了一次 ",[56,4166,4077],{},"。这就太奢侈了，数据利用率极低，训练效率慢得令人发指。",[19,4169,4170,4171,4174],{},"所以，这里需要一个**“黄金比例”**。经验法则通常是：",[37,4172,4173],{},"调节 Mini Batch 的大小，让每一轮 Rollout 的总更新步数保持在 4-8 步左右。"," 既保证了数据被重复利用，又不会让策略偏离太远。",[183,4176,4178],{"id":4177},"_3-实战避坑指南","3. 实战避坑指南",[19,4180,4181,4182,1608,4185,4188],{},"最后总结一下，我们在看 Log 的时候，主要盯着 ",[56,4183,4184],{},"Clip Ratio",[56,4186,4187],{},"KL Divergence"," 这两个指标看就行：",[390,4190,4191,4207],{},[268,4192,4193,4199,4200,4203,4204,4206],{},[37,4194,4195,4196,4198],{},"如果你发现 ",[56,4197,4184],{}," 特别低 (\u003C 1%)","，而且 Loss 下降得很慢。那说明你太保守了，数据还没被榨干。\n👉 ",[37,4201,4202],{},"尝试","：减小 ",[56,4205,3987],{},"（多更几步），或者干脆多跑一个 Epoch。",[268,4208,4209,4214,4215,4217,4218,4220],{},[37,4210,4195,4211,4213],{},[56,4212,4184],{}," 飙升 (> 15%)","，或者 KL 散度突然爆炸。那说明你太激进，模型已经“学歪了”。\n👉 ",[37,4216,4202],{},"：增大 ",[56,4219,3987],{},"（少更几步），让模型“少吃多餐”变为“多吃少餐”，稳住心态。",{"title":54,"searchDepth":68,"depth":68,"links":4222},[4223,4224,4225,4229],{"id":3923,"depth":68,"text":3923},{"id":3953,"depth":68,"text":3953},{"id":4002,"depth":68,"text":4003,"children":4226},[4227,4228],{"id":4015,"depth":74,"text":4016},{"id":4050,"depth":74,"text":4051},{"id":4096,"depth":68,"text":4097,"children":4230},[4231,4232,4233],{"id":4107,"depth":74,"text":4108},{"id":4139,"depth":74,"text":4140},{"id":4177,"depth":74,"text":4178},"\u002Fimages\u002Fposts\u002Fverl_0\u002Fcover.png","2026-01-08",{},"\u002Fposts\u002Fverl_0\u002Fmain",{"title":3914,"description":54},"posts\u002Fverl_0\u002Fmain","梳理GRPO流程以及参数",[2044,2535],"_oKO3pjhfCDakSPQBUJpkFye5oOiGAhgShYOmxmJquI",1782672216476]