[{"data":1,"prerenderedAt":4271},["ShallowReactive",2],{"home-latest-posts":3},[4,2047,2162,2566,3940],{"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":2151,"date":2152,"description":2054,"draft":2036,"extension":2037,"meta":2153,"navigation":1339,"path":2154,"seo":2155,"stem":2156,"summary":2157,"tags":2158,"__hash__":2161},"posts\u002Fposts\u002Flearning_with_ai\u002Fmain.md","关于AI时代的学习",{"type":8,"value":2051,"toc":2145},[2052,2055,2058,2061,2064,2067,2070,2073,2076,2082,2085,2088,2091,2094,2097,2100,2103,2106,2109,2112,2118,2124,2131,2134],[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 学习时会遇到的坑”，这一节说说我自己正在试的解法。",[19,2104,2105],{},"先说个大背景：AI 出现后，新人入行其实变难了。以前新人靠给老登当实习生入门——老登招个黑奴把自己从基础流程里解放出来，新人也能在干活中慢慢上手。但现在 AI 太好用了，工程师能用 AI 快速实现想法，研究生博士能用 AI 写测评、整理实验、润色文章，根本没什么带实习生的动机。不过坏处里也藏着机会：AI 在“获取知识”这件事上同样强大。所以对新人来说，挑战就变成了一个新问题——怎么用 AI 把自己缺的能力快速补齐。",[19,2107,2108],{},"我个人有一定的 research 经验，但工程落地基本是空白，所以干脆拿“学项目开发”当试验场，探索更有效的 AI 学习方法。",[19,2110,2111],{},"核心思路是受 AI coding 启发的：开发复杂任务时，我通常不会一上来就让 AI 写代码，而是先讨论需求、澄清边界、拆分步骤，再进入具体实现。学复杂知识，也许可以套同样的流程。",[19,2113,2114,2117],{},[37,2115,2116],{},"做法一：先规划，再开学。"," 学一个新领域前，先和 AI 一起根据自己的背景制定学习计划——我已经会什么、不熟悉什么、目标是能读懂项目还是能独立开发、每天大概投入多少时间。计划定下来后，再把学习过程拆成一个个小任务，每完成一部分就让 AI 帮忙检查理解是否到位，或者根据练习结果调整后续安排。",[19,2119,2120,2123],{},[37,2121,2122],{},"做法二：渐进式实现项目。"," 对一个复杂项目，直接去磕源码，就算有 AI 也很容易被繁杂的依赖和设计绕晕。但一切设计都是有动机的，所以更好的方式也许是让 AI 手把手带着把一个简化版本实现一遍：从最核心的模块起步，逐步扩展，在扩展中自然遇到需求、引出设计。这样既能在实践中理解“设计为什么这么做”，也更有成就感。",[19,2125,2126,2127,2130],{},"配套还可以做一件事：像维护 ",[56,2128,2129],{},"CLAUDE.md"," 一样，维护一份“个人学习画像”——已有基础、常见误区、喜欢的解释方式、正在学的主题、已经完成的练习等。这样 AI 不用每次从零理解我是谁，也能更快进入合适的讲解深度。",[19,2132,2133],{},"我现在正在用这套方法学网站前后端开发和工业级 coding agent 搭建，也算是拿它本身做个实验，看看效果如何。",[19,2135,2136,2137,2144],{},"skill目前初步维护在",[2138,2139,2143],"a",{"href":2140,"rel":2141},"https:\u002F\u002Fgithub.com\u002FSolus-sano\u002FAEsir_learning",[2142],"nofollow","这里","，可以直接扔给coding agent装，后续也会逐步完善。",{"title":54,"searchDepth":68,"depth":68,"links":2146},[2147,2148,2149,2150],{"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时代如何更高效地学习计算机？",[2159,2160],"杂谈","AI","HSGBk68dbo1hO72_JrTayr-J_b_npJu4gSwJtcZ0dMw",{"id":2163,"title":2164,"body":2165,"cover":2556,"date":2557,"description":2169,"draft":2036,"extension":2037,"meta":2558,"navigation":1339,"path":2559,"seo":2560,"stem":2561,"summary":2562,"tags":2563,"__hash__":2565},"posts\u002Fposts\u002FFSDP\u002Fmain.md","FSDP-Zero123分片简介",{"type":8,"value":2166,"toc":2546},[2167,2170,2173,2180,2183,2186,2270,2280,2283,2286,2289,2292,2295,2303,2306,2310,2323,2329,2332,2335,2338,2341,2349,2353,2356,2362,2365,2374,2377,2380,2383,2387,2395,2401,2404,2412,2415,2418,2421,2424,2435,2439,2449,2455,2458,2464,2467,2471,2474,2543],[19,2168,2169],{},"随着模型越来越大，单卡显存已经无法满足训练需求，人们使用多种手段来扩大训练规模，如使用混合精度减少参数内存占用，或是使用多卡通信的方法进行并行训练。ZeRO 是常见的数据并行模型状态分片方案，思路为将模型权重、梯度、优化器状态等训练状态分片到多张卡上。",[19,2171,2172],{},"这里需要先区分 ZeRO 分片和 tensor parallel。ZeRO-3 也会分片模型权重，但它分片的是模型状态的常驻存储：每张卡平时只保存一部分权重，真正计算某一层时再临时 AllGather 出这一层需要的完整参数。tensor parallel 则是把一层里的矩阵乘、attention head、MLP hidden dim 等计算本身切开，多张卡共同完成同一个 batch 的同一层计算。ZeRO 的核心问题是“状态放不下，怎么存”，tensor parallel 的核心问题是“单层算不动，怎么切计算”。",[19,2174,2175],{},[2176,2177],"img",{"alt":2178,"src":2179},"全量微调一个7.5B模型时，显存中的参数组成，以及zero并行方案示意图","\u002Fimages\u002Fposts\u002FFSDP\u002Ffsdp_preview.png",[26,2181,2182],{"id":2182},"参数组成",[19,2184,2185],{},"假设使用AdamW + BF16 进行混合精度训练，则在训练过程中，需要存储的参数组成如下：",[99,2187,2188,2204],{},[102,2189,2190],{},[105,2191,2192,2195,2198,2201],{},[108,2193,2194],{},"组件",[108,2196,2197],{},"精度",[108,2199,2200],{},"每参数字节数",[108,2202,2203],{},"用途",[117,2205,2206,2220,2232,2246,2258],{},[105,2207,2208,2211,2214,2217],{},[122,2209,2210],{},"模型权重（计算用）",[122,2212,2213],{},"BF16",[122,2215,2216],{},"2B",[122,2218,2219],{},"前向\u002F反向计算",[105,2221,2222,2225,2227,2229],{},[122,2223,2224],{},"梯度",[122,2226,2213],{},[122,2228,2216],{},[122,2230,2231],{},"反向传播产出，供优化器更新参数",[105,2233,2234,2237,2240,2243],{},[122,2235,2236],{},"FP32 主权重（优化器）",[122,2238,2239],{},"FP32",[122,2241,2242],{},"4B",[122,2244,2245],{},"保存更高精度的参数副本，用于稳定更新",[105,2247,2248,2251,2253,2255],{},[122,2249,2250],{},"一阶动量 m_t",[122,2252,2239],{},[122,2254,2242],{},[122,2256,2257],{},"AdamW 对梯度均值的指数滑动平均",[105,2259,2260,2263,2265,2267],{},[122,2261,2262],{},"二阶动量 v_t",[122,2264,2239],{},[122,2266,2242],{},[122,2268,2269],{},"AdamW 对梯度平方的指数滑动平均",[19,2271,2272,2273,400,2276,2279],{},"BF16 模型权重主要服务于矩阵乘等实际计算，可以显著降低显存和带宽压力；梯度是反向传播阶段产生的临时训练信号，优化器会读取它来决定参数更新方向；FP32 主权重、",[56,2274,2275],{},"m_t",[56,2277,2278],{},"v_t"," 则属于 AdamW 的优化器状态，通常用更高精度保存，以减少长时间训练中的数值误差。",[19,2281,2282],{},"可见训练情况下，参数占用的大头在优化器状态上",[19,2284,2285],{},"不分片时：每张卡都持有完整的一份，即 N × (2+2+4+4+4) = 16N 字节（不算激活值）。",[26,2287,2288],{"id":2288},"训练流程梳理",[19,2290,2291],{},"先不考虑 ZeRO 分片，只看普通多卡 DDP 的训练流程。DDP 中每张 GPU 都持有一份完整的模型权重和优化器状态，但每张卡读取的数据不同。例如 8 卡训练时，一个 global batch 会被切成 8 份 micro-batch，每张卡只负责其中一份。前向传播时，每张卡用自己本地的完整 BF16 模型权重计算输出，并保存反向传播需要的激活值。",[19,2293,2294],{},"反向传播时，每张卡根据自己的 micro-batch 计算本地梯度。此时不同 GPU 上的梯度并不相同，因为它们看到的数据不同。如果直接用各自的梯度更新参数，8 张卡的模型副本会从这一步开始分叉，后续训练就不再等价于单个大 batch 的训练。因此 DDP 会在反向传播过程中对梯度做 AllReduce，把所有卡的梯度求和或平均，并把同步后的梯度写回每张卡本地。",[19,2296,2297,2298,1608,2300,2302],{},"梯度同步完成后，每张卡都拥有相同的模型权重、相同的平均梯度，以及相同的 AdamW 优化器状态。随后每张卡各自在本地执行 optimizer step，读取 BF16 梯度、FP32 主权重、",[56,2299,2275],{},[56,2301,2278],{},"，计算出完全相同的参数更新。因为更新前的状态相同、梯度也已经同步，所以更新后的模型副本仍然保持一致。",[19,2304,2305],{},"这个流程的好处是实现简单、计算并行度高，每张卡都可以独立完成完整模型的前向和反向。代价是显存冗余很大：模型权重、梯度和优化器状态在每张卡上都完整复制了一份。ZeRO 后续要优化的正是这部分冗余存储。",[26,2307,2309],{"id":2308},"zero-stage-1分片优化器状态","ZeRO Stage 1：分片优化器状态",[19,2311,2312,2313,400,2315,2317,2318,400,2320,2322],{},"ZeRO-1 先处理最大的一块冗余：优化器状态。在普通 DDP 里，每张卡不仅保存完整的 BF16 计算权重和 BF16 梯度，还保存完整的 FP32 主权重、",[56,2314,2275],{},[56,2316,2278],{},"。如果使用 AdamW，这三项合起来是 12N 字节，比 BF16 模型权重本身大很多。ZeRO-1 的做法是：BF16 模型权重和 BF16 梯度仍然完整复制在每张卡上，但把 FP32 主权重、",[56,2319,2275],{},[56,2321,2278],{}," 按参数范围切成 8 份，每张卡只负责其中 1 份。",[49,2324,2327],{"className":2325,"code":2326,"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,2328,2326],{"__ignoreMap":54},[19,2330,2331],{},"训练时，ZeRO-1 的前向和反向基本仍然像 DDP 一样执行。每张卡都有完整 BF16 权重，所以可以独立完成自己 micro-batch 的 forward\u002Fbackward；反向传播得到的梯度也会像普通 DDP 一样做 AllReduce，使每张卡都拿到完整的平均梯度。",[19,2333,2334],{},"区别发生在 optimizer step。由于 GPU0 只保存第 0 片参数的 FP32 主权重和 AdamW 动量，GPU1 只保存第 1 片，以此类推，所以每张卡只更新自己负责的那 1\u002F8 参数。更新完成后，各卡会把自己更新好的参数分片同步给其他卡，所有卡再把这些分片拼回本地完整的 BF16 模型权重。这样下一轮 forward 时，每张卡仍然能看到完整且一致的模型。",[19,2336,2337],{},"从通信角度看，ZeRO-1 仍然保留 DDP 的梯度 AllReduce；额外需要在参数更新后同步各自更新的参数分片。它的优点是对计算流程改动较小，因为 forward\u002Fbackward 期间每张卡仍有完整模型；缺点是梯度和 BF16 权重还没有省，显存优化只作用在优化器状态上。",[19,2339,2340],{},"每卡显存：2N + 2N + (4N+4N+4N)\u002F8 = 5.5N 字节（vs 原来 16N）",[19,2342,2343,2344,400,2346,2348],{},"节省的是：FP32 主权重、",[56,2345,2275],{},[56,2347,2278],{}," 这 12N 优化器状态从完整保存变成按卡分片。",[26,2350,2352],{"id":2351},"zero-stage-2分片优化器状态-梯度","ZeRO Stage 2：分片优化器状态 + 梯度",[19,2354,2355],{},"ZeRO-2 在 ZeRO-1 的基础上继续观察一个事实：既然每张卡只负责更新 1\u002F8 的参数，那么它其实也只需要这 1\u002F8 参数对应的平均梯度。Stage 1 中每张卡保留完整梯度，是为了和普通 DDP 的流程保持一致；Stage 2 则把这部分也分片掉。",[49,2357,2360],{"className":2358,"code":2359,"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,2361,2359],{"__ignoreMap":54},[19,2363,2364],{},"普通 DDP 的梯度同步可以理解为“先把所有卡的梯度求和，再把完整结果发回每张卡”。ZeRO-2 把这个过程改成 Reduce-Scatter：仍然对所有卡的梯度求和或平均，但最终结果不是完整复制给每张卡，而是按参数范围 scatter 出去。GPU0 只拿到第 0 片平均梯度，GPU1 只拿到第 1 片平均梯度，刚好和各自保存的优化器状态对应。",[19,2366,2367,2368,2370,2371,2373],{},"这样做在数学上仍然等价于 DDP 的全局平均梯度更新，只是每张卡不再保存自己不负责的梯度部分。随后 optimizer step 和 ZeRO-1 类似：每张卡读取自己的梯度分片、FP32 主权重分片、",[56,2369,2275],{}," 分片和 ",[56,2372,2278],{}," 分片，更新自己负责的参数。因为 BF16 模型权重在 Stage 2 里仍然完整复制在每张卡上，所以更新后的参数分片仍需要同步给所有卡，让每张卡本地的完整 BF16 权重保持一致。",[19,2375,2376],{},"实际实现通常会按 bucket 或按 layer 边反向、边 Reduce-Scatter、边释放梯度缓存，而不是等整个模型的梯度都算完再统一处理。因此下面的公式更接近稳定状态下的模型状态占用；真实峰值还会受到 bucket 大小、梯度累积、通信重叠策略影响。",[19,2378,2379],{},"每卡显存：2N + (2N+4N+4N+4N)\u002F8 = 3.75N 字节",[19,2381,2382],{},"节省的是：在 Stage 1 基础上，梯度也从 2N → 0.25N。",[26,2384,2386],{"id":2385},"zero-stage-3分片一切优化器状态-梯度-模型权重","ZeRO Stage 3：分片一切（优化器状态 + 梯度 + 模型权重）",[19,2388,2389,2390,2370,2392,2394],{},"ZeRO-3 再进一步，把计算用的 BF16 模型权重也分片保存。到这一步，每张卡常驻的模型状态只剩自己负责的 1\u002F8：BF16 参数分片、BF16 梯度分片、FP32 主权重分片、",[56,2391,2275],{},[56,2393,2278],{}," 分片。它和 tensor parallel 的关键区别是：ZeRO-3 并不是把某个矩阵乘的计算永久切给不同 GPU，而是把参数平时分片存放；真正执行某个 module 的计算前，再临时收集出这个 module 需要的完整参数。",[49,2396,2399],{"className":2397,"code":2398,"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,2400,2398],{"__ignoreMap":54},[19,2402,2403],{},"前向传播时，框架会在进入某一层或某个 FSDP unit 前，对这一段参数做 AllGather。AllGather 之后，每张卡临时拥有这一层的完整 BF16 权重，于是可以像普通数据并行一样，用自己的 micro-batch 完成这一层计算。计算结束后，非本卡负责的参数分片可以释放，只保留本卡原本负责的 shard。",[19,2405,2406,2407,400,2409,2411],{},"反向传播时也类似。计算某一层的 backward 需要对应的权重，因此可能再次 AllGather 这一层参数；得到梯度后，再用 Reduce-Scatter 把梯度规约并切回各个 rank。最终每张卡只保留自己负责的梯度分片，并用它更新本地的 FP32 主权重、",[56,2408,2275],{},[56,2410,2278],{}," 和 BF16 参数分片。",[19,2413,2414],{},"和 ZeRO-1\u002F2 不同，ZeRO-3 更新完参数后不需要把完整模型权重常驻同步到每张卡，因为每张卡本来就不保存完整权重。下一次 forward 需要某一层参数时，再从各卡当前持有的参数分片 AllGather 出来即可。这个设计把常驻显存压到最低，但把参数通信放进了 forward\u002Fbackward 的关键路径。",[19,2416,2417],{},"每卡显存：(2N+2N+4N+4N+4N)\u002F8 = 2N 字节",[19,2419,2420],{},"这个 2N 指的是理想情况下每卡常驻的模型状态。真实训练峰值会更高，因为某些时刻会临时持有当前层 AllGather 出来的完整参数、通信 bucket、prefetch 参数以及激活值。ZeRO-3 的核心取舍就是：用更多、更频繁的参数 AllGather，换取模型状态显存近似按数据并行卡数线性下降。",[26,2422,2423],{"id":2423},"通信代价的直觉",[390,2425,2426,2429,2432],{},[268,2427,2428],{},"ZeRO-1：梯度同步仍然接近普通 DDP，额外多了参数更新后的同步，整体通信压力通常可控。",[268,2430,2431],{},"ZeRO-2：用 Reduce-Scatter 替代 AllReduce 梯度同步，通信总量和 DDP 同阶，但每张卡只保留梯度分片。",[268,2433,2434],{},"ZeRO-3：前向和反向会按层 AllGather 参数，反向后再 Reduce-Scatter 梯度。它的显存收益最大，但速度更依赖网络带宽、分片粒度、通信计算重叠和框架实现。",[26,2436,2438],{"id":2437},"zero分片与张量并行组合使用","Zero分片与张量并行组合使用",[19,2440,2441,2442,400,2445,2448],{},"假设有 8 张 GPU，设置 ",[56,2443,2444],{},"tp_size=2",[56,2446,2447],{},"dp_size=4","，那么可以把相邻两张卡组成一个 TP group，共同切分同一个模型副本中的张量计算：",[49,2450,2453],{"className":2451,"code":2452,"language":435,"meta":54},[433],"TP groups:\n  [GPU0, GPU1], [GPU2, GPU3], [GPU4, GPU5], [GPU6, GPU7]\n",[56,2454,2452],{"__ignoreMap":54},[19,2456,2457],{},"这 4 个 TP group 之间再构成数据并行关系，每个 group 处理不同的 mini-batch。为了让同一个 tensor-parallel 分片位置之间同步梯度或分片优化器状态，通常会按 TP rank 位置组成 DP group：",[49,2459,2462],{"className":2460,"code":2461,"language":435,"meta":54},[433],"DP groups:\n  [GPU0, GPU2, GPU4, GPU6]  # 每个模型副本里的第 0 个 TP 分片\n  [GPU1, GPU3, GPU5, GPU7]  # 每个模型副本里的第 1 个 TP 分片\n",[56,2463,2461],{"__ignoreMap":54},[19,2465,2466],{},"这样，一层内部的计算由 TP group 内的两张卡协作完成；不同数据 batch 之间的梯度同步、优化器状态分片，则发生在 DP group 内。ZeRO\u002FFSDP 通常作用在 DP group 维度上，而不是替代 tensor parallel 的算子切分。",[26,2468,2470],{"id":2469},"fsdp-和-zero-的关系","FSDP 和 ZeRO 的关系",[19,2472,2473],{},"PyTorch FSDP 可以理解为 ZeRO 思路在 PyTorch 里的实现之一。常见对应关系是：",[99,2475,2476,2489],{},[102,2477,2478],{},[105,2479,2480,2483,2486],{},[108,2481,2482],{},"FSDP 策略",[108,2484,2485],{},"近似对应",[108,2487,2488],{},"说明",[117,2490,2491,2504,2517,2530],{},[105,2492,2493,2498,2501],{},[122,2494,2495],{},[56,2496,2497],{},"NO_SHARD",[122,2499,2500],{},"DDP",[122,2502,2503],{},"不分片参数、梯度和优化器状态",[105,2505,2506,2511,2514],{},[122,2507,2508],{},[56,2509,2510],{},"SHARD_GRAD_OP",[122,2512,2513],{},"ZeRO-2",[122,2515,2516],{},"分片梯度和优化器状态，参数在计算时保持完整",[105,2518,2519,2524,2527],{},[122,2520,2521],{},[56,2522,2523],{},"FULL_SHARD",[122,2525,2526],{},"ZeRO-3",[122,2528,2529],{},"分片参数、梯度和优化器状态",[105,2531,2532,2537,2540],{},[122,2533,2534],{},[56,2535,2536],{},"HYBRID_SHARD",[122,2538,2539],{},"ZeRO-3 + 层级分组",[122,2541,2542],{},"组内分片，组间复制，常用于多机多卡",[19,2544,2545],{},"实际训练时还需要额外考虑激活值、通信 bucket、临时 AllGather 出来的完整参数、CUDA allocator 碎片以及 checkpoint 保存方式，所以这些公式更适合用来建立显存组成的直觉，而不是直接等同于最终峰值显存。",{"title":54,"searchDepth":68,"depth":68,"links":2547},[2548,2549,2550,2551,2552,2553,2554,2555],{"id":2182,"depth":68,"text":2182},{"id":2288,"depth":68,"text":2288},{"id":2308,"depth":68,"text":2309},{"id":2351,"depth":68,"text":2352},{"id":2385,"depth":68,"text":2386},{"id":2423,"depth":68,"text":2423},{"id":2437,"depth":68,"text":2438},{"id":2469,"depth":68,"text":2470},"\u002Fimages\u002Fposts\u002FFSDP\u002Fcover.png","2026-04-16",{},"\u002Fposts\u002Ffsdp\u002Fmain",{"title":2164,"description":2169},"posts\u002FFSDP\u002Fmain","zero并行训练方案简介",[2044,2564],"后训练","LXnMqZsxJNTbq709BfEfelc-WUxUk4q9kvBXhrGS_GQ",{"id":2567,"title":2568,"body":2569,"cover":3933,"date":2557,"description":2573,"draft":2036,"extension":2037,"meta":3934,"navigation":1339,"path":3935,"seo":3936,"stem":3937,"summary":2568,"tags":3938,"__hash__":3939},"posts\u002Fposts\u002Fmix_precision\u002Fmain.md","混合精度训练简介",{"type":8,"value":2570,"toc":3927},[2571,2574,2577,2580,2871,2874,2877,2884,2887,2894,2904,2907,2910,2916,2919,3308,3311,3462,3465,3469,3615,3618,3726,3740,3743,3757,3761,3764,3767,3911,3914,3917,3924],[19,2572,2573],{},"传统的模型训练一般使用FP32精度进行训练。近年来，由于模型越来越大，我们希望在有限的显存中训练更大的模型，也希望能够提高训练速度，因此，人们开始研究如何在混合使用低精度的参数进行模型训练时，保持模型的性能。",[26,2575,2576],{"id":2576},"浮点数据类型介绍",[19,2578,2579],{},"要理解混合精度训练，最好先把“浮点数”这件事想清楚。计算机里存一个浮点数，并不是直接把十进制小数塞进去，而是把它拆成三块：符号位决定正负，指数位决定这个数大概落在哪个数量级，尾数位决定这个数量级里还能分得多细。粗略写出来就是：",[59,2581,2584,2685],{"className":2582},[2583],"katex",[59,2585,2588],{"className":2586},[2587],"katex-mathml",[2589,2590,2592],"math",{"xmlns":2591},"http:\u002F\u002Fwww.w3.org\u002F1998\u002FMath\u002FMathML",[2593,2594,2595,2680],"semantics",{},[2596,2597,2598,2603,2606,2610,2631,2634,2637,2639,2641,2644,2646,2648,2650,2652,2654],"mrow",{},[2599,2600,2602],"mo",{"stretchy":2601},"false","(",[2599,2604,2605],{},"−",[2607,2608,2609],"mn",{},"1",[2611,2612,2613,2616],"msup",{},[2599,2614,2615],{"stretchy":2601},")",[2596,2617,2618,2622,2625,2628],{},[2619,2620,2621],"mi",{},"s",[2619,2623,2624],{},"i",[2619,2626,2627],{},"g",[2619,2629,2630],{},"n",[2599,2632,2633],{},"×",[2619,2635,2636],{},"m",[2619,2638,2138],{},[2619,2640,2630],{},[2619,2642,2643],{},"t",[2619,2645,2624],{},[2619,2647,2621],{},[2619,2649,2621],{},[2619,2651,2138],{},[2599,2653,2633],{},[2611,2655,2656,2659],{},[2607,2657,2658],{},"2",[2596,2660,2661,2664,2667,2669,2672,2674,2676,2678],{},[2619,2662,2663],{},"e",[2619,2665,2666],{},"x",[2619,2668,19],{},[2619,2670,2671],{},"o",[2619,2673,2630],{},[2619,2675,2663],{},[2619,2677,2630],{},[2619,2679,2643],{},[2681,2682,2684],"annotation",{"encoding":2683},"application\u002Fx-tex","(-1)^{sign} \\times mantissa \\times 2^{exponent}\n",[59,2686,2690,2778,2811],{"className":2687,"ariaHidden":2689},[2688],"katex-html","true",[59,2691,2694,2699,2703,2707,2710,2766,2771,2775],{"className":2692},[2693],"base",[59,2695],{"className":2696,"style":2698},[2697],"strut","height:1.0747em;vertical-align:-0.25em;",[59,2700,2602],{"className":2701},[2702],"mopen",[59,2704,2605],{"className":2705},[2706],"mord",[59,2708,2609],{"className":2709},[2706],[59,2711,2714,2717],{"className":2712},[2713],"mclose",[59,2715,2615],{"className":2716},[2713],[59,2718,2721],{"className":2719},[2720],"msupsub",[59,2722,2725],{"className":2723},[2724],"vlist-t",[59,2726,2729],{"className":2727},[2728],"vlist-r",[59,2730,2734],{"className":2731,"style":2733},[2732],"vlist","height:0.8247em;",[59,2735,2737,2742],{"style":2736},"top:-3.063em;margin-right:0.05em;",[59,2738],{"className":2739,"style":2741},[2740],"pstrut","height:2.7em;",[59,2743,2749],{"className":2744},[2745,2746,2747,2748],"sizing","reset-size6","size3","mtight",[59,2750,2752,2756,2759,2763],{"className":2751},[2706,2748],[59,2753,2621],{"className":2754},[2706,2755,2748],"mathnormal",[59,2757,2624],{"className":2758},[2706,2755,2748],[59,2760,2627],{"className":2761,"style":2762},[2706,2755,2748],"margin-right:0.0359em;",[59,2764,2630],{"className":2765},[2706,2755,2748],[59,2767],{"className":2768,"style":2770},[2769],"mspace","margin-right:0.2222em;",[59,2772,2633],{"className":2773},[2774],"mbin",[59,2776],{"className":2777,"style":2770},[2769],[59,2779,2781,2785,2789,2792,2795,2799,2802,2805,2808],{"className":2780},[2693],[59,2782],{"className":2783,"style":2784},[2697],"height:0.7429em;vertical-align:-0.0833em;",[59,2786,2788],{"className":2787},[2706,2755],"man",[59,2790,2643],{"className":2791},[2706,2755],[59,2793,2624],{"className":2794},[2706,2755],[59,2796,2798],{"className":2797},[2706,2755],"ss",[59,2800,2138],{"className":2801},[2706,2755],[59,2803],{"className":2804,"style":2770},[2769],[59,2806,2633],{"className":2807},[2774],[59,2809],{"className":2810,"style":2770},[2769],[59,2812,2814,2818],{"className":2813},[2693],[59,2815],{"className":2816,"style":2817},[2697],"height:0.7936em;",[59,2819,2821,2824],{"className":2820},[2706],[59,2822,2658],{"className":2823},[2706],[59,2825,2827],{"className":2826},[2720],[59,2828,2830],{"className":2829},[2724],[59,2831,2833],{"className":2832},[2728],[59,2834,2836],{"className":2835,"style":2817},[2732],[59,2837,2838,2841],{"style":2736},[59,2839],{"className":2840,"style":2741},[2740],[59,2842,2844],{"className":2843},[2745,2746,2747,2748],[59,2845,2847,2850,2853,2856,2859,2862,2865,2868],{"className":2846},[2706,2748],[59,2848,2663],{"className":2849},[2706,2755,2748],[59,2851,2666],{"className":2852},[2706,2755,2748],[59,2854,19],{"className":2855},[2706,2755,2748],[59,2857,2671],{"className":2858},[2706,2755,2748],[59,2860,2630],{"className":2861},[2706,2755,2748],[59,2863,2663],{"className":2864},[2706,2755,2748],[59,2866,2630],{"className":2867},[2706,2755,2748],[59,2869,2643],{"className":2870},[2706,2755,2748],[19,2872,2873],{},"所以同样是一个 16 bit 或 32 bit 的数字，指数位多一点，能表示的范围就更大；尾数位多一点，相邻两个可表示数字之间就更密，数值就更精细。混合精度训练里的很多选择，本质上都是在这两个方向之间做取舍：我们到底更怕数值溢出，还是更怕舍入误差。",[19,2875,2876],{},"FP32 是最传统的训练精度，它用 1 bit 存符号、8 bit 存指数、23 bit 存尾数，一共 4 bytes。它的好处是范围够大、精度也够细，所以不管是前向计算、反向梯度，还是优化器里长期累积的小更新，都比较稳。问题也很直接：每个参数 4 bytes，模型一大，权重、梯度、优化器状态很快就把显存吃完；同时 GPU 做矩阵乘时，低精度 Tensor Core 往往能给出更高吞吐，继续全程 FP32 就显得有点浪费。",[19,2878,2879,2880,2883],{},"FP16 看起来像是最自然的压缩版本：它只有 2 bytes，显存直接减半，而且 10 bit 尾数比 BF16 更细。但 FP16 把指数位压到了 5 bit，动态范围小很多；按照 IEEE FP16 的编码规则，它的最大有限值是 65504。训练里一旦激活值、loss scale 或梯度中间值稍微大一点，就容易上溢成 ",[56,2881,2882],{},"inf","；梯度太小的时候又容易下溢成 0。因此早期 FP16 训练通常要配合 loss scaling，把 loss 先放大再反传，尽量让梯度落在 FP16 能表示的区间里。",[19,2885,2886],{},"BF16 的设计思路刚好不一样。它同样是 2 bytes，但保留了和 FP32 一样的 8 bit 指数，只把尾数缩到了 7 bit。也就是说，BF16 的动态范围几乎和 FP32 一样，训练中不太容易因为量级问题直接爆掉；代价是同一个数量级里的刻度更粗，精度比 FP16 还低。这个取舍非常适合深度学习里的矩阵乘：神经网络本身对一点舍入噪声比较耐受，而大范围可以显著减少上溢、下溢问题。所以现在做大模型训练或微调时，BF16 往往比 FP16 更省心。",[19,2888,2889,2890,2893],{},"这也解释了为什么后文会把 BF16 放在前向和反向传播里，却仍然让优化器保存 FP32 主权重。前向\u002F反向主要是大批量矩阵乘和激活函数，低精度误差通常会被模型和 batch 噪声“吃掉”；优化器更新则是在做长期累加，很多时候是把一个很小的 ",[56,2891,2892],{},"lr * grad"," 加到一个已经存在的权重上。如果权重只保存在 BF16 里，这个微小更新可能连最小刻度都碰不到，直接被舍入掉。于是计算可以低精度，状态累积仍然要高精度，这就是混合精度训练里最核心的分工。",[19,2895,2896,2897,1608,2900,2903],{},"再往下压就是 FP8。PyTorch 里常见的两个 FP8 类型是 ",[56,2898,2899],{},"torch.float8_e4m3fn",[56,2901,2902],{},"torch.float8_e5m2","。前者可以理解成 4 bit 指数、3 bit 尾数，精度稍好但范围更小；后者是 5 bit 指数、2 bit 尾数，范围更大但刻度更粗。实际训练里，FP8 通常不会像 BF16 那样直接替换所有计算，而是要配合 scaling，把张量按块或按通道缩放到合适区间，再交给硬件做低精度矩阵乘。直觉上可以把它看成比 BF16 更激进的带宽和算力优化：收益更大，但对数值缩放、算子支持和训练框架的要求也更高。",[26,2905,2906],{"id":2906},"混合精度训练的完整工作流",[19,2908,2909],{},"下面以 AdamW + BF16 计算为例。先说明一下，这里讨论的是大模型训练里很常见的一种实现方式：前向、反向使用 BF16 权重和激活来降低显存与带宽压力，优化器内部仍然维护 FP32 主权重和 FP32 动量状态。不同框架的细节会有差异，比如 PyTorch AMP 可能让参数本体保持 FP32，只在算子执行时临时 autocast，但背后的数值逻辑是一样的：计算可以低精度，长期累积的训练状态最好保留高精度。",[19,2911,2912],{},[2176,2913],{"alt":2914,"src":2915},"混合精度训练流程（BF16计算，FP32优化器）","\u002Fimages\u002Fposts\u002Fmix_precision\u002Fmix_precision.png",[19,2917,2918],{},"如果只看模型状态，不考虑激活值，一个参数在 AdamW + BF16 混合精度全量微调中大致会占：",[59,2920,2922,3017],{"className":2921},[2583],[59,2923,2925],{"className":2924},[2587],[2589,2926,2927],{"xmlns":2591},[2593,2928,2929,3014],{},[2596,2930,2931,2933,2937,2939,2942,2944,2947,2949,2951,2953,2956,2958,2960,2963,2965,2967,2970,2972,2974,2976,2978,2980,2987,2989,2991,2993,2995,2997,3004,3006,3009,3012],{},[2607,2932,2658],{},[2934,2935,2936],"mtext",{},"B",[2599,2938,2602],{"stretchy":2601},[2934,2940,2941],{},"BF16 权重",[2599,2943,2615],{"stretchy":2601},[2599,2945,2946],{},"+",[2607,2948,2658],{},[2934,2950,2936],{},[2599,2952,2602],{"stretchy":2601},[2934,2954,2955],{},"BF16 梯度",[2599,2957,2615],{"stretchy":2601},[2599,2959,2946],{},[2607,2961,2962],{},"4",[2934,2964,2936],{},[2599,2966,2602],{"stretchy":2601},[2934,2968,2969],{},"FP32 主权重",[2599,2971,2615],{"stretchy":2601},[2599,2973,2946],{},[2607,2975,2962],{},[2934,2977,2936],{},[2599,2979,2602],{"stretchy":2601},[2981,2982,2983,2985],"msub",{},[2619,2984,2636],{},[2619,2986,2643],{},[2599,2988,2615],{"stretchy":2601},[2599,2990,2946],{},[2607,2992,2962],{},[2934,2994,2936],{},[2599,2996,2602],{"stretchy":2601},[2981,2998,2999,3002],{},[2619,3000,3001],{},"v",[2619,3003,2643],{},[2599,3005,2615],{"stretchy":2601},[2599,3007,3008],{},"=",[2607,3010,3011],{},"16",[2934,3013,2936],{},[2681,3015,3016],{"encoding":2683},"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,3018,3020,3063,3102,3143,3219,3292],{"className":3019,"ariaHidden":2689},[2688],[59,3021,3023,3027,3030,3036,3039,3051,3054,3057,3060],{"className":3022},[2693],[59,3024],{"className":3025,"style":3026},[2697],"height:1em;vertical-align:-0.25em;",[59,3028,2658],{"className":3029},[2706],[59,3031,3033],{"className":3032},[2706,435],[59,3034,2936],{"className":3035},[2706],[59,3037,2602],{"className":3038},[2702],[59,3040,3042,3046],{"className":3041},[2706,435],[59,3043,3045],{"className":3044},[2706],"BF16 ",[59,3047,3050],{"className":3048},[2706,3049],"cjk_fallback","权重",[59,3052,2615],{"className":3053},[2713],[59,3055],{"className":3056,"style":2770},[2769],[59,3058,2946],{"className":3059},[2774],[59,3061],{"className":3062,"style":2770},[2769],[59,3064,3066,3069,3072,3078,3081,3090,3093,3096,3099],{"className":3065},[2693],[59,3067],{"className":3068,"style":3026},[2697],[59,3070,2658],{"className":3071},[2706],[59,3073,3075],{"className":3074},[2706,435],[59,3076,2936],{"className":3077},[2706],[59,3079,2602],{"className":3080},[2702],[59,3082,3084,3087],{"className":3083},[2706,435],[59,3085,3045],{"className":3086},[2706],[59,3088,2224],{"className":3089},[2706,3049],[59,3091,2615],{"className":3092},[2713],[59,3094],{"className":3095,"style":2770},[2769],[59,3097,2946],{"className":3098},[2774],[59,3100],{"className":3101,"style":2770},[2769],[59,3103,3105,3108,3111,3117,3120,3131,3134,3137,3140],{"className":3104},[2693],[59,3106],{"className":3107,"style":3026},[2697],[59,3109,2962],{"className":3110},[2706],[59,3112,3114],{"className":3113},[2706,435],[59,3115,2936],{"className":3116},[2706],[59,3118,2602],{"className":3119},[2702],[59,3121,3123,3127],{"className":3122},[2706,435],[59,3124,3126],{"className":3125},[2706],"FP32 ",[59,3128,3130],{"className":3129},[2706,3049],"主权重",[59,3132,2615],{"className":3133},[2713],[59,3135],{"className":3136,"style":2770},[2769],[59,3138,2946],{"className":3139},[2774],[59,3141],{"className":3142,"style":2770},[2769],[59,3144,3146,3149,3152,3158,3161,3207,3210,3213,3216],{"className":3145},[2693],[59,3147],{"className":3148,"style":3026},[2697],[59,3150,2962],{"className":3151},[2706],[59,3153,3155],{"className":3154},[2706,435],[59,3156,2936],{"className":3157},[2706],[59,3159,2602],{"className":3160},[2702],[59,3162,3164,3167],{"className":3163},[2706],[59,3165,2636],{"className":3166},[2706,2755],[59,3168,3170],{"className":3169},[2720],[59,3171,3174,3198],{"className":3172},[2724,3173],"vlist-t2",[59,3175,3177,3193],{"className":3176},[2728],[59,3178,3181],{"className":3179,"style":3180},[2732],"height:0.2806em;",[59,3182,3184,3187],{"style":3183},"top:-2.55em;margin-left:0em;margin-right:0.05em;",[59,3185],{"className":3186,"style":2741},[2740],[59,3188,3190],{"className":3189},[2745,2746,2747,2748],[59,3191,2643],{"className":3192},[2706,2755,2748],[59,3194,3197],{"className":3195},[3196],"vlist-s","​",[59,3199,3201],{"className":3200},[2728],[59,3202,3205],{"className":3203,"style":3204},[2732],"height:0.15em;",[59,3206],{},[59,3208,2615],{"className":3209},[2713],[59,3211],{"className":3212,"style":2770},[2769],[59,3214,2946],{"className":3215},[2774],[59,3217],{"className":3218,"style":2770},[2769],[59,3220,3222,3225,3228,3234,3237,3278,3281,3285,3289],{"className":3221},[2693],[59,3223],{"className":3224,"style":3026},[2697],[59,3226,2962],{"className":3227},[2706],[59,3229,3231],{"className":3230},[2706,435],[59,3232,2936],{"className":3233},[2706],[59,3235,2602],{"className":3236},[2702],[59,3238,3240,3243],{"className":3239},[2706],[59,3241,3001],{"className":3242,"style":2762},[2706,2755],[59,3244,3246],{"className":3245},[2720],[59,3247,3249,3270],{"className":3248},[2724,3173],[59,3250,3252,3267],{"className":3251},[2728],[59,3253,3255],{"className":3254,"style":3180},[2732],[59,3256,3258,3261],{"style":3257},"top:-2.55em;margin-left:-0.0359em;margin-right:0.05em;",[59,3259],{"className":3260,"style":2741},[2740],[59,3262,3264],{"className":3263},[2745,2746,2747,2748],[59,3265,2643],{"className":3266},[2706,2755,2748],[59,3268,3197],{"className":3269},[3196],[59,3271,3273],{"className":3272},[2728],[59,3274,3276],{"className":3275,"style":3204},[2732],[59,3277],{},[59,3279,2615],{"className":3280},[2713],[59,3282],{"className":3283,"style":3284},[2769],"margin-right:0.2778em;",[59,3286,3008],{"className":3287},[3288],"mrel",[59,3290],{"className":3291,"style":3284},[2769],[59,3293,3295,3299,3302],{"className":3294},[2693],[59,3296],{"className":3297,"style":3298},[2697],"height:0.6833em;",[59,3300,3011],{"className":3301},[2706],[59,3303,3305],{"className":3304},[2706,435],[59,3306,2936],{"className":3307},[2706],[19,3309,3310],{},"所以一个常用的粗估是：",[59,3312,3314,3352],{"className":3313},[2583],[59,3315,3317],{"className":3316},[2587],[2589,3318,3319],{"xmlns":2591},[2593,3320,3321,3349],{},[2596,3322,3323,3326,3329,3332,3334,3336,3339,3341,3344,3346],{},[2934,3324,3325],{},"显存",[2599,3327,3328],{},"≈",[2934,3330,3331],{},"参数量",[2599,3333,2633],{},[2607,3335,3011],{},[2934,3337,3338],{}," bytes",[2599,3340,2946],{},[2934,3342,3343],{},"激活值",[2599,3345,2946],{},[2934,3347,3348],{},"临时 buffer",[2681,3350,3351],{"encoding":2683},"\\text{显存} \\approx \\text{参数量} \\times 16\\text{ bytes} + \\text{激活值} + \\text{临时 buffer}\n",[59,3353,3355,3376,3398,3423,3444],{"className":3354,"ariaHidden":2689},[2688],[59,3356,3358,3361,3367,3370,3373],{"className":3357},[2693],[59,3359],{"className":3360,"style":3298},[2697],[59,3362,3364],{"className":3363},[2706,435],[59,3365,3325],{"className":3366},[2706,3049],[59,3368],{"className":3369,"style":3284},[2769],[59,3371,3328],{"className":3372},[3288],[59,3374],{"className":3375,"style":3284},[2769],[59,3377,3379,3383,3389,3392,3395],{"className":3378},[2693],[59,3380],{"className":3381,"style":3382},[2697],"height:0.7667em;vertical-align:-0.0833em;",[59,3384,3386],{"className":3385},[2706,435],[59,3387,3331],{"className":3388},[2706,3049],[59,3390],{"className":3391,"style":2770},[2769],[59,3393,2633],{"className":3394},[2774],[59,3396],{"className":3397,"style":2770},[2769],[59,3399,3401,3405,3408,3414,3417,3420],{"className":3400},[2693],[59,3402],{"className":3403,"style":3404},[2697],"height:0.8889em;vertical-align:-0.1944em;",[59,3406,3011],{"className":3407},[2706],[59,3409,3411],{"className":3410},[2706,435],[59,3412,3338],{"className":3413},[2706],[59,3415],{"className":3416,"style":2770},[2769],[59,3418,2946],{"className":3419},[2774],[59,3421],{"className":3422,"style":2770},[2769],[59,3424,3426,3429,3435,3438,3441],{"className":3425},[2693],[59,3427],{"className":3428,"style":3382},[2697],[59,3430,3432],{"className":3431},[2706,435],[59,3433,3343],{"className":3434},[2706,3049],[59,3436],{"className":3437,"style":2770},[2769],[59,3439,2946],{"className":3440},[2774],[59,3442],{"className":3443,"style":2770},[2769],[59,3445,3447,3451],{"className":3446},[2693],[59,3448],{"className":3449,"style":3450},[2697],"height:0.6944em;",[59,3452,3454,3458],{"className":3453},[2706,435],[59,3455,3457],{"className":3456},[2706,3049],"临时",[59,3459,3461],{"className":3460},[2706]," buffer",[19,3463,3464],{},"实际估算时我一般会把模型状态按 16 到 18 bytes\u002Fparam 留余量，因为不同实现里还会有参数对齐、通信 bucket、梯度临时副本、fused optimizer workspace 等额外开销。真正容易被低估的往往是激活值，它和 sequence length、micro batch size、是否开启 activation checkpointing 强相关；有时候模型状态算得很准，最后还是爆在激活值上。",[26,3466,3468],{"id":3467},"为什么优化器必须保存-fp32-主权重","为什么优化器必须保存 FP32 主权重？",[19,3470,3471,3472,3543,3544,3614],{},"最核心的原因是权重更新通常非常小，而 BF16 在某个数值附近能分辨的刻度又比较粗。BF16 只有 7 bit 显式尾数，算上隐含的最高位，大约可以理解成 8 bit 有效精度；在 1.0 附近，相邻两个 BF16 数之间的间隔约为 ",[59,3473,3475,3498],{"className":3474},[2583],[59,3476,3478],{"className":3477},[2587],[2589,3479,3480],{"xmlns":2591},[2593,3481,3482,3495],{},[2596,3483,3484],{},[2611,3485,3486,3488],{},[2607,3487,2658],{},[2596,3489,3490,3492],{},[2599,3491,2605],{},[2607,3493,3494],{},"7",[2681,3496,3497],{"encoding":2683},"2^{-7}",[59,3499,3501],{"className":3500,"ariaHidden":2689},[2688],[59,3502,3504,3508],{"className":3503},[2693],[59,3505],{"className":3506,"style":3507},[2697],"height:0.8141em;",[59,3509,3511,3514],{"className":3510},[2706],[59,3512,2658],{"className":3513},[2706],[59,3515,3517],{"className":3516},[2720],[59,3518,3520],{"className":3519},[2724],[59,3521,3523],{"className":3522},[2728],[59,3524,3526],{"className":3525,"style":3507},[2732],[59,3527,3528,3531],{"style":2736},[59,3529],{"className":3530,"style":2741},[2740],[59,3532,3534],{"className":3533},[2745,2746,2747,2748],[59,3535,3537,3540],{"className":3536},[2706,2748],[59,3538,2605],{"className":3539},[2706,2748],[59,3541,3494],{"className":3542},[2706,2748],"，也就是 0.0078125。FP32 的尾数细得多，在 1.0 附近的间隔约为 ",[59,3545,3547,3570],{"className":3546},[2583],[59,3548,3550],{"className":3549},[2587],[2589,3551,3552],{"xmlns":2591},[2593,3553,3554,3567],{},[2596,3555,3556],{},[2611,3557,3558,3560],{},[2607,3559,2658],{},[2596,3561,3562,3564],{},[2599,3563,2605],{},[2607,3565,3566],{},"23",[2681,3568,3569],{"encoding":2683},"2^{-23}",[59,3571,3573],{"className":3572,"ariaHidden":2689},[2688],[59,3574,3576,3579],{"className":3575},[2693],[59,3577],{"className":3578,"style":3507},[2697],[59,3580,3582,3585],{"className":3581},[2706],[59,3583,2658],{"className":3584},[2706],[59,3586,3588],{"className":3587},[2720],[59,3589,3591],{"className":3590},[2724],[59,3592,3594],{"className":3593},[2728],[59,3595,3597],{"className":3596,"style":3507},[2732],[59,3598,3599,3602],{"style":2736},[59,3600],{"className":3601,"style":2741},[2740],[59,3603,3605],{"className":3604},[2745,2746,2747,2748],[59,3606,3608,3611],{"className":3607},[2706,2748],[59,3609,2605],{"className":3610},[2706,2748],[59,3612,3566],{"className":3613},[2706,2748],"，也就是 1.19e-7。",[19,3616,3617],{},"训练里一次参数更新大概长这样：",[59,3619,3621,3659],{"className":3620},[2583],[59,3622,3624],{"className":3623},[2587],[2589,3625,3626],{"xmlns":2591},[2593,3627,3628,3656],{},[2596,3629,3630,3634,3637,3639,3642,3645,3647,3649,3651,3653],{},[2619,3631,3633],{"mathvariant":3632},"normal","Δ",[2619,3635,3636],{},"w",[2599,3638,3008],{},[2619,3640,3641],{},"l",[2619,3643,3644],{},"r",[2599,3646,2633],{},[2619,3648,2627],{},[2619,3650,3644],{},[2619,3652,2138],{},[2619,3654,3655],{},"d",[2681,3657,3658],{"encoding":2683},"\\Delta w = lr \\times grad\n",[59,3660,3662,3684,3708],{"className":3661,"ariaHidden":2689},[2688],[59,3663,3665,3668,3671,3675,3678,3681],{"className":3664},[2693],[59,3666],{"className":3667,"style":3298},[2697],[59,3669,3633],{"className":3670},[2706],[59,3672,3636],{"className":3673,"style":3674},[2706,2755],"margin-right:0.0269em;",[59,3676],{"className":3677,"style":3284},[2769],[59,3679,3008],{"className":3680},[3288],[59,3682],{"className":3683,"style":3284},[2769],[59,3685,3687,3691,3695,3699,3702,3705],{"className":3686},[2693],[59,3688],{"className":3689,"style":3690},[2697],"height:0.7778em;vertical-align:-0.0833em;",[59,3692,3641],{"className":3693,"style":3694},[2706,2755],"margin-right:0.0197em;",[59,3696,3644],{"className":3697,"style":3698},[2706,2755],"margin-right:0.0278em;",[59,3700],{"className":3701,"style":2770},[2769],[59,3703,2633],{"className":3704},[2774],[59,3706],{"className":3707,"style":2770},[2769],[59,3709,3711,3714,3717,3720,3723],{"className":3710},[2693],[59,3712],{"className":3713,"style":3404},[2697],[59,3715,2627],{"className":3716,"style":2762},[2706,2755],[59,3718,3644],{"className":3719,"style":3698},[2706,2755],[59,3721,2138],{"className":3722},[2706,2755],[59,3724,3655],{"className":3725},[2706,2755],[19,3727,3728,3729,3732,3733,3736,3737,181],{},"如果学习率是 1e-5，梯度量级又差不多是 1，那么这一步更新就是 1e-5。对于 FP32 来说，这个变化明显大于 1.0 附近的最小刻度，所以权重会真实发生变化；虽然十进制的 ",[56,3730,3731],{},"1.00001"," 本身不一定能被二进制浮点数精确表示，但这个更新不会被整个抹掉。对于 BF16 来说就不同了，1e-5 远小于 0.0078125，",[56,3734,3735],{},"1.0 + 0.00001"," 四舍五入之后仍然很可能回到 ",[56,3738,3739],{},"1.0",[19,3741,3742],{},"这就是所谓的 swamping problem：大数加小数时，小数被低精度格式的刻度吞掉。它麻烦的地方不在于某一步误差稍微大一点，而在于训练本来就是几千步、几万步的小更新累积。如果每一步都在 BF16 权重上直接更新，大量微小变化会反复消失，模型看起来在反传，实际上参数没有按预期移动。",[19,3744,3745,3746,1608,3748,3750,3751,3753,3754,3756],{},"AdamW 里的 ",[56,3747,2275],{},[56,3749,2278],{}," 也有类似问题。它们是梯度的一阶、二阶指数滑动平均，本质上也是长期状态。如果这些状态本身就用低精度保存，早期的细小变化和后期的微调信号都会更容易被量化误差污染。所以常见做法是：计算权重可以是 BF16，优化器读到梯度后，仍然在 FP32 主权重、FP32 ",[56,3752,2275],{}," 和 FP32 ",[56,3755,2278],{}," 上完成更新，再把新的权重同步回低精度计算副本。",[26,3758,3760],{"id":3759},"为什么前向反向传播可以用-bf16","为什么前向\u002F反向传播可以用 BF16？",[19,3762,3763],{},"看起来这好像有点矛盾：既然优化器更新这么怕 BF16，为什么前向和反向又能用 BF16？关键在于，这两类计算对误差的敏感方式不一样。",[19,3765,3766],{},"前向传播主要是一层层矩阵乘、归一化、激活函数和残差连接：",[59,3768,3770,3822],{"className":3769},[2583],[59,3771,3773],{"className":3772},[2587],[2589,3774,3775],{"xmlns":2591},[2593,3776,3777,3819],{},[2596,3778,3779,3782,3784,3786,3789,3791,3793,3795,3797,3799,3801,3803,3805,3807,3810,3812,3814,3817],{},[2619,3780,3781],{},"y",[2599,3783,3008],{},[2619,3785,2138],{},[2619,3787,3788],{},"c",[2619,3790,2643],{},[2619,3792,2624],{},[2619,3794,3001],{},[2619,3796,2138],{},[2619,3798,2643],{},[2619,3800,2624],{},[2619,3802,2671],{},[2619,3804,2630],{},[2599,3806,2602],{"stretchy":2601},[2619,3808,3809],{},"W",[2619,3811,2666],{},[2599,3813,2946],{},[2619,3815,3816],{},"b",[2599,3818,2615],{"stretchy":2601},[2681,3820,3821],{"encoding":2683},"y = activation(Wx + b)\n",[59,3823,3825,3844,3899],{"className":3824,"ariaHidden":2689},[2688],[59,3826,3828,3832,3835,3838,3841],{"className":3827},[2693],[59,3829],{"className":3830,"style":3831},[2697],"height:0.625em;vertical-align:-0.1944em;",[59,3833,3781],{"className":3834,"style":2762},[2706,2755],[59,3836],{"className":3837,"style":3284},[2769],[59,3839,3008],{"className":3840},[3288],[59,3842],{"className":3843,"style":3284},[2769],[59,3845,3847,3850,3853,3856,3859,3862,3865,3868,3871,3874,3877,3880,3883,3887,3890,3893,3896],{"className":3846},[2693],[59,3848],{"className":3849,"style":3026},[2697],[59,3851,2138],{"className":3852},[2706,2755],[59,3854,3788],{"className":3855},[2706,2755],[59,3857,2643],{"className":3858},[2706,2755],[59,3860,2624],{"className":3861},[2706,2755],[59,3863,3001],{"className":3864,"style":2762},[2706,2755],[59,3866,2138],{"className":3867},[2706,2755],[59,3869,2643],{"className":3870},[2706,2755],[59,3872,2624],{"className":3873},[2706,2755],[59,3875,2671],{"className":3876},[2706,2755],[59,3878,2630],{"className":3879},[2706,2755],[59,3881,2602],{"className":3882},[2702],[59,3884,3809],{"className":3885,"style":3886},[2706,2755],"margin-right:0.1389em;",[59,3888,2666],{"className":3889},[2706,2755],[59,3891],{"className":3892,"style":2770},[2769],[59,3894,2946],{"className":3895},[2774],[59,3897],{"className":3898,"style":2770},[2769],[59,3900,3902,3905,3908],{"className":3901},[2693],[59,3903],{"className":3904,"style":3026},[2697],[59,3906,3816],{"className":3907},[2706,2755],[59,3909,2615],{"className":3910},[2713],[19,3912,3913],{},"BF16 和 FP32 一样有 8 bit exponent，动态范围接近 FP32，因此相比 FP16 更不容易因为量级问题上溢或下溢。这里不能说“不会溢出”，极端输入、异常 loss、错误的初始化照样会把数值打爆；更准确的说法是，BF16 把 FP16 最头疼的动态范围问题缓和了很多。尾数少带来的舍入误差依然存在，但神经网络训练本身就带有 mini-batch 采样噪声、dropout、数据增强等随机性，很多局部舍入误差不会像优化器状态那样长期原封不动地累积。",[19,3915,3916],{},"反向传播也类似。梯度当然不是越粗越好，但它本身就是从一个 mini-batch 估计出来的随机量，我们通常更关心整体方向和统计趋势，而不是每个元素最后几位小数完全准确。更重要的是，现代 GPU 上很多低精度矩阵乘并不是“从头到尾都 BF16”：常见路径是 BF16 输入，内部用更高精度做累加，再输出 BF16 或 FP32 结果。框架也经常会让 softmax、LayerNorm、loss 计算这类敏感算子保留更高精度。",[19,3918,3919,3920,3923],{},"所以这里不能简单概括成“乘法可以低精度，累加必须高精度”。矩阵乘本身就包含大量累加，只是这些累加通常发生在受控的算子内部，并且硬件和框架会用更稳的 accumulate 精度处理。优化器更新的问题更特殊：它是在同一个持久化权重上反复加一个很小的增量，",[56,3921,3922],{},"w = w + lr * grad"," 这种“大数吃小数”的场景正好是低尾数精度最容易翻车的地方。",[19,3925,3926],{},"因此混合精度训练的直觉可以总结成一句话：把吞吐量最大的前向\u002F反向计算交给 BF16，把最怕长期量化误差的优化器状态留在 FP32。这样既吃到低精度硬件的速度和显存收益，又尽量不破坏训练过程中真正需要细粒度累积的部分。",{"title":54,"searchDepth":68,"depth":68,"links":3928},[3929,3930,3931,3932],{"id":2576,"depth":68,"text":2576},{"id":2906,"depth":68,"text":2906},{"id":3467,"depth":68,"text":3468},{"id":3759,"depth":68,"text":3760},"\u002Fimages\u002Fposts\u002Fmix_precision\u002Fcover.png",{},"\u002Fposts\u002Fmix_precision\u002Fmain",{"title":2568,"description":2573},"posts\u002Fmix_precision\u002Fmain",[2044,2564],"S-4aoM7SUoMsqd7d8Gp3rk4W3X4J9vb1xRMS7Nnztfw",{"id":3941,"title":3942,"body":3943,"cover":4262,"date":4263,"description":54,"draft":2036,"extension":2037,"meta":4264,"navigation":1339,"path":4265,"seo":4266,"stem":4267,"summary":4268,"tags":4269,"__hash__":4270},"posts\u002Fposts\u002Fverl_0\u002Fmain.md","verl调参指南",{"type":8,"value":3944,"toc":4249},[3945,3949,3952,3963,3973,3979,3982,3988,3996,4008,4017,4026,4028,4032,4041,4045,4052,4061,4076,4080,4087,4097,4114,4120,4122,4126,4133,4137,4142,4156,4165,4169,4175,4196,4203,4207,4217],[11,3946,3948],{"id":3947},"grpo-训练与调参梳理","GRPO 训练与调参梳理",[26,3950,3951],{"id":3951},"前言",[19,3953,3954,3955,3958,3959,3962],{},"不久前尝试复现了一个 GRPO 训练 Qwen2.5-VL-7B 的工作，当时面对 ",[56,3956,3957],{},"verl"," 繁多的参数，看个文档也是一知半解，简单粗暴地改小了 ",[56,3960,3961],{},"train_batch_size"," 试图增加 logging 频率，结果没练几步就爆了。",[19,3964,3965,3969],{},[2176,3966],{"alt":3967,"src":3968},"github issue 0","\u002Fimages\u002Fposts\u002Fverl_0\u002Fgrpo_0.png",[2176,3970],{"alt":3971,"src":3972},"github issue 1","\u002Fimages\u002Fposts\u002Fverl_0\u002Fgrpo_1.png",[19,3974,3975,3976,3978],{},"这次重回 ",[56,3977,3957],{}," 进行训练，决定先把这些参数背后的逻辑彻底理顺。",[26,3980,3981],{"id":3981},"概念梳理",[19,3983,3984,3985,3987],{},"在聊流程之前，我们得先搞清楚 ",[56,3986,3957],{}," 里这几个容易打架的 Batch Size 概念。这几个参数如果不区分清楚，后面的逻辑很难盘得通：",[19,3989,3990,3991,3995],{},"首先是 ",[37,3992,3993],{},[56,3994,3961],{},"，这是宏观层面的一次“采样大部队”。它决定了在一次采样阶段（Rollout），模型总共要处理多少个不同的 Prompt。",[19,3997,3998,3999,4004,4005,181],{},"其次是 ",[37,4000,4001],{},[56,4002,4003],{},"rollout.n","，也就是 Group Size。GRPO 的特点就是对同一个 Prompt 生成多个回复（Group），然后组内比较优势。所以，实际上模型在采样阶段产生的数据总量是 ",[56,4006,4007],{},"train_batch_size * rollout.n",[19,4009,4010,4011,4016],{},"有了数据就要训练，这就轮到 ",[37,4012,4013],{},[56,4014,4015],{},"ppo_mini_batch_size"," 了。这才是优化器（Optimizer）真正关心的参数——每次更新权重时“吃”多少数据。",[19,4018,4019,4020,4025],{},"至于 ",[37,4021,4022],{},[56,4023,4024],{},"micro_batch_size","，它纯粹是为了照顾显存的。如果显卡塞不下 mini batch，就切得更碎一点用梯度累加（Gradient Accumulation）来凑，它不影响训练逻辑。",[23,4027],{},[26,4029,4031],{"id":4030},"grpo-的两阶段循环","GRPO 的两阶段循环",[19,4033,4034,4035,1608,4038,181],{},"GRPO 的训练本质上就是两个阶段的无限循环：",[37,4036,4037],{},"造数据（Rollout）",[37,4039,4040],{},"吃数据（Update）",[183,4042,4044],{"id":4043},"第一阶段造数据-rollout","第一阶段：造数据 (Rollout)",[19,4046,4047,4048,4051],{},"这个时候模型是冻结的（",[56,4049,4050],{},"eval"," 模式），它的任务就是根据当前的策略去“见世面”。",[19,4053,4054,4055,4057,4058,4060],{},"系统会从数据集中捞出 ",[56,4056,3961],{}," 个 Prompt，然后让模型对着每个 Prompt 生成 ",[56,4059,4003],{}," 个回答。",[19,4062,4063,4064,4067,4068,4071,4072,4075],{},"生成完之后，最关键的一步来了：模型会立刻计算这些回答的 ",[56,4065,4066],{},"old_log_prob","。你可以把这个值看作是一个 ",[37,4069,4070],{},"“锚点”","。在接下来的训练中，无论模型参数怎么变，这个锚点数值是",[37,4073,4074],{},"绝对不会变","的。它就像是一个参照系，用来时刻提醒模型：“你现在的策略和刚开始采样时的策略差了多远”。",[183,4077,4079],{"id":4078},"第二阶段吃数据-ppo-update","第二阶段：吃数据 (PPO Update)",[19,4081,4082,4083,4086],{},"数据造好了，Reward Model 也打完分了，现在模型切换到训练模式（",[56,4084,4085],{},"train","），开始参数更新。",[19,4088,4089,4090,4093,4094,4096],{},"通常我们会把这批造好的数据重复利用几次（",[56,4091,4092],{},"ppo_epochs","），在每一个 Epoch 里，数据被切分成小块（",[56,4095,4015],{},"）喂给优化器。",[19,4098,4099,4102,4103,4106,4107,4110,4111,4113],{},[37,4100,4101],{},"这里有个非常重要的细节：","\n我们在 PPO Update 中会进行多次 ",[56,4104,4105],{},"optimizer.step()","。虽然每一次更新后，模型的参数都变了，新计算出的概率（",[56,4108,4109],{},"log_prob","）也变了，但我们在第一阶段算好的那个“锚点”（",[56,4112,4066],{},"）是定死的。",[19,4115,4116,4117,181],{},"这意味着，",[37,4118,4119],{},"随着更新步数越来越多，现在的模型策略会逐渐偏离采样时的策略",[23,4121],{},[26,4123,4125],{"id":4124},"为什么会练崩调参背后的逻辑","为什么会练崩？调参背后的逻辑",[19,4127,4128,4129,4132],{},"理解了上面的流程，之前的“崩盘”原因就呼之欲出了。调参的核心，其实就是在平衡**“外循环的广度”",[37,4130,4131],{},"和","“内循环的深度”**。",[183,4134,4136],{"id":4135},"_1-外循环为什么-train_batch_size-越大越稳","1. 外循环：为什么 train_batch_size 越大越稳？",[19,4138,4139,4141],{},[56,4140,3961],{}," 决定了模型在改变策略之前，到底看了多少“案例”。",[390,4143,4144,4150],{},[268,4145,4146,4149],{},[37,4147,4148],{},"如果 Batch 很小（比如 256）","：\n这就好比一个学生，做了一道题就急着对答案、改思路。这道题可能比较偏，学生改完思路后，再做下一道题发现又不对，于是又改。模型就会陷入这种“做一题改一次”的震荡中，非常容易过拟合当前的局部数据，导致策略剧烈抖动。",[268,4151,4152,4155],{},[37,4153,4154],{},"如果 Batch 很大（比如 10240）","：\n这就像学生做完了一整套模拟卷（100题），综合了所有题目的得失，才总结出一套改进方案。这种基于大样本量的梯度方向是非常稳健的，因为它看到了数据的“全貌”。",[19,4157,4158,4159],{},"所以结论很简单：",[37,4160,4161,4162,4164],{},"只要显存和时间允许，",[56,4163,3961],{}," 越大越好。",[183,4166,4168],{"id":4167},"_2-内循环ppo_mini_batch_size-的黄金比例","2. 内循环：ppo_mini_batch_size 的黄金比例",[19,4170,4171,4172,4174],{},"确定了要采多少数据（Train Batch），接下来就是决定“怎么吃掉这批数据”。这时候 ",[56,4173,4015],{}," 就很关键了。",[390,4176,4177,4187],{},[268,4178,4179,4182,4183,4186],{},[37,4180,4181],{},"吃得太慢（Mini Batch 太小）","：\n这意味着你需要更新很多次参数才能吃完这批数据。就像上面说的，更新次数越多，模型策略偏离“锚点”就越远。\n这就导致到了后面几步，模型现在的想法和采样时的想法已经天差地别了。此时计算出的 Ratio (",[56,4184,4185],{},"log_prob \u002F old_log_prob",") 会变得极不稳定，PPO 的裁剪机制（Clip）会强制把梯度切零。结果就是：你后面算的这些步数，基本都是无效计算，甚至是有害的。",[268,4188,4189,4192,4193,4195],{},[37,4190,4191],{},"吃得太快（Mini Batch 太大）","：\n假如你直接一口吞（Mini Batch = Train Batch），那这一轮 Rollout 辛辛苦苦生成的几万条数据，只换来了一次 ",[56,4194,4105],{},"。这就太奢侈了，数据利用率极低，训练效率慢得令人发指。",[19,4197,4198,4199,4202],{},"所以，这里需要一个**“黄金比例”**。经验法则通常是：",[37,4200,4201],{},"调节 Mini Batch 的大小，让每一轮 Rollout 的总更新步数保持在 4-8 步左右。"," 既保证了数据被重复利用，又不会让策略偏离太远。",[183,4204,4206],{"id":4205},"_3-实战避坑指南","3. 实战避坑指南",[19,4208,4209,4210,1608,4213,4216],{},"最后总结一下，我们在看 Log 的时候，主要盯着 ",[56,4211,4212],{},"Clip Ratio",[56,4214,4215],{},"KL Divergence"," 这两个指标看就行：",[390,4218,4219,4235],{},[268,4220,4221,4227,4228,4231,4232,4234],{},[37,4222,4223,4224,4226],{},"如果你发现 ",[56,4225,4212],{}," 特别低 (\u003C 1%)","，而且 Loss 下降得很慢。那说明你太保守了，数据还没被榨干。\n👉 ",[37,4229,4230],{},"尝试","：减小 ",[56,4233,4015],{},"（多更几步），或者干脆多跑一个 Epoch。",[268,4236,4237,4242,4243,4245,4246,4248],{},[37,4238,4223,4239,4241],{},[56,4240,4212],{}," 飙升 (> 15%)","，或者 KL 散度突然爆炸。那说明你太激进，模型已经“学歪了”。\n👉 ",[37,4244,4230],{},"：增大 ",[56,4247,4015],{},"（少更几步），让模型“少吃多餐”变为“多吃少餐”，稳住心态。",{"title":54,"searchDepth":68,"depth":68,"links":4250},[4251,4252,4253,4257],{"id":3951,"depth":68,"text":3951},{"id":3981,"depth":68,"text":3981},{"id":4030,"depth":68,"text":4031,"children":4254},[4255,4256],{"id":4043,"depth":74,"text":4044},{"id":4078,"depth":74,"text":4079},{"id":4124,"depth":68,"text":4125,"children":4258},[4259,4260,4261],{"id":4135,"depth":74,"text":4136},{"id":4167,"depth":74,"text":4168},{"id":4205,"depth":74,"text":4206},"\u002Fimages\u002Fposts\u002Fverl_0\u002Fcover.png","2026-01-08",{},"\u002Fposts\u002Fverl_0\u002Fmain",{"title":3942,"description":54},"posts\u002Fverl_0\u002Fmain","梳理GRPO流程以及参数",[2044,2564],"_oKO3pjhfCDakSPQBUJpkFye5oOiGAhgShYOmxmJquI",1782840071009]