[{"content":"Asyncio 写后端时，asyncio 最容易被误解成“让 Python 变快”。更准确地说，它解决的是另一类问题：当程序大量时间花在等待网络、磁盘、数据库、缓存、第三方 API 时，不要让一个等待中的请求占住整条执行线。\n同步程序的默认思路是：函数调用之后一直等，等它返回再继续下一步。异步程序的思路是：遇到可以等待的 I/O，就把执行权还给事件循环，让事件循环先去推进别的任务。CPU 并没有凭空变多，只是等待时间被更充分地利用了。\n这也是为什么 asyncio 很适合 Web 服务、爬虫、消息队列消费者、长连接服务、批量调用外部 API 等场景；但如果任务主要是在本地做大规模计算，例如压缩图片、训练模型、解析巨型文件，asyncio 本身不会明显提升性能，应该考虑多进程、线程池、向量化计算或把任务丢给专门的 worker。\nEvent Loop：一个线程里的调度器 asyncio 的核心是事件循环。它不是魔法，也不是自动并行执行所有代码的运行时。可以把它理解成一个负责调度协程的循环：\nasyncio │ ▼ Event Loop │ 调度很多 Task │ ┌──────────────┼──────────────┐ ▼ ▼ ▼ Task A Task B Task C │ │ │ ▼ ▼ ▼ Coroutine Coroutine Coroutine │ │ │ await await await │ │ │ └─────── 发生 I/O 等待 ───────┘ │ ▼ 把执行权还给 Event Loop │ ▼ 执行其他 Task 普通函数用 def 定义，调用后会立刻执行；协程函数用 async def 定义，调用后只会得到一个 coroutine object，并不会马上运行。真正让它运行的，是 await、asyncio.run()、create_task() 这类入口。\nimport asyncio async def fetch_user(user_id: int) -\u0026gt; dict: await asyncio.sleep(1) return {\u0026#34;id\u0026#34;: user_id, \u0026#34;name\u0026#34;: \u0026#34;Deng\u0026#34;} async def main() -\u0026gt; None: user = await fetch_user(1) print(user) asyncio.run(main()) 这里的 asyncio.sleep(1) 可以理解成一个模拟 I/O 的等待点。执行到 await 时，当前协程暂停，事件循环可以去运行别的 task。等 1 秒计时结束后，这个协程再从暂停的位置继续执行。\n因此，await 的含义不是“开一个新线程”，而是“我现在要等一个异步结果，等待期间可以把控制权交出去”。如果一个 async def 里面全是普通 CPU 计算，没有任何真正可让出的等待点，它依然会阻塞事件循环。\nCoroutine、Task 和 Future 学习 asyncio 时，最常见的三个词是 coroutine、task、future。它们不是同一个层级。\nCoroutine 是“可暂停的函数调用”。调用 async def 函数得到的就是 coroutine，它描述了要做什么，但还没有被事件循环正式调度。\nTask 是“已经交给事件循环调度的 coroutine”。一旦用 asyncio.create_task() 包起来，事件循环就可以在合适的时候推进它。\nFuture 是“未来某个时刻会完成的结果容器”。平时业务代码里不常手写 Future，但很多底层异步库会用它表示尚未完成的 I/O 结果。\nimport asyncio async def call_api(name: str, delay: float) -\u0026gt; str: await asyncio.sleep(delay) return f\u0026#34;{name} done\u0026#34; async def main() -\u0026gt; None: task = asyncio.create_task(call_api(\u0026#34;profile\u0026#34;, 1.0)) print(\u0026#34;task has been scheduled\u0026#34;) result = await task print(result) asyncio.run(main()) create_task() 之后，call_api() 已经被安排进事件循环。后面 await task 的意思是：当前协程需要这个任务的结果，如果还没完成，就先让出执行权。\n一个容易踩坑的地方是：只创建 coroutine，不 await，也不创建 task，它不会真正执行。\nasync def send_email() -\u0026gt; None: ... async def main() -\u0026gt; None: send_email() # 错误：这里只是创建 coroutine，没有执行 await send_email() # 正确：等待它执行完成 在真实项目里，这类问题经常表现为日志没打、请求没发、后台任务没跑，同时还可能出现 coroutine was never awaited 警告。\n并发：gather、create_task 和 TaskGroup asyncio 最直接的收益来自并发等待。比如一个后端接口需要同时查用户资料、订单列表和推荐结果，如果三件事彼此独立，就没有必要串行等待。\n串行写法的问题很明显：\nimport asyncio async def get_profile() -\u0026gt; str: await asyncio.sleep(1) return \u0026#34;profile\u0026#34; async def get_orders() -\u0026gt; str: await asyncio.sleep(1) return \u0026#34;orders\u0026#34; async def get_recommendations() -\u0026gt; str: await asyncio.sleep(1) return \u0026#34;recommendations\u0026#34; async def main() -\u0026gt; None: profile = await get_profile() orders = await get_orders() recommendations = await get_recommendations() print(profile, orders, recommendations) asyncio.run(main()) 这段代码总耗时接近 3 秒，因为每一步都等上一步完成。改成 asyncio.gather() 后，三个 I/O 等待可以重叠：\nasync def main() -\u0026gt; None: profile, orders, recommendations = await asyncio.gather( get_profile(), get_orders(), get_recommendations(), ) print(profile, orders, recommendations) 它的时间线更接近下面这样：\nprofile ██████████ 1s orders ██████████ 1s recommendations ██████████ 1s total ██████████ about 1s gather() 适合“我有一组任务，并且需要等它们全部完成”的场景。它会按传入顺序返回结果，而不是按完成顺序返回。\n如果想先启动一个任务，后面再决定什么时候等待，可以用 create_task()：\nasync def main() -\u0026gt; None: profile_task = asyncio.create_task(get_profile()) orders = await get_orders() profile = await profile_task print(profile, orders) Python 3.11 之后，更推荐在结构化并发场景里使用 TaskGroup。它的好处是任务生命周期更清晰：离开 TaskGroup 作用域时，里面的任务要么全部正常结束，要么异常被集中抛出，未完成任务会被取消。\nimport asyncio async def main() -\u0026gt; None: async with asyncio.TaskGroup() as tg: profile_task = tg.create_task(get_profile()) orders_task = tg.create_task(get_orders()) rec_task = tg.create_task(get_recommendations()) print( profile_task.result(), orders_task.result(), rec_task.result(), ) asyncio.run(main()) 对于后端服务来说，TaskGroup 的语义更接近“这一组子任务属于当前请求”。请求结束时，子任务也应该被明确收束，而不是悄悄留在事件循环里继续跑。\n不过，gather() 和 TaskGroup 更适合“一批任务同时开始，最后一起收口”的场景。如果任务是持续进入的，或者上游和下游速度不一致，就应该考虑 asyncio.Queue。Queue 的价值不是让代码更高级，而是把并发拆成生产者和消费者，并且通过 maxsize 做背压：下游处理不过来时，上游会在 put() 处等待，避免内存被无限撑大。\n在后端里，Queue 常见于这几类场景：消息流消费、批量文件处理、爬虫 URL 调度、日志/埋点异步落库、RAG 文档处理流水线。它适合“任务之间有阶段关系”的流程，而不是简单的三次独立 API 调用。\n以 RAG 管道为例，文档解析、Embedding 计算、向量库写入通常不是同一种并发模型。解析 PDF 可能是纯 Python CPU 密集，适合进程池；Embedding 如果调用的是本地 C/CUDA 库，可能适合线程池或专用服务；向量库写入大多是网络 I/O，直接 await。这时就不要等所有文档解析完再统一 embedding，而是让每个阶段通过 Queue 串起来，谁完成就把结果交给下一段。\nparse_queue = asyncio.Queue(maxsize=20) embed_queue = asyncio.Queue(maxsize=20) # parse_worker: # 解析一个文档 # await parse_queue.put(text) # # embed_worker: # text = await parse_queue.get() # 计算 embedding # await embed_queue.put((text, embedding)) # # store_worker: # text, embedding = await embed_queue.get() # await vector_db.upsert(text, embedding) 这个结构的关键是“流水线并发”：解析、Embedding、写入三个阶段可以同时推进，但每个阶段又能设置自己的并发上限。Queue 的 maxsize 负责背压，worker 数量负责吞吐，结束信号负责让消费者自然退出。相比一口气 gather() 几千个任务，这种写法更适合长批次任务和生产服务。\n超时、取消和异常 异步代码里最不能省的是超时。一个外部 API、数据库查询或缓存连接如果没有超时，就可能把请求挂死。同步代码会卡住线程；异步代码虽然不会卡住整个线程，但会泄漏任务、堆积连接，并最终拖垮服务。\nPython 3.11 以后可以用 asyncio.timeout() 给一段异步逻辑加超时：\nimport asyncio async def call_payment_api() -\u0026gt; str: await asyncio.sleep(10) return \u0026#34;ok\u0026#34; async def main() -\u0026gt; None: try: async with asyncio.timeout(2): result = await call_payment_api() print(result) except TimeoutError: print(\u0026#34;payment api timeout\u0026#34;) asyncio.run(main()) 超时的本质通常是取消当前等待中的任务。取消不是普通失败，它会在协程内部抛出 asyncio.CancelledError。如果协程持有连接、锁、临时文件或其他资源，必须用 try/finally 做清理。\nasync def consume_messages(consumer) -\u0026gt; None: await consumer.start() try: async for message in consumer: await handle_message(message) finally: await consumer.stop() 不要随手吞掉 CancelledError。如果确实要捕获它做清理，清理后通常应该继续抛出，让上层知道任务已经被取消。\nasync def worker() -\u0026gt; None: try: await asyncio.sleep(60) except asyncio.CancelledError: await flush_metrics() raise gather() 的异常行为也要注意：默认情况下，只要其中一个 awaitable 抛异常，gather() 就会把第一个异常抛给调用方；其他已经提交的 awaitable 通常不会因为这个异常自动取消，但调用方也拿不到一组完整的正常结果。业务上如果需要“尽力返回”，可以显式使用 return_exceptions=True，但要认真处理返回列表里的异常对象。\nresults = await asyncio.gather( get_profile(), get_orders(), get_recommendations(), return_exceptions=True, ) for item in results: if isinstance(item, Exception): print(\u0026#34;partial failure:\u0026#34;, item) else: print(\u0026#34;success:\u0026#34;, item) 这种写法适合推荐位、画像补充、非核心信息聚合等场景；不适合支付、下单、扣库存这类强一致流程，因为局部失败不能被轻描淡写地吞掉。\n后端项目里的实践边界 asyncio 在后端项目里通常不会单独出现，而是藏在框架和客户端库下面。比如 FastAPI、Starlette、aiohttp、asyncpg、aioredis、httpx 的异步客户端，本质上都是围绕事件循环组织 I/O。\n一个典型接口可能长这样：\nfrom fastapi import FastAPI import asyncio import httpx app = FastAPI() async def fetch_json(client: httpx.AsyncClient, url: str) -\u0026gt; dict: response = await client.get(url, timeout=2.0) response.raise_for_status() return response.json() @app.get(\u0026#34;/dashboard/{user_id}\u0026#34;) async def dashboard(user_id: int) -\u0026gt; dict: async with httpx.AsyncClient() as client: profile, orders = await asyncio.gather( fetch_json(client, f\u0026#34;https://api.example.com/users/{user_id}\u0026#34;), fetch_json(client, f\u0026#34;https://api.example.com/orders/{user_id}\u0026#34;), ) return { \u0026#34;profile\u0026#34;: profile, \u0026#34;orders\u0026#34;: orders, } 这里有几个实际约束。\n第一，异步函数里不要调用会长时间阻塞的同步 I/O。例如 requests.get()、普通数据库同步客户端、大文件同步读写，都可能把事件循环卡住。既然入口已经是 async def，依赖也应该尽量换成异步版本。\n第二，连接池要复用。上面的例子为了简洁把 AsyncClient 写在请求内部，真实服务里更常见的是在应用启动时创建客户端，在关闭时统一释放。数据库连接池、HTTP 连接池、Redis 连接池也是同理。\n第三，控制并发量。asyncio.gather() 可以一次性发起很多任务，但外部系统不一定扛得住。批量请求第三方服务时，应该用 Semaphore 做限流。\nimport asyncio import httpx limit = asyncio.Semaphore(10) async def fetch_with_limit(client: httpx.AsyncClient, url: str) -\u0026gt; dict: async with limit: response = await client.get(url, timeout=2.0) response.raise_for_status() return response.json() 第四，区分 await、线程池和进程池。判断标准不是“这个函数写在 async 里面”，而是它等待的到底是什么。\n如果调用对象本身就是异步 I/O，例如异步 HTTP 客户端、异步数据库驱动、异步 Redis 客户端，就直接 await。这类等待不会占住事件循环，正是 asyncio 最擅长的地方。\nresponse = await client.get(url, timeout=2.0) rows = await db.fetch(query) await redis.set(key, value) 如果调用对象是阻塞式 I/O，或者是没有 async 版本的老 SDK，例如 requests、同步数据库客户端、同步文件处理、某些云厂商 SDK，就不要直接在事件循环里调用。轻量阻塞任务可以先放进线程池，避免卡住整个 async 服务。\nimport asyncio def parse_large_file(path: str) -\u0026gt; dict: ... async def handle_upload(path: str) -\u0026gt; dict: return await asyncio.to_thread(parse_large_file, path) asyncio.to_thread() 是最省事的线程池入口，适合“偶尔包一层同步函数”。如果需要控制线程数量，或者同一类任务会大量出现，可以显式使用 ThreadPoolExecutor。\nfrom concurrent.futures import ThreadPoolExecutor thread_pool = ThreadPoolExecutor(max_workers=8) result = await loop.run_in_executor(thread_pool, blocking_io_call, arg) 如果任务是纯 Python CPU 密集，例如复杂文本解析、压缩解压、图片处理、规则引擎、大量 JSON 反序列化，线程池通常帮不大，因为 GIL 会限制同一进程内 Python 字节码的并行执行。这类任务更适合进程池，或者直接交给独立 worker 服务。\nfrom concurrent.futures import ProcessPoolExecutor process_pool = ProcessPoolExecutor(max_workers=4) text = await loop.run_in_executor(process_pool, parse_pdf, path) 可以用一个简单规则记住：\n异步 I/O：直接 await。 阻塞 I/O：用线程池，或者换成 async 客户端。 纯 Python CPU 密集：用进程池、任务队列或独立计算服务。 C 扩展/GPU 计算：看库是否释放 GIL；如果释放，线程池可能有效，否则仍然考虑进程池或服务化。 RAG 管道就是一个典型混合场景：文档解析可能进程池，Embedding 可能线程池或模型服务，向量库写入直接 await。asyncio 在这里负责统一调度，而不是亲自完成所有计算。\n最后，可以用一个简单判断来决定要不要引入 asyncio：如果瓶颈主要来自等待 I/O，并且一次请求或一个任务里有多个可重叠的等待点，异步会让吞吐明显改善；如果瓶颈主要来自 CPU，异步只会让代码结构更复杂。asyncio 的价值不是“所有地方都 async”，而是在等待很多、连接很多、任务很多的时候，让一个线程更有秩序地处理这些等待。\n","permalink":"https://dengkoicat.github.io/posts/2026-08-12-backend/","summary":"\u003ch2 id=\"asyncio\"\u003eAsyncio\u003c/h2\u003e\n\u003cp\u003e写后端时，\u003ccode\u003easyncio\u003c/code\u003e 最容易被误解成“让 Python 变快”。更准确地说，它解决的是另一类问题：当程序大量时间花在等待网络、磁盘、数据库、缓存、第三方 API 时，不要让一个等待中的请求占住整条执行线。\u003c/p\u003e","title":"Backend Basic"},{"content":" 原文：Harness Engineering for Self-Improvement，作者 Lilian Weng。\n递归式自我改进（recursive self-improvement, RSI） 的概念可以追溯到 I. J. Good (1965)。他把“超智能机器”定义为一个能够在所有智力活动上超越人类，并能设计出更好机器来改进自身的系统。Yudkowsky (2008) 用“递归式自我改进”这个说法描述一个特定反馈环：AI 使用当前智能来改进产生其智能的认知机器。\n在现代 AI 里，这个反馈环可以指模型直接重写自己的权重；也可以更宽泛地指模型改进训练流水线和部署系统，后者再产生一个性能更强、能更好完成经济价值任务的后继模型。前沿实验室中的 AI 研究开发速度已经被证明出现了显著加速（Anthropic；OpenAI）。\n我特意提到**“部署系统”**，是因为位于原始模型和真实世界上下文之间的这一层，似乎和模型的原始智能同样重要，也就是预训练后立即测得的 eval 能力。Claude Code 和 Codex 这类成功的 coding agent 产品表明，harness 是 AI 部署中的重要组件。Harness 是包围基础模型的系统：它编排执行流程，决定模型如何思考和规划、调用工具和行动、感知并管理上下文、存储产物，以及评估结果。\n本文关注 harness 工程相关研究，以及它如何贡献于 RSI。近期关于自动研究、自我改进 agent、演化式程序搜索的大量工作，都可以围绕这个问题来组织。模型 self-play、合成数据、测试时训练以及更广义的持续学习也符合 RSI 愿景（例如 Yuan et al. 2024、Chen et al. 2024、Zhao et al. 2025、Choi et al. 2026），但它们不是本文重点。\nHarness 设计模式 相比早期 agent 框架中的“agent = LLM + memory + tools + planning + action”，harness 工程还包含工作流设计（例如 loop engineering）、评估、权限控制和持久状态管理。它不再只是 prompt template，而更接近运行时和软件系统设计：模型如何观察、行动、记忆、自检和改进。\n设计应该有意保持简单和通用，以支持泛化；同时应参考既有软件工程实践，让预训练知识可以发挥作用。操作系统和 harness 之间也有很强的类比关系。类似 OS，harness 应该封装复杂逻辑，同时保持接口简单。与此同时，配置、工具接口和其他协议可能会在行业内逐渐标准化。\n模式 1：工作流自动化 定义一个让模型可以操作、测试和迭代的工作流，是自动化的关键设计。Karpathy 的 autoresearch 仓库（https://github.com/karpathy/autoresearch）是构建这类工作流的清晰例子。常见工作流遵循一个面向目标的循环：计划、执行、观察/测试、改进、再次执行，直到目标达成。过程中也可能主动向用户请求澄清任务规格或执行偏好。\n一个简化的 Codex agent loop：agent 调用工具，工具响应会影响模型的下一次生成。Image source: OpenAI codex agent post.\n这个工作流图也强调：模型会分析自己的轨迹和失败案例，然后通过一个“agent runtime”继续迭代，而不是只依赖静态 prompt template。\n模式 2：把文件系统作为持久记忆 长周期 agent 系统中反复出现的一个模式，是用简单控制方式管理丰富的状态和产物。Harness 不应该把整个工作流和所有日志都放进上下文里，而应该把持久状态保存在文件中。在长周期 agent rollout 中，实验日志、代码 diff、论文摘要、错误 trace 和过去 rollout 轨迹等产物，通常会远远超过模型训练时见过的上下文窗口长度。\n学习如何读、写、编辑文件系统，通常通过 bash 命令完成，是 LLM 的基础能力。因此，用文件这种简单形式管理持久记忆，会自然受益于核心模型能力的提升。\n模式 3：Sub-agent 和后台任务 Harness 可以启动多个 subagent 并行执行，并监控后台任务。当主 agent 需要搜索多个假设、并发运行实验，或把隔离子任务委托出去而不污染主上下文时，这很有用。父 agent 于是需要一个小型进程管理器：启动任务、查看日志、取消失败运行，并把结果合并回主 agent 线程。\n关键设计选择是让并行性显式且可检查。如果 subagent 输出只存在于临时聊天上下文里，它们很快会过期并隐藏起来。如果它们被存成文件、日志和状态记录，模型就能在中断后恢复，并基于自己的执行历史进行推理。\n案例研究：Coding Agent Harness 主流 coding agent 的核心接口已经在 Claude Code、Codex、OpenCode 和 Cursor 风格 agent 之间趋于稳定。它们通常使用如下循环：\n有了一组工具后，coding agent 就可以在给定仓库中开发并调试问题，类似人类开发者配备 IDE。\n下面不是完整清单，只用于示意。感兴趣可阅读这里。\n分组 工具定义 文件系统 文件发现：glob、grep、ls\n文件读取：read、read_many\n文件修改：write（完整写入新文件）、edit（精确字符串替换）、multi_edit、apply_patch（应用结构化 patch/diff） Shell 执行 运行命令：bash、PowerShell IO lsp，以及 git_status、git_diff、git_commit 等 git 工具 外部上下文 MCP tools、Skills Web 搜索 web_search、web_fetch、browser tools Artifacts 读取文档和图像；生成 HTML、图像 后台进程 例如 CronCreate、CronDelete、CronList Agent 委托 例如 spawn_agent、resume_agent、wait_agent、list_agents、close_agent、interrupt_agent 等 Harness 层还是核心智能？ 很难预测 RSI 的未来会在多大程度上依赖 harness 工程，但近期路径不太可能从模型直接重写自身权重开始。我对近期可行路径的预测是：\nHarness 工程会朝着 meta-methodology 的方向演化，也就是改进获得更好答案的机器，而不只是改进答案本身。Harness 系统本身会成为优化对象，启发式规则会减少，通用机制会增加。 成熟的 harness 会反过来支持模型自我改进循环中的自动研究；更聪明的模型又会防止 harness 被过度工程化，让系统保持可持续。 最终，许多 harness 改进可能会被内化到核心模型行为中，但外部上下文和工具接口仍然应该保留。我们已经在 prompt engineering 中看到过更温和的类似模式：随着 instruction tuning 和模型推理能力提升，手工 prompt 技巧不再那么核心，但指定目标、约束、上下文和评估的需求并没有消失。\nHarness 优化 Harness 系统中被优化对象的大致演进路径是：instruction prompts -\u0026gt; structured context -\u0026gt; workflow -\u0026gt; harness code -\u0026gt; optimizer code。随着模型越来越智能、越来越强大，我们会走向更复杂的目标和更通用的方法。\n上下文工程 随着 agentic 任务周期显著拉长，把所有工具响应和模型生成结果简单追加进上下文，很快就会失控。上下文管理是一层用于为 LLM 构造更结构化、更简洁上下文并管理持久状态的系统。长期上下文研究毫无疑问会继续进展，但当前长上下文智能和上下文工程有时是交织在一起的。\nAgentic Context Engineering（ACE；Zhang et al. 2025）把上下文视为一个持续演化的 playbook，而不是越来越长的 prompt。它有三个组件，用来维护一个由 bullet point 组成的上下文 playbook，每条都有标识符和描述。\nGenerator：参考 bullet point 产生任务轨迹。 Reflector：从成功和失败轨迹中提炼洞见。 Curator：用增量的、条目化的 entry 更新结构化上下文。 Agentic Context Engineering (ACE) 框架。Image source: Zhang et al. 2025.\n为了在迭代重写中防止上下文坍缩和简短偏置，ACE 的一个关键设计选择是 curator 不重写完整 prompt blob。它输出一组结构化、条目化的 bullet，形式为 (identifier, description)，再通过确定性逻辑合并进结构化上下文日志簿。上下文条目会周期性地精炼和去重。\nACE 从 rollout 中学习洞见这一点，帮助我们走向自管理记忆；但它的更新规则和整体工作流仍然是手写的。为了走向更自我改进的循环，Meta Context Engineering（MCE；Ye et al. 2026）把机制（如何管理上下文）与 artifact 内容（上下文里有什么）分开：在 meta-optimization 层运行 skill evolution，在 base 层运行 context optimization。\n一个 MCE skill $s \\in \\mathcal{S}$ 定义上下文函数 $c_s=(\\rho_s,F_s)$，并把输入 $x$ 映射到上下文 $c = F_s(x;\\rho_s)$，其中：\n$\\rho_s = \\{\\rho_1,\\dots,\\rho_m\\}$ 是静态组件（prompts、knowledge bases、code libraries）。 $F_s = \\{F_1,\\dots,F_k\\}$ 是动态算子（search、selection、filtering、formatting）。 双层优化是在训练数据上给定 skill $s$ 找到最佳上下文 $c_s^*$，同时外层循环寻找能在验证集上获得最佳性能的最优 skill：\n$$ \\text{Inner: }c_s^*=\\arg\\max_{c_s}J_\\text{train}(c_s;s)\\quad \\text{Outer: }s^*=\\arg\\max_{s\\in\\mathcal{S}}J_\\text{val}(c_s^*) $$Skill 数据库会跟踪此前 skill、context function 和 eval metric 的历史：$\\mathcal{H}_{k-1} = \\{(s_i,c_i,J_i^\\text{train}, J_i^\\text{val})\\}_{i=1}^{k-1}$。一个 meta-level agent 会对先前 skills 执行 agentic crossover，为任务 $\\tau$ 创建新 skill：$s_k=\\text{crossover}(\\tau,\\mathcal{H}_{k-1})$。\n随后，base-level context engineer 执行 skill $s_k$，并在当前 skill 指导下从 rollout 反馈 $\\mathcal{R}_k$ 中学习上下文函数：$c_k=\\text{engineer}(\\tau,s_k;c_{k-1}^*,\\mathcal{R}_k)$。\nMeta Context Engineering (MCE) 框架：meta-level skill evolution 搜索上下文管理机制，base level 优化任务上下文。Image source: Ye et al. 2026.\nMCE 不像 ACE 那样强制规定如何组织上下文的启发式规则。它使用自由形式 skill 来存储任务中最重要的知识，并让 skill 和以 skill 为条件的上下文一起迭代演化。实现上，上下文函数 $c$ 被实例化为专用目录中的一组文件，既包括静态组件（skill.md），也包括动态组件（context 和 data rollouts）。Meta-level 和 base-level 优化都在具备标准工具集的 agentic coding 环境中执行：\n$$ \\mathcal{T}=\\{\\texttt{Read},\\texttt{Write},\\texttt{Edit},\\texttt{Bash},\\texttt{Glob},\\texttt{Grep},\\texttt{TodoWrite}\\} $$Meta-Harness（Lee et al. 2026）又向下推进一层：被优化对象是决定并优化“应该存储、检索和呈现哪些信息给模型”的代码。名字里的“Meta-”表示它是一个用来优化 harness 的 harness。\nMeta-Harness 外层循环优化算法。Image source: Lee et al. 2026.\n用于创建新 harness 的 proposer 本身就是一个 coding agent，最终输出是在 Pareto frontier 上的一组 harness candidates。\n完整执行历史可以通过文件系统访问，因此 coding agent 会用 grep 或 cat 这类命令读取历史，而不是把所有内容塞进一个 prompt 上下文。 提出的 harness 是文件系统中的一个目录，里面包含自身源代码、分数、rollout 轨迹和状态更新。 meta-harness loop 会迭代创建新 harness，并只保留合格的候选。 Meta-Harness 在文本分类和 TerminalBench-2 上的表现。注意 TerminalBench-2 实验的搜索初始化来自 Terminus-KIRA 和 Terminus-2 这两个很强的 harness。Image source: Lee et al. 2026.\n重要教训很清楚：一旦 harness 设计变成可执行搜索空间，强 coding agent 就能利用人类工程师也在使用的同一设计空间。\n工作流设计 Harness 工程中的工作流设计可以由领域专家手工完成。以自动研究为例，已有多个框架被提出并测试。AI Scientist 系统（Lu et al. 2026）构建了一条流水线：提出研究想法、编写代码、运行实验、分析结果、撰写论文并进行 peer review。Meng et al. (2026) 在 ScientistOne 中把可验证性作为核心设计约束：每个 claim（引用、数值、方法、结论）都必须追溯到证据来源，并通过 Chain-of-Evidence 检查审计。\nAI Scientist 用于 idea generation、实验、论文写作和 review 的流水线。Image source: Lu et al. 2026.\nAutodata agent（Kulikov et al. 2026）被设计为一个用于生成训练和评估数据的数据科学家。主 agent 管理一个提出问题的 challenger、一个 weak solver、一个 strong solver 和一个 verifier/judge，目标是合成“刚刚好”难度的数据，也就是 strong solver 能成功而 weak solver 会失败。\n在 Autodata 中，challenger prompt 会根据 solver 和 verifier 的反馈迭代更新。这里的局限在于，合成任务用于微调 weak solver，而不是 strong solver；如果这个循环不能迭代地改进 strong model，它就更像是对生成 prompt 分布的间接蒸馏，RSI 味道会弱一些。\nAutodata 围绕 challenger、solver 和 verifier 角色生成合成训练与评估数据的 agentic workflow 设计。Image source: Kulikov et al. 2026.\n工作流设计空间巨大。我们自然可以把工作流设计看成一个搜索问题，因此应该可以通过算法找到好解，而不仅靠手工构造。沿着这个方向，Automated Design of Agentic Systems（ADAS；Hu et al. 2025）把 agent 设计本身表述为一个优化问题，即“meta-agent search”，其中 meta-agent 会提出新的 agentic workflow 设计。\n用 CoT 和 self-refine 等简单 agent 初始化一个 agentic workflow archive。 要求 meta-agent 参考 archive 中既有解，用代码编写新 agent。 Meta-agent 先生成新 workflow 的高层描述，再用代码实现它。 Draft program 随后由 meta-agent 经过两轮 self-refine，也就是要求模型提供反馈，再要求同一个模型根据反馈改进之前生成的输出（Madaan et al. 2023），用于检查新颖性。 评估每个新候选，并把成功候选加入 archive。 重复第 2-5 步，直到达到最大迭代次数。 Automated Design of Agentic Systems (ADAS) 示意图。Image source: Hu et al. 2025.\nAFlow（Zhang et al. 2025）把 agentic workflow 表示为图，其中节点表示调用 LLM 的动作，边用代码实现逻辑操作。工作流优化依赖 MCTS（Monte Carlo Tree Search）：\n用模板在树中初始化起始 workflow $W_0$。 用分数和均匀探索的 soft mixture 选择一个 workflow node。 要求 LLM 基于评估表现生成一个修改后的 workflow，从而扩展该节点。 执行并评估新 workflow。 如果新 workflow 在 $N$ 轮预算内显示出改进，就把它加入树。 重复第 2-5 步，当 top-$k$ 平均分进入平台期或预算耗尽时停止。 AFlow 在 workflow 候选树上的优化过程。Image source: Zhang et al. 2025.\nAFlow 在 QA、代码和数学任务中的实验表明，相比手工设计工作流和 ADAS，它有不错的改进。\nAFlow 与手工方法和 ADAS 的实验对比。Image source: Zhang et al. 2025.\n自我改进 Harness 上下文工程或工作流设计都只是 harness 的一部分。我们需要在完整设计空间中搜索，并共同优化上下文管理逻辑、工作流、权限和其他许多 harness 组件。正如 Meta-Harness、ADAS 和 AFlow 这类工作所展示的，代码是定义程序和系统的通用语言。简单说，harness 就是用代码编程，决定 prompts、tool calls、subagents、control flow、memory 和 workflow logic 如何协同工作。如果 LLM 能优化执行 agent 的代码，它能访问的设计空间会比手写 prompts 大得多。\nSelf-Taught Optimizer（STOP；Zelikman et al. 2023）是递归式 scaffolding improvement 的早期例子之一。一个 seed improver $I_0$ 在第 $t=0$ 步接受初始解 $s$、效用函数 $u$ 和黑盒语言模型 $M$，并返回一个改进后的解 $s'$，即 $s' = I(u, s; M)$。STOP 的目标不是直接改进 $s$，而是改进 improver $I$ 本身。\n先定义 meta-utility：给定 improver function $I$ 在一组下游任务 $\\mathcal{D}$ 上的平均效用：\n$$ \\hat{u}(I) \\triangleq \\frac{1}{\\vert\\mathcal{D}\\vert}\\mathbb{E}_{(u,s)\\sim \\mathcal{D}}[u(I(u,s; M))] $$由于改进 improver function 本身也是一个优化问题，我们可以基于 $I_{t-1}$ 通过 meta-utility 衡量的表现，递归地产生新版本 $I_t$：\n$$ I_t=I_{t-1}(\\hat{u},I_{t-1};M) $$ Self-Taught Optimizer (STOP) 算法。Image source: Zelikman et al. 2023.\n在实验中，改进后的 improver 发现了多种策略，例如遗传算法、分解并改进局部、多臂 prompt bandits、模拟退火、改变 temperature，以及 beam/tree search。这类似于把 harness workflow 表示成可优化对象。\nSTOP 发现的自我改进策略示例。Image source: Zelikman et al. 2023.\nZelikman et al. (2023) 的一个警示性结果是：STOP 在 GPT-4 上能随着迭代提升下游平均表现，但在 GPT-3.5 和 Mixtral 等较弱模型上会退化。递归结构本身还不够。基础模型必须足够有能力，才能改进机制。这意味着 harness 改进能够让模型部署得更好，但智能仍然是核心。\nLin et al. (2026) 更详细地研究了 harness evolution 对模型能力的依赖。他们拆分出两个轴：（1）harness-updating 指产生有用 harness edits 的能力；（2）harness-benefit 指利用已更新 harness 来更好解决任务的能力。有趣的是，在他们的实验中，从 Qwen3.5-9B 到 Claude Opus 4.6，不同大小和核心智能的模型表现出相似的 harness updating capability；9B harness proposer/evolver 能写出与 Opus 在过程上同构的 skill。为了最好地利用 harness，模型需要正确且及时地调用 skills/tools，并擅长长周期指令遵循。\n主要结果：(A) 从 Qwen2-32B 到 Opus 4.6，不同模型的 harness updating capability 大致持平；(B) harness benefit capability 非单调，中等层级模型受益最多。Image source: Lin et al. 2026.\n更新的工作 Self-Harness（Zhang et al. 2026）依赖 LLM agents 通过 propose-evaluate-accept loop 改进自己的 harness。\nSelf-Harness 通过 weakness mining、bounded harness proposal 和 validation 组成的循环来更新 harness。Image source: Zhang et al. 2026.\nSelf-Harness 的循环有三个阶段：\nWeakness mining：把失败聚类成 verifier-grounded failure patterns。 当前 harness $h_t$ 用于在任务上评估，并收集 execution traces 用于分析。 注意，两个运行在表层错误日志中可能拥有同样的 verifier outcome，例如 timeout 或 missing artifact，但背后的因果机制不同。因此我们需要信息丰富的 failure record，其中包含终端 verifier-level cause、相关 agent 行为的因果状态，以及 trace 暴露出的抽象 agent 机制，用于揭示根因。 Harness proposal：基于挖掘出的 failure patterns 提出有界 harness edits。 同一个模型在 $h_t$ 下作为 proposer 被调用。 模型会得到一个有界 proposal context：（1）当前 harness 的可编辑表面；（2）来自评估系统的 verifier-grounded failure patterns；（3）应被保留的 passing behaviors 记录；（4）此前尝试过的 edits 摘要。 Harness edits 应该优先选择反复出现、可处理（例如不是任务特定难度）且能通过窄改动解决的错误模式。 Harness edit candidates 应该彼此不同且多样。 Proposal validation：验证并合并合格 edits，创建新 harness $h_{t+1}$。 Candidate edits 会在 held-in $D_\\text{in}$（用于测试 weakness 是否被解决）和 held-out $D_\\text{out}$（用于检查是否引入其他未知问题）split 上通过 regression tests 评估。 只有在 held-in 和 held-out 数据上都没有 regression 的 candidate 才会被接受。 被接受的 candidate 会合并并更新 harness 到 $h_{t+1}$；被拒绝的 candidate 会被记录，但不改变 active harness。 在 Terminal-Bench-2 上运行 MiniMax M2.5、Qwen3.5-35B-A3B 和 GLM-5 时，Self-Harness 被证明可以学习模型特定的 harness instructions，针对不同基础模型的不同弱点，并提升 held-out pass rate。\nSelf-harness 这类工作也让我担心：如果一个程序被允许编辑 OS system，抽象边界就会被打破。可编辑表面需要被妥善设计，权限控制和安全层需要位于这个循环之外。围绕 reward hacking 的所有挑战仍然存在。\nAgentic Harness Engineering（AHE；Lin et al. 2026）认为 harness evolution 的瓶颈在于可观测性：当 rollout 失败时，我们需要知道哪个组件负责，并且每个 edit 都应该有证据支撑。\n该框架用 3 个可观测性支柱构建闭环：\n组件可观测性：每个可编辑 harness component 都在文件系统中有表示，因此 action space 是显式且可追踪的。 一个 harness 包含 7 个组件：system prompt、tool description、tool implementation、middleware、skill、sub-agent configuration 和 long-term memory。 每个 failure pattern 都映射到一个组件，使 edit 更有针对性。 经验可观测性：把大量原始轨迹分析并总结成证据和 failure patterns 的层级结构。 每个 harness 生成 $k$ 条 traces。 使用一个 agent（“Agent debugger”）分析每条存储在文件中的 trajectory，并生成 per-task analysis report，说明失败或成功的根因。 所有 per-task reports 会聚合成下一步使用的 benchmark overview；如有需要，仍可访问 raw traces。这种分层访问结构更节省 token。 决策可观测性：每个 edit 都配有对下一轮的预测，以便验证。 一个 agent（“Evolve agent”）读取 repo，决定编辑哪个组件，然后产出 edit 及其背后的推理。 每个 edit 都是文件级、可证伪的 claim，并可以在下一轮验证，受两个约束限制： Edits 只能应用到 harness workspace。runs directory、tracer、verifier 和 LLM configuration 是只读的，这会禁用一组 reward hacking 手段，例如禁用 verifier、替换模型或提高 reasoning budget，因此能让每个记录到的收益都归因于 harness edits。 Edits 是 evidence-driven，并带有 manifesto entry：failure evidence 名称、推断出的根因、目标修复，以及包含 expected fixes 和 at-risk regressions 的 predicted impact。 在 Terminal-Bench-2 上，AHE 的表现优于人类设计的 harness（OpenCode、Terminus-2、Codex），Hard tier 除外；同时也优于一些 self-evolve baselines（ACE、TF-GRPO）。同一个 frozen harness 在不继续演化的情况下迁移到 SWE-bench-verified，说明演化后的 harness 能把工程经验编码进 harness components，而不是只做 benchmark-specific optimization。\n演化式搜索 演化式搜索是一种受自然选择启发的优化方法（见我之前关于演化算法的文章）。它通过 mutation 演化一个解的种群，并只保留群体中“fitness”高的解。当（1）搜索空间很大或形状奇怪；（2）很难直接用梯度优化但容易评估候选解时，演化式搜索很有用。Harness search 看起来很适合这一点。\n过去的研究已经把演化式搜索用于 prompt engineering。Promptbreeder（Fernando et al. 2023）通过丰富的 mutation operations 优化 task-specific prompts；有趣的是，mutation prompts，也就是要求 LLM 改写 task prompt 的指令，本身也会通过演化得到改进。GEPA（Agrawal et al. 2025）把基于 reflection 的 prompting 与演化式搜索结合起来，并使用对 trial-and-error 轨迹的自然语言反思来提出 prompt updates。\nNovikov et al. (2025) 提出了 AlphaEvolve，一个 coding-agent evolutionary search system。它保存一组候选程序，并 prompting frozen LLM 生成用于改进的 diffs。随着系统反复评估 child programs 并保留成功候选，它会逐步发现更好的解。\nAlphaEvolve 如何工作。Image source: Novikov et al. 2025.\nAlphaEvolve 设计中有几个细节很重要：\nPrompt 包含 parent programs、results、instructions，有时还有 meta information。 Coding agent 可以访问完整 repo，但允许改进的代码区域会用 # EVOLVE-BLOCK-START 和 # EVOLVE-BLOCK-END 明确标出。 Meta-prompt 会根据 LLM 建议与 instructions 和 context 共同演化，方式类似 solution programs 的演化。 Ablation 表明，evolution procedure、prompt 中的 context、meta-prompts、full-file evolution 以及使用更强 LLM 都有价值。\nAblation 展示 AlphaEvolve 中多个设计的价值。Image source: Novikov et al. 2025.\n近期变体包括 ThetaEvolve（Wang et al. 2025），它把演化式搜索与 RL 和 in-context learning 结合起来；DemoEvolve（Che, et al. 2026）用人类专家示范作为 harness-level diagnosis 和 editing 的参考经验，增强 self-rollout archive。另一方面，ShinkaEvolve（Lange et al. 2025）引入三个新组件来提升 LLM sampling efficiency：\n通过设计 parent sampling，在 performance rank 和 offspring count 之间平衡，从而实现更高 sample efficiency 的探索。 Code-novelty rejection sampling：基于 embedding cosine similarity 丢弃与现有种群过于相似的 candidates。 在 meta-scratchpad 中识别成功解里的好模式，用于指导未来 mutation。 不同于上述专注于 solution improvement 的方法，Darwin Gödel Machine（DGM；Zhang et al. 2025）明确以可编辑 harness-code repository 的演化为目标，并使用 LLM-based coding agent。准确地说，这个 agent 被允许修改自己的 harness。后续 Hyperagents 工作（Zhang et al. 2026）引入了 meta-agent，用于控制如何修改既有 task agents 来创建新 agent。\n从池中的一个 coding agent 开始。 每次迭代中，以与性能成正比、与已有子代数量成反比的概率选择一个 parent，修改并分支生成新 agent。 被选中的 parent agent 检查自身 benchmark evaluation log，然后提出对自身 harness codebase 的改进，生成新版 coding agent。代码编辑通过两个基础工具实现：（1）bash（args: \u0026lt;bash_command\u0026gt;）；（2）editor（args: view/create/edit \u0026lt;file_path\u0026gt;）。 新 coding agents 被评估，只有性能足够高的才会被加入池中。 重复第 2-4 步，直到达到某个停止条件。 DGM 是固定模型下的 harness evolution。在以 Claude 3.5 Sonnet 作为基础 LLM、使用简单初始 harness configs 的实验中，DGM 发现的 agents 在 SWE-bench Verified（20% 到 50%）和 Polyglot（14.2% 到 30.7%）上能达到或超过手工构造 agents。\n这类方法在候选解可自动评估、候选 fitness 容易量化时表现很好，例如矩阵乘法、GPU kernel 优化、算法竞赛、数据中心调度。它在评估缓慢、模糊或主要依赖启发式指标的领域会更吃力。演化的计算效率和有效性也是值得关注的问题。\n与模型权重联合优化 Harness evolution 改变的是模型周围的非参数系统。为了实现完整自我改进，模型当然也可以被允许同时更新自己的权重。权重更新可以通过改进模型训练流水线，或通过测试时持续学习来实现。持续学习这个主题值得未来单独写一篇文章。\nSIA（Hebbar et al. 2026）是把 harness improvement 和 model-parameter updates 组合进同一个优化循环的早期尝试，其设计包含三个组件：\nMeta-Agent：提出初始 harness。 Task-Specific Agent：执行任务。 Feedback-Agent：根据近期轨迹选择更新 harness 还是模型权重。 SIA 中的 Feedback-Agent 决定下一次迭代类型。Image source: Hebbar et al. 2026.\nSIA 实验中有一些混杂选择，让结果难以解释。例如，task-specific agent 明显弱于 Meta-Agent 和 Feedback-Agent 使用的模型（gpt-oss-120b vs Claude Sonnet 4.6），baseline 也太弱，难以与相关方法清晰交叉参照。我认为这个方向很有趣，但证据仍是初步的。训练稳定性和 Goodhart effect 等许多挑战仍然开放。\nContinual Harness（Karten et al. 2026）在长周期 gameplay setting 中实验了 harness updating，并通过蒸馏强 teacher model 在 low-reward trajectories 上的 labels 来共同学习 policy model。\n未来挑战 AI Scientist 这一系列工作强有力地展示了：专家设计的 harness 能协调自动研究循环中的很大一部分，并以写研究论文的形式做了实验。但论文生产不等于科学发现。一个系统可以写出看似可信的 manuscript，同时仍然存在 fabricated citations、implementation drift 或弱实验结果。\nTrehan \u0026amp; Chopra (2026) 测试了 LLM 是否能在 minimal scaffolding 和基础工具（即 read_file、write_file、llm_search、list_files）下，从研究想法走到论文。每个想法都有独立 workspace，agents 可以生成和读取文档作为上下文的一部分。他们在三个领域做实验：world models、multi-agent RL、AI safety \u0026amp; alignment；每个领域包含 45-50 份高质量 seed documents 用于激发新想法。只有四个想法被人类专家选中进入完整 pipeline，只有一个被完整执行成论文。他们在实验中观察到六类反复出现的失败模式：\n偏向训练数据默认值：使用旧库、过期命令、标准格式，或没有基于实际 repo/dataset 的假设。 执行压力下的实现漂移：当实现变得技术上复杂时，模型可能转向常见的更简单方案，而不是原先提出的方法。 记忆和上下文退化：长周期项目会丢失关键细节，除非日志被写成持久 artifacts。 过度乐观：即使实验有噪声或失败，模型也会宣称成功。类似模式也被 Bubeck et al. (2025) 观察为“p-hacking and eureka-ing”：模型会加入“numerical duct tape”，并在信号仍是噪声时宣布胜利。 领域智能不足：模型缺少 tacit craft knowledge，例如预测实现复杂度、判断实验结果是否可信，或知道哪些 baselines 重要。 科学品味薄弱：实验可能可执行，但没有回答正确的问题。 面向完整 RSI，研究者已经取得真实进展，但仍有几个瓶颈。\n1. 弱且模糊的评估器。 许多研究 claim 没有快速精确的 verifier，很多真实世界任务也一样。当前自我改进循环最适合评估指标可测量且客观的任务，这和 RL 的工作方式类似。\nResearch taste、新颖性和长期科学价值更难衡量。例如，research taste 往往混合了问题 framing、实验设计，以及判断哪些惊讶结果值得追踪、哪些失败案例值得重试的能力。\n2. 上下文和记忆生命周期。 随着 AI agents 变得更自主、更独立，记忆会不断增长。一个有用的 harness 需要管理上下文和记忆，以弥补长上下文生成现有局限，同时最大化长周期任务成功率。由于人类能在一生中维持记忆，我看到一个类比：上下文工程应该并且将会成为智能的核心部分，而不是停留在软件系统层。\n3. 负面结果。 研究者有动机发表成功结果，因此文献会偏向成功。LLM 训练在海量数据上，而这些数据至少目前大多由人类创造，可能因为数据中成功与失败案例的不平衡，而不擅长决定何时放弃假设、报告负面结果，甚至承认失败。Research harness 应该让失败尝试易于保存，因为从失败中学习是缩小任务搜索空间的最佳方式。\n4. 多样性坍缩。 演化循环和 RL 循环倾向于利用已知高奖励模式。我们需要机制防止种群坍缩成同一种解的变体。这对开放式研究尤其关键，因为最佳路径在当前评估器下最初可能看起来更差。\n5. Reward hacking。 自我改进循环会优化它得到的任何信号。如果奖励来自 unit tests，agent 可能 overfit 到测试；如果来自 judge model，它可能学会针对该 judge 的 reward hacking 技巧；如果来自 benchmark scores，它可能利用 benchmark artifacts。\n评估器和权限控制很可能应该位于演化 harness 的循环之外，并配合 held-out tests、trace audits，以及在关键决策点引入 human review。监督能在多大程度上扩展并自动化，仍是开放研究问题。\n6. 长期成功。 外在优化循环依赖单个 rollout 之外的奖励，而这些奖励可以在训练 sandbox 中模拟。\n以 coding agent 为例。Coding agents 已经提升了软件工程中的日常生产力，但许多优化目标仍然太短期。它通常能完成手头任务，但它应该如何保护一个由成百上千名工程师共同维护的 repo 的长期健康，就没那么清楚了。标准的 sandbox-based RLVR-style training 很少捕捉可维护性、ownership boundaries、migration cost、backwards compatibility 或未来 debugging burden。\n7. 人类的角色。 人类应该上移到更高层，而不是被移出循环；也就是说，人类应该在正确时间、正确抽象层级提供监督，系统设计也应该考虑何时以及如何设置这些 touch points。\n上面列出的许多挑战都需要人类反馈和引导。毕竟，我们是在为人类更好的未来构建技术，而不是反过来。\n引用 请按如下格式引用本文：\nWeng, Lilian. “Harness Engineering for Self-Improvement”. Lil’Log (Jul 2026). https://lilianweng.github.io/posts/2026-07-04-harness/\n或使用 BibTeX：\n@article{weng2026harness, title = {Harness Engineering for Self-Improvement}, author = {Weng, Lilian}, journal = {lilianweng.github.io}, year = {2026}, month = {July}, url = \u0026#34;https://lilianweng.github.io/posts/2026-07-04-harness/\u0026#34; } 附录：一些有用的基准 PaperBench：从零复现 20 篇 ICML 2024 Spotlight 和 Oral 论文，包括理解论文贡献、开发代码库并成功执行实验。 每个复现任务被拆分成更小、可单独评分的任务。 总共有 8,316 条 rubrics，由论文作者共同开发。 当时最好的模型（Claude 3.5 Sonnet，约 21%）没有超过 ML PhDs。 包含 PaperBench、PaperBench Code-Dev（较轻量版本）和 JudgeEval。 CORE-Bench：评估已发表研究的计算可复现性。 包含跨计算机科学、社会科学和医学的 90 篇科学论文，共 270 个任务。 任务涉及用提供的代码和数据复现结果。 包含多个难度级别，以及 language-only 和 vision-language 任务。 当时报告的最佳 agent（GPT-4o 和 GPT-4o-mini）在最难任务上仅达到 21% 准确率。 ScienceAgentBench：评估 LLM agents 进行数据驱动科学发现的能力。 从数学、化学、生物、地理四个学科的 44 篇同行评审出版物中抽取 102 个任务。 覆盖这些领域中的基础数据科学任务：数据处理、模型开发、数据分析和信息可视化。 RE-Bench：在真实 ML research-engineering 环境中，将前沿 AI agents 与人类专家对比评估。 7 个具有挑战性、开放式的 ML research-engineering 环境。 每个环境 = scoring function、starting solution、reference solution；每个环境可用 8 张或更少 H100 GPU 运行。 示例包括优化 kernel、运行 scaling-law experiment、修复 embedding、微调 GPT-2 做 QA 等。 包含来自 61 位不同人类专家的 71 次 8 小时尝试数据。 人类专家在 82% 的 8 小时尝试中获得非零分；24% 达到或超过强 reference solutions。 最佳 AI agents 在 2 小时预算下得分是人类的 4 倍，但人类在更长预算下有更好的边际收益，并在 8 小时和 32 小时设置下超过 agents。 MLE-bench：在离线 Kaggle competitions 上评估 ML engineering agents。 包含 75 个从 Kaggle 筛选出的 ML-engineering competitions。 测试训练模型、准备数据集、运行实验并向评分脚本提交预测。 使用 Kaggle public leaderboards 作为人类 baseline。 论文中最佳设置 o1-preview + AIDE scaffolding，在 16.9% 的 competitions 中至少达到 Kaggle bronze-medal level。 包含 resource-scaling 和 contamination 分析。 KernelBench：评估生成 GPU kernels 的正确性和速度。 包含 250 个 PyTorch 任务，用于评估 LLM 是否能写出快速且正确的 kernels。 评估指标 fast_p = 生成 kernel 中正确且快于 baseline 的比例。 参考文献 [1] Good, I. J. “Speculations Concerning the First Ultraintelligent Machine.” Advances in Computers, 6:31-88, 1965.\n[2] Yudkowsky, Eliezer. “Recursive Self-Improvement.” LessWrong, 2008.\n[3] Choi, et al. “Anchored Self-Play for Code Repair.” ICML 2026.\n[4] Zhao, et al. “Absolute Zero: Reinforced Self-play Reasoning with Zero Data.” arXiv preprint arXiv:2505.03335, 2025.\n[5] Yuan, et al. “Self-Rewarding Language Models.” arXiv preprint arXiv:2401.10020, 2024.\n[6] Chen, et al. “Self-Play Fine-Tuning Converts Weak Language Models to Strong Language Models.” ICML 2024.\n[7] Zhang, et al. “Agentic Context Engineering: Evolving Contexts for Self-Improving Language Models.” ICLR 2026.\n[8] Ye, et al. “Meta Context Engineering via Agentic Skill Evolution.” arXiv preprint arXiv:2601.21557, 2026.\n[9] Lee, et al. “Meta-Harness: End-to-End Optimization of Model Harnesses.” arXiv preprint arXiv:2603.28052, 2026.\n[10] Lu, et al. “Towards end-to-end automation of AI research.” Nature, 651:914-919, 2026.\n[11] Meng, et al. “ScientistOne: Towards Human-Level Autonomous Research via Chain-of-Evidence.” arXiv preprint arXiv:2605.26340, 2026.\n[12] Kulikov, et al. “Autodata: An agentic data scientist to create high quality synthetic data.” arXiv preprint arXiv:2606.25996, 2026.\n[13] Hu, Lu, and Clune. “Automated Design of Agentic Systems.” ICLR 2025.\n[14] Madaan, et al. “Self-Refine: Iterative Refinement with Self-Feedback.” NeurIPS 2023.\n[15] Zhang, et al. “AFlow: Automating Agentic Workflow Generation.” ICLR 2025.\n[16] Zelikman, et al. “Self-Taught Optimizer (STOP): Recursively Self-Improving Code Generation.” COLM 2024.\n[17] Zhang, et al. “Self-Harness: Harnesses That Improve Themselves.” arXiv preprint arXiv:2606.09498, 2026.\n[18] Fernando, et al. “Promptbreeder: Self-Referential Self-Improvement Via Prompt Evolution.” arXiv preprint arXiv:2309.16797, 2023.\n[19] Agrawal, A. et al. “GEPA: Reflective Prompt Evolution Can Outperform Reinforcement Learning.” arXiv preprint arXiv:2507.19457, 2025.\n[20] Novikov, et al. “AlphaEvolve: A coding agent for scientific and algorithmic discovery.” arXiv preprint arXiv:2506.13131, 2025.\n[21] Lange, Imajuku, and Cetin. “ShinkaEvolve: Towards Open-Ended And Sample-Efficient Program Evolution.” arXiv preprint arXiv:2509.19349, 2025.\n[22] Wang, et al. “ThetaEvolve: Test-time Learning on Open Problems.” arXiv preprint arXiv:2511.23473, 2025.\n[23] Zhang, et al. “Darwin Gödel Machine: Open-Ended Evolution of Self-Improving Agents.” arXiv preprint arXiv:2505.22954, 2025.\n[24] Zhang, et al. “Hyperagents.” arXiv preprint arXiv:2603.19461, 2026.\n[25] Yuksekgonul, et al. “Learning to Discover at Test Time.” arXiv preprint arXiv:2601.16175, 2026.\n[26] Riaz, et al. “Epistemic Uncertainty for Test-Time Discovery.” arXiv preprint arXiv:2605.11328, 2026.\n[27] Hebbar, et al. “SIA: Self Improving AI with Harness \u0026amp; Weight Updates.” arXiv preprint arXiv:2605.27276, 2026.\n[28] Trehan and Chopra. “Why LLMs Aren’t Scientists Yet: Lessons from Four Autonomous Research Attempts.” arXiv preprint arXiv:2601.03315, 2026.\n[29] Bubeck, et al. “Early science acceleration experiments with GPT-5.” arXiv preprint arXiv:2511.16072, 2025.\n[30] Starace, et al. “PaperBench: Evaluating AI’s Ability to Replicate AI Research.” ICML 2025.\n[31] Wijk, et al. “RE-Bench: Evaluating frontier AI R\u0026amp;D capabilities of language model agents against human experts.” ICML 2025.\n[32] Chan, et al. “MLE-bench: Evaluating Machine Learning Agents on Machine Learning Engineering.” arXiv preprint arXiv:2410.07095, 2024.\n[33] Chen, et al. “ScienceAgentBench: Toward Rigorous Assessment of Language Agents for Data-Driven Scientific Discovery.” ICLR 2025.\n[34] Siegel, et al. “CORE-Bench: Fostering the Credibility of Published Research Through a Computational Reproducibility Agent Benchmark.” TMLR 2024.\n[35] Ouyang, et al. “KernelBench: Can LLMs Write Efficient GPU Kernels?” arXiv preprint arXiv:2502.10517, 2025.\n[36] Lin, et al. “Harness Updating Is Not Harness Benefit: Disentangling Evolution Capabilities in Self-Evolving LLM Agents.” arXiv preprint arXiv:2605.30621, 2026.\n[37] Lin, et al. “Agentic Harness Engineering: Observability-Driven Automatic Evolution of Coding-Agent Harnesses.” arXiv preprint arXiv:2604.25850, 2026.\n[38] Karten, et al. “Continual Harness: Online Adaptation for Self-Improving Foundation Agents.” arXiv preprint arXiv:2605.09998, 2026.\n[39] Che, et al. “DemoEvolve: Overcoming Sparse Feedback in Agentic Harness Evolution with Demonstrations.” arXiv preprint arXiv:2605.24539, 2026.\n","permalink":"https://dengkoicat.github.io/posts/2026-08-04-harness/","summary":"\u003cblockquote\u003e\n\u003cp\u003e原文：\u003ca href=\"https://lilianweng.github.io/posts/2026-07-04-harness/\"\u003eHarness Engineering for Self-Improvement\u003c/a\u003e，作者 Lilian Weng。\u003c/p\u003e\u003c/blockquote\u003e\n\u003cp\u003e\u003cstrong\u003e递归式自我改进（recursive self-improvement, RSI）\u003c/strong\u003e 的概念可以追溯到 \u003ca href=\"https://philpapers.org/rec/GOOSCT\"\u003eI. J. Good (1965)\u003c/a\u003e。他把“超智能机器”定义为一个能够在所有智力活动上超越人类，并能设计出更好机器来改进自身的系统。\u003ca href=\"https://www.lesswrong.com/posts/JBadX7rwdcRFzGuju/recursive-self-improvement\"\u003eYudkowsky (2008)\u003c/a\u003e 用“递归式自我改进”这个说法描述一个特定反馈环：AI 使用当前智能来改进产生其智能的认知机器。\u003c/p\u003e","title":"Harness Engineering for Self-Improvement"},{"content":"Why not ReAct? 传统 RAG 通常只在生成前检索一次，检索内容主要由原始问题决定。因此，在多跳问答或复杂推理任务中，它很容易遇到信息不足、检索偏差或上下文不完整的问题。\nReAct、IRCoT 等方法在此基础上加入了“推理—检索—再推理”的过程，使模型能够根据中间结果继续搜索。不过，这类方法仍然较依赖提示词设计和模型原有的工具调用能力。当问题较复杂、检索结果存在噪声，或中间推理方向出现偏差时，模型可能会重复搜索、生成无效查询，甚至在证据不足时提前作答。\nAgentic RL 的不同之处在于，它不只是规定模型“应该如何搜索”，而是直接对完整的推理与检索过程进行训练。模型在与搜索引擎交互的过程中，根据最终答案获得奖励，并逐渐学习何时搜索、如何拆解问题、怎样调整查询，以及什么时候停止。这种方式比手工设计固定流程更灵活，也减少了对人工编写推理轨迹的依赖。\n当然，强化学习并不能完全解决检索质量和错误信息带来的问题，其效果仍会受到奖励设计、训练稳定性和搜索环境质量的影响。但它为 Agentic RAG 提供了一条更自然的改进路径：提示词负责规定基本交互形式，强化学习则帮助模型从实际反馈中学会更有效的搜索与推理策略。\nSearch-R1 在 Agentic RL 范式下，Search-R1 进一步探索了强化学习驱动的搜索增强推理能力。它将搜索引擎建模为强化学习环境的一部分，使 LLM 的内部推理过程与外部知识检索过程交织进行。这样，模型不再依赖人工设计的固定搜索流程，而是通过 PPO、GRPO 等强化学习算法，优化“思考—行动—反馈”的决策策略。\n为实现结构化的多轮交互，Search-R1 引入了特殊标记机制：\n\u0026lt;think\u0026gt; 用于表示模型的内部推理过程，使模型能够逐步分析当前问题； \u0026lt;search\u0026gt; 用于触发搜索工具调用，使模型自主决定何时、如何获取外部信息； \u0026lt;information\u0026gt; 用于承载搜索引擎返回的检索内容，使外部知识能够融入后续推理； \u0026lt;answer\u0026gt; 用于标记最终输出结果，表示模型完成推理与检索后的答案生成阶段。 基于这种机制，模型可以形成“推理 → 检索 → 观察 → 再推理 → 回答”的循环决策模式。Search-R1 采用基于最终结果的奖励函数，仅根据答案正确性优化模型行为，避免了复杂过程奖励设计带来的额外成本。因此，它可以被视为 DeepSeek-R1 Zero 在工具增强场景下的扩展：强化学习不只用于提升模型内部推理能力，也进一步用于优化模型的搜索调用、信息利用和任务完成策略。\nFig. 1. SEARCH-R1 框架下 PPO 与 GRPO 训练过程演示。在模型推演（rollout）阶段，大语言模型（LLMs）能够与搜索引擎进行多轮交互。 (Image source: Search-R1, 2025)\nMulti-turn Search Rollout 在生成阶段，Search-R1 并不是一次性完成回答，而是采用多轮搜索调用的 rollout 过程。模型会在生成文本的同时，根据当前推理状态按需调用外部搜索引擎，可以理解为：\n$$y\\sim\\pi(\\cdot\\mid x;\\boldsymbol{\\theta},\\mathcal{R})=\\pi(\\cdot\\mid x;\\boldsymbol{\\theta})\\otimes\\mathcal{R}$$具体来说，当模型认为需要外部知识时，会生成 \u0026lt;search\u0026gt; query \u0026lt;/search\u0026gt;。系统检测到该标记后，会抽取 query，调用搜索引擎 $\\mathcal{R}$，并将返回结果封装为 \u0026lt;information\u0026gt; retrieved content \u0026lt;/information\u0026gt; 追加到当前上下文中。随后，模型继续基于这些外部信息进行下一步 \u0026lt;think\u0026gt; 推理。\n这一过程会持续进行，直到满足两个停止条件之一：一是达到最大搜索动作预算 $B$；二是模型生成 \u0026lt;answer\u0026gt; final answer \u0026lt;/answer\u0026gt;。因此，Search-R1 的生成轨迹可以看作一个动态循环：先思考，必要时搜索，再将检索结果纳入上下文继续推理，最后给出答案。\n训练时，Search-R1 使用了一个简洁的 template 来约束输出格式。这个模板只规定模型应使用 \u0026lt;think\u0026gt;、\u0026lt;search\u0026gt;、\u0026lt;information\u0026gt; 和 \u0026lt;answer\u0026gt; 组织推理、检索和回答，并不强行规定具体推理方式或搜索策略。因此，强化学习可以更自然地观察和优化模型自身形成的搜索行为。Train Template：\nAnswer the given question. You must conduct reasoning inside \u0026lt;think\u0026gt; and \u0026lt;/think\u0026gt; first every time you get new information. After reasoning, if you find you lack some knowledge, you can call a search engine by \u0026lt;search\u0026gt; query \u0026lt;/search\u0026gt;, and it will return the top searched results between \u0026lt;information\u0026gt; and \u0026lt;/information\u0026gt;. You can search as many times as you want. If you find no further external knowledge needed, you can directly provide the answer inside \u0026lt;answer\u0026gt; and \u0026lt;/answer\u0026gt; without detailed illustrations. For example, \u0026lt;answer\u0026gt; xxx \u0026lt;/answer\u0026gt;. Question: question.\nObjective Function Search-R1 的核心目标函数为：\n$$\\max_{\\boldsymbol{\\theta}}\\ \\mathbb{E}_{x\\sim\\mathcal{D},\\,y\\sim\\pi(\\cdot\\mid x;\\boldsymbol{\\theta},\\mathcal{R})}\\left[r_\\phi(x,y)\\right]-\\lambda_{\\mathrm{KL}} D_{\\mathrm{KL}}\\left[\\pi(\\cdot\\mid x;\\boldsymbol{\\theta},\\mathcal{R})\\parallel\\pi_{\\mathrm{ref}}(\\cdot\\mid x;\\mathcal{R})\\right]$$其中，策略模型 $\\pi(\\cdot;\\boldsymbol{\\theta})$ 在搜索引擎 $\\mathcal{R}$ 的辅助下生成包含“推理—检索”的轨迹，并通过奖励函数 $r_\\phi$ 提高任务表现；$\\lambda_{\\mathrm{KL}}$ 是 KL 正则项系数，用于限制策略模型不要过度偏离参考模型 $\\pi_{\\mathrm{ref}}$。与传统仅依赖模型自身生成的 RL 方法相比，Search-R1 显式引入外部检索信息，并基于 PPO 或 GRPO 优化检索增强推理能力。\n在训练细节上，Search-R1 对检索返回的 token 使用 loss masking。一次 rollout 中既包含模型生成的 token，也包含搜索引擎返回的检索 token。如果对整段序列都计算损失，模型可能会错误地学习外部文本本身，而不是学习如何搜索和利用信息。因此，Search-R1 只对 LLM 生成的 token 计算策略梯度，并将检索 token 从优化目标中屏蔽掉。\n为了和前一篇强化学习博客的符号保持一致，记第 $t$ 步状态为 $s_t=(x,y_{\\lt t};\\mathcal{R})$，当前位置 token 记为动作 $a_t=y_t$。令 $I(a_t)$ 表示 token-level mask：当 $a_t$ 是模型生成 token 时，$I(a_t)=1$；当 $a_t$ 是检索返回 token 时，$I(a_t)=0$。\n在 PPO 设置下，Search-R1 的核心目标可以写为：\n$$\\mathcal{J}_{\\mathrm{PPO}}(\\boldsymbol{\\theta})=\\mathbb{E}\\left[\\frac{1}{\\sum_{t=1}^{|y|}I(a_t)}\\sum_{t=1}^{|y|}I(a_t)\\left(\\min\\left(r_t(\\boldsymbol{\\theta})\\hat{A}_t,\\mathrm{clip}(r_t(\\boldsymbol{\\theta}),1-\\epsilon,1+\\epsilon)\\hat{A}_t\\right)-\\lambda_{\\mathrm{KL}} D_{\\mathrm{KL}}\\left[\\pi(\\cdot\\mid s_t;\\boldsymbol{\\theta})\\parallel\\pi_{\\mathrm{ref}}(\\cdot\\mid s_t)\\right]\\right)\\right]$$其中：\n$$r_t(\\boldsymbol{\\theta})=\\frac{\\pi(a_t\\mid s_t;\\boldsymbol{\\theta})}{\\pi(a_t\\mid s_t;\\boldsymbol{\\theta}_{\\mathrm{old}})}$$这里 $\\hat{A}_t$ 通常由 GAE 结合未来奖励和价值网络 $v(s_t;\\mathbf{w})$ 估计得到。PPO 通过 clip 机制限制策略更新幅度，而 mask 项 $I(a_t)$ 确保只有模型生成内容参与优化。\nGRPO 则不依赖额外的 value model，而是对同一问题采样一组回答，并用组内相对奖励估计优势。其核心目标可写为：\n$$\\mathcal{J}_{\\mathrm{GRPO}}(\\boldsymbol{\\theta})=\\mathbb{E}\\left[\\frac{1}{G}\\sum_{i=1}^{G}\\frac{1}{\\sum_{t=1}^{|y_i|}I(a_{i,t})}\\sum_{t=1}^{|y_i|}I(a_{i,t})\\left(\\min\\left(r_{i,t}(\\boldsymbol{\\theta})\\hat{A}_{i,t},\\mathrm{clip}(r_{i,t}(\\boldsymbol{\\theta}),1-\\epsilon,1+\\epsilon)\\hat{A}_{i,t}\\right)-\\lambda_{\\mathrm{KL}} D_{\\mathrm{KL}}\\left[\\pi(\\cdot\\mid s_{i,t};\\boldsymbol{\\theta})\\parallel\\pi_{\\mathrm{ref}}(\\cdot\\mid s_{i,t})\\right]\\right)\\right]$$其中：\n$$ r_{i,t}(\\boldsymbol{\\theta})=\\frac{\\pi(a_{i,t}\\mid s_{i,t};\\boldsymbol{\\theta})}{\\pi(a_{i,t}\\mid s_{i,t};\\boldsymbol{\\theta}_{\\mathrm{old}})} $$其中 $s_{i,t}=(x,y_{i,\\lt t};\\mathcal{R})$，$a_{i,t}=y_{i,t}$，$\\hat{A}_{i,t}$ 来自同一组回答内部的相对奖励。相比 PPO，GRPO 省去了价值函数估计，用组内 baseline 来稳定训练；同时，KL 散度作为正则项直接加入损失，用于限制当前策略不要过度偏离参考策略。\nReward Modeling Search-R1 的奖励设计较为直接，主要采用 rule-based final outcome reward，即只根据最终答案是否正确来给奖励，而不额外设计复杂的过程奖励。其奖励函数可以表示为：\n$$r_\\phi(x,y)=\\mathrm{EM}(a_{\\mathrm{pred}},a_{\\mathrm{gold}})$$其中，$a_{\\mathrm{pred}}$ 是从模型最终响应 $y$ 中抽取出的答案，$a_{\\mathrm{gold}}$ 是标准答案，$\\mathrm{EM}$ 表示 exact match。换句话说，只要最终答案匹配标准答案，模型就获得正奖励；否则奖励较低或为零。\n这种设计的优点是简单、稳定，并且避免了人为规定“正确推理过程”带来的偏置。Search-R1 没有额外加入 format reward，因为模型在模板约束下已经能够较好遵守 \u0026lt;think\u0026gt;、\u0026lt;search\u0026gt;、\u0026lt;information\u0026gt; 和 \u0026lt;answer\u0026gt; 的结构。这样，训练信号主要集中在最终任务表现上，让模型自己学习更有效的搜索与推理策略。\n整体来看，Search-R1 的关键不只是“让模型能搜索”，而是把搜索行为本身纳入强化学习优化过程。模型不再被动接收一次性检索结果，而是在多轮交互中学习如何提出查询、何时停止搜索，以及如何将外部信息整合进最终回答。\n","permalink":"https://dengkoicat.github.io/posts/2026-07-28-agentic-rl-rag/","summary":"\u003ch2 id=\"why-not-react\"\u003eWhy not ReAct?\u003c/h2\u003e\n\u003cp\u003e传统 RAG 通常只在生成前检索一次，检索内容主要由原始问题决定。因此，在多跳问答或复杂推理任务中，它很容易遇到信息不足、检索偏差或上下文不完整的问题。\u003c/p\u003e","title":"Agentic RAG"},{"content":"这篇博客主要是总结一下项目，讲这个项目的 Agent Harness\nLoop Engineering 这个项目本质上是一个 prompt 引导的 ReAct Agent。标准 ReAct loop 用 langchain.agents.create_agent 搭；如果要完全控制 Think、Act、Observe、Reflect 每个节点，就直接用 StateGraph 自己搭。\n这两个层次不要混在一起：\ncreate_agent：高级 agent harness。模型、工具循环、messages reducer、checkpoint、middleware 都已经接好，适合大多数 ReAct Agent。 StateGraph：低层编排。自己定义 State、节点、边和 ToolNode，适合需要强控制流、复杂状态机、多分支并行、human-in-the-loop 的场景。 项目里的 hook 统一挂到 middleware：\non_session_start / on_session_end -\u0026gt; before_agent / after_agent pre_think / post_reflect -\u0026gt; before_model / after_model 或 wrap_model_call pre_tool_call / post_tool_call -\u0026gt; wrap_tool_call 如果使用纯 StateGraph，这些 hook 也能保留原来的名字，只是挂载位置变成显式节点：START -\u0026gt; before_agent -\u0026gt; model -\u0026gt; tools -\u0026gt; model -\u0026gt; after_agent -\u0026gt; END。\nReAct Agent Agent 入口是 create_agent：\nfrom langchain.agents import create_agent from langgraph.checkpoint.memory import InMemorySaver agent = create_agent( model=get_llm(), tools=FULL_TOOL_SET, system_prompt=prompt, middleware=[ GlobexLifecycleMiddleware(), GlobexContextMiddleware(summary_model=get_summary_llm()), GlobexToolMiddleware(), ], state_schema=GlobexAgentState, context_schema=GlobexRuntimeContext, checkpointer=InMemorySaver(), ) config = {\u0026#34;configurable\u0026#34;: {\u0026#34;thread_id\u0026#34;: thread_id}} result = await agent.ainvoke( { \u0026#34;messages\u0026#34;: [{\u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: query}], \u0026#34;task_state\u0026#34;: initial_task_state, }, config=config, context=GlobexRuntimeContext( user_id=user_id, session_dir=session_dir, locale=\u0026#34;zh-CN\u0026#34;, ), ) 这里有几个关键点：\nsystem prompt 通过 system_prompt= 传入。 工具还是直接传 tools=[...]，不要把 ToolNode 当 tools 传进去。 state_schema 放 Agent 需要持久化的业务状态。 context_schema 放本次运行的 runtime context，比如 user_id、session_dir、租户信息，这些不应该写进模型 messages。 checkpointer 负责按 thread_id 保存短期记忆和中间状态。 tool-call hooks 每一轮模型都会看到：\nsystem prompt 历史 messages 当前可见的 tools schema 如果模型判断需要调用工具，就会在 AIMessage 里生成 tool_calls：\n{ \u0026#34;id\u0026#34;: \u0026#34;call_123\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;item_search\u0026#34;, \u0026#34;args\u0026#34;: {\u0026#34;query\u0026#34;: \u0026#34;旅行收纳袋\u0026#34;} } 一旦 AIMessage 里有 tool_calls，LangGraph 就会把流程从 LLM 节点路由到 tools 节点。\n默认情况下，tools 节点是 LangGraph 内置的 ToolNode，它负责：\n根据 tool_call[\u0026ldquo;name\u0026rdquo;] 找到工具 把 tool_call[\u0026ldquo;args\u0026rdquo;] 传给工具 执行工具 把结果包装成 ToolMessage，追加回 messages 工具调用 hook 放在 wrap_tool_call middleware，它天然包在单次工具调用前后：\nfrom collections.abc import Awaitable, Callable from langchain.agents.middleware import AgentMiddleware from langchain.messages import ToolMessage from langchain.tools.tool_node import ToolCallRequest from langgraph.types import Command class GlobexToolMiddleware(AgentMiddleware[GlobexAgentState, GlobexRuntimeContext]): async def awrap_tool_call( self, request: ToolCallRequest, handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]], ) -\u0026gt; ToolMessage | Command: tool_call = request.tool_call ctx = await harness.run(\u0026#34;pre_tool_call\u0026#34;, { \u0026#34;tool_name\u0026#34;: tool_call[\u0026#34;name\u0026#34;], \u0026#34;tool_args\u0026#34;: tool_call[\u0026#34;args\u0026#34;], \u0026#34;tool_call_id\u0026#34;: tool_call[\u0026#34;id\u0026#34;], \u0026#34;state\u0026#34;: request.state, \u0026#34;runtime\u0026#34;: request.runtime, }) if ctx.get(\u0026#34;_rejected\u0026#34;): return ToolMessage( content=f\u0026#34;[Harness 拒绝] {ctx[\u0026#39;_reject_reason\u0026#39;]}\u0026#34;, tool_call_id=tool_call[\u0026#34;id\u0026#34;], ) result = await handler(request) ctx = await harness.run(\u0026#34;post_tool_call\u0026#34;, { \u0026#34;tool_name\u0026#34;: tool_call[\u0026#34;name\u0026#34;], \u0026#34;tool_result\u0026#34;: result.content if isinstance(result, ToolMessage) else result, \u0026#34;state\u0026#34;: request.state, \u0026#34;runtime\u0026#34;: request.runtime, }) if isinstance(result, ToolMessage) and \u0026#34;tool_result\u0026#34; in ctx: return ToolMessage( content=ctx[\u0026#34;tool_result\u0026#34;], tool_call_id=result.tool_call_id, name=result.name, ) return result pre_tool_call 做工具白名单、阶段权限、参数校验、调用顺序检查；post_tool_call 做结果过滤、截断、压缩、熔断记录和 schema 校验。核心是把工具边界治理集中在 wrap_tool_call。\nCircuitBreaker CircuitBreaker 不是一个独立工具，也不是单个 hook，而是工具调用边界上的保护组件。它治理的是外部依赖失败，比如某个平台 API 超时、Reranker 服务异常、Embedding 服务不可用。\n项目里按工具维度维护熔断状态：\nClosed -\u0026gt; Open -\u0026gt; HalfOpen -\u0026gt; Closed Closed：正常放行工具调用，同时记录成功/失败。 Open：工具已熔断，不再真实请求外部服务，直接返回结构化不可用结果。 HalfOpen：等待恢复窗口后放一次试探请求，成功则恢复 Closed，失败则回到 Open。 默认策略是最近 100 次调用做滑动窗口统计，失败率超过 30% 进入 Open；但至少要有 10 个样本才判断，避免刚启动时一两次失败就误熔断。Open 后等待 5 分钟再进入 HalfOpen 试探恢复。Reranker、tower_encode 这种核心链路可以把阈值调得更敏感，比如 20% 失败率就熔断。\n核心点不是让 Agent 报错退出，而是把工具不可用包装成 observation，让主 loop 自己决定换平台、降级或基于已有结果收尾：\nreturn ItemSearchOutput( platform=platform, candidates=[], total_recall=0, truncated=False, error_message=\u0026#34;工具 item_search_amazon 已熔断，预计 300s 后试探恢复\u0026#34;, ) 主 loop 看到 candidates=[] + error_message 后，会跳过已熔断平台，继续用 Shopee / AliExpress / eBay 的候选比价，避免卡在 Amazon API 上等到子任务超时。\n当前项目里，CircuitBreaker 不是 hook，而是包在具体工具内部的保护器。例如 item_search 通过 get_breaker(f\u0026quot;item_search_{platform}\u0026quot;) 取得熔断器，再用 breaker.call(_actual_search, ...) 调真实平台 API。若状态是 Open，就不再请求外部服务，而是把 CircuitOpenError 转成结构化 observation 返回给主 loop。\n迁到统一 Harness Pipeline 后，post_tool_call 只负责记录工具结果并更新熔断状态：\npost_tool_call: 记录 success / failure 更新 Closed / Open / HalfOpen 如果要把 Open 状态短路也收进 Harness，可以额外加 pre_tool_call.breaker_check；但这属于进一步收敛工具边界，不是当前项目描述里已落地的 hook。\n多实例时，熔断状态不能只存在内存里，必须同步到 Redis。否则实例 A 已经熔断，实例 B 仍会继续请求坏掉的服务。跨用户生效也不是靠 WebSocket 广播，而是靠共享状态：第一个用户失败后，post_tool_call 更新 breaker:{tool_name}；其他用户下次调用时，由 breaker.call 或 pre_tool_call 读取 Redis，若状态为 Open，就直接返回“工具暂时不可用”的 observation，让 Agent 换平台或降级收尾。AGUI / WebSocket 只展示当前任务事件，不承担全局熔断同步。\n如果工具要写 Agent State，不要改全局变量，直接返回 Command(update={...})，并同时写入对应 ToolMessage，这样更新才能走 LangGraph reducer，并被 checkpoint 恢复。\nagent-loop hooks AgentLoop 级别的 hooks 对应 middleware 的四个节点式 hook：\nbefore_agent -\u0026gt; on_session_start，每次 invoke 开始时执行一次 before_model -\u0026gt; pre_think，每次 LLM 调用前执行 after_model -\u0026gt; post_reflect，每次 LLM 返回后执行 after_agent -\u0026gt; on_session_end，每次 invoke 结束时执行一次 如果逻辑只是读 state、写 state，用 before_model / after_model；如果要包住模型调用做 retry、fallback、动态换模型、动态工具暴露，就用 wrap_model_call。这里要分清职责：GlobexLifecycleMiddleware 只负责生命周期 hook 和运行时提示；模型上下文拼接、Breakpoint 判断和压缩统一放在 GlobexContextMiddleware。\nfrom typing import Any from langchain.agents.middleware import AgentMiddleware from langgraph.runtime import Runtime class GlobexLifecycleMiddleware(AgentMiddleware[GlobexAgentState, GlobexRuntimeContext]): state_schema = GlobexAgentState async def abefore_agent( self, state: GlobexAgentState, runtime: Runtime[GlobexRuntimeContext], ) -\u0026gt; dict[str, Any] | None: ctx = await harness.run(\u0026#34;on_session_start\u0026#34;, { \u0026#34;state\u0026#34;: state, \u0026#34;runtime\u0026#34;: runtime, \u0026#34;user_id\u0026#34;: runtime.context.user_id, \u0026#34;thread_id\u0026#34;: runtime.execution_info.thread_id, }) return ctx.get(\u0026#34;state_update\u0026#34;) async def abefore_model( self, state: GlobexAgentState, runtime: Runtime[GlobexRuntimeContext], ) -\u0026gt; dict[str, Any] | None: ctx = await harness.run(\u0026#34;pre_think\u0026#34;, { \u0026#34;state\u0026#34;: state, \u0026#34;runtime\u0026#34;: runtime, \u0026#34;messages\u0026#34;: state[\u0026#34;messages\u0026#34;], }) return ctx.get(\u0026#34;state_update\u0026#34;) async def aafter_model( self, state: GlobexAgentState, runtime: Runtime[GlobexRuntimeContext], ) -\u0026gt; dict[str, Any] | None: ctx = await harness.run(\u0026#34;post_reflect\u0026#34;, { \u0026#34;state\u0026#34;: state, \u0026#34;runtime\u0026#34;: runtime, \u0026#34;response\u0026#34;: state[\u0026#34;messages\u0026#34;][-1], }) return ctx.get(\u0026#34;state_update\u0026#34;) async def aafter_agent( self, state: GlobexAgentState, runtime: Runtime[GlobexRuntimeContext], ) -\u0026gt; dict[str, Any] | None: ctx = await harness.run(\u0026#34;on_session_end\u0026#34;, { \u0026#34;state\u0026#34;: state, \u0026#34;runtime\u0026#34;: runtime, \u0026#34;final_answer\u0026#34;: state[\u0026#34;messages\u0026#34;][-1].content, }) return ctx.get(\u0026#34;state_update\u0026#34;) 这样每次 ReAct Agent 调 LLM 前，都会先跑 pre_think，用于注入 token 降级 hint、漂移纠正信号、阶段约束等；每次 LLM 返回后，会跑 post_reflect，用于循环检测、漂移检测、预算检查和阶段转移。会话开始和结束的逻辑则由 before_agent / after_agent 负责。模型真正看到的上下文窗口由后面的 GlobexContextMiddleware 在进入模型前统一整理。\nContext Compression Globex 不把上下文压缩拆成另一套官方摘要 middleware，而是统一放进 GlobexContextMiddleware。它在每轮 LLM 调用前运行，负责把 LangGraph State 里的任务状态、工作记忆和消息轨迹，整理成模型真正能看到的 messages 窗口。\n先把几个名字分清楚：\nGlobexAgentState ├─ messages 对话轨迹：HumanMessage / AIMessage / ToolMessage ├─ task_state 结构化任务状态：目标、预算、平台、当前阶段 ├─ hot_context 热上下文：刚从工具观察到、下一轮马上要用的信息 ├─ working_memory 工作记忆：已确认偏好、阶段性结论、稳定历史摘要 ├─ cold_data 冷数据引用：原始大结果的文件路径或对象引用 └─ compression_stats 压缩统计：上次摘要步数、摘要策略、轮次计数 新的用户输入只走 messages：\nawait agent.ainvoke( {\u0026#34;messages\u0026#34;: [{\u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: query}]}, config={\u0026#34;configurable\u0026#34;: {\u0026#34;thread_id\u0026#34;: thread_id}}, ) 这不是把 query 直接塞进整个 state，而是提交了一次 messages 字段的增量更新。messages 有 message reducer，新用户消息会追加到 state[\u0026quot;messages\u0026quot;]；模型回复的 AIMessage、工具返回的 ToolMessage 也继续追加到这里。\n业务字段不是自动从消息里长出来的。Planner、工具、Reflect 或 middleware 要显式返回 state update，才会更新 task_state / hot_context / working_memory / cold_data。例如一次商品搜索通常会拆成三份：\n原始搜索结果很大 -\u0026gt; 落盘，路径写入 cold_data.last_search_result 下一轮马上要用的 Top-N 摘要 -\u0026gt; 写入 hot_context.latest_observation 模型需要知道“刚发生了什么” -\u0026gt; 写成 ToolMessage 追加到 messages 代码形态大概是：\nreturn Command(update={ \u0026#34;hot_context\u0026#34;: {\u0026#34;latest_observation\u0026#34;: compact_items}, \u0026#34;cold_data\u0026#34;: {\u0026#34;last_search_result\u0026#34;: path}, \u0026#34;messages\u0026#34;: [ ToolMessage( content=f\u0026#34;找到 {len(raw_items)} 个候选，已压缩保留 Top-N，原始结果：{path}\u0026#34;, tool_call_id=runtime.tool_call_id, ) ], }) 所以每轮调用模型前，GlobexContextMiddleware.abefore_model 做的事情不是“把整个 State 发给模型”，而是从 State 里挑出模型该看的部分，重新拼出一个上下文窗口：\nwindow = build_llm_context_window( task_state=state.get(\u0026#34;task_state\u0026#34;, {}), hot_context=state.get(\u0026#34;hot_context\u0026#34;, {}), working_memory=state.get(\u0026#34;working_memory\u0026#34;, {}), messages=state[\u0026#34;messages\u0026#34;], cold_data=state.get(\u0026#34;cold_data\u0026#34;, {}), ) 拼出来的顺序固定为：\nsystem_prompt（由 create_agent 维护） TASK STATE LATEST OBSERVATION WORKING MEMORY STABLE HISTORY SUMMARY ----- Cache Breakpoint ----- RECENT TOOL MESSAGES（最近 K=3 条工具调用与工具结果） CURRENT USER REQUEST 这里有两个关键点：\nsystem_prompt 由 create_agent(system_prompt=...) 维护，不是业务 state 的一部分。 CURRENT USER REQUEST 仍然来自 state[\u0026quot;messages\u0026quot;] 里的最新 HumanMessage，只是在拼窗口时被放到最后。 压缩只在满足条件时触发。条件满足任一即可：\nBreakpoint 后动态区 \u0026gt; 15K token。 总上下文 \u0026gt; 60K token。 连续 M=5 轮没有做过 L3 会话压缩。 触发 L3 后，也不是把 task_state / hot_context / working_memory / cold_data 全部压掉。真正被摘要的是较旧的动态消息历史；结构化状态更多是作为摘要输入，帮助摘要保留任务目标、约束和关键结论。\nrecent_tool_uses = keep_recent_tool_uses( messages=state[\u0026#34;messages\u0026#34;], k=3, ) summary = await summarize_old_dynamic_history( messages=messages_before(recent_tool_uses, state[\u0026#34;messages\u0026#34;]), task_state=state.get(\u0026#34;task_state\u0026#34;, {}), working_memory=state.get(\u0026#34;working_memory\u0026#34;, {}), cold_data=state.get(\u0026#34;cold_data\u0026#34;, {}), ) 摘要生成后，会沉淀到稳定区：\nworking_memory = { **state.get(\u0026#34;working_memory\u0026#34;, {}), \u0026#34;stable_history_summary\u0026#34;: summary, } 然后重新构建窗口，并替换 messages：\nrebuilt = build_llm_context_window( task_state=state.get(\u0026#34;task_state\u0026#34;, {}), hot_context=state.get(\u0026#34;hot_context\u0026#34;, {}), working_memory=working_memory, recent_tool_uses=recent_tool_uses, cold_data=state.get(\u0026#34;cold_data\u0026#34;, {}), ) return { \u0026#34;working_memory\u0026#34;: working_memory, \u0026#34;messages\u0026#34;: [ RemoveMessage(id=REMOVE_ALL_MESSAGES), *rebuilt.messages, ], \u0026#34;compression_stats\u0026#34;: { \u0026#34;last_summary_at_step\u0026#34;: runtime.execution_info.step, \u0026#34;rounds_since_summary\u0026#34;: 0, \u0026#34;strategy\u0026#34;: \u0026#34;cache_breakpoint_l3\u0026#34;, }, } RemoveMessage(id=REMOVE_ALL_MESSAGES) 这一步很重要。因为 messages 默认是追加 reducer，如果只返回新的 rebuilt.messages，它会继续 append 到旧窗口后面，压缩就失效了。正确做法是先清空旧消息窗口，再追加重建后的上下文窗口。\n最终可以把这条链路理解成：\n用户新消息 -\u0026gt; 追加到 state[\u0026#34;messages\u0026#34;] Planner / 工具 / Reflect -\u0026gt; 更新 task_state / hot_context / working_memory / cold_data 每轮 LLM 前 -\u0026gt; GlobexContextMiddleware 读取这些字段 -\u0026gt; build_llm_context_window 拼出模型可见 messages 触发压缩 -\u0026gt; 旧动态 messages 摘要进 working_memory.stable_history_summary -\u0026gt; 保留最近 K=3 条工具消息原文 -\u0026gt; RemoveMessage 清空旧窗口 -\u0026gt; 写入 rebuilt.messages checkpoint -\u0026gt; 按 thread_id 保存新的 state runtime metadata、trace、request_id、debug 信息不要进 messages，放 runtime.context 或不可见状态字段。模型应该看到的是任务上下文，不是后端运行日志。\nAgentState、消息和 checkpoint create_agent 默认 state 里已经有 messages，它使用 LangGraph 的 message reducer：新消息会追加，同 ID 消息会覆盖。项目里要扩展业务字段时，直接继承 AgentState：\nfrom typing import Annotated, Any, Literal from typing_extensions import NotRequired from langchain.agents.middleware import AgentState from pydantic import BaseModel def merge_dict(left: dict | None, right: dict | None) -\u0026gt; dict: return {**(left or {}), **(right or {})} class GlobexAgentState(AgentState): task_state: NotRequired[Annotated[dict[str, Any], merge_dict]] hot_context: NotRequired[Annotated[dict[str, Any], merge_dict]] working_memory: NotRequired[Annotated[dict[str, Any], merge_dict]] cold_data: NotRequired[Annotated[dict[str, str], merge_dict]] compression_stats: NotRequired[Annotated[dict[str, Any], merge_dict]] loop_count: NotRequired[int] current_phase: NotRequired[Literal[\u0026#34;search\u0026#34;, \u0026#34;compare\u0026#34;, \u0026#34;recommend\u0026#34;, \u0026#34;final\u0026#34;]] class GlobexRuntimeContext(BaseModel): user_id: str session_dir: str locale: str 每次调用时复用同一个 thread_id，LangGraph 就会把这一轮的 messages、task_state、working_memory 等 state 从 checkpoint 恢复出来，再把新的输入 append 进去。服务重启、超时恢复、人工中断续跑，都靠这个 thread_id 找回状态。\nPrompt Engineering 同一段 system prompt，一次任务里可能被送进模型三四十次。这意味着：\nprompt 里任何一句模糊的话，会在几十轮里持续误导模型； prompt 里任何一个多余的段落，会在几十轮里持续烧 token； prompt 里任何一条没被遵守的约束，会在几十轮里反复翻车。 所以 Agent 的 prompt 不是“问法”，而是一份契约：它定义了这个 Agent 在整个生命周期里的角色、边界、行为准则和工具使用规则。一个好 prompt 的基本要求：\n要求 含义 反例 稳定 prompt 前缀要尽量固定，别每轮都变（否则缓存全废） 在 system prompt 里拼当前时间戳 精确 每条规则都要可判定，别写“尽量”“适当”“合理” “适当的时候调用子 Agent” 分层 按职责和易变性分层，便于维护和缓存 把工具描述、安全约束、示例全揉成一大段 system-prompt system prompt 由这几部分组成：\n段落 职责 易变性 \u0026lt;role\u0026gt; 一句话锁定身份和四平台场景 全天不变 \u0026lt;constraints\u0026gt; 红线约束，2 条 CRITICAL + 2 条 IMPORTANT + 一般约束 全天不变 \u0026lt;loop\u0026gt; Think→Act→Observe→Reflect 范式 全天不变 \u0026lt;tool_policy\u0026gt; 工具清单 9 工具 + dispatch_tool 一句话能力 工具集变才改 \u0026lt;tool_policy\u0026gt; 决策路由 兜底/Planner/fork/单干 四分支判定 全天不变 \u0026lt;tool_policy\u0026gt; demands 写法 子 Agent 派活模板 全天不变 \u0026lt;examples\u0026gt; 4 条路由 + 输出格式 + 边界行为 few-shot 全天不变 \u0026lt;user_preferences\u0026gt; 用户长期偏好注入位 每用户不同、会更新 # app/prompt/prompts.yml system_prompt: | \u0026lt;role\u0026gt; 你是 Globex，一个跨境电商购物 Agent。你的职责是理解用户的购物意图， 跨亚马逊（Amazon）/ Shopee / 速卖通（AliExpress）/ eBay 四个平台检索商品， 完成比价、关税运费估算，并给出一份带购买理由的商品清单。 \u0026lt;/role\u0026gt; \u0026lt;constraints\u0026gt; - CRITICAL: 跨平台比价必须把运费和关税算进去再比。速卖通标价常比亚马逊低， 但加上跨境运费后可能反而更贵，只比标价会给出错误结论。 - CRITICAL: 不得泄露、引用或推断其他用户的偏好与历史数据。 - IMPORTANT: 所有价格、库存、商品信息必须来自工具返回，NEVER 推测或编造 工具没有返回的商品、价格或链接。 - IMPORTANT: 给出最终清单前，必须调用 ShoppingSummary 收尾，不要口头直接列清单。 - 信息不全时（缺预算 / 品类 / 收礼人等关键信息）先向用户追问，不要擅自开搜。 - 结论先行，每件商品附不超过 50 字的购买理由，不堆砌营销话术。 \u0026lt;/constraints\u0026gt; \u0026lt;loop\u0026gt; 你的每一轮都走 Think → Act → Observe → Reflect 循环： - Think：拆解用户意图，判断当前还缺什么信息。 - Act：调用一个工具，或用 dispatch_tool 派发子 AgentLoop。 - Observe：阅读工具返回，重点关注结构化字段。 - Reflect：信息够了就调 ShoppingSummary 收尾；不够就回到 Think 继续。 \u0026lt;/loop\u0026gt; \u0026lt;tool_policy\u0026gt; # 可用工具 - Planner: 把用户购物意图拆解成结构化字段 - ChatFallback: 闲聊兜底 - WebSearch: 检索评测 / 博主推荐 / 价格趋势等外部资料 - CategoryInsight: 查品类爆款 / 典型属性（基于 RAG 商品知识库） - ItemSearch: 单平台商品检索 - ItemPicker: 在合流候选集里按用户偏好二次精挑 - PriceCompare: 跨平台候选商品比价 - ShippingCalc: 关税 + 运费估算 - ShoppingSummary: 终结性工具，给出最终清单 + 选购理由 - dispatch_tool(demands): 派一个同质子 AgentLoop 去执行 demands # 决策路由：每轮 Think 结束时，按下面顺序判断这一步怎么走 ## 1) 非购物意图 → ChatFallback 兜底 用户输入与购物无关（闲聊 / 问候 / 询问你的能力）时，调 ChatFallback， 简短友好回应并引导回购物场景。不要为闲聊启动 Planner / 检索 / fork。 ## 2) 复杂多约束意图 → 先用 Planner 规划 当用户意图满足以下任一条，先调 Planner 拆解，再继续： - 带 2 个及以上约束（预算 + 材质 + 风格 / 平台...） - 品类模糊或是组合品类（如\u0026#34;三件套\u0026#34;\u0026#34;送礼方案\u0026#34;） - 首轮且信息量大 只有单一、明确的查询（\u0026#34;看看 XX 多少钱\u0026#34;）才跳过 Planner。 ## 3) 满足 fork 三件事 → dispatch_tool 派子 AgentLoop 当下一步子任务满足以下任一条，调 dispatch_tool(demands=\u0026#34;...\u0026#34;): 1. 能并行：多个独立检索可同时跑（如跨 4 平台 ItemSearch） 2. 要隔离：子任务输出很大，会占满主 loop 上下文（如拉 100 件精挑） 3. 链够深：子任务内部还要 \u0026gt;= 3 轮 Think→Act # When NOT to fork（同等重要） - 单步就能完成的原子操作 → 直接单干，别 fork - 只是想\u0026#34;换个工具调一下\u0026#34; → 直接调那个工具，别 fork - 子任务输出很小、不需要隔离 → 直接单干 fork 有开销（起子 loop + 上下文传递），鸡毛蒜皮别 fork。 ## 4) 其余情况 → 主 Agent 直接单干 当下一步是\u0026#34;单个原子操作\u0026#34;时，直接调对应工具，不要 Planner、不要 fork： - 单平台、品类明确的检索 → 直接 ItemSearch - 候选已合流回主 loop → 直接 PriceCompare / ShippingCalc / ItemPicker - 已到收尾条件 → 直接 ShoppingSummary 判断口诀：一步能拿到结果、且不需要隔离大输出，就自己干。 # dispatch_tool 的 demands 写法 demands 必须自包含（不依赖主 loop 隐含上下文），写清平台、品类、硬约束、 软偏好、期望产出，并在结尾指定\u0026#34;返回不超过 N 条的候选摘要，每条含 名称/价格/平台/关键卖点，不要回传原始 API 全量响应\u0026#34;。 \u0026lt;/tool_policy\u0026gt; \u0026lt;examples\u0026gt; \u0026lt;example\u0026gt; # 走\u0026#34;单干\u0026#34; user: 亚马逊上那款 XX 键盘现在多少钱 assistant: [Think: 单平台、品类明确 → 直接 ItemSearch(amazon)] \u0026lt;/example\u0026gt; \u0026lt;example\u0026gt; # 走\u0026#34;Planner\u0026#34; user: 想买套便宜又抗造的旅行三件套，预算300，不要塑料 assistant: [Think: 多约束 + 组合品类 → 先 Planner 拆解] \u0026lt;/example\u0026gt; \u0026lt;example\u0026gt; # 走\u0026#34;fork\u0026#34; user: 这个背包亚马逊、Shopee、速卖通、eBay 哪个划算 assistant: [Think: 四平台可并行 → dispatch_tool fork 4 路 ItemSearch] \u0026lt;/example\u0026gt; \u0026lt;example\u0026gt; # 走\u0026#34;兜底\u0026#34; user: 你好，你是谁 assistant: [Think: 非购物 → ChatFallback] \u0026lt;/example\u0026gt; \u0026lt;example\u0026gt; # 输出格式 user: 帮我看看有没有便宜点的机械键盘 assistant: 给你挑了 3 把（含运费，可直邮）： 1. XX 机械键盘 ¥219（Amazon）——热插拔轴，预算内最耐用 2. ... \u0026lt;/example\u0026gt; \u0026lt;example\u0026gt; # 边界行为：信息不全先问 user: 帮我买个好点的礼物 assistant: 想先确认几点好帮你精准挑：送谁？预算多少？有没有偏好的品类？ \u0026lt;/example\u0026gt; \u0026lt;/examples\u0026gt; \u0026lt;user_preferences\u0026gt; 下面是该用户已沉淀的长期偏好（可能为空），请在检索与精挑时遵守： {long_term_preferences} \u0026lt;/user_preferences\u0026gt; 读完上面的代码我们可能有一些疑惑：决策层路由为什么要这个顺序？constrain 为什么放 loop 前面？为什么 example 在 user_preferences 前面？以及 user_preferences 怎么找回和写入？\n全文里路由的判断顺序是刻意设计的，不是随便排的：\n先排除非购物（兜底）——最省，一眼能判，不用往下走 再判要不要规划（Planner）——决定后续所有工具能不能拿到干净字段 再判要不要 fork——决定这一步是并行还是串行 都不满足才单干——默认分支 红线约束（constraints）必须在范式说明（loop）之前——因为约束是\u0026quot;任何时候都不能破\u0026quot;的，而 loop 是\u0026quot;怎么干活\u0026quot;的流程。先立规矩，再教方法。\nexamples 是全天不变的静态内容，user_preferences 是每用户不同的动态内容。按 19-3 的静态→动态排布规律，静态的 examples 必须在动态的 user_preferences 前面，这样 examples 才能进缓存前缀被所有用户共享。\n长期记忆 user_preferences 是主 AgentLoop 创建之前召回，并不是全量召回，召回最相关的 $\\text{Top} - N$。\n召回：会话开始时，store.read_relevant(user_id, query) 召回相关 Top-K 偏好，注入 system prompt。\n使用：主 Agent、ItemPicker、ShoppingSummary 在搜索、过滤、排序和推荐时参考这些偏好。\n写入：任务收尾时，主 Agent 把本轮新识别的可复用偏好放入 shopping_summary.new_preferences，由 run_agent 写入 Store。\nclass ShoppingSummaryOutput(BaseModel): final_text: str picks: list[PickedItem] learned_preferences: list[str] 除此之外，system-prompt 里面的内容也要分层缓存。 Breakpoint 用 cache_control 实现，根据落点原则和 prompt 设计 断点 1：工具 schema 之后（工具定义全天不变，1h TTL）\n断点 2： 结束、\u0026lt;user_preferences\u0026gt; 开始之前 ← system prompt 静态区的尾巴\n断点 3：早期对话历史末尾（每轮往后挪，5 分钟 TTL）\n断点 4：预留给特别大的稳定工具结果（如一次 CategoryInsight 知识块）\nmeta-prompt 不只是 Agent 的一个 system prompt。凡是“用 LLM 去处理另一段内容”的地方，都需要一段提示词，我们叫它元提示词（meta-prompt）。它们往往被忽视，但质量直接影响主链路。如 Context Engineering 里面的 Planner prompt，工具结果压缩，长上下文全量压缩，Rubric评分\u0026hellip;\n1.Planner Planner 等工具的提示词就相对 system-prompt 轻量太多了，重点强调 JSON 输出。当然 prompt 约束 JSON 输出只是一层面， 还有代码层面的 Pydantic 解析校验与重试机制，以及底层 API 提供的结构化输出（Structured Output）强制约束。\n# Planner 元提示词：强制结构化 JSON 输出 planner_prompt: | 你是 Globex 的 Planner。把用户购物意图拆成严格的 JSON，字段固定： { \u0026#34;budget\u0026#34;: number | null, \u0026#34;category\u0026#34;: string, \u0026#34;material_pref\u0026#34;: {\u0026#34;exclude\u0026#34;: string[], \u0026#34;prefer\u0026#34;: string[]}, \u0026#34;style_pref\u0026#34;: string | null, \u0026#34;platforms\u0026#34;: string[], \u0026#34;hard_constraints\u0026#34;: string[], \u0026#34;soft_preferences\u0026#34;: string[] } 规则： - 只输出 JSON，不要任何解释文字。 - 用户没提到的字段填 null 或空数组，NEVER 编造。 - 预算若为区间取上限。 2. Tool Result Summary 对应 Context Engineering 里的 L2 Cache-Aware 微压缩， tool result 不是无脑进入 LLM Context，而是先经过一次治理。 L0 在工具出口控制返回体积，大结果落盘；L2 在进入动态上下文前判断是否需要精简，只对冗余、重复、过长的工具结果做字段裁剪或 Top-N 压缩。这样近期工具结果还能保留决策价值，又不会撑爆 Breakpoint 后面的动态区。\n# 工具结果精简元提示词：先判断要不要压，再压 tool_result_compress_prompt: | 判断这段工具返回是否需要精简： - 若为大量重复 / 冗余字段（如 100 件商品的完整描述）→ 需要精简， 只保留 名称 / 价格 / 评分 / 平台。 - 若为已经很精简的结构化结果 → 不精简，原样保留。 输出 \u0026lt;should_compress\u0026gt;true/false\u0026lt;/should_compress\u0026gt; + \u0026lt;kept_fields\u0026gt;...\u0026lt;/kept_fields\u0026gt; 3. Session Summary 对应 Context Engineering 里的 L3 会话压缩，总结的对象不是单条 tool_result，而是一段会话过程。 用于对话太长，即将超出长下文窗口时，这是最贵的。\n补充一下，什么时候调用，满足之一触发全量总结：\n当Breakpoint后暂存区内容 \u0026gt; 15 K 总上下文长度超过60K 已经M=5轮没压缩过 仍然保持最近K=3条工具在breakpoint之后，因为最可能被下一次会话用到。\n# 会话摘要元提示词：结构化八段式（防止约束丢失） session_summary_prompt: | 把当前购物会话总结成以下固定结构： 1. 用户核心需求（品类 / 预算 / 平台） 2. 已确认的硬约束和软偏好 3. 已检索的平台和候选概况 4. 已排除的商品和原因 5. 当前进行到哪一步（对应 Think→Act→Observe→Reflect） 6. 下一步计划 只输出结构化摘要，不要寒暄。 4. Shopping Summary Shopping summary 也是输出结构化 JSON，包含：\n最终推荐文本 md 商品信息（展示和交互用） 偏好收集 # ShoppingSummary 元提示词：终结性结构化输出 shopping_summary_prompt: | 你是 Globex 的 ShoppingSummary 工具。基于已收集的候选商品、价格结果、运费关税结果和用户偏好， 生成本轮购物任务的最终总结。 只输出严格 JSON，不要输出任何解释文字。字段固定为： { \u0026#34;final_text\u0026#34;: string, \u0026#34;picks\u0026#34;: [ { \u0026#34;name\u0026#34;: string, \u0026#34;platform\u0026#34;: string, \u0026#34;landed_cny\u0026#34;: number, \u0026#34;shipping_included\u0026#34;: boolean, \u0026#34;duty_included\u0026#34;: boolean, \u0026#34;direct_shipping\u0026#34;: boolean, \u0026#34;reason\u0026#34;: string } ], \u0026#34;learned_preferences\u0026#34;: string[] } 字段规则： - picks 最多 3 件，只能来自已收集的候选商品，不能编造商品、价格、平台或直邮信息。 - landed_cny 必须是含运费和关税后的到手价；如果缺少运费或关税信息，不得假装已包含。 - reason 每件商品不超过 50 字，说明为什么符合用户需求。 - final_text 用 Markdown 写给用户看，必须说明价格是否含运费关税、是否可直邮。 - learned_preferences 只放本轮明确、可复用的长期偏好，例如“不接受塑料材质”“偏好小众设计”。 - 不要把一次性任务条件、临时商品名称、当前平台搜索过程写入 learned_preferences。 5. Rubric Judege 用强模型，且 $tempature = 0$，必须严格稳定。\n# Rubric judge 元提示词：评分（完整体系见第 8 章） rubric_judge_prompt: | 你是 Globex 的评分员。根据下面动态生成的评分细则给 Agent 回答打分： \u0026lt;rubric\u0026gt; P0（必须满足，否则判 0 分）：{p0_items} P1（重要）：{p1_items} P2（加分项）：{p2_items} \u0026lt;/rubric\u0026gt; 逐条判定，输出每条命中情况 + 总分 + 简短理由。 Context Engineering 为什么要有 Context Engineering？核心是控制模型每一轮“看见什么、不看见什么、以什么结构看见”。\n主要收益可以分成成本和质量两类：\n节约 token，降低输出前的上下文处理时间和整体延迟 保持稳定前缀，尽可能命中 Prompt Cache，降低算力和调用成本 减少无关信息进入窗口，降低模型幻觉和注意力分散 把任务目标、约束、工具结果结构化，让模型知道下一步该干什么，减少瞎猜 上下文治理的核心不是“尽量塞更多历史”，而是把信息分层：\nRuntime Context：后端运行需要，模型不看 Session Context：Agent 内部状态账本，可 checkpoint LLM Context：每轮真正发给模型的 messages 窗口 Long-term Memory：跨会话复用的稳定偏好和经验 其中最容易混的是 Session Context 和 LLM Context。\nSession Context 是 LangGraph State 里和任务有关的内部状态：\nstate ├─ messages 原始对话轨迹和工具轨迹 ├─ task_state 任务结构：目标、预算、平台、阶段 ├─ hot_context 热观察：最新工具结果摘要、当前约束 ├─ working_memory 已确认结论、稳定历史摘要 ├─ cold_data 大结果引用 └─ compression_stats 压缩统计 LLM Context 是 GlobexContextMiddleware 每轮从 Session Context 里挑出来、重新排列、必要时压缩后的模型输入：\nsystem_prompt TASK STATE LATEST OBSERVATION WORKING MEMORY STABLE HISTORY SUMMARY ----- Cache Breakpoint ----- RECENT TOOL MESSAGES CURRENT USER REQUEST 所以新消息的流转不是“用户说一句，就把整段历史直接传给模型”。实际链路是：\n1. 用户新消息 -\u0026gt; 追加到 state[\u0026#34;messages\u0026#34;] 2. Planner 解析意图 -\u0026gt; 写入 state[\u0026#34;task_state\u0026#34;] -\u0026gt; 明确的预算、材质偏好、平台约束也会沉淀到 working_memory.confirmed_preferences 3. 工具执行 -\u0026gt; 简短 observation 追加到 state[\u0026#34;messages\u0026#34;] -\u0026gt; 下一轮马上要看的 Top-N、最低价、到手价等摘要写入 state[\u0026#34;hot_context\u0026#34;] -\u0026gt; 原始大结果引用写入 state[\u0026#34;cold_data\u0026#34;] 4. Reflect / 状态机 -\u0026gt; 把已确认偏好、排除理由、阶段性结论写入 state[\u0026#34;working_memory\u0026#34;] -\u0026gt; 比如“塑料材质已排除”“后续只比较可直邮”“下一步进入 PriceCompare” -\u0026gt; 更新 task_state.current_step 5. 模型调用前 -\u0026gt; GlobexContextMiddleware 读取 state -\u0026gt; build_llm_context_window 拼出 LLM Context 6. 满足压缩条件 -\u0026gt; 旧动态 messages 摘要为 working_memory.stable_history_summary -\u0026gt; 最近 K 条工具消息保留原文 -\u0026gt; 重建 messages 窗口并 checkpoint 这里的边界是：hot_context 偏“刚观察到、马上要用”，通常来自工具 observation 和运行时约束整理；working_memory 偏“已经确认、后面要遵守”，通常来自 Planner 归纳、Reflect、ItemPicker 决策和 L3 压缩摘要。工具刚看到的东西先进 hot_context，经过筛选、确认、总结后，才进入 working_memory。\n对于“旅行三件套，预算 300，不要塑料，Amazon 和 AliExpress 都看看”这样的任务，Planner 先把自然语言拆成购物意图：\nplanner_output: budget: 300 category: \u0026#34;旅行三件套\u0026#34; material_pref: exclude: [\u0026#34;塑料\u0026#34;] platforms: [\u0026#34;Amazon\u0026#34;, \u0026#34;AliExpress\u0026#34;] hard_constraints: - \u0026#34;优先直邮\u0026#34; 随后 State Builder / LangGraph reducer / 状态机会把它归一化成内部 task_state：\ntask_state: goal: \u0026#34;推荐旅行三件套\u0026#34; budget_cny_max: 300 platforms: [\u0026#34;Amazon\u0026#34;, \u0026#34;AliExpress\u0026#34;] constraints: - \u0026#34;不要塑料\u0026#34; - \u0026#34;优先直邮\u0026#34; current_step: \u0026#34;search\u0026#34; 工具跑完后，Session Context 会逐步长成这样：\ntask_state: goal: \u0026#34;推荐旅行三件套\u0026#34; budget_cny_max: 300 platforms: [\u0026#34;Amazon\u0026#34;, \u0026#34;AliExpress\u0026#34;] current_step: \u0026#34;price_compare\u0026#34; hot_context: latest_observation: - \u0026#34;Amazon 返回 20 个候选，Top-N 已保留\u0026#34; - \u0026#34;AliExpress 返回 18 个候选，Top-N 已保留\u0026#34; active_constraints: - \u0026#34;仅比较可直邮商品\u0026#34; working_memory: confirmed_decisions: - \u0026#34;两平台候选都已过滤塑料材质\u0026#34; - \u0026#34;后续只比较价格、运费和税费\u0026#34; stable_history_summary: - \u0026#34;用户需要旅行三件套，预算 300 元以内，不要塑料，优先直邮。Amazon / AliExpress 已完成召回。\u0026#34; cold_data: amazon_raw_result: \u0026#34;output/session_xxx/amazon.json\u0026#34; aliexpress_raw_result: \u0026#34;output/session_xxx/aliexpress.json\u0026#34; 模型最终看到的是重新拼出来的窗口，而不是上面这个 state 原样：\nSYSTEM 你是跨境购物 Agent。遵守工具调用与推荐规则。 TASK STATE goal: 推荐旅行三件套 budget: \u0026lt;= 300 CNY platforms: [Amazon, AliExpress] current_step: price_compare LATEST OBSERVATION - Amazon 返回 20 个候选，Top-N 已保留 - AliExpress 返回 18 个候选，Top-N 已保留 - 已过滤塑料材质 WORKING MEMORY - 后续只比较可直邮商品 - 重点比较价格、运费、税费和评分 STABLE HISTORY SUMMARY - 用户需要旅行三件套，预算 300 元以内，不要塑料。 - Amazon / AliExpress 已完成商品召回。 ----- Cache Breakpoint ----- RECENT TOOL MESSAGES assistant: 调用 item_search(Amazon) tool: Amazon Top-5 候选摘要 assistant: 调用 item_search(AliExpress) tool: AliExpress Top-5 候选摘要 CURRENT USER REQUEST 继续比较亚马逊和速卖通候选商品。 Cache Breakpoint system prompt 分层管“固定规则”，Cache Breakpoint 管“会话历史”；目的都和缓存稳定性有关，但前者偏提示词工程，后者偏上下文窗口治理。\n在上下文过长时，如果随意压缩消息，前缀就会变化，导致 Prompt Cache 失效。实测结果：\n指标 不压缩 盲目压缩 Cache Breakpoint token 数 80K 56K 60K 压缩率 0% 30% 25% 缓存命中率 85% 15% 80% 实际计费 token 12K 47.6K 12K 综合成本 基准 +297% -35% 关键理解： 实际计费 token = 总 token × (1 - 缓存命中率 × 折扣)。缓存命中的部分按半价或免费计费，所以命中率才是决定成本的关键变量。\nCache Breakpoint（缓存转折点）就是为了解决“既要压缩上下文，又不能破坏 Prompt Cache”这个矛盾。\n它的核心思路是把上下文分成两块：\nBreakpoint 前：稳定前缀，尽量不动，用来提高 Prompt Cache 命中率。 Breakpoint 后：动态尾部，保留最近关键消息；当动态尾部继续变长时，再把较旧部分压缩成新的稳定摘要。 这里要把两个名字分清楚：旧消息压缩后的结果叫 STABLE HISTORY SUMMARY，放在 Breakpoint 前，属于低频更新的稳定前缀；最近 K 条工具调用与工具结果叫 RECENT TOOL MESSAGES，放在 Breakpoint 后，属于当前决策还要直接使用的热消息。\nBreakpoint 后是动态暂存区，用来承接最近产生的工具调用、工具结果和当前用户请求。新的 tool_result 进入前会先经过 L0 落盘和 L2 工具结果精简。压缩前，Breakpoint 后可以暂存多条工具调用与工具结果；只要动态暂存区没有超过阈值，就不急着做 L3 总结，避免频繁改写稳定前缀，也避免成本过大。\n但旧工具结果不会每次被挤出就立刻总结。L3 会话压缩是低频批量触发，只有满足以下任一条件时才执行：\nBreakpoint 后动态暂存区超过 15K token 总上下文长度超过 60K token 已经连续 M=5 轮没有做过会话压缩 触发 L3 后，GlobexContextMiddleware.abefore_model 会把“除最近 K=3 条工具调用与工具结果之外的较旧动态历史”总结成新的 Stable History Summary，沉淀到 Breakpoint 前方；最近 K=3 条工具调用与工具结果仍保留在 Breakpoint 后方。\n[ Stable History Summary ] ← Breakpoint 前：稳定、可缓存、低频更新 --------- Cache Breakpoint --------- [ 最近 K=3 条工具调用与工具结果 ] ← Breakpoint 后：动态、经 L0/L2 精简 [ 当前用户请求 ] ← 永远变化，不缓存 上下文窗口的管理原则可以收束成几条：\n不该进入 LLM messages 的不要进：日志、trace、thread_id、request_id、调试信息等。这些属于运行时上下文，不属于模型上下文。 大工具结果不要全量塞进窗口：原始结果落盘，只给模型 Top-N、摘要、关键字段、truncated 标记和文件引用。 旧消息按价值处理：低价值消息直接丢弃；有决策价值但太长的历史压缩成摘要；当前用户需求、硬约束、最近关键工具结果必须保留。 稳定内容尽量放在前缀：system prompt、工具规则、少量长期偏好、稳定任务摘要，以提高 Prompt Cache 命中率。 长期记忆不要等于长聊天记录：只把可复用的用户偏好、黑名单、预算习惯等结构化存入 Store，下次按 query 召回相关记忆再注入。 层 名称 做什么 成本 对应信息层 L0 工具侧防线 控制工具返回体积，大内容写文件而非注入上下文 零 cold_data / hot_context 入口 L1 工程手段 动态 max_tokens、断点续传、服务端缓存 零 模型请求层 / hot_context L2 Cache-Aware 微压缩 在 Breakpoint 之后对近期工具结果做轻量压缩 低 hot_context / recent tool messages L3 会话压缩 当上下文逼近阈值时，用 LLM 做阶段性摘要 中，一次 LLM 调用 message_history → working_memory L4 Session Memory 维护结构化任务状态和会话内记忆，替代全量历史 低，增量更新 task_state / working_memory Eval Benchmark 不能只看 token 降了多少。 如果一个方案只是把 prompt 变短，但恢复后经常忘记用户约束、改错平台、漏掉失败工具，那它不叫上下文治理，只是把问题藏起来了。\nGlobex 评估上下文治理方案时，会同时看四类指标：\n指标 看什么 失败信号 任务成功率 最终是否完成跨平台检索、比价和推荐 token 降了，但回答经常缺商品、缺价格、缺理由 约束遗漏率 用户约束是否被保留，例如预算、平台、材质偏好 用户第一轮说\u0026quot;不要塑料\u0026quot;，后面又推荐塑料商品 中断恢复成功率 服务重启或子 Agent 超时后，能否从 Checkpoint 继续 任务从头跑、重复调用工具、丢失当前计划 单位任务成本与延迟 每次完整任务的 token 成本、TTFT、端到端耗时 token 省了，但首 token 更慢，或摘要调用把延迟吃回去 对应到 Globex 的实测：\n缓存命中率：15% → 80%+ 综合 token 成本：降低 35% 任务完成率：71% → 89%（配合 LoopDetector / 工具超时 / 截断策略） 约束遗漏率：重点看预算、平台、材质偏好三个字段 恢复成功率：通过 thread_id + checkpoint_id 验证长任务能否断点续跑 Middleware Globex 的 Hook 体系保留 HarnessMiddleware 这层业务抽象，由 LangChain middleware 统一调用。也就是说：\nLangChain AgentMiddleware -\u0026gt; 调用 Globex HarnessMiddleware -\u0026gt; 执行业务 hook -\u0026gt; 返回 state update / ToolMessage / Command Hook 点有 6 个，对应到 middleware 的不同挂载位置：\nHook 点 触发时机 主要做什么 on_session_start before_agent 初始化预算、线程上下文、长期偏好、阶段状态 pre_think before_model / wrap_model_call 注入预算提示、纠偏提示、阶段约束；上下文拼接和压缩由 GlobexContextMiddleware 负责 pre_tool_call wrap_tool_call 调用 handler 前 工具白名单、阶段权限、参数校验、调用顺序检查 post_tool_call wrap_tool_call 调用 handler 后 工具结果过滤、截断、压缩、熔断记录、schema 校验、语义校验 post_reflect after_model 循环检测、漂移检测、阶段切换、预算检查；必要时写入压缩或收束提示 on_session_end after_agent 输出审计、长期记忆写回、LangFuse 评分、清理上下文 如何将 Hooks 注册成 HarnessMiddleware ？项目内部还可以继续用装饰器注册，因为这层只是业务 hook registry，和 LangChain middleware 不冲突。\n首先定义 Agent 生命周期 Hook 点存入 HOOK_POINTS。HarnessMiddleware 是注册、管理和运行 hooks 的类，通过一个 dict 来管理阶段对应的 Hooks，比如 ( on_session_start,(init_budget(),..)) ，注册 hooks 钩子其实就是在这个哈希表里面对应阶段增加新的函数。\n要实现装饰器注册 hooks实例化一个全局 harness = HarnessMiddleware()，装饰器内部用这个全局实例去注册：\nHOOK_POINTS = [ \u0026#34;on_session_start\u0026#34;, \u0026#34;pre_think\u0026#34;, \u0026#34;pre_tool_call\u0026#34;, \u0026#34;post_tool_call\u0026#34;, \u0026#34;post_reflect\u0026#34;, \u0026#34;on_session_end\u0026#34;, ] class HarnessMiddleware: def __init__(self): self._hooks = defaultdict(list) def register(self, hook_point: str, name: str, fn, priority: int = 100): if hook_point not in HOOK_POINTS: raise ValueError(f\u0026#34;未知 hook_point: {hook_point}\u0026#34;) self._hooks[hook_point].append((name, fn, priority)) self._hooks[hook_point].sort(key=lambda item: item[2]) async def run(self, hook_point: str, context: dict) -\u0026gt; dict: for name, fn, priority in self._hooks.get(hook_point, []): result = await fn(context) if result is not None: context = result return context # 先实例化全局 middleware harness = HarnessMiddleware() # 再定义装饰器 def harness_hook(hook_point: str, name: str, priority: int = 100): def decorator(fn): harness.register( hook_point=hook_point, name=name, fn=fn, priority=priority, ) return fn return decorator 调用方式不再散落在 AgentLoop 里，而是集中包进一个 AgentMiddleware：\nclass GlobexHarnessMiddleware(AgentMiddleware[GlobexAgentState, GlobexRuntimeContext]): state_schema = GlobexAgentState async def abefore_agent(self, state, runtime): ctx = await harness.run(\u0026#34;on_session_start\u0026#34;, { \u0026#34;state\u0026#34;: state, \u0026#34;runtime\u0026#34;: runtime, }) return ctx.get(\u0026#34;state_update\u0026#34;) async def abefore_model(self, state, runtime): ctx = await harness.run(\u0026#34;pre_think\u0026#34;, { \u0026#34;state\u0026#34;: state, \u0026#34;runtime\u0026#34;: runtime, \u0026#34;messages\u0026#34;: state[\u0026#34;messages\u0026#34;], }) return ctx.get(\u0026#34;state_update\u0026#34;) async def aafter_model(self, state, runtime): ctx = await harness.run(\u0026#34;post_reflect\u0026#34;, { \u0026#34;state\u0026#34;: state, \u0026#34;runtime\u0026#34;: runtime, \u0026#34;response\u0026#34;: state[\u0026#34;messages\u0026#34;][-1], }) return ctx.get(\u0026#34;state_update\u0026#34;) async def aafter_agent(self, state, runtime): ctx = await harness.run(\u0026#34;on_session_end\u0026#34;, { \u0026#34;state\u0026#34;: state, \u0026#34;runtime\u0026#34;: runtime, \u0026#34;final_answer\u0026#34;: state[\u0026#34;messages\u0026#34;][-1].content, }) return ctx.get(\u0026#34;state_update\u0026#34;) 工具调用阶段用 wrap_tool_call，不再继承 ToolNode：\nclass GlobexToolMiddleware(AgentMiddleware[GlobexAgentState, GlobexRuntimeContext]): async def awrap_tool_call(self, request, handler): tool_call = request.tool_call ctx = await harness.run(\u0026#34;pre_tool_call\u0026#34;, { \u0026#34;tool_name\u0026#34;: tool_call[\u0026#34;name\u0026#34;], \u0026#34;tool_args\u0026#34;: tool_call[\u0026#34;args\u0026#34;], \u0026#34;tool_call_id\u0026#34;: tool_call[\u0026#34;id\u0026#34;], \u0026#34;state\u0026#34;: request.state, \u0026#34;runtime\u0026#34;: request.runtime, }) if ctx.get(\u0026#34;_rejected\u0026#34;): return ToolMessage( content=f\u0026#34;[Harness 拒绝] {ctx[\u0026#39;_reject_reason\u0026#39;]}\u0026#34;, tool_call_id=tool_call[\u0026#34;id\u0026#34;], ) result = await handler(request) ctx = await harness.run(\u0026#34;post_tool_call\u0026#34;, { \u0026#34;tool_name\u0026#34;: tool_call[\u0026#34;name\u0026#34;], \u0026#34;tool_result\u0026#34;: result.content if isinstance(result, ToolMessage) else result, \u0026#34;state\u0026#34;: request.state, \u0026#34;runtime\u0026#34;: request.runtime, }) if isinstance(result, ToolMessage) and \u0026#34;tool_result\u0026#34; in ctx: return ToolMessage( content=ctx[\u0026#34;tool_result\u0026#34;], tool_call_id=result.tool_call_id, name=result.name, ) return result Hooks 1. on_session_start Agent 开始工作前执行，用来初始化本次任务的运行上下文。\nset_thread_context()，初始化本次任务的 ContextVar，写入 thread_id 和 session_dir，让深层工具、AGUI/WebSocket 推送、子 Agent 能拿到当前任务上下文，避免多用户并发串台。 init_budget()，初始化本次请求的 TokenBudget，记录 token 总预算、已消耗量、剩余额度和模型档位，后续用于预算检查、模型降级、压缩或提前收束。 2. pre_think LLM 推理前触发，主要用于在每轮 Think 前注入运行时提示。它不负责拼接完整 LLM Context；上下文窗口由 GlobexContextMiddleware.abefore_model 统一构建。\nTokenBudget hint 注入，根据当前 token 预算状态给模型注入降级提示，比如进入 minimal 档时提醒模型“不要继续探索，基于已有 Observation 收笔”。 inject_runtime_messages，统一注入运行时提示。它会根据当前 TokenBudget 状态生成降级 hint，比如进入 minimal 档时提醒模型“不要继续探索，基于已有 Observation 收笔”；同时也会把上一轮 loop_detector / drift_check / budget_check 写入的 context.inject_messages 合并到当前 messages，让模型在下一轮 Think 前看到纠偏、收笔或阶段约束提示。 3. pre/post_tool_call 工具执行前后触发，用来治理工具调用边界。\npre_tool_call：\ntool_whitelist，工具白名单校验。最先执行，如果模型幻觉出未注册工具，直接拒绝。 phase_check，阶段权限检查。根据当前 Agent 阶段判断这个工具能不能用，比如 PLANNING 阶段不能直接调用 shopping_summary。 schema_validate，参数 schema 校验。检查 tool_call[\u0026ldquo;args\u0026rdquo;] 是否符合工具参数定义，避免参数缺失、类型错误。 post_tool_call：\ncontent_filter，工具结果内容过滤，避免把敏感或不安全内容继续喂回模型。 truncate，工具结果截断，防止单个工具返回过长导致上下文膨胀。 breaker_record，记录工具调用成功/失败，更新工具级 CircuitBreaker 的 Closed / Open / HalfOpen 状态。 step_assertion，单步断言检查，校验当前工具结果是否满足预期格式或业务约束。 4. post_reflect ReAct Agent 每轮 Reflect 之后触发，用于做循环检测、目标偏移检查、token 余量检查和阶段转移。\nloop_detector，检测工具调用循环，比如连续多次调用同一个工具；触发后往下一轮注入“收笔/换思路”提示。 drift_check，检测 Agent 行为是否偏离用户原始需求；如果轻微/严重漂移，就注入纠偏提示。 budget_check，检查 token 预算余量，必要时写入压缩提示、降级提示或准备 fallback；真正的上下文窗口重建仍由 GlobexContextMiddleware 在模型调用前执行。 phase_transition，根据当前执行结果推动阶段流转，比如 PLANNING -\u0026gt; SEARCHING -\u0026gt; COMPARING -\u0026gt; CONCLUDING。 补充讲一下 drift_check ，当然不是直接用一个 Lite 模型去校验，而是规则校验，如果疑似偏移就调用 Lite 模型去校验。也不是每轮都跑，而是 3 轮跑一次。\ndef _computational_drift_check(query: str, actions: str, context: dict) -\u0026gt; str: \u0026#34;\u0026#34;\u0026#34;快速计算检查，返回 normal / suspicious。\u0026#34;\u0026#34;\u0026#34; keywords = set(re.findall(r\u0026#39;\\w+\u0026#39;, query)) action_words = set(re.findall(r\u0026#39;\\w+\u0026#39;, actions)) overlap = keywords \u0026amp; action_words if len(overlap) / max(len(keywords), 1) \u0026lt; 0.2: return \u0026#34;suspicious\u0026#34; return \u0026#34;normal\u0026#34; Lite 模型检测结果有三个 “正常” ,“轻量偏移”，“严重偏移”，“连续严重”。只有 “连续严重” 的时候会要求 LLM 基于已有观察，立即调用 ShoppingSummary 终止任务。\n5. on_session_end AgentLoop 收束后、最终结果返回给前端前触发，通常发生在 shopping_summary 产出结果之后。\noutput_guard，最终输出审核。对应 audit_final_output() / audit_output()，用于检查并处理最终回答里的风险内容，比如内部 item_id、API Key、敏感信息泄露等。 store_writeback，长期记忆写回。对应 writeback_preferences()，把本轮对话中识别出的新用户偏好写回 Store，供下次会话使用。 其中 store_writeback 写回的是本轮识别出的新偏好，来源通常是 shopping_summary.learned_preferences，或者从最终 trajectory / final message 里取出对应字段。\n下面是按你原有结构整理后的完整版 Markdown。我尽量保留了你已有的呈现方式，只在 Evolutionary Feedback Loop、Bad Case 数据飞轮、最终闭环 里补清楚 Trace、动态 Rubric、Judge、Goodcase/Badcase 的数据流。\nAgent Evolve 一个上线后不再迭代的 Agent，即便初期表现优异，也会因外部环境的动态变化而逐渐退化；这种退化并非源于代码本身的劣化，而是受限于：\n模型升级导致的 Prompt 行为漂移 API 更新引发的工具不兼容 用户需求演化带来的新场景知识缺失 竞品提升所推高的用户期望水位 四类退化信号：\n退化类型 表现 典型来源 监控指标 Quality Drift Rubric 日均分持续下滑 • 闭源模型版本升级 • 数据分布偏移 Rubric 7日均分趋势 Tool Decay 某平台工具成功率骤降 • 平台 API 行为变更 • 反爬策略升级 工具成功率滑动窗口 Preference Stale 用户偏好变了但 Agent 还按旧偏好推 • Store 里的偏好过期 • 季节性变化 偏好命中率下降 Context Rot Prompt 和实际工具集不匹配 • 新增/删除工具但 prompt 未同步 工具调用 404 率 退化通常不是断崖式的（那种容易发现），而是每天降 0.5%——连续一个月后从 79 分降到 68 分，但因为没人每天看分数曲线，等发现时已经积累了严重的用户体验损失。\n自进化的核心动机：不能等人发现了再修，要让系统自己发现并自己修复。\n业界 Agent 3×3 进化矩阵 What Evolves 工程原则：优先外层迭代，外层无法满足再向内层推进。外层便宜快速可逆；内层昂贵缓慢持久。\n层级 内容 特点 Layer1 External Files 记忆、知识库、技能库、成功策略 低成本、即时生效、可回滚 Layer2 Agent Harness Prompt、工具定义、工作流、路由规则 成本中等 Layer3 Model Weights 参数知识、底层决策能力 成本高、迭代慢、效果持久 构建 Agent 系统时遵循由外向内迭代：\n优先更新外部知识库、记忆文件（Layer1） 其次调整 Prompt、工作流、工具链（Layer2） 最后才考虑微调模型权重（Layer3） When Persist 持久范围 作用域 案例 Single Session 单次会话内 临时 scratchpad、工具缓存 Across Sessions 同一用户、跨会话 用户偏好、技能库、会话 Checkpoint Across Users 全体用户群体 模型微调、全局 Prompt 升级 完整 3×3 矩阵 External Files Agent Harness Model Weights Single Session 临时记忆 scratchpad 动态工具编排、会话状态机 极少使用：Test-time Training Across Sessions 用户偏好 Store + 个人技能库 Prompt 版本管理、动态工具增删 参数化个性化（前沿方案） Across Users 共享知识库、公共策略库 全局默认 Prompt 迭代升级 SFT / RL 模型训练 现在已有的部分 矩阵格子 Globex 已有 还缺 Files × Session Store 跨会话持久化 √ Files × Across Store 跨会话持久化 成功策略 / Skill Library Files × Users CategoryInsight RAG 知识库 共享策略 Commons Harness × Session 17-4 阶段状态机 / Token 预算 hint √ Harness × Across 无 Prompt 版本化 + A/B Harness × Users 无 默认 prompt 自动升级 Weights × Session $ \\times $ $ \\times $（成本不合理） Weights × Across $ \\times $ $ \\times $（Frontier 研究） Weights × Users SFT + RL Bad case 飞轮持续供给 Evolutionary Feedback Loop MAPE 借鉴 NVIDIA/IBM 的 MAPE（Monitor-Analyze-Plan-Execute）数据飞轮框架。\nFig. 1. MAPE\n基于 MAPE 理论，每个阶段做对应工程实现：\n步骤 做什么 依赖什么基建 Monitor • 发现 Rubric 分下降 • 工具失败率上升 • 偏好命中率下降 LangFuse Trace / Span / Score Analyze 按 P0/P1/P2 定位根因：\n• 是格式问题还是决策问题 • 知识缺失 Rubrics as Rewards 评测体系 Plan 根据根因选择进化路径 • 改记忆 • 改 prompt • 训模型 进化路径决策表 Evolve 执行进化并验证效果 • Bad Case 数据飞轮 • Prompt 自进化 • 记忆进化 这里需要补一层执行时机：Trace、Rubric Score、Judge 不是同一件事，也不是同一时刻发生。\n用户主链路： 用户 query 进入 -\u0026gt; 创建 LangFuse Trace -\u0026gt; AgentLoop 正常执行 -\u0026gt; LLM 调用 / 工具调用 / fork 过程写入 Span -\u0026gt; 返回最终答案给用户 后台评测链路： Agent 请求结束 -\u0026gt; 拿到完整 trajectory / Trace -\u0026gt; 针对这条 query 生成动态 Rubric -\u0026gt; Rubric Judge 给 Agent Trace 打分 -\u0026gt; Score 回填到 LangFuse -\u0026gt; 根据分数分流 Goodcase / Badcase 什么时候记录 Trace 到 LangFuse？\nTrace 在用户 query 进入 Agent 时就创建。后续每一次 LLM 推理、工具调用、fork 子 Agent、最终回答或异常，都写到这条 Trace 下面。\n记录对象 记录时机 作用 query / user_id / thread_id 请求进入时 复现用户原始需求 LLM Generation 每次 Think / Reflect 调模型时 分析 token、延迟、模型版本 Tool Span 每次 Act 调工具时 定位工具失败、参数错误、返回为空 fork 子 Agent 信息 dispatch / 子 Agent 启停时 分析多分支是否正确收敛 final_answer / error 请求结束时 作为后续 Judge 和排障上下文 Trace 的价值是保存“过程证据”。很多 bad case 不是最终回答那一步才坏，而是前面某个工具参数偏了、某个平台超时、某个子 Agent 没有合流。没有完整 Trace，只能知道“回答不好”；有 Trace 才能知道“从哪一步开始不好”。\n什么时候给 query 动态打分？\n动态打分发生在 Agent 跑完之后。不是用户刚输入 query 就给最终分，而是等完整 trajectory 生成后，再基于 query 生成动态 Rubric，并对 final answer + trajectory 做评测。\nquery -\u0026gt; Agent 生成完整 trajectory -\u0026gt; 基于 query 生成动态 Rubric -\u0026gt; Judge 按 Rubric 评估 final_answer + trajectory -\u0026gt; 产出 P0 / P1 / P2 明细和总分 Rubric 必须动态生成，因为不同 query 的评分标准不同：\n“预算 300，不要塑料”必须检查预算和材质约束； “送 28 岁男生生日礼物，不要玩具”必须检查人群、礼物场景和负向约束； “跨平台比价”必须检查是否真的做了多平台搜索、比价和到手价计算。 线上实时阶段可以做轻量校验，例如 schema check、工具顺序检查、循环检测；完整 Rubric 打分更适合放在请求结束后的后台评测链路。\nRubric Judge 模型在哪里、什么时候给 Agent Trace 打分？会不会影响用户主 query？\nRubric Judge 是评测链路里的专用模型，一般放在 eval / judge 模块中，使用比主 Agent 更稳定、更强的模型，并设置 temperature=0，保证评分稳定。\nJudge 的输入不是只有最终回答，而是：\nJudge 输入 = 用户原始 query + 动态 Rubric + Agent final_answer + messages / tool_calls / structured_result + LangFuse Trace metadata Judge 的输出是结构化评分：\n层级 判断内容 结果用途 P0 红线 是否预算严重超标、推荐违禁品、泄露内部字段、违反硬约束 一票否决，进入规则 / Hook 修复 P1 规范 是否按正确工具顺序执行，是否跳过关键步骤，格式是否完整 判断是否需要强模型重跑并生成 SFT 样本 P2 质量 推荐是否贴合需求，理由是否充分，场景洞察和决策价值是否足够 作为 RL reward 或策略沉淀信号 这个过程不应该影响用户主 query。用户主链路先返回购物结果；Judge 在后台异步评分。即使 Judge 失败，影响的只是这条 Trace 暂时没有 Rubric Score，不能进入 Goodcase / Badcase 自动流转，不应该影响用户看到最终回答。\n进化路径选择 Monitor 发现退化后，不能一上来就训模型。训模型是最贵、最慢、最难回滚的手段，应该放到最后。\n根因类型 优先路径 为什么 偏好过期 / 知识缺失 改记忆 / 改知识库 秒级生效，风险最低 Prompt 规则缺失 改 Prompt 分钟级生效，可以 A/B 验证 工具行为变化 改工具 / Harness Hook 小时级修复，属于工程治理 模型决策质量不足 SFT / RL 成本最高，但能提升长期能力上限 简单判断流程：\n退化出现 -\u0026gt; 是偏好 / 知识问题？优先改 Store / Skill -\u0026gt; 是规则缺失？改 Prompt 并做 A/B -\u0026gt; 是工具变化？改 Tool / Harness Hook -\u0026gt; 都不是，才进入 SFT / RL 这个原则可以概括为：能改外层就不改内层，能改规则就不训模型。\nBad Case 数据飞轮 Bad Case 飞轮负责把线上失败样本变成可持续迭代的数据资产。它的入口通常是低分 Trace：\nLangFuse Score \u0026lt; 阈值 -\u0026gt; 采集完整 trajectory -\u0026gt; 按 Rubric 明细分成 P0 / P1 / P2 -\u0026gt; 不同等级走不同修复路径 这里的 LangFuse Score 来自 Rubric Judge 的评测结果。也就是说，Badcase 和 Goodcase 都来自同一套 Rubric 评分体系，只是分数区间和后续去向不同。\nRubric 高分 -\u0026gt; Goodcase -\u0026gt; SFT 样本池 / 成功策略库 Rubric 低分 -\u0026gt; Badcase -\u0026gt; P0 / P1 / P2 分流 -\u0026gt; Hook / Prompt / Store / SFT / RL 采集的不是一条孤立回答，而是一整个诊断包：\n字段 作用 query 复现用户原始需求 trajectory 保存完整 AgentLoop 轨迹 rubric_score 判断是否是 bad case rubric_detail 定位 P0 / P1 / P2 根因 tool_calls 分析工具调用顺序和参数 token_consumed 判断是否有成本失控 trace_id 回溯 LangFuse Trace 核心采集逻辑可以很轻：\nCOLLECTION_THRESHOLD = 0.65 async def should_collect(rubric_score: float) -\u0026gt; bool: return rubric_score \u0026lt; COLLECTION_THRESHOLD 采集后再按 P0/P1/P2 分流：\ndef route_bad_case(rubric_detail: dict) -\u0026gt; str: if rubric_detail.get(\u0026#34;p0_pass\u0026#34;) is False: return \u0026#34;P0\u0026#34; if rubric_detail.get(\u0026#34;p1_total_deduction\u0026#34;, 0) \u0026gt;= 4: return \u0026#34;P1\u0026#34; if rubric_detail.get(\u0026#34;p2_average\u0026#34;, 5.0) \u0026lt; 3.0: return \u0026#34;P2\u0026#34; return \u0026#34;P2\u0026#34; Goodcase 和 Badcase 的去向可以概括为：\n类型 来源 去向 用途 Goodcase P0 全过，Rubric 总分达到高分阈值 SFT 样本池 / 成功策略库 学习正确 AgentLoop 范式，沉淀可复用策略 Badcase-P0 P0 红线失败 OutputGuard / Harness Hook / 黑名单规则 秒级堵住确定性风险 Badcase-P1 P1 执行规范扣分明显 强模型重跑，过门禁后进 SFT 生成正确示范，修复流程问题 Badcase-P2 P2 质量低 RL reward 池，与 goodcase 配对 优化长期决策质量上限 所以，Bad Case 飞轮不是只收集“坏回答”，而是把 Rubric 分数变成数据路由规则：\n高分轨迹进入 Goodcase，用来做 SFT 或沉淀成功策略； 低分轨迹进入 Badcase，再按 P0 / P1 / P2 选择不同修复路径； P1 badcase 不能直接进 SFT，需要强模型重跑后过门禁； P2 badcase 更适合作为 RL 的低分对照样本，和同类 query 的 goodcase 配对。 P0：秒级规则修复 P0 是红线问题，比如：\n输出泄露内部 item_id 推荐违禁品 暴露工具名 / API Key 严重格式错误导致前端不可用 这类问题不需要训模型，因为它们通常是确定性可判断的。直接生成 Hook 规则，挂到 output_guard / pre_tool_call / post_tool_call 更快。\nasync def auto_fix_p0(case): comment = case.rubric_detail.get(\u0026#34;p0_comment\u0026#34;, \u0026#34;\u0026#34;) if \u0026#34;泄露\u0026#34; in comment: patterns = extract_leaked_patterns(case.trajectory) output_guard.add_patterns(patterns) if \u0026#34;违禁\u0026#34; in comment: blacklist.add(case.rubric_detail[\u0026#34;bad_category\u0026#34;]) P0 修复不是临时 patch，而是持久化进 Harness 规则。后续所有请求都会经过这层防线。\nP1：强模型重跑，进入 SFT P1 是执行规范问题，比如：\n没调 ShoppingSummary 就直接回答 工具调用顺序错 Markdown 结构不完整 参数格式不符合约束 这类问题不能直接把错误轨迹丢进训练集，否则会以错训错。正确做法是：用强模型重跑同一条 query，生成一条更好的轨迹，再过门禁进入 SFT。\nasync def replay_with_strong_model(case): result = await run_agent( query=case.query, model_override=get_judge_llm(), ) new_score = await evaluate_trajectory(result[\u0026#34;trajectory\u0026#34;]) old_score = case.rubric_detail[\u0026#34;total_score\u0026#34;] if new_score[\u0026#34;total_score\u0026#34;] - old_score \u0026gt;= 15: return { \u0026#34;query\u0026#34;: case.query, \u0026#34;trajectory\u0026#34;: result[\u0026#34;trajectory\u0026#34;], \u0026#34;score\u0026#34;: new_score[\u0026#34;total_score\u0026#34;], \u0026#34;source\u0026#34;: \u0026#34;p1_replay\u0026#34;, } return None 入库前还要过门禁：\n门禁 要求 Rubric 分 大于等于 75 格式正确率 100% 相比原始轨迹 至少提升 15 分 轨迹长度 不超过 16K token P1 的产物是高质量 SFT 样本，用来让模型学会“正确的 AgentLoop 范式”。\nP2：进入 RL Reward 池 P2 是质量问题，比如：\n推荐和 query 不够贴合 覆盖了显式需求但没覆盖隐式需求 决策建议没有价值 场景洞察力弱 这类问题不一定存在唯一标准答案，SFT 能教格式，但很难教“哪个决策更好”。所以 P2 更适合作为 RL 信号：\nP2 低分轨迹 + 线上高分轨迹 -\u0026gt; 按 query pattern 配对 -\u0026gt; 形成好坏对比 -\u0026gt; 进入 RL reward 池 最终形成三个节奏：\n周期 处理对象 产出 生效速度 日级 P0 红线 新 Hook / 新规则 秒级 周级 P1 规范 SFT 数据 / 新 checkpoint 周级 月级 P2 质量 RL reward 信号 / 新 checkpoint 月级 Prompt 自进化 Prompt 也会过期。工具变了、模型版本变了、用户 query 类型变了，都会让旧 prompt 的行为开始漂移。\n如果靠人工直接改 prompt，会有几个问题：\n不知道修了 A 会不会坏了 B 没有版本记录，回滚困难 多人修改容易冲突 System Prompt 一变，Prompt Cache 可能大面积失效 所以 Prompt 不能“随手改”，而要版本化。\nPrompt Version 每次 prompt 改动都存成一个版本：\n@dataclass class PromptVersion: version: str content: str changelog: str author: str status: str = \u0026#34;draft\u0026#34; # draft / testing / active / retired rubric_score: float | None = None 版本号按语义化管理：\n类型 例子 处理方式 major 改 AgentLoop 结构 全量回归 minor 新增工具说明 / 新规则 A/B 测试 patch 修错字 / 微调措辞 快速上线 A/B 测试 Prompt 自进化的核心不是“自动生成一段新 prompt”，而是新 prompt 不能直接全量上线。\ndef get_prompt_for_user(user_id: str) -\u0026gt; str: if no_testing_version(): return prompt_store.get_active().content ratio = hash(user_id) % 100 / 100.0 if ratio \u0026lt; 0.10: return prompt_store.get_testing().content return prompt_store.get_active().content A/B 期间看四个指标：\n指标 判断 Rubric 均分 候选 prompt 连续 3 天高于基线才放量 格式正确率 低于阈值直接回滚 工具失败率 明显升高则回滚 Cache 命中率 大幅下降则告警 放量策略也要渐进：\n10% 流量观察 -\u0026gt; 30% 流量观察 -\u0026gt; 100% 全量 Prompt 更新还有一个容易忽略的点：Prompt Cache 基于前缀匹配。所以新规则尽量加在 prompt 末尾，不要频繁改开头的核心描述。A/B 测试期间也不要直接用 token 成本判定候选 prompt 变差，因为它的缓存还没预热，天然会更贵。\nAuto Prompt Optimization Prompt 自进化可以分三档：\n阶段 做法 人工参与 手动 人看 bad case，手写规则 高 半自动 LLM 分析 bad case，生成修改建议，人审核 中 全自动 LLM 生成改动，自动 A/B，自动合入或回滚 低 项目里更稳的是半自动：让 judge LLM 读最近一批低分 bad case，输出修改建议，而不是直接改线上 prompt。\nasync def suggest_prompt_improvement(bad_cases): current = prompt_store.get_active() summary = summarize_bad_cases(bad_cases) resp = await get_judge_llm().ainvoke([ (\u0026#34;user\u0026#34;, ANALYZE_PROMPT.format( current_prompt=current.content, bad_cases_summary=summary, )) ]) return { \u0026#34;suggestion\u0026#34;: resp.content, \u0026#34;based_on_version\u0026#34;: current.version, } 自动化也要有门禁：\n门禁 约束 修改幅度 diff 不超过固定字数 工具描述 不能删除已有工具说明 核心流程 不能自动改 fork / 阶段规则 频率 每天最多生成一个候选版本 原则是：自动系统可以加规则、改措辞，但不能擅自改架构。\nMemory \u0026amp; Strategy Evolution 记忆层进化是成本最低、反馈最快的一层。\n最初的 Store 只存用户偏好：\n用户说“不要塑料” -\u0026gt; ShoppingSummary 提取 learned_preferences -\u0026gt; store_writeback 写入 Store -\u0026gt; 下次 on_session_start 读取并注入 prompt 但只存偏好还不够。升级后的 Store 还要存两类东西：\n成功策略：高分轨迹里可复用的做法 失败教训：低分轨迹里应该避免的坑 成功策略 成功策略不是完整轨迹，而是压缩后的经验：\n某类 query + 某种工具调用顺序 + 高 Rubric 分 -\u0026gt; 提炼成可复用策略 一条策略可以长这样：\nclass StrategyEntry(BaseModel): strategy_id: str query_pattern: str summary: str tool_hints: list[str] key_decisions: list[str] rubric_score: float confidence: float = 1.0 times_referenced: int = 0 例如：\nquery_pattern: 跨平台比价类，带预算和材质约束 summary: 先确认品类属性，再并行搜索，最后综合比价和筛选 tool_hints: - category_insight - dispatch_tool - price_compare - shipping_calc - item_picker - shopping_summary 策略来自高分轨迹，但不是所有高分轨迹都能入库：\n门禁 条件 Rubric 分 大于等于 0.80 轨迹轮次 至少 4 轮 Act 工具多样性 至少 3 个不同工具 去重 和已有策略不能太相似 新会话开始时，系统按当前 query 检索 1~3 条相似策略，注入 system prompt：\ndef inject_strategies(prompt: str, strategies: list[StrategyEntry]) -\u0026gt; str: if not strategies: return prompt strategy_text = \u0026#34;\\n\u0026#34;.join( f\u0026#34;- {s.query_pattern}: {s.summary}\u0026#34; for s in strategies[:3] ) return prompt + f\u0026#34;\u0026#34;\u0026#34; # 成功策略参考 {strategy_text} 注意：以上策略仅供参考，请根据当前 query 灵活调整。 \u0026#34;\u0026#34;\u0026#34; 这里重点是“参考”而不是“强制”。策略库不是要把 Agent 变成固定流程，而是减少重复探索。\n失败教训 低分轨迹也有价值。它可以沉淀成“不要这么做”的经验：\nclass LessonEntry(BaseModel): lesson_id: str query_pattern: str what_went_wrong: str avoid_hints: list[str] rubric_comment: str confidence: float = 1.0 注入时可以和成功策略放在一起：\n# 成功策略参考 - 跨平台比价类：先 CategoryInsight，再 fork 多平台搜索 # 失败教训参考 - 送礼类 query：不要只搜商品名，要考虑收礼人的年龄、性别、爱好 - 旅行套装类 query：不要跳过 CategoryInsight，否则容易漏组件 这类记忆让 Agent 不只是“记住用户喜欢什么”，还会逐渐记住“哪些做法容易失败”。\n策略生命周期 策略也会过期，所以需要 confidence：\ndef compute_confidence(strategy): days_old = days_since(strategy.created_at) time_decay = exp_decay(days_old, half_life=60) reference_boost = min(strategy.times_referenced * 0.05, 0.5) return min(1.0, strategy.rubric_score * time_decay + reference_boost) 60 天没人引用，置信度减半 被高分请求引用，置信度回升 被低分请求引用，置信度下降 这样 Store 不是越存越乱，而是会自然沉淀出长期有效的策略。\nDynamic Fork Evolution Globex 里 fork 多平台搜索不是永远固定的。平台状态会变：\n某平台最近 API 经常超时 某平台在某品类下结果质量低 新平台接入后需要加入候选 所以 fork 候选可以根据历史成功率动态调整：\nclass PlatformSuccessTracker: def record(self, platform: str, success: bool): ... def get_success_rate(self, platform: str) -\u0026gt; float: ... def should_fork_to(self, platform: str, threshold: float = 0.3) -\u0026gt; bool: return self.get_success_rate(platform) \u0026gt;= threshold 生成候选平台：\ndef get_fork_candidates() -\u0026gt; list[str]: candidates = [] for platform in DEFAULT_PLATFORMS: if platform_tracker.should_fork_to(platform): candidates.append(platform) return candidates[:4] 然后在 pre_think 里注入提示：\n@harness_hook(\u0026#34;pre_think\u0026#34;, name=\u0026#34;fork_platform_hint\u0026#34;, priority=30) async def inject_fork_hint(context: dict): candidates = get_fork_candidates() context.setdefault(\u0026#34;inject_messages\u0026#34;, []).append({ \u0026#34;role\u0026#34;: \u0026#34;system\u0026#34;, \u0026#34;content\u0026#34;: f\u0026#34;当前推荐 fork 的平台：{\u0026#39;, \u0026#39;.join(candidates)}\u0026#34; }) return context 这本质上是 Harness 层的自适应：不改模型、不改 prompt，只根据线上反馈动态改变 Agent 的行动边界。\n最终闭环 到这里，Agent Evolve 不是一个单点功能，而是一套闭环：\n线上请求 -\u0026gt; LangFuse 记录 Trace -\u0026gt; 请求结束后 Rubric Judge 生成 Score -\u0026gt; Score 回填 LangFuse -\u0026gt; 高分进入 Goodcase -\u0026gt; 低分进入 Bad Case 池 -\u0026gt; P0/P1/P2 分流 -\u0026gt; 改 Hook / 改 Prompt / 改 Store / 训模型 -\u0026gt; A/B 或灰度验证 -\u0026gt; 候选版本上线 -\u0026gt; 继续监控 从成本和生效速度看：\n进化层 修复对象 生效速度 典型手段 External Files 偏好、知识、策略、教训 秒级 Store / Skill / Strategy Agent Harness Prompt、工具、Hook、路由 分钟到小时级 Prompt A/B / Harness 规则 Model Weights 决策能力和范式理解 周到月级 SFT / RL 所以 Globex 的自进化不是“模型自己训练自己”这么玄的东西，而是一个工程闭环：先观测，再分级，再选择最低成本的修复路径，最后用 Rubric 和线上指标验证效果。\n越用越聪明，背后其实是三件事在持续发生：\n记忆层记住用户偏好、成功策略和失败教训； Harness 层通过 Prompt、Hook、工具路由持续修正行为边界； Weights 层用高质量轨迹和 reward 信号慢慢提升决策上限。 ","permalink":"https://dengkoicat.github.io/posts/2026-07-24-llm-agent/","summary":"\u003cp\u003e这篇博客主要是总结一下项目，讲这个项目的 Agent Harness\u003c/p\u003e\n\u003ch2 id=\"loop-engineering\"\u003eLoop Engineering\u003c/h2\u003e\n\u003cp\u003e这个项目本质上是一个 prompt 引导的 ReAct Agent。标准 ReAct loop 用 \u003ccode\u003elangchain.agents.create_agent\u003c/code\u003e 搭；如果要完全控制 Think、Act、Observe、Reflect 每个节点，就直接用 \u003ccode\u003eStateGraph\u003c/code\u003e 自己搭。\u003c/p\u003e","title":"Agent Harness"},{"content":"这篇博客想梳理大语言模型对齐阶段最常见的几条路线：PPO、DPO、GRPO 和 GSPO。\n它们经常一起出现，但并不在同一个层级上。更准确地说，SFT 是让模型学会“按指令回答”的第一步；RLHF / DPO / GRPO / GSPO 则是在此基础上，让模型的输出更符合人类偏好、任务奖励或可验证标准。\n一个简化的对齐流程如下：\n预训练 Base Model ↓ SFT：监督微调，让模型学会遵循指令 ↓ 偏好 / 强化对齐阶段 ├── RLHF：先训练奖励模型，再用强化学习优化策略 │ └── PPO：经典 RLHF 策略优化算法 │ └── GRPO：去掉 Critic，用组内相对奖励估计 Advantage │ └── GSPO：把策略比率从 token 级改成 sequence 级 │ └── DPO：不显式训练奖励模型，直接用偏好对优化策略 本文的重点不是罗列名词，而是把每个方法的目标函数拆开：先看它想解决什么问题，再看公式为什么长成这样。\nFig. 1. 在 InstructGPT 实验中，SFT 和 PPO 训练后的模型都优于 GPT(Prompt) 基线。Image source: Training language models to follow instructions with human feedback, OpenAI, 2022.\n符号 符号 含义 $x$ 用户输入，也就是 prompt $y$ 模型生成的完整回答 $y_t$ 回答中的第 $t$ 个 token $y_{\\lt t}$ 第 $t$ 个 token 之前已经生成的前缀 $\\pi(y \\mid x; \\boldsymbol{\\theta})$ 当前要训练的策略模型，也就是 Actor $\\pi_{\\mathrm{ref}}(y \\mid x)$ 参考模型，通常是冻结的 SFT 模型 $\\pi(y \\mid x; \\boldsymbol{\\theta}_{\\mathrm{old}})$ 策略更新前的旧模型，用于计算 PPO / GRPO 的概率比 $r(x,y;\\boldsymbol{\\phi})$ 奖励模型对回答 $y$ 的打分 $v(s_t;\\mathbf{w})$ Value Model / Critic，对状态 $s_t$ 的价值估计 $D_{\\mathrm{KL}}(P\\|Q)$ KL 散度，用来衡量两个概率分布的差异 $\\beta$ KL 约束或偏好差异项的权重 $\\sigma(z)$ Sigmoid 函数，$\\sigma(z)=\\frac{1}{1+e^{-z}}$ $y_w, y_l$ 偏好数据中的 chosen response 与 rejected response $u_t$ 从第 $t$ 步开始的实际回报 $\\hat{A}_t$ token 级 Advantage 估计，表示当前动作比平均水平好多少 $A_i$ 第 $i$ 条回答的组相对 Advantage $G$ 同一个 prompt 下采样的回答数量 RLHF SFT SFT（Supervised Fine-Tuning）是大语言模型对齐的第一步。预训练模型只会预测下一个 token；SFT 的作用是把它变成能理解指令、按格式回答、遵守基本交互规范的 Instruct Model。\nSFT 数据通常是一组输入输出对：\n{ \u0026#34;messages\u0026#34;: [ {\u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: \u0026#34;解释什么是过拟合。\u0026#34;}, {\u0026#34;role\u0026#34;: \u0026#34;assistant\u0026#34;, \u0026#34;content\u0026#34;: \u0026#34;过拟合是模型过度记住训练数据，导致它在新数据上的泛化能力下降。\u0026#34;} ] } 如果把用户输入记为 $x$，assistant 回答记为 $y=(y_1,\\dots,y_T)$，SFT 的基本损失为：\n$$ \\mathcal{L}_{\\mathrm{SFT}}(\\boldsymbol{\\theta}) =-\\sum_{t=1}^{T}\\log \\pi(y_t \\mid x,y_{\\lt t};\\boldsymbol{\\theta}) $$工程实现中通常会加 mask，只在 assistant 的回答 token 上计算 loss：\n$$ \\mathcal{L}_{\\mathrm{SFT}}(\\boldsymbol{\\theta}) =-\\sum_{t=1}^{T}m_t\\log \\pi(y_t \\mid x,y_{\\lt t};\\boldsymbol{\\theta}) $$其中 $m_t=1$ 表示该 token 属于 assistant 回答，需要监督；$m_t=0$ 表示该 token 是 prompt、system message 或其他上下文，不计入训练损失。\nSFT 很稳定，也很直观。但它本质上是在模仿标准答案，无法直接表达“两个回答哪个更好”。例如两个回答都正确，但一个更简洁、更安全、更符合语气要求，SFT 很难显式建模这种偏好差异。偏好对齐要解决的正是这个问题。\nPPO RLHF（Reinforcement Learning from Human Feedback）的经典流程可以分成三步：\n用 SFT 得到一个初始策略模型 $\\pi_{\\mathrm{ref}}$。 收集同一 prompt 下多个回答的人类偏好排序，训练奖励模型 $r(x,y;\\boldsymbol{\\phi})$。 用 PPO 优化策略模型 $\\pi(\\cdot;\\boldsymbol{\\theta})$，让它获得更高奖励，同时不要偏离参考模型太远。 奖励模型 奖励模型的训练数据不是标准答案，而是偏好对：\n$$ (x,y_w,y_l) $$其中 $y_w$ 是人类更喜欢的回答，$y_l$ 是人类不太喜欢的回答。奖励模型给两个回答分别打分：\n$$ r(x,y_w;\\boldsymbol{\\phi}),\\quad r(x,y_l;\\boldsymbol{\\phi}) $$我们希望 $r(x,y_w;\\boldsymbol{\\phi})$ 比 $r(x,y_l;\\boldsymbol{\\phi})$ 更大。Bradley-Terry 模型把这个想法写成概率：\n$$ P(y_w \\succ y_l \\mid x) =\\sigma\\left(r(x,y_w;\\boldsymbol{\\phi})-r(x,y_l;\\boldsymbol{\\phi})\\right) $$对应的负对数似然损失为：\n$$ \\mathcal{L}_{\\mathrm{RM}}(\\boldsymbol{\\phi}) =-\\mathbb{E}_{(x,y_w,y_l)} \\left[ \\log\\sigma\\left(r(x,y_w;\\boldsymbol{\\phi})-r(x,y_l;\\boldsymbol{\\phi})\\right) \\right] $$这个式子的直觉很清楚：如果 chosen 的分数明显高于 rejected，Sigmoid 接近 1，loss 变小；如果 rejected 分数更高，loss 会变大。\n训练完成后，奖励模型通常被冻结，后续 PPO 把它当作奖励函数来使用。\nRLHF 的优化目标 在 RLHF 中，语言模型可以被看成一个策略：\n强化学习概念 LLM 中的对应物 状态 $s_t$ prompt 加上已经生成的 token，即 $(x,y_{\\lt t})$ 动作 $a_t$ 下一个 token $y_t$ 策略 $\\pi(a_t \\mid s_t;\\boldsymbol{\\theta})$ 当前模型的 token 概率分布 轨迹 $\\tau$ 一次完整生成 $(x,y)$ 奖励 $r(x,y;\\boldsymbol{\\phi})$ 奖励模型对完整回答的评分 给定 prompt $x$，语言模型自回归生成回答 $y=(y_1,\\dots,y_T)$，整段回答的概率为：\n$$ \\pi(y \\mid x;\\boldsymbol{\\theta}) =\\prod_{t=1}^{T}\\pi(y_t \\mid x,y_{\\lt t};\\boldsymbol{\\theta}) $$如果只追求奖励最大化，可以写成：\n$$ \\max_{\\boldsymbol{\\theta}} \\mathbb{E}_{y\\sim\\pi(\\cdot\\mid x;\\boldsymbol{\\theta})} \\left[ r(x,y;\\boldsymbol{\\phi}) \\right] $$但直接最大化奖励很危险。模型可能学会钻 Reward Model 的空子，生成高分但奇怪、重复、啰嗦或分布崩坏的回答，这就是 reward hacking。\n因此 RLHF 通常加入参考模型 KL 约束，在追求高奖励的同时，不让策略跑得太远：\n$$ \\max_{\\boldsymbol{\\theta}} \\mathbb{E}_{y\\sim\\pi(\\cdot\\mid x;\\boldsymbol{\\theta})} \\left[ r(x,y;\\boldsymbol{\\phi}) -\\beta D_{\\mathrm{KL}} \\left( \\pi(\\cdot\\mid x;\\boldsymbol{\\theta}) \\| \\pi_{\\mathrm{ref}}(\\cdot\\mid x) \\right) \\right] $$这个目标有两个方向：\n$r(x,y;\\boldsymbol{\\phi})$：鼓励模型生成奖励更高的回答。 $D_{\\mathrm{KL}}$：限制模型不要离 SFT 参考模型太远。 $\\beta$ 控制约束强度。它越大，模型越保守；它越小，模型越敢追求奖励。\n这就是 RLHF 想优化的目标。接下来的问题是：怎么求解它？PPO 给出的答案是 Actor-Critic + clipped objective。\nPPO 怎么优化这个目标 PPO 是 Actor-Critic 风格的方法。Actor 是当前策略模型 $\\pi(\\cdot;\\boldsymbol{\\theta})$，Critic 是 Value Model $v(s;\\mathbf{w})$。\n为什么需要 Value Model？ 直接对策略做 policy gradient 的问题是方差太大——同一个 token 在不同采样中可能拿到差别很大的奖励，导致梯度信号噪声很高。Value Model 的作用是提供一个 baseline：它估计当前状态的\u0026quot;平均水平\u0026quot;，让 Advantage 只关注\u0026quot;比平均好多少\u0026quot;，从而降低方差。\nValue Model 估计当前状态未来能拿到多少回报：\n$$ v(s_t;\\mathbf{w}) \\approx \\mathbb{E} \\left[ \\sum_{k=0}^{T-t}\\gamma^k r_{t+k} \\mid s_t \\right] $$有了 $v(s;\\mathbf{w})$，就可以计算 Advantage 的采样估计：\n$$ \\hat{A}_t=u_t-v(s_t;\\mathbf{w}) $$其中 $u_t$ 是从第 $t$ 步开始的实际回报。如果 $\\hat{A}_t\u003e0$，说明当前 token 比平均水平更好，应该提高它的概率；如果 $\\hat{A}_t\u003c0$，说明它低于预期，应该降低它的概率。实际 PPO 中常用 GAE（Generalized Advantage Estimation）通过多步 TD residual 的加权组合来进一步平滑 Advantage 估计，降低方差。\n有了 Advantage 估计，下一步是用它更新策略。但直接做 policy gradient（最大化 $\\rho_t(\\boldsymbol{\\theta})\\hat{A}_t$）可能一步更新太大，新策略跑偏。PPO 的做法是 clip：\n$$ L_t^{\\mathrm{PPO}}(\\boldsymbol{\\theta}) = \\min \\left( \\rho_t(\\boldsymbol{\\theta})\\hat{A}_t,\\; \\mathrm{clip}(\\rho_t(\\boldsymbol{\\theta}),1-\\epsilon,1+\\epsilon)\\hat{A}_t \\right) $$其中 $\\rho_t(\\boldsymbol{\\theta}) = \\frac{\\pi(y_t\\mid s_t;\\boldsymbol{\\theta})} {\\pi(y_t\\mid s_t;\\boldsymbol{\\theta}_{\\mathrm{old}})}$ 是新旧策略的重要性采样比率。\n分情况看会更容易理解：\n如果 $\\hat{A}_t\u003e0$，说明这个 token 比预期好，模型应该提高它的概率；但 $\\rho_t$ 超过 $1+\\epsilon$ 后，不再给额外收益。 如果 $\\hat{A}_t\u003c0$，说明这个 token 比预期差，模型应该降低它的概率；但 $\\rho_t$ 低于 $1-\\epsilon$ 后，也不再给额外收益。 这就是 PPO 里的 proximal：每次更新只允许策略在旧策略附近移动。\n最后还有一个工程问题：前面定义的 KL 约束目标是在整段回答上的，但 PPO 是逐 token 更新的。实际做法是把 KL 惩罚折算成 token-level reward：\n$$ r_t^{\\mathrm{KL}} =-\\beta \\left[ \\log\\pi(y_t\\mid x,y_{\\lt t};\\boldsymbol{\\theta}) -\\log\\pi_{\\mathrm{ref}}(y_t\\mid x,y_{\\lt t}) \\right] $$每个 token 承受 KL 惩罚；回答结束时，再把 Reward Model 对完整回答的分数加到最后一步：\n$$ r_t= \\begin{cases} r_t^{\\mathrm{KL}}, \u0026 t \\lt T \\\\ r(x,y;\\boldsymbol{\\phi})+r_t^{\\mathrm{KL}}, \u0026 t=T \\end{cases} $$把上面这些组件合在一起，PPO 的完整训练目标为：\n$$ \\mathcal{L}_{\\mathrm{PPO}}(\\boldsymbol{\\theta},\\mathbf{w}) = -\\frac{1}{T}\\sum_{t=1}^{T} L_t^{\\mathrm{PPO}}(\\boldsymbol{\\theta}) + c_1 \\mathcal{L}_{\\mathrm{VF}}(\\mathbf{w}) - c_2 H\\big(\\pi(\\cdot;\\boldsymbol{\\theta})\\big) $$其中：\n策略项 $-\\frac{1}{T}\\sum_{t} L_t^{\\mathrm{PPO}}(\\boldsymbol{\\theta})$：PPO-Clip 目标的均值，取负是因为优化器做最小化。 Value loss $\\mathcal{L}_{\\mathrm{VF}}(\\mathbf{w}) = \\left(v(s_t;\\mathbf{w}) - u_t\\right)^2$：让 Critic 的价值估计逼近实际回报。 熵正则 $H\\big(\\pi(\\cdot;\\boldsymbol{\\theta})\\big) = -\\sum_{a}\\pi(a\\mid s_t;\\boldsymbol{\\theta})\\log\\pi(a\\mid s_t;\\boldsymbol{\\theta})$：鼓励探索，防止策略过早坍缩。 $c_1$ 和 $c_2$ 分别是 Value loss 和熵正则的权重系数。KL 约束不放在 loss 里，而是通过 token-level reward 间接生效。\nFig. 2. PPO 过程\nDPO：直接偏好优化 PPO 的流程很完整，但也很重。它需要训练 Reward Model，需要在线采样，需要 Value Model，需要估计 Advantage，还要小心 reward hacking 和 KL 控制。\nDPO（Direct Preference Optimization）的出发点更直接：\n既然最终想让 chosen response 比 rejected response 更可能出现，为什么一定要显式训练 Reward Model，再跑一轮强化学习？\nDPO 直接使用偏好对 $(x,y_w,y_l)$ 优化策略模型，不再单独训练奖励模型，也不需要在线 RL 采样。\n从 KL 约束目标出发 先看 RLHF 中常见的 KL 约束优化目标：\n$$ \\max_{\\pi} \\mathbb{E}_{y\\sim\\pi(\\cdot\\mid x)} \\left[ r(x,y) -\\beta\\log\\frac{\\pi(y\\mid x)}{\\pi_{\\mathrm{ref}}(y\\mid x)} \\right] $$固定一个 prompt $x$，这个优化问题的最优策略可以写成：\n$$ \\pi^*(y\\mid x) = \\frac{1}{Z(x)} \\pi_{\\mathrm{ref}}(y\\mid x) \\exp\\left(\\frac{1}{\\beta}r(x,y)\\right) $$其中 $Z(x)$ 是归一化项：\n$$ Z(x) = \\sum_y \\pi_{\\mathrm{ref}}(y\\mid x) \\exp\\left(\\frac{1}{\\beta}r(x,y)\\right) $$这个式子说明：如果某个回答的奖励 $r(x,y)$ 更高，那么最优策略 $\\pi^*$ 就应该比参考模型更倾向于生成它。\n对最优策略公式取对数：\n$$ \\log\\pi^*(y\\mid x) = \\log\\pi_{\\mathrm{ref}}(y\\mid x) +\\frac{1}{\\beta}r(x,y) -\\log Z(x) $$整理后得到奖励函数的等价表示：\n$$ r(x,y) = \\beta \\left[ \\log\\frac{\\pi^*(y\\mid x)} {\\pi_{\\mathrm{ref}}(y\\mid x)} +\\log Z(x) \\right] $$这一步是 DPO 的关键。它告诉我们：奖励可以用“最优策略相对于参考模型的 log-prob 比值”表示出来。\n用当前策略替代最优策略 真实的 $\\pi^*$ 不可得。DPO 的做法是用当前要训练的策略 $\\pi(\\cdot;\\boldsymbol{\\theta})$ 替代它，从而得到隐式奖励：\n$$ r_{\\boldsymbol{\\theta}}(x,y) = \\beta \\left[ \\log\\frac{\\pi(y\\mid x;\\boldsymbol{\\theta})} {\\pi_{\\mathrm{ref}}(y\\mid x)} +\\log Z(x) \\right] $$偏好建模仍然采用 Bradley-Terry 形式：\n$$ P(y_w\\succ y_l\\mid x) = \\sigma \\left( r_{\\boldsymbol{\\theta}}(x,y_w)-r_{\\boldsymbol{\\theta}}(x,y_l) \\right) $$把 $r_{\\boldsymbol{\\theta}}$ 代入，两个回答对应同一个 prompt $x$，所以都有相同的 $\\log Z(x)$，差值中会被抵消：\n$$ \\begin{aligned} r_{\\boldsymbol{\\theta}}(x,y_w)-r_{\\boldsymbol{\\theta}}(x,y_l) \u0026= \\beta \\left[ \\log\\frac{\\pi(y_w\\mid x;\\boldsymbol{\\theta})} {\\pi_{\\mathrm{ref}}(y_w\\mid x)} +\\log Z(x) \\right] \\\\ \u0026\\quad - \\beta \\left[ \\log\\frac{\\pi(y_l\\mid x;\\boldsymbol{\\theta})} {\\pi_{\\mathrm{ref}}(y_l\\mid x)} +\\log Z(x) \\right] \\\\ \u0026= \\beta\\log\\frac{\\pi(y_w\\mid x;\\boldsymbol{\\theta})} {\\pi_{\\mathrm{ref}}(y_w\\mid x)} - \\beta\\log\\frac{\\pi(y_l\\mid x;\\boldsymbol{\\theta})} {\\pi_{\\mathrm{ref}}(y_l\\mid x)} \\end{aligned} $$于是 DPO 的损失函数为：\n$$ \\mathcal{L}_{\\mathrm{DPO}}(\\boldsymbol{\\theta}) = - \\mathbb{E}_{(x,y_w,y_l)} \\left[ \\log\\sigma \\left( \\beta\\log\\frac{\\pi(y_w\\mid x;\\boldsymbol{\\theta})} {\\pi_{\\mathrm{ref}}(y_w\\mid x)} - \\beta\\log\\frac{\\pi(y_l\\mid x;\\boldsymbol{\\theta})} {\\pi_{\\mathrm{ref}}(y_l\\mid x)} \\right) \\right] $$也可以写得更直观一点：\n$$ \\mathcal{L}_{\\mathrm{DPO}}(\\boldsymbol{\\theta}) = - \\log\\sigma \\left( \\beta \\left[ \\log\\pi(y_w\\mid x;\\boldsymbol{\\theta})-\\log\\pi_{\\mathrm{ref}}(y_w\\mid x) \\right] - \\beta \\left[ \\log\\pi(y_l\\mid x;\\boldsymbol{\\theta})-\\log\\pi_{\\mathrm{ref}}(y_l\\mid x) \\right] \\right) $$这个 loss 在做两件事：\n提高当前模型相对于参考模型在 $y_w$ 上的概率。 降低当前模型相对于参考模型在 $y_l$ 上的概率。 所以 DPO 不是简单模仿 chosen response，而是在优化 chosen 和 rejected 的相对偏好。\n$\\beta$ 控制策略偏离参考模型的强度。沿着前面的 KL 约束视角看，较大的 $\\beta$ 表示更强的 KL 约束，模型通常更保守、更接近参考模型；较小的 $\\beta$ 允许模型更大幅度地偏离参考模型。\nFig. 3. DPO 过程\nDPO 的优缺点 DPO 的优势很明显：\n不需要单独训练 Reward Model。 不需要在线采样和强化学习循环。 训练形式接近监督学习，实现简单，稳定性通常更好。 但它也有边界：\n强依赖偏好数据质量，chosen / rejected 标注有噪声时会直接影响模型。 它是离线方法，不像 PPO / GRPO 那样能让当前模型在线探索新回答。 对数学、代码、工具调用等可验证任务，如果奖励可以由环境或测试直接给出，在线 RL 方法可能更灵活。 GRPO GRPO（Group Relative Policy Optimization）更接近 PPO，而不是 DPO。它仍然让模型在线生成回答，再根据奖励更新策略。\n它对 PPO 的关键简化是：去掉 Critic / Value Model。\nPPO 用 $v(s_t;\\mathbf{w})$ 估计某个状态的平均回报，再用 $u_t-v(s_t;\\mathbf{w})$ 估计 Advantage。GRPO 不训练 Value Model，而是在同一个 prompt 下采样多条回答，用这组回答的相对好坏估计 Advantage。\n组内采样 对同一个 prompt $x$，从旧策略中采样 $G$ 条回答：\n$$ \\{y_1,y_2,\\dots,y_G\\} \\sim \\pi(\\cdot\\mid x;\\boldsymbol{\\theta}_{\\mathrm{old}}) $$然后用奖励模型或规则奖励函数打分：\n$$ r_i=r(x,y_i),\\quad i=1,\\dots,G $$如果某条回答的奖励高于组内平均，它就是相对好的回答；如果低于组内平均，它就是相对差的回答。\nGRPO 的组相对 Advantage 通常写成：\n$$ A_i= \\frac{ r_i-\\mathrm{mean}(r_1,\\dots,r_G) }{ \\mathrm{std}(r_1,\\dots,r_G) } $$其中 $A_i$ 是第 $i$ 条回答相对于同组回答的好坏。\n这个设计非常适合数学、代码、推理等任务。比如同一道题采样 8 个答案，有的正确、有的错误，GRPO 不需要为每个中间状态训练价值函数，只需要比较同组回答的最终奖励。\nGRPO 目标函数 GRPO 仍然保留 PPO-Clip 的思想。对第 $i$ 条回答中的第 $t$ 个 token，定义概率比：\n$$ \\rho_{i,t}(\\boldsymbol{\\theta}) = \\frac{ \\pi(y_{i,t}\\mid x,y_{i,\\lt t};\\boldsymbol{\\theta}) }{ \\pi(y_{i,t}\\mid x,y_{i,\\lt t};\\boldsymbol{\\theta}_{\\mathrm{old}}) } $$GRPO 的策略优化目标可以写成：\n$$ \\mathcal{L}_{\\mathrm{GRPO}}(\\boldsymbol{\\theta}) = - \\frac{1}{G} \\sum_{i=1}^{G} \\frac{1}{|y_i|} \\sum_{t=1}^{|y_i|} \\left[ \\min \\left( \\rho_{i,t}(\\boldsymbol{\\theta})A_i,\\; \\mathrm{clip}(\\rho_{i,t}(\\boldsymbol{\\theta}),1-\\epsilon,1+\\epsilon)A_i \\right) -\\beta D_{\\mathrm{KL}}\\big(\\pi(\\cdot \\mid x,y_{i,\\lt t};\\boldsymbol{\\theta})\\|\\pi_{\\mathrm{ref}}(\\cdot \\mid x,y_{i,\\lt t})\\big) \\right] $$它和 PPO 的核心差异在 Advantage：\nPPO 的 Advantage 估计来自 $u_t-v(s_t;\\mathbf{w})$，需要 Value Model。 GRPO 的 Advantage 来自同组回答的奖励归一化，不需要 Value Model。 Fig. 4. GRPO 过程\nGRPO 的优点是节省了 Value Model 的显存和计算开销，也减少了一部分训练复杂度。代价是它需要同一个 prompt 下采样多条回答；如果组内回答差异太小，或者奖励函数区分度不足，Advantage 估计就会变弱。\nGSPO GRPO 去掉了 Value Model，但仍然有一个粒度不一致的问题：\nreward 通常是 sequence-level 的，也就是对整段回答打分； Advantage $A_i$ 也是 sequence-level 的； 但概率比 $\\rho_{i,t}$ 和 clipping 仍然是 token-level 的。 这会导致一个现象：明明奖励评价的是整段回答，训练却在每个 token 上分别做 ratio 和 clip。某些 token 的概率波动可能放大梯度噪声，影响训练稳定性。\nGSPO（Group Sequence Policy Optimization）的核心改动是：把策略比率从 token 级改成 sequence 级。\n序列级重要性比 对第 $i$ 条回答 $y_i$，GSPO 定义 sequence-level ratio：\n$$ s_i(\\boldsymbol{\\theta}) = \\left( \\frac{\\pi(y_i\\mid x;\\boldsymbol{\\theta})} {\\pi(y_i\\mid x;\\boldsymbol{\\theta}_{\\mathrm{old}})} \\right)^{\\frac{1}{|y_i|}} $$由于语言模型是自回归的：\n$$ \\pi(y_i\\mid x;\\boldsymbol{\\theta}) = \\prod_{t=1}^{|y_i|} \\pi(y_{i,t}\\mid x,y_{i,\\lt t};\\boldsymbol{\\theta}) $$所以 $s_i(\\boldsymbol{\\theta})$ 可以写成：\n$$ s_i(\\boldsymbol{\\theta}) = \\exp \\left( \\frac{1}{|y_i|} \\sum_{t=1}^{|y_i|} \\log \\frac{ \\pi(y_{i,t}\\mid x,y_{i,\\lt t};\\boldsymbol{\\theta}) }{ \\pi(y_{i,t}\\mid x,y_{i,\\lt t};\\boldsymbol{\\theta}_{\\mathrm{old}}) } \\right) $$这里的 $\\frac{1}{|y_i|}$ 是长度归一化。它避免长回答因为 token 更多而导致 log-ratio 累积过大。\n直观地说：\nGRPO 看的是“每个 token 的概率变化”。 GSPO 看的是“整段回答平均意义上的概率变化”。 GSPO 目标函数 GSPO 保留组相对 Advantage $A_i$，但用 sequence-level ratio 做 clipping：\n$$ \\mathcal{J}_{\\mathrm{GSPO}}(\\boldsymbol{\\theta}) = \\frac{1}{G} \\sum_{i=1}^{G} \\min \\left( s_i(\\boldsymbol{\\theta})A_i,\\; \\mathrm{clip}(s_i(\\boldsymbol{\\theta}),1-\\epsilon,1+\\epsilon)A_i \\right) $$如果按最小化 loss 写，并加上 KL 正则防止策略偏离参考模型太远：\n$$ \\mathcal{L}_{\\mathrm{GSPO}}(\\boldsymbol{\\theta}) = - \\frac{1}{G} \\sum_{i=1}^{G} \\left[ \\min \\left( s_i(\\boldsymbol{\\theta})A_i,\\; \\mathrm{clip}(s_i(\\boldsymbol{\\theta}),1-\\epsilon,1+\\epsilon)A_i \\right) - \\beta D_{\\mathrm{KL}}\\big(\\pi(\\cdot \\mid x;\\boldsymbol{\\theta})\\|\\pi_{\\mathrm{ref}}(\\cdot \\mid x)\\big) \\right] $$当 $A_i\u003e0$ 时，说明这条回答比同组平均更好，GSPO 会提高整段回答的生成概率；当 $A_i\u003c0$ 时，说明这条回答较差，GSPO 会降低整段回答的生成概率。\nFig. 5. GSPO 过程\nGSPO 的价值在于让优化粒度和奖励粒度更一致。对于数学、代码、长链路推理这类整段回答打分的任务，这种 sequence-level 更新通常更符合奖励信号的形态。\n它的代价是牺牲了一部分 token-level 的细粒度控制。如果一条回答整体奖励不错，但内部某些 token 很差，GSPO 不会像 token-level 方法那样精细地区分每个 token。\n方法对比 方法 核心思想 数据来源 是否在线采样 是否需要奖励模型 / 奖励函数 是否需要 Value Model Advantage 计算 Ratio / Clip 粒度 PPO 先训练奖励模型，再用强化学习优化策略 prompt + 在线生成回答 需要 需要 需要 $\\hat{A}_t=u_t-v(s_t;\\mathbf{w})$，常配合 GAE token-level DPO 直接用偏好对优化 chosen / rejected 的相对概率 prompt + chosen + rejected 不需要 不需要显式奖励模型 不需要 不显式计算 Advantage sequence-level 偏好对 GRPO 去掉 Critic，用同组回答的相对奖励估计 Advantage prompt + 在线生成多条回答 需要 需要奖励模型或规则奖励 不需要 $A_i=\\frac{r_i-\\mathrm{mean}(r)}{\\mathrm{std}(r)}$ token-level GSPO 在 GRPO 基础上，把 ratio 改成 sequence-level prompt + 在线生成多条回答 需要 需要奖励模型或规则奖励 不需要 组相对 Advantage sequence-level 从工程角度看：\n方法 训练成本 工程复杂度 适合场景 不太适合的场景 PPO 最高 最高 通用 RLHF、安全对齐、多目标偏好优化 资源有限、快速迭代、小模型实验 DPO 最低 最低 偏好对齐、回答风格优化、低成本 post-training 需要在线探索、环境交互或可验证奖励的任务 GRPO 中高 中等 数学、代码、推理等可验证奖励任务 奖励函数弱、组内样本差异小的任务 GSPO 中高 中等偏高 序列级奖励、长链路推理、大规模 RL 训练 需要 token 级精细控制的任务 分布式训练 为什么需要分布式训练 训练大模型最直接的瓶颈不是「能不能把数据读进去」，而是单张 GPU 同时放不下训练过程中必须常驻或临时保留的状态。推理时主要需要模型参数和当前生成所需的 KV cache；训练时还要保存前向传播的中间激活、反向传播产生的梯度，以及优化器为了更新参数而维护的额外状态。因此，一个看起来只是「30B 参数」的模型，全参数训练时显存开销会远大于权重文件本身。\n以 30B 参数、FP16 权重、AdamW 全参数训练为例，如果每个参数本体用 FP16 保存，权重约占 $30\\times10^9\\times2\\text{B}\\approx60\\text{GB}$；反向传播后还要有一份 FP16 梯度，约 60GB；AdamW 会为每个参数维护 fp32 的一阶、二阶动量，共约 240GB；混合精度训练里还经常保留 fp32 master weights，约 120GB。仅模型状态就接近：\n$$ \\text{Memory}_{\\text{model state}} \\approx N_{\\text{param}} \\times (2 + 2\\text{-}4 + 8 + 0\\text{-}4)\\ \\text{bytes} $$在这个例子里，未做 ZeRO/FSDP 切分时，仅这些状态就约为 $60+60+240+120=480\\text{GB}$。再加上前向传播保存的 activations、attention 临时 buffer、通信 buffer、算子 workspace，训练时需要的显存会继续上升。一个简化的显存构成如下：\n显存来源 估算方式 30B FP16 示例 大致占比 说明 参数 weights $30B \\times 2\\text{B}$ 60GB 约 12% FP16/bf16 参数本体，前向、反向、更新都要访问 梯度 gradients $30B \\times 2\\text{B}$ 60GB 约 12% backward 后产生，通常与参数量同阶 AdamW 优化器状态 $30B \\times 8\\text{B}$ 240GB 约 47% 一阶动量 $m$ 和二阶动量 $v$ 通常是 fp32，各 4 bytes FP32 master weights $30B \\times 4\\text{B}$ 120GB 约 24% 混合精度训练中常见，用于更稳定地更新参数 激活 activations 与 batch size、sequence length、hidden size、layer 数相关 约几十 GB 约 5%+ 前向时保存，反向时用于链式求导；checkpointing 可以用重算换显存 Attention/KV、通信 buffer、算子 workspace 与序列长度、并行策略、kernel 实现相关 不固定 不固定 长上下文 attention、all-reduce/all-gather、FlashAttention 或 fused kernel 都可能产生临时显存 当前 batch 数据 token ids、labels、attention mask 通常远小于 1GB 约 0% 数据集本体通常在磁盘或 CPU 内存中，GPU 只接收当前 micro-batch RLHF/GRPO/PPO 额外开销 policy/reference/reward/critic、rollout 样本、logprobs 视算法而定 视算法而定 在线 RL 训练可能同时保留多个模型或中间结果，比 SFT 更吃显存和吞吐 因此，分布式训练的核心目标不是简单地「多几张卡就更快」，而是把这些显存和计算压力拆开：数据并行把 batch 分到多张卡上提升吞吐；张量并行把单层矩阵乘法切开；流水线并行把不同层放到不同 GPU；ZeRO/FSDP 则把参数、梯度、优化器状态切片存储，避免每张卡都保存完整副本。对于大模型 post-training，尤其是 PPO/GRPO/GSPO 这类需要在线生成、评分、再更新的流程，分布式训练几乎是让实验能跑起来的前提。\n数据并行 DDP 最朴素的数据并行（DP / DataParallel）思路是：把一个 batch 切到多张 GPU 上，每张卡各自前向和反向，最后再把结果汇总。但这种做法通常由单个进程调度，多卡之间的输入分发、输出收集和梯度聚合容易集中到 GPU_0 上，导致主卡显存更高、通信和 Python 调度开销更明显。假设模型参数 / 梯度大小为 $\\Psi$，GPU 数为 $N$，DP 中 GPU_0 大致需要接收 $(N-1)\\Psi$ 的梯度，再向其他 GPU 同步 $(N-1)\\Psi$ 的参数或梯度结果；其他 GPU 通常只传出约 $\\Psi$ 梯度、传入约 $\\Psi$ 参数。因此 DP 的通信和负载集中在主卡上，卡数一多，GPU 利用率反而不稳定。\nDDP（Distributed Data Parallel）把每张 GPU 放在独立进程里，每个进程持有一份模型副本、处理自己的 mini-batch，并在反向传播时通过 all-reduce 同步梯度。这样通信可以和 backward 部分重叠，主卡瓶颈也会小很多，所以实际训练中单机多卡和多机多卡一般优先使用 DDP，而不是 DP。\nFig. 6. DDP 中常见的 Ring-AllReduce：先通过 Scatter-Reduce 让每个梯度分块在环上累加，再通过 All-Gather 把累加后的分块广播给所有 GPU。\nRing-AllReduce 可以理解为把完整梯度切成多个 chunk，例如 $A,B,C$。在 Scatter-Reduce 阶段，每张 GPU 每一轮只把一个 chunk 发给右邻居、从左邻居接收一个 chunk，并把收到的 chunk 累加到本地对应分块上；经过 $N-1$ 轮后，每个 chunk 都会在某一张 GPU 上得到全局求和结果 $\\sum_i g_i[\\text{chunk}]$。接着进入 All-Gather 阶段，这些已经求和的 chunk 继续沿环传递，再经过 $N-1$ 轮，所有 GPU 都拿到完整的全局梯度。最后通常除以 world_size 得到平均梯度，每张卡再用相同的梯度执行 optimizer step，所以各卡模型参数保持一致。\n从通信量看，Ring-AllReduce 会把大小为 $\\Psi$ 的梯度切成 $N$ 份，每份大小为 $\\Psi/N$。对每张 GPU 来说，Scatter-Reduce 阶段需要传入 / 传出 $(N-1)\\Psi/N \\approx \\Psi$，All-Gather 阶段也需要传入 / 传出 $(N-1)\\Psi/N \\approx \\Psi$，所以总传入 / 传出约为 $2(N-1)\\Psi/N \\approx 2\\Psi$。这个通信量基本不随 GPU 数线性增长，也没有 DP 那种 GPU_0 集中瓶颈。\nZeRO DDP 解决了 DP 的主卡瓶颈，但它仍然有一个明显问题：每张 GPU 都保存完整的参数、梯度和优化器状态。ZeRO（Zero Redundancy Optimizer）的思路是把这些“每张卡重复存一份”的模型状态切开，让数据并行不只并行计算，也并行存储。\nZeRO 的三个阶段是累积关系：\nZeRO-1：切分 optimizer states。 ZeRO-2：在 ZeRO-1 基础上继续切分 gradients。 ZeRO-3：在 ZeRO-2 基础上继续切分 parameters。 Fig. 7. ZeRO 三个阶段对参数、梯度和优化器状态的切分方式。Image source: Microsoft Research, ZeRO \u0026amp; DeepSpeed, 2020.\n沿用前面 30B 参数、FP16 权重、AdamW 的估算：参数 60GB，梯度 60GB，AdamW 状态 240GB，FP32 master weights 120GB。若数据并行度为 $N$，只看模型状态，不计 activation 和临时 buffer，则各阶段单卡显存大致为：\n方式 单卡模型状态显存 $N=8$ $N=64$ 主要代价 DDP $P+G+O+M$ 480GB 480GB 每卡完整复制，显存不随卡数下降 ZeRO-1 $P+G+\\frac{O+M}{N}$ 165GB 125.6GB optimizer step 前后需要同步参数更新结果 ZeRO-2 $P+\\frac{G+O+M}{N}$ 112.5GB 66.6GB 梯度用 reduce-scatter / all-gather 形式通信 ZeRO-3 $\\frac{P+G+O+M}{N}$ 60GB 7.5GB 前向和反向时需要按层 all-gather 参数 这个表里最值得注意的是：ZeRO-1 对 AdamW 这类优化器很有效，因为优化器状态本来就是最大头；ZeRO-3 的显存下降最彻底，但它把参数也切开了，计算到某一层时必须临时把这一层参数聚合出来，所以通信调度会更复杂。\nZeRO-1 ZeRO-1 只切分优化器状态。每张 GPU 仍然持有完整参数 $P$ 和完整梯度 $G$，但 AdamW 的一阶动量 $m$、二阶动量 $v$，以及常见的 FP32 master weights 会按数据并行 rank 分片保存。\n如果用普通 DDP，每张卡都要保存完整的 $O+M=360\\text{GB}$ 优化器相关状态；ZeRO-1 在 8 卡时把这部分降到 $360/8=45\\text{GB}$，单卡模型状态从 480GB 降到约 165GB。这个收益已经很明显，但它仍然保留完整参数和完整梯度，所以对于更大的模型，只靠 ZeRO-1 往往不够。\n工程上，ZeRO-1 的侵入性相对小，通信量也接近 DDP。它适合“模型参数本身还能放下，但 AdamW 状态放不下”的场景，比如中等规模全参数 SFT 或 DPO。\nZeRO-2 ZeRO-2 在 ZeRO-1 的基础上继续切分梯度。反向传播过程中，各卡不再长期保存完整梯度，而是通过 reduce-scatter 把梯度归约到对应分片上：哪个 rank 负责哪部分参数的 optimizer state，它就只保留那部分参数的梯度。\n这一步把 $G$ 也从每卡完整复制变成 $\\frac{G}{N}$。在 30B、8 卡的例子里，单卡模型状态约为：\n$$ P+\\frac{G+O+M}{N}=60+\\frac{60+240+120}{8}=112.5\\text{GB} $$ZeRO-2 的特点是性价比很高：它比 ZeRO-1 多省一份梯度显存，但参数仍然完整保存在每张卡上，因此前向和反向的参数访问方式比较简单。很多 RLHF / GRPO 训练如果还要同时放 reference model、reward model 或 rollout engine，会优先尝试 ZeRO-2，再配合 activation checkpointing、bf16 和更小的 micro-batch。\nZeRO-3 ZeRO-3 把参数也切分了。每张 GPU 只保存 $\\frac{1}{N}$ 的参数、梯度和优化器状态；执行某一层前向或反向时，再临时 all-gather 出当前层需要的完整参数，用完后释放或重新分片。\nFig. 8. ZeRO-3 的一次训练流程：模型状态长期按 rank 分片保存，前向 / 反向时临时聚合当前计算所需参数，反向后再把梯度归约并重新切回各自负责的分片。\n因此 ZeRO-3 的显存下降几乎随数据并行度线性缩放。30B 模型在 64 卡下，模型状态从 DDP 的 480GB/卡降到约 7.5GB/卡，这才让“单卡完全放不下参数本体”的训练变得可行。代价是通信更多，尤其是 layer-wise parameter all-gather 会进入训练主路径；如果网络带宽弱、batch 太小或 overlap 做得不好，吞吐可能明显下降。\n直观选择可以按下面的规则：\n模型能放下，主要是 optimizer state 太大：优先 ZeRO-1。 参数能放下，但梯度和优化器状态太大：优先 ZeRO-2。 参数本身也放不下，或者还要叠加 RLHF 多模型开销：考虑 ZeRO-3 / FSDP。 所以 ZeRO 的本质不是“让训练一定更快”，而是用通信换显存。对于 post-training，显存省下来以后可以换成更大的模型、更长的 sequence、更大的 rollout batch，或者同时容纳 Actor / Reference / Critic / Reward Model，这通常比单纯追求单步吞吐更重要。\nFSDP FSDP（Fully Sharded Data Parallel）可以理解为 PyTorch 原生的 ZeRO-3 风格数据并行。它同样把 parameters、gradients、optimizer states 切到不同 rank 上，计算时再把当前 FSDP unit 需要的参数临时 all-gather 出来；反向传播结束后，通过 reduce-scatter 让每张卡只保留自己负责的梯度分片，optimizer step 也只更新本地参数分片。\nFig. 9. FSDP 的前向 / 反向流程：每个 FSDP unit 在计算前 all-gather 参数，计算后释放完整参数，反向时再 reduce-scatter 梯度。Image source: PyTorch Tutorials, Getting Started with FSDP2.\nFSDP 和 ZeRO-3 的直觉非常接近，但工程入口不同：ZeRO 通常出现在 DeepSpeed 训练栈里，通过 optimizer/runtime 配置打开不同 stage；FSDP 则是 PyTorch torch.distributed 里的模块级 sharding API，通常按 transformer block 或更大的 module 粒度包起来。包得太粗，每次 all-gather 的完整参数更大，峰值显存更高；包得太细，通信次数更多，调度开销也会上升，所以实际训练里常按 layer/block 做 auto-wrap 或手动 sharding。\nFSDP1 早期 PyTorch FSDP 一般指 FullyShardedDataParallel 这个 wrapper API。它会把一个 FSDP unit 里的多个参数 flatten 成 FlatParameter，这样通信 bucket 更规整，也方便在 eager mode 下做 all-gather / reduce-scatter overlap。\n问题也来自 flatten：多个原始参数被拼成一个大参数以后，参数级别的行为会变复杂，比如只冻结其中一部分参数、给不同参数用不同 dtype / optimizer 规则、保存和加载 sharded state dict 等，都需要额外处理。因此 FSDP1 在已有代码里仍然常见，但新项目里不一定是最顺手的默认选项。\nFSDP2 FSDP2 是 PyTorch 新一代 FSDP API，核心入口从 wrapper 变成了 composable 的 fully_shard(module)。它通常从内到外使用：先对每个 transformer layer 调用 fully_shard，最后再对 root model 调用一次，让非 layer 参数也进入 sharding 体系。\n更关键的变化是，FSDP2 用 DTensor 表示被切分的参数，并通过 DeviceMesh 描述设备拓扑。这样做的好处是参数仍然更像“原来的参数”，而不是被揉进一个 FlatParameter 里；state dict、meta-device 初始化、冻结部分参数、以及和 TP / PP / CP 这类并行方式组合时，抽象会更清楚。PyTorch 当前教程也把 FSDP1 标为 deprecated，新代码一般优先看 FSDP2。\n维度 FSDP1 FSDP2 API 形态 FullyShardedDataParallel(module) wrapper fully_shard(module) composable API 参数表示 多个参数 flatten 成 FlatParameter sharded parameter 以 DTensor 表示 设备拓扑 主要围绕 process group 配置 通过 DeviceMesh 更自然地表达 1D/2D/多维并行 组合能力 可用，但和 TP、checkpoint、参数冻结等组合时更容易遇到边角复杂度 更适合和 TP、DCP、meta init、混合精度策略组合 新项目倾向 维护老代码时常见 新 PyTorch 分布式训练优先考虑 因此选择时可以这样理解：如果训练栈已经围绕 DeepSpeed、HuggingFace Trainer 或 verl 的 ZeRO 配置组织，继续用 ZeRO 会更直接；如果希望尽量留在 PyTorch 原生 torch.distributed 体系里，并且后面可能叠加 tensor parallel、distributed checkpoint 或 TorchTitan 风格的训练代码，FSDP2 会更贴近新的工程方向。\n模型并行 Tensor Parallel Tensor Parallel（TP，张量并行）切的是「层内部的矩阵」，不是 batch，也不是完整的模型状态副本。对于一个很宽的 Transformer layer，单个 linear / attention projection 的权重矩阵本身就可能很大；TP 会把这些矩阵沿输入维或输出维切到多个 GPU 上，让每张卡只做这一层的一部分 GEMM。\nFig. 10. Megatron-LM 风格的 Tensor Parallel：MLP 中第一层 linear 做 column parallel，第二层 linear 做 row parallel；Self-Attention 中 Q/K/V 和 attention heads 可以按 head 分到不同 GPU。Image source: PyTorch Tutorials, Large Scale Transformer model training with Tensor Parallel, adapted from Megatron-LM.\n以两层 MLP 为例，第一层权重 $A$ 可以按输出维切成 $A=[A_1,A_2,\\dots,A_T]$，每张 GPU 计算自己的 $XA_i$，中间的 GeLU 也可以各自独立做；第二层权重 $B$ 再按输入维切成 $\\begin{bmatrix}B_1;B_2;\\dots;B_T\\end{bmatrix}$，每张卡计算 $Y_iB_i$，最后用一次 all-reduce 把各卡部分结果加起来。这样两层 linear 中间不需要先把完整 activation 拼出来，通信点被压到 block 边界附近。\nSelf-Attention 更适合 TP：multi-head attention 的不同 head 本来就是相对独立的。Q/K/V projection 可以按 head 或 hidden 维度切开，每张卡负责一部分 head 的 attention 计算；attention output projection 再用 row parallel，把各卡结果归约回完整 hidden 表示。Megatron-LM 的设计重点就在这里：尽量让大矩阵乘法本地完成，只在必要位置做少量 collective。\n和 ZeRO/FSDP 相比，TP 的侧重点不同：\n方式 切分对象 主要节省 主要通信 更适合 ZeRO/FSDP 参数、梯度、优化器状态这些 model states 训练状态显存 参数 all-gather、梯度 reduce-scatter 模型状态太大、需要扩大数据并行规模 Tensor Parallel 单层内部的权重矩阵和 activation 单层权重、activation、GEMM 计算量 layer 内 all-reduce / all-gather / reduce-scatter hidden size 很大、单层矩阵本身放不下或算不动 TP 的代价是通信更贴近每个 Transformer block 的主路径。DDP/ZeRO 的通信通常围绕梯度同步或参数分片展开，而 TP 在每一层的 attention / MLP 里都可能有 collective，所以它非常依赖 GPU 间带宽和拓扑。实践中 TP 通常优先放在单机 NVLink / NVSwitch 内做，例如 2、4、8 路 TP；跨节点继续增大 TP degree 往往会被网络延迟拖住。\nTP 还有两个工程限制：第一，hidden size、attention heads、vocab size 往往需要能被 tensor_model_parallel_size 整除，否则要 padding 或改切分策略；第二，TP degree 太大以后，每张卡上的 GEMM 变小，kernel 利用率和 CPU 调度开销都会变差。因此 TP 不是越大越好，常见做法是把 TP 控制在节点内部，再和 DP / ZeRO / FSDP、Pipeline Parallel 组合起来：\n$$ \\text{world size}=\\text{DP}\\times\\text{TP}\\times\\text{PP}\\times\\text{CP} $$比如 64 张 GPU 可以设成 TP=4、PP=2、DP=8：TP 负责把单层宽矩阵切开，PP 负责把层深度切开，DP/FSDP 负责处理 batch 和模型状态。对 post-training 来说，如果只是 optimizer state 太大，优先考虑 ZeRO/FSDP；如果单个 attention/MLP block 已经成为显存或计算瓶颈，再引入 TP 会更合理。\nPipeline Parallel Pipeline Parallel（PP，流水线并行）切的是「模型深度」。它把连续的 transformer layers 分成多个 stage，例如 48 层模型切成 4 段，每张 GPU 或每组 GPU 负责 12 层。前向时 activation 从前一个 stage 传到后一个 stage，反向时 activation gradient 再反向传回来；每个 stage 只保存自己那段层的参数、梯度、优化器状态和激活。\n如果直接把一个 batch 串行跑过所有 stage，GPU 利用率会很差：第 1 个 stage 算完后才能把结果交给第 2 个 stage，第 2 个 stage 工作时第 1 个 stage 又闲着。因此 PP 通常会把 batch 再切成多个 micro-batches，让不同 micro-batch 同时处在不同 stage 上，就像生产线一样填满各段计算。\nFig. 11. Pipeline Parallel 的 1F1B 和 interleaved 1F1B schedule：蓝色是 forward，绿色是 backward，灰色是 pipeline bubble。Interleaving 让每张设备持有多个 model chunks，可以缩短 bubble，但会增加 stage 边界通信。Image source: NVIDIA Technical Blog, Scaling Language Model Training to a Trillion Parameters Using Megatron.\n最简单的 GPipe schedule 是先让所有 micro-batches 做完 forward，再统一做 backward。它容易理解，但 activation 会一直保存到对应 backward 才能释放，所以显存压力随 micro-batch 数 $m$ 上升。1F1B（one-forward-one-backward）则在 warm-up 之后交替执行 forward 和 backward：某个 stage 只要有 backward 可以做，就尽快反传并释放 activation，因此峰值 activation 显存通常低很多。\nPP 的核心代价是 pipeline bubble。设 pipeline stage 数为 $p$，micro-batch 数为 $m$，不考虑 interleaving 时，开头填充流水线和结尾排空流水线都会产生空闲时间，bubble fraction 可以粗略写成：\n$$ \\text{bubble fraction}\\approx\\frac{p-1}{m} $$所以 PP 想跑得满，$m$ 通常要明显大于 $p$。但 micro-batch 也不能无限加：micro-batch 太小会让单次 GEMM 变小、kernel 利用率下降；micro-batch 太多又会增加调度开销，并可能影响 optimizer step 的 batch 语义。实际训练里常用 gradient accumulation 把多个 micro-batches 聚成一个 global batch，再在 pipeline flush 后统一更新参数。\nInterleaved 1F1B 进一步把每张设备上的连续层切成多个 virtual pipeline stages / model chunks。例如原来每张 GPU 负责 8 层，可以拆成两个 4 层 chunk，按交错顺序安排在 pipeline 中。这样等价于让流水线更细，bubble 大致可以再除以 chunk 数 $v$：\n$$ \\text{bubble time}\\approx\\frac{(p-1)(t_f+t_b)}{v} $$代价是 stage 边界变多，activation 和 activation gradient 的点对点通信也变多。因此 PP 常被放在跨节点维度：TP 适合节点内高带宽互联，PP 的通信只发生在相邻 stage 之间，消息是 activation 而不是整层参数 all-reduce，更容易跨节点扩展。\n和 TP 的区别可以这样看：\n方式 切分维度 通信位置 主要收益 主要风险 Tensor Parallel 层内部宽度 每个 Transformer block 内部 collective 解决单层矩阵太宽、单层计算太重 TP degree 过大导致小 GEMM 和频繁通信 Pipeline Parallel 层深度 相邻 stage 之间 send / recv activation 解决模型太深、整模型放不下，适合跨节点扩展 pipeline bubble、stage 负载不均、micro-batch 调参复杂 因此，PP 更适合「层数很多」或「单机放不下完整模型深度」的场景。对 LLM 训练常见的组合是 TP 放在节点内、PP 跨节点切层、DP/FSDP/ZeRO 在最外层扩 batch 和切模型状态。对 post-training 来说，如果 rollout batch 不大、sequence 很长，PP 的 bubble 可能更难摊薄；如果训练的是很深的大模型，并且已经有足够的 micro-batches 或 gradient accumulation，PP 才更容易体现收益。\nLlamaFactory LLaMA-Factory 更适合做 SFT、LoRA、QLoRA、DPO 这类离线微调和快速实验。它对 Qwen 等常见模型、HuggingFace / ModelScope 数据集、本地数据集、LoRA 合并与量化、Web UI 和命令行训练流程支持完整，工程门槛低，适合单机或中小规模集群上快速验证数据、模板、超参和微调方法。\nSFT 简单用 4090 上手验证一下 SFT (LoRA)，通过 webui 配置训练参数得到命令行训练。训练数据是 Alpaca 格式，不管是 Alpaca 还是 ShareGPT，LF 都会转换为一个统一的格式，然后 --template qwen 转换为 Qwen Chat Template。\nLoRA 设置为 --lora_target all，表示对模型中的所有线性层应用 LoRA。rank 越大，可训练参数越多，表达能力也更强；常用 rank 为 $rank \\in [4,8,16,32]$，太大边际收益小。lora_alpha 是 LoRA 的缩放系数，常见经验是设为 rank 的 2 倍，表示 LoRA 分支输出会按 2 倍系数缩放后再叠加到原始权重上。\n参数 说明 \u0026ndash;stage sft 训练阶段是监督微调，让模型学习指令数据里的回答方式。 \u0026ndash;finetuning_type lora 使用 LoRA 做参数高效微调，只训练低秩适配器，不更新全部模型参数。 \u0026ndash;model_name_or_path Qwen2.5-0.5B-Instruct 基座模型路径，这里用本地的 Qwen2.5-0.5B-Instruct。 \u0026ndash;template qwen 对话模板，需要和 Qwen 系列模型的 chat template 对齐。 \u0026ndash;dataset_dir cvalues-sft / \u0026ndash;dataset cvalue 数据集目录和数据集名称，对应 LLaMA-Factory 的数据集配置。 \u0026ndash;cutoff_len 2048 单条样本的最大 token 长度，超过后会截断。 \u0026ndash;per_device_train_batch_size 8 单卡 micro batch size 稍微设大一点，目的是尽量吃满 GPU，提高训练吞吐；前提是显存能放下。 \u0026ndash;gradient_accumulation_steps 2 梯度累积步数设小一点，每 2 个 micro batch 更新一次；单卡等效为 8 * 2 = 16 个样本更新一次。 \u0026ndash;learning_rate 5e-05 LoRA 参数的学习率，通常可以比全参微调更大。 \u0026ndash;num_train_epochs 3.0 训练 3 个 epoch。 \u0026ndash;lr_scheduler_type cosine / \u0026ndash;warmup_ratio 0.03 前 3% 训练步数做 warmup，让学习率从小到大逐步升高，避免训练初期不稳定；之后用 cosine 衰减，让后期更新更平滑。 \u0026ndash;bf16 True 使用 bf16 训练，适合支持 bf16 的 GPU。 \u0026ndash;flash_attn auto 自动启用可用的 FlashAttention，降低显存并提升长序列吞吐。 \u0026ndash;lora_rank 8 / \u0026ndash;lora_alpha 16 / \u0026ndash;lora_dropout 0 LoRA 的秩、缩放系数和 dropout。 \u0026ndash;lora_target all 尽量对模型中的可训练线性层都插入 LoRA adapter。 llamafactory-cli train \\ --stage sft \\ --do_train True \\ --model_name_or_path /public/home/test120/models-datasets/models/Qwen2.5-0.5B-Instruct \\ --preprocessing_num_workers 16 \\ --finetuning_type lora \\ --template qwen \\ --flash_attn auto \\ --dataset_dir /public/home/test120/models-datasets/datasets/cvalues-sft \\ --dataset cvalue \\ --cutoff_len 2048 \\ --learning_rate 5e-05 \\ --num_train_epochs 3.0 \\ --max_samples 100000 \\ --seed 42 \\ --per_device_train_batch_size 8 \\ --gradient_accumulation_steps 2 \\ --lr_scheduler_type cosine \\ --max_grad_norm 1.0 \\ --logging_steps 5 \\ --save_steps 500 \\ --warmup_ratio 0.03 \\ --packing False \\ --enable_thinking False \\ --report_to none \\ --output_dir /public/home/test120/Dev/SFT-cvalue-LF/train_logs \\ --bf16 True \\ --plot_loss True \\ --trust_remote_code True \\ --ddp_timeout 180000000 \\ --include_num_input_tokens_seen True \\ --optim adamw_torch \\ --lora_rank 8 \\ --lora_alpha 16 \\ --lora_dropout 0 \\ --lora_target all Fig. 12. SFT 过程中的 train_loss。\nVeRL veRL 更适合做 PPO、GRPO、GSPO 等 RLHF / RLVR 强化学习后训练，尤其是多机多卡的大规模训练。虽然 LLaMA-Factory 也支持 PPO、奖励模型训练等 RLHF 流程，但在线强化学习通常要高吞吐生成多条回答、调用奖励模型或规则奖励打分，并在 actor、reference model、reward model、rollout engine 和 trainer 之间协调资源；veRL 对这类分布式 RL 训练流程更偏工程化，能更自然地接入 FSDP / Megatron-LM、vLLM / SGLang 等训练和推理基础设施。\nPPO 快速入门：在 GSM8K 数据集上进行 PPO 训练\n入门级别的 PPO 训练教程，数据集 OpenAI/GSM8K，模型 Qwen2.5-0.5B-Instruct。\nHuggingFace 原始 GSM8K 的 parquet 不能直接用，还要把原始 GSM8K 的字段结构变成 verl 训练需要的字段结构：\n它包含计算 RL 奖励所需的字段 读取速度更快 python3 examples/data_preprocess/gsm8k.py --local_save_dir ~/data/gsm8k ------------ 原始 ------------ { \u0026#34;question\u0026#34;: \u0026#34;Natalia sold clips to 48 of her friends in April, and then she sold half as many clips in May. How many clips did Natalia sell altogether in April and May?\u0026#34;, \u0026#34;answer\u0026#34;: \u0026#34;Natalia sold 48/2 = \u0026lt;\u0026lt;48/2=24\u0026gt;\u0026gt;24 clips in May.\\nNatalia sold 48+24 = \u0026lt;\u0026lt;48+24=72\u0026gt;\u0026gt;72 clips altogether in April and May.\\n#### 72\u0026#34; } ------------ VeRL ------------ { \u0026#34;data_source\u0026#34;: \u0026#34;openai/gsm8k\u0026#34;, \u0026#34;prompt\u0026#34;: [ { \u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: \u0026#34;Natalia sold clips to 48 of her friends in April, and then she sold half as many clips in May. How many clips did Natalia sell altogether in April and May? Let\u0026#39;s think step by step and output the final answer after \\\u0026#34;####\\\u0026#34;.\u0026#34; } ], \u0026#34;ability\u0026#34;: \u0026#34;math\u0026#34;, \u0026#34;reward_model\u0026#34;: { \u0026#34;style\u0026#34;: \u0026#34;rule\u0026#34;, \u0026#34;ground_truth\u0026#34;: \u0026#34;72\u0026#34; }, \u0026#34;extra_info\u0026#34;: { \u0026#34;split\u0026#34;: \u0026#34;train\u0026#34;, \u0026#34;index\u0026#34;: 0, \u0026#34;answer\u0026#34;: \u0026#34;Natalia sold 48/2 = \u0026lt;\u0026lt;48/2=24\u0026gt;\u0026gt;24 clips in May.\\nNatalia sold 48+24 = \u0026lt;\u0026lt;48+24=72\u0026gt;\u0026gt;72 clips altogether in April and May.\\n#### 72\u0026#34;, \u0026#34;question\u0026#34;: \u0026#34;Natalia sold clips to 48 of her friends in April, and then she sold half as many clips in May. How many clips did Natalia sell altogether in April and May?\u0026#34; } } 在这次实验中，Qwen2.5-0.5B 有这些职责：\nActor，训练，PPO 优化目标 Rollout，不训练，其实和 Actor 权重一样，就是 Actor 的 copy Reference，冻结，参考模型 Critic，训练，proj head Qwen2.5-0.5B │ ┌───────────┼───────────┐ ↓ ↓ ↓ Actor Reference Critic 训练 冻结 训练 │ ↓ Rollout vLLM生成回答 最终的的 VeRL 训练命令行，一些关键的参数：\ntrain_batch_size=128，$\\pi_{old}$ 一次 rollout 的数量，如果小的话方差不稳定 ppo_mini_batch_size=64，64 条轨迹更新一次参数 ppo_micro_batch_size_per_gpu=4，gpu 一次处理 4 条轨迹 PYTHONUNBUFFERED=1 python3 -m verl.trainer.main_ppo \\ data.train_files=$HOME/data/gsm8k/train.parquet \\ data.val_files=$HOME/data/gsm8k/test.parquet \\ data.train_batch_size=256 \\ data.max_prompt_length=512 \\ data.max_response_length=512 \\ actor_rollout_ref.model.path=Qwen/Qwen2.5-0.5B-Instruct \\ actor_rollout_ref.actor.optim.lr=1e-6 \\ actor_rollout_ref.actor.ppo_mini_batch_size=64 \\ actor_rollout_ref.actor.ppo_micro_batch_size_per_gpu=4 \\ actor_rollout_ref.rollout.name=vllm \\ actor_rollout_ref.rollout.log_prob_micro_batch_size_per_gpu=8 \\ actor_rollout_ref.rollout.tensor_model_parallel_size=1 \\ actor_rollout_ref.rollout.gpu_memory_utilization=0.4 \\ actor_rollout_ref.ref.log_prob_micro_batch_size_per_gpu=4 \\ critic.optim.lr=1e-5 \\ critic.model.path=Qwen/Qwen2.5-0.5B-Instruct \\ critic.ppo_micro_batch_size_per_gpu=4 \\ algorithm.kl_ctrl.kl_coef=0.001 \\ trainer.logger=console \\ trainer.val_before_train=False \\ trainer.n_gpus_per_node=1 \\ trainer.nnodes=1 \\ trainer.save_freq=10 \\ trainer.test_freq=10 \\ trainer.total_epochs=15 2\u0026gt;\u0026amp;1 | tee verl_demo.log 下面这组 TensorBoard 曲线来自一次单卡 smoke test：train_batch_size=256，ppo_mini_batch_size=64，actor / critic 的 ppo_micro_batch_size_per_gpu=1，kl_coef=0.001，训练 3 个 epoch，并在训练前先跑一次验证。它不是上面示例命令的完整替代，而是用相同 GSM8K 数据和 Qwen2.5-0.5B-Instruct 做的一次快速可行性检查。\nFig. 12. PPO smoke test 的 TensorBoard 曲线汇总：上排是验证分数、critic reward 和 critic score，下排是 actor KL、PPO clip fraction 和梯度范数。\n这组曲线可以放在一起看：val-core/open.../mean@1 从接近 0 上升到约 0.53，说明经过 PPO 后，模型在 GSM8K 验证集上的 pass@1 类指标有了明显提升；critic/rewards/mean 和 critic/score/mean 基本同步从 0 附近涨到 0.62 左右，说明 reward/score 信号和最终验证指标方向一致，不是只在训练奖励上虚高。\nActor 侧的三个指标主要看训练是否稳定。actor/ppo_kl 保持在 $10^{-4}$ 量级，和 kl_coef=0.001 对应，说明策略没有明显偏离 reference；actor/pg_clipfrac 最终约 0.004，表示被 PPO clip 截断的 token 比例很低，策略更新没有频繁撞到 clip 边界；actor/grad_norm 大致在 1.9 到 2.1 之间波动，没有持续爆炸。整体看，这次 A01 单卡实验更像是一个稳定收敛的 sanity check：reward、score 和验证指标都在升，KL 和 clipfrac 仍然很小。\n参考文献 [1] Long Ouyang et al. Training language models to follow instructions with human feedback, 2022.\n[2] John Schulman et al. Proximal Policy Optimization Algorithms, 2017.\n[3] Paul F. Christiano et al. Deep reinforcement learning from human preferences, 2017.\n[4] Rafael Rafailov et al. Direct Preference Optimization: Your Language Model is Secretly a Reward Model, 2023.\n[5] Zhihong Shao et al. DeepSeekMath: Pushing the Limits of Mathematical Reasoning in Open Language Models, 2024.\n[6] Chujie Zheng et al. Group Sequence Policy Optimization, 2025.\n[7] Samyam Rajbhandari et al. ZeRO: Memory Optimizations Toward Training Trillion Parameter Models, 2020.\n","permalink":"https://dengkoicat.github.io/posts/2026-07-06-llm-aligned/","summary":"\u003cp\u003e这篇博客想梳理大语言模型对齐阶段最常见的几条路线：\u003cstrong\u003ePPO、DPO、GRPO 和 GSPO\u003c/strong\u003e。\u003c/p\u003e\n\u003cp\u003e它们经常一起出现，但并不在同一个层级上。更准确地说，SFT 是让模型学会“按指令回答”的第一步；RLHF / DPO / GRPO / GSPO 则是在此基础上，让模型的输出更符合人类偏好、任务奖励或可验证标准。\u003c/p\u003e","title":"Reinforcement Learning for LLMs"},{"content":"策略梯度 回顾一下，使用策略网络 $\\pi(a|s; \\boldsymbol{\\theta})$ 来控制 Agent。\n状态价值函数 $$ \\begin{aligned} V_\\pi(s) \u0026= \\mathbb{E}_{A \\sim \\pi}\\big[Q_\\pi(s, A)\\big] \\\\ \u0026= \\sum_a \\pi(a|s; \\boldsymbol{\\theta}) \\cdot Q_\\pi(s, a) \\end{aligned} $$策略梯度 $$ \\frac{\\partial V_\\pi(s)}{\\partial \\boldsymbol{\\theta}} = \\mathbb{E}_{A \\sim \\pi}\\left[ \\frac{\\partial \\ln \\pi(A | s; \\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\cdot \\underline{Q_\\pi(s, A)} \\right] $$Baseline 在策略梯度强化学习中，baseline $b(s)$ 是一个仅由当前状态 $s$ 决定、和采样动作 $A$ 无关的标量值。Baseline 的核心就是降低梯度方差，避免参数更新抖动，加快训练收敛。baseline 有以下性质：\n仅依赖状态，与动作无关 $\\mathbb{E}_{A \\sim \\pi}\\left[ b \\cdot \\frac{\\partial \\ln \\pi(A \\mid s;\\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\right] = 0$ $$ \\begin{aligned} \\mathbb{E}_{A \\sim \\pi}\\left[ b \\cdot \\frac{\\partial \\ln \\pi(A \\mid s;\\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\right] \u0026= b \\cdot \\mathbb{E}_{A \\sim \\pi}\\left[ \\frac{\\partial \\ln \\pi(A \\mid s;\\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\right] \\\\ \u0026= b \\cdot \\sum_a \\pi(a \\mid s;\\boldsymbol{\\theta}) \\cdot \\left[ \\frac{1}{\\pi(a \\mid s;\\boldsymbol{\\theta})} \\cdot \\frac{\\partial \\pi(a \\mid s;\\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\right] \\\\ \u0026= b \\cdot \\sum_a \\frac{\\partial \\pi(a \\mid s;\\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\\\ \u0026= b \\cdot \\frac{\\partial \\sum_a \\pi(a \\mid s;\\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\\\ \u0026= b \\cdot \\frac{\\partial 1}{\\partial \\boldsymbol{\\theta}} \\\\ \u0026= 0 \\end{aligned} $$可以看出 baseline 具有无偏性，不改变梯度真实期望，原始策略梯度与引入 baseline 的梯度，\n$$ \\begin{aligned} \\frac{\\partial V_\\pi(s)}{\\partial \\boldsymbol{\\theta}} \u0026= \\mathbb{E}_{A \\sim \\pi}\\left[ \\frac{\\partial \\ln \\pi(A \\mid s; \\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\cdot Q_\\pi(s,A) \\right] - \\mathbb{E}_{A \\sim \\pi}\\left[ \\frac{\\partial \\ln \\pi(A \\mid s; \\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\cdot b \\right] \\\\ \u0026= \\mathbb{E}_{A \\sim \\pi}\\left[ \\frac{\\partial \\ln \\pi(A \\mid s; \\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\cdot \\big(Q_\\pi(s,A) - b\\big) \\right] \\end{aligned} $$ Theorem. 如果 $b$ 不依赖 $A_t$, 策略梯度可以写成: $$ \u003e\\frac{\\partial V_\\pi(s_t)}{\\partial \\boldsymbol{\\theta}} = \\mathbb{E}_{A_t \\sim \\pi}\\left[ \\frac{\\partial \\ln \\pi(A_t \\mid s_t; \\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\cdot \\big(Q_\\pi(s_t, A_t) - b\\big) \\right].$$ Monte Carlo Approximation 策略梯度期望公式 $$ \\frac{\\partial V_\\pi(s_t)}{\\partial \\boldsymbol{\\theta}} = \\mathbb{E}_{A_t \\sim \\pi}\\left[ \\frac{\\partial \\ln \\pi(A_t \\mid s_t; \\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\cdot \\big(Q_\\pi(s_t, A_t) - b\\big) \\right] $$ 从策略分布 $\\pi(\\cdot \\mid s_t; \\boldsymbol{\\theta})$ 中随机采样动作 $a_t$，计算样本梯度 $\\boldsymbol{g}(a_t)$。 $\\boldsymbol{g}(a_t)$ 是策略梯度的无偏估计： $$ \\mathbb{E}_{A_t \\sim \\pi}\\big[\\boldsymbol{g}(A_t)\\big] = \\frac{\\partial V_\\pi(s_t)}{\\partial \\boldsymbol{\\theta}} $$ 随机策略梯度公式（蒙特卡洛近似）\n$$ \\boldsymbol{g}(a_t) = \\frac{\\partial \\ln \\pi(a_t \\mid s_t; \\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\cdot \\big(Q_\\pi(s_t, a_t) - b\\big) $$ 虽然 $b$ 不影响 $\\mathbb{E}_{A_t \\sim \\pi}\\big[\\boldsymbol{g}(A_t)\\big]$ 但显然影响 $\\boldsymbol{g}(a_t)$ 当 $b$ 越接近 $Q_\\pi(s_t, A_t)$，$\\boldsymbol{g}(a_t)$ 方差越小，收敛越快，baseline有以下常见选择 $b=0$ $b=V_\\pi(s_t)$，事实上 $V_\\pi(s_t)$ 就很接近 $Q_\\pi(s_t, A_t)$，因为$V_\\pi(s_t) = \\mathbb{E}_{A_t}\\big[Q_\\pi(s_t, A_t)\\big]$ 随机策略梯度上升 $$ \\boldsymbol{\\theta} \\leftarrow \\boldsymbol{\\theta} + \\beta \\cdot \\boldsymbol{g}(a_t) $$REINFORCE with baseline 随机策略梯度， $$ \\mathbf{g}(a_t) = \\frac{\\partial \\ln \\pi(a_t|s_t; \\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\cdot \\big(Q_\\pi(s_t,a_t) - V_\\pi(s_t)\\big) $$ 这个时候，梯度还有两个随机量 $Q_\\pi(s_t,a_t)$ 和 $ V_\\pi(s_t)$\n对于 $Q_\\pi(s_t,a_t)$\n回顾定义：$Q_\\pi(s_t,a_t) = \\mathbb{E}\\big[U_t \\mid s_t,a_t\\big]$\n蒙特卡洛近似估计 $Q_\\pi(s_t,a_t) \\approx u_t$（REINFORCE 算法）：\n采集轨迹：$s_t,a_t,r_t,s_{t+1},a_{t+1},r_{t+1},\\dots,s_n,a_n,r_n$ 计算回报：$u_t = \\sum_{i=t}^n \\gamma^{i-t} \\cdot r_i$ $u_t$ 是 $Q_\\pi(s_t,a_t)$ 的无偏估计 对于 $V_\\pi(s_t)$\n用价值网络 $v(s; \\mathbf{w})$ 近似状态价值函数 $V(s; \\theta)$ 最终近似梯度， $$\\frac{\\partial V_\\pi(s_t)}{\\partial \\boldsymbol{\\theta}} \\approx \\mathbf{g}(a_t) \\approx \\frac{\\partial \\ln \\pi(a_t|s_t; \\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\cdot \\big(u_t - v(s_t; \\mathbf{w})\\big) $$到达最终近似梯度，用了三次近似方式\n使用单个样本 $a_t$ 近似数学期望（蒙特卡洛近似） 使用轨迹折扣回报 $u_t$ 近似状态动作价值 $Q_\\pi(s_t,a_t)$（蒙特卡洛近似） 使用价值网络 $v(s;\\mathbf{w})$ 近似状态价值函数 $V_\\pi(s)$ $$ \\begin{aligned} \\frac{\\partial V_\\pi(s_t)}{\\partial \\boldsymbol{\\theta}} \u0026= \\mathbb{E}_{A_t \\sim \\pi}\\left[ \\frac{\\partial \\ln \\pi(A_t \\mid s_t; \\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\cdot \\big(Q_\\pi(s_t,A_t) - V_\\pi(s_t)\\big) \\right] \\\\ \u0026\\Downarrow \\\\ \\mathbf{g}(a_t) \u0026= \\frac{\\partial \\ln \\pi(a_t \\mid s_t;\\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\cdot \\big(Q_\\pi(s_t,a_t) - V_\\pi(s_t)\\big) \\\\ \u0026\\Downarrow \\\\ \\mathbf{g}(a_t) \u0026\\approx \\frac{\\partial \\ln \\pi(a_t \\mid s_t;\\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\cdot \\big(u_t - v(s_t; \\mathbf{w})\\big) \\end{aligned} $$有了上面的推导，紧接着就来搭建神经网络：\n策略网络，用来控制 Agent。 价值网络，起辅助作用，作为 baseline 帮助训练策略网络。用策略网络 $\\pi({a\\mid s;\\mathbf{\\theta}})$ 近似策略函数 $\\pi(a\\mid s)$。 用价值网络 $v(s;\\mathbf{w})$ 近似 $V_{\\pi}(s)$。 由于 $\\pi({a\\mid s;\\mathbf{\\theta}})$ 和 $v(s;\\mathbf{w})$ 的输入都是状态 $s$，而且都会用到 Conv ，就可共享 Conv 层，用相同的 Conv 层提取参数。 Fig. 1. 参数共享。 (Image source: Shusen Wang YouTube, 2020)\n近似策略梯度的参数更新 $$ \\frac{\\partial V_\\pi(s_t)}{\\partial \\boldsymbol{\\theta}} \\approx \\frac{\\partial \\ln \\pi(a_t|s_t;\\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\cdot \\big(u_t - v(s_t; \\mathbf{w})\\big) $$ 通过策略梯度上升更新策略网络： $$ \\boldsymbol{\\theta} \\leftarrow \\boldsymbol{\\theta} + \\beta \\cdot \\frac{\\partial \\ln \\pi(a_t \\mid s_t;\\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\cdot \\underbrace{\\big(u_t - v(s_t; \\mathbf{w})\\big)}_{=-\\delta_t} $$ $$ \\boldsymbol{\\theta} \\leftarrow \\boldsymbol{\\theta} - \\beta \\cdot \\delta_t \\cdot \\frac{\\partial \\ln \\pi(a_t \\mid s_t;\\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}}. $$价值网络的更新 回顾：价值网络 $v(s_t; \\mathbf{w})$ 是状态价值 $V_\\pi(s_t) = \\mathbb{E}[U_t \\mid s_t]$ 的近似拟合 预测误差： $$ \\delta_t = v(s_t; \\mathbf{w}) - u_t $$ 损失梯度（取均方误差损失 $\\frac{1}{2}\\delta_t^2$）： $$ \\frac{\\partial \\left(\\delta_t^2 / 2\\right)}{\\partial \\mathbf{w}} = \\delta_t \\cdot \\frac{\\partial v(s_t;\\mathbf{w})}{\\partial \\mathbf{w}} $$ 梯度下降更新价值网络参数： $$ \\mathbf{w} \\leftarrow \\mathbf{w} - \\alpha \\cdot \\delta_t \\cdot \\frac{\\partial v(s_t;\\mathbf{w})}{\\partial \\mathbf{w}} $$ Advantage Actor-Critic (A2C) 这一节将 baseline 与 Actor-Critic 结合。\n策略网络（Actor 行动器）：$\\pi(a \\mid s; \\boldsymbol{\\theta})$\n是策略函数 $\\pi(a \\mid s)$ 的近似拟合网络 负责智能体的动作决策与行为控制 价值网络（Critic 评价器）：$\\boldsymbol{v}(s; \\mathbf{w})$\n之前我们用 $Q_\\pi(s,a)$ 作为价值函数，依赖于 $s$ 和 $a$。这里用的是状态价值 $V$ 作为价值函数，不依赖动作 $a$，更易于训练\n是状态价值函数 $V_\\pi(s)$ 的近似拟合网络 负责评估当前状态 $s$ 的优劣程度 A2C 的训练过程 采集状态转移样本：$(s_t, a_t, r_t, s_{t+1})$ TD目标值： $$ y_t = r_t + \\gamma \\cdot v(s_{t+1}; \\mathbf{w}) $$ TD时序差分误差： $$ \\delta_t = v(s_t; \\mathbf{w}) - y_t $$ 更新策略网络（Actor）： $$ \\boldsymbol{\\theta} \\leftarrow \\boldsymbol{\\theta} - \\beta \\cdot \\delta_t \\cdot \\frac{\\partial \\ln \\pi(a_t \\mid s_t; \\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} $$ 更新价值网络（Critic）： $$ \\mathbf{w} \\leftarrow \\mathbf{w} - \\alpha \\cdot \\delta_t \\cdot \\frac{\\partial v(s_t;\\mathbf{w})}{\\partial \\mathbf{w}} $$ REINFORCE versus A2C 前面可以看到，REINFORCE with baseline 和 A2C 的形式非常相似。两者都使用策略梯度更新 Actor，并且都引入一个状态价值函数 $v(s;\\mathbf{w})$ 作为 baseline 来降低方差。核心区别在于：REINFORCE 使用完整轨迹的 Monte Carlo return，而 A2C 使用一步 TD target。\n对于 REINFORCE with baseline，优势函数的估计为：\n$$ \\hat{A}_t = u_t - v(s_t;\\mathbf{w}) $$其中\n$$ u_t = \\sum_{i=t}^{n} \\gamma^{i-t} r_i $$是从时刻 $t$ 开始直到 episode 结束的折扣回报。因此 REINFORCE 需要等一整条轨迹采样完成之后，才能计算 $u_t$ 并更新参数。\nActor 的更新为：\n$$ \\boldsymbol{\\theta} \\leftarrow \\boldsymbol{\\theta} + \\beta \\cdot \\frac{\\partial \\ln \\pi(a_t \\mid s_t;\\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\cdot \\big(u_t - v(s_t;\\mathbf{w})\\big) $$如果记\n$$ \\delta_t = v(s_t;\\mathbf{w}) - u_t $$则也可以写成：\n$$ \\boldsymbol{\\theta} \\leftarrow \\boldsymbol{\\theta}-\\beta\\cdot\\delta_t\\cdot\\frac{\\partial \\ln \\pi(a_t \\mid s_t;\\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} $$而在 A2C 中，不再等待完整 episode 结束，而是使用一步 TD target：\n$$ y_t = r_t + \\gamma v(s_{t+1};\\mathbf{w}) $$对应的优势函数估计为：\n$$ \\hat{A}_t = y_t - v(s_t;\\mathbf{w}) = r_t + \\gamma v(s_{t+1};\\mathbf{w}) - v(s_t;\\mathbf{w}) $$如果仍然记\n$$ \\delta_t = v(s_t;\\mathbf{w}) - y_t $$则 Actor 的更新为：\n$$ \\boldsymbol{\\theta}\\leftarrow \\boldsymbol{\\theta}-\\beta\\cdot\\delta_t\\cdot\\frac{\\partial \\ln \\pi(a_t \\mid s_t;\\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} $$可以看到，REINFORCE 和 A2C 的 Actor 更新形式几乎一样，区别只在于 $\\delta_t$ 的定义不同：\n$$ \\delta_t^{\\text{REINFORCE}}=v(s_t;\\mathbf{w}) - u_t $$$$ \\delta_t^{\\text{A2C}}=v(s_t;\\mathbf{w}) -\\big(r_t + \\gamma v(s_{t+1};\\mathbf{w})\\big) $$也就是说：\nREINFORCE 用完整回报 $u_t$ 近似 $Q_\\pi(s_t,a_t)$。 A2C 用 TD target $r_t + \\gamma v(s_{t+1};\\mathbf{w})$ 近似 $Q_\\pi(s_t,a_t)$。 REINFORCE 是 Monte Carlo 方法。 A2C 是 Temporal Difference 方法。 REINFORCE 不引入 bootstrap，因此估计偏差较小，但方差较大。 A2C 引入 bootstrap，因此估计有一定偏差，但方差更小，训练更稳定，样本效率更高。 二者的对比如下：\n方法 $Q_\\pi(s_t,a_t)$ 的近似 是否需要完整轨迹 是否 bootstrap 方差 偏差 更新频率 REINFORCE with baseline $u_t = \\sum_{i=t}^{n}\\gamma^{i-t}r_i$ 需要 否 高 低 episode 结束后 A2C $r_t + \\gamma v(s_{t+1};\\mathbf{w})$ 不需要 是 低 较高 每一步或每 $n$ 步 直观地说，REINFORCE 会等游戏结束后再总结“这一步到底好不好”；A2C 则在每走一步后就用 Critic 估计未来价值，并立即给 Actor 一个更及时的反馈。因此 A2C 通常比 REINFORCE 收敛更快，也更适合较长 episode 或连续控制任务。\n","permalink":"https://dengkoicat.github.io/posts/2026-7-05-policy-gradient-with-baseline/","summary":"\u003ch2 id=\"策略梯度\"\u003e策略梯度\u003c/h2\u003e\n\u003cp\u003e回顾一下，使用策略网络 $\\pi(a|s; \\boldsymbol{\\theta})$ 来控制 Agent。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e状态价值函数\u003c/strong\u003e\n\u003c/p\u003e\n$$\n\\begin{aligned}\nV_\\pi(s) \u0026= \\mathbb{E}_{A \\sim \\pi}\\big[Q_\\pi(s, A)\\big] \\\\\n\u0026= \\sum_a \\pi(a|s; \\boldsymbol{\\theta}) \\cdot Q_\\pi(s, a)\n\\end{aligned}\n$$\u003cp\u003e\u003cstrong\u003e策略梯度\u003c/strong\u003e\n\u003c/p\u003e\n$$\n\\frac{\\partial V_\\pi(s)}{\\partial \\boldsymbol{\\theta}}\n= \\mathbb{E}_{A \\sim \\pi}\\left[\n\\frac{\\partial \\ln \\pi(A | s; \\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}}\n\\cdot \\underline{Q_\\pi(s, A)}\n\\right]\n$$\u003ch2 id=\"baseline\"\u003eBaseline\u003c/h2\u003e\n\u003cp\u003e在策略梯度强化学习中，baseline $b(s)$ 是一个仅由当前状态 $s$ 决定、和采样动作 $A$ 无关的标量值。Baseline 的核心就是降低梯度方差，避免参数更新抖动，加快训练收敛。baseline 有以下性质：\u003c/p\u003e","title":"Policy Gradient with Baseline"},{"content":"基本概论 符号 含义 state $s$ 状态 Action $a$ 动作 Policy $\\pi$ 决策函数，根据状态做出决策到控制 Agent 的动作（马里奥上下左右） reward $R$ 奖励，最影响强化学习的结果 state transition 状态转移 $\\text{old-state} \\rightarrow \\text{new-state}$,一般是随机的，随机性从环境来 Return $U(t)$ 回报，把t时刻以后的奖励加起来 $U_t = R_t+R_{t+1}+R_{t+2}+\\dots$ Discounted Return $U(t)$ 带有衰减 $U_t=R_t+\\gamma R_{t+1} + \\gamma ^2 R_{t+2} + \\gamma ^3 R_{t+3}+\\dots$ Action-value Function $Q_\\pi(s,a)$ 动作价值函数，评判动作好坏 $Q_\\pi(s_t,a_t) = \\mathbb{E}[U_t \\mid S_t = s_t,A_t = a_t]$ State-value Function $V_\\pi(s)$ 状态价值函数，来评价策略函数的好坏 $V_\\pi(s_t) = \\mathbb{E}_A[Q_\\pi(s_t,A)] = \\sum_{a} \\pi(a \\mid s_t) \\cdot Q_\\pi(s_t, a)$ 决策函数 Policy function $\\pi: (s, a) \\in [0, 1]$,就是一个 PDF :\n$$\\pi(a \\mid s) = \\mathbb{P}(A = a \\mid S = s).$$强化学习就是学习这个 Policy function $\\pi$, 来指导 Agent 观测 $\\mathcal{s}$ 来做出动作 $\\mathcal{a}$。这个 $\\pi$ 使得Agent 动作有随机性， 这样才不会被“对手”算出来下一步动作, 所以 $\\pi$ 最好是一个PDF。\nFig. 1. Policy函数举例，马里奥可以上左右移动。 (Image source: Shusen Wang YouTube, 2019)\nAgent 与环境交互 马里奥就是 Agent，环境就是游戏界面，马里奥根据环境来做决策，然后到下一个状态。吃到金币给奖励，赢了游戏给大奖励，与之对应，输了游戏给大惩罚。\nFig. 2. Agent和环境交互例子。 (Image source: Shusen Wang YouTube, 2019)\nRL 中的随机性 动作随机性：给定状态 $s$ ，Agent的状态可能是随机的，比如\n$\\pi(\\text{\"left\"} \\mid s) = 0.2$, $\\pi(\\text{\"right\"} \\mid s) = 0.1$, $\\pi(\\text{\"up\"} \\mid s) = 0.7$. 状态随机性：给定状态 $S = s,A = a$ ，$S\\rightarrow S'$ 环境转移是随机的，系统会随机抽样到下一个状态\n$S' = P(\\cdot \\mid s,a)$ RL 过程 游戏界面 (state $s_1$) 根据 $\\pi$ 做出 (action $a_1$) 得到一个新的游戏界面 (state $s_2$) 和 reward $r_1$ 根据 $\\pi$ 做出 (action $a_2$) \u0026hellip;. Fig. 3. RL 轨迹链路示意。 (Image source: Shusen Wang YouTube, 2019)\n(state,action,reward)轨迹： $$s_1,a_1,r_1,s_2,a_2,r_2,\\dots,s_T,a_T,r_T$$Return \u0026amp; Reward Return 回报，把t时刻以后的奖励加起来:\n$U_t = R_t+R_{t+1}+R_{t+2}+\\dots$ eg, $R_t$ 和 $R_{t+1}$ 同样重要吗？\n下面哪个你最想要？ 立刻给你 80 一年后给你 100 很明显，肯定是现在 80 最好，一年后的 100 块不如现在的 80。所有 $R_{t+1}$ 不如当前的 $R{t}$ 重要 ，一般要给 $R_{t+1}$ 打个折扣，比如八折。\nDiscounted Return 折扣回报率。\n$\\gamma \\in [0,1]$，折扣率 (hyper-parameter) $U_t=R_t+\\gamma R_{t+1} + \\gamma ^2 R_{t+2} + \\gamma ^3 R_{t+3}+\\dots$ 在 $t$ 步，回报 $U_t$ 是随机的。\n随机性来源两方面： action 是随机的，$\\mathbb{P}(A = a \\mid S = s) = \\pi(a \\mid s)$ state 是随机的，$\\mathbb{P}(S' = s' \\mid S = s,A = a ) = \\pi(s' \\mid s,a)$ 对于 $i \\ge t$，reward $R_i$ 取决于 $S_i$ 和 $A_i$。 因此，给定 $s_t$，回报率 $U_t$ 依赖于这两个随机变量 $A_t,A_{t+1},A_{t+2},\\dots$ and $S_{t+1},S_{t+2},\\dots$ $$A,S \\rightarrow R \\rightarrow U$$ 价值函数 折扣回报 $U_t = R_t+\\gamma R_{t+1} + \\gamma ^2 R_{t+2} + \\gamma ^3 R_{t+3}+\\dots$，是一个随机变量，在 $t$ 时刻并不知道具体是什么，那如何评估呢？可以对 $U_t$ 求期望，这样就可以消除随机变量。\n$Q_\\pi(s,a)$，动作价值函数 $$Q_\\pi(s_t,a_t) = \\mathbb{E}[U_t \\mid S_t = s_t,A_t = a_t]$$ 动作价值函数 $Q_\\pi(s,a)$ 的直观意义就是，用 Policy 函数 $\\pi$ 在 $s_t$ 做出动作 $a_t$ 是好还是坏。 已知 Policy 函数 $\\pi$，$Q_\\pi(s,a)$ 就会给当前状态下，所有动作 $a$ 打分，这样就可以知道动作的好坏。\n$Q^\\star(s_t,a_t)$，最大化动作价值函数 $$Q^\\star(s_t, a_t) = \\max_\\pi Q_\\pi(s_t, a_t).$$ $Q^\\star$ 直接根据 $s_t$ 对 $a_t$ 做评价，比如玩超级玛丽的时候，马里奥当前向上走“好不好”、“胜算有多大”。有了 $Q^\\star$ 之后，Agent就可以根据 $Q^\\star$ 对动作的评价来做决策，比如 $Q^\\star$ 认为往上跳分数最高，Agent就会往上跳。\n假如我们在 $Q_\\pi(s_t,a_t)$ 不对 $\\pi$ 求期望，而是对 $A$ 求期望呢？对 $A$ 求期望就可以把 $A$ 消除掉，是不是就可以得到环境的评估函数？\n$V(s)$，状态价值函数 $$V_\\pi(s_t) = \\mathbb{E}_A[Q_\\pi(s_t,A)] = \\sum_{a} \\pi(a \\mid s_t) \\cdot Q_\\pi(s_t, a)$$ $V_\\pi(s)_t$ 的直观意义就是能够得到局势信息，根据游戏界面，我们就可以知道当前局势是不是“有利”的，是快赢了还是快输了，用于评估状态。\n当然我们还可以在 $V_\\pi(s)_t$ 对 $s$ 求期望得到 $\\mathbb{E}_S[V\\pi{S}]$，可以评估 $\\pi$ 的好坏。\nHow does AI control the agent? 假设我们有一个好的 Policy 函数 $\\pi(a \\mid s)$。\n观测 State $s_t$， 随机采样得到动作：$a_t \\sim \\pi(a \\mid s_t)$ 假设我们知道最优动作价值函数 $Q^\\star(s_t, a_t)$\n观测 State $s_t$， 根据 $Q^\\star(s_t, a_t)$ 选择最好的 $a_t$：$a_t = \\text{argmax}_{a}Q^\\star(s_t, a_t)$ 所有 RL 的目的就是学习这两个函数，有了这两个函数之一，我们就可以让 Agent 对 $s_t$ 来做出决策 $a_t$。\n价值学习,Value-Based RL Deep Q-Network 强化学习的目标：赢下游戏（最大化 reward）。\n如果我们知道 $Q^\\star(s, a)$， 很明显最好的 action 就是 $a\\star = \\text{argmax}_{a}Q^\\star(s, a)$，\n这样我们就可以利用 $Q^\\star(s, a)$ 来做决策，虽然在某一时刻不一定 是最好的，但也代表了一个趋势。它不一定追求“即时奖励最大”，而是追求“长远累积回报最大”。\n但是问题是，我们不知道 $Q^\\star(s, a)$ 是什么。 谁也不是先知，很难预测股票市场的 $Q^\\star(s, a)$，但是对于超级玛丽，Agent 玩了几百万场次后也和先知没有区别了。用自己学习到的经验近似先知（$Q^\\star(s, a)$）。\nDQN 就是用神经网络来近似 $Q^\\star(s, a)$, $$Q(s, a;w) \\approx Q^\\star(s, a)$$ Fig. 4. DQN的输入在不同任务下可能不同，对于超级玛丽来说，游戏画面就是输入，用卷积层将输入变成向量，全连接层映射到输出向量（score action）。 (Image source: Shusen Wang YouTube, 2019)\nDQN的输入在不同任务下可能不同，对于超级玛丽来说，游戏画面就是输入，输出就是移动的打分。\nFig. 5. 将 DQN 应用在游戏里。 (Image source: Shusen Wang YouTube, 2019)\n将 DQN 应用在游戏里 观测到游戏画面内容 $s_t$ 根据 $a_t = argmax_aQ(s_t, a;w)$ 做出这一步的决策 得到奖励 $r_t$，状态转移 $s_{t+1} \\sim p(\\cdot \\mid s_t, a_t)$ \u0026hellip; Temporal Difference (TD) Learning 驾车从成都到西安需要的时间\n模型可能估计要 10 个小时，这个值可能不是准确的，甚至完全偏移。不难想到如果非常多的人用了这个模型，用数据去更新模型，从而让模型更准确。\n问题就是 如何更新模型\n出发前让模型做一个预测： $q = Q(w)$，e.g., $q=10$。 实际上我从成都到西安，驾车只需要两个小时，这个两小时就是真实值 $y$，$y=2$。 $\\mathcal{loss}= L = \\frac{1}{2}(q-y)^2$，记录损失 让模型对 $L$ 在 $w$ 求偏导 $\\frac{\\partial L}{\\partial \\mathbf{w}} = \\frac{\\partial q}{\\partial \\mathbf{w}} \\cdot \\frac{\\partial L}{\\partial q} = (q - y) \\cdot \\frac{\\partial Q(\\mathbf{w})}{\\partial \\mathbf{w}}$ 用梯度下降更新模型参数 $\\mathbf{w}_{t+1} = \\mathbf{w}_t - \\alpha \\cdot \\left. \\frac{\\partial L}{\\partial \\mathbf{w}} \\right|_{\\mathbf{w}=\\mathbf{w}_t}$ 这样的话问题又开了，我们必须完成整个旅途才能更新模型。能不能在中途或者出发前更新呢？\n能不能在成都-\u0026gt;西安中途去更新模型呢，比如到达汉中的时候就去更新模型，有的兄弟有的，TD 算法就是来解决这个问题的。\n模型预测 $q=10$，但实际上 6个小时(actual) 就到汉中了 模型这个时候更新权重，并且预测 汉中-\u0026gt;西安 2个小时(estimate) 更新估计值 $6 + 2 = 8$，这个 8小时 就是 TD Target 预测值 8个小时比 10个小时 更可靠，因为有了真实观测值介入 所以，TD 更新权重的方法是，\nTD Target $y = 8$ $L=\\frac{1}{2}(Q(W)-y)^2$，$Q(W)-y$ 称为 TD error $\\delta$ $\\frac{\\partial L}{\\partial \\mathbf{w}} = \\frac{\\partial q}{\\partial \\mathbf{w}} \\cdot \\frac{\\partial L}{\\partial q} = (10-8) \\cdot \\frac{\\partial Q(\\mathbf{w})}{\\partial \\mathbf{w}}$ 用梯度下降更新模型参数 $\\mathbf{w}_{t+1} = \\mathbf{w}_t - \\alpha \\cdot \\left. \\frac{\\partial L}{\\partial \\mathbf{w}} \\right|_{\\mathbf{w}=\\mathbf{w}_t}$ TD Learning for DQN 在 成都-\u0026gt;西安，我们有这个表达式： $$T_{CD-\u003eXI'AN} \\approx T_{CD-\u003eHZ} + T_{HZ-\u003eXI'AN}$$ 其中，$T_{CD-\u003eXI'AN}$ 模型总预测，$T_{CD-\u003eHZ}$ 实际时间，$T_{HZ-\u003eXI'AN}$ 模型估计。\n在深度RL中： $$Q(s_t,a_t;\\mathbf{w})\\approx r_t+\\gamma \\cdot Q(s_{t+1},a_{t+1};\\mathbf{w})$$ 其中，$r_t$ t时刻真实的奖励，相当于 $T_{CD-\u003eHZ}$ 实际时间，$\\gamma \\cdot Q(s_{t+1},a_{t+1};\\mathbf{w})$ 是 DQN 在$t+1$时刻做的估计。 为什么 RL 有这种公式呢？\n回顾一下折扣回报， $$ \\begin{aligned} U_t \u0026= R_t + \\gamma R_{t+1} + \\gamma ^2 R_{t+2} + \\gamma ^3 R_{t+3} + \\dots \\\\ \u0026= R_t + \\gamma \\cdot(R_{t+1} + \\gamma R_{t+2} + \\gamma ^2 R_{t+3} + \\dots) \\\\ \u0026= R_t + \\gamma \\cdot U_{t+1} \\end{aligned} $$将 TD 应用到 DQN:\nDQN 的输出，$Q(s_t,a_t;\\mathbf{w})$，是 $\\mathbb{E}[U_t]$\nDQN 的输出，$Q(s_{t+1},a_{t+1};\\mathbf{w})$，是 $\\mathbb{E}[U_{t+1} ]$\n由 $U_t = R_t + \\gamma \\cdot U_{t+1}$ 得到，$Q(s_t,a_t;\\mathbf{w}) \\approx \\mathbb{E}[R_t + \\gamma\\cdot Q(s_{t+1},a_{t+1};\\mathbf{w})]$\n有了预测值 $Q(s_t,a_t;\\mathbf{w_t})$\nTD Target: $$ \\begin{aligned} y_t \u0026= r_t + \\gamma \\cdot Q(s_{t+1}, a_{t+1}; \\mathbf{w}_t) \\\\ \u0026= r_t + \\gamma \\cdot \\max_{a} Q(s_{t+1}, a; \\mathbf{w}_t). \\end{aligned} $$ Loss: $L_t = \\frac{1}{2} [Q(s_t, a_t; \\mathbf{w}) - y_t]^2$\nGradient descent: $\\mathbf{w}_{t+1} = \\mathbf{w}_t - \\alpha \\cdot \\left. \\frac{\\partial L_t}{\\partial \\mathbf{w}} \\right|_{\\mathbf{w}=\\mathbf{w}_t}$\n策略学习,Policy-Based RL 回顾一下，前面提到 Policy function $\\pi (s \\mid a) \\in [0, 1]$,就是一个 PDF 。 输入是 $s$ ，输出是所有动作的概率，如 $$ \\begin{aligned} \\pi(\\text{left} \\mid s) \u0026= 0.2, \\\\ \\pi(\\text{right} \\mid s) \u0026= 0.1, \\\\ \\pi(\\text{up} \\mid s) \u0026= 0.7. \\end{aligned} $$ 有了这些概率值，Agent 就会做一次随机抽样来做出动作 $a$ 。\n对于超级玛丽这种问题，状态 $s$ 有无数个，我们无法像棋类游戏或者其他简单游戏一样列举出每种情况，因此我们也需要一个参数来近似学习 Policy Function。\nPolicy network，策略网络: 用神经网络来近似 $\\pi(a \\mid s)$\n用 Policy network $\\pi(a\\mid s;\\mathbf{\\theta})$ 来近似 $\\pi(a\\mid s)$ $\\sum_{a \\in \\mathcal{A}} \\pi(a|s; \\boldsymbol{\\theta}) = 1.$ Fig. 6. 策略网络。 (Image source: Shusen Wang YouTube, 2019)\n开始之前先回顾一下，折扣回报，\n随机性来自于未来所有的 $A_t,A_{t+1},A_{t+2}$ 和 $S_{t+1},S{t+2}$ $$U_t = R_t+\\gamma R_{t+1} + \\gamma ^2 R_{t+2} + \\gamma ^3 R_{t+3}+\\dots$$ 动作价值函数， $$Q_\\pi(s_t,a_t) = \\mathbb{E}[U_t \\mid S_t = s_t,A_t = a_t]$$状态价值函数，\n在 $Q_\\pi(s_t,a_t)$ 基础上，把 $A$ 消去，只和 $\\pi,s_t$ 有关系 $$V_\\pi(s_t) = \\mathbb{E}_A[Q_\\pi(s_t,A)] = \\sum_{a} \\pi(a \\mid s_t) \\cdot Q_\\pi(s_t, a)$$ State-Value Function $V(s)$ Approximation 可以像近似 $Q\\star$ 那样用神经网络来近似 $\\pi(a \\mid s_t;\\mathbf{\\theta}) \\sim \\pi(a\\mid s_t)$,\n从而可以通过 $\\pi(a \\mid s_t;\\mathbf{\\theta})$ 估计 $V_\\pi(s_t)$： $$V(s_t;\\mathbf{\\theta})=\\sum_{a} \\pi(a \\mid s_t;\\mathbf{\\theta}) \\cdot Q_\\pi(s_t,a)$$ 可以用状态价值函数来评价状态下策略网络 $\\pi$ 的好坏，网络越好 $V(s_t;\\mathbf{\\theta})$ 越大\n那如何让策略网络 $\\pi$ 越来越好呢？当然，可以通过调整 $\\mathbf{\\theta}$ 来实现，基于这个想法，目标函数就可以定义为： $$J(\\mathbf{\\theta}) = \\mathbb{E}_S[V(S;\\mathbf{\\theta})]$$参数更新方式也类似：\n观测状态 $s$ 更新权重 $\\mathbf{\\theta} \\leftarrow \\mathbf{\\theta} + \\beta \\cdot \\frac{\\partial V(s;\\mathbf{\\theta})}{\\partial \\mathbf{\\theta}}$ Policy Gradient 策略梯度 近似状态价值函数：\n$V(s_t;\\mathbf{\\theta})=\\sum_{a} \\pi(a \\mid s_t;\\mathbf{\\theta}) \\cdot Q_\\pi(s_t,a)$ 现在来推导一下 Policy Gradient 策略梯度 $V(s;\\mathbf{\\theta})$:\n注意为了方便理解，这里假设 $Q_{\\pi}(s,a)$ 与 $\\mathbf{\\theta}$ 无关，但事实上 $\\pi(a\\mid s;\\mathbf{\\theta})$ 依赖于 $\\mathbf{\\theta}$ $$ \\begin{aligned} \\frac{\\partial V(s; \\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \u0026= \\frac{\\partial \\sum_{a} \\pi(a|s; \\boldsymbol{\\theta}) \\cdot Q_{\\pi}(s,a)}{\\partial \\boldsymbol{\\theta}} \\\\ \u0026= \\sum_{a} \\frac{\\partial \\pi(a|s; \\boldsymbol{\\theta}) \\cdot Q_{\\pi}(s,a)}{\\partial \\boldsymbol{\\theta}} \\\\ \u0026= \\sum_{a} \\frac{\\partial \\pi(a|s; \\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\cdot Q_{\\pi}(s,a) \\\\ \u0026= \\sum_{a} \\pi(a \\mid s; \\boldsymbol{\\theta}) \\cdot \\frac{\\partial \\log \\pi(a \\mid s; \\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\cdot Q_{\\pi}(s, a)\\\\ \u0026= \\mathbb{E}_{A} \\left[ \\frac{\\partial \\log \\pi(A \\mid s; \\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\cdot Q_{\\pi}(s, A) \\right] \\end{aligned} $$ 至此，我们已经推导出了策略梯度 Policy Gradient，两种形式：\n$\\frac{\\partial V(s; \\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} = \\sum_{a} \\frac{\\partial \\pi(a|s; \\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\cdot Q_{\\pi}(s,a)$ $\\frac{\\partial V(s; \\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} = \\mathbb{E}_{A\\sim \\pi({\\cdot \\mid s;\\mathbf{\\theta}})} \\left[ \\frac{\\partial \\log \\pi(A \\mid s; \\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\cdot Q_{\\pi}(s, A) \\right]$ 如果动作 $a$ 是离散的，动作空间 $\\mathcal{A} = {\"\\text{left}\",\"\\text{right}\",\"\\text{up}\"}$，用 1 形式：\n为 $a \\in \\mathcal{A}$ 计算 $\\mathbf{f}(a, \\boldsymbol{\\theta}) = \\frac{\\partial \\pi(a \\mid s; \\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\cdot Q_{\\pi}(s, a)$ Policy Gradient：$\\frac{\\partial V(s; \\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} = \\mathbf{f}(\\text{``left''}, \\boldsymbol{\\theta}) + \\mathbf{f}(\\text{``right''}, \\boldsymbol{\\theta}) + \\mathbf{f}(\\text{``up''}, \\boldsymbol{\\theta})$ 如果动作 $a$ 是连续的，动作空间 $\\mathcal{A} = [0,1]$，用 2 形式，但是 $\\pi(\\cdot \\mid s; \\boldsymbol{\\theta})$ 是个神经网络，无法对其进行定积分，所以用蒙特卡洛近似 （Monte Carlo）：\n根据 PDF $\\pi(\\cdot|s;\\boldsymbol{\\theta})$ 随机抽样一个动作 $\\hat{a}$ 计算 $\\mathbf{g}(\\hat{a}, \\mathbf{\\theta}) = \\boxed{\\frac{\\partial \\log \\pi(\\hat{a}|s;\\mathbf{\\theta})}{\\partial \\mathbf{\\theta}} \\cdot Q_{\\pi}(s, \\hat{a})}$ $\\mathbb{E}_{A}\\left[\\mathbf{g}(A,\\mathbf{\\theta})\\right] = \\frac{\\partial V(s;\\mathbf{\\theta})}{\\partial \\mathbf{\\theta}}$ 由于是根据 PDF 随机抽样的，所以 $\\mathbf{g}(\\hat{a}, \\mathbf{\\theta})$ 是 $\\frac{\\partial V(s; \\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}}$ 的无偏估计 蒙特卡洛近似：由于 $\\mathbf{g}(\\hat{a}, \\mathbf{\\theta})$ 是 $\\frac{\\partial V(s; \\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}}$ 的无偏估计，所有可以用 $\\mathbf{g}(\\hat{a}, \\mathbf{\\theta})$ 近似策略梯度 $\\frac{\\partial V(s; \\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}}$ （随机样本近似期望） 更新策略网络 观察状态 $s_t$ 根据 $\\pi\\left(\\cdot \\mid s_t ; \\boldsymbol{\\theta}_t\\right)$ 随机采样动作 $a_t$ 计算 $q_t \\approx Q_\\pi(s_t, a_t)$ REINFORE 完整走完一局交互，生成完整轨迹： $s_1,a_1,r_1,\\; s_2,a_2,r_2,\\;\\dots\\;, s_T,a_T,r_T$ 对每个时刻t，计算折扣回报： $u_t = \\sum_{k=t}^{T} \\gamma^{k-t} r_k$ 由于动作价值函数满足 $Q_\\pi(s_t,a_t) = \\mathbb{E}\\left[U_t\\right]$，因此可以用单条轨迹的实际回报 $u_t=$ 近似 $Q_\\pi(s_t,a_t)$。 于是令近似 $Q$ 值：$q_t = u_t$ actor-critic:神经网络近似，有一个神经网络近似 $\\pi$ 现在用另一个近似 $Q$ 对策略网络求导： $\\mathbf{d}_{\\boldsymbol{\\theta},t} = \\left. \\frac{\\partial \\log \\pi(a_t|s_t,\\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}} \\right|_{\\boldsymbol{\\theta}=\\boldsymbol{\\theta}_t}$ 近似策略梯度 $g(a_t, \\boldsymbol{\\theta}_t) = q_t \\cdot \\mathbf{d}_{\\boldsymbol{\\theta},t}$ 更新策略网络 $\\boldsymbol{\\theta}_{t+1} = \\boldsymbol{\\theta}_t + \\beta \\cdot \\mathbf{g}(a_t, \\boldsymbol{\\theta}_t)$ Actor-Critic Method actor 是策略网络用来控制 Agent 运动，critic 是策略网络用来给动作打分。 现在要做的就是怎么设计这两个神经网络，然后通过环境奖励更新学习网络。 前面两节分别讲了价值学习和策略学习，Actor-Critic 就是把这两种方法结合起来。 Fig. 7. Actor-Critic 方法，价值学习 \u0026amp; 策略学习 (Image source: Shusen Wang YouTube, 2019)\nPolicy network (actor): 用神经网络 $\\pi(a|s; \\boldsymbol{\\theta})$ 近似 $\\pi(a|s)$\n输入：状态 $s$，例如超级马里奥游戏的画面截图。 输出：所有动作上的概率分布。 设 $\\mathcal{A}$ 为全部动作的集合，例：$\\mathcal{A} = \\{\\text{``左移''}, \\text{``右移''}, \\text{``上跳''}\\}$。 $\\sum_{a \\in \\mathcal{A}} \\pi(a|s, \\boldsymbol{\\theta}) = 1$。 Fig. 8. 策略网络。 (Image source: Shusen Wang YouTube, 2019)\nValue network (critic): 用神经网络 $q(s, a; \\mathbf{w})$ 近似 $Q_\\pi(s, a)$\n输入：状态 $s$ 与动作 $a$。 输出：近似动作价值（标量）。 Fig. 9. 价值网络。 (Image source: Shusen Wang YouTube, 2019)\nState-value function $V_\\pi(s) = \\sum_a \\boxed{\\pi(a|s)} \\cdot \\boxed{Q_\\pi(s,a)} \\approx \\sum_a \\boxed{\\pi(a|s; \\boldsymbol{\\theta})} \\cdot \\boxed{q(s,a; \\mathbf{w})}$ ，训练要更新两个参数 $\\boldsymbol{\\theta}$ 和 $\\mathbf{w}$，而且参数的目的是不同的\n更新策略网络 $\\pi(a|s; \\boldsymbol{\\theta})$ 来增大状态价值函数 $V_\\pi(s;\\mathbf{\\theta},\\mathbf{w})$，同一 $s$ 下 $V$ 越大策略网络越好。且是由价值网络（critic）监督的，cirtic 会给 $\\pi(a|s; \\boldsymbol{\\theta})$ “打分”（监督信号）。 更新价值网络 $q(s,a; \\mathbf{w})$ 是为了让打分更准确，更好估计未来奖励总和。 $V_\\pi(s;\\mathbf{\\theta},\\mathbf{w}) = \\sum_a \\boxed{\\pi(a|s; \\boldsymbol{\\theta})} \\cdot \\boxed{q(s,a; \\mathbf{w})}$，更新 $\\mathbf{\\theta}$ 和 $\\mathbf{w}$\n观察到状态 $s$ 从 $\\pi(\\cdot|s_t; \\boldsymbol{\\theta_t})$ 随机抽样 $a_t$ Agent 执行动作 $a_t$ ，环境更新状态 $s_{t+1}$ 和奖励 $r_t$ 从 $\\pi(\\cdot|s_{t+1}; \\boldsymbol{\\theta_t})$ 随机抽样 $\\tilde{a}_{t+1}$ （不执行，只为了估计下一个 $q$） 用 TD 更新价值网络 $\\mathbf{w}$ 用价值网络估计 $q_t = q(s_t,a_t;\\mathbf{w}_t)$ 和 $q_{t+1} = q(s_{t+1},\\tilde{a}_{t+1};\\mathbf{w}_t)$ TD Target：$y_t = r_t + \\gamma \\cdot q_{t+1}$ TD 误差：$\\delta_t = q_t - y_t$ 价值网络梯度：$\\mathbf{d}_{w,t} = \\left.\\frac{\\partial q(s_t,a_t;\\mathbf{w})}{\\partial \\mathbf{w}}\\right|_{\\mathbf{w}=\\mathbf{w}_t}$ 梯度下降更新：$\\mathbf{w}_{t+1} = \\mathbf{w}_t - \\alpha \\cdot \\delta_t \\cdot \\mathbf{d}_{w,t}$ 用策略梯度算法更新策略网络 $\\boldsymbol{\\theta}$ 策略梯度导数项：$\\mathbf{d}_{\\theta,t} = \\left.\\frac{\\partial \\log \\pi(a_t|s_t,\\boldsymbol{\\theta})}{\\partial \\boldsymbol{\\theta}}\\right|_{\\boldsymbol{\\theta}=\\boldsymbol{\\theta}_t}$ 价值函数梯度的蒙特卡洛近似：$\\frac{\\partial V(s_t;\\boldsymbol{\\theta}_t,\\mathbf{w}_t)}{\\partial \\boldsymbol{\\theta}}= \\mathbb{E}_{A}\\big[\\delta_t \\cdot \\mathbf{d}_{\\theta,t}\\big]$ 梯度上升更新（Baseline）：$\\boldsymbol{\\theta}_{t+1} = \\boldsymbol{\\theta}_t + \\beta \\cdot \\delta_t \\cdot \\mathbf{d}_{\\theta,t}$ Fig. 10. actor-critic的互相作用。 (Image source: Shusen Wang YouTube, 2019)\n","permalink":"https://dengkoicat.github.io/posts/2026-7-02-reinforcement-learning/","summary":"\u003ch2 id=\"基本概论\"\u003e基本概论\u003c/h2\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth style=\"text-align: left\"\u003e符号\u003c/th\u003e\n          \u003cth style=\"text-align: left\"\u003e含义\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003estate\u003c/strong\u003e $s$\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e状态\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003eAction\u003c/strong\u003e $a$\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e动作\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003ePolicy\u003c/strong\u003e $\\pi$\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e决策函数，根据状态做出决策到控制 Agent 的动作（马里奥上下左右）\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003ereward\u003c/strong\u003e $R$\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e奖励，最影响强化学习的结果\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003estate transition\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e状态转移 $\\text{old-state} \\rightarrow \\text{new-state}$,一般是随机的，随机性从环境来\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003eReturn\u003c/strong\u003e $U(t)$\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e回报，把t时刻以后的奖励加起来 $U_t = R_t+R_{t+1}+R_{t+2}+\\dots$\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003eDiscounted Return\u003c/strong\u003e $U(t)$\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e带有衰减 $U_t=R_t+\\gamma R_{t+1} + \\gamma ^2 R_{t+2} + \\gamma ^3 R_{t+3}+\\dots$\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003eAction-value Function\u003c/strong\u003e $Q_\\pi(s,a)$\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e动作价值函数，评判动作好坏 $Q_\\pi(s_t,a_t) = \\mathbb{E}[U_t \\mid S_t = s_t,A_t = a_t]$\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003eState-value Function\u003c/strong\u003e $V_\\pi(s)$\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e状态价值函数，来评价策略函数的好坏 $V_\\pi(s_t) = \\mathbb{E}_A[Q_\\pi(s_t,A)] = \\sum_{a} \\pi(a \\mid s_t) \\cdot Q_\\pi(s_t, a)$\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003ch3 id=\"决策函数\"\u003e决策函数\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003ePolicy function\u003c/strong\u003e $\\pi: (s, a) \\in [0, 1]$,就是一个 \u003cem\u003ePDF\u003c/em\u003e :\u003c/p\u003e","title":"Reinforement Learning"},{"content":"参数高效微调（Parameter-Efficient Fine-Tuning, PEFT）讨论的是一个很现实的问题：当基础模型已经足够大、训练足够贵时，如何只引入很小的任务特定状态，就让同一个模型适配不同任务、领域、用户偏好或输出协议。\n如果只从 LoRA 开始看，PEFT 很容易被误解成一种低秩矩阵技巧。但 LoRA 只是 PEFT 谱系中的一类。更完整的视角是看“适配信号被放在哪里”：\n输入侧适配：改变上下文或输入 embedding，例如 hard prompting、soft prompt、prompt tuning、P-tuning、prefix tuning、P-tuning v2。 层内模块适配：冻结主干，在 Transformer 层内部插入小模块，例如 adapter。 权重更新适配：不直接更新原权重，而是约束权重更新的形式，例如 LoRA；QLoRA 进一步把冻结基座量化。 这三条线的共同点是：基础模型参数 $\\theta_0$ 共享，任务特定参数或状态 $\\phi_t$ 很小。差异在于 $\\phi_t$ 作用于输入空间、激活空间，还是权重更新空间。\nPrompt tuning 论文中的 Figure 1：随着 T5 模型规模增大，prompt tuning 与 full model tuning 的差距逐渐缩小。Image source: Lester et al., 2021.\n问题设定 预训练模型可以写成：\n$$ y = f_{\\theta_0}(x) $$其中 $\\theta_0$ 是预训练得到的全部参数。全参数微调会为任务 $t$ 学到一套完整的新参数：\n$$ \\theta_t = \\theta_0 + \\Delta\\theta_t $$这给模型最大的自由度，但也带来训练显存、优化器状态、模型副本存储、多任务服务切换和灾难性遗忘等成本。PEFT 把问题改写为：\n$$ y = f(x; \\theta_0, \\phi_t), \\quad |\\phi_t| \\ll |\\theta_0| $$训练时冻结 $\\theta_0$，只优化或切换小型任务状态 $\\phi_t$。如果是 hard prompting，$\\phi_t$ 可以只是一段离散文本；如果是 P-tuning、adapter 或 LoRA，$\\phi_t$ 是少量可训练张量。\n符号 含义 $\\theta_0$ 预训练基础模型参数，PEFT 中通常冻结 $\\phi_t$ 任务 $t$ 的适配参数或适配状态 $x$ 输入 token 序列 $E(x)$ 输入 embedding 序列 $d$ Transformer 隐藏维度 $L$ Transformer 层数 $m$ prompt / prefix 的虚拟 token 数 $r$ bottleneck 或 LoRA rank，通常 $r \\ll d$ $W_0$ 冻结的预训练权重矩阵 $\\Delta W$ 任务适配产生的权重更新 $A,B$ LoRA 的低秩分解矩阵 $\\alpha$ LoRA 分支缩放系数 从工程角度看，PEFT 至少有五个设计轴。\n设计轴 关键问题 典型答案 适配位置 改输入、改激活，还是改权重更新？ Prompt 系列、Adapter、LoRA 参数规模 每个任务要保存多少参数？ 0、$O(md)$、$O(Lmd)$、$O(Ldr)$、$O(dr)$ 推理开销 适配是否拉长序列或增加模块？ prompt 增加序列长度；adapter 增加 MLP；LoRA 合并后无额外分支 表达能力 适配信号能影响多深？ input-only、deep prompt、layer module、weight update 可部署性 是否能切换、融合、版本化？ prompt 最轻；adapter/LoRA 更像可管理的任务插件 PEFT 的本质不是“少训练一点参数”，而是给任务适配施加结构化约束。约束越强，训练和部署越便宜；但如果任务确实需要大幅改变模型行为，约束也可能变成瓶颈。\nPrompt 系列 输入侧适配把任务信息放进上下文或 embedding。它的优势是非侵入：模型主体不变，任务差异由输入端控制。它的发展路径大致是：\n$$ \\text{Hard prompt} \\rightarrow \\text{Soft prompt / Prompt tuning} \\rightarrow \\text{P-tuning} \\rightarrow \\text{Prefix tuning / P-tuning v2} $$这条线的核心问题一直没变：如何让模型在不改主干权重的情况下，稳定地理解任务条件。\nHard Prompt Hard prompt 是自然语言或结构化文本：\n$$ y = f_{\\theta_0}([\\text{prompt}; x]) $$它可以是任务说明、few-shot examples、输出格式约束、检索片段或工具调用上下文。优点是无需训练、可解释、迭代快；缺点是优化空间离散且巨大：\n$$ p^* = \\arg\\max_{p \\in \\mathcal{V}^m} J(f_{\\theta_0}([p;x])) $$其中 $\\mathcal{V}$ 是词表，$m$ 是 prompt 长度。离散 prompt 的小改动可能导致 embedding 空间的大跳变，因此性能对措辞、示例顺序和格式噪声敏感。对需要吸收大量监督数据的稳定任务，hard prompt 通常不够。\nPrompt Tuning Prompt tuning 把 prompt 从离散 token 变成连续向量。设输入 embedding 为：\n$$ E(x) = [e_1, e_2, \\ldots, e_n], \\quad e_i \\in \\mathbb{R}^{d} $$学习一个长度为 $m$ 的 soft prompt：\n$$ P = [p_1, p_2, \\ldots, p_m], \\quad p_i \\in \\mathbb{R}^{d} $$输入变成：\n$$ [P; E(x)] $$训练时只更新 $P$，参数量为：\n$$ |\\phi| = m \\cdot d $$如果 $m=20$、$d=4096$，一个任务只有 $81{,}920$ 个参数。Lester et al. (2021) 的重要观察是：模型规模越大，prompt tuning 越接近 full model tuning。这说明 soft prompt 的能力强依赖冻结模型本身是否足够强，能否把输入端的连续控制向量解释成任务行为。\nPrompt tuning 论文对不同 conditioning 方法的参数量比较。Prompt tuning 只学习输入端少量连续向量，任务特定参数很少。Image source: Lester et al., 2021.\nP-tuning P-tuning（Liu et al., 2021）和 prompt tuning 的共同点是学习连续 prompt；差异在于 P-tuning 不是简单地把每个 pseudo token 当作独立 embedding，而是引入一个轻量 prompt encoder 来生成连续提示向量。\n设 $[P_i]$ 是第 $i$ 个 pseudo prompt token，P-tuning 用一个映射函数 $f$ 得到真正送入模型的连续向量：\n$$ h_i = f([P_i]) $$模板可以写成：\n$$ T = \\{[P_{0:i}], x, [P_{i+1:j}], y, [P_{j+1:k}]\\} $$送入预训练模型的不是离散 prompt embedding，而是：\n$$ \\{h_0,\\ldots,h_i, E(x), h_{i+1},\\ldots,h_j, E(y), h_{j+1},\\ldots,h_k\\} $$Prompt encoder 的作用是给 prompt 向量加入结构化依赖。原论文实验过 LSTM、MLP 和 identity mapping；直觉是多个 prompt token 不应该完全独立，它们共同形成一段“连续模板”。训练结束后，推理通常只需要保留 encoder 输出的 prompt embedding，prompt encoder 本身可以被丢弃。\nP-tuning 原论文中的框架图：相比离散 prompt search 只能接收离散奖励，P-tuning 通过 prompt encoder 生成可微优化的连续 prompt。Image source: Liu et al., 2021.\nP-tuning 的目标不是替代所有微调，而是解决早期离散 prompt 的两个问题：一是人工模板不稳定，二是离散搜索无法顺畅利用梯度。它尤其强调 GPT 风格模型也能通过 cloze-style prompt 做 NLU 任务，因此论文标题是 “GPT Understands, Too”。\nPrefix Tuning Prompt tuning 和 P-tuning 都主要在输入 embedding 层插入连续提示。Prefix tuning（Li \u0026amp; Liang, 2021）把可训练 prefix 注入到每层 attention 的 key/value 中。\n在第 $l$ 层，原本有：\n$$ K_l = X_l W^K_l, \\quad V_l = X_l W^V_l $$Prefix tuning 学习额外的 prefix key/value：\n$$ \\tilde{K}_l = [P^K_l; K_l], \\quad \\tilde{V}_l = [P^V_l; V_l] $$后续 token 可以像 attending to context token 一样 attending to prefix。与输入端 soft prompt 相比，prefix 更深：每层 attention 都能直接看到任务特定 memory。\nPrefix-tuning 论文中的示意图：prefix 被当作虚拟 token，后续 token 可以对其进行 attention。Image source: Li \u0026amp; Liang, 2021.\nP-tuning v2 P-tuning v2（Liu et al., 2021）可以看作把 P-tuning / prompt tuning 从 input-only 推进到 deep prompt tuning。它的动机很直接：只在输入层加连续 prompt 有两个限制。\n可训练参数受输入长度限制，任务容量小。 输入层 prompt 要经过很多 Transformer 层才影响输出，对中小模型和复杂序列标注任务不够直接。 P-tuning v2 在每一层加入独立的连续 prompt，形式上接近为每层添加 prefix token。这样每层都有任务特定信号：\n$$ \\{P_1, P_2, \\ldots, P_L\\} $$参数量从非常小的 input prompt 扩展到约 $0.1\\%-3\\%$ 的 backbone 参数量，仍然属于 PEFT，但任务容量明显更大。论文还强调两个实践变化：一是 reparameterization（如 MLP）是否有用依赖任务；二是在完整监督 NLU 场景中，未必需要 verbalizer + LM head，可以回到普通分类头，这让它更容易处理序列标注、抽取式 QA 等任务。\nP-tuning v2 原论文中的对比：早期 prompt tuning/P-tuning 只在输入层放连续 prompt，P-tuning v2 在多层加入 deep prompts，提高跨模型规模和跨任务的通用性。Image source: Liu et al., 2021.\nPrompt 系列方法的演进可以总结为：hard prompt 解决“无需训练”，soft prompt 解决“可微优化”，P-tuning 解决“连续 prompt 的结构化生成”，prefix tuning / P-tuning v2 解决“输入层控制太浅”的问题。\nAdapter Adapter-tuning 的思路是冻结 Transformer 主干，在每层插入小型可训练模块。原模型负责通用表示，adapter 负责把表示推向任务需要的方向。\nHoulsby et al. (2019) 的 adapter 是 bottleneck 结构：\n$$ \\text{Adapter}(h)=W_{\\text{up}}\\ \\sigma(W_{\\text{down}}h) $$其中：\n$$ W_{\\text{down}} \\in \\mathbb{R}^{r \\times d}, \\quad W_{\\text{up}} \\in \\mathbb{R}^{d \\times r}, \\quad r \\ll d $$adapter 输出通过残差方式加回隐藏状态：\n$$ h' = h + \\text{Adapter}(h) $$参数量约为：\n$$ |\\phi_{\\text{adapter}}| \\approx 2dr $$如果每层放两个 adapter，总参数量约为 $4Ldr$。当 $d=4096$、$r=64$ 时，每个 adapter 约 $0.52M$ 参数，相对整层 attention 和 MLP 权重仍很小。\nAdapter 原论文中的插入位置：在 Transformer 子层之后加入小型 adapter，并通过残差连接回主路径。Image source: Houlsby et al., 2019.\nBottleneck 的意义不只是省参数。它还给任务适配加了低维通道约束：任务不能任意重写整个隐藏空间，只能先压到 $r$ 维，再升回 $d$ 维。这与 LoRA 的低秩思想有相似性，只是 adapter 作用在激活上，LoRA 作用在权重更新上。\nAdapter 模块本身的 bottleneck 结构：先降维，再非线性变换，最后升维回隐藏维度。Image source: Houlsby et al., 2019.\nAdapter 的工程优点是模块边界清晰。训练时冻结 $\\theta_0$，只训练每个任务的 adapter；部署时加载一个共享基础模型，按任务挂载不同 adapter。Houlsby et al. (2019) 在 GLUE 上报告，adapter 只增加每任务约 3.6% 参数，就能接近 full fine-tuning。\n它的代价是推理路径变长。每个 adapter 都是额外 MLP，会引入额外 kernel、激活和残差路径。在多任务隔离、模块管理、组合适配很重要时，adapter 很自然；如果最看重合并后无额外推理延迟，LoRA 更适合。\nLoRA 与 QLoRA LoRA（Low-Rank Adaptation）把适配位置从激活路径移到权重更新路径。它不训练原权重 $W_0$，而是假设微调更新 $\\Delta W$ 可以被低秩分解：\n$$ W = W_0 + \\Delta W, \\quad \\Delta W = BA $$其中：\n$$ A \\in \\mathbb{R}^{r \\times d_{\\text{in}}}, \\quad B \\in \\mathbb{R}^{d_{\\text{out}} \\times r}, \\quad r \\ll \\min(d_{\\text{in}}, d_{\\text{out}}) $$前向传播为：\n$$ y = W_0x + \\frac{\\alpha}{r}BAx $$训练时冻结 $W_0$，只训练 $A,B$。常见初始化是 $A$ 随机初始化、$B$ 初始化为零，因此训练开始时：\n$$ \\Delta W = BA = 0 $$模型初始行为与原模型一致，随后逐步学习任务更新。\nLoRA 原论文中的核心结构：冻结预训练权重 $W$，用低秩矩阵 $A,B$ 表示可训练更新。Image source: Hu et al., 2022.\n对一个 $d \\times d$ 线性层，全参数微调更新 $d^2$ 个参数；LoRA 只训练：\n$$ 2dr $$比例为：\n$$ \\frac{2dr}{d^2} = \\frac{2r}{d} $$如果 $d=4096$、$r=8$，比例约 $0.39\\%$。如果只对 attention 中的 $W_Q,W_V$ 或 $W_Q,W_K,W_V,W_O$ 加 LoRA，整体可训练参数占比更低。\nLoRA 的关键工程优势是可合并。训练完成后：\n$$ W' = W_0 + \\frac{\\alpha}{r}BA $$把 $\\Delta W$ 加回 $W_0$ 后，推理仍是一层普通线性变换，没有额外分支延迟。需要多任务动态切换时，也可以不合并，而是在服务端保留多个 LoRA adapter：\n$$ \\{\\phi_1, \\phi_2, \\ldots, \\phi_T\\} $$LoRA 常加在 attention projection 和 MLP projection 上。\n目标模块 作用 适配影响 $W_Q$ 产生 query 改变 token 关注什么 $W_K$ 产生 key 改变 token 如何被匹配 $W_V$ 产生 value 改变被聚合的信息内容 $W_O$ attention 输出投影 改变多头信息如何回到隐藏空间 MLP up/down/gate projection 前馈网络 改变逐 token 非线性变换 Embedding / LM head 输入输出词表映射 适合词表、风格或领域输出变化 早期 LoRA 常只加到 $W_Q,W_V$；现代 LLM 指令微调和领域适配经常覆盖 attention 与 MLP 的多个 projection。目标模块越多，表达能力越强，训练成本也越高。\nQLoRA 解决的是另一个瓶颈：LoRA 减少了可训练参数，但基础模型权重仍要加载到显存。一个 65B 模型以 16-bit 加载，仅权重就约需要：\n$$ 65B \\times 2\\text{ bytes} \\approx 130\\text{ GB} $$QLoRA 把冻结基础模型以 4-bit 存储，同时仍训练 LoRA：\n$$ y = \\text{Dequant}(W_0^{\\text{4-bit}})x + \\frac{\\alpha}{r}BAx $$基础权重只参与反量化计算，不被更新；梯度回传到 LoRA 参数。QLoRA 的关键机制包括 NF4、double quantization 和 paged optimizers。Dettmers et al. (2023) 报告 QLoRA 可以在单张 48GB GPU 上微调 65B 参数模型，同时保持接近 16-bit 微调的任务性能。\nQLoRA 原论文中的对比：full finetuning、LoRA 与 QLoRA 的权重、adapter、优化器状态和分页流。Image source: Dettmers et al., 2023.\nLoRA 与 QLoRA 应该放在同一个方法族里理解：LoRA 约束可训练更新，QLoRA 进一步压缩冻结基座。前者回答“训练哪些参数”，后者回答“冻结的大模型怎么放进显存”。\n对比与选型 方法 适配位置 可训练对象 参数规模 推理成本 典型优点 典型限制 Hard prompting 输入文本 无 0 增加上下文长度 无训练、可解释、迭代快 对措辞和格式敏感 Prompt tuning 输入 embedding soft prompt $O(md)$ 增加虚拟 token 参数极少、易切换 input-only，依赖模型规模 P-tuning 输入 embedding prompt encoder / continuous prompt $O(md)$ 加小 encoder 增加虚拟 token 可微优化，prompt token 有结构依赖 主要影响输入层 Prefix tuning 每层 attention prefix key/value $O(Lmd)$ attention 长度增加 每层都有控制信号 序列开销更明显 P-tuning v2 多层 deep prompt 每层连续 prompt 约 $0.1\\%-3\\%$ 增加多层 prompt 跨规模、跨 NLU 任务更通用 prompt 长度和任务细节敏感 Adapter-tuning 层内激活路径 bottleneck MLP $O(Ldr)$ 增加模块 模块化强，多任务隔离好 推理路径变长 LoRA 权重更新路径 低秩矩阵 $A,B$ $O(dr)$ 每目标矩阵 合并后无额外延迟 强基线，部署方便 rank 和目标模块需调 QLoRA 量化基座 + LoRA LoRA 参数 类似 LoRA 量化反量化开销 显存占用极低 对量化实现和硬件敏感 Full fine-tuning 全模型 全部参数 $O( \\theta )$ 无额外模块 一个实用选型顺序是：\n任务还在探索期：先用 hard prompt 和 few-shot prompt 明确任务协议。 只需要轻量行为控制：试 prompt tuning、P-tuning 或 prefix tuning。 需要在中小模型、复杂 NLU 或序列标注上接近全参微调：考虑 P-tuning v2 或 adapter。 需要通用强基线、训练后可合并、推理无额外延迟：优先 LoRA。 显存是核心瓶颈：用 QLoRA，而不是普通 LoRA。 数据量大、目标稳定、PEFT 已充分调参仍不足：再考虑 full fine-tuning 或继续预训练。 常见失效模式也很固定。\n把 PEFT 当作数据质量替代品。 PEFT 只能降低训练成本，不能修复脏数据、错误标签和混乱任务定义。LoRA 和 P-tuning 都会学习数据里的格式偏差。\n适配位置选错。 如果任务需要改变 MLP 表示，却只给 $W_Q,W_V$ 加 LoRA，模型会学得吃力；如果任务只是格式控制，却上来训练多层 adapter，可能过重。\nprompt 协议不一致。 训练 LoRA 或 P-tuning 时使用一种模板，推理时换模板，效果可能明显下降。参数少不代表对上下文协议不敏感。\n忽略推理开销。 adapter 参数少，但每层额外 MLP 会增加延迟；prefix / deep prompt 会拉长 attention；未合并 LoRA 也有额外分支。\n低估量化误差。 QLoRA 很强，但 4-bit 权重、计算 dtype、量化分组和硬件 kernel 都影响稳定性。小模型、数值敏感任务、特殊领域需要单独验证。\n只看单一 benchmark。 PEFT 可能在目标 benchmark 上很好，但在泛化、鲁棒性、拒答、安全偏好或长上下文任务上退化。尤其是 instruction tuning，需要同时评估帮助性、真实性、格式遵循和安全边界。\nPEFT 方法也可以组合。RAG + prompting 适合动态知识；Prompt + LoRA 让 prompt 控制运行时协议，LoRA 固化长期偏好；QLoRA + instruction tuning 适合低显存训练；多 LoRA 路由适合领域、用户或风格隔离。组合时要评估模块间是否争夺同一行为控制权，例如 prompt 要求简洁，而 LoRA 数据偏向长解释。\nPEFT 的核心价值，是把任务适配从“复制整个模型”变成“保存和切换小型任务状态”。Prompt 系列把状态放在输入和上下文里，adapter 把状态放在层内激活路径里，LoRA 把状态放在权重更新子空间里，QLoRA 则进一步让冻结基座更便宜。今天的 LLM 微调实践中，LoRA/QLoRA 往往是默认训练基线，prompting 是运行时控制层，adapter 和 deep prompt 在模块化或 NLU 任务中仍有清晰位置。\n参考文献 [1] Houlsby, Neil, et al. \u0026ldquo;Parameter-Efficient Transfer Learning for NLP.\u0026rdquo; ICML 2019.\n[2] Li, Xiang Lisa, and Percy Liang. \u0026ldquo;Prefix-Tuning: Optimizing Continuous Prompts for Generation.\u0026rdquo; ACL 2021.\n[3] Liu, Xiao, et al. \u0026ldquo;GPT Understands, Too.\u0026rdquo; arXiv preprint arXiv:2103.10385, 2021.\n[4] Lester, Brian, Rami Al-Rfou, and Noah Constant. \u0026ldquo;The Power of Scale for Parameter-Efficient Prompt Tuning.\u0026rdquo; EMNLP 2021.\n[5] Hu, Edward J., et al. \u0026ldquo;LoRA: Low-Rank Adaptation of Large Language Models.\u0026rdquo; ICLR 2022.\n[6] Liu, Xiao, et al. \u0026ldquo;P-Tuning v2: Prompt Tuning Can Be Comparable to Fine-tuning Universally Across Scales and Tasks.\u0026rdquo; ACL 2022.\n[7] He, Junxian, et al. \u0026ldquo;Towards a Unified View of Parameter-Efficient Transfer Learning.\u0026rdquo; ICLR 2022.\n[8] Dettmers, Tim, et al. \u0026ldquo;QLoRA: Efficient Finetuning of Quantized LLMs.\u0026rdquo; NeurIPS 2023.\n[9] Mangrulkar, Sourab, et al. \u0026ldquo;PEFT: State-of-the-art Parameter-Efficient Fine-Tuning methods.\u0026rdquo; Hugging Face, 2023.\n","permalink":"https://dengkoicat.github.io/posts/lora/","summary":"\u003cp\u003e参数高效微调（Parameter-Efficient Fine-Tuning, PEFT）讨论的是一个很现实的问题：\u003cstrong\u003e当基础模型已经足够大、训练足够贵时，如何只引入很小的任务特定状态，就让同一个模型适配不同任务、领域、用户偏好或输出协议\u003c/strong\u003e。\u003c/p\u003e","title":"PEFT: Parameter-Efficient Fine-Tuning"},{"content":"Transformer 的核心贡献是把序列建模从循环结构中解放出来：每个 token 不再只能沿着时间步逐个传递信息，而是可以通过 attention 直接和上下文中的其他 token 建立联系。今天的 GPT、LLaMA、Qwen、Mistral 等 Decoder-only LLM，本质上都是在反复堆叠 Transformer Decoder Block。\n本文从一次自回归语言模型的前向传播讲起：文本如何变成 token，token 如何进入 embedding，位置顺序如何通过 RoPE 注入 attention，multi-head attention 如何计算，FFN 为什么是逐 token 的非线性加工，最后模型如何得到下一个 token 的概率分布。\nFig. 1. Transformer 原论文中的 Encoder-Decoder 架构图。Decoder-only LLM 可以看作保留右侧 decoder 主干，并移除 cross-attention。 (Image source: Vaswani et al., 2017)\n符号 符号 含义 $N$ 输入序列长度，即 token 数 $V_{\\text{vocab}}$ 词表大小 $d_{\\text{model}}$ 模型隐藏维度 $n_{\\text{head}}$ attention head 数量 $d_k$ 每个 head 的维度，通常 $d_k=d_{\\text{model}}/n_{\\text{head}}$ $X$ token embedding 矩阵，$X\\in\\mathbb{R}^{N\\times d_{\\text{model}}}$ $Q,K,V$ Query、Key、Value 矩阵 $\\hat{Q},\\hat{K}$ 经过 RoPE 旋转后的 Query、Key $A$ attention weight 矩阵 $M$ causal mask 矩阵 Token Embedding 语言模型不能直接处理字符串。给定文本 $s$，tokenizer 会先把它变成 token id 序列：\n$$ s \\xrightarrow{\\text{tokenizer}} [t_1,t_2,\\ldots,t_N],\\quad t_i\\in\\{1,\\ldots,V_{\\text{vocab}}\\}. $$随后通过词嵌入矩阵查表：\n$$ W_{\\text{embed}}\\in\\mathbb{R}^{V_{\\text{vocab}}\\times d_{\\text{model}}}, \\quad X_i = W_{\\text{embed}}[t_i]. $$堆叠所有 token 后得到：\n$$ X = \\begin{bmatrix} X_1\\\\ X_2\\\\ \\vdots\\\\ X_N \\end{bmatrix} \\in \\mathbb{R}^{N\\times d_{\\text{model}}}. $$从矩阵乘法角度看，token id 也可以先写成 one-hot 向量 $e_{t_i}\\in\\mathbb{R}^{V_{\\text{vocab}}}$，再乘 embedding table：\n$$ X_i=e_{t_i}^T W_{\\text{embed}}. $$实际实现不会真的构造 one-hot，因为那会浪费内存；框架里的 embedding lookup 本质上就是按行索引。这个视角仍然有用：它说明 embedding layer 也是一个线性层，只是输入非常稀疏。\nTokenizer 的粒度会影响后续所有计算。字符级 tokenizer 词表小，但序列更长；词级 tokenizer 序列短，但词表巨大且难处理未登录词；现代 LLM 常用 BPE、SentencePiece 或类似 subword tokenizer，把常见词作为整体 token，把罕见词拆成子词。由于 attention 复杂度近似 $O(N^2)$，tokenizer 让同一段文本变成多少 token，会直接影响上下文成本。\nEmbedding 矩阵通常也是参数量大户之一。如果 $V_{\\text{vocab}}=50{,}000$，$d_{\\text{model}}=4096$，仅输入 embedding 就有：\n$$ 50{,}000\\times4096\\approx2.05\\times10^8 $$个参数。很多语言模型会把输入 embedding 和输出 LM head 权重绑定（weight tying），即：\n$$ W_{\\text{vocab}}=W_{\\text{embed}}^T. $$这样可以减少参数量，也让“读入 token 的语义空间”和“预测 token 的语义空间”共享同一套表示。\n此时 $X_i$ 只表达“第 $i$ 个 token 是什么”，还没有表达“它在序列中的位置”。如果把 token 顺序打乱，只看 embedding 本身，attention 并不会天然知道原始顺序。\n位置编码 原始 Transformer 使用正弦位置编码：\n$$ \\begin{aligned} PE_{(pos,2i)} \u0026= \\sin\\left(pos / 10000^{2i/d_{\\text{model}}}\\right),\\\\ PE_{(pos,2i+1)} \u0026= \\cos\\left(pos / 10000^{2i/d_{\\text{model}}}\\right). \\end{aligned} $$然后把位置向量直接加到 token embedding 上：\n$$ \\tilde{X}=X+PE. $$ Fig. 2. 正弦位置编码在不同维度和位置上的取值。横轴是 embedding 维度，纵轴是序列位置；不同频率的正弦/余弦波共同提供位置信息。\n这种做法简单直观：低维通道变化快，高维通道变化慢，不同频率共同编码位置。绝对位置编码的缺点也很明显：位置信息在输入端一次性注入，attention score 本身并不显式建模相对距离。\n正弦位置编码还有一个重要性质：相对位移可以由线性变换表达。对同一频率 $\\omega$，有：\n$$ \\begin{bmatrix}\\sin((pos+k)\\omega)\\\\ \\cos((pos+k)\\omega)\\end{bmatrix} = \\begin{bmatrix}\\cos(k\\omega)\u0026\\sin(k\\omega)\\\\ -\\sin(k\\omega)\u0026\\cos(k\\omega)\\end{bmatrix}\\begin{bmatrix}\\sin(pos\\omega)\\\\ \\cos(pos\\omega)\\end{bmatrix}. $$也就是说，位置 $pos+k$ 的编码可以由位置 $pos$ 的编码通过一个只依赖 $k$ 的旋转矩阵得到。这是原始 Transformer 选择正弦/余弦的数学动机之一：模型有机会从绝对位置编码中推断相对位移。\n现代 Decoder-only LLM 更常使用旋转位置编码（Rotary Position Embedding, RoPE）。RoPE 不把位置向量加到 $X$ 上，而是在计算 attention 之前旋转 $Q$ 和 $K$。\n对每个 head 内相邻两维组成的二维向量 $(x_{2i},x_{2i+1})$，位置 $m$ 对应的旋转为：\n$$ \\begin{bmatrix} x'_{2i}\\\\ x'_{2i+1} \\end{bmatrix} = \\begin{bmatrix} \\cos(m\\theta_i)\u0026-\\sin(m\\theta_i)\\\\ \\sin(m\\theta_i)\u0026\\cos(m\\theta_i) \\end{bmatrix} \\begin{bmatrix} x_{2i}\\\\ x_{2i+1} \\end{bmatrix}, \\quad \\theta_i = 10000^{-2i/d_k}. $$记二维旋转矩阵为 $R_\\theta$，则：\n$$ \\hat{Q}_m=R_{m\\theta}Q_m,\\quad \\hat{K}_n=R_{n\\theta}K_n. $$ Fig. 3. RoFormer 论文中的 RoPE 实现示意图：通过对 $q$ 和 $k$ 的相邻维度施加正弦/余弦旋转，将位置信息注入 attention。 (Image source: Su et al., 2021)\nRoPE 的关键性质来自旋转矩阵的组合：\n$$ R_a^T R_b = R_{b-a}. $$因此 Query-Key 点积可以展开为：\n$$ \\begin{aligned} \\hat{Q}_m^T\\hat{K}_n \u0026=(R_{m\\theta}Q_m)^T(R_{n\\theta}K_n)\\\\ \u0026=Q_m^T R_{m\\theta}^T R_{n\\theta}K_n\\\\ \u0026=Q_m^T R_{(n-m)\\theta}K_n. \\end{aligned} $$这说明 RoPE 分别用绝对位置 $m,n$ 旋转 $Q,K$，但 attention score 中出现的是相对位移 $n-m$。这也是 RoPE 适合自回归模型的原因：它把“内容相似度”和“相对距离”一起放进了点积结构。\nRoPE 和正弦位置编码的频率形式很像，但注入位置的地方不同。正弦位置编码修改的是输入表示：\n$$ X_i \\leftarrow X_i + PE_i. $$RoPE 修改的是 attention 匹配过程：\n$$ Q_i,K_j \\leftarrow R_iQ_i,R_jK_j. $$因此 RoPE 更直接地作用在 $QK^T$ 上。对 Decoder-only LM 来说，模型每一步都在问“当前位置应该看历史哪些位置”，把相对位移放进 attention score 通常更自然。\nScaled Dot-Product Attention Self-attention 的第一步是从输入表示中投影出 $Q,K,V$：\n$$ Q=XW_q,\\quad K=XW_k,\\quad V=XW_v. $$其中：\n$$ W_q,W_k,W_v\\in\\mathbb{R}^{d_{\\text{model}}\\times d_{\\text{model}}}. $$如果使用 RoPE，则 attention 实际使用的是旋转后的 $\\hat{Q},\\hat{K}$：\n$$ \\hat{Q}=\\text{RoPE}(Q),\\quad \\hat{K}=\\text{RoPE}(K). $$对于第 $i$ 个 query 和第 $j$ 个 key，未归一化的 attention score 为：\n$$ s_{ij} = \\frac{\\hat{Q}_i\\hat{K}_j^T}{\\sqrt{d_k}}. $$除以 $\\sqrt{d_k}$ 是为了控制点积方差。若 $q,k$ 的每一维近似独立、均值为 0、方差为 1，则：\n$$ \\text{Var}(q^Tk) =\\text{Var}\\left(\\sum_{\\ell=1}^{d_k}q_\\ell k_\\ell\\right) =d_k. $$随着 $d_k$ 增大，点积尺度会变大，softmax 更容易进入饱和区，梯度变小。缩放后：\n$$ \\text{Var}\\left(\\frac{q^Tk}{\\sqrt{d_k}}\\right)\\approx 1. $$这就是 Scaled Dot-Product Attention 中 scale 的由来。\n从矩阵形状看，单个 head 中：\n$$ Q,K,V\\in\\mathbb{R}^{N\\times d_k},\\quad QK^T\\in\\mathbb{R}^{N\\times N},\\quad A V\\in\\mathbb{R}^{N\\times d_k}. $$$N\\times N$ 的 attention matrix 是 Transformer 的关键能力来源，也是长上下文成本的来源。第 $i$ 行表示第 $i$ 个 token 对所有 key 位置的注意力分布；第 $j$ 列表示其他 token 对第 $j$ 个 token 的读取强度。\nFig. 4. Scaled Dot-Product Attention：先计算 $QK^T$，再 scale、mask、softmax，最后对 $V$ 加权求和。 (Image source: Vaswani et al., 2017)\nDecoder-only 语言模型还需要 causal mask，禁止当前位置看到未来 token。对长度为 3 的序列，score 矩阵在 mask 后变为：\n$$ S_{\\text{causal}} = \\begin{bmatrix} s_{00} \u0026 -\\infty \u0026 -\\infty\\\\ s_{10} \u0026 s_{11} \u0026 -\\infty\\\\ s_{20} \u0026 s_{21} \u0026 s_{22} \\end{bmatrix}. $$再对每一行做 softmax：\n$$ A_{ij} = \\frac{\\exp(s_{ij}+M_{ij})} {\\sum_{r=0}^{N-1}\\exp(s_{ir}+M_{ir})}. $$其中：\n$$ M_{ij} = \\begin{cases} 0, \u0026 j\\le i,\\\\ -\\infty, \u0026 j\u003ei. \\end{cases} $$最终输出为：\n$$ \\text{Attention}(Q,K,V) = \\text{softmax} \\left( \\frac{\\hat{Q}\\hat{K}^T}{\\sqrt{d_k}}+M \\right)V. $$直觉上，$Q$ 表示“我想找什么”，$K$ 表示“我能被什么匹配”，$V$ 表示“匹配成功后我贡献什么内容”。Attention weight $A_{ij}$ 越大，说明第 $i$ 个 token 越应该从第 $j$ 个 token 处读取信息。\n注意 softmax 是逐行做的。对于 causal LM，第 $i$ 行只有 $j\\le i$ 的位置有效，因此：\n$$ \\sum_{j=0}^{i}A_{ij}=1,\\quad A_{ij}=0\\text{ for }j\u003ei. $$这保证了训练时虽然可以并行计算整段序列的所有位置，但每个位置的预测仍然只依赖历史 token，不会泄漏答案。\nMulti-Head Attention 单个 attention head 只在一个子空间中建模 token 关系。如果只用一个 head，模型必须把所有关系都压进同一套 $Q,K,V$ 投影里：主谓关系、指代关系、局部修饰、长距离依赖、格式边界都会混在同一个 attention 分布中。Multi-Head Attention（MHA）的做法是把隐藏表示拆到多个子空间中，让每个 head 学习一套独立的匹配方式。\n设输入 $X\\in\\mathbb{R}^{N\\times d_{\\text{model}}}$，head 数为 $h=n_{\\text{head}}$，每个 head 维度为 $d_k=d_{\\text{model}}/h$。第 $r$ 个 head 的投影矩阵为：\n$$ W_q^{(r)},W_k^{(r)},W_v^{(r)}\\in\\mathbb{R}^{d_{\\text{model}}\\times d_k}. $$于是：\n$$ \\text{head}_h = \\text{Attention}(XW_q^{(h)},XW_k^{(h)},XW_v^{(h)}), $$每个 head 的输出形状都是 $\\mathbb{R}^{N\\times d_k}$。把 $h$ 个 head 拼接后，维度回到 $\\mathbb{R}^{N\\times d_{\\text{model}}}$：\n$$ \\text{MHA}(X) = \\text{Concat}(\\text{head}_1,\\ldots,\\text{head}_{n_{\\text{head}}})W_o. $$ Fig. 5. Multi-Head Attention：多个 head 并行计算 Scaled Dot-Product Attention，再 concat 后经过线性投影。 (Image source: Vaswani et al., 2017)\n如果 $d_{\\text{model}}=12288$、$n_{\\text{head}}=96$，则每个 head 的维度为：\n$$ d_k = \\frac{12288}{96}=128. $$这不是把 12288 维整体做一次 attention，而是让 96 个 head 分别在 128 维子空间中学习不同关系。有些 head 可能关注短距离修饰，有些可能关注长距离指代，有些可能关注格式、标点或结构边界。拼接之后的 $W_o$ 负责再次混合这些子空间，让不同 head 的信息发生交互。\nFig. 6. 原论文中 Multi-Head Attention 的另一种画法：$Q,K,V$ 先经过线性投影并 split 到多个 head，再并行做 attention。 (Image source: Vaswani et al., 2017)\nRoPE 通常在每个 head 内独立应用。也就是说，旋转发生在 $d_k$ 维子空间中，而不是先在完整 $d_{\\text{model}}$ 上统一旋转再切分。\nMHA、MQA 与 GQA 上面的标准 MHA 中，每个 query head 都有自己独立的 key head 和 value head。也就是说，query head 数、key head 数、value head 数相同：\n$$ n_q=n_k=n_v=h. $$这在训练时很自然，但在自回归推理时会带来显存带宽压力。生成第 $t$ 个 token 时，模型只产生一个新的 query，却必须从 KV cache 中读取历史所有 token、所有层、所有 KV head 的 $K,V$。如果每个 query head 都有独立的 $K,V$，那么 KV cache 的规模与 $h$ 成正比。\n忽略 batch 和层数，只看单层单请求，MHA 的 KV cache 大小近似为：\n$$ M_{\\text{KV}}^{\\text{MHA}} \\propto 2\\cdot N\\cdot h\\cdot d_k. $$这里的 $2$ 来自 Key 和 Value。Multi-Query Attention（MQA）把所有 query heads 共享同一组 $K,V$，即：\n$$ n_q=h,\\quad n_k=n_v=1. $$此时每个 head 仍然有自己的 $Q^{(r)}$，但所有 head 使用相同的 $K,V$：\n$$ \\text{head}_r=\\text{Attention}(XW_q^{(r)},XW_k,XW_v). $$MQA 的 KV cache 规模变为：\n$$ M_{\\text{KV}}^{\\text{MQA}} \\propto 2\\cdot N\\cdot 1\\cdot d_k. $$因此相比 MHA，MQA 在 decode 阶段读取 KV cache 的带宽大约可以降低 $h$ 倍。代价是所有 query heads 共享同一套 key/value 表示，表达能力可能下降，尤其是大模型或复杂任务中，不同 head 失去了各自独立的 memory view。\nGrouped-Query Attention（GQA）是 MHA 和 MQA 的折中。设 query head 数为 $h$，KV head 数为 $g$，满足：\n$$ 1","permalink":"https://dengkoicat.github.io/posts/2026-06-09-transformer/","summary":"\u003cp\u003eTransformer 的核心贡献是把序列建模从循环结构中解放出来：每个 token 不再只能沿着时间步逐个传递信息，而是可以通过 attention 直接和上下文中的其他 token 建立联系。今天的 GPT、LLaMA、Qwen、Mistral 等 Decoder-only LLM，本质上都是在反复堆叠 Transformer Decoder Block。\u003c/p\u003e","title":"Transformer"}]